diff --git a/core/src/main/java/org/keycloak/util/TokenUtil.java b/core/src/main/java/org/keycloak/util/TokenUtil.java index 41432d68e7..92d7d13273 100644 --- a/core/src/main/java/org/keycloak/util/TokenUtil.java +++ b/core/src/main/java/org/keycloak/util/TokenUtil.java @@ -42,6 +42,10 @@ public class TokenUtil { public static final String TOKEN_TYPE_DPOP = "DPoP"; + // 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; + public static final String TOKEN_TYPE_KEYCLOAK_ID = "Serialized-ID"; public static final String TOKEN_TYPE_ID = "ID"; diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java index 6e66e41b4a..215763ef9c 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java @@ -64,6 +64,7 @@ import org.keycloak.services.resources.IdentityBrokerService; import org.keycloak.services.resources.RealmsResource; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.util.JsonSerialization; +import org.keycloak.util.TokenUtil; import org.keycloak.vault.VaultStringSecret; import jakarta.ws.rs.GET; @@ -79,6 +80,7 @@ import jakarta.ws.rs.core.UriInfo; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Objects; @@ -97,6 +99,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider SUPPORTED_TOKEN_TYPES = Arrays.asList(TokenUtil.TOKEN_TYPE_ID, TokenUtil.TOKEN_TYPE_BEARER, TokenUtil.TOKEN_TYPE_JWT_ACCESS_TOKEN, TokenUtil.TOKEN_TYPE_JWT_ACCESS_TOKEN_PREFIXED); public OIDCIdentityProvider(KeycloakSession session, OIDCIdentityProviderConfig config) { super(session, config); @@ -849,7 +852,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider params) { if (!supportsExternalExchange()) return null; diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProvider.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProvider.java index 5fc050ca36..c52b047c08 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProvider.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProvider.java @@ -59,7 +59,7 @@ public class TestApplicationResourceProvider implements RealmResourceProvider { private final BlockingQueue adminLogoutActions; private final BlockingQueue frontChannelLogoutTokens; - private final BlockingQueue backChannelLogoutTokens; + private final BlockingQueue backChannelLogoutTokens; private final BlockingQueue adminPushNotBeforeActions; private final BlockingQueue adminTestAvailabilityAction; private final TestApplicationResourceProviderFactory.OIDCClientData oidcClientData; @@ -71,7 +71,7 @@ public class TestApplicationResourceProvider implements RealmResourceProvider { private final HttpRequest request; public TestApplicationResourceProvider(KeycloakSession session, BlockingQueue adminLogoutActions, - BlockingQueue backChannelLogoutTokens, + BlockingQueue backChannelLogoutTokens, BlockingQueue frontChannelLogoutTokens, BlockingQueue adminPushNotBeforeActions, BlockingQueue adminTestAvailabilityAction, @@ -102,8 +102,8 @@ public class TestApplicationResourceProvider implements RealmResourceProvider { @POST @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Path("/admin/backchannelLogout") - public void backchannelLogout() throws JWSInputException { - backChannelLogoutTokens.add(new JWSInput(request.getDecodedFormParameters().getFirst(OAuth2Constants.LOGOUT_TOKEN)).readJsonContent(LogoutToken.class)); + public void backchannelLogout() { + backChannelLogoutTokens.add(request.getDecodedFormParameters().getFirst(OAuth2Constants.LOGOUT_TOKEN)); } @GET @@ -139,7 +139,14 @@ public class TestApplicationResourceProvider implements RealmResourceProvider { @GET @Produces(MediaType.APPLICATION_JSON) @Path("/poll-backchannel-logout") - public LogoutToken getBackChannelLogoutAction() throws InterruptedException { + public LogoutToken getBackChannelLogoutAction() throws InterruptedException, JWSInputException { + return new JWSInput(backChannelLogoutTokens.poll(20, TimeUnit.SECONDS)).readJsonContent(LogoutToken.class); + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Path("/poll-backchannel-raw-logout") + public String getBackChanneRawlLogoutAction() throws InterruptedException { return backChannelLogoutTokens.poll(20, TimeUnit.SECONDS); } diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProviderFactory.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProviderFactory.java index e15772ad90..a1a8db9c08 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProviderFactory.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProviderFactory.java @@ -45,7 +45,7 @@ import java.util.concurrent.LinkedBlockingDeque; public class TestApplicationResourceProviderFactory implements RealmResourceProviderFactory { private BlockingQueue adminLogoutActions = new LinkedBlockingDeque<>(); - private BlockingQueue backChannelLogoutTokens = new LinkedBlockingDeque<>(); + private BlockingQueue backChannelLogoutTokens = new LinkedBlockingDeque<>(); private BlockingQueue frontChannelLogoutTokens = new LinkedBlockingDeque<>(); private BlockingQueue pushNotBeforeActions = new LinkedBlockingDeque<>(); private BlockingQueue testAvailabilityActions = new LinkedBlockingDeque<>(); diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestApplicationResource.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestApplicationResource.java index c0d4d3a594..698741083e 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestApplicationResource.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestApplicationResource.java @@ -45,6 +45,11 @@ public interface TestApplicationResource { @Path("/poll-backchannel-logout") LogoutToken getBackChannelLogoutToken(); + @GET + @Produces(MediaType.APPLICATION_JSON) + @Path("/poll-backchannel-raw-logout") + String getBackChannelRawLogoutToken(); + @GET @Produces(MediaType.APPLICATION_JSON) @Path("/poll-frontchannel-logout") diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTokenExchangeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTokenExchangeTest.java index 6899c587ce..c7f78de3b2 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTokenExchangeTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerTokenExchangeTest.java @@ -23,12 +23,15 @@ import static org.hamcrest.Matchers.notNullValue; import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_ALIAS; import static org.keycloak.testsuite.util.ProtocolMapperUtil.createHardcodedClaim; +import java.util.concurrent.TimeUnit; + import jakarta.ws.rs.client.Client; import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.client.WebTarget; import jakarta.ws.rs.core.Form; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; import org.junit.Test; import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.resource.ClientResource; @@ -45,13 +48,16 @@ import org.keycloak.models.IdentityProviderMapperSyncMode; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; +import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.IdentityProviderMapperRepresentation; +import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.authorization.ClientPolicyRepresentation; import org.keycloak.services.resources.admin.permissions.AdminPermissionManagement; import org.keycloak.services.resources.admin.permissions.AdminPermissions; +import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.arquillian.annotation.EnableFeatures; import org.keycloak.testsuite.util.AdminClientUtil; @@ -127,6 +133,61 @@ public final class KcOidcBrokerTokenExchangeTest extends AbstractInitializedBase } } + @Test + public void testSupportedTokenTypesWhenValidatingSubjectToken() throws Exception { + testingClient.server().run(KcOidcBrokerTokenExchangeTest::setupRealm); + RealmResource providerRealm = realmsResouce().realm(bc.providerRealmName()); + ClientsResource clients = providerRealm.clients(); + ClientRepresentation brokerApp = clients.findByClientId("brokerapp").get(0); + brokerApp.setDirectAccessGrantsEnabled(true); + ClientResource brokerAppResource = providerRealm.clients().get(brokerApp.getId()); + brokerAppResource.update(brokerApp); + RealmResource consumerRealm = realmsResouce().realm(bc.consumerRealmName()); + IdentityProviderResource identityProviderResource = consumerRealm.identityProviders().get(bc.getIDPAlias()); + IdentityProviderRepresentation idpRep = identityProviderResource.toRepresentation(); + idpRep.getConfig().put("disableUserInfo", "true"); + identityProviderResource.update(idpRep); + getCleanup().addCleanup(() -> { + idpRep.getConfig().put("disableUserInfo", "false"); + identityProviderResource.update(idpRep); + }); + + OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest(bc.providerRealmName(), bc.getUserLogin(), bc.getUserPassword(), null, brokerApp.getClientId(), brokerApp.getSecret()); + assertThat(tokenResponse.getIdToken(), notNullValue()); + String idTokenString = tokenResponse.getIdToken(); + oauth.realm(bc.providerRealmName()); + String logoutUrl = oauth.getLogoutUrl().idTokenHint(idTokenString) + .postLogoutRedirectUri(oauth.APP_AUTH_ROOT).build(); + driver.navigate().to(logoutUrl); + String logoutToken = testingClient.testApp().getBackChannelRawLogoutToken(); + Assert.assertNotNull(logoutToken); + + Client httpClient = AdminClientUtil.createResteasyClient(); + try { + WebTarget exchangeUrl = httpClient.target(OAuthClient.AUTH_SERVER_ROOT) + .path("/realms") + .path(bc.consumerRealmName()) + .path("protocol/openid-connect/token"); + // test user info validation. + try (Response response = exchangeUrl.request() + .header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader( + "test-app", "secret")) + .post(Entity.form( + new Form() + .param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE) + .param(OAuth2Constants.SUBJECT_TOKEN, logoutToken) + .param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.JWT_TOKEN_TYPE) + .param(OAuth2Constants.SUBJECT_ISSUER, bc.getIDPAlias()) + .param(OAuth2Constants.SCOPE, OAuth2Constants.SCOPE_OPENID) + + ))) { + assertThat(response.getStatus(), equalTo(Status.BAD_REQUEST.getStatusCode())); + } + } finally { + httpClient.close(); + } + } + private static void setupRealm(KeycloakSession session) { RealmModel realm = session.realms().getRealmByName(BrokerTestConstants.REALM_CONS_NAME); IdentityProviderModel idp = realm.getIdentityProviderByAlias(IDP_OIDC_ALIAS); @@ -149,5 +210,10 @@ public final class KcOidcBrokerTokenExchangeTest extends AbstractInitializedBase ResourceServer server = management.realmResourceServer(); Policy clientPolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientRep); management.idps().exchangeToPermission(idp).addAssociatedPolicy(clientPolicy); + + realm = session.realms().getRealmByName(BrokerTestConstants.REALM_PROV_NAME); + client = realm.getClientByClientId("brokerapp"); + client.addRedirectUri(OAuthClient.APP_ROOT + "/auth"); + client.setAttribute(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, OAuthClient.APP_ROOT + "/admin/backchannelLogout"); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientTokenExchangeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientTokenExchangeTest.java index 5e8a75debf..3620e88417 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientTokenExchangeTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientTokenExchangeTest.java @@ -17,11 +17,13 @@ package org.keycloak.testsuite.oauth; +import jakarta.ws.rs.core.Response.Status; import org.junit.Rule; import org.junit.Test; import org.keycloak.OAuth2Constants; import org.keycloak.TokenVerifier; import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.admin.client.resource.ClientsResource; import org.keycloak.authorization.model.Policy; import org.keycloak.authorization.model.ResourceServer; import org.keycloak.common.Profile; @@ -65,6 +67,7 @@ import jakarta.ws.rs.core.Response; import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import static org.hamcrest.Matchers.instanceOf; import static org.junit.Assert.assertEquals; @@ -179,6 +182,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { directLegal.setSecret("secret"); directLegal.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); directLegal.setFullScopeAllowed(false); + directLegal.addRedirectUri(OAuthClient.APP_ROOT + "/auth"); ClientModel directPublic = realm.addClient("direct-public"); directPublic.setClientId("direct-public"); @@ -1017,6 +1021,36 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { testExchange(); } + @Test + public void testSupportedTokenTypesWhenValidatingSubjectToken() throws Exception { + testingClient.server().run(ClientTokenExchangeTest::setupRealm); + oauth.realm(TEST); + oauth.clientId("direct-legal"); + oauth.scope(OAuth2Constants.SCOPE_OPENID); + ClientsResource clients = adminClient.realm(oauth.getRealm()).clients(); + ClientRepresentation rep = clients.findByClientId(oauth.getClientId()).get(0); + rep.getAttributes().put(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, oauth.APP_ROOT + "/admin/backchannelLogout"); + getCleanup().addCleanup(() -> { + rep.getAttributes().put(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, ""); + clients.get(rep.getId()).update(rep); + }); + clients.get(rep.getId()).update(rep); + String logoutToken; + oauth.clientSessionState("client-session"); + oauth.doLogin("user", "password"); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "secret"); + String idTokenString = tokenResponse.getIdToken(); + String logoutUrl = oauth.getLogoutUrl().idTokenHint(idTokenString) + .postLogoutRedirectUri(oauth.APP_AUTH_ROOT).build(); + driver.navigate().to(logoutUrl); + logoutToken = testingClient.testApp().getBackChannelRawLogoutToken(); + Assert.assertNotNull(logoutToken); + OAuthClient.AccessTokenResponse response = oauth.doTokenExchange(TEST, logoutToken, "target", "direct-legal", "secret"); + assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusCode()); + + } + private static void addDirectExchanger(KeycloakSession session) { RealmModel realm = session.realms().getRealmByName(TEST); RoleModel exampleRole = realm.addRole("example");