From f7cfbfa3d56601de1fb2532ca457ea17502c9794 Mon Sep 17 00:00:00 2001 From: mposolda Date: Tue, 1 Jul 2025 19:34:20 +0200 Subject: [PATCH] Verification of external OIDC token by introspection-endpoint. Adding ExternalInternalTokenExchangeV2Test closes #40167 closes #40198 Signed-off-by: mposolda --- .../admin/messages/messages_en.properties | 1 + .../add/DiscoverySettings.tsx | 6 + .../admin-ui/test/identity-providers/main.ts | 1 + .../test/identity-providers/oidc.spec.ts | 9 +- .../broker/oauth/OAuth2IdentityProvider.java | 9 + .../oauth/OAuth2IdentityProviderFactory.java | 6 + .../oidc/AbstractOAuth2IdentityProvider.java | 112 ++++++ .../oidc/OAuth2IdentityProviderConfig.java | 15 +- .../broker/oidc/OIDCIdentityProvider.java | 9 + .../oidc/OIDCIdentityProviderFactory.java | 6 + .../oidc/mappers/AudienceProtocolMapper.java | 2 +- .../client/KeycloakTestingClient.java | 10 +- .../broker/KcOidcBrokerConfiguration.java | 3 +- .../broker/KcOidcBrokerTokenExchangeTest.java | 2 +- .../ExternalInternalTokenExchangeV2Test.java | 327 ++++++++++++++++++ 15 files changed, 508 insertions(+), 10 deletions(-) create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/ExternalInternalTokenExchangeV2Test.java 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 8ff9d7e24907..0c4056691dd8 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 3a4146d442d8..c90975d1ffc8 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 358870206b52..f2ce8cf672ad 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.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 @@ protected BrokeredIdentityContext doGetFederatedIdentity(String accessToken) { return identity; } + @Override + protected BrokeredIdentityContext exchangeExternalTokenV2Impl(TokenExchangeContext tokenExchangeContext) { + // Supporting only introspection-endpoint validation for now + validateExternalTokenWithIntrospectionEndpoint(tokenExchangeContext); + + return exchangeExternalUserInfoValidationOnly(tokenExchangeContext.getEvent(), tokenExchangeContext.getFormParams()); + } + private JsonNode fetchUserProfile(String accessToken) { String userInfoUrl = getConfig().getUserInfoUrl(); diff --git a/services/src/main/java/org/keycloak/broker/oauth/OAuth2IdentityProviderFactory.java b/services/src/main/java/org/keycloak/broker/oauth/OAuth2IdentityProviderFactory.java index ba50d044895c..4de3e5960233 100755 --- a/services/src/main/java/org/keycloak/broker/oauth/OAuth2IdentityProviderFactory.java +++ b/services/src/main/java/org/keycloak/broker/oauth/OAuth2IdentityProviderFactory.java @@ -90,6 +90,12 @@ public Map parseConfig(KeycloakSession session, String rawConfig config.setAuthorizationUrl(rep.getAuthorizationEndpoint()); config.setTokenUrl(rep.getTokenEndpoint()); config.setUserInfoUrl(rep.getUserinfoEndpoint()); + + // Introspection URL may or may not be available in the configuration. It is mentioned in RFC8414 , but not in the OIDC discovery specification. + // Hence some servers may not add it to their well-known responses + if (rep.getIntrospectionEndpoint() != null) { + config.setTokenIntrospectionUrl(rep.getIntrospectionEndpoint()); + } return config.getConfig(); } } 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 ddfc80e29392..129e6723bb56 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java @@ -56,19 +56,25 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.oidc.AccessTokenIntrospectionProviderFactory; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.protocol.oidc.TokenExchangeContext; import org.keycloak.protocol.oidc.TokenExchangeProvider; import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; +import org.keycloak.protocol.oidc.endpoints.TokenIntrospectionEndpoint; import org.keycloak.protocol.oidc.utils.PkceUtils; import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.JsonWebToken; +import org.keycloak.representations.oidc.TokenMetadataRepresentation; import org.keycloak.services.ErrorPage; import org.keycloak.services.ErrorResponseException; import org.keycloak.services.Urls; import org.keycloak.services.managers.ClientSessionCode; import org.keycloak.services.messages.Messages; +import org.keycloak.services.resources.RealmsResource; import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.urls.UrlType; import org.keycloak.utils.StringUtil; import org.keycloak.vault.VaultStringSecret; @@ -657,6 +663,7 @@ protected BrokeredIdentityContext extractIdentityFromProfile(EventBuilder event, protected BrokeredIdentityContext validateExternalTokenThroughUserInfo(EventBuilder event, String subjectToken, String subjectTokenType) { event.detail("validation_method", "user info"); + SimpleHttp.Response response = null; int status = 0; try { @@ -754,6 +761,111 @@ protected BrokeredIdentityContext exchangeExternalTokenV2Impl(TokenExchangeConte throw new UnsupportedOperationException("Not yet supported to verify the external token of the identity provider " + getConfig().getAlias()); } + /** + * Called usually during external-internal token exchange for validation of external token, which is the token issued by the IDP. + * The validation of external token is done by calling OAuth2 introspection endpoint on the IDP side and validate if the response contains all the necessary claims + * and token is authorized for the token exchange (including validating of claims like aud from introspection response) + * + * @param tokenExchangeContext token exchange context with the external token (subject token) and other details related to token exchange + * @throws ErrorResponseException in case that validation failed for any reason + */ + protected void validateExternalTokenWithIntrospectionEndpoint(TokenExchangeContext tokenExchangeContext) { + EventBuilder event = tokenExchangeContext.getEvent(); + + TokenMetadataRepresentation tokenMetadata = sendTokenIntrospectionRequest(tokenExchangeContext.getParams().getSubjectToken(), event); + + boolean clientValid = false; + String tokenClientId = tokenMetadata.getClientId(); + List 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 611c1f19d701..ad03d43e0cc9 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 void setAuthorizationUrl(String authorizationUrl) { } 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 void setUserInfoUrl(String userInfoUrl) { 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 void validate(RealmModel realm) { 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 eab1b532736a..84591004987c 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.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 @@ protected BrokeredIdentityContext exchangeExternalTokenV1Impl(EventBuilder event } } + @Override + protected BrokeredIdentityContext exchangeExternalTokenV2Impl(TokenExchangeContext tokenExchangeContext) { + // Supporting only introspection-endpoint validation for now + validateExternalTokenWithIntrospectionEndpoint(tokenExchangeContext); + + return exchangeExternalUserInfoValidationOnly(tokenExchangeContext.getEvent(), tokenExchangeContext.getFormParams()); + } + @Override protected UriBuilder createAuthorizationUrl(AuthenticationRequest request) { UriBuilder uriBuilder = super.createAuthorizationUrl(request); diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderFactory.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderFactory.java index f11fad4c6e1e..e6b32bc3964f 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderFactory.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderFactory.java @@ -75,6 +75,12 @@ protected static Map parseOIDCConfig(KeycloakSession session, St config.setUseJwksUrl(true); config.setJwksUrl(rep.getJwksUri()); } + + // Introspection URL may or may not be available in the configuration. It is available in RFC8414 , but not in the OIDC discovery specification. + // Hence some servers may not add it to their well-known responses + if (rep.getIntrospectionEndpoint() != null) { + config.setTokenIntrospectionUrl(rep.getIntrospectionEndpoint()); + } return config.getConfig(); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/mappers/AudienceProtocolMapper.java b/services/src/main/java/org/keycloak/protocol/oidc/mappers/AudienceProtocolMapper.java index c65e321cf381..fca2f0ffc8c0 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/mappers/AudienceProtocolMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/mappers/AudienceProtocolMapper.java @@ -41,7 +41,7 @@ public class AudienceProtocolMapper extends AbstractOIDCProtocolMapper implement private static final String INCLUDED_CLIENT_AUDIENCE_LABEL = "included.client.audience.label"; private static final String INCLUDED_CLIENT_AUDIENCE_HELP_TEXT = "included.client.audience.tooltip"; - private static final String INCLUDED_CUSTOM_AUDIENCE = "included.custom.audience"; + public static final String INCLUDED_CUSTOM_AUDIENCE = "included.custom.audience"; private static final String INCLUDED_CUSTOM_AUDIENCE_LABEL = "included.custom.audience.label"; private static final String INCLUDED_CUSTOM_AUDIENCE_HELP_TEXT = "included.custom.audience.tooltip"; diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/KeycloakTestingClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/KeycloakTestingClient.java index ee13d407c00c..2b9111d4d192 100755 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/KeycloakTestingClient.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/KeycloakTestingClient.java @@ -86,7 +86,7 @@ public TestingResource testing(String realm) { public void enableFeature(Profile.Feature feature) { String featureString; - if (Profile.getFeatureVersions(feature.getUnversionedKey()).size() > 1) { + if (shouldUseVersionedKey(feature)) { featureString = feature.getVersionedKey(); } else { featureString = feature.getKey(); @@ -96,9 +96,13 @@ public void enableFeature(Profile.Feature feature) { 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 void disableFeature(Profile.Feature feature) { */ 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 cea5513658d2..7b057ddede61 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.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 @@ protected void applyDefaultConfiguration(final Map config, Ident 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 c034ca3c8e89..9fb1c4b075d9 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.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 000000000000..629fb8994a2c --- /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); + } +}