diff --git a/cmd/import.go b/cmd/import.go index c1a74c4..5fb7c35 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -76,7 +76,7 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command // If Keycloak is enabled, retrieve an OAuth token using Keycloak Client. kc := connectors.NewKeycloakClient(keycloakURL, globalClientOpts.ClientId, globalClientOpts.ClientSecret) - oauthToken, err = kc.ConnectAndGetToken() + oauthToken, _, err = kc.ConnectAndGetToken() if err != nil { fmt.Printf("Got error when invoking Keycloak client: %s", err) os.Exit(1) diff --git a/cmd/importURL.go b/cmd/importURL.go index 69c32e8..f1b0a7c 100644 --- a/cmd/importURL.go +++ b/cmd/importURL.go @@ -62,7 +62,7 @@ func NewImportURLCommand(globalClientOpts *connectors.ClientOptions) *cobra.Comm // If Keycloak is enabled, retrieve an OAuth token using Keycloak Client. kc := connectors.NewKeycloakClient(keycloakURL, globalClientOpts.ClientId, globalClientOpts.ClientSecret) - oauthToken, err = kc.ConnectAndGetToken() + oauthToken, _, err = kc.ConnectAndGetToken() if err != nil { fmt.Printf("Got error when invoking Keycloak client: %s", err) os.Exit(1) diff --git a/cmd/test.go b/cmd/test.go index 70ecb4f..84775d7 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -113,6 +113,12 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { var mc connectors.MicrocksClient var serverAddr string + // kc and tokenExpiresAt are kept in scope so the polling loop can + // refresh the bearer token before it expires (headless mode only). + var kc connectors.KeycloakClient + var tokenExpiresAt int64 + var headlessKeycloakEnabled bool + if globalClientOpts.ServerAddr != "" && globalClientOpts.ClientId != "" && globalClientOpts.ClientSecret != "" { // create client with server address @@ -128,13 +134,17 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { var oauthToken string = "unauthenticated-token" if keycloakURL != "null" { // If Keycloak is enabled, retrieve an OAuth token using Keycloak Client. - kc := connectors.NewKeycloakClient(keycloakURL, globalClientOpts.ClientId, globalClientOpts.ClientSecret) + headlessKeycloakEnabled = true + kc = connectors.NewKeycloakClient(keycloakURL, globalClientOpts.ClientId, globalClientOpts.ClientSecret) - oauthToken, err = kc.ConnectAndGetToken() + var expiresIn int + oauthToken, expiresIn, err = kc.ConnectAndGetToken() if err != nil { fmt.Printf("Got error when invoking Keycloak client: %s", err) os.Exit(1) } + // Schedule refresh 30 seconds before actual expiry to avoid races. + tokenExpiresAt = nowInMilliseconds() + int64(expiresIn-30)*1000 //fmt.Printf("Retrieve OAuthToken: %s", oauthToken) } @@ -186,6 +196,20 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { var success = false for nowInMilliseconds() < future { + // Proactively refresh the bearer token before it expires so that + // long-running ASYNC_API_SCHEMA tests (whose --waitFor can exceed + // the Keycloak access token lifetime) do not get spurious 401s. + if headlessKeycloakEnabled && nowInMilliseconds() >= tokenExpiresAt { + freshToken, freshExpiresIn, refreshErr := kc.ConnectAndGetToken() + if refreshErr != nil { + fmt.Printf("Got error refreshing Keycloak token: %s\n", refreshErr) + os.Exit(1) + } + mc.SetOAuthToken(freshToken) + tokenExpiresAt = nowInMilliseconds() + int64(freshExpiresIn-30)*1000 + fmt.Println("MicrocksTester refreshed OAuth token for continued polling.") + } + testResultSummary, err := mc.GetTestResult(testResultID) if err != nil { fmt.Printf("Got error when invoking Microcks client check TestResult: %s", err) diff --git a/pkg/connectors/keycloak_client.go b/pkg/connectors/keycloak_client.go index 5d6aeed..e4b223d 100644 --- a/pkg/connectors/keycloak_client.go +++ b/pkg/connectors/keycloak_client.go @@ -31,7 +31,9 @@ import ( // KeycloakClient defines methods for cinteracting with Keycloak type KeycloakClient interface { - ConnectAndGetToken() (string, error) + // ConnectAndGetToken performs a client_credentials grant and returns the + // access token, its lifetime in seconds (expires_in), and any error. + ConnectAndGetToken() (string, int, error) ConnectAndGetTokenAndRefreshToken(string, string) (string, string, error) GetOIDCConfig() (*oauth2.Config, error) } @@ -69,13 +71,13 @@ func NewKeycloakClient(realmURL string, username string, password string) Keyclo } // ConnectAndGetToken implementation on keycloakClient structure -func (c *keycloakClient) ConnectAndGetToken() (string, error) { +func (c *keycloakClient) ConnectAndGetToken() (string, int, error) { rel := &url.URL{Path: "protocol/openid-connect/token"} u := c.BaseURL.ResolveReference(rel) req, err := http.NewRequest("POST", u.String(), strings.NewReader(url.Values{"grant_type": {"client_credentials"}}.Encode())) if err != nil { - return "", err + return "", 0, err } credential := base64.StdEncoding.EncodeToString([]byte(c.Username + ":" + c.Password)) @@ -88,7 +90,7 @@ func (c *keycloakClient) ConnectAndGetToken() (string, error) { resp, err := c.httpClient.Do(req) if err != nil { - return "", err + return "", 0, err } defer resp.Body.Close() @@ -97,16 +99,30 @@ func (c *keycloakClient) ConnectAndGetToken() (string, error) { body, err := io.ReadAll(resp.Body) if err != nil { - panic(err.Error()) + return "", 0, err + } + + if resp.StatusCode != http.StatusOK { + return "", 0, fmt.Errorf("Keycloak returned HTTP %d when requesting token: %s", resp.StatusCode, string(body)) } var openIDResp map[string]interface{} if err := json.Unmarshal(body, &openIDResp); err != nil { - panic(err) + return "", 0, fmt.Errorf("failed to parse Keycloak token response: %w", err) + } + + accessToken, ok := openIDResp["access_token"].(string) + if !ok { + return "", 0, fmt.Errorf("missing or invalid access_token in Keycloak response") + } + + // Default to 5 minutes if expires_in is absent or unparseable. + expiresIn := 300 + if v, ok := openIDResp["expires_in"].(float64); ok && v > 0 { + expiresIn = int(v) } - accessToken := openIDResp["access_token"].(string) - return accessToken, err + return accessToken, expiresIn, nil } func (c *keycloakClient) GetOIDCConfig() (*oauth2.Config, error) { diff --git a/pkg/connectors/microcks_client.go b/pkg/connectors/microcks_client.go index f76884b..2a55526 100644 --- a/pkg/connectors/microcks_client.go +++ b/pkg/connectors/microcks_client.go @@ -409,6 +409,13 @@ func (c *microcksClient) GetTestResult(testResultID string) (*TestResultSummary, panic(err.Error()) } + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("authentication failed (HTTP 401): bearer token has expired or is invalid — check Keycloak token lifetime vs --waitFor duration") + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected HTTP %d from Microcks API while polling test result: %s", resp.StatusCode, string(body)) + } + result := TestResultSummary{} if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("failed to parse test result response: %w", err)