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 <mposolda@gmail.com>
This commit is contained in:
mposolda
2025-05-30 16:40:33 +02:00
committed by Marek Posolda
parent aac2dcb6f1
commit ab7edb0d01
15 changed files with 222 additions and 15 deletions
@@ -75,6 +75,7 @@ public class Profile {
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),
@@ -41,6 +41,7 @@ import java.util.Map;
*/
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;
@@ -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 @@ import jakarta.ws.rs.core.MultivaluedMap;
*/
public interface ExchangeExternalToken {
boolean isIssuer(String issuer, MultivaluedMap<String, String> params);
BrokeredIdentityContext exchangeExternal(EventBuilder event, MultivaluedMap<String, String> params);
BrokeredIdentityContext exchangeExternal(TokenExchangeProvider tokenExchangeProvider, TokenExchangeContext tokenExchangeContext);
void exchangeExternalComplete(UserSessionModel userSession, BrokeredIdentityContext context, MultivaluedMap<String, String> params);
}
@@ -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();
}
@@ -57,6 +57,8 @@ import org.keycloak.models.UserModel;
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 abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
return requestedIssuer.equals(getConfig().getAlias());
}
final public BrokeredIdentityContext exchangeExternal(EventBuilder event, MultivaluedMap<String, String> 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<String, String> 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<String, String> 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<String, String> params) {
String subjectToken = params.getFirst(OAuth2Constants.SUBJECT_TOKEN);
if (subjectToken == null) {
@@ -138,7 +138,7 @@ public class KeycloakOIDCIdentityProvider extends OIDCIdentityProvider {
}
@Override
protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap<String, String> params) {
protected BrokeredIdentityContext exchangeExternalTokenV1Impl(EventBuilder event, MultivaluedMap<String, String> params) {
String subjectToken = params.getFirst(OAuth2Constants.SUBJECT_TOKEN);
if (subjectToken == null) {
event.detail(Details.REASON, OAuth2Constants.SUBJECT_TOKEN + " param unset");
@@ -933,7 +933,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
}
@Override
protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap<String, String> params) {
protected BrokeredIdentityContext exchangeExternalTokenV1Impl(EventBuilder event, MultivaluedMap<String, String> params) {
if (!supportsExternalExchange()) return null;
String subjectToken = params.getFirst(OAuth2Constants.SUBJECT_TOKEN);
if (subjectToken == null) {
@@ -288,7 +288,7 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv
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);
@@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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);
}
}
@@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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;
}
}
@@ -62,6 +62,11 @@ import org.keycloak.util.TokenUtil;
*/
public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider {
@Override
public int getVersion() {
return 2;
}
@Override
public boolean supports(TokenExchangeContext context) {
// Subject impersonation request
@@ -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;
@@ -90,7 +90,7 @@ public class GitLabIdentityProvider extends OIDCIdentityProvider implements Soc
@Override
protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap<String, String> params) {
protected BrokeredIdentityContext exchangeExternalTokenV1Impl(EventBuilder event, MultivaluedMap<String, String> params) {
return exchangeExternalUserInfoValidationOnly(event, params);
}
@@ -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 class GoogleIdentityProvider extends OIDCIdentityProvider implements Soci
@Override
protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap<String, String> params) {
protected BrokeredIdentityContext exchangeExternalTokenV1Impl(EventBuilder event, MultivaluedMap<String, String> 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);
@@ -1,3 +1,3 @@
org.keycloak.protocol.oidc.tokenexchange.V1TokenExchangeProviderFactory
org.keycloak.protocol.oidc.tokenexchange.StandardTokenExchangeProviderFactory
org.keycloak.protocol.oidc.tokenexchange.ExternalToInternalTokenExchangeProviderFactory