mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-03 13:40:46 -05:00
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:
@@ -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;
|
||||
|
||||
+3
-3
@@ -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();
|
||||
|
||||
}
|
||||
|
||||
+38
-4
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+52
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+72
@@ -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;
|
||||
}
|
||||
}
|
||||
+5
@@ -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
|
||||
|
||||
+5
@@ -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
-1
@@ -1,3 +1,3 @@
|
||||
org.keycloak.protocol.oidc.tokenexchange.V1TokenExchangeProviderFactory
|
||||
org.keycloak.protocol.oidc.tokenexchange.StandardTokenExchangeProviderFactory
|
||||
|
||||
org.keycloak.protocol.oidc.tokenexchange.ExternalToInternalTokenExchangeProviderFactory
|
||||
|
||||
Reference in New Issue
Block a user