Polishing token exchange with offline tokens (#37708)

closes #37116

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
Marek Posolda 2025-02-28 13:01:38 +01:00 committed by GitHub
parent adaad50fef
commit cc4a413db0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 158 additions and 56 deletions

View File

@ -206,77 +206,96 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider
RootAuthenticationSessionModel rootAuthSession = new AuthenticationSessionManager(session).createAuthenticationSession(realm, false);
AuthenticationSessionModel authSession = createSessionModel(targetUserSession, rootAuthSession, targetUser, client, scope);
if (targetUserSession == null) {
boolean newUserSessionCreated = false;
if (targetUserSession == null || targetUserSession.isOffline()) {
// if no session is associated with a subject_token, a new session will be created, only persistent if refresh token type requested
// The new session created also when original session was offline (assuming we don't allow offline-access from token exchange)
targetUserSession = new UserSessionManager(session).createUserSession(authSession.getParentSession().getId(), realm, targetUser, targetUser.getUsername(),
clientConnection.getRemoteAddr(), ServiceAccountConstants.CLIENT_AUTH, false, null, null,
requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE)
? UserSessionModel.SessionPersistenceState.PERSISTENT
: UserSessionModel.SessionPersistenceState.TRANSIENT);
if (targetUserSession.getPersistenceState() == UserSessionModel.SessionPersistenceState.PERSISTENT) {
newUserSessionCreated = true;
}
}
boolean newClientSessionCreated = !newUserSessionCreated && targetUserSession.getAuthenticatedClientSessionByClient(client.getId()) == null;
event.session(targetUserSession);
ClientSessionContext clientSessionCtx = TokenManager.attachAuthenticationSession(this.session, targetUserSession, authSession);
try {
ClientSessionContext clientSessionCtx = TokenManager.attachAuthenticationSession(this.session, targetUserSession, authSession);
if (requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE)
&& clientSessionCtx.getClientScopesStream().filter(s -> OAuth2Constants.OFFLINE_ACCESS.equals(s.getName())).findAny().isPresent()) {
event.detail(Details.REASON, "Scope offline_access not allowed for token exchange");
event.error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
"Scope offline_access not allowed for token exchange", Response.Status.BAD_REQUEST);
}
updateUserSessionFromClientAuth(targetUserSession);
if (params.getAudience() != null && !targetAudienceClients.isEmpty()) {
clientSessionCtx.setAttribute(Constants.REQUESTED_AUDIENCE_CLIENTS, targetAudienceClients.toArray(ClientModel[]::new));
}
validateConsents(targetUser, clientSessionCtx);
clientSessionCtx.setAttribute(Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE);
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, client, event, this.session, targetUserSession, clientSessionCtx)
.generateAccessToken();
checkRequestedAudiences(responseBuilder);
if (targetUserSession.getPersistenceState() == UserSessionModel.SessionPersistenceState.TRANSIENT) {
responseBuilder.getAccessToken().setSessionId(null);
}
if (OAuth2Constants.REFRESH_TOKEN_TYPE.equals(requestedTokenType)) {
responseBuilder.generateRefreshToken();
}
AccessTokenResponse res;
if (OAuth2Constants.ID_TOKEN_TYPE.equals(requestedTokenType)) {
// Using the id-token inside "access_token" parameter as per description of "access_token" parameter under https://datatracker.ietf.org/doc/html/rfc8693#name-successful-response
res = responseBuilder.generateIDToken().build();
res.setToken(res.getIdToken());
res.setIdToken(null);
res.setTokenType(TokenUtil.TOKEN_TYPE_NA);
} else {
String scopeParam = params.getScope();
if (TokenUtil.isOIDCRequest(scopeParam)) {
responseBuilder.generateIDToken().generateAccessTokenHash();
if (requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE)
&& clientSessionCtx.getClientScopesStream().filter(s -> OAuth2Constants.OFFLINE_ACCESS.equals(s.getName())).findAny().isPresent()) {
event.detail(Details.REASON, "Scope offline_access not allowed for token exchange");
event.error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
"Scope offline_access not allowed for token exchange", Response.Status.BAD_REQUEST);
}
res = responseBuilder.build();
}
res.setOtherClaims(OAuth2Constants.ISSUED_TOKEN_TYPE, requestedTokenType);
updateUserSessionFromClientAuth(targetUserSession);
if (responseBuilder.getAccessToken().getAudience() != null) {
StringJoiner joiner = new StringJoiner(" ");
for (String s : List.of(responseBuilder.getAccessToken().getAudience())) {
joiner.add(s);
if (params.getAudience() != null && !targetAudienceClients.isEmpty()) {
clientSessionCtx.setAttribute(Constants.REQUESTED_AUDIENCE_CLIENTS, targetAudienceClients.toArray(ClientModel[]::new));
}
event.detail(Details.AUDIENCE, joiner.toString());
}
event.user(targetUser);
event.success();
return cors.add(Response.ok(res, MediaType.APPLICATION_JSON_TYPE));
validateConsents(targetUser, clientSessionCtx);
clientSessionCtx.setAttribute(Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE);
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, client, event, this.session, targetUserSession, clientSessionCtx)
.generateAccessToken();
checkRequestedAudiences(responseBuilder);
if (targetUserSession.getPersistenceState() == UserSessionModel.SessionPersistenceState.TRANSIENT) {
responseBuilder.getAccessToken().setSessionId(null);
}
if (OAuth2Constants.REFRESH_TOKEN_TYPE.equals(requestedTokenType)) {
responseBuilder.generateRefreshToken();
}
AccessTokenResponse res;
if (OAuth2Constants.ID_TOKEN_TYPE.equals(requestedTokenType)) {
// Using the id-token inside "access_token" parameter as per description of "access_token" parameter under https://datatracker.ietf.org/doc/html/rfc8693#name-successful-response
res = responseBuilder.generateIDToken().build();
res.setToken(res.getIdToken());
res.setIdToken(null);
res.setTokenType(TokenUtil.TOKEN_TYPE_NA);
} else {
String scopeParam = params.getScope();
if (TokenUtil.isOIDCRequest(scopeParam)) {
responseBuilder.generateIDToken().generateAccessTokenHash();
}
res = responseBuilder.build();
}
res.setOtherClaims(OAuth2Constants.ISSUED_TOKEN_TYPE, requestedTokenType);
if (responseBuilder.getAccessToken().getAudience() != null) {
StringJoiner joiner = new StringJoiner(" ");
for (String s : List.of(responseBuilder.getAccessToken().getAudience())) {
joiner.add(s);
}
event.detail(Details.AUDIENCE, joiner.toString());
}
event.user(targetUser);
event.success();
return cors.add(Response.ok(res, MediaType.APPLICATION_JSON_TYPE));
} catch (RuntimeException e) {
// Cleanup client-session if created in this request
if (newClientSessionCreated) {
targetUserSession.removeAuthenticatedClientSessions(Set.of(client.getId()));
}
// Cleanup user-session if created in this request
if (newUserSessionCreated) {
session.sessions().removeUserSession(realm, targetUserSession);
}
throw e;
}
}
@Override

View File

@ -22,6 +22,7 @@ package org.keycloak.testsuite.oauth.tokenexchange;
import jakarta.ws.rs.core.Response;
import org.hamcrest.MatcherAssert;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
@ -30,6 +31,7 @@ import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.common.Profile;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.encode.AccessTokenContext;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.idm.RealmRepresentation;
@ -55,6 +57,7 @@ import java.util.Map;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson;
@ -89,9 +92,13 @@ public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest {
}
private String resourceOwnerLogin(String username, String password, String clientId, String secret) throws Exception {
return resourceOwnerLogin(username, password, clientId, secret, null);
}
private String resourceOwnerLogin(String username, String password, String clientId, String secret, String scope) throws Exception {
oauth.realm(TEST);
oauth.client(clientId, secret);
oauth.scope(null);
oauth.scope(scope);
oauth.openid(false);
AccessTokenResponse response = oauth.doPasswordGrantRequest(username, password);
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
@ -551,11 +558,87 @@ public class StandardTokenExchangeV2Test extends AbstractClientPoliciesTest {
.setAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, Boolean.TRUE.toString())
.update()) {
String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret");
String sessionId = TokenVerifier.create(accessToken, AccessToken.class).parse().getToken().getSessionId();
Assert.assertEquals(testingClient.testing(TEST).getClientSessionsCountInUserSession(TEST, sessionId), Integer.valueOf(1));
oauth.scope("offline_access");
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), Collections.singletonMap(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE));
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertEquals("Scope offline_access not allowed for token exchange", response.getErrorDescription());
// Check that client session was not created
Assert.assertEquals(testingClient.testing(TEST).getClientSessionsCountInUserSession(TEST, sessionId), Integer.valueOf(1));
}
}
// Issue 37116
@Test
public void testOfflineAccessLoginWithRegularTokenExchange() throws Exception {
try (ClientAttributeUpdater clientUpdater1 = ClientAttributeUpdater.forClient(adminClient, TEST, "requester-client")
.setAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, Boolean.TRUE.toString())
.update();
ClientAttributeUpdater clientUpdater2 = ClientAttributeUpdater.forClient(adminClient, TEST, "subject-client")
.setOptionalClientScopes(List.of(OAuth2Constants.OFFLINE_ACCESS))
.update();
) {
// Login with "scope=offline_access" . Will create offline user-session
String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret", OAuth2Constants.OFFLINE_ACCESS);
TokenVerifier<AccessToken> verifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken originalToken = verifier.parse().getToken();
AccessTokenContext ctx = getTestingClient().testing().getTokenContext(originalToken.getId());
assertEquals(ctx.getSessionType(), AccessTokenContext.SessionType.OFFLINE);
// Token-exchange without "scope=offline_access". It is allowed and will create new "online" user session (as previous session was offline)
oauth.scope(null);
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), Collections.singletonMap(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE));
verifier = TokenVerifier.create(response.getAccessToken(), AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
assertNotEquals(originalToken.getSessionId(), exchangedToken.getSessionId());
ctx = getTestingClient().testing().getTokenContext(exchangedToken.getId());
assertEquals(ctx.getSessionType(), AccessTokenContext.SessionType.ONLINE);
// Refresh with the exchanged token - should be successful
oauth.client("requester-client", "secret");
response = oauth.doRefreshTokenRequest(response.getRefreshToken());
assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1"));
assertEquals(getSessionIdFromToken(response.getAccessToken()), exchangedToken.getSessionId());
}
}
@Test
public void testOfflineAccessNotAllowedAfterOfflineAccessLogin() throws Exception {
try (ClientAttributeUpdater clientUpdater1 = ClientAttributeUpdater.forClient(adminClient, TEST, "requester-client")
.setAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, Boolean.TRUE.toString())
.update();
ClientAttributeUpdater clientUpdater2 = ClientAttributeUpdater.forClient(adminClient, TEST, "subject-client")
.setOptionalClientScopes(List.of(OAuth2Constants.OFFLINE_ACCESS))
.update();
) {
// Login with "scope=offline_access" . Will create offline user-session
String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret", OAuth2Constants.OFFLINE_ACCESS);
TokenVerifier<AccessToken> verifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken originalToken = verifier.parse().getToken();
// Doublecheck count of sessions
String subjectClientUuid = ApiUtil.findClientByClientId(adminClient.realm(TEST), "subject-client").toRepresentation().getId();
String requesterClientUuid = ApiUtil.findClientByClientId(adminClient.realm(TEST), "requester-client").toRepresentation().getId();
UserResource user = ApiUtil.findUserByUsernameId(adminClient.realm(TEST), "mike");
Assert.assertEquals(user.getUserSessions().size(), 0);
Assert.assertEquals(user.getOfflineSessions(subjectClientUuid).size(), 1);
Assert.assertEquals(user.getOfflineSessions(requesterClientUuid).size(), 0);
// Token exchange with scope=offline-access should not be allowed
oauth.scope("offline_access");
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), Collections.singletonMap(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE));
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
// Make sure not new user sessions persisted
Assert.assertEquals(user.getUserSessions().size(), 0);
Assert.assertEquals(user.getOfflineSessions(subjectClientUuid).size(), 1);
Assert.assertEquals(user.getOfflineSessions(requesterClientUuid).size(), 0);
}
}