Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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 `<Response>` but also `<ArtifactResponse>`.
12. Run the new code from the PR through Test classes, not only `<Response>` but also `<ArtifactResponse>`.

* (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 `<Response>` or `<ArtifactResponse>`, 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)
Expand Down
6 changes: 4 additions & 2 deletions patch-tool/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions patch-tool/keycloak-files.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<description>
SAML v2.0 extensions that adds more configuration options to connect to SAML Service Providers.
</description>
<version>26.5</version>
<version>26.6</version>

<properties>
<maven.compiler.source>17</maven.compiler.source>
Expand All @@ -20,7 +20,7 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

<keycloak.version>26.5.7</keycloak.version>
<keycloak.version>26.6.4</keycloak.version>
<spotbugs.version>4.8.3.1</spotbugs.version>

<jib-maven-plugin.version>3.4.1</jib-maven-plugin.version>
Expand Down
34 changes: 22 additions & 12 deletions src/main/java/nl/first8/keycloak/broker/saml/SAMLEndpoint.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -410,7 +411,7 @@ private Consumer<UserSessionModel> processLogout(AtomicReference<LogoutRequestTy
};
}

private String getEntityId(UriInfo uriInfo, RealmModel realm) {
protected final String getEntityId(UriInfo uriInfo, RealmModel realm) {
String configEntityId = config.getEntityId();

if (configEntityId == null || configEntityId.isEmpty())
Expand Down Expand Up @@ -555,7 +556,16 @@ protected Response handleLoginResponse(String samlResponse, SAMLDocumentHolder h
identity.setEmail(subjectNameID.getValue());
}

if (Booleans.isTrue(config.isStoreToken())) {
// set the token in session and model depending the brokering api version
if (Profile.isFeatureEnabled(Profile.Feature.IDENTITY_BROKERING_API_V2)) {
final String assertionString = DocumentUtil.getNodeAsString(assertionElement);
if (config.isStoreTokenInSession()) {
identity.getContextData().put(UserAuthenticationIdentityProvider.FEDERATED_ACCESS_TOKEN, assertionString);
}
if (Booleans.isTrue(config.isStoreToken())) {
identity.setToken(assertionString);
}
} else if (Booleans.isTrue(config.isStoreToken())) {
identity.setToken(samlResponse);
}

Expand Down Expand Up @@ -623,7 +633,7 @@ protected Response handleLoginResponse(String samlResponse, SAMLDocumentHolder h
* @param clientUrlName
* @return see description
*/
private AuthenticationSessionModel samlIdpInitiatedSSO(final String clientUrlName) {
protected final AuthenticationSessionModel samlIdpInitiatedSSO(final String clientUrlName) {
event.event(EventType.LOGIN);
CacheControlUtil.noBackButtonCacheControlHeader(session);
Optional<ClientModel> oClient = SAMLEndpoint.this.session.clients()
Expand All @@ -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
Expand Down Expand Up @@ -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)) {
Expand All @@ -922,7 +932,7 @@ private String getPrincipal(AssertionType assertion) {

}

private String getFirstMatchingAttribute(AssertionType assertion, Predicate<AttributeType> predicate) {
protected final String getFirstMatchingAttribute(AssertionType assertion, Predicate<AttributeType> predicate) {
return assertion.getAttributeStatements().stream()
.map(AttributeStatementType::getAttributes)
.flatMap(Collection::stream)
Expand All @@ -935,7 +945,7 @@ private String getFirstMatchingAttribute(AssertionType assertion, Predicate<Attr
.orElse(null);
}

private String expectedPrincipalType() {
protected final String expectedPrincipalType() {
SamlPrincipalType principalType = config.getPrincipalType();
switch (principalType) {
case SUBJECT:
Expand All @@ -948,13 +958,13 @@ private String expectedPrincipalType() {
}
}

private NameIDType getSubjectNameID(final AssertionType assertion) {
protected final NameIDType getSubjectNameID(final AssertionType assertion) {
SubjectType subject = assertion.getSubject();
SubjectType.STSubType subType = subject.getSubType();
return subType != null ? (NameIDType) subType.getBaseID() : null;
}

private boolean validateInResponseToAttribute(ResponseType responseType, String expectedRequestId) {
protected final boolean validateInResponseToAttribute(ResponseType responseType, String expectedRequestId) {
// If we are not expecting a request ID, do not validate.
if (expectedRequestId == null || expectedRequestId.isEmpty())
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
Expand Down Expand Up @@ -56,6 +57,7 @@
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.saml.*;
import org.keycloak.protocol.saml.preprocessor.SamlAuthenticationPreprocessor;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.saml.*;
import org.keycloak.saml.SamlProtocolExtensionsAwareBuilder.NodeGenerator;
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
Expand All @@ -69,6 +71,7 @@
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.util.Booleans;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.TokenUtil;

import org.jboss.logging.Logger;
import org.w3c.dom.Document;
Expand Down Expand Up @@ -254,13 +257,37 @@ public void authenticationFinished(AuthenticationSessionModel authSession, Broke
authSession.setUserSessionNote(SAMLEndpoint.SAML_FEDERATED_SESSION_INDEX, authn.getSessionIndex());

}
if (context.getContextData().containsKey(FEDERATED_ACCESS_TOKEN)) {
authSession.setUserSessionNote(FEDERATED_ACCESS_TOKEN, (String) context.getContextData().get(FEDERATED_ACCESS_TOKEN));
}
}

@Override
public Response retrieveToken(KeycloakSession session, FederatedIdentityModel identity) {
return Response.ok(identity.getToken()).type(MediaType.TEXT_PLAIN_TYPE).build();
}

@Override
public Response retrieveToken(KeycloakSession session, FederatedIdentityModel identity, UserSessionModel userSession, UserModel user) {
String token = null;
if (userSession != null && getConfig().isStoreTokenInSession()) {
token = getFederatedAccessToken(userSession);
}

if (token == null && Booleans.isTrue(getConfig().isStoreToken())) {
token = identity.getToken();
}

if (token == null) {
return exchangeErrorResponse(session.getContext().getUri(), null, userSession, "token_expired", "No token stored.");
}

AccessTokenResponse tokenResponse = new AccessTokenResponse();
tokenResponse.setToken(Base64.getUrlEncoder().withoutPadding().encodeToString(token.getBytes(GeneralConstants.SAML_CHARSET)));
tokenResponse.setTokenType(TokenUtil.TOKEN_TYPE_BEARER);
return Response.ok(tokenResponse).type(MediaType.APPLICATION_JSON_TYPE).build();
}

@Override
public void backchannelLogout(KeycloakSession session, UserSessionModel userSession, UriInfo uriInfo, RealmModel realm) {
String singleLogoutServiceUrl = getConfig().getSingleLogoutServiceUrl();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import org.jboss.logging.Logger;
import org.keycloak.common.enums.SslRequired;
import org.keycloak.common.Profile;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.saml.SamlPrincipalType;
Expand Down Expand Up @@ -325,4 +326,11 @@ public void validate(RealmModel realm) {
throw new IllegalArgumentException("Can not have Transient NameID Policy Format together with SUBJECT Principal Type");

}

@Override
public boolean isStoreTokenInSession() {
// for saml is false by default
return Profile.isFeatureEnabled(Profile.Feature.IDENTITY_BROKERING_API_V2)
& Boolean.parseBoolean(getConfig().get(STORE_TOKEN_IN_SESSION));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public class UserAttributeMapper extends AbstractIdentityProviderMapper implemen
public static final String ATTRIBUTE_FRIENDLY_NAME = "attribute.friendly.name";
public static final String ATTRIBUTE_NAME_FORMAT = "attribute.name.format";
public static final String USER_ATTRIBUTE = "user.attribute";
public static final String ALLOW_NULLABLE = "allow.nullable.property";
public static final String XML_ELEMENT_AS_ATTRIBUTE = "attribute.xml.element";
private static final String ID = "id";
private static final String EMAIL = "email";
Expand Down Expand Up @@ -114,6 +115,14 @@ public class UserAttributeMapper extends AbstractIdentityProviderMapper implemen
property.setHelpText("Gets the attributes from the <xacml-context:Resource> instead of the <saml2:AttributeStatement> 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";
Expand Down Expand Up @@ -246,6 +255,13 @@ private void setIfNotEmptyAndDifferentAndStripMailto(Consumer<String> consumer,
}


private void setIfDifferent(Consumer<String> consumer, Supplier<String> currentValueSupplier, List<String> values) {
String newValue = (values == null || values.isEmpty()) ? null : values.get(0);
if (!Objects.equals(newValue, currentValueSupplier.get())) {
consumer.accept(newValue);
}
}

private Predicate<AttributeStatementType.ASTChoiceType> elementWith(String attributeName) {
return attributeType -> {
AttributeType attribute = attributeType.getAttribute();
Expand Down Expand Up @@ -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;
}
Expand All @@ -345,11 +363,23 @@ public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserMo
List<String> 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<String> currentAttributeValues = user.getAttributes().get(attribute);
Expand Down
Loading
Loading