8000 Introduce ExternalToInternalTokenExchangeProvider. Make it working wi… by mposolda · Pull Request #40134 · keycloak/keycloak · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Introduce ExternalToInternalTokenExchangeProvider. Make it working wi… #40134

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
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
1 change: 1 addition & 0 deletions common/src/main/java/org/keycloak/common/Profile.java
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -30,7 +30,7 @@
*/
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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();

}
6D40
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -705,21 +707,53 @@ public boolean isIssuer(String issuer, MultivaluedMap<String, String> params) {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ public SimpleHttp generateTokenRequest(String authorizationCode) {
}

@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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -933,7 +933,7 @@ protected boolean isTokenTypeSupported(JsonWebToken parsedToken) {
}

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

}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@
*/
public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider {

@Override
public int getVersion() {
return 2;
}

@Override
public boolean supports(TokenExchangeContext context) {
// Subject impersonation request
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ public boolean isIssuer(String issuer, MultivaluedMap<String, String> params) {


@Override
protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap<String, String> params) {
protected BrokeredIdentityContext exchangeExternalTokenV1Impl(EventBuilder event, MultivaluedMap<String, String> params) {
return exchangeExternalUserInfoValidationOnly(event, params);
}

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

/**
Expand All @@ -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";
Expand Down Expand Up @@ -88,10 +97,32 @@ public boolean isIssuer(String issuer, MultivaluedMap<String, String> params) {


@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);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
org.keycloak.protocol.oidc.tokenexchange.V1TokenExchangeProviderFactory
org.keycloak.protocol.oidc.tokenexchange.StandardTokenExchangeProviderFactory

org.keycloak.protocol.oidc.tokenexchange.ExternalToInternalTokenExchangeProviderFactory
Loading
0