Verification of external OIDC token by introspection-endpoint. Adding ExternalInternalTokenExchangeV2Test

closes #40167
closes #40198

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
mposolda
2025-07-01 19:34:20 +02:00
committed by Marek Posolda
parent 7fd37690c4
commit c52edc853d
15 changed files with 508 additions and 10 deletions

View File

@@ -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.

View File

@@ -71,6 +71,12 @@ const Fields = ({ readOnly, isOIDC }: DiscoverySettingsProps) => {
required: isOIDC ? "" : t("required"),
}}
/>
<TextControl
name="config.tokenIntrospectionUrl"
label={t("tokenIntrospectionUrl")}
type="url"
readOnly={readOnly}
/>
{isOIDC && (
<TextControl
name="config.issuer"

View File

@@ -52,6 +52,7 @@ export async function assertAuthorizationUrl(page: Page) {
type UrlType =
| "authorization"
| "token"
| "tokenIntrospection"
| "singleSignOnService"
| "singleLogoutService";

View File

@@ -1,7 +1,7 @@
import { test } from "@playwright/test";
import { v4 as uuid } from "uuid";
import adminClient from "../utils/AdminClient";
import { switchOff, switchOn } from "../utils/form";
import { switchOn } from "../utils/form";
import { login } from "../utils/login";
import { assertNotificationMessage } from "../utils/masthead";
import { goToIdentityProviders } from "../utils/sidebar";
@@ -53,8 +53,13 @@ test.describe("OIDC identity provider test", () => {
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);

View File

@@ -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<OAuth
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();

View File

@@ -90,6 +90,12 @@ public class OAuth2IdentityProviderFactory extends AbstractIdentityProviderFacto
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();
}
}

View File

@@ -56,19 +56,25 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.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 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
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 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
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<String> 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<String, String> params) {
String subjectToken = params.getFirst(OAuth2Constants.SUBJECT_TOKEN);
if (subjectToken == null) {

View File

@@ -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();

View File

@@ -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<OIDCIde
}
}
@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);

View File

@@ -75,6 +75,12 @@ public class OIDCIdentityProviderFactory extends AbstractIdentityProviderFactory
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();
}

View File

@@ -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";

View File

@@ -86,7 +86,7 @@ public class KeycloakTestingClient implements AutoCloseable {
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 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)) {

View File

@@ -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");

View File

@@ -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 {

View File

@@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
// 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<String, String> config, IdentityProviderSyncMode syncMode) {
super.applyDefaultConfiguration(config, syncMode);
config.put(TOKEN_INTROSPECTION_URL, config.get(TOKEN_ENDPOINT_URL) + "/introspect");
}
@Override
public List<ClientRepresentation> createProviderClients() {
List<ClientRepresentation> 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<String, String> 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<Response> 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);
}
}