CVE-2025-12110
Description
A flaw was found in Keycloak. An offline session continues to be valid when the offline_access scope is removed from the client. The refresh token is accepted and you can continue to request new tokens for the session. As it can lead to a situation where an administrator removes the scope, and assumes that offline sessions are no longer available, but they are.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.keycloak:keycloak-servicesMaven | < 26.2.3 | 26.2.3 |
Affected products
1Patches
2c830a27928caUserInfo request fails by using an access token obtained in Hybrid flow with offline_access scope
3 files changed · +49 −7
services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java+6 −0 modified@@ -315,6 +315,12 @@ public Response authenticated(AuthenticationSessionModel authSession, UserSessio redirectUri.addParam(OAuth2Constants.TOKEN_TYPE, res.getTokenType()); redirectUri.addParam(OAuth2Constants.EXPIRES_IN, String.valueOf(res.getExpiresIn())); } + + boolean offlineTokenRequested = clientSessionCtx.isOfflineTokenRequested(); + if (!responseType.isImplicitFlow() && offlineTokenRequested) { + // Allow creating offline token early, so the tokens issued from authz-enpdpoint can lookup offline-user-session if used before code-to-token request + responseBuilder.createOrUpdateOfflineSession(); + } } return buildRedirectUri(redirectUri, authSession, userSession, clientSessionCtx);
services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java+11 −7 modified@@ -1173,17 +1173,11 @@ private void generateRefreshToken(boolean offlineTokenRequested) { UserSessionModel userSession = clientSession.getUserSession(); userSession.setLastSessionRefresh(refreshToken.getIat().intValue()); if (offlineTokenRequested) { - UserSessionManager sessionManager = new UserSessionManager(session); - if (!sessionManager.isOfflineTokenAllowed(clientSessionCtx)) { - event.detail(Details.REASON, "Offline tokens not allowed for the user or client"); - event.error(Errors.NOT_ALLOWED); - throw new ErrorResponseException(Errors.NOT_ALLOWED, "Offline tokens not allowed for the user or client", Response.Status.BAD_REQUEST); - } refreshToken.type(TokenUtil.TOKEN_TYPE_OFFLINE); if (realm.isOfflineSessionMaxLifespanEnabled()) { refreshToken.exp(getExpiration(true)); } - sessionManager.createOrUpdateOfflineSession(clientSessionCtx.getClientSession(), userSession); + createOrUpdateOfflineSession(); } else { refreshToken.exp(getExpiration(false)); } @@ -1195,6 +1189,16 @@ private void generateRefreshToken(boolean offlineTokenRequested) { } } + public void createOrUpdateOfflineSession() { + UserSessionManager sessionManager = new UserSessionManager(session); + if (!sessionManager.isOfflineTokenAllowed(clientSessionCtx)) { + event.detail(Details.REASON, "Offline tokens not allowed for the user or client"); + event.error(Errors.NOT_ALLOWED); + throw new ErrorResponseException(Errors.NOT_ALLOWED, "Offline tokens not allowed for the user or client", Response.Status.BAD_REQUEST); + } + sessionManager.createOrUpdateOfflineSession(clientSessionCtx.getClientSession(), userSession); + } + /** * RFC9449 chapter 5<br/> * Refresh tokens issued to confidential clients are not bound to the DPoP proof public key because
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/UserInfoTest.java+32 −0 modified@@ -49,6 +49,7 @@ import org.keycloak.jose.jws.JWSInputException; import org.keycloak.jose.jws.crypto.RSAProvider; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.representations.AccessToken; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.idm.ClientScopeRepresentation; @@ -57,6 +58,7 @@ import org.keycloak.testsuite.client.resources.TestApplicationResourceUrls; import org.keycloak.testsuite.client.resources.TestOIDCEndpointsApplicationResource; import org.keycloak.testsuite.pages.LoginPage; +import org.keycloak.testsuite.updaters.ClientAttributeUpdater; import org.keycloak.testsuite.util.KeyUtils; import org.keycloak.testsuite.util.KeycloakModelUtils; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; @@ -77,6 +79,7 @@ import org.keycloak.testsuite.util.RoleBuilder; import org.keycloak.testsuite.util.TokenSignatureUtil; import org.keycloak.testsuite.util.UserInfoClientUtil; +import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; import org.keycloak.util.BasicAuthHelper; import org.keycloak.util.JsonSerialization; import org.keycloak.util.TokenUtil; @@ -105,6 +108,7 @@ import static org.junit.Assert.assertNull; import static org.hamcrest.MatcherAssert.assertThat; import static org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO; +import static org.keycloak.testsuite.AbstractTestRealmKeycloakTest.TEST_REALM_NAME; import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; import static org.keycloak.testsuite.util.oauth.OAuthClient.AUTH_SERVER_ROOT; @@ -641,6 +645,34 @@ public void testAccessTokenAfterUserSessionLogoutAndLoginAgain() { } } + // Issue 39037 + @Test + public void testUserInfoWithOfflineAccessAndHybridFlow() throws Exception { + try (Client client = AdminClientUtil.createResteasyClient(); + ClientAttributeUpdater oidcClient = ClientAttributeUpdater.forClient(adminClient, TEST_REALM_NAME, "test-app") + .setImplicitFlowEnabled(true) + .update()) { + oauth.scope(OAuth2Constants.SCOPE_OPENID + " " + OAuth2Constants.OFFLINE_ACCESS) + .responseType(OIDCResponseType.CODE + " " + OIDCResponseType.TOKEN) + .doLogin("test-user@localhost", "password"); + AuthorizationEndpointResponse authzEndpointResponse = oauth.parseLoginResponse(); + + // UserInfo request with the accessToken returned from authz endpoint + Response response1 = UserInfoClientUtil.executeUserInfoRequest_getMethod(client, authzEndpointResponse.getAccessToken()); + assertResponseSuccessful(response1); + + org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(authzEndpointResponse.getCode()); + + // Another userInfo request with the accessToken (but after tokens are exchanged) + Response response2 = UserInfoClientUtil.executeUserInfoRequest_getMethod(client, authzEndpointResponse.getAccessToken()); + assertResponseSuccessful(response2); + + // UserInfo request with the token returned from token response + Response response3 = UserInfoClientUtil.executeUserInfoRequest_getMethod(client, tokenResponse.getAccessToken()); + assertResponseSuccessful(response3); + } + } + @Test public void testNotBeforeTokens() { Client client = AdminClientUtil.createResteasyClient();
54e1c8af1e08UserInfo request fails by using an access token obtained in Hybrid flow with offline_access scope
3 files changed · +49 −7
services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java+6 −0 modified@@ -315,6 +315,12 @@ public Response authenticated(AuthenticationSessionModel authSession, UserSessio redirectUri.addParam(OAuth2Constants.TOKEN_TYPE, res.getTokenType()); redirectUri.addParam(OAuth2Constants.EXPIRES_IN, String.valueOf(res.getExpiresIn())); } + + boolean offlineTokenRequested = clientSessionCtx.isOfflineTokenRequested(); + if (!responseType.isImplicitFlow() && offlineTokenRequested) { + // Allow creating offline token early, so the tokens issued from authz-enpdpoint can lookup offline-user-session if used before code-to-token request + responseBuilder.createOrUpdateOfflineSession(); + } } return buildRedirectUri(redirectUri, authSession, userSession, clientSessionCtx);
services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java+11 −7 modified@@ -1167,17 +1167,11 @@ private void generateRefreshToken(boolean offlineTokenRequested) { UserSessionModel userSession = clientSession.getUserSession(); userSession.setLastSessionRefresh(refreshToken.getIat().intValue()); if (offlineTokenRequested) { - UserSessionManager sessionManager = new UserSessionManager(session); - if (!sessionManager.isOfflineTokenAllowed(clientSessionCtx)) { - event.detail(Details.REASON, "Offline tokens not allowed for the user or client"); - event.error(Errors.NOT_ALLOWED); - throw new ErrorResponseException(Errors.NOT_ALLOWED, "Offline tokens not allowed for the user or client", Response.Status.BAD_REQUEST); - } refreshToken.type(TokenUtil.TOKEN_TYPE_OFFLINE); if (realm.isOfflineSessionMaxLifespanEnabled()) { refreshToken.exp(getExpiration(true)); } - sessionManager.createOrUpdateOfflineSession(clientSessionCtx.getClientSession(), userSession); + createOrUpdateOfflineSession(); } else { refreshToken.exp(getExpiration(false)); } @@ -1189,6 +1183,16 @@ private void generateRefreshToken(boolean offlineTokenRequested) { } } + public void createOrUpdateOfflineSession() { + UserSessionManager sessionManager = new UserSessionManager(session); + if (!sessionManager.isOfflineTokenAllowed(clientSessionCtx)) { + event.detail(Details.REASON, "Offline tokens not allowed for the user or client"); + event.error(Errors.NOT_ALLOWED); + throw new ErrorResponseException(Errors.NOT_ALLOWED, "Offline tokens not allowed for the user or client", Response.Status.BAD_REQUEST); + } + sessionManager.createOrUpdateOfflineSession(clientSessionCtx.getClientSession(), userSession); + } + /** * RFC9449 chapter 5<br/> * Refresh tokens issued to confidential clients are not bound to the DPoP proof public key because
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/UserInfoTest.java+32 −0 modified@@ -49,6 +49,7 @@ import org.keycloak.jose.jws.JWSInputException; import org.keycloak.jose.jws.crypto.RSAProvider; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.representations.AccessToken; import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.idm.ClientScopeRepresentation; @@ -57,6 +58,7 @@ import org.keycloak.testsuite.client.resources.TestApplicationResourceUrls; import org.keycloak.testsuite.client.resources.TestOIDCEndpointsApplicationResource; import org.keycloak.testsuite.pages.LoginPage; +import org.keycloak.testsuite.updaters.ClientAttributeUpdater; import org.keycloak.testsuite.util.KeyUtils; import org.keycloak.testsuite.util.KeycloakModelUtils; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; @@ -77,6 +79,7 @@ import org.keycloak.testsuite.util.RoleBuilder; import org.keycloak.testsuite.util.TokenSignatureUtil; import org.keycloak.testsuite.util.UserInfoClientUtil; +import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; import org.keycloak.util.BasicAuthHelper; import org.keycloak.util.JsonSerialization; import org.keycloak.util.TokenUtil; @@ -105,6 +108,7 @@ import static org.junit.Assert.assertNull; import static org.hamcrest.MatcherAssert.assertThat; import static org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO; +import static org.keycloak.testsuite.AbstractTestRealmKeycloakTest.TEST_REALM_NAME; import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; import static org.keycloak.testsuite.util.oauth.OAuthClient.AUTH_SERVER_ROOT; @@ -641,6 +645,34 @@ public void testAccessTokenAfterUserSessionLogoutAndLoginAgain() { } } + // Issue 39037 + @Test + public void testUserInfoWithOfflineAccessAndHybridFlow() throws Exception { + try (Client client = AdminClientUtil.createResteasyClient(); + ClientAttributeUpdater oidcClient = ClientAttributeUpdater.forClient(adminClient, TEST_REALM_NAME, "test-app") + .setImplicitFlowEnabled(true) + .update()) { + oauth.scope(OAuth2Constants.SCOPE_OPENID + " " + OAuth2Constants.OFFLINE_ACCESS) + .responseType(OIDCResponseType.CODE + " " + OIDCResponseType.TOKEN) + .doLogin("test-user@localhost", "password"); + AuthorizationEndpointResponse authzEndpointResponse = oauth.parseLoginResponse(); + + // UserInfo request with the accessToken returned from authz endpoint + Response response1 = UserInfoClientUtil.executeUserInfoRequest_getMethod(client, authzEndpointResponse.getAccessToken()); + assertResponseSuccessful(response1); + + org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(authzEndpointResponse.getCode()); + + // Another userInfo request with the accessToken (but after tokens are exchanged) + Response response2 = UserInfoClientUtil.executeUserInfoRequest_getMethod(client, authzEndpointResponse.getAccessToken()); + assertResponseSuccessful(response2); + + // UserInfo request with the token returned from token response + Response response3 = UserInfoClientUtil.executeUserInfoRequest_getMethod(client, tokenResponse.getAccessToken()); + assertResponseSuccessful(response3); + } + } + @Test public void testNotBeforeTokens() { Client client = AdminClientUtil.createResteasyClient();
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
11- github.com/advisories/GHSA-895x-rfqp-jh5cghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-12110ghsaADVISORY
- access.redhat.com/errata/RHSA-2025:21370nvdWEB
- access.redhat.com/errata/RHSA-2025:21371nvdWEB
- access.redhat.com/errata/RHSA-2025:22088nvdWEB
- access.redhat.com/errata/RHSA-2025:22089nvdWEB
- access.redhat.com/security/cve/CVE-2025-12110nvdWEB
- bugzilla.redhat.com/show_bug.cginvdWEB
- github.com/keycloak/keycloak/commit/54e1c8af1e089ad33d32e0f2792610e4b8df421bghsaWEB
- github.com/keycloak/keycloak/commit/c830a27928cac4294619af7d147bdff34d4a85e7ghsaWEB
- github.com/keycloak/keycloak/pull/43790nvdWEB
News mentions
0No linked articles in our index yet.