diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties
index 8ff9d7e2490..0c4056691dd 100644
--- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties
+++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties
@@ -431,6 +431,7 @@ guiOrder=Display Order
friendlyName=Friendly name of attribute to search for in assertion. You can leave this blank and specify a name instead.
testSuccess=Successfully connected to LDAP
userInfoUrl=User Info URL
+tokenIntrospectionUrl=Token Introspection URL
displayOnConsentScreen=Display on consent screen
noClientPolicies=No client policies
defaultAdminInitiatedActionLifespanHelp=Maximum time before an action permit sent to a user by administrator is expired. This value is recommended to be long to allow administrators to send e-mails for users that are currently offline. The default timeout can be overridden immediately before issuing the token.
diff --git a/js/apps/admin-ui/src/identity-providers/add/DiscoverySettings.tsx b/js/apps/admin-ui/src/identity-providers/add/DiscoverySettings.tsx
index 3a4146d442d..c90975d1ffc 100644
--- a/js/apps/admin-ui/src/identity-providers/add/DiscoverySettings.tsx
+++ b/js/apps/admin-ui/src/identity-providers/add/DiscoverySettings.tsx
@@ -71,6 +71,12 @@ const Fields = ({ readOnly, isOIDC }: DiscoverySettingsProps) => {
required: isOIDC ? "" : t("required"),
}}
/>
+
{isOIDC && (
{
await assertInvalidUrlNotification(page, "token");
await clickRevertButton(page);
+ await setUrl(page, "tokenIntrospection", "invalid");
+ await clickSaveButton(page);
+ await assertInvalidUrlNotification(page, "tokenIntrospection");
+ await clickRevertButton(page);
+
await assertJwksUrlExists(page);
- await switchOff(page, "#config\\.useJwksUrl");
+ await page.getByText("Use JWKS URL").click();
await assertJwksUrlExists(page, false);
await assertPkceMethodExists(page, false);
diff --git a/services/src/main/java/org/keycloak/broker/oauth/OAuth2IdentityProvider.java b/services/src/main/java/org/keycloak/broker/oauth/OAuth2IdentityProvider.java
index 358870206b5..f2ce8cf672a 100755
--- a/services/src/main/java/org/keycloak/broker/oauth/OAuth2IdentityProvider.java
+++ b/services/src/main/java/org/keycloak/broker/oauth/OAuth2IdentityProvider.java
@@ -27,6 +27,7 @@ import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.IdentityBrokerException;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.models.KeycloakSession;
+import org.keycloak.protocol.oidc.TokenExchangeContext;
import java.io.IOException;
@@ -91,6 +92,14 @@ public class OAuth2IdentityProvider extends AbstractOAuth2IdentityProvider tokenAudiences = null;
+ if (tokenClientId != null && tokenClientId.equals(getConfig().getClientId())) {
+ // Consider external token valid if issued to same client, which was configured as the client on IDP side
+ clientValid = true;
+ } else if (tokenMetadata.getAudience() != null && tokenMetadata.getAudience().length > 0) {
+ tokenAudiences = Arrays.stream(tokenMetadata.getAudience()).toList();
+ if (tokenAudiences.contains(getConfig().getClientId())) {
+ // Consider external token valid if client configured as the IDP client included in token audience
+ clientValid = true;
+ } else {
+ // Consider valid introspection also if token contains audience where URL is Keycloak server (either as issuer or as token-endpoint URL).
+ // Aligned with https://datatracker.ietf.org/doc/html/rfc7523#section-3 - point 3
+ UriInfo frontendUriInfo = session.getContext().getUri(UrlType.FRONTEND);
+ UriInfo backendUriInfo = session.getContext().getUri(UrlType.BACKEND);
+ RealmModel realm = session.getContext().getRealm();
+ String realmIssuer = Urls.realmIssuer(frontendUriInfo.getBaseUri(), realm.getName());
+ String realmTokenUrl = RealmsResource.protocolUrl(backendUriInfo).clone()
+ .path(OIDCLoginProtocolService.class, "token")
+ .build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString();
+ if (tokenAudiences.contains(realmIssuer) || tokenAudiences.contains(realmTokenUrl)) {
+ clientValid = true;
+ }
+ }
+ }
+ if (!clientValid) {
+ logger.debugf("Token not authorized for token exchange. Token client Id: %s, Token audiences: %s", tokenClientId, tokenAudiences);
+ throwErrorResponse(event, Errors.INVALID_TOKEN, OAuthErrorException.INVALID_TOKEN, "Token not authorized for token exchange");
+ }
+ }
+
+ /**
+ * Send introspection request as specified in the OAuth2 token introspection specification. It requires
+ *
+ * @param idpAccessToken access token issued by the IDP
+ * @param event event builder
+ * @return token metadata in case that token introspection was successful and token is valid and active
+ * @throws ErrorResponseException in case that introspection response was not correct for any reason (other status than 200) or the token was not active
+ */
+ protected TokenMetadataRepresentation sendTokenIntrospectionRequest(String idpAccessToken, EventBuilder event) {
+ String introspectionEndointUrl = getConfig().getTokenIntrospectionUrl();
+ if (introspectionEndointUrl == null) {
+ throwErrorResponse(event, Errors.INVALID_CONFIG, OAuthErrorException.INVALID_REQUEST, "Introspection endpoint not configured for IDP");
+ }
+
+ try {
+
+ // Supporting only access-tokens for now
+ SimpleHttp introspectionRequest = SimpleHttp.doPost(introspectionEndointUrl, session)
+ .param(TokenIntrospectionEndpoint.PARAM_TOKEN, idpAccessToken)
+ .param(TokenIntrospectionEndpoint.PARAM_TOKEN_TYPE_HINT, AccessTokenIntrospectionProviderFactory.ACCESS_TOKEN_TYPE);
+ introspectionRequest = authenticateTokenRequest(introspectionRequest);
+
+ try (SimpleHttp.Response introspectionResponse = introspectionRequest.asResponse()) {
+ int status = introspectionResponse.getStatus();
+
+ if (status != 200) {
+ try {
+ logger.warnf("Failed to invoke introspection endpoint. Status: %d, Introspection response details: %s", status, introspectionResponse.asString());
+ } catch (Exception ioe) {
+ logger.warnf("Failed to invoke introspection endpoint. Status: %d", status);
+ }
+ throwErrorResponse(event, Errors.INVALID_REQUEST, OAuthErrorException.INVALID_REQUEST, "Introspection endpoint call failure. Introspection response status: " + status);
+ }
+
+ TokenMetadataRepresentation tokenMetadata = null;
+ try {
+ tokenMetadata = introspectionResponse.asJson(TokenMetadataRepresentation.class);
+ } catch (IOException e) {
+ throwErrorResponse(event, Errors.INVALID_TOKEN, OAuthErrorException.INVALID_TOKEN, "Invalid format of the introspection response");
+ }
+
+ if (!tokenMetadata.isActive()) {
+ throwErrorResponse(event, Errors.INVALID_TOKEN, OAuthErrorException.INVALID_TOKEN, "Token not active");
+ }
+
+ return tokenMetadata;
+ }
+ } catch (IOException e) {
+ logger.debug("Failed to invoke introspection endpoint", e);
+ throwErrorResponse(event, Errors.INVALID_TOKEN, OAuthErrorException.INVALID_TOKEN, "Failed to invoke introspection endpoint");
+ return null; // Unreachable
+ }
+ }
+
+ private void throwErrorResponse(EventBuilder event, String eventError, String oauthError, String errorDetails) {
+ event.detail(Details.REASON, errorDetails);
+ event.error(eventError);
+ throw new ErrorResponseException(oauthError, errorDetails, Response.Status.BAD_REQUEST);
+ }
+
protected BrokeredIdentityContext exchangeExternalUserInfoValidationOnly(EventBuilder event, MultivaluedMap params) {
String subjectToken = params.getFirst(OAuth2Constants.SUBJECT_TOKEN);
if (subjectToken == null) {
diff --git a/services/src/main/java/org/keycloak/broker/oidc/OAuth2IdentityProviderConfig.java b/services/src/main/java/org/keycloak/broker/oidc/OAuth2IdentityProviderConfig.java
index 611c1f19d70..ad03d43e0cc 100644
--- a/services/src/main/java/org/keycloak/broker/oidc/OAuth2IdentityProviderConfig.java
+++ b/services/src/main/java/org/keycloak/broker/oidc/OAuth2IdentityProviderConfig.java
@@ -34,6 +34,8 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
public static final String PKCE_ENABLED = "pkceEnabled";
public static final String PKCE_METHOD = "pkceMethod";
+ public static final String TOKEN_ENDPOINT_URL = "tokenUrl";
+ public static final String TOKEN_INTROSPECTION_URL = "tokenIntrospectionUrl";
public static final String JWT_X509_HEADERS_ENABLED = "jwtX509HeadersEnabled";
@@ -54,11 +56,11 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
}
public String getTokenUrl() {
- return getConfig().get("tokenUrl");
+ return getConfig().get(TOKEN_ENDPOINT_URL);
}
public void setTokenUrl(String tokenUrl) {
- getConfig().put("tokenUrl", tokenUrl);
+ getConfig().put(TOKEN_ENDPOINT_URL, tokenUrl);
}
public String getUserInfoUrl() {
@@ -69,6 +71,14 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
getConfig().put("userInfoUrl", userInfoUrl);
}
+ public String getTokenIntrospectionUrl() {
+ return getConfig().get(TOKEN_INTROSPECTION_URL);
+ }
+
+ public void setTokenIntrospectionUrl(String introspectionEndpointUrl) {
+ getConfig().put(TOKEN_INTROSPECTION_URL, introspectionEndpointUrl);
+ }
+
public String getClientId() {
return getConfig().get("clientId");
}
@@ -209,6 +219,7 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
checkUrl(sslRequired, getAuthorizationUrl(), "authorization_url");
checkUrl(sslRequired, getTokenUrl(), "token_url");
checkUrl(sslRequired, getUserInfoUrl(), "userinfo_url");
+ checkUrl(sslRequired, getTokenIntrospectionUrl(), "tokenIntrospection_url");
if (isPkceEnabled()) {
String pkceMethod = getPkceMethod();
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 eab1b532736..84591004987 100755
--- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java
+++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java
@@ -53,6 +53,7 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
+import org.keycloak.protocol.oidc.TokenExchangeContext;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.JsonWebToken;
@@ -956,6 +957,14 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider 1) {
+ if (shouldUseVersionedKey(feature)) {
featureString = feature.getVersionedKey();
} else {
featureString = feature.getKey();
@@ -96,9 +96,13 @@ public class KeycloakTestingClient implements AutoCloseable {
ProfileAssume.updateDisabledFeatures(disabledFeatures);
}
+ private boolean shouldUseVersionedKey(Profile.Feature feature) {
+ return ((Profile.getFeatureVersions(feature.getUnversionedKey()).size() > 1) || (feature.getVersion() != 1));
+ }
+
public void disableFeature(Profile.Feature feature) {
String featureString;
- if (Profile.getFeatureVersions(feature.getUnversionedKey()).size() > 1) {
+ if (shouldUseVersionedKey(feature)) {
featureString = feature.getVersionedKey();
} else {
featureString = feature.getKey();
@@ -115,7 +119,7 @@ public class KeycloakTestingClient implements AutoCloseable {
*/
public void resetFeature(Profile.Feature feature) {
String featureString;
- if (Profile.getFeatureVersions(feature.getUnversionedKey()).size() > 1) {
+ if (shouldUseVersionedKey(feature)) {
featureString = feature.getVersionedKey();
Profile.Feature featureVersionHighestPriority = Profile.getFeatureVersions(feature.getUnversionedKey()).iterator().next();
if (featureVersionHighestPriority.getType().equals(Profile.Feature.Type.DEFAULT)) {
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerConfiguration.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerConfiguration.java
index cea5513658d..7b057ddede6 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerConfiguration.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/KcOidcBrokerConfiguration.java
@@ -22,6 +22,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
+import static org.keycloak.broker.oidc.OAuth2IdentityProviderConfig.TOKEN_ENDPOINT_URL;
import static org.keycloak.testsuite.broker.BrokerTestConstants.*;
import static org.keycloak.testsuite.broker.BrokerTestTools.*;
@@ -204,7 +205,7 @@ public class KcOidcBrokerConfiguration implements BrokerConfiguration {
config.put("loginHint", "true");
config.put(OIDCIdentityProviderConfig.ISSUER, getProviderRoot() + "/auth/realms/" + REALM_PROV_NAME);
config.put("authorizationUrl", getProviderRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/auth");
- config.put("tokenUrl", getProviderRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/token");
+ config.put(TOKEN_ENDPOINT_URL, getProviderRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/token");
config.put("logoutUrl", getProviderRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/logout");
config.put("userInfoUrl", getProviderRoot() + "/auth/realms/" + REALM_PROV_NAME + "/protocol/openid-connect/userinfo");
config.put("defaultScope", "email profile");
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 c034ca3c8e8..9fb1c4b075d 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
@@ -80,7 +80,7 @@ import org.keycloak.testsuite.util.ServerURLs;
import org.keycloak.util.BasicAuthHelper;
/**
- * Test for identity-provider token exchange scenarios. Base for tests of token-exchange V1 as well as token-exchange-federated V2
+ * Test for identity-provider token exchange scenarios. Base for tests of token-exchange V1
*/
@EnableFeatures({@EnableFeature(Profile.Feature.TOKEN_EXCHANGE), @EnableFeature(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ)})
public class KcOidcBrokerTokenExchangeTest extends AbstractInitializedBaseBrokerTest {
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/ExternalInternalTokenExchangeV2Test.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/ExternalInternalTokenExchangeV2Test.java
new file mode 100644
index 00000000000..629fb8994a2
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/ExternalInternalTokenExchangeV2Test.java
@@ -0,0 +1,327 @@
+/*
+ * Copyright 2025 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.keycloak.testsuite.oauth.tokenexchange;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+
+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 org.junit.Test;
+import org.keycloak.OAuth2Constants;
+import org.keycloak.admin.client.resource.ClientsResource;
+import org.keycloak.admin.client.resource.IdentityProviderResource;
+import org.keycloak.admin.client.resource.RealmResource;
+import org.keycloak.authorization.model.Policy;
+import org.keycloak.authorization.model.ResourceServer;
+import org.keycloak.common.Profile;
+import org.keycloak.models.ClientModel;
+import org.keycloak.models.IdentityProviderModel;
+import org.keycloak.models.IdentityProviderSyncMode;
+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.protocol.oidc.mappers.AudienceProtocolMapper;
+import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
+import org.keycloak.representations.AccessTokenResponse;
+import org.keycloak.representations.idm.ClientRepresentation;
+import org.keycloak.representations.idm.IdentityProviderRepresentation;
+import org.keycloak.representations.idm.ProtocolMapperRepresentation;
+import org.keycloak.representations.idm.authorization.ClientPolicyRepresentation;
+import org.keycloak.services.resources.admin.fgap.AdminPermissionManagement;
+import org.keycloak.services.resources.admin.fgap.AdminPermissions;
+import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
+import org.keycloak.testsuite.arquillian.annotation.EnableFeatures;
+import org.keycloak.testsuite.broker.AbstractInitializedBaseBrokerTest;
+import org.keycloak.testsuite.broker.BrokerConfiguration;
+import org.keycloak.testsuite.broker.BrokerTestConstants;
+import org.keycloak.testsuite.broker.KcOidcBrokerConfiguration;
+import org.keycloak.testsuite.util.AdminClientUtil;
+import org.keycloak.testsuite.util.oauth.OAuthClient;
+import org.keycloak.util.BasicAuthHelper;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+import static org.junit.Assert.assertEquals;
+import static org.keycloak.broker.oidc.OAuth2IdentityProviderConfig.TOKEN_INTROSPECTION_URL;
+import static org.keycloak.broker.oidc.OAuth2IdentityProviderConfig.TOKEN_ENDPOINT_URL;
+import static org.keycloak.testsuite.broker.BrokerTestConstants.CLIENT_ID;
+import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_ALIAS;
+import static org.keycloak.testsuite.broker.BrokerTestConstants.REALM_CONS_NAME;
+import static org.keycloak.testsuite.broker.BrokerTestTools.getProviderRoot;
+
+/**
+ * Test for external-internal token exchange using token_exchange_external_internal:v2
+ *
+ * @author Marek Posolda
+ */
+// TODO: Remove fine grained admin permissions should not be needed. They are neded now for token_exchange_external_internal:v2, but should not be needed in the future
+@EnableFeatures({@EnableFeature(Profile.Feature.TOKEN_EXCHANGE_EXTERNAL_INTERNAL_V2), @EnableFeature(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ)})
+public class ExternalInternalTokenExchangeV2Test extends AbstractInitializedBaseBrokerTest {
+
+ @Override
+ protected BrokerConfiguration getBrokerConfiguration() {
+ return new KcOidcBrokerConfiguration() {
+
+ @Override
+ protected void applyDefaultConfiguration(Map config, IdentityProviderSyncMode syncMode) {
+ super.applyDefaultConfiguration(config, syncMode);
+ config.put(TOKEN_INTROSPECTION_URL, config.get(TOKEN_ENDPOINT_URL) + "/introspect");
+ }
+
+ @Override
+ public List createProviderClients() {
+ List providerClients = super.createProviderClients();
+ ClientRepresentation brokerApp = providerClients.stream()
+ .filter(client -> CLIENT_ID.equals(client.getClientId()))
+ .findFirst().get();
+ brokerApp.setDirectAccessGrantsEnabled(true);
+
+ ClientRepresentation client2 = createProviderClientWithAudienceMapper("client-with-brokerapp-audience", CLIENT_ID);
+ ClientRepresentation client3 = createProviderClientWithAudienceMapper("client-with-consumer-realm-issuer-audience", getProviderRoot() + "/auth/realms/" + REALM_CONS_NAME);
+ ClientRepresentation client4 = createProviderClientWithAudienceMapper("client-without-valid-audience", "some-random-audience");
+
+ providerClients = new ArrayList<>(providerClients);
+ providerClients.addAll(Arrays.asList(client2, client3, client4));
+ return providerClients;
+ }
+
+ private ClientRepresentation createProviderClientWithAudienceMapper(String clientId, String hardcodedAudience) {
+ ClientRepresentation client = new ClientRepresentation();
+ client.setClientId(clientId);
+ client.setSecret("secret");
+ client.setDirectAccessGrantsEnabled(true);
+
+ ProtocolMapperRepresentation hardcodedAudienceMapper = new ProtocolMapperRepresentation();
+ hardcodedAudienceMapper.setName("audience");
+ hardcodedAudienceMapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
+ hardcodedAudienceMapper.setProtocolMapper(AudienceProtocolMapper.PROVIDER_ID);
+
+ Map hardcodedAudienceMapperConfig = hardcodedAudienceMapper.getConfig();
+ hardcodedAudienceMapperConfig.put(AudienceProtocolMapper.INCLUDED_CUSTOM_AUDIENCE, hardcodedAudience);
+ hardcodedAudienceMapperConfig.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
+
+ client.setProtocolMappers(Collections.singletonList(hardcodedAudienceMapper));
+ return client;
+ }
+
+ };
+ }
+
+ private static void setupRealm(KeycloakSession session) {
+ RealmModel realm = session.getContext().getRealm();
+ IdentityProviderModel idp = session.identityProviders().getByAlias(IDP_OIDC_ALIAS);
+ org.junit.Assert.assertNotNull(idp);
+
+ ClientModel client = realm.addClient("test-app");
+ client.setClientId("test-app");
+ client.setPublicClient(false);
+ client.setDirectAccessGrantsEnabled(true);
+ client.setEnabled(true);
+ client.setSecret("secret");
+ client.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
+ client.setFullScopeAllowed(false);
+ client.setRedirectUris(Set.of(OAuthClient.AUTH_SERVER_ROOT + "/*"));
+
+ ClientModel brokerApp = realm.getClientByClientId("broker-app");
+
+ AdminPermissionManagement management = AdminPermissions.management(session, realm);
+ management.idps().setPermissionsEnabled(idp, true);
+ ClientPolicyRepresentation clientRep = new ClientPolicyRepresentation();
+ clientRep.setName("toIdp");
+ clientRep.addClient(client.getId(), brokerApp.getId());
+ 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");
+ }
+
+
+ @Test
+ public void testSuccess_externalTokenIssuedToBrokerClient() throws Exception {
+ ClientRepresentation brokerApp = getBrokerAppClient();
+
+ // Send initial direct-grant request
+ org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = oauth.realm(bc.providerRealmName()).client(brokerApp.getClientId(), brokerApp.getSecret()).doPasswordGrantRequest(bc.getUserLogin(), bc.getUserPassword());
+ assertThat(tokenResponse.getIdToken(), notNullValue());
+
+ testingClient.server(BrokerTestConstants.REALM_CONS_NAME).run(ExternalInternalTokenExchangeV2Test::setupRealm);
+
+ // Send token-exchange
+ testTokenExchange(tokenResponse.getAccessToken(), (tokenExchangeResponse) -> {
+ assertThat(tokenExchangeResponse.getStatus(), equalTo(200));
+ AccessTokenResponse externalToInternalTokenResponse = tokenExchangeResponse.readEntity(AccessTokenResponse.class);
+ assertThat(externalToInternalTokenResponse.getToken(), notNullValue());
+ });
+ }
+
+
+ @Test
+ public void testSuccess_brokerClientAsAudienceOfExternalToken() throws Exception {
+ // Send initial direct-grant request. Token is issued to the "client-with-brokerapp-audience". Client "brokerapp" is available inside token audience
+ org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = oauth.realm(bc.providerRealmName()).client("client-with-brokerapp-audience", "secret").doPasswordGrantRequest(bc.getUserLogin(), bc.getUserPassword());
+ assertThat(tokenResponse.getIdToken(), notNullValue());
+
+ testingClient.server(BrokerTestConstants.REALM_CONS_NAME).run(ExternalInternalTokenExchangeV2Test::setupRealm);
+
+ testTokenExchange(tokenResponse.getAccessToken(), (tokenExchangeResponse) -> {
+ assertThat(tokenExchangeResponse.getStatus(), equalTo(200));
+ AccessTokenResponse externalToInternalTokenResponse = tokenExchangeResponse.readEntity(AccessTokenResponse.class);
+ assertThat(externalToInternalTokenResponse.getToken(), notNullValue());
+ });
+ }
+
+
+ @Test
+ public void testSuccess_consumerRealmIssuerAsAudienceOfExternalToken() throws Exception {
+ // Send initial direct-grant request. Token is issued to the "client-with-consumer-realm-issuer-audience". Consumer realm is available inside token audience and hence token considered as valid external token for the token exchange
+ org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = oauth.realm(bc.providerRealmName()).client("client-with-consumer-realm-issuer-audience", "secret").doPasswordGrantRequest(bc.getUserLogin(), bc.getUserPassword());
+ assertThat(tokenResponse.getIdToken(), notNullValue());
+
+ testingClient.server(BrokerTestConstants.REALM_CONS_NAME).run(ExternalInternalTokenExchangeV2Test::setupRealm);
+
+ testTokenExchange(tokenResponse.getAccessToken(), (tokenExchangeResponse) -> {
+ assertThat(tokenExchangeResponse.getStatus(), equalTo(200));
+ AccessTokenResponse externalToInternalTokenResponse = tokenExchangeResponse.readEntity(AccessTokenResponse.class);
+ assertThat(externalToInternalTokenResponse.getToken(), notNullValue());
+ });
+ }
+
+
+ @Test
+ public void testFailure_externalTokenIssuedToInvalidClient() throws Exception {
+ // Send initial direct-grant request. Token is issued to the "client-without-valid-audience". This external token will fail token-exchange as token is not issued to brokerapp and there is not any valid audience available
+ org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = oauth.realm(bc.providerRealmName()).client("client-without-valid-audience", "secret").doPasswordGrantRequest(bc.getUserLogin(), bc.getUserPassword());
+ assertThat(tokenResponse.getIdToken(), notNullValue());
+
+ testingClient.server(BrokerTestConstants.REALM_CONS_NAME).run(ExternalInternalTokenExchangeV2Test::setupRealm);
+
+ testTokenExchange(tokenResponse.getAccessToken(), (tokenExchangeResponse) -> {
+ assertThat(tokenExchangeResponse.getStatus(), equalTo(400));
+ AccessTokenResponse externalToInternalTokenResponse = tokenExchangeResponse.readEntity(AccessTokenResponse.class);
+ assertThat(externalToInternalTokenResponse.getToken(), nullValue());
+ assertEquals("Token not authorized for token exchange", externalToInternalTokenResponse.getErrorDescription());
+ });
+ }
+
+ @Test
+ public void testFailure_externalTokenIntrospectionFailureDueInvalidClientCredentials() throws Exception {
+ // Update IDP and set invalid credentials there
+ IdentityProviderResource idpResource = adminClient.realm(REALM_CONS_NAME).identityProviders().get(IDP_OIDC_ALIAS);
+ IdentityProviderRepresentation idpRep = idpResource.toRepresentation();
+ idpRep.getConfig().put("clientSecret", "invalid");
+ idpResource.update(idpRep);
+
+ ClientRepresentation brokerApp = getBrokerAppClient();
+
+ try {
+ org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = oauth.realm(bc.providerRealmName()).client(brokerApp.getClientId(), brokerApp.getSecret()).doPasswordGrantRequest(bc.getUserLogin(), bc.getUserPassword());
+ assertThat(tokenResponse.getIdToken(), notNullValue());
+
+ testingClient.server(BrokerTestConstants.REALM_CONS_NAME).run(ExternalInternalTokenExchangeV2Test::setupRealm);
+
+ testTokenExchange(tokenResponse.getAccessToken(), (tokenExchangeResponse) -> {
+ assertThat(tokenExchangeResponse.getStatus(), equalTo(400));
+ AccessTokenResponse externalToInternalTokenResponse = tokenExchangeResponse.readEntity(AccessTokenResponse.class);
+ assertThat(externalToInternalTokenResponse.getToken(), nullValue());
+ assertEquals("Introspection endpoint call failure. Introspection response status: 401", externalToInternalTokenResponse.getErrorDescription());
+ });
+ } finally {
+ // Revert IDP config
+ idpRep.getConfig().put("clientSecret", brokerApp.getSecret());
+ idpResource.update(idpRep);
+ }
+ }
+
+
+ @Test
+ public void testFailure_inactiveExternalToken() throws Exception {
+ ClientRepresentation brokerApp = getBrokerAppClient();
+ org.keycloak.testsuite.util.oauth.AccessTokenResponse tokenResponse = oauth.realm(bc.providerRealmName()).client(brokerApp.getClientId(), brokerApp.getSecret()).doPasswordGrantRequest(bc.getUserLogin(), bc.getUserPassword());
+ assertThat(tokenResponse.getIdToken(), notNullValue());
+
+ testingClient.server(BrokerTestConstants.REALM_CONS_NAME).run(ExternalInternalTokenExchangeV2Test::setupRealm);
+
+ setTimeOffset(3600);
+
+ testTokenExchange(tokenResponse.getAccessToken(), (tokenExchangeResponse) -> {
+ assertThat(tokenExchangeResponse.getStatus(), equalTo(400));
+ AccessTokenResponse externalToInternalTokenResponse = tokenExchangeResponse.readEntity(AccessTokenResponse.class);
+ assertThat(externalToInternalTokenResponse.getToken(), nullValue());
+ assertEquals("Token not active", externalToInternalTokenResponse.getErrorDescription());
+ });
+ }
+
+ private void testTokenExchange(String subjectToken, Consumer tokenExchangeResponseConsumer) {
+ // Send token-exchange
+ try (Client httpClient = AdminClientUtil.createResteasyClient()) {
+ WebTarget exchangeUrl = getConsumerTokenEndpoint(httpClient);
+
+ String subjectTokenType = OAuth2Constants.ACCESS_TOKEN_TYPE; // hardcoded to access-token just for now. More types might need to be tested...
+ try (Response response = sendExternalInternalTokenExchangeRequest(exchangeUrl, subjectToken, subjectTokenType)) {
+ tokenExchangeResponseConsumer.accept(response);
+ }
+ }
+ }
+
+ private Response sendExternalInternalTokenExchangeRequest(WebTarget exchangeUrl, String subjectToken, String subjectTokenType) {
+ return 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, subjectToken)
+ .param(OAuth2Constants.SUBJECT_TOKEN_TYPE, subjectTokenType)
+ .param(OAuth2Constants.SUBJECT_ISSUER, bc.getIDPAlias())
+ .param(OAuth2Constants.SCOPE, OAuth2Constants.SCOPE_OPENID)
+
+ ));
+ }
+
+ private WebTarget getConsumerTokenEndpoint(Client httpClient) {
+ return httpClient.target(OAuthClient.AUTH_SERVER_ROOT)
+ .path("/realms")
+ .path(bc.consumerRealmName())
+ .path("protocol/openid-connect/token");
+ }
+
+ private ClientRepresentation getBrokerAppClient() {
+ RealmResource providerRealm = realmsResouce().realm(bc.providerRealmName());
+ ClientsResource clients = providerRealm.clients();
+ return clients.findByClientId("brokerapp").get(0);
+ }
+}