From 3b3a61dfba44af166ee5f465d345929994edb72a Mon Sep 17 00:00:00 2001 From: Dmitry Telegin Date: Wed, 21 Apr 2021 12:55:11 +0300 Subject: [PATCH] KEYCLOAK-18639 Token Exchange SPI Milestone 1 --- .../java/org/keycloak/OAuth2Constants.java | 4 + .../protocol/oidc/TokenExchangeContext.java | 162 +++++ .../protocol/oidc/TokenExchangeProvider.java | 47 ++ .../oidc/TokenExchangeProviderFactory.java | 28 + .../protocol/oidc/TokenExchangeSpi.java | 52 ++ .../services/org.keycloak.provider.Spi | 1 + .../oidc/DefaultTokenExchangeProvider.java | 569 ++++++++++++++++++ .../DefaultTokenExchangeProviderFactory.java | 53 ++ .../oidc/endpoints/TokenEndpoint.java | 496 +-------------- ...protocol.oidc.TokenExchangeProviderFactory | 2 + 10 files changed, 949 insertions(+), 465 deletions(-) create mode 100644 server-spi-private/src/main/java/org/keycloak/protocol/oidc/TokenExchangeContext.java create mode 100644 server-spi-private/src/main/java/org/keycloak/protocol/oidc/TokenExchangeProvider.java create mode 100644 server-spi-private/src/main/java/org/keycloak/protocol/oidc/TokenExchangeProviderFactory.java create mode 100644 server-spi-private/src/main/java/org/keycloak/protocol/oidc/TokenExchangeSpi.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProviderFactory.java create mode 100644 services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.TokenExchangeProviderFactory diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java index 48317a4dc4..ba864ae9fd 100755 --- a/core/src/main/java/org/keycloak/OAuth2Constants.java +++ b/core/src/main/java/org/keycloak/OAuth2Constants.java @@ -107,11 +107,15 @@ public interface OAuth2Constants { String PKCE_METHOD_PLAIN = "plain"; String PKCE_METHOD_S256 = "S256"; + // https://tools.ietf.org/html/rfc8693#section-2.1 String TOKEN_EXCHANGE_GRANT_TYPE="urn:ietf:params:oauth:grant-type:token-exchange"; String AUDIENCE="audience"; + String RESOURCE="resource"; String REQUESTED_SUBJECT="requested_subject"; String SUBJECT_TOKEN="subject_token"; String SUBJECT_TOKEN_TYPE="subject_token_type"; + String ACTOR_TOKEN="actor_token"; + String ACTOR_TOKEN_TYPE="actor_token_type"; String REQUESTED_TOKEN_TYPE="requested_token_type"; String ISSUED_TOKEN_TYPE="issued_token_type"; String REQUESTED_ISSUER="requested_issuer"; diff --git a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/TokenExchangeContext.java b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/TokenExchangeContext.java new file mode 100644 index 0000000000..9f97c7f933 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/TokenExchangeContext.java @@ -0,0 +1,162 @@ +/* + * Copyright 2021 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; + +import org.keycloak.OAuth2Constants; +import org.keycloak.common.ClientConnection; +import org.keycloak.events.EventBuilder; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; + +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MultivaluedMap; + +import java.util.Map; + +/** + * Token exchange context + * + * @author Dmitry Telegin + */ +public class TokenExchangeContext { + + private final KeycloakSession session; + private final MultivaluedMap formParams; + + // TODO: resolve deps issue and use correct types + private final Object cors; + private final Object tokenManager; + + private final ClientModel client; + private final RealmModel realm; + private final EventBuilder event; + + private ClientConnection clientConnection; + private HttpHeaders headers; + private Map clientAuthAttributes; + + private final Params params = new Params(); + + public TokenExchangeContext(KeycloakSession session, + MultivaluedMap formParams, + Object cors, + RealmModel realm, + EventBuilder event, + ClientModel client, + ClientConnection clientConnection, + HttpHeaders headers, + Object tokenManager, + Map clientAuthAttributes) { + this.session = session; + this.formParams = formParams; + this.cors = cors; + this.client = client; + this.realm = realm; + this.event = event; + this.clientConnection = clientConnection; + this.headers = headers; + this.tokenManager = tokenManager; + this.clientAuthAttributes = clientAuthAttributes; + } + + public KeycloakSession getSession() { + return session; + } + + public MultivaluedMap getFormParams() { + return formParams; + } + + public Object getCors() { + return cors; + } + + public RealmModel getRealm() { + return realm; + } + + public ClientModel getClient() { + return client; + } + + public EventBuilder getEvent() { + return event; + } + + public ClientConnection getClientConnection() { + return clientConnection; + } + + public HttpHeaders getHeaders() { + return headers; + } + + public Object getTokenManager() { + return tokenManager; + } + + public Map getClientAuthAttributes() { + return clientAuthAttributes; + } + + public Params getParams() { + return params; + } + + public class Params { + + public String getActorToken() { + return formParams.getFirst(OAuth2Constants.ACTOR_TOKEN); + } + + public String getActorTokenType() { + return formParams.getFirst(OAuth2Constants.ACTOR_TOKEN_TYPE); + } + + public String getAudience() { + return formParams.getFirst(OAuth2Constants.AUDIENCE); + } + + public String getResource() { + return formParams.getFirst(OAuth2Constants.RESOURCE); + } + + public String getRequestedTokenType() { + return formParams.getFirst(OAuth2Constants.REQUESTED_TOKEN_TYPE); + } + + public String getScope() { + return formParams.getFirst(OAuth2Constants.SCOPE); + } + + public String getSubjectToken() { + return formParams.getFirst(OAuth2Constants.SUBJECT_TOKEN); + } + + public String getSubjectTokenType() { + return formParams.getFirst(OAuth2Constants.SUBJECT_TOKEN_TYPE); + } + + public String getSubjectIssuer() { + return formParams.getFirst(OAuth2Constants.SUBJECT_ISSUER); + } + + } + +} 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 new file mode 100644 index 0000000000..2d0e2596c4 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/TokenExchangeProvider.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021 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; + +import org.keycloak.provider.Provider; + +import javax.ws.rs.core.Response; + +/** + * Provides token exchange mechanism for supported tokens + * + * @author Dmitry Telegin + */ +public interface TokenExchangeProvider extends Provider { + + /** + * Check if exchange request is supported by this provider + * + * @param context token exchange context + * @return true if the request is supported + */ + boolean supports(TokenExchangeContext context); + + /** + * Exchange the token. + * + * @param context + * @return response with a new token + */ + Response exchange(TokenExchangeContext context); + +} diff --git a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/TokenExchangeProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/TokenExchangeProviderFactory.java new file mode 100644 index 0000000000..10452baf3c --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/TokenExchangeProviderFactory.java @@ -0,0 +1,28 @@ +/* + * Copyright 2021 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; + +import org.keycloak.provider.ProviderFactory; + +/** + * A factory that creates {@link TokenExchangeProvider} instances. + * + * @author Dmitry Telegin + */ +public interface TokenExchangeProviderFactory extends ProviderFactory { +} diff --git a/server-spi-private/src/main/java/org/keycloak/protocol/oidc/TokenExchangeSpi.java b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/TokenExchangeSpi.java new file mode 100644 index 0000000000..e49bf2e206 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/protocol/oidc/TokenExchangeSpi.java @@ -0,0 +1,52 @@ +/* + * Copyright 2021 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; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +/** + *

A {@link Spi} to support pluggable token exchange handlers in the OAuth2 Token Endpoint. + * + * @author Dmitry Telegin + */ +public class TokenExchangeSpi implements Spi { + + public static final String SPI_NAME = "oauth2-token-exchange"; + + @Override + public boolean isInternal() { + return true; + } + + @Override + public String getName() { + return SPI_NAME; + } + + @Override + public Class getProviderClass() { + return TokenExchangeProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return TokenExchangeProviderFactory.class; + } +} diff --git a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi index a4d2f052c7..eb7c5d8311 100755 --- a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -75,6 +75,7 @@ org.keycloak.authorization.policy.provider.PolicySpi org.keycloak.authorization.store.StoreFactorySpi org.keycloak.authorization.AuthorizationSpi org.keycloak.models.cache.authorization.CachedStoreFactorySpi +org.keycloak.protocol.oidc.TokenExchangeSpi org.keycloak.protocol.oidc.TokenIntrospectionSpi org.keycloak.protocol.saml.ArtifactResolverSpi org.keycloak.policy.PasswordPolicySpi diff --git a/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java new file mode 100644 index 0000000000..b9bf6c1e88 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java @@ -0,0 +1,569 @@ +/* + * Copyright 2021 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; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.spi.ResteasyProviderFactory; +import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; +import org.keycloak.broker.provider.BrokeredIdentityContext; +import org.keycloak.broker.provider.ExchangeExternalToken; +import org.keycloak.broker.provider.ExchangeTokenToIdentityProviderToken; +import org.keycloak.broker.provider.IdentityProvider; +import org.keycloak.broker.provider.IdentityProviderFactory; +import org.keycloak.broker.provider.IdentityProviderMapper; +import org.keycloak.broker.provider.IdentityProviderMapperSyncModeDelegate; +import org.keycloak.common.ClientConnection; +import org.keycloak.common.util.Base64Url; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventBuilder; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.jose.jws.JWSInputException; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientSessionContext; +import org.keycloak.models.FederatedIdentityModel; +import org.keycloak.models.IdentityProviderMapperModel; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.LoginProtocol; +import org.keycloak.protocol.LoginProtocolFactory; +import org.keycloak.protocol.oidc.endpoints.TokenEndpoint.TokenExchangeSamlProtocol; +import org.keycloak.protocol.saml.SamlClient; +import org.keycloak.protocol.saml.SamlProtocol; +import org.keycloak.protocol.saml.SamlService; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.representations.JsonWebToken; +import org.keycloak.saml.common.constants.GeneralConstants; +import org.keycloak.services.CorsErrorResponseException; +import org.keycloak.services.Urls; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.managers.AuthenticationSessionManager; +import org.keycloak.services.managers.BruteForceProtector; +import org.keycloak.services.resources.Cors; +import org.keycloak.services.resources.IdentityBrokerService; +import org.keycloak.services.resources.admin.AdminAuth; +import org.keycloak.services.resources.admin.permissions.AdminPermissions; +import org.keycloak.services.validation.Validation; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.sessions.RootAuthenticationSessionModel; +import org.keycloak.util.TokenUtil; + +import static org.keycloak.authentication.authenticators.util.AuthenticatorUtils.getDisabledByBruteForceEventError; +import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID; +import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; + +/** + * Default token exchange implementation + * + * @author Dmitry Telegin + */ +public class DefaultTokenExchangeProvider implements TokenExchangeProvider { + + private static final Logger logger = Logger.getLogger(DefaultTokenExchangeProvider.class); + + private MultivaluedMap formParams; + private KeycloakSession session; + private Cors cors; + private RealmModel realm; + private ClientModel client; + private EventBuilder event; + private ClientConnection clientConnection; + private HttpHeaders headers; + private TokenManager tokenManager; + private Map clientAuthAttributes; + + @Override + public boolean supports(TokenExchangeContext context) { + return true; + } + + @Override + public Response exchange(TokenExchangeContext context) { + this.formParams = context.getFormParams(); + this.session = context.getSession(); + this.cors = (Cors)context.getCors(); + this.realm = context.getRealm(); + this.client = context.getClient(); + this.event = context.getEvent(); + this.clientConnection = context.getClientConnection(); + this.headers = context.getHeaders(); + this.tokenManager = (TokenManager)context.getTokenManager(); + this.clientAuthAttributes = context.getClientAuthAttributes(); + return tokenExchange(); + } + + @Override + public void close() { + } + + protected Response tokenExchange() { + + UserModel tokenUser = null; + UserSessionModel tokenSession = null; + AccessToken token = null; + + String subjectToken = formParams.getFirst(OAuth2Constants.SUBJECT_TOKEN); + if (subjectToken != null) { + String subjectTokenType = formParams.getFirst(OAuth2Constants.SUBJECT_TOKEN_TYPE); + String realmIssuerUrl = Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()); + String subjectIssuer = formParams.getFirst(OAuth2Constants.SUBJECT_ISSUER); + + if (subjectIssuer == null && OAuth2Constants.JWT_TOKEN_TYPE.equals(subjectTokenType)) { + try { + JWSInput jws = new JWSInput(subjectToken); + JsonWebToken jwt = jws.readJsonContent(JsonWebToken.class); + subjectIssuer = jwt.getIssuer(); + } catch (JWSInputException e) { + event.detail(Details.REASON, "unable to parse jwt subject_token"); + event.error(Errors.INVALID_TOKEN); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_TOKEN, "Invalid token type, must be access token", Response.Status.BAD_REQUEST); + + } + } + + if (subjectIssuer != null && !realmIssuerUrl.equals(subjectIssuer)) { + event.detail(OAuth2Constants.SUBJECT_ISSUER, subjectIssuer); + return exchangeExternalToken(subjectIssuer, subjectToken); + + } + + if (subjectTokenType != null && !subjectTokenType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE)) { + event.detail(Details.REASON, "subject_token supports access tokens only"); + event.error(Errors.INVALID_TOKEN); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_TOKEN, "Invalid token type, must be access token", Response.Status.BAD_REQUEST); + + } + + AuthenticationManager.AuthResult authResult = AuthenticationManager.verifyIdentityToken(session, realm, session.getContext().getUri(), clientConnection, true, true, null, false, subjectToken, headers); + if (authResult == null) { + event.detail(Details.REASON, "subject_token validation failure"); + event.error(Errors.INVALID_TOKEN); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_TOKEN, "Invalid token", Response.Status.BAD_REQUEST); + } + + tokenUser = authResult.getUser(); + tokenSession = authResult.getSession(); + token = authResult.getToken(); + } + + String requestedSubject = formParams.getFirst(OAuth2Constants.REQUESTED_SUBJECT); + if (requestedSubject != null) { + event.detail(Details.REQUESTED_SUBJECT, requestedSubject); + UserModel requestedUser = session.users().getUserByUsername(realm, requestedSubject); + if (requestedUser == null) { + requestedUser = session.users().getUserById(realm, requestedSubject); + } + + if (requestedUser == null) { + // We always returned access denied to avoid username fishing + event.detail(Details.REASON, "requested_subject not found"); + event.error(Errors.NOT_ALLOWED); + throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); + + } + + if (token != null) { + event.detail(Details.IMPERSONATOR, tokenUser.getUsername()); + // for this case, the user represented by the token, must have permission to impersonate. + AdminAuth auth = new AdminAuth(realm, token, tokenUser, client); + if (!AdminPermissions.evaluator(session, realm, auth).users().canImpersonate(requestedUser)) { + event.detail(Details.REASON, "subject not allowed to impersonate"); + event.error(Errors.NOT_ALLOWED); + throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); + } + + } else { + // no token is being exchanged, this is a direct exchange. Client must be authenticated, not public, and must be allowed + // to impersonate + if (client.isPublicClient()) { + event.detail(Details.REASON, "public clients not allowed"); + event.error(Errors.NOT_ALLOWED); + throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); + + } + if (!AdminPermissions.management(session, realm).users().canClientImpersonate(client, requestedUser)) { + event.detail(Details.REASON, "client not allowed to impersonate"); + event.error(Errors.NOT_ALLOWED); + throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); + } + } + + tokenSession = session.sessions().createUserSession(realm, requestedUser, requestedUser.getUsername(), clientConnection.getRemoteAddr(), "impersonate", false, null, null); + if (tokenUser != null) { + tokenSession.setNote(IMPERSONATOR_ID.toString(), tokenUser.getId()); + tokenSession.setNote(IMPERSONATOR_USERNAME.toString(), tokenUser.getUsername()); + } + + tokenUser = requestedUser; + } + + String requestedIssuer = formParams.getFirst(OAuth2Constants.REQUESTED_ISSUER); + if (requestedIssuer == null) { + return exchangeClientToClient(tokenUser, tokenSession); + } else { + try { + return exchangeToIdentityProvider(tokenUser, tokenSession, requestedIssuer); + } finally { + if (subjectToken == null) { // we are naked! So need to clean up user session + try { + session.sessions().removeUserSession(realm, tokenSession); + } catch (Exception ignore) { + + } + } + } + } + } + + protected Response exchangeToIdentityProvider(UserModel targetUser, UserSessionModel targetUserSession, String requestedIssuer) { + event.detail(Details.REQUESTED_ISSUER, requestedIssuer); + IdentityProviderModel providerModel = realm.getIdentityProviderByAlias(requestedIssuer); + if (providerModel == null) { + event.detail(Details.REASON, "unknown requested_issuer"); + event.error(Errors.UNKNOWN_IDENTITY_PROVIDER); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Invalid issuer", Response.Status.BAD_REQUEST); + } + + IdentityProvider provider = IdentityBrokerService.getIdentityProvider(session, realm, requestedIssuer); + if (!(provider instanceof ExchangeTokenToIdentityProviderToken)) { + event.detail(Details.REASON, "exchange unsupported by requested_issuer"); + event.error(Errors.UNKNOWN_IDENTITY_PROVIDER); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Issuer does not support token exchange", Response.Status.BAD_REQUEST); + } + if (!AdminPermissions.management(session, realm).idps().canExchangeTo(client, providerModel)) { + event.detail(Details.REASON, "client not allowed to exchange for requested_issuer"); + event.error(Errors.NOT_ALLOWED); + throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); + } + Response response = ((ExchangeTokenToIdentityProviderToken)provider).exchangeFromToken(session.getContext().getUri(), event, client, targetUserSession, targetUser, formParams); + return cors.builder(Response.fromResponse(response)).build(); + + } + + protected Response exchangeClientToClient(UserModel targetUser, UserSessionModel targetUserSession) { + String requestedTokenType = formParams.getFirst(OAuth2Constants.REQUESTED_TOKEN_TYPE); + if (requestedTokenType == null) { + requestedTokenType = OAuth2Constants.REFRESH_TOKEN_TYPE; + } else if (!requestedTokenType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE) && + !requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE) && + !requestedTokenType.equals(OAuth2Constants.SAML2_TOKEN_TYPE)) { + event.detail(Details.REASON, "requested_token_type unsupported"); + event.error(Errors.INVALID_REQUEST); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "requested_token_type unsupported", Response.Status.BAD_REQUEST); + + } + ClientModel targetClient = client; + String audience = formParams.getFirst(OAuth2Constants.AUDIENCE); + if (audience != null) { + targetClient = realm.getClientByClientId(audience); + if (targetClient == null) { + event.detail(Details.REASON, "audience not found"); + event.error(Errors.CLIENT_NOT_FOUND); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Audience not found", Response.Status.BAD_REQUEST); + + } + } + + if (targetClient.isConsentRequired()) { + event.detail(Details.REASON, "audience requires consent"); + event.error(Errors.CONSENT_DENIED); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Client requires user consent", Response.Status.BAD_REQUEST); + } + + if (!targetClient.equals(client) && !AdminPermissions.management(session, realm).clients().canExchangeTo(client, targetClient)) { + event.detail(Details.REASON, "client not allowed to exchange to audience"); + event.error(Errors.NOT_ALLOWED); + throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); + } + + String scope = formParams.getFirst(OAuth2Constants.SCOPE); + + switch (requestedTokenType) { + case OAuth2Constants.ACCESS_TOKEN_TYPE: + case OAuth2Constants.REFRESH_TOKEN_TYPE: + return exchangeClientToOIDCClient(targetUser, targetUserSession, requestedTokenType, targetClient, audience, scope); + case OAuth2Constants.SAML2_TOKEN_TYPE: + return exchangeClientToSAML2Client(targetUser, targetUserSession, requestedTokenType, targetClient, audience, scope); + } + + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "requested_token_type unsupported", Response.Status.BAD_REQUEST); + } + + protected Response exchangeClientToOIDCClient(UserModel targetUser, UserSessionModel targetUserSession, String requestedTokenType, + ClientModel targetClient, String audience, String scope) { + RootAuthenticationSessionModel rootAuthSession = new AuthenticationSessionManager(session).createAuthenticationSession(realm, false); + AuthenticationSessionModel authSession = rootAuthSession.createAuthenticationSession(targetClient); + + authSession.setAuthenticatedUser(targetUser); + authSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + authSession.setClientNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName())); + authSession.setClientNote(OIDCLoginProtocol.SCOPE_PARAM, scope); + + event.session(targetUserSession); + + AuthenticationManager.setClientScopesInSession(authSession); + ClientSessionContext clientSessionCtx = TokenManager.attachAuthenticationSession(this.session, targetUserSession, authSession); + + updateUserSessionFromClientAuth(targetUserSession); + + TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, targetClient, event, this.session, targetUserSession, clientSessionCtx) + .generateAccessToken(); + responseBuilder.getAccessToken().issuedFor(client.getClientId()); + + if (audience != null) { + responseBuilder.getAccessToken().addAudience(audience); + } + + if (requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE) + && OIDCAdvancedConfigWrapper.fromClientModel(client).isUseRefreshToken()) { + responseBuilder.generateRefreshToken(); + responseBuilder.getRefreshToken().issuedFor(client.getClientId()); + } + + String scopeParam = clientSessionCtx.getClientSession().getNote(OAuth2Constants.SCOPE); + if (TokenUtil.isOIDCRequest(scopeParam)) { + responseBuilder.generateIDToken().generateAccessTokenHash(); + } + + AccessTokenResponse res = responseBuilder.build(); + event.detail(Details.AUDIENCE, targetClient.getClientId()); + + event.success(); + + return cors.builder(Response.ok(res, MediaType.APPLICATION_JSON_TYPE)).build(); + } + + protected Response exchangeClientToSAML2Client(UserModel targetUser, UserSessionModel targetUserSession, String requestedTokenType, + ClientModel targetClient, String audience, String scope) { + // Create authSession with target SAML 2.0 client and authenticated user + LoginProtocolFactory factory = (LoginProtocolFactory) session.getKeycloakSessionFactory() + .getProviderFactory(LoginProtocol.class, SamlProtocol.LOGIN_PROTOCOL); + SamlService samlService = (SamlService) factory.createProtocolEndpoint(realm, event); + ResteasyProviderFactory.getInstance().injectProperties(samlService); + AuthenticationSessionModel authSession = samlService.getOrCreateLoginSessionForIdpInitiatedSso(session, realm, + targetClient, null); + if (authSession == null) { + logger.error("SAML assertion consumer url not set up"); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Client requires assertion consumer url set up", Response.Status.BAD_REQUEST); + } + + authSession.setAuthenticatedUser(targetUser); + + event.session(targetUserSession); + + AuthenticationManager.setClientScopesInSession(authSession); + ClientSessionContext clientSessionCtx = TokenManager.attachAuthenticationSession(this.session, targetUserSession, + authSession); + + updateUserSessionFromClientAuth(targetUserSession); + + // Create SAML 2.0 Assertion Response + SamlClient samlClient = new SamlClient(targetClient); + SamlProtocol samlProtocol = new TokenExchangeSamlProtocol(samlClient).setEventBuilder(event).setHttpHeaders(headers).setRealm(realm) + .setSession(session).setUriInfo(session.getContext().getUri()); + + Response samlAssertion = samlProtocol.authenticated(authSession, targetUserSession, clientSessionCtx); + if (samlAssertion.getStatus() != 200) { + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Can not get SAML 2.0 token", Response.Status.BAD_REQUEST); + } + String xmlString = (String) samlAssertion.getEntity(); + String encodedXML = Base64Url.encode(xmlString.getBytes(GeneralConstants.SAML_CHARSET)); + + int assertionLifespan = samlClient.getAssertionLifespan(); + + AccessTokenResponse res = new AccessTokenResponse(); + res.setToken(encodedXML); + res.setTokenType("Bearer"); + res.setExpiresIn(assertionLifespan <= 0 ? realm.getAccessCodeLifespan() : assertionLifespan); + res.setOtherClaims(OAuth2Constants.ISSUED_TOKEN_TYPE, requestedTokenType); + + event.detail(Details.AUDIENCE, targetClient.getClientId()); + event.success(); + + return cors.builder(Response.ok(res, MediaType.APPLICATION_JSON_TYPE)).build(); + } + + protected Response exchangeExternalToken(String issuer, String subjectToken) { + AtomicReference externalIdp = new AtomicReference<>(null); + AtomicReference externalIdpModel = new AtomicReference<>(null); + + realm.getIdentityProvidersStream().filter(idpModel -> { + IdentityProviderFactory factory = IdentityBrokerService.getIdentityProviderFactory(session, idpModel); + IdentityProvider idp = factory.create(session, idpModel); + if (idp instanceof ExchangeExternalToken) { + ExchangeExternalToken external = (ExchangeExternalToken) idp; + if (idpModel.getAlias().equals(issuer) || external.isIssuer(issuer, formParams)) { + externalIdp.set(external); + externalIdpModel.set(idpModel); + return true; + } + } + return false; + }).findFirst(); + + + if (externalIdp.get() == null) { + event.error(Errors.INVALID_ISSUER); + throw new CorsErrorResponseException(cors, Errors.INVALID_ISSUER, "Invalid " + OAuth2Constants.SUBJECT_ISSUER + " parameter", Response.Status.BAD_REQUEST); + } + if (!AdminPermissions.management(session, realm).idps().canExchangeTo(client, externalIdpModel.get())) { + event.detail(Details.REASON, "client not allowed to exchange subject_issuer"); + event.error(Errors.NOT_ALLOWED); + throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); + } + BrokeredIdentityContext context = externalIdp.get().exchangeExternal(event, formParams); + if (context == null) { + event.error(Errors.INVALID_ISSUER); + throw new CorsErrorResponseException(cors, Errors.INVALID_ISSUER, "Invalid " + OAuth2Constants.SUBJECT_ISSUER + " parameter", Response.Status.BAD_REQUEST); + } + + UserModel user = importUserFromExternalIdentity(context); + + UserSessionModel userSession = session.sessions().createUserSession(realm, user, user.getUsername(), clientConnection.getRemoteAddr(), "external-exchange", false, null, null); + externalIdp.get().exchangeExternalComplete(userSession, context, formParams); + + // this must exist so that we can obtain access token from user session if idp's store tokens is off + userSession.setNote(IdentityProvider.EXTERNAL_IDENTITY_PROVIDER, externalIdpModel.get().getAlias()); + userSession.setNote(IdentityProvider.FEDERATED_ACCESS_TOKEN, subjectToken); + + return exchangeClientToClient(user, userSession); + } + + protected UserModel importUserFromExternalIdentity(BrokeredIdentityContext context) { + IdentityProviderModel identityProviderConfig = context.getIdpConfig(); + + String providerId = identityProviderConfig.getAlias(); + + // do we need this? + //AuthenticationSessionModel authenticationSession = clientCode.getClientSession(); + //context.setAuthenticationSession(authenticationSession); + //session.getContext().setClient(authenticationSession.getClient()); + + context.getIdp().preprocessFederatedIdentity(session, realm, context); + Set mappers = realm.getIdentityProviderMappersByAliasStream(context.getIdpConfig().getAlias()) + .collect(Collectors.toSet()); + KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory(); + for (IdentityProviderMapperModel mapper : mappers) { + IdentityProviderMapper target = (IdentityProviderMapper)sessionFactory.getProviderFactory(IdentityProviderMapper.class, mapper.getIdentityProviderMapper()); + target.preprocessFederatedIdentity(session, realm, mapper, context); + } + + FederatedIdentityModel federatedIdentityModel = new FederatedIdentityModel(providerId, context.getId(), + context.getUsername(), context.getToken()); + + UserModel user = this.session.users().getUserByFederatedIdentity(realm, federatedIdentityModel); + + if (user == null) { + + logger.debugf("Federated user not found for provider '%s' and broker username '%s'.", providerId, context.getUsername()); + + String username = context.getModelUsername(); + if (username == null) { + if (this.realm.isRegistrationEmailAsUsername() && !Validation.isBlank(context.getEmail())) { + username = context.getEmail(); + } else if (context.getUsername() == null) { + username = context.getIdpConfig().getAlias() + "." + context.getId(); + } else { + username = context.getUsername(); + } + } + username = username.trim(); + context.setModelUsername(username); + if (context.getEmail() != null && !realm.isDuplicateEmailsAllowed()) { + UserModel existingUser = session.users().getUserByEmail(realm, context.getEmail()); + if (existingUser != null) { + event.error(Errors.FEDERATED_IDENTITY_EXISTS); + throw new CorsErrorResponseException(cors, Errors.INVALID_TOKEN, "User already exists", Response.Status.BAD_REQUEST); + } + } + + UserModel existingUser = session.users().getUserByUsername(realm, username); + if (existingUser != null) { + event.error(Errors.FEDERATED_IDENTITY_EXISTS); + throw new CorsErrorResponseException(cors, Errors.INVALID_TOKEN, "User already exists", Response.Status.BAD_REQUEST); + } + + + user = session.users().addUser(realm, username); + user.setEnabled(true); + user.setEmail(context.getEmail()); + user.setFirstName(context.getFirstName()); + user.setLastName(context.getLastName()); + + + federatedIdentityModel = new FederatedIdentityModel(context.getIdpConfig().getAlias(), context.getId(), + context.getUsername(), context.getToken()); + session.users().addFederatedIdentity(realm, user, federatedIdentityModel); + + context.getIdp().importNewUser(session, realm, user, context); + + for (IdentityProviderMapperModel mapper : mappers) { + IdentityProviderMapper target = (IdentityProviderMapper)sessionFactory.getProviderFactory(IdentityProviderMapper.class, mapper.getIdentityProviderMapper()); + target.importNewUser(session, realm, user, mapper, context); + } + + if (context.getIdpConfig().isTrustEmail() && !Validation.isBlank(user.getEmail())) { + logger.debugf("Email verified automatically after registration of user '%s' through Identity provider '%s' ", user.getUsername(), context.getIdpConfig().getAlias()); + user.setEmailVerified(true); + } + } else { + if (!user.isEnabled()) { + event.error(Errors.USER_DISABLED); + throw new CorsErrorResponseException(cors, Errors.INVALID_TOKEN, "Invalid Token", Response.Status.BAD_REQUEST); + } + + String bruteForceError = getDisabledByBruteForceEventError(session.getProvider(BruteForceProtector.class), session, realm, user); + if (bruteForceError != null) { + event.error(bruteForceError); + throw new CorsErrorResponseException(cors, Errors.INVALID_TOKEN, "Invalid Token", Response.Status.BAD_REQUEST); + } + + context.getIdp().updateBrokeredUser(session, realm, user, context); + + for (IdentityProviderMapperModel mapper : mappers) { + IdentityProviderMapper target = (IdentityProviderMapper)sessionFactory.getProviderFactory(IdentityProviderMapper.class, mapper.getIdentityProviderMapper()); + IdentityProviderMapperSyncModeDelegate.delegateUpdateBrokeredUser(session, realm, user, mapper, context, target); + } + } + return user; + } + + // TODO: move to utility class + private void updateUserSessionFromClientAuth(UserSessionModel userSession) { + for (Map.Entry attr : clientAuthAttributes.entrySet()) { + userSession.setNote(attr.getKey(), attr.getValue()); + } + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProviderFactory.java new file mode 100644 index 0000000000..88d5674326 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProviderFactory.java @@ -0,0 +1,53 @@ +/* + * Copyright 2021 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; + +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +/** + * Default token exchange provider factory + * + * @author Dmitry Telegin + */ +public class DefaultTokenExchangeProviderFactory implements TokenExchangeProviderFactory { + + @Override + public TokenExchangeProvider create(KeycloakSession session) { + return new DefaultTokenExchangeProvider(); + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return "default"; + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java index 1634a6f3da..20b9b4ea50 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java @@ -27,17 +27,9 @@ import org.keycloak.authentication.AuthenticationProcessor; import org.keycloak.authorization.AuthorizationProvider; import org.keycloak.authorization.authorization.AuthorizationTokenService; import org.keycloak.authorization.util.Tokens; -import org.keycloak.broker.provider.BrokeredIdentityContext; -import org.keycloak.broker.provider.ExchangeExternalToken; -import org.keycloak.broker.provider.ExchangeTokenToIdentityProviderToken; -import org.keycloak.broker.provider.IdentityProvider; -import org.keycloak.broker.provider.IdentityProviderFactory; -import org.keycloak.broker.provider.IdentityProviderMapper; -import org.keycloak.broker.provider.IdentityProviderMapperSyncModeDelegate; import org.keycloak.common.ClientConnection; import org.keycloak.common.Profile; import org.keycloak.common.constants.ServiceAccountConstants; -import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.KeycloakUriBuilder; import org.keycloak.constants.AdapterConstants; import org.keycloak.events.Details; @@ -51,33 +43,28 @@ import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.ClientSessionContext; -import org.keycloak.models.FederatedIdentityModel; -import org.keycloak.models.IdentityProviderMapperModel; -import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.KeycloakSession; -import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.models.utils.AuthenticationFlowResolver; -import org.keycloak.protocol.LoginProtocol; -import org.keycloak.protocol.LoginProtocolFactory; -import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType; -import org.keycloak.protocol.oidc.grants.device.DeviceGrantType; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.TokenExchangeContext; +import org.keycloak.protocol.oidc.TokenExchangeProvider; import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType; +import org.keycloak.protocol.oidc.grants.device.DeviceGrantType; import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; +import org.keycloak.protocol.oidc.utils.OAuth2Code; +import org.keycloak.protocol.oidc.utils.OAuth2CodeParser; import org.keycloak.protocol.oidc.utils.PkceUtils; import org.keycloak.protocol.saml.JaxrsSAML2BindingBuilder; import org.keycloak.protocol.saml.SamlClient; import org.keycloak.protocol.saml.SamlProtocol; -import org.keycloak.protocol.saml.SamlService; import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessTokenResponse; -import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.idm.authorization.AuthorizationRequest.Metadata; -import org.keycloak.saml.common.constants.GeneralConstants; import org.keycloak.saml.common.constants.JBossSAMLConstants; import org.keycloak.saml.common.constants.JBossSAMLURIConstants; import org.keycloak.saml.common.exceptions.ConfigurationException; @@ -92,18 +79,11 @@ import org.keycloak.services.clientpolicy.context.TokenRequestContext; import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationSessionManager; -import org.keycloak.services.managers.BruteForceProtector; import org.keycloak.services.managers.ClientManager; -import org.keycloak.protocol.oidc.utils.OAuth2Code; -import org.keycloak.protocol.oidc.utils.OAuth2CodeParser; import org.keycloak.services.managers.RealmManager; import org.keycloak.services.resources.Cors; -import org.keycloak.services.resources.IdentityBrokerService; -import org.keycloak.services.resources.admin.AdminAuth; -import org.keycloak.services.resources.admin.permissions.AdminPermissions; -import org.keycloak.services.util.MtlsHoKTokenUtil; import org.keycloak.services.util.DefaultClientSessionContext; -import org.keycloak.services.validation.Validation; +import org.keycloak.services.util.MtlsHoKTokenUtil; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.RootAuthenticationSessionModel; import org.keycloak.util.TokenUtil; @@ -112,6 +92,7 @@ import org.w3c.dom.Document; import org.w3c.dom.Element; import javax.ws.rs.Consumes; +import javax.ws.rs.InternalServerErrorException; import javax.ws.rs.OPTIONS; import javax.ws.rs.POST; import javax.ws.rs.Path; @@ -123,22 +104,15 @@ import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; import javax.xml.namespace.QName; -import java.io.IOException; +import java.io.IOException; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.function.Supplier; -import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Stream; -import java.util.stream.Collectors; - -import static org.keycloak.authentication.authenticators.util.AuthenticatorUtils.getDisabledByBruteForceEventError; -import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID; -import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME; /** * @author Stian Thorgersen @@ -822,434 +796,26 @@ public class TokenEndpoint { event.detail(Details.AUTH_METHOD, "token_exchange"); event.client(client); - UserModel tokenUser = null; - UserSessionModel tokenSession = null; - AccessToken token = null; - - String subjectToken = formParams.getFirst(OAuth2Constants.SUBJECT_TOKEN); - if (subjectToken != null) { - String subjectTokenType = formParams.getFirst(OAuth2Constants.SUBJECT_TOKEN_TYPE); - String realmIssuerUrl = Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()); - String subjectIssuer = formParams.getFirst(OAuth2Constants.SUBJECT_ISSUER); - - if (subjectIssuer == null && OAuth2Constants.JWT_TOKEN_TYPE.equals(subjectTokenType)) { - try { - JWSInput jws = new JWSInput(subjectToken); - JsonWebToken jwt = jws.readJsonContent(JsonWebToken.class); - subjectIssuer = jwt.getIssuer(); - } catch (JWSInputException e) { - event.detail(Details.REASON, "unable to parse jwt subject_token"); - event.error(Errors.INVALID_TOKEN); - throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_TOKEN, "Invalid token type, must be access token", Response.Status.BAD_REQUEST); - - } - } - - if (subjectIssuer != null && !realmIssuerUrl.equals(subjectIssuer)) { - event.detail(OAuth2Constants.SUBJECT_ISSUER, subjectIssuer); - return exchangeExternalToken(subjectIssuer, subjectToken); - - } - - if (subjectTokenType != null && !subjectTokenType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE)) { - event.detail(Details.REASON, "subject_token supports access tokens only"); - event.error(Errors.INVALID_TOKEN); - throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_TOKEN, "Invalid token type, must be access token", Response.Status.BAD_REQUEST); - - } - - AuthenticationManager.AuthResult authResult = AuthenticationManager.verifyIdentityToken(session, realm, session.getContext().getUri(), clientConnection, true, true, null, false, subjectToken, headers); - if (authResult == null) { - event.detail(Details.REASON, "subject_token validation failure"); - event.error(Errors.INVALID_TOKEN); - throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_TOKEN, "Invalid token", Response.Status.BAD_REQUEST); - } - - tokenUser = authResult.getUser(); - tokenSession = authResult.getSession(); - token = authResult.getToken(); - } - - String requestedSubject = formParams.getFirst(OAuth2Constants.REQUESTED_SUBJECT); - if (requestedSubject != null) { - event.detail(Details.REQUESTED_SUBJECT, requestedSubject); - UserModel requestedUser = session.users().getUserByUsername(realm, requestedSubject); - if (requestedUser == null) { - requestedUser = session.users().getUserById(realm, requestedSubject); - } - - if (requestedUser == null) { - // We always returned access denied to avoid username fishing - event.detail(Details.REASON, "requested_subject not found"); - event.error(Errors.NOT_ALLOWED); - throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); - - } - - if (token != null) { - event.detail(Details.IMPERSONATOR, tokenUser.getUsername()); - // for this case, the user represented by the token, must have permission to impersonate. - AdminAuth auth = new AdminAuth(realm, token, tokenUser, client); - if (!AdminPermissions.evaluator(session, realm, auth).users().canImpersonate(requestedUser)) { - event.detail(Details.REASON, "subject not allowed to impersonate"); - event.error(Errors.NOT_ALLOWED); - throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); - } - - } else { - // no token is being exchanged, this is a direct exchange. Client must be authenticated, not public, and must be allowed - // to impersonate - if (client.isPublicClient()) { - event.detail(Details.REASON, "public clients not allowed"); - event.error(Errors.NOT_ALLOWED); - throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); - - } - if (!AdminPermissions.management(session, realm).users().canClientImpersonate(client, requestedUser)) { - event.detail(Details.REASON, "client not allowed to impersonate"); - event.error(Errors.NOT_ALLOWED); - throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); - } - } - - tokenSession = session.sessions().createUserSession(realm, requestedUser, requestedUser.getUsername(), clientConnection.getRemoteAddr(), "impersonate", false, null, null); - if (tokenUser != null) { - tokenSession.setNote(IMPERSONATOR_ID.toString(), tokenUser.getId()); - tokenSession.setNote(IMPERSONATOR_USERNAME.toString(), tokenUser.getUsername()); - } - - tokenUser = requestedUser; - } - - String requestedIssuer = formParams.getFirst(OAuth2Constants.REQUESTED_ISSUER); - if (requestedIssuer == null) { - return exchangeClientToClient(tokenUser, tokenSession); - } else { - try { - return exchangeToIdentityProvider(tokenUser, tokenSession, requestedIssuer); - } finally { - if (subjectToken == null) { // we are naked! So need to clean up user session - try { - session.sessions().removeUserSession(realm, tokenSession); - } catch (Exception ignore) { - - } - } - } - } - } - - public Response exchangeToIdentityProvider(UserModel targetUser, UserSessionModel targetUserSession, String requestedIssuer) { - event.detail(Details.REQUESTED_ISSUER, requestedIssuer); - IdentityProviderModel providerModel = realm.getIdentityProviderByAlias(requestedIssuer); - if (providerModel == null) { - event.detail(Details.REASON, "unknown requested_issuer"); - event.error(Errors.UNKNOWN_IDENTITY_PROVIDER); - throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Invalid issuer", Response.Status.BAD_REQUEST); - } - - IdentityProvider provider = IdentityBrokerService.getIdentityProvider(session, realm, requestedIssuer); - if (!(provider instanceof ExchangeTokenToIdentityProviderToken)) { - event.detail(Details.REASON, "exchange unsupported by requested_issuer"); - event.error(Errors.UNKNOWN_IDENTITY_PROVIDER); - throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Issuer does not support token exchange", Response.Status.BAD_REQUEST); - } - if (!AdminPermissions.management(session, realm).idps().canExchangeTo(client, providerModel)) { - event.detail(Details.REASON, "client not allowed to exchange for requested_issuer"); - event.error(Errors.NOT_ALLOWED); - throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); - } - Response response = ((ExchangeTokenToIdentityProviderToken)provider).exchangeFromToken(session.getContext().getUri(), event, client, targetUserSession, targetUser, formParams); - return cors.builder(Response.fromResponse(response)).build(); - - } - - protected Response exchangeClientToClient(UserModel targetUser, UserSessionModel targetUserSession) { - String requestedTokenType = formParams.getFirst(OAuth2Constants.REQUESTED_TOKEN_TYPE); - if (requestedTokenType == null) { - requestedTokenType = OAuth2Constants.REFRESH_TOKEN_TYPE; - } else if (!requestedTokenType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE) && - !requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE) && - !requestedTokenType.equals(OAuth2Constants.SAML2_TOKEN_TYPE)) { - event.detail(Details.REASON, "requested_token_type unsupported"); - event.error(Errors.INVALID_REQUEST); - throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "requested_token_type unsupported", Response.Status.BAD_REQUEST); - - } - ClientModel targetClient = client; - String audience = formParams.getFirst(OAuth2Constants.AUDIENCE); - if (audience != null) { - targetClient = realm.getClientByClientId(audience); - if (targetClient == null) { - event.detail(Details.REASON, "audience not found"); - event.error(Errors.CLIENT_NOT_FOUND); - throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Audience not found", Response.Status.BAD_REQUEST); - - } - } - - if (targetClient.isConsentRequired()) { - event.detail(Details.REASON, "audience requires consent"); - event.error(Errors.CONSENT_DENIED); - throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Client requires user consent", Response.Status.BAD_REQUEST); - } - - if (!targetClient.equals(client) && !AdminPermissions.management(session, realm).clients().canExchangeTo(client, targetClient)) { - event.detail(Details.REASON, "client not allowed to exchange to audience"); - event.error(Errors.NOT_ALLOWED); - throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); - } - - String scope = formParams.getFirst(OAuth2Constants.SCOPE); - - switch (requestedTokenType) { - case OAuth2Constants.ACCESS_TOKEN_TYPE: - case OAuth2Constants.REFRESH_TOKEN_TYPE: - return exchangeClientToOIDCClient(targetUser, targetUserSession, requestedTokenType, targetClient, audience, scope); - case OAuth2Constants.SAML2_TOKEN_TYPE: - return exchangeClientToSAML2Client(targetUser, targetUserSession, requestedTokenType, targetClient, audience, scope); - } - - throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "requested_token_type unsupported", Response.Status.BAD_REQUEST); - } - - protected Response exchangeClientToOIDCClient(UserModel targetUser, UserSessionModel targetUserSession, String requestedTokenType, - ClientModel targetClient, String audience, String scope) { - RootAuthenticationSessionModel rootAuthSession = new AuthenticationSessionManager(session).createAuthenticationSession(realm, false); - AuthenticationSessionModel authSession = rootAuthSession.createAuthenticationSession(targetClient); - - authSession.setAuthenticatedUser(targetUser); - authSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); - authSession.setClientNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName())); - authSession.setClientNote(OIDCLoginProtocol.SCOPE_PARAM, scope); - - event.session(targetUserSession); - - AuthenticationManager.setClientScopesInSession(authSession); - ClientSessionContext clientSessionCtx = TokenManager.attachAuthenticationSession(this.session, targetUserSession, authSession); - - updateUserSessionFromClientAuth(targetUserSession); - - TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, targetClient, event, this.session, targetUserSession, clientSessionCtx) - .generateAccessToken(); - responseBuilder.getAccessToken().issuedFor(client.getClientId()); - - if (audience != null) { - responseBuilder.getAccessToken().addAudience(audience); - } - - if (requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE) - && OIDCAdvancedConfigWrapper.fromClientModel(client).isUseRefreshToken()) { - responseBuilder.generateRefreshToken(); - responseBuilder.getRefreshToken().issuedFor(client.getClientId()); - } - - String scopeParam = clientSessionCtx.getClientSession().getNote(OAuth2Constants.SCOPE); - if (TokenUtil.isOIDCRequest(scopeParam)) { - responseBuilder.generateIDToken().generateAccessTokenHash(); - } - - AccessTokenResponse res = responseBuilder.build(); - event.detail(Details.AUDIENCE, targetClient.getClientId()); - - event.success(); - - return cors.builder(Response.ok(res, MediaType.APPLICATION_JSON_TYPE)).build(); - } - - protected Response exchangeClientToSAML2Client(UserModel targetUser, UserSessionModel targetUserSession, String requestedTokenType, - ClientModel targetClient, String audience, String scope) { - // Create authSession with target SAML 2.0 client and authenticated user - LoginProtocolFactory factory = (LoginProtocolFactory) session.getKeycloakSessionFactory() - .getProviderFactory(LoginProtocol.class, SamlProtocol.LOGIN_PROTOCOL); - SamlService samlService = (SamlService) factory.createProtocolEndpoint(realm, event); - ResteasyProviderFactory.getInstance().injectProperties(samlService); - AuthenticationSessionModel authSession = samlService.getOrCreateLoginSessionForIdpInitiatedSso(session, realm, - targetClient, null); - if (authSession == null) { - logger.error("SAML assertion consumer url not set up"); - throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Client requires assertion consumer url set up", Response.Status.BAD_REQUEST); - } - - authSession.setAuthenticatedUser(targetUser); - - event.session(targetUserSession); - - AuthenticationManager.setClientScopesInSession(authSession); - ClientSessionContext clientSessionCtx = TokenManager.attachAuthenticationSession(this.session, targetUserSession, - authSession); - - updateUserSessionFromClientAuth(targetUserSession); - - // Create SAML 2.0 Assertion Response - SamlClient samlClient = new SamlClient(targetClient); - SamlProtocol samlProtocol = new TokenExchangeSamlProtocol(samlClient).setEventBuilder(event).setHttpHeaders(headers).setRealm(realm) - .setSession(session).setUriInfo(session.getContext().getUri()); - - Response samlAssertion = samlProtocol.authenticated(authSession, targetUserSession, clientSessionCtx); - if (samlAssertion.getStatus() != 200) { - throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Can not get SAML 2.0 token", Response.Status.BAD_REQUEST); - } - String xmlString = (String) samlAssertion.getEntity(); - String encodedXML = Base64Url.encode(xmlString.getBytes(GeneralConstants.SAML_CHARSET)); - - int assertionLifespan = samlClient.getAssertionLifespan(); - - AccessTokenResponse res = new AccessTokenResponse(); - res.setToken(encodedXML); - res.setTokenType("Bearer"); - res.setExpiresIn(assertionLifespan <= 0 ? realm.getAccessCodeLifespan() : assertionLifespan); - res.setOtherClaims(OAuth2Constants.ISSUED_TOKEN_TYPE, requestedTokenType); - - event.detail(Details.AUDIENCE, targetClient.getClientId()); - event.success(); - - return cors.builder(Response.ok(res, MediaType.APPLICATION_JSON_TYPE)).build(); - } - - public Response exchangeExternalToken(String issuer, String subjectToken) { - AtomicReference externalIdp = new AtomicReference<>(null); - AtomicReference externalIdpModel = new AtomicReference<>(null); - - realm.getIdentityProvidersStream().filter(idpModel -> { - IdentityProviderFactory factory = IdentityBrokerService.getIdentityProviderFactory(session, idpModel); - IdentityProvider idp = factory.create(session, idpModel); - if (idp instanceof ExchangeExternalToken) { - ExchangeExternalToken external = (ExchangeExternalToken) idp; - if (idpModel.getAlias().equals(issuer) || external.isIssuer(issuer, formParams)) { - externalIdp.set(external); - externalIdpModel.set(idpModel); - return true; - } - } - return false; - }).findFirst(); - - - if (externalIdp.get() == null) { - event.error(Errors.INVALID_ISSUER); - throw new CorsErrorResponseException(cors, Errors.INVALID_ISSUER, "Invalid " + OAuth2Constants.SUBJECT_ISSUER + " parameter", Response.Status.BAD_REQUEST); - } - if (!AdminPermissions.management(session, realm).idps().canExchangeTo(client, externalIdpModel.get())) { - event.detail(Details.REASON, "client not allowed to exchange subject_issuer"); - event.error(Errors.NOT_ALLOWED); - throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); - } - BrokeredIdentityContext context = externalIdp.get().exchangeExternal(event, formParams); - if (context == null) { - event.error(Errors.INVALID_ISSUER); - throw new CorsErrorResponseException(cors, Errors.INVALID_ISSUER, "Invalid " + OAuth2Constants.SUBJECT_ISSUER + " parameter", Response.Status.BAD_REQUEST); - } - - UserModel user = importUserFromExternalIdentity(context); - - UserSessionModel userSession = session.sessions().createUserSession(realm, user, user.getUsername(), clientConnection.getRemoteAddr(), "external-exchange", false, null, null); - externalIdp.get().exchangeExternalComplete(userSession, context, formParams); - - // this must exist so that we can obtain access token from user session if idp's store tokens is off - userSession.setNote(IdentityProvider.EXTERNAL_IDENTITY_PROVIDER, externalIdpModel.get().getAlias()); - userSession.setNote(IdentityProvider.FEDERATED_ACCESS_TOKEN, subjectToken); - - return exchangeClientToClient(user, userSession); - } - - protected UserModel importUserFromExternalIdentity(BrokeredIdentityContext context) { - IdentityProviderModel identityProviderConfig = context.getIdpConfig(); - - String providerId = identityProviderConfig.getAlias(); - - // do we need this? - //AuthenticationSessionModel authenticationSession = clientCode.getClientSession(); - //context.setAuthenticationSession(authenticationSession); - //session.getContext().setClient(authenticationSession.getClient()); - - context.getIdp().preprocessFederatedIdentity(session, realm, context); - Set mappers = realm.getIdentityProviderMappersByAliasStream(context.getIdpConfig().getAlias()) - .collect(Collectors.toSet()); - KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory(); - for (IdentityProviderMapperModel mapper : mappers) { - IdentityProviderMapper target = (IdentityProviderMapper)sessionFactory.getProviderFactory(IdentityProviderMapper.class, mapper.getIdentityProviderMapper()); - target.preprocessFederatedIdentity(session, realm, mapper, context); - } - - FederatedIdentityModel federatedIdentityModel = new FederatedIdentityModel(providerId, context.getId(), - context.getUsername(), context.getToken()); - - UserModel user = this.session.users().getUserByFederatedIdentity(realm, federatedIdentityModel); - - if (user == null) { - - logger.debugf("Federated user not found for provider '%s' and broker username '%s'.", providerId, context.getUsername()); - - String username = context.getModelUsername(); - if (username == null) { - if (this.realm.isRegistrationEmailAsUsername() && !Validation.isBlank(context.getEmail())) { - username = context.getEmail(); - } else if (context.getUsername() == null) { - username = context.getIdpConfig().getAlias() + "." + context.getId(); - } else { - username = context.getUsername(); - } - } - username = username.trim(); - context.setModelUsername(username); - if (context.getEmail() != null && !realm.isDuplicateEmailsAllowed()) { - UserModel existingUser = session.users().getUserByEmail(realm, context.getEmail()); - if (existingUser != null) { - event.error(Errors.FEDERATED_IDENTITY_EXISTS); - throw new CorsErrorResponseException(cors, Errors.INVALID_TOKEN, "User already exists", Response.Status.BAD_REQUEST); - } - } - - UserModel existingUser = session.users().getUserByUsername(realm, username); - if (existingUser != null) { - event.error(Errors.FEDERATED_IDENTITY_EXISTS); - throw new CorsErrorResponseException(cors, Errors.INVALID_TOKEN, "User already exists", Response.Status.BAD_REQUEST); - } - - - user = session.users().addUser(realm, username); - user.setEnabled(true); - user.setEmail(context.getEmail()); - user.setFirstName(context.getFirstName()); - user.setLastName(context.getLastName()); - - - federatedIdentityModel = new FederatedIdentityModel(context.getIdpConfig().getAlias(), context.getId(), - context.getUsername(), context.getToken()); - session.users().addFederatedIdentity(realm, user, federatedIdentityModel); - - context.getIdp().importNewUser(session, realm, user, context); - - for (IdentityProviderMapperModel mapper : mappers) { - IdentityProviderMapper target = (IdentityProviderMapper)sessionFactory.getProviderFactory(IdentityProviderMapper.class, mapper.getIdentityProviderMapper()); - target.importNewUser(session, realm, user, mapper, context); - } - - if (context.getIdpConfig().isTrustEmail() && !Validation.isBlank(user.getEmail())) { - logger.debugf("Email verified automatically after registration of user '%s' through Identity provider '%s' ", user.getUsername(), context.getIdpConfig().getAlias()); - user.setEmailVerified(true); - } - } else { - if (!user.isEnabled()) { - event.error(Errors.USER_DISABLED); - throw new CorsErrorResponseException(cors, Errors.INVALID_TOKEN, "Invalid Token", Response.Status.BAD_REQUEST); - } - - String bruteForceError = getDisabledByBruteForceEventError(session.getProvider(BruteForceProtector.class), session, realm, user); - if (bruteForceError != null) { - event.error(bruteForceError); - throw new CorsErrorResponseException(cors, Errors.INVALID_TOKEN, "Invalid Token", Response.Status.BAD_REQUEST); - } - - context.getIdp().updateBrokeredUser(session, realm, user, context); - - for (IdentityProviderMapperModel mapper : mappers) { - IdentityProviderMapper target = (IdentityProviderMapper)sessionFactory.getProviderFactory(IdentityProviderMapper.class, mapper.getIdentityProviderMapper()); - IdentityProviderMapperSyncModeDelegate.delegateUpdateBrokeredUser(session, realm, user, mapper, context, target); - } - } - return user; + TokenExchangeContext context = new TokenExchangeContext( + session, + formParams, + cors, + realm, + event, + client, + clientConnection, + headers, + tokenManager, + clientAuthAttributes); + + return session.getKeycloakSessionFactory() + .getProviderFactoriesStream(TokenExchangeProvider.class) + .sorted((f1, f2) -> f2.order() - f1.order()) + .map(f -> session.getProvider(TokenExchangeProvider.class, f.getId())) + .filter(p -> p.supports(context)) + .findFirst() + .orElseThrow(() -> new InternalServerErrorException("No token exchange provider available")) + .exchange(context); } public Response permissionGrant() { @@ -1418,11 +984,11 @@ public class TokenEndpoint { return m.matches(); } - private static class TokenExchangeSamlProtocol extends SamlProtocol { + public static class TokenExchangeSamlProtocol extends SamlProtocol { final SamlClient samlClient; - TokenExchangeSamlProtocol(SamlClient samlClient) { + public TokenExchangeSamlProtocol(SamlClient samlClient) { this.samlClient = samlClient; } 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 new file mode 100644 index 0000000000..8ec88198b1 --- /dev/null +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.TokenExchangeProviderFactory @@ -0,0 +1,2 @@ +org.keycloak.protocol.oidc.DefaultTokenExchangeProviderFactory +