From 7905af77e794410186e5e862ea2274b655128af3 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 10 Sep 2026 17:31:02 +0530 Subject: [PATCH 1/5] Fix openid scope loss on token refresh Closes #52574 Signed-off-by: Himanshu Mishra --- .../util/DefaultClientSessionContext.java | 4 +-- .../tests/oauth/RefreshTokenTest.java | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java b/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java index 662a8c0be0f6..ec288cec7df4 100644 --- a/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java +++ b/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java @@ -193,7 +193,7 @@ public String getScopeString(boolean ignoreIncludeInTokenScope) { if (Profile.isFeatureEnabled(Profile.Feature.PARAMETERIZED_SCOPES)) { String scopeParam = buildScopesStringFromAuthorizationRequest(ignoreIncludeInTokenScope); logger.tracef("Generated scope param with Parameterized Scopes enabled: %1s", scopeParam); - String scopeSent = requestedScopeString; + String scopeSent = clientSession.getNote(OAuth2Constants.SCOPE); if (TokenUtil.isOIDCRequest(scopeSent)) { scopeParam = TokenUtil.attachOIDCScope(scopeParam); } @@ -207,7 +207,7 @@ public String getScopeString(boolean ignoreIncludeInTokenScope) { .collect(Collectors.joining(" ")); // See if "openid" scope is requested - String scopeSent = requestedScopeString; + String scopeSent = clientSession.getNote(OAuth2Constants.SCOPE); if (TokenUtil.isOIDCRequest(scopeSent)) { scopeParam = TokenUtil.attachOIDCScope(scopeParam); } diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java index 4808e33d9b2c..fc9f4ceeca57 100755 --- a/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java @@ -89,6 +89,7 @@ import org.keycloak.testsuite.util.oauth.AbstractHttpPostRequest; import org.keycloak.testsuite.util.oauth.AbstractOAuthClient; import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.testsuite.util.oauth.UserInfoResponse; import org.keycloak.util.DPoPGenerator; import org.apache.http.client.methods.CloseableHttpResponse; @@ -753,6 +754,39 @@ public void refreshTokenReuseTokenWithoutRefreshTokensRevokedWithLessScopes() { } } + @Test + public void refreshTokenRetainsOpenidScopeWhenOmittedInRefreshRequest() { + try { + oauth.scope("openid profile"); + oauth.doLogin("test-user@localhost", "password"); + + String code = oauth.parseLoginResponse().getCode(); + + AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code); + assertEquals(200, tokenResponse.getStatusCode()); + assertScopes("openid email profile", tokenResponse.getScope()); + + RefreshToken initialRefreshToken = oauth.parseRefreshToken(tokenResponse.getRefreshToken()); + assertNotNull(initialRefreshToken); + + timeOffSet.set(2); + + oauth.scope("profile"); + AccessTokenResponse refreshResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken()); + assertEquals(200, refreshResponse.getStatusCode()); + + assertScopes("openid profile", refreshResponse.getScope()); + + UserInfoResponse userInfoResponse = oauth.doUserInfoRequest(refreshResponse.getAccessToken()); + assertEquals(200, userInfoResponse.getStatusCode()); + + RefreshToken refreshedRefreshToken = oauth.parseRefreshToken(refreshResponse.getRefreshToken()); + assertNotNull(refreshedRefreshToken); + } finally { + oauth.scope(null); + } + } + @Test public void refreshTokenReuseTokenScopeParameterNotInRefreshToken() { try { From 3ba30a8ca2980d1e794686595099dbaba3689621 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Fri, 11 Sep 2026 08:38:58 +0530 Subject: [PATCH 2/5] fix: ensure OIDC scope is correctly preserved during token refresh in DefaultClientSessionContext and verify with test assertions Signed-off-by: Himanshu Mishra --- .../util/DefaultClientSessionContext.java | 16 ++++++++++++---- .../keycloak/tests/oauth/RefreshTokenTest.java | 2 ++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java b/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java index ec288cec7df4..ab11b381e0a0 100644 --- a/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java +++ b/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java @@ -193,8 +193,7 @@ public String getScopeString(boolean ignoreIncludeInTokenScope) { if (Profile.isFeatureEnabled(Profile.Feature.PARAMETERIZED_SCOPES)) { String scopeParam = buildScopesStringFromAuthorizationRequest(ignoreIncludeInTokenScope); logger.tracef("Generated scope param with Parameterized Scopes enabled: %1s", scopeParam); - String scopeSent = clientSession.getNote(OAuth2Constants.SCOPE); - if (TokenUtil.isOIDCRequest(scopeSent)) { + if (isOIDCRequest()) { scopeParam = TokenUtil.attachOIDCScope(scopeParam); } return scopeParam; @@ -207,14 +206,23 @@ public String getScopeString(boolean ignoreIncludeInTokenScope) { .collect(Collectors.joining(" ")); // See if "openid" scope is requested - String scopeSent = clientSession.getNote(OAuth2Constants.SCOPE); - if (TokenUtil.isOIDCRequest(scopeSent)) { + if (isOIDCRequest()) { scopeParam = TokenUtil.attachOIDCScope(scopeParam); } return scopeParam; } + private boolean isOIDCRequest() { + if (TokenUtil.isOIDCRequest(requestedScopeString)) { + return true; + } + if (OAuth2Constants.REFRESH_TOKEN.equals(getAttribute(Constants.GRANT_TYPE, String.class))) { + return TokenUtil.isOIDCRequest(clientSession.getNote(OAuth2Constants.SCOPE)); + } + return false; + } + /** * Get all the scopes from the {@link AuthorizationRequestContext} by filtering entries by Source and by whether * they should be included in tokens or not. diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java index fc9f4ceeca57..85bf80eb527e 100755 --- a/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java @@ -768,6 +768,7 @@ public void refreshTokenRetainsOpenidScopeWhenOmittedInRefreshRequest() { RefreshToken initialRefreshToken = oauth.parseRefreshToken(tokenResponse.getRefreshToken()); assertNotNull(initialRefreshToken); + assertScopes("openid basic email roles service_account web-origins acr profile", initialRefreshToken.getScope()); timeOffSet.set(2); @@ -782,6 +783,7 @@ public void refreshTokenRetainsOpenidScopeWhenOmittedInRefreshRequest() { RefreshToken refreshedRefreshToken = oauth.parseRefreshToken(refreshResponse.getRefreshToken()); assertNotNull(refreshedRefreshToken); + assertScopes("openid basic email roles service_account web-origins acr profile", refreshedRefreshToken.getScope()); } finally { oauth.scope(null); } From 02a42b28d8caa64733877493de844bc23b9cbd0c Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Fri, 11 Sep 2026 09:20:27 +0530 Subject: [PATCH 3/5] fix: retain OIDC scope during refresh token process and add corresponding test case Signed-off-by: Himanshu Mishra --- .../refresh/AbstractRefreshTokenProvider.java | 5 ++- .../util/DefaultClientSessionContext.java | 3 +- .../tests/oauth/RefreshTokenTest.java | 44 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/services/src/main/java/org/keycloak/protocol/oidc/refresh/AbstractRefreshTokenProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/refresh/AbstractRefreshTokenProvider.java index b4c25a8ea784..2c26b6980e48 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/refresh/AbstractRefreshTokenProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/refresh/AbstractRefreshTokenProvider.java @@ -41,6 +41,7 @@ import org.keycloak.representations.AccessToken; import org.keycloak.representations.AuthorizationDetailsJSONRepresentation; import org.keycloak.representations.RefreshToken; +import org.keycloak.services.util.DefaultClientSessionContext; import org.keycloak.util.TokenUtil; import org.jboss.logging.Logger; @@ -121,6 +122,7 @@ public TokenManager.AccessTokenResponseBuilder refreshAccessToken(RefreshTokenCo clientSessionCtx.setAttribute(OIDCLoginProtocol.NONCE_PARAM, oldRefreshToken.getNonce()); } clientSessionCtx.setAttribute(Constants.GRANT_TYPE, OAuth2Constants.REFRESH_TOKEN); + clientSessionCtx.setAttribute(DefaultClientSessionContext.ORIGINAL_REFRESH_TOKEN_SCOPE, oldRefreshToken.getScope()); // recreate token. AccessToken newToken = tokenManager.createClientAccessToken(session, realm, authorizedClient, user, userSession, clientSessionCtx, userSession.isOffline()); @@ -173,8 +175,7 @@ public TokenManager.AccessTokenResponseBuilder refreshAccessToken(RefreshTokenCo responseBuilder.getRefreshToken().setAuthorization(newToken.getAuthorization()); } - String scopeParam = clientSession.getNote(OAuth2Constants.SCOPE); - if (TokenUtil.isOIDCRequest(scopeParam)) { + if (TokenUtil.isOIDCRequest(oldRefreshToken.getScope())) { responseBuilder.generateIDToken().generateAccessTokenHash(); } diff --git a/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java b/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java index ab11b381e0a0..6e8bc5aee768 100644 --- a/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java +++ b/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java @@ -58,6 +58,7 @@ public class DefaultClientSessionContext implements ClientSessionContext { private static final Logger logger = Logger.getLogger(DefaultClientSessionContext.class); + public static final String ORIGINAL_REFRESH_TOKEN_SCOPE = "original_refresh_token_scope"; private final AuthenticatedClientSessionModel clientSession; private final Set requestedScopes; @@ -218,7 +219,7 @@ private boolean isOIDCRequest() { return true; } if (OAuth2Constants.REFRESH_TOKEN.equals(getAttribute(Constants.GRANT_TYPE, String.class))) { - return TokenUtil.isOIDCRequest(clientSession.getNote(OAuth2Constants.SCOPE)); + return TokenUtil.isOIDCRequest(getAttribute(ORIGINAL_REFRESH_TOKEN_SCOPE, String.class)); } return false; } diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java index 85bf80eb527e..4db72f8f62b8 100755 --- a/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java @@ -89,6 +89,7 @@ import org.keycloak.testsuite.util.oauth.AbstractHttpPostRequest; import org.keycloak.testsuite.util.oauth.AbstractOAuthClient; import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; import org.keycloak.testsuite.util.oauth.UserInfoResponse; import org.keycloak.util.DPoPGenerator; @@ -789,6 +790,49 @@ public void refreshTokenRetainsOpenidScopeWhenOmittedInRefreshRequest() { } } + @Test + public void refreshTokenRetainsOpenidScopeAfterClientSessionScopeNoteOverwritten() { + try { + AuthorizationEndpointResponse initialAuthResponse = oauth.loginForm() + .scope("openid profile") + .doLogin("test-user@localhost", "password"); + assertNotNull(initialAuthResponse.getCode()); + + AccessTokenResponse initialTokenResponse = oauth.doAccessTokenRequest(initialAuthResponse.getCode()); + assertEquals(200, initialTokenResponse.getStatusCode()); + assertScopes("openid email profile", initialTokenResponse.getScope()); + + RefreshToken initialRefreshToken = oauth.parseRefreshToken(initialTokenResponse.getRefreshToken()); + assertNotNull(initialRefreshToken); + assertScopes("openid basic email roles service_account web-origins acr profile", initialRefreshToken.getScope()); + + AuthorizationEndpointResponse laterAuthResponse = oauth.loginForm() + .scope("profile") + .doLoginWithCookie(); + assertNotNull(laterAuthResponse.getCode()); + + AccessTokenResponse laterTokenResponse = oauth.doAccessTokenRequest(laterAuthResponse.getCode()); + assertEquals(200, laterTokenResponse.getStatusCode()); + assertScopes("email profile", laterTokenResponse.getScope()); + + timeOffSet.set(2); + + oauth.scope("profile"); + AccessTokenResponse refreshResponse = oauth.doRefreshTokenRequest(initialTokenResponse.getRefreshToken()); + assertEquals(200, refreshResponse.getStatusCode()); + assertScopes("openid profile", refreshResponse.getScope()); + + UserInfoResponse userInfoResponse = oauth.doUserInfoRequest(refreshResponse.getAccessToken()); + assertEquals(200, userInfoResponse.getStatusCode()); + + RefreshToken refreshedRefreshToken = oauth.parseRefreshToken(refreshResponse.getRefreshToken()); + assertNotNull(refreshedRefreshToken); + assertScopes("openid basic email roles service_account web-origins acr profile", refreshedRefreshToken.getScope()); + } finally { + oauth.scope(null); + } + } + @Test public void refreshTokenReuseTokenScopeParameterNotInRefreshToken() { try { From 7777889373ad03ddf6956ea5b69f17ebb3c071c7 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Fri, 11 Sep 2026 09:31:54 +0530 Subject: [PATCH 4/5] test: enhance RefreshTokenTest to validate ID token properties after refresh Signed-off-by: Himanshu Mishra --- .../java/org/keycloak/tests/oauth/RefreshTokenTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java index 4db72f8f62b8..32523f4aa7fa 100755 --- a/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java @@ -41,6 +41,7 @@ import org.keycloak.jose.jws.JWSHeader; import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jws.JWSInputException; +import org.keycloak.jose.jws.crypto.HashUtils; import org.keycloak.models.AccountRoles; import org.keycloak.models.Constants; import org.keycloak.models.UserSessionProvider; @@ -822,6 +823,14 @@ public void refreshTokenRetainsOpenidScopeAfterClientSessionScopeNoteOverwritten assertEquals(200, refreshResponse.getStatusCode()); assertScopes("openid profile", refreshResponse.getScope()); + assertNotNull(refreshResponse.getIdToken()); + IDToken refreshedIdToken = oauth.verifyToken(refreshResponse.getIdToken(), IDToken.class); + assertEquals(user.getId(), refreshedIdToken.getSubject()); + assertNull(refreshedIdToken.getNonce()); + assertNotNull(refreshedIdToken.getAccessTokenHash()); + assertEquals(HashUtils.accessTokenHash(Algorithm.RS256, refreshResponse.getAccessToken()), refreshedIdToken.getAccessTokenHash()); + assertNotEquals(HashUtils.accessTokenHash(Algorithm.RS256, initialTokenResponse.getAccessToken()), refreshedIdToken.getAccessTokenHash()); + UserInfoResponse userInfoResponse = oauth.doUserInfoRequest(refreshResponse.getAccessToken()); assertEquals(200, userInfoResponse.getStatusCode()); From cae71f1bbec004a24ce0e8b3feb39754542c3919 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Mon, 14 Sep 2026 22:15:38 +0530 Subject: [PATCH 5/5] Fix refresh token scope test expectation Signed-off-by: Himanshu Mishra --- .../test/java/org/keycloak/tests/oauth/RefreshTokenTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java index 32523f4aa7fa..195c981288c3 100755 --- a/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java @@ -778,7 +778,7 @@ public void refreshTokenRetainsOpenidScopeWhenOmittedInRefreshRequest() { AccessTokenResponse refreshResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken()); assertEquals(200, refreshResponse.getStatusCode()); - assertScopes("openid profile", refreshResponse.getScope()); + assertScopes("openid email profile", refreshResponse.getScope()); UserInfoResponse userInfoResponse = oauth.doUserInfoRequest(refreshResponse.getAccessToken()); assertEquals(200, userInfoResponse.getStatusCode()); @@ -821,7 +821,7 @@ public void refreshTokenRetainsOpenidScopeAfterClientSessionScopeNoteOverwritten oauth.scope("profile"); AccessTokenResponse refreshResponse = oauth.doRefreshTokenRequest(initialTokenResponse.getRefreshToken()); assertEquals(200, refreshResponse.getStatusCode()); - assertScopes("openid profile", refreshResponse.getScope()); + assertScopes("openid email profile", refreshResponse.getScope()); assertNotNull(refreshResponse.getIdToken()); IDToken refreshedIdToken = oauth.verifyToken(refreshResponse.getIdToken(), IDToken.class);