Skip to content

Commit f03f511

Browse files
committed
Polishing support for id-token in standard token exchange
closes #37113 Signed-off-by: mposolda <[email protected]>
1 parent 8923973 commit f03f511

File tree

5 files changed

+99
-13
lines changed

5 files changed

+99
-13
lines changed

core/src/main/java/org/keycloak/util/TokenUtil.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ public class TokenUtil {
4242

4343
public static final String TOKEN_TYPE_DPOP = "DPoP";
4444

45+
// Mentioned in the token-exchange specification https://datatracker.ietf.org/doc/html/rfc8693#name-successful-response
46+
public static final String TOKEN_TYPE_NA = "N_A";
47+
4548
// JWT Access Token types from https://datatracker.ietf.org/doc/html/rfc9068#section-2.1
4649
public static final String TOKEN_TYPE_JWT_ACCESS_TOKEN = "at+jwt";
4750
public static final String TOKEN_TYPE_JWT_ACCESS_TOKEN_PREFIXED = "application/" + TOKEN_TYPE_JWT_ACCESS_TOKEN;

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/AbstractTokenExchangeProvider.java

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -294,12 +294,10 @@ protected Response exchangeClientToClient(UserModel targetUser, UserSessionModel
294294

295295
try {
296296
setClientToContext(targetAudienceClients);
297-
switch (requestedTokenType) {
298-
case OAuth2Constants.ACCESS_TOKEN_TYPE:
299-
case OAuth2Constants.REFRESH_TOKEN_TYPE:
300-
return exchangeClientToOIDCClient(targetUser, targetUserSession, requestedTokenType, targetAudienceClients, scope);
301-
case OAuth2Constants.SAML2_TOKEN_TYPE:
302-
return exchangeClientToSAML2Client(targetUser, targetUserSession, requestedTokenType, targetAudienceClients);
297+
if (getSupportedOAuthResponseTokenTypes().contains(requestedTokenType))
298+
return exchangeClientToOIDCClient(targetUser, targetUserSession, requestedTokenType, targetAudienceClients, scope);
299+
else if (OAuth2Constants.SAML2_TOKEN_TYPE.equals(requestedTokenType)) {
300+
return exchangeClientToSAML2Client(targetUser, targetUserSession, requestedTokenType, targetAudienceClients);
303301
}
304302
} finally {
305303
session.getContext().setClient(client);
@@ -324,6 +322,10 @@ protected void forbiddenIfClientIsNotTokenHolder(boolean disallowOnHolderOfToken
324322
}
325323
}
326324

325+
protected List<String> getSupportedOAuthResponseTokenTypes() {
326+
return Arrays.asList(OAuth2Constants.ACCESS_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE);
327+
}
328+
327329
protected AuthenticationSessionModel createSessionModel(UserSessionModel targetUserSession, RootAuthenticationSessionModel rootAuthSession, UserModel targetUser, ClientModel client, String scope) {
328330
AuthenticationSessionModel authSession = rootAuthSession.createAuthenticationSession(client);
329331
authSession.setAuthenticatedUser(targetUser);

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121

2222
import jakarta.ws.rs.core.MediaType;
2323
import jakarta.ws.rs.core.Response;
24+
25+
import java.util.Arrays;
2426
import java.util.HashSet;
2527
import java.util.List;
2628
import java.util.Set;
@@ -126,6 +128,7 @@ protected Response tokenExchange() {
126128
return exchangeClientToClient(tokenUser, tokenSession, token, true);
127129
}
128130

131+
@Override
129132
protected void validateAudience(AccessToken token, boolean disallowOnHolderOfTokenMismatch, List<ClientModel> targetAudienceClients) {
130133
ClientModel tokenHolder = token == null ? null : realm.getClientByClientId(token.getIssuedFor());
131134

@@ -165,11 +168,13 @@ protected String getRequestedScope(AccessToken token, List<ClientModel> targetAu
165168
return scope;
166169
}
167170

171+
@Override
168172
protected void setClientToContext(List<ClientModel> targetAudienceClients) {
169173
// The client requesting exchange is set in the context
170174
session.getContext().setClient(client);
171175
}
172176

177+
@Override
173178
protected Response exchangeClientToOIDCClient(UserModel targetUser, UserSessionModel targetUserSession, String requestedTokenType,
174179
List<ClientModel> targetAudienceClients, String scope) {
175180
RootAuthenticationSessionModel rootAuthSession = new AuthenticationSessionManager(session).createAuthenticationSession(realm, false);
@@ -201,7 +206,9 @@ protected Response exchangeClientToOIDCClient(UserModel targetUser, UserSessionM
201206
}
202207

203208
String issuedTokenType;
204-
if (requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE)
209+
if (requestedTokenType.equals(OAuth2Constants.ID_TOKEN_TYPE)) {
210+
issuedTokenType = OAuth2Constants.ID_TOKEN_TYPE;
211+
} else if (requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE)
205212
&& OIDCAdvancedConfigWrapper.fromClientModel(client).isUseRefreshToken()
206213
&& targetUserSession.getPersistenceState() != UserSessionModel.SessionPersistenceState.TRANSIENT) {
207214
responseBuilder.generateRefreshToken();
@@ -210,12 +217,21 @@ protected Response exchangeClientToOIDCClient(UserModel targetUser, UserSessionM
210217
issuedTokenType = OAuth2Constants.ACCESS_TOKEN_TYPE;
211218
}
212219

213-
String scopeParam = clientSessionCtx.getClientSession().getNote(OAuth2Constants.SCOPE);
214-
if (TokenUtil.isOIDCRequest(scopeParam)) {
215-
responseBuilder.generateIDToken().generateAccessTokenHash();
220+
AccessTokenResponse res;
221+
if (OAuth2Constants.ID_TOKEN_TYPE.equals(issuedTokenType)) {
222+
// 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
223+
res = responseBuilder.generateIDToken().build();
224+
res.setToken(res.getIdToken());
225+
res.setIdToken(null);
226+
res.setTokenType(TokenUtil.TOKEN_TYPE_NA);
227+
} else {
228+
String scopeParam = params.getScope();
229+
if (TokenUtil.isOIDCRequest(scopeParam)) {
230+
responseBuilder.generateIDToken().generateAccessTokenHash();
231+
}
232+
res = responseBuilder.build();
216233
}
217234

218-
AccessTokenResponse res = responseBuilder.build();
219235
res.setOtherClaims(OAuth2Constants.ISSUED_TOKEN_TYPE, issuedTokenType);
220236

221237
if (responseBuilder.getAccessToken().getAudience() != null) {
@@ -247,4 +263,25 @@ protected void checkRequestedAudiences(TokenManager.AccessTokenResponseBuilder r
247263
}
248264
}
249265
}
266+
267+
@Override
268+
protected List<String> getSupportedOAuthResponseTokenTypes() {
269+
return Arrays.asList(OAuth2Constants.ACCESS_TOKEN_TYPE, OAuth2Constants.ID_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE);
270+
}
271+
272+
@Override
273+
protected String getRequestedTokenType() {
274+
String requestedTokenType = params.getRequestedTokenType();
275+
if (requestedTokenType == null) {
276+
requestedTokenType = OAuth2Constants.REFRESH_TOKEN_TYPE; // TODO: Refresh token should not be the default one and should be supported just if enabled by the switch
277+
} else if (!requestedTokenType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE) &&
278+
!requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE) &&
279+
!requestedTokenType.equals(OAuth2Constants.ID_TOKEN_TYPE) &&
280+
!requestedTokenType.equals(OAuth2Constants.SAML2_TOKEN_TYPE)) { // TODO: SAML probably won't be supported?
281+
event.detail(Details.REASON, "requested_token_type unsupported");
282+
event.error(Errors.INVALID_REQUEST);
283+
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "requested_token_type unsupported", Response.Status.BAD_REQUEST);
284+
}
285+
return requestedTokenType;
286+
}
250287
}

testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import org.keycloak.common.Profile;
3131
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
3232
import org.keycloak.representations.AccessToken;
33+
import org.keycloak.representations.IDToken;
3334
import org.keycloak.representations.idm.ClientRepresentation;
3435
import org.keycloak.representations.idm.RealmRepresentation;
3536
import org.keycloak.testsuite.AbstractKeycloakTest;
@@ -39,6 +40,7 @@
3940
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
4041
import org.keycloak.testsuite.arquillian.annotation.UncaughtServerErrorExpected;
4142
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
43+
import org.keycloak.util.TokenUtil;
4244

4345
import java.util.Collections;
4446
import java.util.List;
@@ -122,6 +124,49 @@ public void testExchangeRequestAccessTokenType() throws Exception {
122124
assertEquals("requester-client", exchangedToken.getIssuedFor());
123125
}
124126

127+
@Test
128+
public void testExchangeForIdToken() throws Exception {
129+
oauth.realm(TEST);
130+
String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret");
131+
132+
// Exchange request with "scope=oidc" . ID Token should be issued in addition to access-token
133+
oauth.openid(true);
134+
oauth.scope(OAuth2Constants.SCOPE_OPENID);
135+
AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, Map.of(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE));
136+
assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
137+
AccessToken exchangedToken = TokenVerifier.create(response.getAccessToken(), AccessToken.class)
138+
.parse().getToken();
139+
assertEquals(TokenUtil.TOKEN_TYPE_BEARER, exchangedToken.getType());
140+
141+
Assert.assertNotNull("ID Token is null, but was expected to be present", response.getIdToken());
142+
IDToken exchangedIdToken = TokenVerifier.create(response.getIdToken(), IDToken.class)
143+
.parse().getToken();
144+
assertEquals(TokenUtil.TOKEN_TYPE_ID, exchangedIdToken.getType());
145+
assertEquals(getSessionIdFromToken(accessToken), exchangedIdToken.getSessionId());
146+
assertEquals("requester-client", exchangedIdToken.getIssuedFor());
147+
148+
// Exchange request without "scope=oidc" . Only access-token should be issued, but not ID Token
149+
oauth.openid(false);
150+
oauth.scope(null);
151+
response = tokenExchange(accessToken, "requester-client", "secret", null, Map.of(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE));
152+
assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
153+
Assert.assertNotNull(response.getAccessToken());
154+
Assert.assertNull("ID Token was present, but should not be present", response.getIdToken());
155+
156+
// 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")
157+
response = tokenExchange(accessToken, "requester-client", "secret", null, Map.of(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.ID_TOKEN_TYPE));
158+
assertEquals(OAuth2Constants.ID_TOKEN_TYPE, response.getIssuedTokenType());
159+
assertEquals(TokenUtil.TOKEN_TYPE_NA, response.getTokenType());
160+
Assert.assertNotNull(response.getAccessToken());
161+
Assert.assertNull("ID Token was present, but should not be present", response.getIdToken());
162+
163+
exchangedIdToken = TokenVerifier.create(response.getAccessToken(), IDToken.class)
164+
.parse().getToken();
165+
assertEquals(TokenUtil.TOKEN_TYPE_ID, exchangedIdToken.getType());
166+
assertEquals(getSessionIdFromToken(accessToken), exchangedIdToken.getSessionId());
167+
assertEquals("requester-client", exchangedIdToken.getIssuedFor());
168+
}
169+
125170
@Test
126171
@UncaughtServerErrorExpected
127172
public void testExchangeUsingServiceAccount() throws Exception {
@@ -239,7 +284,7 @@ public void testUnavailableAudienceRequested() throws Exception {
239284
org.junit.Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
240285
org.junit.Assert.assertEquals(OAuthErrorException.INVALID_CLIENT, response.getError());
241286
org.junit.Assert.assertEquals("Audience not found", response.getErrorDescription());
242-
// The "target-client3" is valid client, but unavailable to the user. Request allowed, but "target-client3" audience will not be available
287+
// The "target-client3" is valid client, but audience unavailable to the user. Request not allowed
243288
response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1", "target-client3"), null);
244289
org.junit.Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
245290
org.junit.Assert.assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());

testsuite/integration-arquillian/tests/base/src/test/resources/token-exchange/testrealm-token-exchange-v2.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2190,7 +2190,6 @@
21902190
"xRobotsTag" : "none",
21912191
"xFrameOptions" : "SAMEORIGIN",
21922192
"contentSecurityPolicy" : "frame-src 'self'; frame-ancestors 'self'; object-src 'none';",
2193-
"xXSSProtection" : "1; mode=block",
21942193
"strictTransportSecurity" : "max-age=31536000; includeSubDomains"
21952194
},
21962195
"smtpServer" : { },

0 commit comments

Comments
 (0)