From 45e6917983a4aa6718d6eaa253ce4e2c2a2c50fb Mon Sep 17 00:00:00 2001 From: mposolda Date: Fri, 30 May 2025 16:40:33 +0200 Subject: [PATCH] Introduce ExternalToInternalTokenExchangeProvider. Make it working with Google IDP using token-info endpoint instead of user-info endpoint closes #40146 closes #40133 Signed-off-by: mposolda --- .../java/org/keycloak/common/Profile.java | 1 + .../representations/JsonWebToken.java | 3 +- .../provider/ExchangeExternalToken.java | 6 +- .../protocol/oidc/TokenExchangeProvider.java | 6 ++ .../oidc/AbstractOAuth2IdentityProvider.java | 42 +++++++++-- .../oidc/KeycloakOIDCIdentityProvider.java | 2 +- .../broker/oidc/OIDCIdentityProvider.java | 2 +- .../AbstractTokenExchangeProvider.java | 2 +- ...ternalToInternalTokenExchangeProvider.java | 52 ++++++++++++++ ...oInternalTokenExchangeProviderFactory.java | 72 +++++++++++++++++++ .../StandardTokenExchangeProvider.java | 5 ++ .../V1TokenExchangeProvider.java | 5 ++ .../social/gitlab/GitLabIdentityProvider.java | 2 +- .../social/google/GoogleIdentityProvider.java | 35 ++++++++- ...protocol.oidc.TokenExchangeProviderFactory | 2 +- 15 files changed, 222 insertions(+), 15 deletions(-) create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/ExternalToInternalTokenExchangeProvider.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/ExternalToInternalTokenExchangeProviderFactory.java diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index c3fd567c274c..1c356f17b437 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -75,6 +75,7 @@ public enum Feature { TOKEN_EXCHANGE("Token Exchange Service", Type.PREVIEW, 1), TOKEN_EXCHANGE_STANDARD_V2("Standard Token Exchange version 2", Type.DEFAULT, 2), + TOKEN_EXCHANGE_EXTERNAL_INTERNAL_V2("External to Internal Token Exchange version 2", Type.EXPERIMENTAL, 2), WEB_AUTHN("W3C Web Authentication (WebAuthn)", Type.DEFAULT), diff --git a/core/src/main/java/org/keycloak/representations/JsonWebToken.java b/core/src/main/java/org/keycloak/representations/JsonWebToken.java index a969273fde24..ce1a98fb1e76 100755 --- a/core/src/main/java/org/keycloak/representations/JsonWebToken.java +++ b/core/src/main/java/org/keycloak/representations/JsonWebToken.java @@ -41,6 +41,7 @@ */ public class JsonWebToken implements Serializable, Token { public static final String AZP = "azp"; + public static final String AUD = "aud"; public static final String SUBJECT = "sub"; @JsonProperty("jti") @@ -52,7 +53,7 @@ public class JsonWebToken implements Serializable, Token { @JsonProperty("iss") protected String issuer; - @JsonProperty("aud") + @JsonProperty(AUD) @JsonSerialize(using = StringOrArraySerializer.class) @JsonDeserialize(using = StringOrArrayDeserializer.class) protected String[] audience; diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/ExchangeExternalToken.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/ExchangeExternalToken.java index 5a07d167b0b2..2449739c9e6c 100644 --- a/server-spi-private/src/main/java/org/keycloak/broker/provider/ExchangeExternalToken.java +++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/ExchangeExternalToken.java @@ -16,11 +16,11 @@ */ package org.keycloak.broker.provider; -import org.keycloak.broker.provider.BrokeredIdentityContext; -import org.keycloak.events.EventBuilder; import org.keycloak.models.UserSessionModel; import jakarta.ws.rs.core.MultivaluedMap; +import org.keycloak.protocol.oidc.TokenExchangeContext; +import org.keycloak.protocol.oidc.TokenExchangeProvider; /** * Exchange a token crafted by this provider for a local realm token. @@ -30,7 +30,7 @@ */ public interface ExchangeExternalToken { boolean isIssuer(String issuer, MultivaluedMap params); - BrokeredIdentityContext exchangeExternal(EventBuilder event, MultivaluedMap params); + BrokeredIdentityContext exchangeExternal(TokenExchangeProvider tokenExchangeProvider, TokenExchangeContext tokenExchangeContext); void exchangeExternalComplete(UserSessionModel userSession, BrokeredIdentityContext context, MultivaluedMap params); } diff --git a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/TokenExchangeProvider.java b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/TokenExchangeProvider.java index f5608cedd67e..c9ab17301c01 100644 --- a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/TokenExchangeProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/TokenExchangeProvider.java @@ -44,4 +44,10 @@ public interface TokenExchangeProvider extends Provider { */ Response exchange(TokenExchangeContext context); + /** + * @return version of the token-exchange provider. Could be useful by various components (like for example identity-providers), which need to interact with the token-exchange provider + * to doublecheck if it should have a "legacy" behaviour (for older version of token-exchange provider) or a "new" behaviour + */ + int getVersion(); + } diff --git a/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java index 55b41c931663..08c308d1bc5f 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java @@ -57,6 +57,8 @@ import org.keycloak.models.UserSessionModel; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.TokenExchangeContext; +import org.keycloak.protocol.oidc.TokenExchangeProvider; import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; import org.keycloak.protocol.oidc.utils.PkceUtils; import org.keycloak.representations.AccessTokenResponse; @@ -705,21 +707,53 @@ public boolean isIssuer(String issuer, MultivaluedMap params) { return requestedIssuer.equals(getConfig().getAlias()); } - - final public BrokeredIdentityContext exchangeExternal(EventBuilder event, MultivaluedMap params) { + @Override + final public BrokeredIdentityContext exchangeExternal(TokenExchangeProvider tokenExchangeProvider, TokenExchangeContext tokenExchangeContext) { if (!supportsExternalExchange()) return null; - BrokeredIdentityContext context = exchangeExternalImpl(event, params); + + BrokeredIdentityContext context; + int teVersion = tokenExchangeProvider.getVersion(); + switch (teVersion) { + case 1: + context = exchangeExternalTokenV1Impl(tokenExchangeContext.getEvent(), tokenExchangeContext.getFormParams()); + break; + case 2: + context = exchangeExternalTokenV2Impl(tokenExchangeContext); + break; + default: + throw new IllegalArgumentException("Unsupported token exchange version " + teVersion); + } + if (context != null) { context.setIdp(this); } return context; } - protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap params) { + /** + * Usage with token-exchange V1 + * + * @param event event builder + * @param params parameters of the token-exchange request + * @return brokered identity context with the details about user from the IDP + */ + protected BrokeredIdentityContext exchangeExternalTokenV1Impl(EventBuilder event, MultivaluedMap params) { return exchangeExternalUserInfoValidationOnly(event, params); } + /** + * Usage with external-internal token-exchange v2. + * + * @param tokenExchangeContext data about token-exchange request + * @return brokered identity context with the details about user from the IDP + */ + protected BrokeredIdentityContext exchangeExternalTokenV2Impl(TokenExchangeContext tokenExchangeContext) { + // Needs to be properly implemented for every provider to make sure it verifies external-token in appropriate way to validate user and also if the external-token + // was issued to the proper audience + throw new UnsupportedOperationException("Not yet supported to verify the external token of the identity provider " + getConfig().getAlias()); + } + protected BrokeredIdentityContext exchangeExternalUserInfoValidationOnly(EventBuilder event, MultivaluedMap params) { String subjectToken = params.getFirst(OAuth2Constants.SUBJECT_TOKEN); if (subjectToken == null) { diff --git a/services/src/main/java/org/keycloak/broker/oidc/KeycloakOIDCIdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/KeycloakOIDCIdentityProvider.java index b1de891e74f7..139bed7f1246 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/KeycloakOIDCIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/KeycloakOIDCIdentityProvider.java @@ -138,7 +138,7 @@ public SimpleHttp generateTokenRequest(String authorizationCode) { } @Override - protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap params) { + protected BrokeredIdentityContext exchangeExternalTokenV1Impl(EventBuilder event, MultivaluedMap params) { String subjectToken = params.getFirst(OAuth2Constants.SUBJECT_TOKEN); if (subjectToken == null) { event.detail(Details.REASON, OAuth2Constants.SUBJECT_TOKEN + " param unset"); diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java index 5732fb67c8c5..eab1b532736a 100755 --- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java +++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java @@ -933,7 +933,7 @@ protected boolean isTokenTypeSupported(JsonWebToken parsedToken) { } @Override - protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap params) { + protected BrokeredIdentityContext exchangeExternalTokenV1Impl(EventBuilder event, MultivaluedMap params) { if (!supportsExternalExchange()) return null; String subjectToken = params.getFirst(OAuth2Constants.SUBJECT_TOKEN); if (subjectToken == null) { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/AbstractTokenExchangeProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/AbstractTokenExchangeProvider.java index 92b52594035d..05411913f0dd 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/AbstractTokenExchangeProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/AbstractTokenExchangeProvider.java @@ -288,7 +288,7 @@ protected Response exchangeExternalToken(String subjectIssuer, String subjectTok event.error(Errors.NOT_ALLOWED); throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); } - BrokeredIdentityContext context = externalExchangeContext.provider().exchangeExternal(event, formParams); + BrokeredIdentityContext context = externalExchangeContext.provider().exchangeExternal(this, this.context); if (context == null) { event.error(Errors.INVALID_ISSUER); throw new CorsErrorResponseException(cors, Errors.INVALID_ISSUER, "Invalid " + OAuth2Constants.SUBJECT_ISSUER + " parameter", Response.Status.BAD_REQUEST); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/ExternalToInternalTokenExchangeProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/ExternalToInternalTokenExchangeProvider.java new file mode 100644 index 000000000000..9d0fd86c0229 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/ExternalToInternalTokenExchangeProvider.java @@ -0,0 +1,52 @@ +/* + * 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.protocol.oidc.tokenexchange; + +import jakarta.ws.rs.core.Response; +import org.keycloak.protocol.oidc.TokenExchangeContext; + +/** + * Provider for external-internal token exchange + * + * TODO Should not extend from V1TokenExchangeProvider, but rather AbstractTokenExchangeProvider or from StandardTokenExchangeProvider (as issuing internal tokens might be done in a same/similar way like for standard V2 provider) + * + * @author Marek Posolda + */ +public class ExternalToInternalTokenExchangeProvider extends V1TokenExchangeProvider { + + @Override + public boolean supports(TokenExchangeContext context) { + return (isExternalInternalTokenExchangeRequest(context)); + } + + @Override + public int getVersion() { + return 2; + } + + @Override + protected Response tokenExchange() { + String subjectToken = context.getParams().getSubjectToken(); + String subjectTokenType = context.getParams().getSubjectTokenType(); + String subjectIssuer = getSubjectIssuer(this.context, subjectToken, subjectTokenType); + return exchangeExternalToken(subjectIssuer, subjectToken); + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/ExternalToInternalTokenExchangeProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/ExternalToInternalTokenExchangeProviderFactory.java new file mode 100644 index 000000000000..f95765b86547 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/ExternalToInternalTokenExchangeProviderFactory.java @@ -0,0 +1,72 @@ +/* + * 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.protocol.oidc.tokenexchange; + +import org.keycloak.Config; +import org.keycloak.common.Profile; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.protocol.oidc.TokenExchangeProvider; +import org.keycloak.protocol.oidc.TokenExchangeProviderFactory; +import org.keycloak.provider.EnvironmentDependentProviderFactory; + +/** + * Provider factory for external-internal token exchange + * + * @author Marek Posolda + */ +public class ExternalToInternalTokenExchangeProviderFactory implements TokenExchangeProviderFactory, EnvironmentDependentProviderFactory { + + @Override + public TokenExchangeProvider create(KeycloakSession session) { + return new ExternalToInternalTokenExchangeProvider(); + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return "external-internal"; + } + + @Override + public boolean isSupported(Config.Scope config) { + return Profile.isFeatureEnabled(Profile.Feature.TOKEN_EXCHANGE_EXTERNAL_INTERNAL_V2); + } + + @Override + public int order() { + // Bigger priority than V1, so it has preference if both V1 and V2 enabled. Also bigger priority than "standard", so it can verify if request is from external-token + return 20; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java index 67ba7e42a4fd..d366e2213309 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java @@ -62,6 +62,11 @@ */ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider { + @Override + public int getVersion() { + return 2; + } + @Override public boolean supports(TokenExchangeContext context) { // Subject impersonation request diff --git a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/V1TokenExchangeProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/V1TokenExchangeProvider.java index 34e83dcc1ee5..2a5cf9d1dbe5 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/V1TokenExchangeProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/V1TokenExchangeProvider.java @@ -78,6 +78,11 @@ public class V1TokenExchangeProvider extends AbstractTokenExchangeProvider { private static final Logger logger = Logger.getLogger(V1TokenExchangeProvider.class); + @Override + public int getVersion() { + return 1; + } + @Override public boolean supports(TokenExchangeContext context) { return true; diff --git a/services/src/main/java/org/keycloak/social/gitlab/GitLabIdentityProvider.java b/services/src/main/java/org/keycloak/social/gitlab/GitLabIdentityProvider.java index d91f73b7930c..8e9b2aa83452 100755 --- a/services/src/main/java/org/keycloak/social/gitlab/GitLabIdentityProvider.java +++ b/services/src/main/java/org/keycloak/social/gitlab/GitLabIdentityProvider.java @@ -90,7 +90,7 @@ public boolean isIssuer(String issuer, MultivaluedMap params) { @Override - protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap params) { + protected BrokeredIdentityContext exchangeExternalTokenV1Impl(EventBuilder event, MultivaluedMap params) { return exchangeExternalUserInfoValidationOnly(event, params); } diff --git a/services/src/main/java/org/keycloak/social/google/GoogleIdentityProvider.java b/services/src/main/java/org/keycloak/social/google/GoogleIdentityProvider.java index b02348e2e1f4..5b0df5c008c4 100755 --- a/services/src/main/java/org/keycloak/social/google/GoogleIdentityProvider.java +++ b/services/src/main/java/org/keycloak/social/google/GoogleIdentityProvider.java @@ -16,22 +16,30 @@ */ package org.keycloak.social.google; +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.ws.rs.core.Response; import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; import org.keycloak.broker.oidc.OIDCIdentityProvider; import org.keycloak.broker.oidc.OIDCIdentityProviderConfig; import org.keycloak.broker.provider.AuthenticationRequest; import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.broker.provider.IdentityBrokerException; +import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.broker.social.SocialIdentityProvider; import org.keycloak.common.ClientConnection; import org.keycloak.common.util.KeycloakUriBuilder; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.TokenExchangeContext; import org.keycloak.representations.JsonWebToken; import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.UriBuilder; -import java.util.Arrays; +import org.keycloak.services.ErrorResponseException; + import java.util.List; /** @@ -42,6 +50,7 @@ public class GoogleIdentityProvider extends OIDCIdentityProvider implements Soci public static final String AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; public static final String TOKEN_URL = "https://oauth2.googleapis.com/token"; public static final String PROFILE_URL = "https://openidconnect.googleapis.com/v1/userinfo"; + public static final String TOKEN_INFO_URL = "https://oauth2.googleapis.com/tokeninfo"; public static final String DEFAULT_SCOPE = "openid profile email"; private static final String OIDC_PARAMETER_HOSTED_DOMAINS = "hd"; @@ -88,10 +97,32 @@ public boolean isIssuer(String issuer, MultivaluedMap params) { @Override - protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap params) { + protected BrokeredIdentityContext exchangeExternalTokenV1Impl(EventBuilder event, MultivaluedMap params) { return exchangeExternalUserInfoValidationOnly(event, params); } + @Override + protected BrokeredIdentityContext exchangeExternalTokenV2Impl(TokenExchangeContext tokenExchangeContext) { + try { + JsonNode tokenInfo = SimpleHttp.doGet(TOKEN_INFO_URL, session) + .header("Authorization", "Bearer " + tokenExchangeContext.getParams().getSubjectToken()) + .asJson(); + if (tokenInfo == null || !tokenInfo.has(JsonWebToken.AUD) || !tokenInfo.get(JsonWebToken.AUD).asText().equals(getConfig().getClientId())) { + logger.tracef("Invalid response or unmatching audience from the token-info endpoint from Google. Expected audience '%s' . Token info response: %s", getConfig().getClientId(), tokenInfo); + EventBuilder event = tokenExchangeContext.getEvent(); + event.detail(Details.REASON, "Google token validation failed"); + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Google token validation failed", Response.Status.BAD_REQUEST); + } + } catch (ErrorResponseException ere) { + throw ere; + } catch (Exception e) { + throw new IdentityBrokerException("Could not verify token info from google.", e); + } + + return exchangeExternalUserInfoValidationOnly(tokenExchangeContext.getEvent(), tokenExchangeContext.getFormParams()); + } + @Override protected UriBuilder createAuthorizationUrl(AuthenticationRequest request) { UriBuilder uriBuilder = super.createAuthorizationUrl(request); diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.TokenExchangeProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.TokenExchangeProviderFactory index 0c1f232fba1c..a405a8166ce2 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.TokenExchangeProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.TokenExchangeProviderFactory @@ -1,3 +1,3 @@ org.keycloak.protocol.oidc.tokenexchange.V1TokenExchangeProviderFactory org.keycloak.protocol.oidc.tokenexchange.StandardTokenExchangeProviderFactory - +org.keycloak.protocol.oidc.tokenexchange.ExternalToInternalTokenExchangeProviderFactory