Polishing support for id-token in standard token exchange

closes #37113

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
mposolda 2025-02-19 12:03:40 +01:00 committed by Marek Posolda
parent 892397333f
commit f03f511844
5 changed files with 99 additions and 13 deletions

View File

@ -42,6 +42,9 @@ public class TokenUtil {
public static final String TOKEN_TYPE_DPOP = "DPoP";
// Mentioned in the token-exchange specification https://datatracker.ietf.org/doc/html/rfc8693#name-successful-response
public static final String TOKEN_TYPE_NA = "N_A";
// JWT Access Token types from https://datatracker.ietf.org/doc/html/rfc9068#section-2.1
public static final String TOKEN_TYPE_JWT_ACCESS_TOKEN = "at+jwt";
public static final String TOKEN_TYPE_JWT_ACCESS_TOKEN_PREFIXED = "application/" + TOKEN_TYPE_JWT_ACCESS_TOKEN;

View File

@ -294,12 +294,10 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv
try {
setClientToContext(targetAudienceClients);
switch (requestedTokenType) {
case OAuth2Constants.ACCESS_TOKEN_TYPE:
case OAuth2Constants.REFRESH_TOKEN_TYPE:
return exchangeClientToOIDCClient(targetUser, targetUserSession, requestedTokenType, targetAudienceClients, scope);
case OAuth2Constants.SAML2_TOKEN_TYPE:
return exchangeClientToSAML2Client(targetUser, targetUserSession, requestedTokenType, targetAudienceClients);
if (getSupportedOAuthResponseTokenTypes().contains(requestedTokenType))
return exchangeClientToOIDCClient(targetUser, targetUserSession, requestedTokenType, targetAudienceClients, scope);
else if (OAuth2Constants.SAML2_TOKEN_TYPE.equals(requestedTokenType)) {
return exchangeClientToSAML2Client(targetUser, targetUserSession, requestedTokenType, targetAudienceClients);
}
} finally {
session.getContext().setClient(client);
@ -324,6 +322,10 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv
}
}
protected List<String> getSupportedOAuthResponseTokenTypes() {
return Arrays.asList(OAuth2Constants.ACCESS_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE);
}
protected AuthenticationSessionModel createSessionModel(UserSessionModel targetUserSession, RootAuthenticationSessionModel rootAuthSession, UserModel targetUser, ClientModel client, String scope) {
AuthenticationSessionModel authSession = rootAuthSession.createAuthenticationSession(client);
authSession.setAuthenticatedUser(targetUser);

View File

@ -21,6 +21,8 @@ package org.keycloak.protocol.oidc.tokenexchange;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@ -126,6 +128,7 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider
return exchangeClientToClient(tokenUser, tokenSession, token, true);
}
@Override
protected void validateAudience(AccessToken token, boolean disallowOnHolderOfTokenMismatch, List<ClientModel> targetAudienceClients) {
ClientModel tokenHolder = token == null ? null : realm.getClientByClientId(token.getIssuedFor());
@ -165,11 +168,13 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider
return scope;
}
@Override
protected void setClientToContext(List<ClientModel> targetAudienceClients) {
// The client requesting exchange is set in the context
session.getContext().setClient(client);
}
@Override
protected Response exchangeClientToOIDCClient(UserModel targetUser, UserSessionModel targetUserSession, String requestedTokenType,
List<ClientModel> targetAudienceClients, String scope) {
RootAuthenticationSessionModel rootAuthSession = new AuthenticationSessionManager(session).createAuthenticationSession(realm, false);
@ -201,7 +206,9 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider
}
String issuedTokenType;
if (requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE)
if (requestedTokenType.equals(OAuth2Constants.ID_TOKEN_TYPE)) {
issuedTokenType = OAuth2Constants.ID_TOKEN_TYPE;
} else if (requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE)
&& OIDCAdvancedConfigWrapper.fromClientModel(client).isUseRefreshToken()
&& targetUserSession.getPersistenceState() != UserSessionModel.SessionPersistenceState.TRANSIENT) {
responseBuilder.generateRefreshToken();
@ -210,12 +217,21 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider
issuedTokenType = OAuth2Constants.ACCESS_TOKEN_TYPE;
}
String scopeParam = clientSessionCtx.getClientSession().getNote(OAuth2Constants.SCOPE);
if (TokenUtil.isOIDCRequest(scopeParam)) {
responseBuilder.generateIDToken().generateAccessTokenHash();
AccessTokenResponse res;
if (OAuth2Constants.ID_TOKEN_TYPE.equals(issuedTokenType)) {
// 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();
}
AccessTokenResponse res = responseBuilder.build();
res.setOtherClaims(OAuth2Constants.ISSUED_TOKEN_TYPE, issuedTokenType);
if (responseBuilder.getAccessToken().getAudience() != null) {
@ -247,4 +263,25 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider
}
}
}
@Override
protected List<String> getSupportedOAuthResponseTokenTypes() {
return Arrays.asList(OAuth2Constants.ACCESS_TOKEN_TYPE, OAuth2Constants.ID_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE);
}
@Override
protected String getRequestedTokenType() {
String requestedTokenType = params.getRequestedTokenType();
if (requestedTokenType == null) {
requestedTokenType = OAuth2Constants.REFRESH_TOKEN_TYPE; // TODO: Refresh token should not be the default one and should be supported just if enabled by the switch
} else if (!requestedTokenType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE) &&
!requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE) &&
!requestedTokenType.equals(OAuth2Constants.ID_TOKEN_TYPE) &&
!requestedTokenType.equals(OAuth2Constants.SAML2_TOKEN_TYPE)) { // TODO: SAML probably won't be supported?
event.detail(Details.REASON, "requested_token_type unsupported");
event.error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "requested_token_type unsupported", Response.Status.BAD_REQUEST);
}
return requestedTokenType;
}
}

View File

@ -30,6 +30,7 @@ import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.common.Profile;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractKeycloakTest;
@ -39,6 +40,7 @@ import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.UncaughtServerErrorExpected;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.util.TokenUtil;
import java.util.Collections;
import java.util.List;
@ -122,6 +124,49 @@ public class StandardTokenExchangeV2Test extends AbstractKeycloakTest {
assertEquals("requester-client", exchangedToken.getIssuedFor());
}
@Test
public void testExchangeForIdToken() throws Exception {
oauth.realm(TEST);
String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret");
// Exchange request with "scope=oidc" . ID Token should be issued in addition to access-token
oauth.openid(true);
oauth.scope(OAuth2Constants.SCOPE_OPENID);
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, Map.of(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE));
assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
AccessToken exchangedToken = TokenVerifier.create(response.getAccessToken(), AccessToken.class)
.parse().getToken();
assertEquals(TokenUtil.TOKEN_TYPE_BEARER, exchangedToken.getType());
Assert.assertNotNull("ID Token is null, but was expected to be present", response.getIdToken());
IDToken exchangedIdToken = TokenVerifier.create(response.getIdToken(), IDToken.class)
.parse().getToken();
assertEquals(TokenUtil.TOKEN_TYPE_ID, exchangedIdToken.getType());
assertEquals(getSessionIdFromToken(accessToken), exchangedIdToken.getSessionId());
assertEquals("requester-client", exchangedIdToken.getIssuedFor());
// Exchange request without "scope=oidc" . Only access-token should be issued, but not ID Token
oauth.openid(false);
oauth.scope(null);
response = tokenExchange(accessToken, "requester-client", "secret", null, Map.of(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE));
assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
Assert.assertNotNull(response.getAccessToken());
Assert.assertNull("ID Token was present, but should not be present", response.getIdToken());
// Exchange request requesting id-token. ID Token should be issued inside "access_token" parameter (as per token-exchange specification https://datatracker.ietf.org/doc/html/rfc8693#name-successful-response - parameter "access_token")
response = tokenExchange(accessToken, "requester-client", "secret", null, Map.of(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.ID_TOKEN_TYPE));
assertEquals(OAuth2Constants.ID_TOKEN_TYPE, response.getIssuedTokenType());
assertEquals(TokenUtil.TOKEN_TYPE_NA, response.getTokenType());
Assert.assertNotNull(response.getAccessToken());
Assert.assertNull("ID Token was present, but should not be present", response.getIdToken());
exchangedIdToken = TokenVerifier.create(response.getAccessToken(), IDToken.class)
.parse().getToken();
assertEquals(TokenUtil.TOKEN_TYPE_ID, exchangedIdToken.getType());
assertEquals(getSessionIdFromToken(accessToken), exchangedIdToken.getSessionId());
assertEquals("requester-client", exchangedIdToken.getIssuedFor());
}
@Test
@UncaughtServerErrorExpected
public void testExchangeUsingServiceAccount() throws Exception {
@ -239,7 +284,7 @@ public class StandardTokenExchangeV2Test extends AbstractKeycloakTest {
org.junit.Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
org.junit.Assert.assertEquals(OAuthErrorException.INVALID_CLIENT, response.getError());
org.junit.Assert.assertEquals("Audience not found", response.getErrorDescription());
// The "target-client3" is valid client, but unavailable to the user. Request allowed, but "target-client3" audience will not be available
// The "target-client3" is valid client, but audience unavailable to the user. Request not allowed
response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1", "target-client3"), null);
org.junit.Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
org.junit.Assert.assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());

View File

@ -2190,7 +2190,6 @@
"xRobotsTag" : "none",
"xFrameOptions" : "SAMEORIGIN",
"contentSecurityPolicy" : "frame-src 'self'; frame-ancestors 'self'; object-src 'none';",
"xXSSProtection" : "1; mode=block",
"strictTransportSecurity" : "max-age=31536000; includeSubDomains"
},
"smtpServer" : { },