VYPR
Medium severity5.4GHSA Advisory· Published Oct 23, 2025· Updated Apr 15, 2026

CVE-2025-12110

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.

PackageAffected versionsPatched versions
org.keycloak:keycloak-servicesMaven
< 26.2.326.2.3

Affected products

1

Patches

2
c830a27928ca

UserInfo request fails by using an access token obtained in Hybrid flow with offline_access scope

https://github.com/keycloak/keycloakMarek PosoldaApr 30, 2025via ghsa
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();
    
54e1c8af1e08

UserInfo request fails by using an access token obtained in Hybrid flow with offline_access scope

https://github.com/keycloak/keycloakMarek PosoldaApr 25, 2025via ghsa
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

News mentions

0

No linked articles in our index yet.