diff --git a/README.md b/README.md index 02ce978..69aa4d9 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ In addition, the Extended SAML Identity Provider adds support for Encrypted Attr ## Configuration -For keycloak up to version 20.x, the Extended SAML Identity Provider can be added to a realm using the standard keycloak +For Keycloak up to version 20.x, the Extended SAML Identity Provider can be added to a realm using the standard keycloak Admin Console, provided you set the Admin Theme to `keycloak` to indicate you want to use the v1 (legacy) UI. In keycloak versions 21.x and higher, the legacy UI is no longer available; you can still edit most settings of the @@ -24,9 +24,9 @@ provider in the Admin Console if it was created using the legacy UI, but some of To manage the Extended SAML IDP in new keycloak releases you can use the REST API provided by keycloak in combination with a custom frontend. -## Updating/adding release branches +## Upgrading -The steps are as follows: +The steps to upgrade to a new Keycloak minor version are as follows (also: the steps are analogous in case of a new major version): 1. Pull the branch corresponding to the most recent Keycloak minor version that already exists in the repository. @@ -47,30 +47,33 @@ If you have trouble creating the patch, consult the Readme in the `/patch-tool` 5. Apply the patch. * Many manual actions are needed, as the line numbers do not correspond. - * If a change introduces a references a Keycloak class that isn't in the repository, just import it, instead of adding it to this repository. + * Don't remove our custom code unless 100% sure that it is unnecessary. Preferably keep that sort of clean-up out of this upgrade-PR though, to retain overview. + * If a change references a Keycloak class that doesn't have an equivalent (same name) in this repository, just import it, instead of adding it to this repository. * If a method (/signature) is changed, it is probably due to deprecation. You could look up the documentation for the method that got replaced to make sure. * You might understand the rationale for changes in the commit message corresponding to that change, and/or the issue that is linked from that commit message. -6. (Optionally) verify that your SAML IDP connections work with the old Keycloak version and/or old jar. +6. Fix bugs until you can build a jar normally with `mvn clean package`. -7. (Optionally) do the same tests as the PR reviewer will in steps +7. (Optionally) first verify that your SAML IDP connections work with the old Keycloak version and/or old jar. -8. Create a branch, e.g. `27.2.x-once-PR-merged`, from the previous minor, e.g. `27.1` here. +8. (Optionally) do the same tests as the PR reviewer will do in their steps below. -9. Push both new branches: `{a}.{b+1}.x-once-PR-merged` and `upgrade-to-{a}.{b+1}` +9. Create a branch, e.g. `27.2.x-once-PR-merged`, from the previous minor, e.g. `27.1` here. -10. Make a PR from `upgrade-to-{a}.{b+1}` into `{a}.{b+1}.x-once-PR-merged`. +10. Push both new branches: `{a}.{b+1}.x-once-PR-merged` and `upgrade-to-{a}.{b+1}` + +11. Make a PR from `upgrade-to-{a}.{b+1}` into `{a}.{b+1}.x-once-PR-merged`. ### For the Pull Request Reviewer -11. Run the new code from the PR through Test classes, not only `` but also ``. +12. Run the new code from the PR through Test classes, not only `` but also ``. * (Preferably) don't just test the auto-generated mock (Artifact)Responses, but also real-world (Artifact)Responses that you can copy into the test resource folder and reference in Test classes (don't commit those changes). -12. Build the jar from your new branch and try logging in with the SAML IDP connections in your test environment. +13. Build the jar from your new branch and try logging in with the SAML IDP connections in your test environment. * A Keycloak upgrade might be needed beforehand. * If you normally only receive `` or ``, set up a connection to test the other type (you may skip this if you already tested real-world examples of that type in Test classes) diff --git a/patch-tool/README.md b/patch-tool/README.md index cf7b705..1ccbaff 100644 --- a/patch-tool/README.md +++ b/patch-tool/README.md @@ -30,10 +30,12 @@ The main command is: ./patch.sh [--apply] OLD_BRANCH NEW_BRANCH ``` +***Note: The patch process will ask for your SSH passhprase many times*** + ### Arguments -* `OLD_BRANCH` – The base branch in the Keycloak repo (e.g., `archive/release/26.1`) -* `NEW_BRANCH` – The comparison branch in the Keycloak repo (e.g., `archive/release/26.0`) +* `OLD_BRANCH` – The base branch in the Keycloak repo (e.g., `archive/release/27.0`) +* `NEW_BRANCH` – The comparison branch in the Keycloak repo (e.g., `archive/release/27.1`) * `--apply` – Optional flag to **apply** the generated patch to the parent directory ### Examples diff --git a/patch-tool/keycloak-files.txt b/patch-tool/keycloak-files.txt index 88e710c..6a20447 100644 --- a/patch-tool/keycloak-files.txt +++ b/patch-tool/keycloak-files.txt @@ -18,6 +18,8 @@ services/src/main/java/org/keycloak/protocol/saml/mappers/SamlMetadataDescriptor services/src/main/java/org/keycloak/protocol/saml/JaxrsSAML2BindingBuilder.java services/src/main/java/org/keycloak/protocol/saml/SamlProtocolUtils.java saml-core-api/src/main/java/org/keycloak/saml/common/constants/JBossSAMLConstants.java +saml-core/src/main/java/org/keycloak/saml/common/DefaultPicketLinkLogger.java +saml-core/src/main/java/org/keycloak/saml/common/PicketLinkLoggerFactory.java saml-core/src/main/java/org/keycloak/saml/processing/api/saml/v2/request/SAML2Request.java saml-core/src/main/java/org/keycloak/saml/processing/api/saml/v2/request/SecurityActions.java saml-core/src/main/java/org/keycloak/saml/processing/api/saml/v2/response/SAML2Response.java diff --git a/pom.xml b/pom.xml index cd5ac04..6fd311c 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ SAML v2.0 extensions that adds more configuration options to connect to SAML Service Providers. - 26.5 + 26.6 17 @@ -20,7 +20,7 @@ UTF-8 UTF-8 - 26.5.7 + 26.6.4 4.8.3.1 3.4.1 diff --git a/src/main/java/nl/first8/keycloak/broker/saml/SAMLEndpoint.java b/src/main/java/nl/first8/keycloak/broker/saml/SAMLEndpoint.java index 32cbbd3..03820f5 100644 --- a/src/main/java/nl/first8/keycloak/broker/saml/SAMLEndpoint.java +++ b/src/main/java/nl/first8/keycloak/broker/saml/SAMLEndpoint.java @@ -49,6 +49,7 @@ import org.keycloak.broker.provider.IdentityBrokerException; import org.keycloak.broker.provider.UserAuthenticationIdentityProvider; import org.keycloak.common.ClientConnection; +import org.keycloak.common.Profile; import org.keycloak.common.VerificationException; import org.keycloak.common.util.Base64; import org.keycloak.crypto.KeyUse; @@ -410,7 +411,7 @@ private Consumer processLogout(AtomicReference oClient = SAMLEndpoint.this.session.clients() @@ -649,7 +659,7 @@ private AuthenticationSessionModel samlIdpInitiatedSSO(final String clientUrlNam } - private boolean isSuccessfulSamlResponse(ResponseType responseType) { + protected final boolean isSuccessfulSamlResponse(ResponseType responseType) { return responseType != null && responseType.getStatus() != null && responseType.getStatus().getStatusCode() != null @@ -895,20 +905,20 @@ protected String getBindingType() { } - private String getX500Attribute(AssertionType assertion, X500SAMLProfileConstants attribute) { + protected final String getX500Attribute(AssertionType assertion, X500SAMLProfileConstants attribute) { return getFirstMatchingAttribute(assertion, attribute::correspondsTo); } - private String getAttributeByName(AssertionType assertion, String name) { + protected final String getAttributeByName(AssertionType assertion, String name) { return getFirstMatchingAttribute(assertion, attribute -> Objects.equals(attribute.getName(), name)); } - private String getAttributeByFriendlyName(AssertionType assertion, String friendlyName) { + protected final String getAttributeByFriendlyName(AssertionType assertion, String friendlyName) { return getFirstMatchingAttribute(assertion, attribute -> Objects.equals(attribute.getFriendlyName(), friendlyName)); } - private String getPrincipal(AssertionType assertion) { + protected final String getPrincipal(AssertionType assertion) { SamlPrincipalType principalType = config.getPrincipalType(); if (principalType == null || principalType.equals(SamlPrincipalType.SUBJECT)) { @@ -922,7 +932,7 @@ private String getPrincipal(AssertionType assertion) { } - private String getFirstMatchingAttribute(AssertionType assertion, Predicate predicate) { + protected final String getFirstMatchingAttribute(AssertionType assertion, Predicate predicate) { return assertion.getAttributeStatements().stream() .map(AttributeStatementType::getAttributes) .flatMap(Collection::stream) @@ -935,7 +945,7 @@ private String getFirstMatchingAttribute(AssertionType assertion, Predicate instead of the tag"); property.setType(ProviderConfigProperty.BOOLEAN_TYPE); configProperties.add(property); + ProviderConfigProperty allowNullableProperty; + allowNullableProperty = new ProviderConfigProperty(); + allowNullableProperty.setName(ALLOW_NULLABLE); + allowNullableProperty.setLabel("Allow Nullable Property"); + allowNullableProperty.setHelpText("If true, the property will be set to null when the claim is empty."); + allowNullableProperty.setType(ProviderConfigProperty.BOOLEAN_TYPE); + allowNullableProperty.setDefaultValue(Boolean.FALSE.toString()); + configProperties.add(allowNullableProperty); } public static final String PROVIDER_ID = "saml-extended-user-attribute-idp-mapper"; @@ -246,6 +255,13 @@ private void setIfNotEmptyAndDifferentAndStripMailto(Consumer consumer, } + private void setIfDifferent(Consumer consumer, Supplier currentValueSupplier, List values) { + String newValue = (values == null || values.isEmpty()) ? null : values.get(0); + if (!Objects.equals(newValue, currentValueSupplier.get())) { + consumer.accept(newValue); + } + } + private Predicate elementWith(String attributeName) { return attributeType -> { AttributeType attribute = attributeType.getAttribute(); @@ -336,6 +352,8 @@ public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserMo logger.debug("Update Brokered User."); logContext(context, user); String attribute = mapperModel.getConfig().get(USER_ATTRIBUTE); + boolean isNullableProperty = Boolean.parseBoolean(mapperModel.getConfig().getOrDefault(ALLOW_NULLABLE, Boolean.FALSE.toString())); + if (StringUtil.isNullOrEmpty(attribute)) { return; } @@ -345,11 +363,23 @@ public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserMo List attributeValuesInContext = findAttributeValuesInContext(attributeName, context, mapperModel, keys); logger.debugf("Found %d attributes in BrokeredIdentityContext for `%s`. Setting user attribute as %s", attributeValuesInContext.size(), attributeName, attribute); if (attribute.equalsIgnoreCase(EMAIL)) { - setIfNotEmptyAndDifferentAndStripMailto(user::setEmail, user::getEmail, attributeValuesInContext); + if (isNullableProperty) { + setIfDifferent(user::setEmail, user::getEmail, attributeValuesInContext); + } else { + setIfNotEmptyAndDifferentAndStripMailto(user::setEmail, user::getEmail, attributeValuesInContext); + } } else if (attribute.equalsIgnoreCase(FIRST_NAME)) { - setIfNotEmptyAndDifferent(user::setFirstName, user::getFirstName, attributeValuesInContext); + if (isNullableProperty) { + setIfDifferent(user::setFirstName, user::getFirstName, attributeValuesInContext); + } else { + setIfNotEmptyAndDifferent(user::setFirstName, user::getFirstName, attributeValuesInContext); + } } else if (attribute.equalsIgnoreCase(LAST_NAME)) { - setIfNotEmptyAndDifferent(user::setLastName, user::getLastName, attributeValuesInContext); + if (isNullableProperty) { + setIfDifferent(user::setLastName, user::getLastName, attributeValuesInContext); + } else { + setIfNotEmptyAndDifferent(user::setLastName, user::getLastName, attributeValuesInContext); + } } else { logger.debugf("Attribute `%s` not of known type(`%s`, `%s`, `%s`). So setting custom user attribute.", attribute, EMAIL, FIRST_NAME, LAST_NAME); List currentAttributeValues = user.getAttributes().get(attribute); diff --git a/src/main/java/nl/first8/keycloak/broker/saml/mappers/UserAttributeValueMapper.java b/src/main/java/nl/first8/keycloak/broker/saml/mappers/UserAttributeValueMapper.java index ffe9b79..b66646b 100644 --- a/src/main/java/nl/first8/keycloak/broker/saml/mappers/UserAttributeValueMapper.java +++ b/src/main/java/nl/first8/keycloak/broker/saml/mappers/UserAttributeValueMapper.java @@ -7,7 +7,7 @@ import nl.first8.keycloak.dom.saml.v2.metadata.AttributeConsumingServiceType; import nl.first8.keycloak.dom.saml.v2.metadata.EntityDescriptorType; import nl.first8.keycloak.dom.saml.v2.metadata.RequestedAttributeValueType; -import nl.first8.keycloak.protocol.saml.SamlMetadataDescriptorUpdater; +import nl.first8.keycloak.protocol.saml.mappers.SamlMetadataDescriptorUpdater; import org.keycloak.broker.provider.AbstractIdentityProviderMapper; import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.common.util.CollectionUtil; diff --git a/src/main/java/nl/first8/keycloak/broker/saml/mappers/UsernameTemplateMapper.java b/src/main/java/nl/first8/keycloak/broker/saml/mappers/UsernameTemplateMapper.java index aa15c63..4338eb7 100644 --- a/src/main/java/nl/first8/keycloak/broker/saml/mappers/UsernameTemplateMapper.java +++ b/src/main/java/nl/first8/keycloak/broker/saml/mappers/UsernameTemplateMapper.java @@ -40,7 +40,7 @@ public class UsernameTemplateMapper extends AbstractIdentityProviderMapper { - protected static final Logger logger = Logger.getLogger(UsernameTemplateMapper.class); + private static final Logger logger = Logger.getLogger(UsernameTemplateMapper.class); public static final String[] COMPATIBLE_PROVIDERS = {SAMLIdentityProviderFactory.PROVIDER_ID}; @@ -53,11 +53,11 @@ public enum Target { BROKER_USERNAME { public void set(BrokeredIdentityContext context, String value) { context.setUsername(value); } }; public abstract void set(BrokeredIdentityContext context, String value); } - public static final List TARGETS = Arrays.asList(UsernameTemplateMapper.Target.LOCAL.toString(), UsernameTemplateMapper.Target.BROKER_ID.toString(), UsernameTemplateMapper.Target.BROKER_USERNAME.toString()); + public static final List TARGETS = Arrays.asList(Target.LOCAL.toString(), Target.BROKER_ID.toString(), Target.BROKER_USERNAME.toString()); - public static final Map> TRANSFORMERS = new HashMap<>(); + public static final Map> TRANSFORMERS = new HashMap<>(); - private static final List configProperties = new ArrayList<>(); + private static final List configProperties = new ArrayList(); private static final Set IDENTITY_PROVIDER_SYNC_MODES = new HashSet<>(Arrays.asList(IdentityProviderSyncMode.values())); private static KeyWrapper keys; @@ -164,7 +164,7 @@ public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserMo logger.info("Update Brokered User setting username from template"); // preprocessFederatedIdentity gets called anyways, so we only need to set the username if necessary. // However, we don't want to set the username when the email is used as username - if (getTarget(mapperModel.getConfig().get(TARGET)) == UsernameTemplateMapper.Target.LOCAL && !realm.isRegistrationEmailAsUsername()) { + if (getTarget(mapperModel.getConfig().get(TARGET)) == Target.LOCAL && !realm.isRegistrationEmailAsUsername()) { user.setUsername(context.getModelUsername()); } } @@ -184,24 +184,25 @@ private void setUserNameFromTemplate(IdentityProviderMapperModel mapperModel, Br logger.debugf("Searching for template: `%s`", template); Matcher m = SUBSTITUTION.matcher(template); StringBuffer sb = new StringBuffer(); + boolean hasUnresolvedVariable = false; while (m.find()) { String variable = m.group(1).trim(); String transformerKey = m.group(2); logger.debugf("Searching for transformer `%s`.", transformerKey); - UnaryOperator transformer = Optional.ofNullable(transformerKey).map(TRANSFORMERS::get).orElse(UnaryOperator.identity()); + UnaryOperator transformer = Optional.ofNullable(transformerKey).map(TRANSFORMERS::get).orElse(UnaryOperator.identity()); if (variable.equals("ALIAS")) { - m.appendReplacement(sb, (String) transformer.apply(context.getIdpConfig().getAlias())); + m.appendReplacement(sb, transformer.apply(context.getIdpConfig().getAlias())); } else if (variable.equals("UUID")) { - m.appendReplacement(sb, (String) transformer.apply(KeycloakModelUtils.generateId())); + m.appendReplacement(sb, transformer.apply(KeycloakModelUtils.generateId())); } else if (variable.equals("NAMEID")) { SubjectType subject = assertion.getSubject(); SubjectType.STSubType subType = subject.getSubType(); NameIDType subjectNameID = (NameIDType) subType.getBaseID(); - m.appendReplacement(sb, (String) transformer.apply(subjectNameID.getValue())); + m.appendReplacement(sb, transformer.apply(subjectNameID.getValue())); } else if (variable.startsWith("ATTRIBUTE.")) { String name = variable.substring("ATTRIBUTE.".length()); - Object value = null; + String value = null; for (AttributeStatementType statement : assertion.getAttributeStatements()) { for (AttributeStatementType.ASTChoiceType choice : statement.getAttributes()) { AttributeType attr = choice.getAttribute(); @@ -209,13 +210,18 @@ private void setUserNameFromTemplate(IdentityProviderMapperModel mapperModel, Br if (name.equals(attr.getName()) || name.equals(attr.getFriendlyName())) { List attributeValue = attr.getAttributeValue(); if (attributeValue != null && !attributeValue.isEmpty()) { - value = attributeValue.get(0); + value = attributeValue.get(0).toString(); } break; } } } - m.appendReplacement(sb, (String) transformer.apply(value)); + if (value == null) { + hasUnresolvedVariable = true; + m.appendReplacement(sb, ""); + } else { + m.appendReplacement(sb, transformer.apply(value)); + } } else { m.appendReplacement(sb, m.group(1)); } @@ -223,8 +229,13 @@ private void setUserNameFromTemplate(IdentityProviderMapperModel mapperModel, Br } m.appendTail(sb); - UsernameTemplateMapper.Target t = getTarget(mapperModel.getConfig().get(TARGET)); - t.set(context, sb.toString()); + if (hasUnresolvedVariable) { + logger.warnf("Username template '%s' for identity provider '%s' contains unresolved attributes. Check that the identity provider is sending the expected SAML attributes.", + template, context.getIdpConfig().getAlias()); + } + + Target t = getTarget(mapperModel.getConfig().get(TARGET)); + t.set(context, hasUnresolvedVariable ? "" : sb.toString()); } @Override @@ -232,11 +243,11 @@ public String getHelpText() { return "Format the username to import."; } - public static UsernameTemplateMapper.Target getTarget(String value) { + public static Target getTarget(String value) { try { - return value == null ? UsernameTemplateMapper.Target.LOCAL : UsernameTemplateMapper.Target.valueOf(value); + return value == null ? Target.LOCAL : Target.valueOf(value); } catch (IllegalArgumentException ex) { - return UsernameTemplateMapper.Target.LOCAL; + return Target.LOCAL; } } diff --git a/src/main/java/nl/first8/keycloak/protocol/saml/SamlMetadataDescriptorUpdater.java b/src/main/java/nl/first8/keycloak/protocol/saml/SamlMetadataDescriptorUpdater.java deleted file mode 100644 index 2f4c1fb..0000000 --- a/src/main/java/nl/first8/keycloak/protocol/saml/SamlMetadataDescriptorUpdater.java +++ /dev/null @@ -1,8 +0,0 @@ -package nl.first8.keycloak.protocol.saml; - -import nl.first8.keycloak.dom.saml.v2.metadata.EntityDescriptorType; -import org.keycloak.models.IdentityProviderMapperModel; - -public interface SamlMetadataDescriptorUpdater { - void updateMetadata(IdentityProviderMapperModel identityProviderMapperModel, EntityDescriptorType entityDescriptorType); -} \ No newline at end of file diff --git a/src/main/java/nl/first8/keycloak/protocol/saml/SamlProtocolUtils.java b/src/main/java/nl/first8/keycloak/protocol/saml/SamlProtocolUtils.java index b87b7b9..52b1b68 100644 --- a/src/main/java/nl/first8/keycloak/protocol/saml/SamlProtocolUtils.java +++ b/src/main/java/nl/first8/keycloak/protocol/saml/SamlProtocolUtils.java @@ -8,6 +8,8 @@ import java.security.PublicKey; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.util.Map; +import java.util.Objects; import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.UriInfo; @@ -20,8 +22,10 @@ import org.keycloak.dom.saml.v2.SAML2Object; import org.keycloak.dom.saml.v2.assertion.NameIDType; import org.keycloak.dom.saml.v2.protocol.ArtifactResponseType; +import org.keycloak.dom.saml.v2.protocol.AuthnContextComparisonType; import org.keycloak.dom.saml.v2.protocol.ExtensionsType; import org.keycloak.dom.saml.v2.protocol.RequestAbstractType; +import org.keycloak.dom.saml.v2.protocol.RequestedAuthnContextType; import org.keycloak.dom.saml.v2.protocol.StatusCodeType; import org.keycloak.dom.saml.v2.protocol.StatusResponseType; import org.keycloak.dom.saml.v2.protocol.StatusType; @@ -325,4 +329,74 @@ public static Document convert(ArtifactResponseType responseType) throws Process writer.write(responseType); return DocumentUtil.getDocument(new ByteArrayInputStream(bos.toByteArray())); } + + + private static String checkLoAExact(String current, Map acrLoaMap, int minLevel) { + // authentication context in the authentication statement MUST be the exact match of at least one of the authentication contexts specified + Integer level = acrLoaMap.get(current); + if (level == null) { + return null; + } + return level >= minLevel ? current : null; + } + + private static String checkLoAMinimum(String current, Map acrLoaMap, String minLoa, int minLevel) { + // authentication context in the authentication statement MUST be as strong as one of the authentication contexts specified + Integer level = acrLoaMap.get(current); + if (level == null) { + return null; + } + // check if current value is OK, if not return minLoa which is valid because is greater than current + return (level >= minLevel) ? current : minLoa; + } + + private static String checkLoAMaximum(String current, Map acrLoaMap, int minLevel) { + // authentication context in the authentication statement MUST be as strong as possible without exceeding the strength of at least one of the authentication contexts specified + Integer level = acrLoaMap.get(current); + if (level == null) { + return null; + } + // only valid if it is better than minLoa + return level >= minLevel ? current : null; + } + + private static String checkLoABetter(String current, Map acrLoaMap, String minLoa, int minLevel) { + // authentication context in the authentication statement MUST be stronger than any one of the authentication contexts specified + Integer level = acrLoaMap.get(current); + if (level == null) { + return null; + } + // if minLoa is valid return minLoa + if (minLevel > level) { + return minLoa; + } + // find any level that is better than level, get the min of them + return acrLoaMap.entrySet().stream() + .filter(e -> e.getValue() > level) + .min((Map.Entry e1, Map.Entry e2) -> e1.getValue().compareTo(e2.getValue())) + .map(Map.Entry::getKey) + .orElse(null); + } + + private static String checkLoa(AuthnContextComparisonType comparison, String current, Map acrLoaMap, String minLoa, int minLevel) { + if (comparison == null) { + comparison = AuthnContextComparisonType.EXACT; + } + return switch (comparison) { + case EXACT -> checkLoAExact(current, acrLoaMap, minLevel); + case MINIMUM -> checkLoAMinimum(current, acrLoaMap, minLoa, minLevel); + case MAXIMUM -> checkLoAMaximum(current, acrLoaMap, minLevel); + case BETTER -> checkLoABetter(current, acrLoaMap, minLoa, minLevel); + }; + } + + public static String getSelectedLoA(RequestedAuthnContextType requestedAuthnContext, Map acrLoaMap, String minLoa) { + Integer minLevel = minLoa != null ? acrLoaMap.get(minLoa) : null; + return requestedAuthnContext.getAuthnContextClassRef().stream() + .map(current -> checkLoa(requestedAuthnContext.getComparison(), current, acrLoaMap, + minLoa, minLevel != null ? minLevel : Integer.MIN_VALUE)) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } } diff --git a/src/main/java/nl/first8/keycloak/saml/JBossSAMLConstants.java b/src/main/java/nl/first8/keycloak/saml/JBossSAMLConstants.java deleted file mode 100644 index a2c150f..0000000 --- a/src/main/java/nl/first8/keycloak/saml/JBossSAMLConstants.java +++ /dev/null @@ -1,311 +0,0 @@ -package nl.first8.keycloak.saml; - -import org.keycloak.saml.common.constants.JBossSAMLURIConstants; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import javax.xml.namespace.QName; - -import static org.keycloak.saml.common.constants.JBossSAMLURIConstants.*; - -/** - * SAML Constants - * - * @since Dec 10, 2008 - */ -public enum JBossSAMLConstants { - // saml-schema-protocol-2.0.xsd - ARTIFACT(PROTOCOL_NSURI, "Artifact"), - ARTIFACT_RESOLVE(PROTOCOL_NSURI, "ArtifactResolve"), - ARTIFACT_RESPONSE(PROTOCOL_NSURI, "ArtifactResponse"), - ASSERTION_ID_REQUEST(PROTOCOL_NSURI, "AssertionIDRequest"), - ATTRIBUTE_QUERY(PROTOCOL_NSURI, "AttributeQuery"), - AUTHN_QUERY(PROTOCOL_NSURI, "AuthnQuery"), - AUTHN_REQUEST(PROTOCOL_NSURI, "AuthnRequest"), - AUTHZ_DECISION_QUERY(PROTOCOL_NSURI, "AuthzDecisionQuery"), - EXTENSIONS__PROTOCOL(PROTOCOL_NSURI, "Extensions"), - GET_COMPLETE(PROTOCOL_NSURI, "GetComplete"), - IDP_ENTRY(PROTOCOL_NSURI, "IDPEntry"), - IDP_LIST(PROTOCOL_NSURI, "IDPList"), - LOGOUT_REQUEST(PROTOCOL_NSURI, "LogoutRequest"), - LOGOUT_RESPONSE(PROTOCOL_NSURI, "LogoutResponse"), - MANAGE_NAMEID_REQUEST(PROTOCOL_NSURI, "ManageNameIDRequest"), - MANAGE_NAMEID_RESPONSE(PROTOCOL_NSURI, "ManageNameIDResponse"), - NAMEID_MAPPING_REQUEST(PROTOCOL_NSURI, "NameIDMappingRequest"), - NAMEID_MAPPING_RESPONSE(PROTOCOL_NSURI, "NameIDMappingResponse"), - NAMEID_POLICY(PROTOCOL_NSURI, "NameIDPolicy"), - NEW_ENCRYPTEDID(PROTOCOL_NSURI, "NewEncryptedID"), - NEWID(PROTOCOL_NSURI, "NewID"), - REQUESTED_AUTHN_CONTEXT(PROTOCOL_NSURI, "RequestedAuthnContext"), - REQUESTERID(PROTOCOL_NSURI, "RequesterID"), - RESPONSE__PROTOCOL(PROTOCOL_NSURI, "Response"), - SCOPING(PROTOCOL_NSURI, "Scoping"), - SESSION_INDEX(PROTOCOL_NSURI, "SessionIndex"), - STATUS_CODE(PROTOCOL_NSURI, "StatusCode"), - STATUS_DETAIL(PROTOCOL_NSURI, "StatusDetail"), - STATUS_MESSAGE(PROTOCOL_NSURI, "StatusMessage"), - STATUS(PROTOCOL_NSURI, "Status"), - SUBJECT_QUERY(PROTOCOL_NSURI, "SubjectQuery"), - TERMINATE(PROTOCOL_NSURI, "Terminate"), - - // saml-schema-assertion-2.0.xsd - ACTION(ASSERTION_NSURI, "Action"), - ADVICE(ASSERTION_NSURI, "Advice"), - ASSERTION(ASSERTION_NSURI, "Assertion"), - ASSERTION_ID_REF(ASSERTION_NSURI, "AssertionIDRef"), - ASSERTION_URI_REF(ASSERTION_NSURI, "AssertionURIRef"), - ATTRIBUTE(ASSERTION_NSURI, "Attribute"), - ATTRIBUTE_STATEMENT(ASSERTION_NSURI, "AttributeStatement"), - ATTRIBUTE_VALUE(ASSERTION_NSURI, "AttributeValue"), - AUDIENCE(ASSERTION_NSURI, "Audience"), - AUDIENCE_RESTRICTION(ASSERTION_NSURI, "AudienceRestriction"), - AUTHENTICATING_AUTHORITY(ASSERTION_NSURI, "AuthenticatingAuthority"), - AUTHN_CONTEXT(ASSERTION_NSURI, "AuthnContext"), - AUTHN_CONTEXT_CLASS_REF(ASSERTION_NSURI, "AuthnContextClassRef"), - AUTHN_CONTEXT_DECL(ASSERTION_NSURI, "AuthnContextDecl"), - AUTHN_CONTEXT_DECL_REF(ASSERTION_NSURI, "AuthnContextDeclRef"), - AUTHN_STATEMENT(ASSERTION_NSURI, "AuthnStatement"), - AUTHZ_DECISION_STATEMENT(ASSERTION_NSURI, "AuthzDecisionStatement"), - BASEID(ASSERTION_NSURI, "BaseID"), - CONDITION(ASSERTION_NSURI, "Condition"), - CONDITIONS(ASSERTION_NSURI, "Conditions"), - ENCRYPTED_ASSERTION(ASSERTION_NSURI, "EncryptedAssertion"), - ENCRYPTED_ATTRIBUTE(ASSERTION_NSURI, "EncryptedAttribute"), - ENCRYPTED_ID(ASSERTION_NSURI, "EncryptedID"), - EVIDENCE(ASSERTION_NSURI, "Evidence"), - ISSUER(ASSERTION_NSURI, "Issuer"), - NAMEID(ASSERTION_NSURI, "NameID"), - ONE_TIME_USE(ASSERTION_NSURI, "OneTimeUse"), - PROXY_RESTRICTION(ASSERTION_NSURI, "ProxyRestriction"), - STATEMENT(ASSERTION_NSURI, "Statement"), - SUBJECT_CONFIRMATION_DATA(ASSERTION_NSURI, "SubjectConfirmationData"), - SUBJECT_CONFIRMATION(ASSERTION_NSURI, "SubjectConfirmation"), - SUBJECT_LOCALITY(ASSERTION_NSURI, "SubjectLocality"), - SUBJECT(ASSERTION_NSURI, "Subject"), - - // saml-schema-metadata-2.0.xsd - ADDITIONAL_METADATA_LOCATION(METADATA_NSURI, "AdditionalMetadataLocation"), - AFFILIATE_MEMBER(METADATA_NSURI, "AffiliateMember"), - AFFILIATION_DESCRIPTOR(METADATA_NSURI, "AffiliationDescriptor"), - ARTIFACT_RESOLUTION_SERVICE(METADATA_NSURI, "ArtifactResolutionService"), - ASSERTION_CONSUMER_SERVICE(METADATA_NSURI, "AssertionConsumerService"), - ASSERTION_ID_REQUEST_SERVICE(METADATA_NSURI, "AssertionIDRequestService"), - ATTRIBUTE_AUTHORITY_DESCRIPTOR(METADATA_NSURI, "AttributeAuthorityDescriptor"), - ATTRIBUTE_CONSUMING_SERVICE(METADATA_NSURI, "AttributeConsumingService"), - ATTRIBUTE_PROFILE(METADATA_NSURI, "AttributeProfile"), - ATTRIBUTE_SERVICE(METADATA_NSURI, "AttributeService"), - AUTHN_AUTHORITY_DESCRIPTOR(METADATA_NSURI, "AuthnAuthorityDescriptor"), - AUTHN_QUERY_SERVICE(METADATA_NSURI, "AuthnQueryService"), - AUTHZ_SERVICE(METADATA_NSURI, "AuthzService"), - COMPANY(METADATA_NSURI, "Company"), - CONTACT_PERSON(METADATA_NSURI, "ContactPerson"), - EMAIL_ADDRESS(METADATA_NSURI, "EmailAddress"), - ENCRYPTION_METHOD(METADATA_NSURI, "EncryptionMethod"), - ENTITIES_DESCRIPTOR(METADATA_NSURI, "EntitiesDescriptor"), - ENTITY_DESCRIPTOR(METADATA_NSURI, "EntityDescriptor"), - EXTENSIONS__METADATA(METADATA_NSURI, "Extensions"), - GIVEN_NAME(METADATA_NSURI, "GivenName"), - IDP_SSO_DESCRIPTOR(METADATA_NSURI, "IDPSSODescriptor"), - KEY_DESCRIPTOR(METADATA_NSURI, "KeyDescriptor"), - MANAGE_NAMEID_SERVICE(METADATA_NSURI, "ManageNameIDService"), - NAMEID_FORMAT(METADATA_NSURI, "NameIDFormat"), - NAMEID_MAPPING_SERVICE(METADATA_NSURI, "NameIDMappingService"), - ORGANIZATION_DISPLAY_NAME(METADATA_NSURI, "OrganizationDisplayName"), - ORGANIZATION_NAME(METADATA_NSURI, "OrganizationName"), - ORGANIZATION(METADATA_NSURI, "Organization"), - ORGANIZATION_URL(METADATA_NSURI, "OrganizationURL"), - ORGANIZATION_URL_ALT(METADATA_NSURI, "OrganizationUrl"), // non-standard: KEYCLOAK-4040 - PDP_DESCRIPTOR(METADATA_NSURI, "PDPDescriptor"), - REQUESTED_ATTRIBUTE(METADATA_NSURI, "RequestedAttribute"), - ROLE_DESCRIPTOR(METADATA_NSURI, "RoleDescriptor"), - SERVICE_DESCRIPTION(METADATA_NSURI, "ServiceDescription"), - SERVICE_NAME(METADATA_NSURI, "ServiceName"), - SINGLE_LOGOUT_SERVICE(METADATA_NSURI, "SingleLogoutService"), - SINGLE_SIGNON_SERVICE(METADATA_NSURI, "SingleSignOnService"), - SP_SSO_DESCRIPTOR(METADATA_NSURI, "SPSSODescriptor"), - SURNAME(METADATA_NSURI, "SurName"), - TELEPHONE_NUMBER(METADATA_NSURI, "TelephoneNumber"), - - // saml-schema-ecp-2.0.xsd - RELAY_STATE(ECP_PROFILE, "RelayState"), - REQUEST(ECP_PROFILE, "Request"), - RESPONSE__ECP(ECP_PROFILE, "Response"), - - SIGNATURE(XMLDSIG_NSURI, "Signature"), - DSA_KEY_VALUE(XMLDSIG_NSURI, "DSAKeyValue"), - KEY_INFO(XMLDSIG_NSURI, "KeyInfo"), - KEY_VALUE(XMLDSIG_NSURI, "KeyValue"), - RSA_KEY_VALUE(XMLDSIG_NSURI, "RSAKeyValue"), - X509_CERT(XMLDSIG_NSURI, "X509Certificate"), - X509_DATA(XMLDSIG_NSURI, "X509Data"), - - // Attribute names and other constants - ADDRESS("Address"), - ALLOW_CREATE("AllowCreate"), - ASSERTION_CONSUMER_SERVICE_URL("AssertionConsumerServiceURL"), - ASSERTION_CONSUMER_SERVICE_INDEX("AssertionConsumerServiceIndex"), - ATTRIBUTE_CONSUMING_SERVICE_INDEX("AttributeConsumingServiceIndex"), - AUTHN_INSTANT("AuthnInstant"), - AUTHN_REQUESTS_SIGNED("AuthnRequestsSigned"), - BINDING("Binding"), - CACHE_DURATION("cacheDuration"), - COMPARISON("Comparison"), - CONSENT("Consent"), - CONTACT_TYPE("contactType"), - DESTINATION("Destination"), - DNS_NAME("DNSName"), - ENCODING("Encoding"), - ENCRYPTED_KEY("EncryptedKey"), - ENTITY_ID("entityID"), - FORMAT("Format"), - FRIENDLY_NAME("FriendlyName"), - FORCE_AUTHN("ForceAuthn"), - ID("ID"), - INDEX("index"), - INPUT_CONTEXT_ONLY("InputContextOnly"), - IN_RESPONSE_TO("InResponseTo"), - ISDEFAULT("isDefault"), - IS_REQUIRED("isRequired"), - IS_PASSIVE("IsPassive"), - ISSUE_INSTANT("IssueInstant"), - LOCATION("Location"), - METHOD("Method"), - NAME("Name"), - NAME_FORMAT("NameFormat"), - NAME_QUALIFIER("NameQualifier"), - NOT_BEFORE("NotBefore"), - NOT_ON_OR_AFTER("NotOnOrAfter"), - PROTOCOL_BINDING("ProtocolBinding"), - PROTOCOL_SUPPORT_ENUMERATION("protocolSupportEnumeration"), - PROVIDER_NAME("ProviderName"), - REASON("Reason"), - RECIPIENT("Recipient"), - REQUEST_ABSTRACT("RequestAbstract"), - RESPONSE_LOCATION("ResponseLocation"), - RETURN_CONTEXT("ReturnContext"), - SP_PROVIDED_ID("SPProvidedID"), - SP_NAME_QUALIFIER("SPNameQualifier"), - STATUS_RESPONSE_TYPE("StatusResponseType"), - TYPE("type"), - USE("use"), - VALUE("Value"), - VALID_UNTIL("validUntil"), - VERSION("Version"), - WANT_AUTHN_REQUESTS_SIGNED("WantAuthnRequestsSigned"), - WANT_ASSERTIONS_SIGNED("WantAssertionsSigned"), - XACML_AUTHZ_DECISION_QUERY("XACMLAuthzDecisionQuery"), - XACML_AUTHZ_DECISION_QUERY_TYPE("XACMLAuthzDecisionQueryType"), - XACML_AUTHZ_DECISION_STATEMENT_TYPE("XACMLAuthzDecisionStatementType"), - REQUEST_AUTHENTICATED("RequestAuthenticated"), - - UNSOLICITED_RESPONSE_TARGET("TARGET"), - UNSOLICITED_RESPONSE_SAML_VERSION("SAML_VERSION"), - UNSOLICITED_RESPONSE_SAML_BINDING("SAML_BINDING"), - - LANG("lang"), - LANG_EN("en"), - METADATA_MIME("application/samlmetadata+xml"), - SIGNATURE_SHA1_WITH_DSA("http://www.w3.org/2000/09/xmldsig#dsa-sha1"), - SIGNATURE_SHA1_WITH_RSA("http://www.w3.org/2000/09/xmldsig#rsa-sha1"), - VERSION_2_0("2.0"), - - /** @deprecated Use namespace-aware variant instead */ - RESPONSE("Response"), - /** @deprecated Use namespace-aware variant instead */ - EXTENSIONS("Extensions"), - - UNKNOWN_VALUE(null) - ; - - private final QName asQName; - private final JBossSAMLURIConstants nsUri; - - private static class ReverseLookup { - // Private class to make sure JBossSAMLURIConstants is fully initialized - private static final Map QNAME_CONSTANTS; - private static final Map CONSTANTS; - - static { - HashMap q = new HashMap<>(JBossSAMLConstants.values().length); - HashMap m = new HashMap<>(JBossSAMLConstants.values().length); - JBossSAMLConstants old; - for (JBossSAMLConstants c : JBossSAMLConstants.values()) { - if ((old = q.put(c.getAsQName(), c)) != null) { - throw new IllegalStateException("Same name " + c.getAsQName() + " used for two distinct constants: " + c + ", " + old); - } - - String key = c.get(); - if ((old = m.put(key, c)) != null) { -// System.out.println("WARNING: " + old); - if (old != null && c.getAsQName().equals(old.getAsQName())) { - throw new IllegalStateException("Same name " + key + " used for two distinct constants: " + c + ", " + old); - } - m.put(key, null); - } - } - QNAME_CONSTANTS = Collections.unmodifiableMap(q); - CONSTANTS = Collections.unmodifiableMap(m); - } - - public JBossSAMLConstants from(String key) { - return CONSTANTS.get(key); - } - - public JBossSAMLConstants from(QName key) { - return QNAME_CONSTANTS.get(key); - } - } - private static final ReverseLookup REVERSE_LOOKUP = new ReverseLookup(); - - private JBossSAMLConstants(String name) { - this.asQName = name == null ? null : new QName(name); - this.nsUri = null; - } - - private JBossSAMLConstants(JBossSAMLURIConstants namespaceUri, String name) { - this.nsUri = namespaceUri; - this.asQName = name == null ? null : new QName(namespaceUri.get(), name); - } - - public String get() { - return this.asQName == null ? null : this.asQName.getLocalPart(); - } - - public QName getAsQName() { - return asQName; - } - - public JBossSAMLURIConstants getNsUri() { - return nsUri; - } - - /** - * Returns an enum constant based if known for the given {@code key}, or the {@code defaultValue} otherwise. - * @param key - * @return - */ - public static JBossSAMLConstants from(String key, JBossSAMLConstants defaultValue) { - final JBossSAMLConstants res = REVERSE_LOOKUP.from(key); - return res == null ? defaultValue : res; - } - - /** - * Returns an enum constant based if known for the given {@code key}, or the {@code UNKNOWN_VALUE} otherwise. - * @param key - * @return - */ - public static JBossSAMLConstants from(String key) { - return from(key, UNKNOWN_VALUE); - } - - /** - * Returns an enum constant based if known for the given {@code name} (namespace-aware), or the {@code UNKNOWN_VALUE} otherwise. - * @param key - * @return - */ - public static JBossSAMLConstants from(QName name) { - final JBossSAMLConstants res = REVERSE_LOOKUP.from(name); - return res == null ? UNKNOWN_VALUE : res; - } -} \ No newline at end of file diff --git a/src/main/java/nl/first8/keycloak/saml/SAMLRequestParser.java b/src/main/java/nl/first8/keycloak/saml/SAMLRequestParser.java index 372d55e..47d0da2 100644 --- a/src/main/java/nl/first8/keycloak/saml/SAMLRequestParser.java +++ b/src/main/java/nl/first8/keycloak/saml/SAMLRequestParser.java @@ -51,7 +51,13 @@ public static SAMLDocumentHolder parseRequestRedirectBinding(String samlMessage) public static SAMLDocumentHolder parseRequestPostBinding(String samlMessage) { InputStream is; - byte[] samlBytes = PostBindingUtil.base64Decode(samlMessage); + byte[] samlBytes; + try { + samlBytes = PostBindingUtil.base64Decode(samlMessage); + } catch (IllegalArgumentException e) { + logger.samlBase64DecodingError(e); + return null; + } if (log.isDebugEnabled()) { String str = new String(samlBytes, GeneralConstants.SAML_CHARSET); log.debug("SAML POST Binding"); @@ -67,7 +73,13 @@ public static SAMLDocumentHolder parseRequestPostBinding(String samlMessage) { } public static SAMLDocumentHolder parseResponsePostBinding(String samlMessage) { - byte[] samlBytes = PostBindingUtil.base64Decode(samlMessage); + byte[] samlBytes; + try { + samlBytes = PostBindingUtil.base64Decode(samlMessage); + } catch (IllegalArgumentException e) { + logger.samlBase64DecodingError(e); + return null; + } log.debug("SAML POST Binding"); return parseResponseDocument(samlBytes); } diff --git a/src/main/java/nl/first8/keycloak/saml/processing/core/parsers/saml/SAMLParser.java b/src/main/java/nl/first8/keycloak/saml/processing/core/parsers/saml/SAMLParser.java index 9f64829..7695930 100644 --- a/src/main/java/nl/first8/keycloak/saml/processing/core/parsers/saml/SAMLParser.java +++ b/src/main/java/nl/first8/keycloak/saml/processing/core/parsers/saml/SAMLParser.java @@ -39,8 +39,8 @@ public class SAMLParser extends AbstractParser { private static final QName SAML_11_ASSERTION = new QName(SAML11Constants.ASSERTION_11_NSURI, JBossSAMLConstants.ASSERTION.get()); private static final QName SAML_11_ENCRYPTED_ASSERTION = new QName(SAML11Constants.ASSERTION_11_NSURI, JBossSAMLConstants.ENCRYPTED_ASSERTION.get()); - private static final QName SAML_11_RESPONSE = new QName(SAML11Constants.ASSERTION_11_NSURI, JBossSAMLConstants.RESPONSE__PROTOCOL.get()); - private static final QName SAML_11_REQUEST = new QName(SAML11Constants.ASSERTION_11_NSURI, JBossSAMLConstants.REQUEST.get()); + private static final QName SAML_11_RESPONSE = new QName(SAML11Constants.PROTOCOL_11_NSURI, JBossSAMLConstants.RESPONSE__PROTOCOL.get()); + private static final QName SAML_11_REQUEST = new QName(SAML11Constants.PROTOCOL_11_NSURI, JBossSAMLConstants.REQUEST.get()); // Since we have to support JDK 7, no lambdas are available private interface ParserFactory { diff --git a/src/main/java/nl/first8/keycloak/services/resources/IdentityBrokerService.java b/src/main/java/nl/first8/keycloak/services/resources/IdentityBrokerService.java index 7137a4c..44d300d 100644 --- a/src/main/java/nl/first8/keycloak/services/resources/IdentityBrokerService.java +++ b/src/main/java/nl/first8/keycloak/services/resources/IdentityBrokerService.java @@ -30,6 +30,8 @@ import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.NoCache; + +import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; import org.keycloak.authentication.AuthenticationProcessor; import org.keycloak.authentication.RequiredActionContext; @@ -54,6 +56,7 @@ import org.keycloak.broker.saml.SAMLEndpoint; import org.keycloak.broker.social.SocialIdentityProvider; import org.keycloak.common.ClientConnection; +import org.keycloak.common.Profile; import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.ObjectUtil; import org.keycloak.common.util.Time; @@ -70,31 +73,39 @@ import org.keycloak.models.utils.FormMessage; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.LoginProtocol; +import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.protocol.saml.SamlSessionUtils; import org.keycloak.protocol.saml.preprocessor.SamlAuthenticationPreprocessor; import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.services.CorsErrorResponseException; import org.keycloak.services.ErrorPage; import org.keycloak.services.ErrorPageException; import org.keycloak.services.ErrorResponse; import org.keycloak.services.ServicesLogger; import org.keycloak.services.Urls; +import org.keycloak.services.clientpolicy.ClientPolicyException; +import org.keycloak.services.clientpolicy.context.IdentityBrokeringAPIContext; import org.keycloak.services.cors.Cors; 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.ClientSessionCode; +import org.keycloak.services.managers.GrantTypeEndpointRestrictionValidator; import org.keycloak.services.messages.Messages; import org.keycloak.services.resources.LoginActionsService; import org.keycloak.services.resources.SessionCodeChecks; import org.keycloak.services.util.AuthenticationFlowURLHelper; import org.keycloak.services.util.BrowserHistoryHelper; import org.keycloak.services.util.CacheControlUtil; +import org.keycloak.services.util.DPoPUtil; import org.keycloak.services.util.DefaultClientSessionContext; +import org.keycloak.services.util.UserSessionUtil; import org.keycloak.services.validation.Validation; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.RootAuthenticationSessionModel; @@ -424,8 +435,122 @@ public Response retrieveTokenPreflight() { @GET @NoCache @Path("{provider_alias}/token") - public Response retrieveToken(@PathParam("provider_alias") String providerAlias) { - return getToken(providerAlias, false); + public Response retrieveTokenV1(@PathParam("provider_alias") String providerAlias) { + return getTokenV1(providerAlias); + } + + @POST + @NoCache + @Path("{provider_alias}/token") + public Response retrieveTokenV2(@PathParam("provider_alias") String providerAlias) { + return getTokenV2(providerAlias); + } + + private Response getTokenV2(String providerAlias) { + this.event.event(EventType.IDENTITY_PROVIDER_RETRIEVE_TOKEN) + .detail(Details.IDENTITY_PROVIDER, providerAlias); + + Cors cors = Cors.builder().auth().allowedMethods("POST").auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS); + + // check profile is enabled + if (!Profile.isFeatureEnabled(Profile.Feature.IDENTITY_BROKERING_API_V2)) { + event.detail(Details.REASON, "Identity Brokering API feature not enabled"); + event.error(Errors.IDENTITY_PROVIDER_ERROR); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Identity Brokering API feature not enabled", Response.Status.BAD_REQUEST); + } + + // authenticate client + AuthorizeClientUtil.ClientAuthResult clientAuth = AuthorizeClientUtil.authorizeClient(session, event, cors); + ClientModel client = clientAuth.getClient(); + cors.checkAllowedOrigins(session, client); + event.client(client); + session.getContext().setClient(client); + if (client.isPublicClient()) { + event.detail(Details.REASON, "public clients not allowed"); + event.error(Errors.NOT_ALLOWED); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "public clients not allowed", Response.Status.FORBIDDEN); + } + + // check the client is allowed to retrieve tokens to this provider + OIDCAdvancedConfigWrapper oidcClient = OIDCAdvancedConfigWrapper.fromClientModel(client); + if (!oidcClient.getExternalTokenEnabled() || !oidcClient.getExternalAllowedIdentityProviders().contains(providerAlias)) { + event.detail(Details.REASON, "Client not allowed to retrieve token for the provider"); + event.error(Errors.NOT_ALLOWED); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Client not allowed to retrieve token for the provider", Response.Status.FORBIDDEN); + } + + // validate the token + String tokenString = session.getContext().getHttpRequest().getDecodedFormParameters().getFirst(OAuth2Constants.TOKEN); + AuthenticationManager.AuthResult authResult = AuthenticationManager.verifyIdentityToken( + session, realmModel, session.getContext().getUri(), clientConnection, true, true, null, false, tokenString, headers, + verifier -> { + DPoPUtil.withDPoPVerifier(verifier, realmModel, new DPoPUtil.Validator(session).request(request).uriInfo(session.getContext().getUri()).accessToken(tokenString)); + verifier.withChecks(GrantTypeEndpointRestrictionValidator.check(session)); + }); + if (authResult == null) { + event.error(Errors.INVALID_TOKEN); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_TOKEN, "Invalid token", Response.Status.BAD_REQUEST); + } + AccessToken token = authResult.token(); + event.user(authResult.user()); + + // check the request client is in the audience + if (!client.getClientId().equals(token.getIssuedFor()) && !token.hasAudience(client.getClientId())) { + event.detail(Details.REASON, "client is not within the token audience"); + event.error(Errors.NOT_ALLOWED); + throw new CorsErrorResponseException(cors, OAuthErrorException.UNAUTHORIZED_CLIENT, "Client is not within the token audience", Response.Status.FORBIDDEN); + } + + // retrieve the provider model + IdentityProviderModel model = session.identityProviders().getByAlias(providerAlias); + if (model == null || !model.isEnabled()) { + event.detail(Details.REASON, "Invalid identity provider"); + event.error(Errors.IDENTITY_PROVIDER_ERROR); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Invalid identity provider", Response.Status.BAD_REQUEST); + } + + // retrieve the provider + UserAuthenticationIdentityProvider identityProvider = getIdentityProvider(session, model, UserAuthenticationIdentityProvider.class); + if (identityProvider == null) { + event.detail(Details.REASON, "Invalid identity provider"); + event.error(Errors.IDENTITY_PROVIDER_ERROR); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Invalid identity provider", Response.Status.BAD_REQUEST); + } + + // retrieve the identity associated to the user + FederatedIdentityModel identity = this.session.users().getFederatedIdentity(realmModel, authResult.user(), providerAlias); + if (identity == null) { + event.detail(Details.REASON, "User not associated to identity provider"); + event.error(Errors.IDENTITY_PROVIDER_ERROR); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "User not associated to identity provider", Response.Status.BAD_REQUEST); + } + + // obtain the session from the token + UserSessionModel userSession = UserSessionUtil.findValidSessionForAccessToken( + session, realmModel, token, authResult.client(), (invalidUserSession -> {})) + .getUserSession(); + + //client policies + try { + session.clientPolicy().triggerOnEvent(new IdentityBrokeringAPIContext(session, authResult.token(), client, identityProvider.getConfig().getAlias())); + } catch (ClientPolicyException cpe) { + event.detail(Details.REASON, Details.CLIENT_POLICY_ERROR); + event.detail(Details.CLIENT_POLICY_ERROR, cpe.getError()); + event.detail(Details.CLIENT_POLICY_ERROR_DETAIL, cpe.getErrorDetail()); + event.error(cpe.getError()); + throw new CorsErrorResponseException(cors, cpe.getError(), cpe.getErrorDetail(), cpe.getErrorStatus()); + } + + // now it is OK to retrieve the token from the session or the database + try { + Response response = identityProvider.retrieveToken(session, identity, userSession, authResult.user()); + event.success(); + return cors.add(Response.fromResponse(response)); + } catch (Exception e) { + event.detail(Details.REASON, e.getMessage()); + event.error(Errors.INVALID_REQUEST); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, e.getMessage(), Response.Status.BAD_REQUEST); + } } private boolean canReadBrokerToken(AccessToken token) { @@ -434,8 +559,15 @@ private boolean canReadBrokerToken(AccessToken token) { return brokerRoles != null && brokerRoles.isUserInRole(Constants.READ_TOKEN_ROLE); } - private Response getToken(String providerAlias, boolean forceRetrieval) { - this.event.event(EventType.IDENTITY_PROVIDER_RETRIEVE_TOKEN); + private Response getTokenV1(String providerAlias) { + this.event.event(EventType.IDENTITY_PROVIDER_RETRIEVE_TOKEN) + .detail(Details.IDENTITY_PROVIDER, providerAlias); + + if (!Profile.isFeatureEnabled(Profile.Feature.IDENTITY_BROKERING_API_V1)) { + event.detail(Details.REASON, "Identity Brokering API feature not enabled"); + event.error(Errors.IDENTITY_PROVIDER_ERROR); + return badRequest("Identity Brokering API feature not enabled"); + } try { AuthenticationManager.AuthResult authResult = new AppAuthManager.BearerTokenAuthenticator(session) @@ -444,67 +576,73 @@ private Response getToken(String providerAlias, boolean forceRetrieval) { .setHeaders(request.getHttpHeaders()) .authenticate(); - if (authResult != null) { - AccessToken token = authResult.token(); - ClientModel clientModel = authResult.client(); - event.client(clientModel); - event.user(authResult.user()); - - session.getContext().setClient(clientModel); - - ClientModel brokerClient = realmModel.getClientByClientId(Constants.BROKER_SERVICE_CLIENT_ID); - if (brokerClient == null) { - return corsResponse(forbidden("Realm has not migrated to support the broker token exchange service"), clientModel); + if (authResult == null) { + return badRequest("Invalid token."); + } - } - if (!canReadBrokerToken(token)) { - return corsResponse(forbidden("Client [" + clientModel.getClientId() + "] not authorized to retrieve tokens from identity provider [" + providerAlias + "]."), clientModel); + AccessToken token = authResult.token(); + ClientModel clientModel = authResult.client(); + UserModel user = authResult.user(); - } + this.event.client(clientModel); + this.event.user(user); + this.session.getContext().setClient(clientModel); - UserAuthenticationIdentityProvider identityProvider = getIdentityProvider(session, providerAlias); - IdentityProviderModel identityProviderConfig = getIdentityProviderConfig(providerAlias); + ClientModel brokerClient = realmModel.getClientByClientId(Constants.BROKER_SERVICE_CLIENT_ID); + if (brokerClient == null) { + event.detail(Details.REASON, "Realm has not migrated to support the broker token exchange service"); + event.error(Errors.IDENTITY_PROVIDER_ERROR); + return corsResponse(forbidden("Realm has not migrated to support the broker token exchange service"), clientModel); + } - if (Booleans.isTrue(identityProviderConfig.isStoreToken())) { - FederatedIdentityModel identity = this.session.users().getFederatedIdentity(this.realmModel, authResult.user(), providerAlias); + if (!canReadBrokerToken(token)) { + event.detail(Details.REASON, "Client not authorized to retrieve tokens for provider"); + event.error(Errors.UNAUTHORIZED_CLIENT); + return corsResponse(forbidden("Client [" + clientModel.getClientId() + "] not authorized to retrieve tokens from identity provider [" + providerAlias + "]."), clientModel); + } - if (identity == null) { - return corsResponse(badRequest("User [" + authResult.user().getId() + "] is not associated with identity provider [" + providerAlias + "]."), clientModel); - } + UserAuthenticationIdentityProvider identityProvider = getIdentityProvider(session, providerAlias); + IdentityProviderModel identityProviderConfig = getIdentityProviderConfig(providerAlias); + if (Booleans.isFalse(identityProviderConfig.isStoreToken())) { + event.detail(Details.REASON, "Identity Provider does not support this operation"); + event.error(Errors.IDENTITY_PROVIDER_ERROR); + return corsResponse(badRequest("Identity Provider [" + providerAlias + "] does not support this operation."), clientModel); + } - if (identity.getToken() == null) { - return corsResponse(notFound("No token stored for user [" + authResult.user().getId() + "] with associated identity provider [" + providerAlias + "]."), clientModel); - } + FederatedIdentityModel identity = this.session.users().getFederatedIdentity(this.realmModel, user, providerAlias); + if (identity == null) { + this.event.detail(Details.REASON, "User not associated to identity provider"); + this.event.error(Errors.IDENTITY_PROVIDER_ERROR); + return corsResponse(badRequest("User [" + user.getId() + "] is not associated with identity provider [" + providerAlias + "]."), clientModel); + } + if (identity.getToken() == null) { + this.event.detail(Details.REASON, "No token stored for user in this provider"); + this.event.error(Errors.IDENTITY_PROVIDER_ERROR); + return corsResponse(notFound("No token stored for user [" + authResult.user().getId() + "] with associated identity provider [" + providerAlias + "]."), clientModel); + } - String oldToken = identity.getToken(); - try { - Response response = corsResponse(identityProvider.retrieveToken(session, identity), clientModel); - this.event.success(); - return response; - } catch (WebApplicationException e) { - this.event.detail(Details.REASON, e.getMessage()); - this.event.error(Errors.IDENTITY_PROVIDER_ERROR); - return corsResponse(e.getResponse(), clientModel); - } finally { - if (!Objects.equals(oldToken, identity.getToken())) { - // The API of the IdentityProvider doesn't allow use to pass down the realm and the user, so we check if the token has changed, - // and then update the store. - session.users().updateFederatedIdentity(session.getContext().getRealm(), authResult.user(), identity); - } - } + String oldToken = identity.getToken(); + try { + Response response = corsResponse(identityProvider.retrieveToken(session, identity), clientModel); + this.event.success(); + return response; + } catch (WebApplicationException e) { + this.event.detail(Details.REASON, e.getMessage()); + this.event.error(Errors.IDENTITY_PROVIDER_ERROR); + return corsResponse(e.getResponse(), clientModel); + } finally { + if (Booleans.isTrue(identityProviderConfig.isStoreToken()) && !Objects.equals(oldToken, identity.getToken())) { + session.users().updateFederatedIdentity(session.getContext().getRealm(), user, identity); } - - return corsResponse(badRequest("Identity Provider [" + providerAlias + "] does not support this operation."), clientModel); } - return badRequest("Invalid token."); } catch (WebApplicationException e) { this.event.detail(Details.REASON, e.getMessage()); this.event.error(Errors.IDENTITY_PROVIDER_ERROR); return e.getResponse(); } catch (IdentityBrokerException e) { return redirectToErrorPage(Response.Status.BAD_GATEWAY, Messages.COULD_NOT_OBTAIN_TOKEN, e, providerAlias); - } catch (Exception e) { + } catch (Exception e) { return redirectToErrorPage(Response.Status.BAD_GATEWAY, Messages.UNEXPECTED_ERROR_RETRIEVING_TOKEN, e, providerAlias); } } @@ -708,10 +846,14 @@ private Response afterFirstBrokerLogin(AuthenticationSessionModel authSession) { FederatedIdentityModel federatedIdentityModel = new FederatedIdentityModel(context.getIdpConfig().getAlias(), context.getId(), context.getUsername(), context.getToken()); - session.users().addFederatedIdentity(realmModel, federatedUser, federatedIdentityModel); + try { + session.users().addFederatedIdentity(realmModel, federatedUser, federatedIdentityModel); + } catch (ModelDuplicateException de) { + String idpDisplayName = KeycloakModelUtils.getIdentityProviderDisplayName(session, context.getIdpConfig()); + return redirectToErrorPage(authSession, Status.CONFLICT, Messages.IDENTITY_PROVIDER_ALREADY_LINKED_TO_CURRENT_USER, de, idpDisplayName); + } } - String isRegisteredNewUser = authSession.getAuthNote(AbstractIdentityProvider.BROKER_REGISTERED_NEW_USER); if (Boolean.parseBoolean(isRegisteredNewUser)) { @@ -747,7 +889,6 @@ private Response afterFirstBrokerLogin(AuthenticationSessionModel authSession) { } return finishOrRedirectToPostBrokerLogin(authSession, context, true); - } catch (Exception e) { return redirectToErrorPage(authSession, Response.Status.INTERNAL_SERVER_ERROR, Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR, e); } @@ -984,8 +1125,15 @@ private Response performAccountLinking(AuthenticationSessionModel authSession, U } } } else { - this.session.users().addFederatedIdentity(this.realmModel, authenticatedUser, newModel); - federatedUser = authenticatedUser; + try { + this.session.users().addFederatedIdentity(this.realmModel, authenticatedUser, newModel); + federatedUser = authenticatedUser; + } catch(ModelDuplicateException e) { + logger.warnf(e,"Cannot link user '%s' to identity provider '%s' as the link already exists for this user and identity provider", + authenticatedUser.getUsername(), context.getIdpConfig().getAlias()); + String idpDisplayName = KeycloakModelUtils.getIdentityProviderDisplayName(session, context.getIdpConfig()); + return redirectToErrorWhenLinkingFailed(authSession, Messages.IDENTITY_PROVIDER_ALREADY_LINKED_TO_CURRENT_USER, idpDisplayName); + } } updateFederatedIdentity(context, federatedUser); @@ -1317,8 +1465,10 @@ public static UserAuthenticationIdentityProvider getIdentityProvider(Keycloak public static > T getIdentityProvider(KeycloakSession session, IdentityProviderModel identityProviderModel, Class type) { if (identityProviderModel != null) { IdentityProviderFactory providerFactory = getIdentityProviderFactory(session, identityProviderModel); - IdentityProvider idp = providerFactory.create(session, identityProviderModel); - return type.isInstance(idp) ? type.cast(idp) : null; + if (providerFactory != null) { + IdentityProvider idp = providerFactory.create(session, identityProviderModel); + return type.isInstance(idp) ? type.cast(idp) : null; + } } return null; } @@ -1344,8 +1494,7 @@ private IdentityProviderModel getIdentityProviderConfig(String providerAlias) { } private Response corsResponse(Response response, ClientModel clientModel) { - return Cors.builder().auth().allowedOrigins(session, clientModel).add(Response.fromResponse(response)); - } + return Cors.builder().auth().checkAllowedOrigins(session, clientModel).add(Response.fromResponse(response)); } private void fireErrorEvent(String message, Throwable throwable) { if (!this.event.getEvent().getType().toString().endsWith("_ERROR")) { diff --git a/src/test/java/nl/first8/keycloak/broker/saml/SAMLEndpointTest.java b/src/test/java/nl/first8/keycloak/broker/saml/SAMLEndpointTest.java index 90bb807..f7e9c26 100644 --- a/src/test/java/nl/first8/keycloak/broker/saml/SAMLEndpointTest.java +++ b/src/test/java/nl/first8/keycloak/broker/saml/SAMLEndpointTest.java @@ -12,10 +12,13 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.broker.provider.UserAuthenticationIdentityProvider; import org.keycloak.common.ClientConnection; +import org.keycloak.common.Profile; import org.keycloak.common.enums.SslRequired; import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.models.KeyManager; @@ -44,6 +47,18 @@ class SAMLEndpointTest { + // SAMLEndpoint reads Profile.isFeatureEnabled(IDENTITY_BROKERING_API_V2). Profile.getInstance() + // is only populated by Keycloak's server bootstrap, which these unit tests don't run, so it + // must be initialized manually - otherwise Profile.getInstance() is null and NPEs. + @BeforeEach + public void initProfile() { + Profile.defaults(); + } + + @AfterEach + public void resetProfile() { + Profile.reset(); + } @Test public void testHandleSamlResponse() throws URISyntaxException, IllegalAccessException, MarshallingException, SignatureException, EncryptionException {