diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/ExchangeTokenToIdentityProviderToken.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/ExchangeTokenToIdentityProviderToken.java index 0478051e2e..d0048b291f 100644 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/ExchangeTokenToIdentityProviderToken.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/ExchangeTokenToIdentityProviderToken.java @@ -16,6 +16,7 @@ */ package org.keycloak.broker.provider; +import org.keycloak.events.EventBuilder; import org.keycloak.models.ClientModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; @@ -38,5 +39,5 @@ public interface ExchangeTokenToIdentityProviderToken { * @param params form parameters received for requested exchange * @return */ - Response exchangeFromToken(UriInfo uriInfo, ClientModel authorizedClient, UserSessionModel tokenUserSession, UserModel tokenSubject, MultivaluedMap params); + Response exchangeFromToken(UriInfo uriInfo, EventBuilder event, ClientModel authorizedClient, UserSessionModel tokenUserSession, UserModel tokenSubject, MultivaluedMap params); } diff --git a/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java index c3a26dc223..918a38586b 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java @@ -148,26 +148,30 @@ public abstract class AbstractOAuth2IdentityProvider params) { + public Response exchangeFromToken(UriInfo uriInfo, EventBuilder event, ClientModel authorizedClient, UserSessionModel tokenUserSession, UserModel tokenSubject, MultivaluedMap params) { // check to see if we have a token exchange in session // in other words check to see if this session was created by an external exchange - Response tokenResponse = hasExternalExchangeToken(tokenUserSession, params); + Response tokenResponse = hasExternalExchangeToken(event, tokenUserSession, params); if (tokenResponse != null) return tokenResponse; // going further we only support access token type? Why? String requestedType = params.getFirst(OAuth2Constants.REQUESTED_TOKEN_TYPE); if (requestedType != null && !requestedType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE)) { + event.detail(Details.REASON, "requested_token_type unsupported"); + event.error(Errors.INVALID_REQUEST); return exchangeUnsupportedRequiredType(); } if (!getConfig().isStoreToken()) { // if token isn't stored, we need to see if this session has been linked String brokerId = tokenUserSession.getNote(Details.IDENTITY_PROVIDER); if (brokerId == null || !brokerId.equals(getConfig().getAlias())) { + event.detail(Details.REASON, "requested_issuer has not linked"); + event.error(Errors.INVALID_REQUEST); return exchangeNotLinkedNoStore(uriInfo, authorizedClient, tokenUserSession, tokenSubject); } - return exchangeSessionToken(uriInfo, authorizedClient, tokenUserSession, tokenSubject); + return exchangeSessionToken(uriInfo, event, authorizedClient, tokenUserSession, tokenSubject); } else { - return exchangeStoredToken(uriInfo, authorizedClient, tokenUserSession, tokenSubject); + return exchangeStoredToken(uriInfo, event, authorizedClient, tokenUserSession, tokenSubject); } } @@ -178,7 +182,7 @@ public abstract class AbstractOAuth2IdentityProvider params) { + protected Response hasExternalExchangeToken(EventBuilder event, UserSessionModel tokenUserSession, MultivaluedMap params) { if (getConfig().getAlias().equals(tokenUserSession.getNote(OIDCIdentityProvider.EXCHANGE_PROVIDER))) { String requestedType = params.getFirst(OAuth2Constants.REQUESTED_TOKEN_TYPE); @@ -193,6 +197,7 @@ public abstract class AbstractOAuth2IdentityProvider Time.currentTime()) { AccessTokenResponse tokenResponse = new AccessTokenResponse(); tokenResponse.setExpiresIn(expiration); @@ -301,6 +313,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider params) { + public Response exchangeFromToken(UriInfo uriInfo, EventBuilder builder, ClientModel authorizedClient, UserSessionModel tokenUserSession, UserModel tokenSubject, MultivaluedMap params) { String requestedType = params.getFirst(OAuth2Constants.REQUESTED_TOKEN_TYPE); if (requestedType != null && !requestedType.equals(TWITTER_TOKEN_TYPE)) { return exchangeUnsupportedRequiredType(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/AbstractBrokerLinkAndTokenExchangeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/AbstractBrokerLinkAndTokenExchangeTest.java index 9ff6938bdb..be579e23a7 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/AbstractBrokerLinkAndTokenExchangeTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/AbstractBrokerLinkAndTokenExchangeTest.java @@ -451,6 +451,8 @@ public abstract class AbstractBrokerLinkAndTokenExchangeTest extends AbstractSer @Test public void testExternalExchange() throws Exception { + RealmResource childRealm = adminClient.realms().realm(CHILD_IDP); + String accessToken = oauth.doGrantAccessTokenRequest(PARENT_IDP, PARENT2_USERNAME, "password", null, PARENT_CLIENT, "password").getAccessToken(); Assert.assertEquals(0, adminClient.realm(CHILD_IDP).getClientSessionStats().size()); @@ -483,6 +485,10 @@ public abstract class AbstractBrokerLinkAndTokenExchangeTest extends AbstractSer rep.getConfig().put(OIDCIdentityProviderConfig.USE_JWKS_URL, String.valueOf(true)); rep.getConfig().put(OIDCIdentityProviderConfig.JWKS_URL, parentJwksUrl()); adminClient.realm(CHILD_IDP).identityProviders().get(PARENT_IDP).update(rep); + + String exchangedUserId = null; + String exchangedUsername = null; + { // valid exchange Response response = exchangeUrl.request() @@ -502,11 +508,11 @@ public abstract class AbstractBrokerLinkAndTokenExchangeTest extends AbstractSer AccessToken token = jws.readJsonContent(AccessToken.class); response.close(); - String childUserId = token.getSubject(); - String username = token.getPreferredUsername(); + exchangedUserId = token.getSubject(); + exchangedUsername = token.getPreferredUsername(); - System.out.println("childUserId: " + childUserId); - System.out.println("username: " + username); + System.out.println("exchangedUserId: " + exchangedUserId); + System.out.println("exchangedUsername: " + exchangedUsername); // test that we can exchange back to external token @@ -537,13 +543,54 @@ public abstract class AbstractBrokerLinkAndTokenExchangeTest extends AbstractSer Assert.assertEquals(0, adminClient.realm(CHILD_IDP).getClientSessionStats().size()); - RealmResource realm = adminClient.realms().realm(CHILD_IDP); - List links = realm.users().get(childUserId).getFederatedIdentity(); + List links = childRealm.users().get(exchangedUserId).getFederatedIdentity(); Assert.assertEquals(1, links.size()); - realm.users().get(childUserId).remove(); } { - // unauthorized client + // check that we can request an exchange again and that the previously linked user is obtained + Response response = exchangeUrl.request() + .header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader(ClientApp.DEPLOYMENT_NAME, "password")) + .post(Entity.form( + new Form() + .param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE) + .param(OAuth2Constants.SUBJECT_TOKEN, accessToken) + .param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.JWT_ACCESS_TOKEN_TYPE) + .param(OAuth2Constants.SUBJECT_ISSUER, PARENT_IDP) + + )); + Assert.assertEquals(200, response.getStatus()); + AccessTokenResponse tokenResponse = response.readEntity(AccessTokenResponse.class); + String exchangedAccessToken = tokenResponse.getToken(); + JWSInput jws = new JWSInput(tokenResponse.getToken()); + AccessToken token = jws.readJsonContent(AccessToken.class); + response.close(); + + String exchanged2UserId = token.getSubject(); + String exchanged2Username = token.getPreferredUsername(); + + // assert that we get the same linked account as was previously imported + + Assert.assertEquals(exchangedUserId, exchanged2UserId); + Assert.assertEquals(exchangedUsername, exchanged2Username); + + // test logout + response = childLogoutWebTarget(httpClient) + .queryParam("id_token_hint", exchangedAccessToken) + .request() + .get(); + response.close(); + + Assert.assertEquals(0, adminClient.realm(CHILD_IDP).getClientSessionStats().size()); + + + List links = childRealm.users().get(exchangedUserId).getFederatedIdentity(); + Assert.assertEquals(1, links.size()); + } + // cleanup remove the user + childRealm.users().get(exchangedUserId).remove(); + + { + // test unauthorized client gets 403 Response response = exchangeUrl.request() .header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader(UNAUTHORIZED_CHILD_CLIENT, "password")) .post(Entity.form(