mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-16 20:15:46 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -52,6 +52,7 @@ export async function assertAuthorizationUrl(page: Page) {
|
||||
type UrlType =
|
||||
| "authorization"
|
||||
| "token"
|
||||
| "tokenIntrospection"
|
||||
| "singleSignOnService"
|
||||
| "singleLogoutService";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user