Skip to content

fix: refresh expired stored token in exchangeStoredToken (#49341) [fk4WqyCCw3C5ShR1RfB7MoBPTpkRrBFYP1uT35g3MvT] - #51358

Open
waterWang wants to merge 1 commit into
keycloak:mainfrom
waterWang:fix/exchange-stored-token-refresh-49341
Open

fix: refresh expired stored token in exchangeStoredToken (#49341) [fk4WqyCCw3C5ShR1RfB7MoBPTpkRrBFYP1uT35g3MvT]#51358
waterWang wants to merge 1 commit into
keycloak:mainfrom
waterWang:fix/exchange-stored-token-refresh-49341

Conversation

@waterWang

Copy link
Copy Markdown

Summary

The exchangeStoredToken method in AbstractOAuth2IdentityProvider (used by the V2 /broker/{alias}/token endpoint and the token exchange path) returns the stored access token without checking whether it has expired. When the token is expired, the caller receives a stale token that will fail when used.

The V1 retrieveToken method already handles this correctly: it checks accessTokenExpiration, and if the token needs refresh and a refresh token is available, it calls refreshToken and updates the stored token.

Changes

This PR backports the same token refresh logic to exchangeStoredToken:

  1. Parse the stored token as OAuthResponse (the same class used by V1 retrieveToken)
  2. Check if the token needs refresh using needsRefresh() (expired or within minValidityToken)
  3. If a refresh token is available, call refreshToken() to obtain a new token
  4. Update accessTokenExpiration on the new response
  5. Persist the refreshed token via updateFederatedIdentity
  6. Return the refreshed token

If the token is not JSON (e.g., form-encoded), the original extractTokenFromResponse fallback path is preserved.

Related

)

Backport the token refresh logic from retrieveToken (V1) to
exchangeStoredToken (used by V2 and token exchange paths).

When the stored access token has expired and a refresh token is
available, exchangeStoredToken now automatically refreshes the
token before returning it, matching the behavior of the V1
retrieveToken method.

Closes keycloak#49341

[fk4WqyCCw3C5ShR1RfB7MoBPTpkRrBFYP1uT35g3MvT]
Copilot AI review requested due to automatic review settings August 1, 2026 22:06
@waterWang
waterWang requested a review from a team as a code owner August 1, 2026 22:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds stored OAuth2 token refresh handling to the base identity provider.

Changes:

  • Detects expired stored tokens and refreshes them.
  • Persists and returns the refreshed access token.
  • Preserves fallback handling for non-JSON tokens.
Suppressed comments (2)

services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java:519

  • Add coverage for an expired stored JSON token through a non-OIDC AbstractOAuth2IdentityProvider subclass, asserting refresh, persistence, and the returned fresh token. Existing broker refresh coverage exercises the OIDC override, so it cannot catch regressions in this new base-class branch.
                    model.setToken(JsonSerialization.writeValueAsString(newResponse));
                    session.users().updateFederatedIdentity(realm, tokenSubject, model);
                    AccessTokenResponse tokenResponse = new AccessTokenResponse();
                    tokenResponse.setToken(newResponse.getToken());
                    return buildTokenResponse(uriInfo, event, authorizedClient, tokenUserSession, tokenResponse, OAuth2Constants.ACCESS_TOKEN_TYPE);

services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java:524

  • Do not swallow refresh I/O/deserialization failures and then fall through: model still contains the expired token, so lines 526–539 return that stale token as a successful response. Match the existing V1 behavior by returning a gateway error instead.
        } catch (IOException e) {
            logger.debugf("Failed to refresh stored token", e);
        }

Comment on lines +509 to +510
if (needsRefresh(exp) && previousResponse.getRefreshToken() != null) {
OAuthResponse newResponse = refreshToken(previousResponse, session);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GET /broker/{alias}/token returns expired external IDP access token when refresh_token is available

2 participants