Session type incorrectly set in access-token context when token created with scope=offline_access (#37701)

closes #37694

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
Marek Posolda 2025-02-27 15:53:23 +01:00 committed by GitHub
parent f7e21af82e
commit 92c96033f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 95 additions and 12 deletions

View file

@ -40,6 +40,11 @@ public interface ClientSessionContext {
*/
Stream<ClientScopeModel> 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}.

View file

@ -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()) {

View file

@ -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);
}
}

View file

@ -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);

View file

@ -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

View file

@ -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<RoleModel> getRolesStream() {

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -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 {

View file

@ -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);
}
}

View file

@ -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<EventType> events);
@GET
@Path("/token-context")
@Produces(MediaType.APPLICATION_JSON)
AccessTokenContext getTokenContext(@QueryParam("tokenId") String tokenId);
}

View file

@ -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();

View file

@ -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)

View file

@ -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);