+
Skip to content

Verification of external OIDC token by introspection-endpoint. Adding… #40856

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions js/apps/admin-ui/test/identity-providers/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export async function assertAuthorizationUrl(page: Page) {
type UrlType =
| "authorization"
| "token"
| "tokenIntrospection"
| "singleSignOnService"
| "singleLogoutService";

Expand Down
9 changes: 7 additions & 2 deletions js/apps/admin-ui/test/identity-providers/oidc.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ public Map<String, String> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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() {
Expand All @@ -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");
}
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ protected static Map<String, String> 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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;

Expand Down Expand Up @@ -204,7 +205,7 @@ protected void applyDefaultConfiguration(final Map<String, String> 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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading
点击 这是indexloc提供的php浏览器服务,不要输入任何密码和下载