From 92c96033f2c32012b9303c4fa4b3501d3597e5fa Mon Sep 17 00:00:00 2001 From: Marek Posolda Date: Thu, 27 Feb 2025 15:53:23 +0100 Subject: [PATCH] Session type incorrectly set in access-token context when token created with scope=offline_access (#37701) closes #37694 Signed-off-by: mposolda --- .../keycloak/models/ClientSessionContext.java | 5 ++++ .../keycloak/protocol/oidc/TokenManager.java | 3 +-- .../oidc/encode/AccessTokenContext.java | 23 ++++++++++++++++++- .../DefaultTokenContextEncoderProvider.java | 2 +- .../grants/ClientCredentialsGrantType.java | 2 ++ .../util/DefaultClientSessionContext.java | 11 +++++++++ ...efaultTokenContextEncoderProviderTest.java | 12 ++++++++++ .../rest/TestingResourceProvider.java | 10 ++++++++ .../client/resources/TestingResource.java | 6 +++++ .../testsuite/oauth/OfflineTokenTest.java | 11 ++++++++- .../testsuite/oauth/ServiceAccountTest.java | 10 +++++--- .../oidc/LightWeightAccessTokenTest.java | 12 ++++++---- 12 files changed, 95 insertions(+), 12 deletions(-) diff --git a/server-spi/src/main/java/org/keycloak/models/ClientSessionContext.java b/server-spi/src/main/java/org/keycloak/models/ClientSessionContext.java index e613ebcbcb6..13ea3f42a42 100644 --- a/server-spi/src/main/java/org/keycloak/models/ClientSessionContext.java +++ b/server-spi/src/main/java/org/keycloak/models/ClientSessionContext.java @@ -40,6 +40,11 @@ public interface ClientSessionContext { */ Stream getClientScopesStream(); + /** + * @return true if offline token is requested + */ + boolean isOfflineTokenRequested(); + /** * Returns all roles including composite ones as a stream. * @return Stream of {@link RoleModel}. Never returns {@code null}. diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index 4cd8c0546f4..990b75b55a4 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -1212,8 +1212,7 @@ public class TokenManager { throw new IllegalStateException("accessToken not set"); } - ClientScopeModel offlineAccessScope = KeycloakModelUtils.getClientScopeByName(realm, OAuth2Constants.OFFLINE_ACCESS); - boolean offlineTokenRequested = offlineAccessScope==null ? false : clientSessionCtx.getClientScopeIds().contains(offlineAccessScope.getId()); + boolean offlineTokenRequested = clientSessionCtx.isOfflineTokenRequested(); generateRefreshToken(offlineTokenRequested); refreshToken.setScope(clientSessionCtx.getScopeString(true)); if (realm.isRevokeRefreshToken()) { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/encode/AccessTokenContext.java b/services/src/main/java/org/keycloak/protocol/oidc/encode/AccessTokenContext.java index 80918124870..b0a121bcb7c 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/encode/AccessTokenContext.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/encode/AccessTokenContext.java @@ -21,6 +21,9 @@ package org.keycloak.protocol.oidc.encode; import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + /** * Some context info about the token * @@ -66,7 +69,8 @@ public class AccessTokenContext { } } - public AccessTokenContext(SessionType sessionType, TokenType tokenType, String grantType, String rawTokenId) { + @JsonCreator + public AccessTokenContext(@JsonProperty("sessionType") SessionType sessionType, @JsonProperty("tokenType") TokenType tokenType, @JsonProperty("grantType") String grantType, @JsonProperty("rawTokenId") String rawTokenId) { Objects.requireNonNull(sessionType, "Null sessionType not allowed"); Objects.requireNonNull(tokenType, "Null tokenType not allowed"); Objects.requireNonNull(grantType, "Null grantType not allowed"); @@ -92,4 +96,21 @@ public class AccessTokenContext { public String getRawTokenId() { return rawTokenId; } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + return obj instanceof AccessTokenContext that && + sessionType == that.sessionType && + tokenType == that.tokenType && + Objects.equals(grantType, that.grantType) && + Objects.equals(rawTokenId, that.rawTokenId); + } + + @Override + public int hashCode() { + return Objects.hash(sessionType, tokenType, grantType, rawTokenId); + } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProvider.java index 3488759b340..0270abb75c7 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProvider.java @@ -48,7 +48,7 @@ public class DefaultTokenContextEncoderProvider implements TokenContextEncoderPr if (userSession.getPersistenceState() == UserSessionModel.SessionPersistenceState.TRANSIENT) { sessionType = AccessTokenContext.SessionType.TRANSIENT; } else { - sessionType = userSession.isOffline() ? AccessTokenContext.SessionType.OFFLINE : AccessTokenContext.SessionType.ONLINE; + sessionType = clientSessionContext.isOfflineTokenRequested() ? AccessTokenContext.SessionType.OFFLINE : AccessTokenContext.SessionType.ONLINE; } boolean useLightweightToken = AbstractOIDCProtocolMapper.getShouldUseLightweightToken(session); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ClientCredentialsGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ClientCredentialsGrantType.java index c8056472669..4ba217bb302 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/ClientCredentialsGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ClientCredentialsGrantType.java @@ -29,6 +29,7 @@ import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventType; import org.keycloak.models.ClientSessionContext; +import org.keycloak.models.Constants; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.oidc.OIDCLoginProtocol; @@ -120,6 +121,7 @@ public class ClientCredentialsGrantType extends OAuth2GrantTypeBase { AuthenticationManager.setClientScopesInSession(session, authSession); ClientSessionContext clientSessionCtx = TokenManager.attachAuthenticationSession(session, userSession, authSession); + clientSessionCtx.setAttribute(Constants.GRANT_TYPE, context.getGrantType()); // Notes about client details userSession.setNote(ServiceAccountConstants.CLIENT_ID_SESSION_NOTE, client.getClientId()); // This is for backwards compatibility 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 7671dd88ef1..d31c033e7a8 100644 --- a/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java +++ b/services/src/main/java/org/keycloak/services/util/DefaultClientSessionContext.java @@ -38,6 +38,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.RoleModel; import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.RoleUtils; import org.keycloak.protocol.ProtocolMapperUtils; import org.keycloak.protocol.oidc.OIDCLoginProtocol; @@ -130,6 +131,16 @@ public class DefaultClientSessionContext implements ClientSessionContext { return allowedClientScopes.stream(); } + @Override + public boolean isOfflineTokenRequested() { + Boolean offlineAccessRequested = getAttribute(OAuth2Constants.OFFLINE_ACCESS, Boolean.class); + if (offlineAccessRequested != null) return offlineAccessRequested; + + ClientScopeModel offlineAccessScope = KeycloakModelUtils.getClientScopeByName(clientSession.getRealm(), OAuth2Constants.OFFLINE_ACCESS); + offlineAccessRequested = offlineAccessScope == null ? false : getClientScopeIds().contains(offlineAccessScope.getId()); + setAttribute(OAuth2Constants.OFFLINE_ACCESS, offlineAccessRequested); + return offlineAccessRequested; + } @Override public Stream getRolesStream() { diff --git a/services/src/test/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProviderTest.java b/services/src/test/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProviderTest.java index 7e94e9948b7..5716f8cca3f 100644 --- a/services/src/test/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProviderTest.java +++ b/services/src/test/java/org/keycloak/protocol/oidc/encode/DefaultTokenContextEncoderProviderTest.java @@ -19,6 +19,7 @@ package org.keycloak.protocol.oidc.encode; +import java.io.IOException; import java.util.HashMap; import java.util.Map; import java.util.stream.Collectors; @@ -27,6 +28,7 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.keycloak.OAuth2Constants; +import org.keycloak.util.JsonSerialization; /** * @author Marek Posolda @@ -73,6 +75,16 @@ public class DefaultTokenContextEncoderProviderTest { Assert.assertEquals(tokenId, provider.encodeTokenId(ctx)); } + @Test + public void testJsonSerialization() throws IOException { + String tokenId = "trltcc:1234"; + AccessTokenContext ctx = provider.getTokenContextFromTokenId(tokenId); + + String s = JsonSerialization.writeValueAsString(ctx); + AccessTokenContext deserialized = JsonSerialization.readValue(s, AccessTokenContext.class); + Assert.assertEquals(ctx, deserialized); + } + @Test public void testIncorrectGrantType() { try { diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java index 3dabf88edd9..c076e8d759d 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestingResourceProvider.java @@ -65,6 +65,8 @@ import org.keycloak.models.UserSessionModel; import org.keycloak.models.sessions.infinispan.changes.sessions.CrossDCLastSessionRefreshStoreFactory; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.models.utils.ResetTimeOffsetEvent; +import org.keycloak.protocol.oidc.encode.AccessTokenContext; +import org.keycloak.protocol.oidc.encode.TokenContextEncoderProvider; import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType; import org.keycloak.provider.Provider; import org.keycloak.provider.ProviderFactory; @@ -1173,4 +1175,12 @@ public class TestingResourceProvider implements RealmResourceProvider { prov.removeIncludedEvents(events.toArray(EventType[]::new)); } } + + @GET + @Path("/token-context") + @Produces(MediaType.APPLICATION_JSON) + public AccessTokenContext getTokenContext(@QueryParam("tokenId") String tokenId) { + return session.getProvider(TokenContextEncoderProvider.class).getTokenContextFromTokenId(tokenId); + } + } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingResource.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingResource.java index fae25b8f865..ed2711028ac 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingResource.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestingResource.java @@ -21,6 +21,7 @@ import org.jboss.resteasy.reactive.NoCache; import org.keycloak.common.Profile; import org.keycloak.common.enums.HostnameVerificationPolicy; import org.keycloak.events.EventType; +import org.keycloak.protocol.oidc.encode.AccessTokenContext; import org.keycloak.representations.idm.AdminEventRepresentation; import org.keycloak.representations.idm.AuthenticationFlowRepresentation; import org.keycloak.representations.idm.EventRepresentation; @@ -474,4 +475,9 @@ public interface TestingResource { @Path("/email-event-litener-provide/remove-events") @Consumes(MediaType.APPLICATION_JSON) public void removeEventsToEmailEventListenerProvider(List events); + + @GET + @Path("/token-context") + @Produces(MediaType.APPLICATION_JSON) + AccessTokenContext getTokenContext(@QueryParam("tokenId") String tokenId); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java index a5ebfa555c8..151fed6b5e7 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java @@ -17,7 +17,6 @@ package org.keycloak.testsuite.oauth; -import org.apache.http.client.methods.CloseableHttpResponse; import org.jboss.arquillian.graphene.page.Page; import org.junit.Assert; import org.junit.Before; @@ -47,6 +46,7 @@ import org.keycloak.models.UserSessionModel; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.SessionTimeoutHelper; import org.keycloak.protocol.oidc.OIDCConfigAttributes; +import org.keycloak.protocol.oidc.encode.AccessTokenContext; import org.keycloak.representations.AccessToken; import org.keycloak.representations.RefreshToken; import org.keycloak.representations.idm.ClientRepresentation; @@ -261,6 +261,11 @@ public class OfflineTokenTest extends AbstractKeycloakTest { assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); Assert.assertNull(offlineToken.getExp()); + AccessTokenContext ctx = testingClient.testing("test").getTokenContext(token.getId()); + Assert.assertEquals(ctx.getSessionType(), AccessTokenContext.SessionType.OFFLINE); + Assert.assertEquals(ctx.getTokenType(), AccessTokenContext.TokenType.REGULAR); + Assert.assertEquals(ctx.getGrantType(), OAuth2Constants.AUTHORIZATION_CODE); + assertTrue(tokenResponse.getScope().contains(OAuth2Constants.OFFLINE_ACCESS)); // check only offline session is created @@ -365,6 +370,10 @@ public class OfflineTokenTest extends AbstractKeycloakTest { AccessTokenResponse response = oauth.doRefreshTokenRequest(offlineTokenString, "secret1"); AccessToken refreshedToken = oauth.verifyToken(response.getAccessToken()); Assert.assertEquals(200, response.getStatusCode()); + AccessTokenContext ctx = testingClient.testing("test").getTokenContext(refreshedToken.getId()); + Assert.assertEquals(ctx.getSessionType(), AccessTokenContext.SessionType.OFFLINE); + Assert.assertEquals(ctx.getTokenType(), AccessTokenContext.TokenType.REGULAR); + Assert.assertEquals(ctx.getGrantType(), OAuth2Constants.REFRESH_TOKEN); // Assert new refreshToken in the response String newRefreshToken = response.getRefreshToken(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ServiceAccountTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ServiceAccountTest.java index 74c60dd263e..77b89fa7214 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ServiceAccountTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ServiceAccountTest.java @@ -19,13 +19,12 @@ package org.keycloak.testsuite.oauth; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.http.HttpResponse; -import org.apache.http.client.methods.CloseableHttpResponse; import org.hamcrest.Matchers; import org.junit.Assert; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; +import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator; import org.keycloak.common.constants.ServiceAccountConstants; @@ -38,6 +37,7 @@ import org.keycloak.jose.jws.JWSInput; import org.keycloak.models.Constants; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.oidc.OIDCConfigAttributes; +import org.keycloak.protocol.oidc.encode.AccessTokenContext; import org.keycloak.protocol.oidc.mappers.SHA256PairwiseSubMapper; import org.keycloak.representations.AccessToken; import org.keycloak.representations.RefreshToken; @@ -58,7 +58,6 @@ import org.keycloak.testsuite.util.TokenSignatureUtil; import org.keycloak.testsuite.util.UserBuilder; import jakarta.ws.rs.ClientErrorException; -import jakarta.ws.rs.core.Response; import org.keycloak.testsuite.util.oauth.LogoutResponse; import java.io.IOException; @@ -416,6 +415,11 @@ public class ServiceAccountTest extends AbstractKeycloakTest { Assert.assertNull(accessToken.getSessionState()); Assert.assertNull("Refresh-Token should not be present", response.getRefreshToken()); + AccessTokenContext ctx = testingClient.testing("test").getTokenContext(accessToken.getId()); + Assert.assertEquals(ctx.getSessionType(), AccessTokenContext.SessionType.TRANSIENT); + Assert.assertEquals(ctx.getTokenType(), AccessTokenContext.TokenType.REGULAR); + Assert.assertEquals(ctx.getGrantType(), OAuth2Constants.CLIENT_CREDENTIALS); + events.expectClientLogin() .client("service-account-cl") .user(userIdCl) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/LightWeightAccessTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/LightWeightAccessTokenTest.java index 214c21ac2b0..fb330520b8a 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/LightWeightAccessTokenTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/LightWeightAccessTokenTest.java @@ -18,7 +18,6 @@ package org.keycloak.testsuite.oidc; import jakarta.ws.rs.NotFoundException; -import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; @@ -42,6 +41,7 @@ import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory; +import org.keycloak.protocol.oidc.encode.AccessTokenContext; import org.keycloak.protocol.oidc.mappers.AudienceProtocolMapper; import org.keycloak.protocol.oidc.mappers.GroupMembershipMapper; import org.keycloak.protocol.oidc.mappers.HardcodedClaim; @@ -70,7 +70,6 @@ import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse; import org.keycloak.testsuite.util.oauth.OAuthClient; import org.keycloak.testsuite.util.ProtocolMapperUtil; import org.keycloak.util.JsonSerialization; -import org.keycloak.utils.MediaType; import java.io.IOException; import java.util.ArrayList; @@ -107,7 +106,6 @@ import static org.keycloak.protocol.oidc.mappers.PairwiseSubMapperHelper.PAIRWIS import static org.keycloak.protocol.oidc.mappers.RoleNameMapper.NEW_ROLE_NAME; import static org.keycloak.protocol.oidc.mappers.RoleNameMapper.ROLE_CONFIG; import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; -import static org.keycloak.testsuite.auth.page.AuthRealm.TEST; import org.keycloak.testsuite.updaters.ClientAttributeUpdater; import static org.keycloak.testsuite.util.ClientPoliciesUtil.createAnyClientConditionConfig; @@ -358,7 +356,13 @@ public class LightWeightAccessTokenTest extends AbstractClientPoliciesTest { AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(authsEndpointResponse.getCode()); String accessToken = tokenResponse.getAccessToken(); logger.debug("access token:" + accessToken); - assertAccessToken(oauth.verifyToken(accessToken), true, true, true); + AccessToken token = oauth.verifyToken(accessToken); + assertAccessToken(token, true, true, true); + + AccessTokenContext ctx = testingClient.testing("test").getTokenContext(token.getId()); + Assert.assertEquals(ctx.getSessionType(), AccessTokenContext.SessionType.ONLINE); + Assert.assertEquals(ctx.getTokenType(), AccessTokenContext.TokenType.LIGHTWEIGHT); + Assert.assertEquals(ctx.getGrantType(), OAuth2Constants.AUTHORIZATION_CODE); oauth.client(RESOURCE_SERVER_CLIENT_ID, RESOURCE_SERVER_CLIENT_PASSWORD); String introspectResponse = oauth.doIntrospectionAccessTokenRequest(accessToken);