diff --git a/pkg/connectors/keycloak_client.go b/pkg/connectors/keycloak_client.go index 825da66..4cb2eb6 100644 --- a/pkg/connectors/keycloak_client.go +++ b/pkg/connectors/keycloak_client.go @@ -79,12 +79,19 @@ func NewKeycloakClientWithContext(ctx context.Context, realmURL string, username return &kc, nil } +func (c *keycloakClient) context() context.Context { + if c.ctx != nil { + return c.ctx + } + return context.Background() +} + // ConnectAndGetToken implementation on keycloakClient structure func (c *keycloakClient) ConnectAndGetToken() (string, error) { rel := &url.URL{Path: "protocol/openid-connect/token"} u := c.BaseURL.ResolveReference(rel) - req, err := http.NewRequestWithContext(c.ctx, "POST", u.String(), strings.NewReader(url.Values{"grant_type": {"client_credentials"}}.Encode())) + req, err := http.NewRequestWithContext(c.context(), "POST", u.String(), strings.NewReader(url.Values{"grant_type": {"client_credentials"}}.Encode())) if err != nil { return "", err } @@ -132,7 +139,7 @@ func (c *keycloakClient) GetOIDCConfig() (*oauth2.Config, error) { u := c.BaseURL.ResolveReference(rel) // Create HTTP request - req, err := http.NewRequestWithContext(c.ctx, "GET", u.String(), nil) + req, err := http.NewRequestWithContext(c.context(), "GET", u.String(), nil) if err != nil { return nil, errors.Wrap(errors.KindGeneric, fmt.Errorf("creating Keycloak OIDC request: %w", err)) } @@ -186,7 +193,7 @@ func (c *keycloakClient) ConnectAndGetTokenAndRefreshToken(username, password st data.Set("password", password) data.Set("grant_type", "password") // Create HTTP request - req, err := http.NewRequestWithContext(c.ctx, "POST", u.String(), bytes.NewBufferString(data.Encode())) + req, err := http.NewRequestWithContext(c.context(), "POST", u.String(), bytes.NewBufferString(data.Encode())) if err != nil { return "", "", errors.Wrap(errors.KindGeneric, fmt.Errorf("creating Keycloak token request: %w", err)) } diff --git a/pkg/connectors/microcks_client.go b/pkg/connectors/microcks_client.go index 3e121ae..f59d828 100644 --- a/pkg/connectors/microcks_client.go +++ b/pkg/connectors/microcks_client.go @@ -285,12 +285,19 @@ func (c *microcksClient) HttpClient() *http.Client { return c.httpClient } +func (c *microcksClient) context() context.Context { + if c.ctx != nil { + return c.ctx + } + return context.Background() +} + func (c *microcksClient) GetKeycloakURL() (string, error) { // Ensure we have a correct URL for retrieving Keycloal configuration. rel := &url.URL{Path: "keycloak/config"} u := c.APIURL.ResolveReference(rel) - req, err := http.NewRequestWithContext(c.ctx, "GET", u.String(), nil) + req, err := http.NewRequestWithContext(c.context(), "GET", u.String(), nil) if err != nil { return "", errors.Wrap(errors.KindGeneric, fmt.Errorf("creating Keycloak config request: %w", err)) } @@ -391,7 +398,7 @@ func (c *microcksClient) redeemRefreshToken(auth config.Auth) (string, string, e if err != nil { return "", "", err } - kc, err := NewKeycloakClientWithContext(c.ctx, keyCloakUrl, "", "") + kc, err := NewKeycloakClientWithContext(c.context(), keyCloakUrl, "", "") if err != nil { return "", "", err } @@ -403,7 +410,7 @@ func (c *microcksClient) redeemRefreshToken(auth config.Auth) (string, string, e oauth2Conf.ClientSecret = auth.ClientSecret httpClient := c.httpClient - ctx := oidc.ClientContext(c.ctx, httpClient) + ctx := oidc.ClientContext(c.context(), httpClient) t := &oauth2.Token{ RefreshToken: c.RefreshToken, @@ -493,7 +500,7 @@ func (c *microcksClient) getJSON(path string, query url.Values, out any, dumpLab } u := c.APIURL.ResolveReference(rel) - req, err := http.NewRequestWithContext(c.ctx, "GET", u.String(), nil) + req, err := http.NewRequestWithContext(c.context(), "GET", u.String(), nil) if err != nil { return err } @@ -568,7 +575,7 @@ func (c *microcksClient) CreateTestResult(serviceID string, testEndpoint string, return "", errors.Wrap(errors.KindGeneric, fmt.Errorf("failed to marshal test request: %w", err)) } - req, err := http.NewRequestWithContext(c.ctx, "POST", u.String(), bytes.NewReader(input)) + req, err := http.NewRequestWithContext(c.context(), "POST", u.String(), bytes.NewReader(input)) if err != nil { return "", errors.Wrap(errors.KindGeneric, fmt.Errorf("creating test request: %w", err)) } @@ -673,7 +680,7 @@ func (c *microcksClient) UploadArtifact(specificationFilePath string, mainArtifa rel := &url.URL{Path: "artifact/upload"} u := c.APIURL.ResolveReference(rel) - req, err := http.NewRequestWithContext(c.ctx, "POST", u.String(), pr) + req, err := http.NewRequestWithContext(c.context(), "POST", u.String(), pr) if err != nil { return "", errors.Wrap(errors.KindGeneric, fmt.Errorf("creating artifact upload request: %w", err)) } @@ -741,7 +748,7 @@ func (c *microcksClient) DownloadArtifact(artifactURL string, mainArtifact bool, rel := &url.URL{Path: "artifact/download"} u := c.APIURL.ResolveReference(rel) - req, err := http.NewRequestWithContext(c.ctx, "POST", u.String(), body) + req, err := http.NewRequestWithContext(c.context(), "POST", u.String(), body) if err != nil { return "", errors.Wrap(errors.KindGeneric, fmt.Errorf("creating artifact download request: %w", err)) } diff --git a/pkg/connectors/microcks_client_test.go b/pkg/connectors/microcks_client_test.go index d1a2451..9b491f9 100644 --- a/pkg/connectors/microcks_client_test.go +++ b/pkg/connectors/microcks_client_test.go @@ -17,15 +17,21 @@ package connectors import ( + "context" + "encoding/base64" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "strings" "testing" + "time" + "github.com/microcks/microcks-cli/pkg/config" microckserrors "github.com/microcks/microcks-cli/pkg/errors" ) @@ -467,3 +473,165 @@ func TestGetFullTestResultClassifiesNotFound(t *testing.T) { t.Fatalf("KindOf = %v, want KindNotFound", got) } } + +func createDummyJWT(exp int64) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"exp":%d}`, exp))) + return header + "." + payload + "." +} + +func TestRefreshAuthToken_ValidTokenNoRefresh(t *testing.T) { + // A token with expiration 1 hour in the future + futureTime := time.Now().Add(1 * time.Hour).Unix() + dummyToken := createDummyJWT(futureTime) + + // Setup local config + localCfg := &config.LocalConfig{ + CurrentContext: "test-context", + Contexts: []config.ContextRef{ + {Name: "test-context", Server: "localhost", User: "test-user"}, + }, + Servers: []config.Server{ + {Name: "localhost", Server: "localhost"}, + }, + Users: []config.User{ + {Name: "test-user", AuthToken: dummyToken, RefreshToken: "some-refresh-token"}, + }, + } + + mc := µcksClient{ + ctx: context.Background(), + AuthToken: dummyToken, + RefreshToken: "some-refresh-token", + } + + // Calling refreshAuthToken with a valid token should do nothing and return nil + err := mc.refreshAuthToken(localCfg, "test-context", "") + if err != nil { + t.Fatalf("refreshAuthToken failed: %v", err) + } + + // Verify token was not modified + if mc.AuthToken != dummyToken { + t.Errorf("expected AuthToken to remain %q, got %q", dummyToken, mc.AuthToken) + } +} + +func TestRefreshAuthToken_ExpiredTokenTriggersRefresh(t *testing.T) { + // A token with expiration 1 hour in the past + pastTime := time.Now().Add(-1 * time.Hour).Unix() + expiredToken := createDummyJWT(pastTime) + + // We need a temporary config file path since the function calls WriteLocalConfig + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + + // Setup local config. Note: refreshAuthToken uses the context name ("test-context") + // as the name of the user to upsert, so we name the user "test-context" to match. + localCfg := &config.LocalConfig{ + CurrentContext: "test-context", + Contexts: []config.ContextRef{ + {Name: "test-context", Server: "http://localhost", User: "test-context"}, + }, + Servers: []config.Server{ + {Server: "http://localhost"}, + }, + Users: []config.User{ + {Name: "test-context", AuthToken: expiredToken, RefreshToken: "old-refresh-token"}, + }, + Auths: []config.Auth{ + {Server: "http://localhost", ClientId: "cli", ClientSecret: "secret"}, + }, + } + + // Write initial localconfig to the temp file + if err := config.WriteLocalConfig(*localCfg, configPath); err != nil { + t.Fatalf("failed to write local config: %v", err) + } + + // Spin up mock server handling Microcks client / Keycloak routes + var mockServer *httptest.Server + mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/keycloak/config": + // Return keycloak config pointing to this mock server + resp := map[string]interface{}{ + "enabled": true, + "auth-server-url": mockServer.URL, + "realm": "microcks", + } + json.NewEncoder(w).Encode(resp) + case "/realms/microcks/.well-known/openid-configuration": + // Return OIDC metadata pointing to token endpoint on mock server + resp := map[string]string{ + "authorization_endpoint": mockServer.URL + "/realms/microcks/protocol/openid-connect/auth", + "token_endpoint": mockServer.URL + "/realms/microcks/protocol/openid-connect/token", + } + json.NewEncoder(w).Encode(resp) + case "/realms/microcks/protocol/openid-connect/token": + // Verify request body for refresh token grant + if err := r.ParseForm(); err != nil { + t.Fatalf("failed to parse form: %v", err) + } + if r.FormValue("grant_type") != "refresh_token" { + t.Errorf("unexpected grant_type: %q", r.FormValue("grant_type")) + } + if r.FormValue("refresh_token") != "old-refresh-token" { + t.Errorf("unexpected refresh_token: %q", r.FormValue("refresh_token")) + } + + // Return new tokens + resp := map[string]string{ + "access_token": "new-access-token", + "refresh_token": "new-refresh-token", + } + json.NewEncoder(w).Encode(resp) + default: + t.Fatalf("unexpected request to: %s", r.URL.Path) + } + })) + defer mockServer.Close() + + apiURL, err := url.Parse(mockServer.URL + "/api/") + if err != nil { + t.Fatalf("failed to parse URL: %v", err) + } + + mc := µcksClient{ + ctx: context.Background(), + APIURL: apiURL, + AuthToken: expiredToken, + RefreshToken: "old-refresh-token", + httpClient: mockServer.Client(), + } + + err = mc.refreshAuthToken(localCfg, "test-context", configPath) + if err != nil { + t.Fatalf("refreshAuthToken failed: %v", err) + } + + // Verify client tokens were updated + if mc.AuthToken != "new-access-token" { + t.Errorf("expected AuthToken to be refreshed to %q, got %q", "new-access-token", mc.AuthToken) + } + if mc.RefreshToken != "new-refresh-token" { + t.Errorf("expected RefreshToken to be refreshed to %q, got %q", "new-refresh-token", mc.RefreshToken) + } + + // Verify local config was updated and written back to file + updatedCfg, err := config.ReadLocalConfig(configPath) + if err != nil { + t.Fatalf("failed to read back config: %v", err) + } + user, err := updatedCfg.GetUser("test-context") + if err != nil { + t.Fatalf("failed to get user: %v", err) + } + if user.AuthToken != "new-access-token" { + t.Errorf("expected config AuthToken to be %q, got %q", "new-access-token", user.AuthToken) + } + if user.RefreshToken != "new-refresh-token" { + t.Errorf("expected config RefreshToken to be %q, got %q", "new-refresh-token", user.RefreshToken) + } +}