From e52cf2f0949c66e5c8739f28875c897e40c94dcc Mon Sep 17 00:00:00 2001 From: Giuseppe Graziano Date: Mon, 3 Mar 2025 18:52:10 +0100 Subject: [PATCH 1/2] Support revocation for standard token exchange Closes #37120 Signed-off-by: Giuseppe Graziano --- .../java/org/keycloak/events/Details.java | 1 + .../java/org/keycloak/models/Constants.java | 3 +- .../endpoints/TokenRevocationEndpoint.java | 31 ++ .../AbstractTokenExchangeProvider.java | 4 +- .../StandardTokenExchangeProvider.java | 29 +- .../StandardTokenExchangeV2Test.java | 321 +++++++++++++----- ...ExchangeV2WithLegacyTokenExchangeTest.java | 2 +- .../testrealm-token-exchange-v2.json | 43 +++ 8 files changed, 342 insertions(+), 92 deletions(-) diff --git a/server-spi-private/src/main/java/org/keycloak/events/Details.java b/server-spi-private/src/main/java/org/keycloak/events/Details.java index 3fea2f7f7e97..f4686be839e5 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/Details.java +++ b/server-spi-private/src/main/java/org/keycloak/events/Details.java @@ -60,6 +60,7 @@ public interface Details { String REASON = "reason"; String GRANTED_CLIENT = "granted_client"; String REVOKED_CLIENT = "revoked_client"; + String TOKEN_EXCHANGE_REVOKED_CLIENTS = "token_exchange_revoked_clients"; String AUDIENCE = "audience"; String PERMISSION = "permission"; String SCOPE = "scope"; diff --git a/server-spi-private/src/main/java/org/keycloak/models/Constants.java b/server-spi-private/src/main/java/org/keycloak/models/Constants.java index 8a904db2a533..38ddf18d4099 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/Constants.java +++ b/server-spi-private/src/main/java/org/keycloak/models/Constants.java @@ -205,5 +205,6 @@ public final class Constants { public static final String REQUESTED_AUDIENCE = "req-aud"; // Note in clientSessionContext specifying token grant type used public static final String GRANT_TYPE = OAuth2Constants.GRANT_TYPE; - + // Note in client session to know the subject client + public static final String TOKEN_EXCHANGE_SUBJECT_CLIENT = "token_exchange_subject_client"; } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenRevocationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenRevocationEndpoint.java index e01912d3408c..00088f1c9e7d 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenRevocationEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenRevocationEndpoint.java @@ -17,7 +17,10 @@ package org.keycloak.protocol.oidc.endpoints; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; +import java.util.Map; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.OPTIONS; @@ -37,6 +40,7 @@ import org.keycloak.http.HttpRequest; import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; +import org.keycloak.models.Constants; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.SingleUseObjectProvider; @@ -256,6 +260,9 @@ private void revokeClientSession() { AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId()); if (clientSession != null) { TokenManager.dettachClientSession(clientSession); + + revokeTokenExchangeSession(userSession); + // TODO: Might need optimization to prevent loading client sessions from cache in getAuthenticatedClientSessions() if (userSession.getAuthenticatedClientSessions().isEmpty()) { session.sessions().removeUserSession(realm, userSession); @@ -269,5 +276,29 @@ private void revokeAccessToken() { int currentTime = Time.currentTime(); long lifespanInSecs = Math.max(token.getExp() - currentTime + 1, 10); singleUseStore.put(token.getId() + SingleUseObjectProvider.REVOKED_KEY, lifespanInSecs, Collections.emptyMap()); + revokeTokenExchangeSession(); + } + + private void revokeTokenExchangeSession() { + if (token.getSessionId() != null) { + UserSessionModel userSession = session.sessions().getUserSession(realm, token.getSessionId()); + if (userSession != null) { + revokeTokenExchangeSession(userSession); + } + } + } + + private void revokeTokenExchangeSession(UserSessionModel userSession) { + Map clientSessionModelMap = userSession.getAuthenticatedClientSessions(); + List revokedClients = new ArrayList<>(); + clientSessionModelMap.forEach((key, clientSessionModel) -> { + if (clientSessionModel.getNote(Constants.TOKEN_EXCHANGE_SUBJECT_CLIENT + token.getIssuedFor()) != null) { + revokedClients.add(clientSessionModel.getClient().getClientId()); + TokenManager.dettachClientSession(clientSessionModel); + } + }); + if (!revokedClients.isEmpty()) { + event.detail(Details.TOKEN_EXCHANGE_REVOKED_CLIENTS, String.join(",", revokedClients)); + } } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/AbstractTokenExchangeProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/AbstractTokenExchangeProvider.java index 997082878a69..e368b71274bf 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/AbstractTokenExchangeProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/AbstractTokenExchangeProvider.java @@ -297,7 +297,7 @@ protected Response exchangeClientToClient(UserModel targetUser, UserSessionModel try { setClientToContext(targetAudienceClients); if (getSupportedOAuthResponseTokenTypes().contains(requestedTokenType)) - return exchangeClientToOIDCClient(targetUser, targetUserSession, requestedTokenType, targetAudienceClients, scope); + return exchangeClientToOIDCClient(targetUser, targetUserSession, requestedTokenType, targetAudienceClients, scope, token); else if (OAuth2Constants.SAML2_TOKEN_TYPE.equals(requestedTokenType)) { return exchangeClientToSAML2Client(targetUser, targetUserSession, requestedTokenType, targetAudienceClients); } @@ -383,7 +383,7 @@ protected ClientModel getTargetClient(List targetAudienceClients) { } protected Response exchangeClientToOIDCClient(UserModel targetUser, UserSessionModel targetUserSession, String requestedTokenType, - List targetAudienceClients, String scope) { + List targetAudienceClients, String scope, AccessToken subjectToken) { ClientModel targetClient = getTargetClient(targetAudienceClients); RootAuthenticationSessionModel rootAuthSession = new AuthenticationSessionManager(session).createAuthenticationSession(realm, false); AuthenticationSessionModel authSession = createSessionModel(targetUserSession, rootAuthSession, targetUser, targetClient, scope); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java index 699ff634c80a..23e8e4d52277 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java @@ -29,28 +29,26 @@ import java.util.StringJoiner; import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; -import org.keycloak.common.ClientConnection; import org.keycloak.common.Profile; import org.keycloak.common.constants.ServiceAccountConstants; import org.keycloak.common.util.CollectionUtil; import org.keycloak.events.Details; import org.keycloak.events.Errors; -import org.keycloak.events.EventBuilder; +import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientSessionContext; import org.keycloak.models.Constants; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.TokenExchangeContext; import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.protocol.oidc.encode.AccessTokenContext; +import org.keycloak.protocol.oidc.encode.TokenContextEncoderProvider; import org.keycloak.rar.AuthorizationRequestContext; import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessTokenResponse; import org.keycloak.services.CorsErrorResponseException; -import org.keycloak.services.cors.Cors; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationSessionManager; import org.keycloak.services.managers.UserSessionManager; @@ -206,7 +204,7 @@ protected void setClientToContext(List targetAudienceClients) { @Override protected Response exchangeClientToOIDCClient(UserModel targetUser, UserSessionModel targetUserSession, String requestedTokenType, - List targetAudienceClients, String scope) { + List targetAudienceClients, String scope, AccessToken subjectToken) { RootAuthenticationSessionModel rootAuthSession = new AuthenticationSessionManager(session).createAuthenticationSession(realm, false); AuthenticationSessionModel authSession = createSessionModel(targetUserSession, rootAuthSession, targetUser, client, scope); @@ -248,6 +246,25 @@ protected Response exchangeClientToOIDCClient(UserModel targetUser, UserSessionM validateConsents(targetUser, clientSessionCtx); clientSessionCtx.setAttribute(Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE); + TokenContextEncoderProvider encoder = session.getProvider(TokenContextEncoderProvider.class); + AccessTokenContext subjectTokenContext = encoder.getTokenContextFromTokenId(subjectToken.getId()); + + //copy subject client from the client session notes if the subject token used has already been exchanged + if (OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE.equals(subjectTokenContext.getGrantType())) { + ClientModel subjectClient = session.clients().getClientByClientId(realm, subjectToken.getIssuedFor()); + if (subjectClient != null) { + AuthenticatedClientSessionModel subjectClientSession = targetUserSession.getAuthenticatedClientSessionByClient(subjectClient.getId()); + if (subjectClientSession != null) { + subjectClientSession.getNotes().entrySet().stream() + .filter(note -> note.getKey().startsWith(Constants.TOKEN_EXCHANGE_SUBJECT_CLIENT)) + .forEach(note -> clientSessionCtx.getClientSession().setNote(note.getKey(), note.getValue())); + } + } + } + + //store client id of the subject token + clientSessionCtx.getClientSession().setNote(Constants.TOKEN_EXCHANGE_SUBJECT_CLIENT + subjectToken.getIssuedFor(), subjectToken.getId()); + TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, client, event, session, clientSessionCtx.getClientSession().getUserSession(), clientSessionCtx).generateAccessToken(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java index 7b78969f9839..ee3dcdfe388f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java @@ -41,9 +41,11 @@ import org.keycloak.models.AccountRoles; import org.keycloak.models.AdminRoles; import org.keycloak.models.Constants; +import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.encode.AccessTokenContext; +import org.keycloak.protocol.oidc.mappers.AudienceProtocolMapper; import org.keycloak.representations.AccessToken; import org.keycloak.representations.IDToken; import org.keycloak.representations.idm.ClientScopeRepresentation; @@ -64,6 +66,7 @@ import org.keycloak.testsuite.pages.ConsentPage; import org.keycloak.testsuite.services.clientpolicy.executor.TestRaiseExceptionExecutorFactory; import org.keycloak.testsuite.updaters.ClientAttributeUpdater; +import org.keycloak.testsuite.updaters.ProtocolMappersUpdater; import org.keycloak.testsuite.updaters.RoleScopeUpdater; import org.keycloak.testsuite.updaters.UserAttributeUpdater; import org.keycloak.testsuite.util.ClientPoliciesUtil; @@ -116,15 +119,16 @@ protected String getSessionIdFromToken(String accessToken) throws Exception { .getSessionId(); } - protected String resourceOwnerLogin(String username, String password, String clientId, String secret) throws Exception { + protected AccessTokenResponse 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 { + private AccessTokenResponse resourceOwnerLogin(String username, String password, String clientId, String secret, String scope) throws Exception { oauth.realm(TEST); oauth.client(clientId, secret); oauth.scope(scope); oauth.openid(false); + events.clear(); AccessTokenResponse response = oauth.doPasswordGrantRequest(username, password); assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode()); TokenVerifier accessTokenVerifier = TokenVerifier.create(response.getAccessToken(), AccessToken.class); @@ -135,7 +139,7 @@ private String resourceOwnerLogin(String username, String password, String clien .session(token.getSessionId()) .detail(Details.USERNAME, username) .assertEvent(); - return response.getAccessToken(); + return response; } private String loginWithConsents(UserRepresentation user, String password, String clientId, String secret) throws Exception { @@ -170,7 +174,7 @@ protected AccessTokenResponse tokenExchange(String subjectToken, String clientId @UncaughtServerErrorExpected public void testSubjectTokenType() throws Exception { oauth.realm(TEST); - String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret"); + String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken(); TokenExchangeRequest request = oauth.tokenExchangeRequest(accessToken, OAuth2Constants.ACCESS_TOKEN_TYPE); AccessTokenResponse response = request.send(); @@ -207,7 +211,7 @@ public void testSubjectTokenType() throws Exception { public void testRequestedTokenType() throws Exception { final UserRepresentation john = ApiUtil.findUserByUsername(adminClient.realm(TEST), "john"); oauth.realm(TEST); - String accessToken = resourceOwnerLogin(john.getUsername(), "password", "subject-client", "secret"); + String accessToken = resourceOwnerLogin(john.getUsername(), "password", "subject-client", "secret").getAccessToken(); AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, Map.of(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE)); assertAudiencesAndScopes(response, john, List.of("target-client1"), List.of("default-scope1")); @@ -215,19 +219,23 @@ public void testRequestedTokenType() throws Exception { assertEquals(TokenUtil.TOKEN_TYPE_BEARER, response.getTokenType()); assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType()); - response = tokenExchange(accessToken, "requester-client", "secret", null, Map.of(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE)); - assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode()); - assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError()); - assertEquals("requested_token_type unsupported", response.getErrorDescription()); - events.expect(EventType.TOKEN_EXCHANGE_ERROR) - .client("requester-client") - .error(Errors.INVALID_REQUEST) - .user(john.getId()) - .session(AssertEvents.isUUID()) - .detail(Details.REASON, "requested_token_type unsupported") - .detail(Details.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE) - .detail(Details.SUBJECT_TOKEN_CLIENT_ID, "subject-client") - .assertEvent(); + try (ClientAttributeUpdater clientUpdater = ClientAttributeUpdater.forClient(adminClient, TEST, "requester-client") + .setAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, Boolean.FALSE.toString()) + .update()) { + response = tokenExchange(accessToken, "requester-client", "secret", null, Map.of(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE)); + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode()); + assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError()); + assertEquals("requested_token_type unsupported", response.getErrorDescription()); + events.expect(EventType.TOKEN_EXCHANGE_ERROR) + .client("requester-client") + .error(Errors.INVALID_REQUEST) + .user(john.getId()) + .session(AssertEvents.isUUID()) + .detail(Details.REASON, "requested_token_type unsupported") + .detail(Details.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE) + .detail(Details.SUBJECT_TOKEN_CLIENT_ID, "subject-client") + .assertEvent(); + } try (ClientAttributeUpdater clientUpdater = ClientAttributeUpdater.forClient(adminClient, TEST, "requester-client") .setAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, Boolean.TRUE.toString()) @@ -300,7 +308,7 @@ public void testRequestedTokenType() throws Exception { public void testExchange() throws Exception { final UserRepresentation john = ApiUtil.findUserByUsername(adminClient.realm(TEST), "john"); oauth.realm(TEST); - String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret"); + String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken(); { AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, null); assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType()); @@ -336,7 +344,7 @@ public void testTransientSessionForRequester() throws Exception { final UserRepresentation john = ApiUtil.findUserByUsername(realm, "john"); oauth.realm(TEST); - final String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret"); + final String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken(); oauth.scope(OAuth2Constants.SCOPE_OPENID); // add openid scope for the user-info request AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, null); @@ -377,12 +385,12 @@ public void testTransientSessionWithAdminApi() throws Exception { .clientRoleScope(client.toRepresentation().getId()) .add(ApiUtil.findClientRoleByName(client, AdminRoles.VIEW_REALM).toRepresentation()) .update(); - ClientAttributeUpdater clientUpdater = ClientAttributeUpdater.forClient(adminClient, TEST, "requester-client") - .addOptionalClientScope("realm-management-view-scope") - .update()) { + ClientAttributeUpdater clientUpdater = ClientAttributeUpdater.forClient(adminClient, TEST, "requester-client") + .addOptionalClientScope("realm-management-view-scope") + .update()) { oauth.realm(TEST); - final String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret"); + final String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken(); // token exchange with the realm-management-view optional scope oauth.scope("realm-management-view-scope"); @@ -390,7 +398,7 @@ public void testTransientSessionWithAdminApi() throws Exception { assertAudiencesAndScopes(response, john, List.of(Constants.REALM_MANAGEMENT_CLIENT_ID), List.of("realm-management-view-scope")); final AccessToken exchangedToken = TokenVerifier.create(response.getAccessToken(), AccessToken.class).parse().getToken(); assertAccessTokenContext(exchangedToken.getId(), AccessTokenContext.SessionType.TRANSIENT, - AccessTokenContext.TokenType.REGULAR, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE); + AccessTokenContext.TokenType.REGULAR, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE); try (Keycloak keycloak = Keycloak.getInstance(ServerURLs.getAuthServerContextRoot() + "/auth", TEST, Constants.ADMIN_CLI_CLIENT_ID, response.getAccessToken(), TLSUtils.initializeTLS())) { @@ -417,7 +425,7 @@ public void testTransientSessionWithAccountApi() throws Exception { .update()) { oauth.realm(TEST); - final String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret"); + final String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken(); // token exchange with the view-profile optional scope oauth.scope("account-view-profile-scope"); @@ -425,7 +433,7 @@ public void testTransientSessionWithAccountApi() throws Exception { assertAudiencesAndScopes(response, john, List.of(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID), List.of("account-view-profile-scope")); final AccessToken exchangedToken = TokenVerifier.create(response.getAccessToken(), AccessToken.class).parse().getToken(); assertAccessTokenContext(exchangedToken.getId(), AccessTokenContext.SessionType.TRANSIENT, - AccessTokenContext.TokenType.REGULAR, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE); + AccessTokenContext.TokenType.REGULAR, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE); final String accountUrl = ServerURLs.getAuthServerContextRoot() + "/auth/realms/test/account"; assertEquals("john", SimpleHttpDefault.doGet(accountUrl, oauth.httpClient().get()) @@ -442,7 +450,7 @@ public void testTransientSessionWithAccountApi() throws Exception { @Test public void testExchangeRequestAccessTokenType() throws Exception { oauth.realm(TEST); - String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret"); + String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret").getAccessToken(); 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()); String exchangedTokenString = response.getAccessToken(); @@ -455,7 +463,7 @@ public void testExchangeRequestAccessTokenType() throws Exception { @Test public void testExchangeForIdToken() throws Exception { oauth.realm(TEST); - String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret"); + String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret").getAccessToken(); // Exchange request with "scope=oidc" . ID Token should be issued in addition to access-token oauth.openid(true); @@ -539,21 +547,21 @@ public void testExchangeUsingServiceAccount() throws Exception { response = oauth.doRefreshTokenRequest(response.getRefreshToken()); assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1")); events.expect(EventType.REFRESH_TOKEN) - .detail(Details.TOKEN_ID, exchangedToken.getId()) - .detail(Details.REFRESH_TOKEN_ID, AssertEvents.isUUID()) - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) - .detail(Details.UPDATED_REFRESH_TOKEN_ID, AssertEvents.isUUID()) - .session(exchangedToken.getSessionId()); + .detail(Details.TOKEN_ID, exchangedToken.getId()) + .detail(Details.REFRESH_TOKEN_ID, AssertEvents.isUUID()) + .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) + .detail(Details.UPDATED_REFRESH_TOKEN_ID, AssertEvents.isUUID()) + .session(exchangedToken.getSessionId()); oauth.client("requester-client", "secret"); response = oauth.doRefreshTokenRequest(response.getRefreshToken()); assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1")); events.expect(EventType.REFRESH_TOKEN) - .detail(Details.TOKEN_ID, exchangedToken.getId()) - .detail(Details.REFRESH_TOKEN_ID, AssertEvents.isUUID()) - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) - .detail(Details.UPDATED_REFRESH_TOKEN_ID, AssertEvents.isUUID()) - .session(exchangedToken.getSessionId()); + .detail(Details.TOKEN_ID, exchangedToken.getId()) + .detail(Details.REFRESH_TOKEN_ID, AssertEvents.isUUID()) + .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) + .detail(Details.UPDATED_REFRESH_TOKEN_ID, AssertEvents.isUUID()) + .session(exchangedToken.getSessionId()); } } @@ -562,7 +570,7 @@ public void testExchangeUsingServiceAccount() throws Exception { public void testExchangeNoRefreshToken() throws Exception { - String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret"); + String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret").getAccessToken(); { AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, null); assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType()); @@ -587,7 +595,7 @@ public void testExchangeNoRefreshToken() throws Exception { @Test public void testClientExchangeToItself() throws Exception { oauth.realm(TEST); - String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret"); + String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret").getAccessToken(); AccessTokenResponse response = tokenExchange(accessToken, "subject-client", "secret", null, null); assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode()); @@ -599,7 +607,7 @@ public void testClientExchangeToItself() throws Exception { @Test public void testClientExchangeToItselfWithConsents() throws Exception { oauth.realm(TEST); - String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret"); + String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret").getAccessToken(); try (ClientAttributeUpdater clientUpdater = ClientAttributeUpdater.forClient(adminClient, TEST, "subject-client") .setConsentRequired(Boolean.TRUE) @@ -617,7 +625,7 @@ public void testClientExchangeToItselfWithConsents() throws Exception { @Test public void testExchangeWithPublicClient() throws Exception { - String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret"); + String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret").getAccessToken(); AccessTokenResponse response = tokenExchange(accessToken, "requester-client-public", null, null, null); assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode()); assertEquals(OAuthErrorException.INVALID_CLIENT, response.getError()); @@ -627,7 +635,7 @@ public void testExchangeWithPublicClient() throws Exception { @Test public void testOptionalScopeParamRequestedWithoutAudience() throws Exception { final UserRepresentation john = ApiUtil.findUserByUsername(adminClient.realm(TEST), "john"); - String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret"); + String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret").getAccessToken(); oauth.scope("optional-scope2"); AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, null); assertAudiencesAndScopes(response, john, List.of("target-client1", "target-client2"), List.of("default-scope1", "optional-scope2")); @@ -636,14 +644,14 @@ public void testOptionalScopeParamRequestedWithoutAudience() throws Exception { @Test public void testAudienceRequested() throws Exception { final UserRepresentation john = ApiUtil.findUserByUsername(adminClient.realm(TEST), "john"); - String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret"); + String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret").getAccessToken(); AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), null); assertAudiencesAndScopes(response, john, List.of("target-client1"), List.of("default-scope1")); } @Test public void testUnavailableAudienceRequested() throws Exception { - String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret"); + String accessToken = resourceOwnerLogin("john", "password","subject-client", "secret").getAccessToken(); // request invalid client audience AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1", "invalid-client"), null); assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode()); @@ -658,7 +666,7 @@ public void testUnavailableAudienceRequested() throws Exception { @Test public void testScopeNotAllowed() throws Exception { - String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret"); + String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken(); //scope not allowed oauth.scope("optional-scope3"); @@ -679,7 +687,7 @@ public void testScopeNotAllowed() throws Exception { public void testScopeFilter() throws Exception { final RealmResource realm = adminClient.realm(TEST); final UserRepresentation john = ApiUtil.findUserByUsername(realm, "john"); - String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret"); + String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken(); AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client2"), null); assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode()); assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError()); @@ -706,12 +714,12 @@ public void testScopeFilter() throws Exception { //just check that the exchanged token contains the optional-scope2 mapped by the realm role final UserRepresentation mike = ApiUtil.findUserByUsername(realm, "mike"); - accessToken = resourceOwnerLogin("mike", "password","subject-client", "secret"); + accessToken = resourceOwnerLogin("mike", "password","subject-client", "secret").getAccessToken(); oauth.scope("optional-scope2"); response = tokenExchange(accessToken, "requester-client", "secret", null, null); assertAudiencesAndScopes(response, mike, List.of("target-client1"), List.of("default-scope1", "optional-scope2")); - accessToken = resourceOwnerLogin("mike", "password","subject-client", "secret"); + accessToken = resourceOwnerLogin("mike", "password","subject-client", "secret").getAccessToken(); oauth.scope("optional-scope2"); response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), null); assertAudiencesAndScopes(response, mike, List.of("target-client1"), List.of("default-scope1", "optional-scope2")); @@ -723,7 +731,7 @@ public void testScopeParamIncludedAudienceIncludedRefreshToken() throws Exceptio try (ClientAttributeUpdater clientUpdater = ClientAttributeUpdater.forClient(adminClient, TEST, "requester-client") .setAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, Boolean.TRUE.toString()) .update()) { - String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret"); + String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret").getAccessToken(); oauth.scope("optional-scope2"); AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), Collections.singletonMap(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE)); assertAudiencesAndScopes(response, mike, List.of("target-client1"), List.of("default-scope1", "optional-scope2"), OAuth2Constants.REFRESH_TOKEN_TYPE, "subject-client"); @@ -733,21 +741,21 @@ public void testScopeParamIncludedAudienceIncludedRefreshToken() throws Exceptio response = oauth.doRefreshTokenRequest(response.getRefreshToken()); AccessToken exchangedToken = assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1", "optional-scope2")); events.expect(EventType.REFRESH_TOKEN) - .detail(Details.TOKEN_ID, exchangedToken.getId()) - .detail(Details.REFRESH_TOKEN_ID, AssertEvents.isUUID()) - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) - .detail(Details.UPDATED_REFRESH_TOKEN_ID, AssertEvents.isUUID()) - .session(exchangedToken.getSessionId()); + .detail(Details.TOKEN_ID, exchangedToken.getId()) + .detail(Details.REFRESH_TOKEN_ID, AssertEvents.isUUID()) + .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) + .detail(Details.UPDATED_REFRESH_TOKEN_ID, AssertEvents.isUUID()) + .session(exchangedToken.getSessionId()); oauth.client("requester-client", "secret"); response = oauth.doRefreshTokenRequest(response.getRefreshToken()); exchangedToken = assertAudiencesAndScopes(response, List.of("target-client1"), List.of("default-scope1", "optional-scope2")); events.expect(EventType.REFRESH_TOKEN) - .detail(Details.TOKEN_ID, exchangedToken.getId()) - .detail(Details.REFRESH_TOKEN_ID, AssertEvents.isUUID()) - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) - .detail(Details.UPDATED_REFRESH_TOKEN_ID, AssertEvents.isUUID()) - .session(exchangedToken.getSessionId()); + .detail(Details.TOKEN_ID, exchangedToken.getId()) + .detail(Details.REFRESH_TOKEN_ID, AssertEvents.isUUID()) + .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) + .detail(Details.UPDATED_REFRESH_TOKEN_ID, AssertEvents.isUUID()) + .session(exchangedToken.getSessionId()); } } @@ -758,12 +766,12 @@ public void testExchangeWithDynamicScopesEnabled() throws Exception { testExchange(); testingClient.disableFeature(Profile.Feature.DYNAMIC_SCOPES); } - + @Test @UncaughtServerErrorExpected public void testExchangeDisabledOnClient() throws Exception { oauth.realm(TEST); - String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret"); + String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken(); { AccessTokenResponse response = tokenExchange(accessToken, "disabled-requester-client", "secret", null, null); org.junit.Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode()); @@ -781,18 +789,18 @@ public void testConsents() throws Exception { .setConsentRequired(Boolean.TRUE) .update()) { // initial TE without any consent should fail - String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret"); + String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret").getAccessToken(); AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", null, null); assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode()); assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError()); assertEquals("Missing consents for Token Exchange in client requester-client", response.getErrorDescription()); events.expect(EventType.TOKEN_EXCHANGE_ERROR) - .client("requester-client") - .error(Errors.CONSENT_DENIED) - .user(mike.getId()) - .session(AssertEvents.isUUID()) - .detail(Details.REASON, "Missing consents for Token Exchange in client requester-client") - .assertEvent(); + .client("requester-client") + .error(Errors.CONSENT_DENIED) + .user(mike.getId()) + .session(AssertEvents.isUUID()) + .detail(Details.REASON, "Missing consents for Token Exchange in client requester-client") + .assertEvent(); // logout mikeRes.logout(); @@ -809,12 +817,12 @@ public void testConsents() throws Exception { assertEquals(OAuthErrorException.INVALID_SCOPE, response.getError()); assertEquals("Missing consents for Token Exchange in client requester-client", response.getErrorDescription()); events.expect(EventType.TOKEN_EXCHANGE_ERROR) - .client("requester-client") - .error(Errors.CONSENT_DENIED) - .user(mike.getId()) - .session(AssertEvents.isUUID()) - .detail(Details.REASON, "Missing consents for Token Exchange in client requester-client") - .assertEvent(); + .client("requester-client") + .error(Errors.CONSENT_DENIED) + .user(mike.getId()) + .session(AssertEvents.isUUID()) + .detail(Details.REASON, "Missing consents for Token Exchange in client requester-client") + .assertEvent(); // logout mikeRes.logout(); @@ -832,7 +840,7 @@ public void testOfflineAccessNotAllowed() throws Exception { try (ClientAttributeUpdater clientUpdater = ClientAttributeUpdater.forClient(adminClient, TEST, "requester-client") .setAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, Boolean.TRUE.toString()) .update()) { - String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret"); + String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret").getAccessToken(); String sessionId = TokenVerifier.create(accessToken, AccessToken.class).parse().getToken().getSessionId(); Assert.assertEquals(testingClient.testing(TEST).getClientSessionsCountInUserSession(TEST, sessionId), Integer.valueOf(1)); @@ -859,7 +867,7 @@ public void testOfflineAccessLoginWithRegularTokenExchange() throws Exception { .update(); ) { // Login with "scope=offline_access" . Will create offline user-session - String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret", OAuth2Constants.OFFLINE_ACCESS); + String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret", OAuth2Constants.OFFLINE_ACCESS).getAccessToken(); TokenVerifier verifier = TokenVerifier.create(accessToken, AccessToken.class); AccessToken originalToken = verifier.parse().getToken(); @@ -895,7 +903,7 @@ public void testOfflineAccessNotAllowedAfterOfflineAccessLogin() throws Exceptio .update(); ) { // Login with "scope=offline_access" . Will create offline user-session - String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret", OAuth2Constants.OFFLINE_ACCESS); + String accessToken = resourceOwnerLogin("mike", "password", "subject-client", "secret", OAuth2Constants.OFFLINE_ACCESS).getAccessToken(); TokenVerifier verifier = TokenVerifier.create(accessToken, AccessToken.class); AccessToken originalToken = verifier.parse().getToken(); @@ -943,7 +951,7 @@ public void testClientPolicies() throws Exception { updatePolicies(json); final UserRepresentation john = ApiUtil.findUserByUsername(adminClient.realm(TEST), "john"); - String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret"); + String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken(); AccessTokenResponse response = tokenExchange(accessToken, "requester-client", "secret", List.of("target-client1"), null); assertAudiencesAndScopes(response, john, List.of("target-client1"), List.of("default-scope1")); @@ -956,6 +964,155 @@ public void testClientPolicies() throws Exception { assertEquals("Exception thrown intentionally", response.getErrorDescription()); } + @Test + @UncaughtServerErrorExpected + public void testTokenRevocation() throws Exception { + ClientAttributeUpdater.forClient(adminClient, TEST, "requester-client") + .setAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, Boolean.TRUE.toString()) + .update(); + UserRepresentation johnUser = ApiUtil.findUserByUsernameId(adminClient.realm(TEST), "john").toRepresentation(); + + oauth.realm(TEST); + AccessTokenResponse accessTokenResponse = resourceOwnerLogin("john", "password", "subject-client", "secret"); + + //revoke the exchanged access token + AccessTokenResponse tokenExchangeResponse = tokenExchange(accessTokenResponse.getAccessToken(), "requester-client", "secret", null, Collections.singletonMap(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE)); + oauth.client("requester-client", "secret"); + events.clear(); + oauth.doTokenRevoke(tokenExchangeResponse.getAccessToken()); + events.expect(EventType.REVOKE_GRANT) + .client("requester-client") + .user(johnUser) + .assertEvent(); + isAccessTokenEnabled(accessTokenResponse.getAccessToken(), "subject-client", "secret"); + isAccessTokenDisabled(tokenExchangeResponse.getAccessToken(), "requester-client", "secret"); + + //revoke the exchanged refresh token + tokenExchangeResponse = tokenExchange(accessTokenResponse.getAccessToken(), "requester-client", "secret", null, Collections.singletonMap(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE)); + events.clear(); + oauth.doTokenRevoke(tokenExchangeResponse.getRefreshToken()); + events.expect(EventType.REVOKE_GRANT) + .client("requester-client") + .user(johnUser) + .session(tokenExchangeResponse.getSessionState()) + .assertEvent(); + isTokenDisabled(tokenExchangeResponse, "requester-client", "secret"); + + //revoke the subject access token + tokenExchangeResponse = tokenExchange(accessTokenResponse.getAccessToken(), "requester-client", "secret", null, Collections.singletonMap(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE)); + oauth.client("subject-client", "secret"); + events.clear(); + oauth.doTokenRevoke(accessTokenResponse.getAccessToken()); + events.expect(EventType.REVOKE_GRANT) + .client("subject-client") + .user(johnUser) + .detail(Details.TOKEN_EXCHANGE_REVOKED_CLIENTS, "requester-client") + .assertEvent(); + isAccessTokenDisabled(accessTokenResponse.getAccessToken(), "subject-client", "secret"); + isTokenDisabled(tokenExchangeResponse, "requester-client", "secret"); + + //revoke the subject refresh token + accessTokenResponse = resourceOwnerLogin("john", "password", "subject-client", "secret"); + tokenExchangeResponse = tokenExchange(accessTokenResponse.getAccessToken(), "requester-client", "secret", null, Collections.singletonMap(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE)); + assertEquals(Response.Status.OK.getStatusCode(), tokenExchangeResponse.getStatusCode()); + oauth.client("subject-client", "secret"); + events.clear(); + oauth.doTokenRevoke(accessTokenResponse.getRefreshToken()); + events.expect(EventType.REVOKE_GRANT) + .client("subject-client") + .user(johnUser) + .session(tokenExchangeResponse.getSessionState()) + .detail(Details.TOKEN_EXCHANGE_REVOKED_CLIENTS, "requester-client") + .assertEvent(); + isTokenDisabled(accessTokenResponse, "subject-client", "secret"); + isTokenDisabled(tokenExchangeResponse, "requester-client", "secret"); + + //revoke multiple access token + AccessTokenResponse accessTokenResponse1 = resourceOwnerLogin("john", "password", "subject-client", "secret"); + AccessTokenResponse accessTokenResponse2 = oauth.doRefreshTokenRequest(accessTokenResponse1.getRefreshToken()); + AccessTokenResponse accessTokenResponse3 = oauth.doRefreshTokenRequest(accessTokenResponse1.getRefreshToken()); + + AccessTokenResponse tokenExchangeResponse1 = tokenExchange(accessTokenResponse1.getAccessToken(), "requester-client", "secret", null, Collections.singletonMap(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE)); + assertEquals(Response.Status.OK.getStatusCode(), tokenExchangeResponse1.getStatusCode()); + AccessTokenResponse tokenExchangeResponse2 = tokenExchange(accessTokenResponse2.getAccessToken(), "requester-client", "secret", null, Collections.singletonMap(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE)); + assertEquals(Response.Status.OK.getStatusCode(), tokenExchangeResponse2.getStatusCode()); + + oauth.client("subject-client", "secret"); + events.clear(); + oauth.doTokenRevoke(accessTokenResponse3.getAccessToken()); + events.expect(EventType.REVOKE_GRANT) + .client("subject-client") + .user(johnUser) + .detail(Details.TOKEN_EXCHANGE_REVOKED_CLIENTS, String.join(",", List.of("requester-client"))) + .assertEvent(); + isAccessTokenEnabled(accessTokenResponse1.getAccessToken(), "subject-client", "secret"); + isAccessTokenEnabled(accessTokenResponse2.getAccessToken(), "subject-client", "secret"); + isAccessTokenDisabled(accessTokenResponse3.getAccessToken(), "subject-client", "secret"); + isTokenDisabled(tokenExchangeResponse1, "requester-client", "secret"); + isTokenDisabled(tokenExchangeResponse2, "requester-client", "secret"); + + //revoke exchange chain if an already exchanged token is used for token exchange + try ( + ProtocolMappersUpdater clientUpdater1 = ClientAttributeUpdater.forClient(adminClient, TEST, "requester-client") + .protocolMappers() + .add(ModelToRepresentation.toRepresentation(AudienceProtocolMapper.createClaimMapper("requester-client-2", "requester-client-2", null, true, false, true))) + .update(); + + ClientAttributeUpdater clientUpdater2 = ClientAttributeUpdater.forClient(adminClient, TEST, "requester-client-2") + .setAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, Boolean.TRUE.toString()) + .update(); + ) { + accessTokenResponse = resourceOwnerLogin("john", "password", "subject-client", "secret"); + tokenExchangeResponse1 = tokenExchange(accessTokenResponse.getAccessToken(), "requester-client", "secret", null, Collections.singletonMap(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE)); + assertEquals(Response.Status.OK.getStatusCode(), tokenExchangeResponse1.getStatusCode()); + + tokenExchangeResponse2 = tokenExchange(tokenExchangeResponse1.getAccessToken(), "requester-client-2", "secret", null, Collections.singletonMap(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE)); + assertEquals(Response.Status.OK.getStatusCode(), tokenExchangeResponse2.getStatusCode()); + + oauth.client("subject-client", "secret"); + events.clear(); + oauth.doTokenRevoke(accessTokenResponse.getAccessToken()); + events.expect(EventType.REVOKE_GRANT) + .client("subject-client") + .user(johnUser) + .detail(Details.TOKEN_EXCHANGE_REVOKED_CLIENTS, "requester-client-2,requester-client") + .assertEvent(); + + isTokenDisabled(tokenExchangeResponse1, "requester-client", "secret"); + isTokenDisabled(tokenExchangeResponse2, "requester-client-2", "secret"); + + } + } + + private void isAccessTokenEnabled(String accessToken, String clientId, String secret) throws IOException { + oauth.client(clientId, secret); + String introspectionResponse = oauth.doIntrospectionAccessTokenRequest(accessToken); + TokenMetadataRepresentation rep = JsonSerialization.readValue(introspectionResponse, TokenMetadataRepresentation.class); + assertTrue(rep.isActive()); + } + + private void isAccessTokenDisabled(String accessTokenString, String clientId, String secret) throws IOException { + // Test introspection endpoint not possible + oauth.client(clientId, secret); + String introspectionResponse = oauth.doIntrospectionAccessTokenRequest(accessTokenString); + TokenMetadataRepresentation rep = JsonSerialization.readValue(introspectionResponse, TokenMetadataRepresentation.class); + assertFalse(rep.isActive()); + } + + private void isTokenEnabled(AccessTokenResponse tokenResponse, String clientId, String secret) throws IOException { + isAccessTokenEnabled(tokenResponse.getAccessToken(), clientId, secret); + AccessTokenResponse tokenRefreshResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken()); + assertEquals(Response.Status.OK.getStatusCode(), tokenRefreshResponse.getStatusCode()); + } + + private void isTokenDisabled(AccessTokenResponse tokenResponse, String clientId, String secret) throws IOException { + isAccessTokenDisabled(tokenResponse.getAccessToken(), clientId, secret); + + oauth.client(clientId, secret); + AccessTokenResponse tokenRefreshResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken()); + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), tokenRefreshResponse.getStatusCode()); + } + private void assertAudiences(AccessToken token, List expectedAudiences) { MatcherAssert.assertThat("Incompatible audiences", token.getAudience() == null ? List.of() : List.of(token.getAudience()), containsInAnyOrder(expectedAudiences.toArray())); MatcherAssert.assertThat("Incompatible resource access", token.getResourceAccess().keySet(), containsInAnyOrder(expectedAudiences.toArray())); @@ -983,7 +1140,7 @@ private AccessToken assertAudiencesAndScopes(AccessTokenResponse tokenExchangeRe } private AccessToken assertAudiencesAndScopes(AccessTokenResponse tokenExchangeResponse, UserRepresentation user, - List expectedAudiences, List expectedScopes, String expectedTokenType, String expectedSubjectTokenClientId) throws Exception { + List expectedAudiences, List expectedScopes, String expectedTokenType, String expectedSubjectTokenClientId) throws Exception { AccessToken token = assertAudiencesAndScopes(tokenExchangeResponse, expectedAudiences, expectedScopes); events.expect(EventType.TOKEN_EXCHANGE) .client(token.getIssuedFor()) @@ -1040,7 +1197,7 @@ private void assertUserInfoError(String token, String clientId, String clientSec } private void assertAccessTokenContext(String jti, AccessTokenContext.SessionType sessionType, - AccessTokenContext.TokenType tokenType, String grantType) { + AccessTokenContext.TokenType tokenType, String grantType) { AccessTokenContext ctx = testingClient.testing(TEST).getTokenContext(jti); assertEquals(sessionType, ctx.getSessionType()); assertEquals(tokenType, ctx.getTokenType()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2WithLegacyTokenExchangeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2WithLegacyTokenExchangeTest.java index 1ddf9876c569..dbce025e84dc 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2WithLegacyTokenExchangeTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2WithLegacyTokenExchangeTest.java @@ -45,7 +45,7 @@ public class StandardTokenExchangeV2WithLegacyTokenExchangeTest extends Standard public void testExchangeDisabledOnClient() throws Exception { // When client does not have TE enabled, request is handled by V1-provider, which returns different error oauth.realm(TEST); - String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret"); + String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret").getAccessToken(); { AccessTokenResponse response = tokenExchange(accessToken, "disabled-requester-client", "secret", null, null); org.junit.Assert.assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatusCode()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/token-exchange/testrealm-token-exchange-v2.json b/testsuite/integration-arquillian/tests/base/src/test/resources/token-exchange/testrealm-token-exchange-v2.json index 538eee449e1e..af2110d0f9d2 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/token-exchange/testrealm-token-exchange-v2.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/token-exchange/testrealm-token-exchange-v2.json @@ -841,6 +841,49 @@ "defaultClientScopes" : [ "service_account", "acr", "default-scope1", "roles", "basic" ], "optionalClientScopes" : [ "optional-scope2", "offline_access" ] }, { + "id" : "952643a3-2943-4734-9b51-8fa5956ebf55", + "clientId" : "requester-client-2", + "name" : "", + "description" : "", + "rootUrl" : "", + "adminUrl" : "", + "baseUrl" : "", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "secret" : "secret", + "redirectUris" : [ "/*" ], + "webOrigins" : [ "/*" ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : true, + "serviceAccountsEnabled" : true, + "publicClient" : false, + "frontchannelLogout" : true, + "protocol" : "openid-connect", + "attributes" : { + "realm_client" : "false", + "oidc.ciba.grant.enabled" : "false", + "client.secret.creation.time" : "1732884723", + "backchannel.logout.session.required" : "true", + "standard.token.exchange.enabled":"true", + "post.logout.redirect.uris" : "+", + "frontchannel.logout.session.required" : "true", + "oauth2.device.authorization.grant.enabled" : "false", + "display.on.consent.screen" : "false", + "use.jwks.url" : "false", + "backchannel.logout.revoke.offline.tokens" : "false" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : -1, + "defaultClientScopes" : [ "service_account", "acr", "default-scope1", "roles", "basic" ], + "optionalClientScopes" : [ "optional-scope2", "offline_access" ] + }, { "id" : "2daeae03-ff78-4f79-8e72-1c4d443e1655", "clientId" : "requester-client-public", "name" : "", From 339080854de18365638d7c3ec2b3d9161daf97fb Mon Sep 17 00:00:00 2001 From: Giuseppe Graziano Date: Mon, 10 Mar 2025 10:53:19 +0100 Subject: [PATCH 2/2] changed introspection method in StandardTokenExchangeV2Test Closes #37120 Signed-off-by: Giuseppe Graziano changed intrspection method in StandardTokenExchangeV2Test Closes #37120 Signed-off-by: Giuseppe Graziano --- .../oauth/tokenexchange/StandardTokenExchangeV2Test.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java index ee3dcdfe388f..deae20e88810 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java @@ -75,7 +75,6 @@ import org.keycloak.testsuite.util.oauth.UserInfoResponse; import org.keycloak.testsuite.util.oauth.TokenExchangeRequest; import org.keycloak.testsuite.utils.tls.TLSUtils; -import org.keycloak.util.JsonSerialization; import org.keycloak.util.TokenUtil; import java.io.IOException; @@ -1086,16 +1085,14 @@ public void testTokenRevocation() throws Exception { private void isAccessTokenEnabled(String accessToken, String clientId, String secret) throws IOException { oauth.client(clientId, secret); - String introspectionResponse = oauth.doIntrospectionAccessTokenRequest(accessToken); - TokenMetadataRepresentation rep = JsonSerialization.readValue(introspectionResponse, TokenMetadataRepresentation.class); + TokenMetadataRepresentation rep = oauth.doIntrospectionAccessTokenRequest(accessToken).asTokenMetadata(); assertTrue(rep.isActive()); } private void isAccessTokenDisabled(String accessTokenString, String clientId, String secret) throws IOException { // Test introspection endpoint not possible oauth.client(clientId, secret); - String introspectionResponse = oauth.doIntrospectionAccessTokenRequest(accessTokenString); - TokenMetadataRepresentation rep = JsonSerialization.readValue(introspectionResponse, TokenMetadataRepresentation.class); + TokenMetadataRepresentation rep = oauth.doIntrospectionAccessTokenRequest(accessTokenString).asTokenMetadata(); assertFalse(rep.isActive()); }