Details
diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java
index 5a64c88..f068b39 100755
--- a/common/src/main/java/org/keycloak/common/Profile.java
+++ b/common/src/main/java/org/keycloak/common/Profile.java
@@ -35,11 +35,11 @@ import java.util.Set;
public class Profile {
public enum Feature {
- AUTHORIZATION, IMPERSONATION, SCRIPTS, DOCKER, ACCOUNT2
+ AUTHORIZATION, IMPERSONATION, SCRIPTS, DOCKER, ACCOUNT2, TOKEN_EXCHANGE
}
private enum ProfileValue {
- PRODUCT(Feature.AUTHORIZATION, Feature.SCRIPTS, Feature.DOCKER, Feature.ACCOUNT2),
+ PRODUCT(Feature.AUTHORIZATION, Feature.SCRIPTS, Feature.DOCKER, Feature.ACCOUNT2, Feature.TOKEN_EXCHANGE),
PREVIEW(Feature.ACCOUNT2),
COMMUNITY(Feature.DOCKER, Feature.ACCOUNT2);
diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java
index 244de98..098fdcd 100644
--- a/core/src/main/java/org/keycloak/OAuth2Constants.java
+++ b/core/src/main/java/org/keycloak/OAuth2Constants.java
@@ -101,9 +101,11 @@ public interface OAuth2Constants {
String REQUESTED_TOKEN_TYPE="requested_token_type";
String ISSUED_TOKEN_TYPE="issued_token_type";
String REQUESTED_ISSUER="requested_issuer";
+ String SUBJECT_ISSUER="subject_issuer";
String ACCESS_TOKEN_TYPE="urn:ietf:params:oauth:token-type:access_token";
String REFRESH_TOKEN_TYPE="urn:ietf:params:oauth:token-type:refresh_token";
String JWT_TOKEN_TYPE="urn:ietf:params:oauth:token-type:jwt";
+ String JWT_ACCESS_TOKEN_TYPE="urn:ietf:params:oauth:token-type:jwt:access_token";
String ID_TOKEN_TYPE="urn:ietf:params:oauth:token-type:id_token";
diff --git a/server-spi-private/src/main/java/org/keycloak/broker/provider/ExchangeExternalToken.java b/server-spi-private/src/main/java/org/keycloak/broker/provider/ExchangeExternalToken.java
new file mode 100644
index 0000000..a448d3d
--- /dev/null
+++ b/server-spi-private/src/main/java/org/keycloak/broker/provider/ExchangeExternalToken.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2016 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.broker.provider;
+
+import org.keycloak.broker.provider.BrokeredIdentityContext;
+import org.keycloak.events.EventBuilder;
+import org.keycloak.models.UserSessionModel;
+
+import javax.ws.rs.core.MultivaluedMap;
+
+/**
+ * Exchange a token crafted by this provider for a local realm token.
+ *
+ * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
+ * @version $Revision: 1 $
+ */
+public interface ExchangeExternalToken {
+ BrokeredIdentityContext exchangeExternal(EventBuilder event, MultivaluedMap<String, String> params);
+
+ void exchangeExternalComplete(UserSessionModel userSession, BrokeredIdentityContext context, MultivaluedMap<String, String> params);
+}
diff --git a/server-spi-private/src/main/java/org/keycloak/events/Errors.java b/server-spi-private/src/main/java/org/keycloak/events/Errors.java
index ab954bc..632c21c 100755
--- a/server-spi-private/src/main/java/org/keycloak/events/Errors.java
+++ b/server-spi-private/src/main/java/org/keycloak/events/Errors.java
@@ -46,13 +46,16 @@ public interface Errors {
String INVALID_REDIRECT_URI = "invalid_redirect_uri";
String INVALID_CODE = "invalid_code";
String INVALID_TOKEN = "invalid_token";
+ String INVALID_TOKEN_TYPE = "invalid_token_type";
String INVALID_SAML_RESPONSE = "invalid_saml_response";
String INVALID_SAML_AUTHN_REQUEST = "invalid_authn_request";
String INVALID_SAML_LOGOUT_REQUEST = "invalid_logout_request";
String INVALID_SAML_LOGOUT_RESPONSE = "invalid_logout_response";
String INVALID_SIGNATURE = "invalid_signature";
String INVALID_REGISTRATION = "invalid_registration";
+ String INVALID_ISSUER = "invalid_issuer";
String INVALID_FORM = "invalid_form";
+ String INVALID_CONFIG = "invalid_config";
String EXPIRED_CODE = "expired_code";
String REGISTRATION_DISABLED = "registration_disabled";
diff --git a/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java
index 4e2ea95..c3a26dc 100755
--- a/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java
+++ b/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java
@@ -149,11 +149,18 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
@Override
public Response exchangeFromToken(UriInfo uriInfo, ClientModel authorizedClient, UserSessionModel tokenUserSession, UserModel tokenSubject, MultivaluedMap<String, String> params) {
+ // check to see if we have a token exchange in session
+ // in other words check to see if this session was created by an external exchange
+ Response tokenResponse = hasExternalExchangeToken(tokenUserSession, params);
+ if (tokenResponse != null) return tokenResponse;
+
+ // going further we only support access token type? Why?
String requestedType = params.getFirst(OAuth2Constants.REQUESTED_TOKEN_TYPE);
if (requestedType != null && !requestedType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE)) {
return exchangeUnsupportedRequiredType();
}
if (!getConfig().isStoreToken()) {
+ // if token isn't stored, we need to see if this session has been linked
String brokerId = tokenUserSession.getNote(Details.IDENTITY_PROVIDER);
if (brokerId == null || !brokerId.equals(getConfig().getAlias())) {
return exchangeNotLinkedNoStore(uriInfo, authorizedClient, tokenUserSession, tokenSubject);
@@ -164,6 +171,50 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
}
}
+ /**
+ * check to see if we have a token exchange in session
+ * in other words check to see if this session was created by an external exchange
+ * @param tokenUserSession
+ * @param params
+ * @return
+ */
+ protected Response hasExternalExchangeToken(UserSessionModel tokenUserSession, MultivaluedMap<String, String> params) {
+ if (getConfig().getAlias().equals(tokenUserSession.getNote(OIDCIdentityProvider.EXCHANGE_PROVIDER))) {
+
+ String requestedType = params.getFirst(OAuth2Constants.REQUESTED_TOKEN_TYPE);
+ if ((requestedType == null || requestedType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE))) {
+ String accessToken = tokenUserSession.getNote(FEDERATED_ACCESS_TOKEN);
+ if (accessToken != null) {
+ AccessTokenResponse tokenResponse = new AccessTokenResponse();
+ tokenResponse.setToken(accessToken);
+ tokenResponse.setIdToken(null);
+ tokenResponse.setRefreshToken(null);
+ tokenResponse.setRefreshExpiresIn(0);
+ tokenResponse.setExpiresIn(0);
+ tokenResponse.getOtherClaims().clear();
+ tokenResponse.getOtherClaims().put(OAuth2Constants.ISSUED_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE);
+ return Response.ok(tokenResponse).type(MediaType.APPLICATION_JSON_TYPE).build();
+ }
+ } else if (OAuth2Constants.ID_TOKEN_TYPE.equals(requestedType)) {
+ String idToken = tokenUserSession.getNote(OIDCIdentityProvider.FEDERATED_ID_TOKEN);
+ if (idToken != null) {
+ AccessTokenResponse tokenResponse = new AccessTokenResponse();
+ tokenResponse.setToken(null);
+ tokenResponse.setIdToken(idToken);
+ tokenResponse.setRefreshToken(null);
+ tokenResponse.setRefreshExpiresIn(0);
+ tokenResponse.setExpiresIn(0);
+ tokenResponse.getOtherClaims().clear();
+ tokenResponse.getOtherClaims().put(OAuth2Constants.ISSUED_TOKEN_TYPE, OAuth2Constants.ID_TOKEN_TYPE);
+ return Response.ok(tokenResponse).type(MediaType.APPLICATION_JSON_TYPE).build();
+ }
+
+ }
+
+ }
+ return null;
+ }
+
protected Response exchangeStoredToken(UriInfo uriInfo, ClientModel authorizedClient, UserSessionModel tokenUserSession, UserModel tokenSubject) {
FederatedIdentityModel model = session.users().getFederatedIdentity(tokenSubject, getConfig().getAlias(), authorizedClient.getRealm());
if (model == null || model.getToken() == null) {
diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java
index 1dbce4c..2963783 100755
--- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java
+++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProvider.java
@@ -19,12 +19,15 @@ package org.keycloak.broker.oidc;
import com.fasterxml.jackson.databind.JsonNode;
import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants;
+import org.keycloak.OAuthErrorException;
import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper;
import org.keycloak.broker.provider.AuthenticationRequest;
import org.keycloak.broker.provider.BrokeredIdentityContext;
+import org.keycloak.broker.provider.ExchangeExternalToken;
import org.keycloak.broker.provider.IdentityBrokerException;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.common.util.Time;
+import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
@@ -38,11 +41,11 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
-import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.ErrorPage;
+import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.IdentityBrokerService;
@@ -55,6 +58,7 @@ import javax.ws.rs.Path;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.core.UriInfo;
@@ -64,7 +68,7 @@ import java.security.PublicKey;
/**
* @author Pedro Igor
*/
-public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIdentityProviderConfig> {
+public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIdentityProviderConfig> implements ExchangeExternalToken {
protected static final Logger logger = Logger.getLogger(OIDCIdentityProvider.class);
public static final String OAUTH2_PARAMETER_PROMPT = "prompt";
@@ -74,6 +78,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
public static final String FEDERATED_ACCESS_TOKEN_RESPONSE = "FEDERATED_ACCESS_TOKEN_RESPONSE";
public static final String VALIDATED_ID_TOKEN = "VALIDATED_ID_TOKEN";
public static final String ACCESS_TOKEN_EXPIRATION = "accessTokenExpiration";
+ public static final String EXCHANGE_PROVIDER = "EXCHANGE_PROVIDER";
public OIDCIdentityProvider(KeycloakSession session, OIDCIdentityProviderConfig config) {
super(session, config);
@@ -96,7 +101,6 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
}
-
@GET
@Path("logout_response")
public Response logoutResponse(@Context UriInfo uriInfo,
@@ -123,7 +127,8 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
@Override
public void backchannelLogout(KeycloakSession session, UserSessionModel userSession, UriInfo uriInfo, RealmModel realm) {
- if (getConfig().getLogoutUrl() == null || getConfig().getLogoutUrl().trim().equals("") || !getConfig().isBackchannelSupported()) return;
+ if (getConfig().getLogoutUrl() == null || getConfig().getLogoutUrl().trim().equals("") || !getConfig().isBackchannelSupported())
+ return;
String idToken = getIDTokenForLogout(session, userSession);
if (idToken == null) return;
backchannelLogout(userSession, idToken);
@@ -137,7 +142,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
String url = logoutUri.build().toString();
try {
int status = SimpleHttp.doGet(url, session).asStatus();
- boolean success = status >=200 && status < 400;
+ boolean success = status >= 200 && status < 400;
if (!success) {
logger.warn("Failed backchannel broker logout to: " + url);
}
@@ -233,7 +238,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
try {
String modelTokenString = model.getToken();
AccessTokenResponse tokenResponse = JsonSerialization.readValue(modelTokenString, AccessTokenResponse.class);
- Integer exp = (Integer)tokenResponse.getOtherClaims().get(ACCESS_TOKEN_EXPIRATION);
+ Integer exp = (Integer) tokenResponse.getOtherClaims().get(ACCESS_TOKEN_EXPIRATION);
if (exp != null && exp < Time.currentTime()) {
if (tokenResponse.getRefreshToken() == null) {
return exchangeTokenExpired(uriInfo, authorizedClient, tokenUserSession, tokenSubject);
@@ -251,13 +256,13 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
}
AccessTokenResponse newResponse = JsonSerialization.readValue(response, AccessTokenResponse.class);
if (newResponse.getExpiresIn() > 0) {
- int accessTokenExpiration = Time.currentTime() + (int)newResponse.getExpiresIn();
+ int accessTokenExpiration = Time.currentTime() + (int) newResponse.getExpiresIn();
newResponse.getOtherClaims().put(ACCESS_TOKEN_EXPIRATION, accessTokenExpiration);
response = JsonSerialization.writeValueAsString(newResponse);
}
String oldToken = tokenUserSession.getNote(FEDERATED_ACCESS_TOKEN);
if (oldToken != null && oldToken.equals(tokenResponse.getToken())) {
- int accessTokenExpiration = newResponse.getExpiresIn() > 0 ? Time.currentTime() + (int)newResponse.getExpiresIn() : 0;
+ int accessTokenExpiration = newResponse.getExpiresIn() > 0 ? Time.currentTime() + (int) newResponse.getExpiresIn() : 0;
tokenUserSession.setNote(FEDERATED_TOKEN_EXPIRATION, Long.toString(accessTokenExpiration));
tokenUserSession.setNote(FEDERATED_REFRESH_TOKEN, newResponse.getRefreshToken());
tokenUserSession.setNote(FEDERATED_ACCESS_TOKEN, newResponse.getToken());
@@ -361,35 +366,33 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
protected BrokeredIdentityContext extractIdentity(AccessTokenResponse tokenResponse, String accessToken, JsonWebToken idToken) throws IOException {
String id = idToken.getSubject();
BrokeredIdentityContext identity = new BrokeredIdentityContext(id);
- String name = (String)idToken.getOtherClaims().get(IDToken.NAME);
- String preferredUsername = (String)idToken.getOtherClaims().get(getUsernameClaimName());
- String email = (String)idToken.getOtherClaims().get(IDToken.EMAIL);
+ String name = (String) idToken.getOtherClaims().get(IDToken.NAME);
+ String preferredUsername = (String) idToken.getOtherClaims().get(getUsernameClaimName());
+ String email = (String) idToken.getOtherClaims().get(IDToken.EMAIL);
if (!getConfig().isDisableUserInfoService()) {
String userInfoUrl = getUserInfoUrl();
if (userInfoUrl != null && !userInfoUrl.isEmpty() && (id == null || name == null || preferredUsername == null || email == null)) {
- JsonNode userInfo = SimpleHttp.doGet(userInfoUrl, session)
- .header("Authorization", "Bearer " + accessToken).asJson();
-
- id = getJsonProperty(userInfo, "sub");
- name = getJsonProperty(userInfo, "name");
- preferredUsername = getJsonProperty(userInfo, "preferred_username");
- email = getJsonProperty(userInfo, "email");
- AbstractJsonUserAttributeMapper.storeUserProfileForMapper(identity, userInfo, getConfig().getAlias());
+
+ if (accessToken != null) {
+ JsonNode userInfo = SimpleHttp.doGet(userInfoUrl, session)
+ .header("Authorization", "Bearer " + accessToken).asJson();
+
+ id = getJsonProperty(userInfo, "sub");
+ name = getJsonProperty(userInfo, "name");
+ preferredUsername = getJsonProperty(userInfo, "preferred_username");
+ email = getJsonProperty(userInfo, "email");
+ AbstractJsonUserAttributeMapper.storeUserProfileForMapper(identity, userInfo, getConfig().getAlias());
+ }
}
}
- identity.getContextData().put(FEDERATED_ACCESS_TOKEN_RESPONSE, tokenResponse);
identity.getContextData().put(VALIDATED_ID_TOKEN, idToken);
- processAccessTokenResponse(identity, tokenResponse);
identity.setId(id);
identity.setName(name);
identity.setEmail(email);
identity.setBrokerUserId(getConfig().getAlias() + "." + id);
- if (tokenResponse.getSessionState() != null) {
- identity.setBrokerSessionId(getConfig().getAlias() + "." + tokenResponse.getSessionState());
- }
if (preferredUsername == null) {
preferredUsername = email;
@@ -400,6 +403,11 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
}
identity.setUsername(preferredUsername);
+ if (tokenResponse != null && tokenResponse.getSessionState() != null) {
+ identity.setBrokerSessionId(getConfig().getAlias() + "." + tokenResponse.getSessionState());
+ }
+ if (tokenResponse != null) identity.getContextData().put(FEDERATED_ACCESS_TOKEN_RESPONSE, tokenResponse);
+ if (tokenResponse != null) processAccessTokenResponse(identity, tokenResponse);
return identity;
}
@@ -430,6 +438,12 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
}
protected JsonWebToken validateToken(String encodedToken) {
+ boolean ignoreAudience = false;
+
+ return validateToken(encodedToken, ignoreAudience);
+ }
+
+ protected JsonWebToken validateToken(String encodedToken, boolean ignoreAudience) {
if (encodedToken == null) {
throw new IdentityBrokerException("No token from server.");
}
@@ -447,14 +461,14 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
String iss = token.getIssuer();
- if (!token.hasAudience(getConfig().getClientId())) {
- throw new IdentityBrokerException("Wrong audience from token.");
- }
-
if (!token.isActive()) {
throw new IdentityBrokerException("Token is no longer valid");
}
+ if (!ignoreAudience && !token.hasAudience(getConfig().getClientId())) {
+ throw new IdentityBrokerException("Wrong audience from token.");
+ }
+
String trustedIssuers = getConfig().getIssuer();
if (trustedIssuers != null) {
@@ -468,12 +482,13 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
throw new IdentityBrokerException("Wrong issuer from token. Got: " + iss + " expected: " + getConfig().getIssuer());
}
+
return token;
}
@Override
- public void authenticationFinished(AuthenticationSessionModel authSession, BrokeredIdentityContext context) {
- AccessTokenResponse tokenResponse = (AccessTokenResponse)context.getContextData().get(FEDERATED_ACCESS_TOKEN_RESPONSE);
+ public void authenticationFinished(AuthenticationSessionModel authSession, BrokeredIdentityContext context) {
+ AccessTokenResponse tokenResponse = (AccessTokenResponse) context.getContextData().get(FEDERATED_ACCESS_TOKEN_RESPONSE);
int currentTime = Time.currentTime();
long expiration = tokenResponse.getExpiresIn() > 0 ? tokenResponse.getExpiresIn() + currentTime : 0;
authSession.setUserSessionNote(FEDERATED_TOKEN_EXPIRATION, Long.toString(expiration));
@@ -486,4 +501,107 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
protected String getDefaultScopes() {
return "openid";
}
+
+ protected boolean isIssuer(MultivaluedMap<String, String> params) {
+ String requestedIssuer = params.getFirst(OAuth2Constants.SUBJECT_ISSUER);
+ if (requestedIssuer == null) return true;
+ if (requestedIssuer.equals(getConfig().getAlias())) return true;
+
+ String[] issuers = getConfig().getIssuer().split(",");
+
+ for (String trustedIssuer : issuers) {
+ if (requestedIssuer.equals(trustedIssuer.trim())) {
+ return true;
+ }
+ }
+ return false;
+
+ }
+
+ @Override
+ public BrokeredIdentityContext exchangeExternal(EventBuilder event, MultivaluedMap<String, String> params) {
+ if (!isIssuer(params)) {
+ return null;
+ }
+ String subjectToken = params.getFirst(OAuth2Constants.SUBJECT_TOKEN);
+ if (subjectToken == null) {
+ event.detail(Details.REASON, OAuth2Constants.SUBJECT_TOKEN + " param unset");
+ event.error(Errors.INVALID_TOKEN);
+ throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "token not set", Response.Status.BAD_REQUEST);
+ }
+ String subjectTokenType = params.getFirst(OAuth2Constants.SUBJECT_TOKEN_TYPE);
+ if (subjectTokenType == null) {
+ event.detail(Details.REASON, OAuth2Constants.SUBJECT_TOKEN_TYPE + " param unset");
+ event.error(Errors.INVALID_TOKEN_TYPE);
+ throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "token type unset", Response.Status.BAD_REQUEST);
+ }
+ boolean jwtAccessTokenType = subjectTokenType.equals(OAuth2Constants.JWT_ACCESS_TOKEN_TYPE);
+ boolean idTokenType = subjectTokenType.equals(OAuth2Constants.ID_TOKEN_TYPE);
+ if (!jwtAccessTokenType && !idTokenType) {
+ event.error(Errors.INVALID_TOKEN_TYPE);
+ throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token type", Response.Status.BAD_REQUEST);
+ }
+
+
+ if (getConfig().isValidateSignature() == false) {
+ event.detail(Details.REASON, "validate signature unset");
+ event.error(Errors.INVALID_CONFIG);
+ throw new ErrorResponseException(Errors.INVALID_CONFIG, "Invalid server config", Response.Status.BAD_REQUEST);
+ }
+ if (getConfig().isUseJwksUrl()) {
+ logger.debug("using jwks url to validate token exchange");
+ if (getConfig().getJwksUrl() == null) {
+ event.detail(Details.REASON, "jwks url unset");
+ event.error(Errors.INVALID_CONFIG);
+ throw new ErrorResponseException(Errors.INVALID_CONFIG, "Invalid server config", Response.Status.BAD_REQUEST);
+ }
+ } else if (getConfig().getPublicKeySignatureVerifier() == null) {
+ event.detail(Details.REASON, "public key unset");
+ event.error(Errors.INVALID_CONFIG);
+ throw new ErrorResponseException(Errors.INVALID_CONFIG, "Invalid server config", Response.Status.BAD_REQUEST);
+ }
+
+ JsonWebToken parsedToken = null;
+ try {
+ parsedToken = validateToken(subjectToken, true);
+ } catch (IdentityBrokerException e) {
+ logger.debug("Unable to validate token for exchange", e);
+ event.detail(Details.REASON, "token validation failure");
+ event.error(Errors.INVALID_TOKEN);
+ throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token", Response.Status.BAD_REQUEST);
+ }
+
+ try {
+
+ BrokeredIdentityContext context = extractIdentity(null, idTokenType ? null : subjectToken, parsedToken);
+ if (context == null) {
+ logger.debug("Failed to extractIdentity() from id token. Disabling User Info service might fix this");
+ throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token", Response.Status.BAD_REQUEST);
+
+ }
+ if (!idTokenType) {
+ context.getContextData().put(VALIDATED_ID_TOKEN, subjectToken);
+ }
+ if (jwtAccessTokenType) {
+ context.getContextData().put(KeycloakOIDCIdentityProvider.VALIDATED_ACCESS_TOKEN, parsedToken);
+ }
+ context.getContextData().put(EXCHANGE_PROVIDER, getConfig().getAlias());
+ context.setIdp(this);
+ context.setIdpConfig(getConfig());
+ return context;
+ } catch (IOException e) {
+ logger.debug("Unable to extract identity from identity token", e);
+ throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "invalid token", Response.Status.BAD_REQUEST);
+ }
+ }
+
+ @Override
+ public void exchangeExternalComplete(UserSessionModel userSession, BrokeredIdentityContext context, MultivaluedMap<String, String> params) {
+ if (context.getContextData().containsKey(VALIDATED_ID_TOKEN))
+ userSession.setNote(FEDERATED_ACCESS_TOKEN, params.getFirst(OAuth2Constants.SUBJECT_TOKEN));
+ if (context.getContextData().containsKey(VALIDATED_ID_TOKEN))
+ userSession.setNote(FEDERATED_ID_TOKEN, params.getFirst(OAuth2Constants.SUBJECT_TOKEN));
+ userSession.setNote(EXCHANGE_PROVIDER, getConfig().getAlias());
+
+ }
}
diff --git a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderConfig.java b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderConfig.java
index fd49f3e..ce0ccff 100755
--- a/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderConfig.java
+++ b/services/src/main/java/org/keycloak/broker/oidc/OIDCIdentityProviderConfig.java
@@ -23,9 +23,10 @@ import org.keycloak.models.IdentityProviderModel;
*/
public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig {
- private static final String JWKS_URL = "jwksUrl";
+ public static final String JWKS_URL = "jwksUrl";
- private static final String USE_JWKS_URL = "useJwksUrl";
+ public static final String USE_JWKS_URL = "useJwksUrl";
+ public static final String VALIDATE_SIGNATURE = "validateSignature";
public OIDCIdentityProviderConfig(IdentityProviderModel identityProviderModel) {
@@ -73,7 +74,7 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig {
}
public void setValidateSignature(boolean validateSignature) {
- getConfig().put("validateSignature", String.valueOf(validateSignature));
+ getConfig().put(VALIDATE_SIGNATURE, String.valueOf(validateSignature));
}
public boolean isUseJwksUrl() {
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 056932d..30e52be 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
@@ -23,9 +23,16 @@ import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.authentication.AuthenticationProcessor;
+import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator;
+import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
+import org.keycloak.broker.provider.BrokeredIdentityContext;
+import org.keycloak.broker.provider.ExchangeExternalToken;
import org.keycloak.broker.provider.IdentityProvider;
import org.keycloak.broker.provider.ExchangeTokenToIdentityProviderToken;
+import org.keycloak.broker.provider.IdentityProviderFactory;
+import org.keycloak.broker.provider.IdentityProviderMapper;
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.constants.AdapterConstants;
@@ -36,8 +43,12 @@ import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
+import org.keycloak.models.Constants;
+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;
@@ -47,20 +58,26 @@ import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
+import org.keycloak.services.ErrorPage;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.ServicesLogger;
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.managers.ClientManager;
import org.keycloak.services.managers.ClientSessionCode;
import org.keycloak.services.managers.RealmManager;
+import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.Cors;
import org.keycloak.services.resources.IdentityBrokerService;
+import org.keycloak.services.resources.LoginActionsService;
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.util.TokenUtil;
+import org.keycloak.utils.ProfileHelper;
import javax.ws.rs.OPTIONS;
import javax.ws.rs.POST;
@@ -71,8 +88,11 @@ import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
+import java.net.URI;
+import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.security.MessageDigest;
@@ -567,7 +587,9 @@ public class TokenEndpoint {
}
public Response tokenExchange() {
- event.detail(Details.AUTH_METHOD, "oauth_credentials");
+ ProfileHelper.requireFeature(Profile.Feature.TOKEN_EXCHANGE);
+
+ event.detail(Details.AUTH_METHOD, "token_exchange");
event.client(client);
UserModel tokenUser = null;
@@ -576,6 +598,14 @@ public class TokenEndpoint {
String subjectToken = formParams.getFirst(OAuth2Constants.SUBJECT_TOKEN);
if (subjectToken != null) {
+ String subjectIssuer = formParams.getFirst(OAuth2Constants.SUBJECT_ISSUER);
+ String realmIssuerUrl = Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName());
+ if (subjectIssuer != null && !realmIssuerUrl.equals(subjectIssuer)) {
+ event.detail(OAuth2Constants.SUBJECT_ISSUER, subjectIssuer);
+ return exchangeExternalToken();
+
+ }
+
String subjectTokenType = formParams.getFirst(OAuth2Constants.SUBJECT_TOKEN_TYPE);
if (subjectTokenType != null && !subjectTokenType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE)) {
event.error(Errors.INVALID_TOKEN);
@@ -583,7 +613,6 @@ public class TokenEndpoint {
}
-
AuthenticationManager.AuthResult authResult = AuthenticationManager.verifyIdentityToken(session, realm, uriInfo, clientConnection, true, true, false, subjectToken, headers);
if (authResult == null) {
event.error(Errors.INVALID_TOKEN);
@@ -688,7 +717,8 @@ public class TokenEndpoint {
if (audience != null) {
targetClient = realm.getClientByClientId(audience);
}
- if (targetClient.isConsentRequired()) {
+
+ if (targetClient.isConsentRequired()) {
event.error(Errors.CONSENT_DENIED);
throw new ErrorResponseException(OAuthErrorException.INVALID_CLIENT, "Client requires user consent", Response.Status.BAD_REQUEST);
}
@@ -736,6 +766,143 @@ public class TokenEndpoint {
return Cors.add(request, Response.ok(res, MediaType.APPLICATION_JSON_TYPE)).auth().allowedOrigins(uriInfo, client).allowedMethods("POST").exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS).build();
}
+ public Response exchangeExternalToken() {
+ BrokeredIdentityContext context = null;
+
+ for (IdentityProviderModel idpModel : realm.getIdentityProviders()) {
+ IdentityProviderFactory factory = IdentityBrokerService.getIdentityProviderFactory(session, idpModel);
+ IdentityProvider idp = factory.create(session, idpModel);
+ if (idp instanceof ExchangeExternalToken) {
+ context = ((ExchangeExternalToken)idp).exchangeExternal(event, formParams);
+ break;
+ }
+ }
+ if (context == null) {
+ event.error(Errors.INVALID_ISSUER);
+ throw new ErrorResponseException(Errors.INVALID_ISSUER, "Invalid " + OAuth2Constants.SUBJECT_ISSUER + " parameter", Response.Status.BAD_REQUEST);
+ }
+ if (!AdminPermissions.management(session, realm).idps().canExchangeTo(client, context.getIdpConfig())) {
+ logger.debug("Client not allowed to exchange for linked token");
+ event.error(Errors.NOT_ALLOWED);
+ throw new ErrorResponseException(OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN);
+ }
+
+ UserModel user = importUserFromExternalIdentity(context);
+
+ String sessionId = KeycloakModelUtils.generateId();
+ UserSessionModel userSession = session.sessions().createUserSession(sessionId, realm, user, user.getUsername(), clientConnection.getRemoteAddr(), "external-exchange", false, null, null);
+ ((ExchangeExternalToken)context.getIdp()).exchangeExternalComplete(userSession, context, formParams);
+ 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<IdentityProviderMapperModel> mappers = realm.getIdentityProviderMappersByAlias(context.getIdpConfig().getAlias());
+ if (mappers != null) {
+ 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(federatedIdentityModel, realm);
+
+ 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(context.getEmail(), realm);
+ if (existingUser != null) {
+ event.error(Errors.FEDERATED_IDENTITY_EXISTS);
+ throw new ErrorResponseException(Errors.INVALID_TOKEN, "User already exists", Response.Status.BAD_REQUEST);
+ }
+ }
+
+ UserModel existingUser = session.users().getUserByUsername(username, realm);
+ if (existingUser != null) {
+ event.error(Errors.FEDERATED_IDENTITY_EXISTS);
+ throw new ErrorResponseException(Errors.INVALID_TOKEN, "User already exists", Response.Status.BAD_REQUEST);
+ }
+
+ // don't allow user that already exists
+ // firstBroker login
+
+ 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);
+ if (mappers != null) {
+ KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory();
+ 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 ErrorResponseException(Errors.INVALID_TOKEN, "Invalid Token", Response.Status.BAD_REQUEST);
+ }
+ if (realm.isBruteForceProtected()) {
+ if (session.getProvider(BruteForceProtector.class).isTemporarilyDisabled(session, realm, user)) {
+ event.error(Errors.USER_TEMPORARILY_DISABLED);
+ throw new ErrorResponseException(Errors.INVALID_TOKEN, "Invalid Token", Response.Status.BAD_REQUEST);
+ }
+ }
+
+ context.getIdp().updateBrokeredUser(session, realm, user, context);
+ if (mappers != null) {
+ KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory();
+ for (IdentityProviderMapperModel mapper : mappers) {
+ IdentityProviderMapper target = (IdentityProviderMapper)sessionFactory.getProviderFactory(IdentityProviderMapper.class, mapper.getIdentityProviderMapper());
+ target.updateBrokeredUser(session, realm, user, mapper, context);
+ }
+ }
+ }
+ return user;
+ }
+
// https://tools.ietf.org/html/rfc7636#section-4.1
private boolean isValidPkceCodeVerifier(String codeVerifier) {
diff --git a/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java b/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java
index 2ef481e..78fadf5 100755
--- a/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java
+++ b/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java
@@ -1143,7 +1143,7 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
throw new IdentityBrokerException("Identity Provider [" + alias + "] not found.");
}
- private static IdentityProviderFactory getIdentityProviderFactory(KeycloakSession session, IdentityProviderModel model) {
+ public static IdentityProviderFactory getIdentityProviderFactory(KeycloakSession session, IdentityProviderModel model) {
Map<String, IdentityProviderFactory> availableProviders = new HashMap<String, IdentityProviderFactory>();
List<ProviderFactory> allProviders = new ArrayList<ProviderFactory>();