mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-10 15:32:05 -03:30
Polishing support for id-token in standard token exchange
closes #37113 Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
parent
892397333f
commit
f03f511844
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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" : { },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user