8000 Enforce SAML OneTimeUse Condition to Prevent Assertion Replay by ahus1 · Pull Request #55098 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Draft
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
3 changes: 3 additions & 0 deletions docs/documentation/release_notes/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ include::topics/templates/document-attributes.adoc[]
:release_header_latest_link: {releasenotes_link_latest}
include::topics/templates/release-header.adoc[]

== {project_name_full} 26.8.3
include::topics/26_8_3.adoc[leveloffset=2]

== {project_name_full} 26.8.0
include::topics/26_8_0.adoc[leveloffset=2]

Expand Down
12 changes: 12 additions & 0 deletions docs/documentation/release_notes/topics/26_8_3.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Release notes should contain only headline-worthy new features,
// assuming that people who migrate will read the upgrading guide anyway.

= Security and Standards

== SAML OneTimeUse condition enforced for identity brokering

When an external SAML identity provider issues an assertion with a `OneTimeUse` condition, {project_name} now rejects any later attempt to present the same assertion, as required by the SAML 2.0 specification.
This protects brokered logins against assertion replay, in particular in IdP-initiated flows where no `InResponseTo` binding is available.
How long a consumed assertion is remembered is bounded by the new *OneTimeUse maximum age* setting of the SAML identity provider, which defaults to 1 hour.

For detailed upgrade instructions, https://www.keycloak.org/docs/latest/upgrading/index.html[review the upgrading guide].
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ When the option is *OFF*, the certificates in `Validating X509 Certificates` are

|Attribute Consuming Service Name
|A descriptive name for the set of attributes that are advertised in the autogenerated SP metadata document.

|OneTimeUse maximum age
|The maximum age of a SAML assertion that contains a `OneTimeUse` condition, counted from the time the external IDP issued it. {project_name} accepts such an assertion only once and rejects it when it is older than this value. Set this value to at least the time a user needs to complete a login. The default is 1 hour (3,600 seconds). This setting has no effect on assertions without a `OneTimeUse` condition.
|===

You can import all configuration data by providing a URL or a file pointing to the SAML IDP entity descriptor of the external IDP. If you are connecting to a {project_name} external IDP, you can import the IDP settings from the URL `<root>{kc_realms_path}/{realm-name}/protocol/saml/descriptor`. This link is an XML document describing metadata about the IDP. You can also import all this configuration data by providing a URL or XML file pointing to the external SAML IDP's entity descriptor to connect to.
Expand Down
14 changes: 14 additions & 0 deletions docs/documentation/upgrading/topics/changes/changes-26_8_3.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,20 @@ Previously, the Redirect URL was used without validation, which could allow an o

The `invite-existing-user` endpoint now also accepts an optional `client_id` query parameter, the same way `invite-user` already does. If omitted, both endpoints default to the `account` client.

=== SAML assertions with a OneTimeUse condition are no longer replayable
// https://github.com/keycloak/keycloak/issues/46325

When {project_name} acts as a SAML service provider through identity brokering, an assertion that contains a `OneTimeUse` condition is now accepted only once.
Previously, the condition was parsed but not enforced, so the same assertion could be presented again within its validity period.

After upgrading, a replayed assertion is rejected with an error page and an `IDENTITY_PROVIDER_RESPONSE_ERROR` event, and the user is not logged in.
The event has a plain-text `reason` detail that explains why the assertion was rejected, for example that it was already used or that it exceeds the maximum age.
This affects only identity providers that include the condition in their assertions, including a {project_name} realm acting as the identity provider when the *Include OneTimeUse Condition* option is enabled on the SAML client.
Assertions without the condition are not affected.
The new *OneTimeUse maximum age* setting of the SAML identity provider defaults to 1 hour (3,600 seconds).
Assertions with a `OneTimeUse` condition that are older than this are rejected.
Increase the setting if users need more time to complete a login with such an assertion.

// ------------------------ Deprecated features ------------------------ //
== Deprecated features

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4277,3 +4277,5 @@ scimApiEnabledHelp=If enabled, exposes realm resources through an API based on t
allowedProtocolMapperTypesForClientAdministrators=Allowed protocol mapper types for client administrators
allowedProtocolMapperTypesForClientAdministratorsHelp=Specifies the protocol mapper types that client administrators (users with permissions to manage clients, but without permissions to manage realm) are allowed to create, update, or delete. This restriction applies to protocol mappers on both clients and client scopes. If empty, client administrators cannot manage any protocol mappers.
clearInputField=Clear input field
maximum age
age of a SAML assertion with a OneTimeUse condition, counted from the time the identity provider issued it. Older assertions are rejected. Default is 1 hour (3600 seconds).
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,24 @@ export const AdvancedSettings = ({
/>
</FormGroupField>
)}
{isSAML && (
<FormGroupField label="oneTimeUseMaxAgeSeconds">
<Controller
name="config.oneTimeUseMaxAgeSeconds"
defaultValue=""
control={control}
render={({ field }) => (
<TimeSelector
className="kc-one-time-use-max-age-seconds"
data-testid="one-time-use-max-age-seconds-input"
value={field.value!}
>
units={["second", "minute", "hour"]}
/>
)}
/>
</FormGroupField>
)}
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ public enum Result {
protected abstract Result joinResult(Result otherResult);
};

public interface OneTimeUseHandler {

boolean validate(String assertionId);
}

public static class Builder {

private final String assertionId;
Expand All @@ -70,6 +75,8 @@ public static class Builder {

private boolean rejectProxyRestriction = false;

private OneTimeUseHandler oneTimeUseHandler;

public Builder(String assertionId, CommonConditionsType conditions, DestinationValidator destinationValidator) {
this.assertionId = assertionId;
this.conditions = conditions;
Expand All @@ -91,8 +98,13 @@ public Builder rejectProxyRestriction(boolean rejectProxyRestriction) {
return this;
}

public Builder oneTimeUseHandler(OneTimeUseHandler oneTimeUseHandler) {
this.>
return this;
}

public ConditionsValidator build() {
return new ConditionsValidator(assertionId, conditions, clockSkewInMillis, allowedAudiences, destinationValidator, rejectProxyRestriction);
return new ConditionsValidator(assertionId, conditions, clockSkewInMillis, allowedAudiences, destinationValidator, rejectProxyRestriction, oneTimeUseHandler);
}

}
Expand All @@ -115,13 +127,16 @@ public ConditionsValidator build() {

private final boolean rejectProxyRestriction;

private ConditionsValidator(String assertionId, CommonConditionsType conditions, int clockSkewInMillis, Set<URI> allowedAudiences, DestinationValidator destinationValidator, boolean rejectProxyRestriction) {
private final OneTimeUseHandler oneTimeUseHandler;

private ConditionsValidator(String assertionId, CommonConditionsType conditions, int clockSkewInMillis, Set<URI> allowedAudiences, DestinationValidator destinationValidator, boolean rejectProxyRestriction, OneTimeUseHandler oneTimeUseHandler) {
this.assertionId = assertionId;
this.conditions = conditions;
this.clockSkewInMillis = clockSkewInMillis;
this.allowedAudiences = allowedAudiences;
this.destinationValidator = destinationValidator;
this.rejectProxyRestriction = rejectProxyRestriction;
this.>
}

public boolean isValid() {
Expand Down Expand Up @@ -230,6 +245,11 @@ private Result validateOneTimeUse(OneTimeUseType cond) {
return Result.INVALID;
}

if (oneTimeUseHandler != null && !oneTimeUseHandler.validate(assertionId)) {
LOG.infof("Assertion %s with a <OneTimeUse/> condition was rejected as already used or too old.", assertionId);
return Result.INVALID;
}

return Result.VALID; // See line 963 of spec
}

Expand Down
10BC8
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
Expand All @@ -27,13 +28,15 @@
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import javax.xml.crypto.dsig.XMLSignature;
import javax.xml.datatype.XMLGregorianCalendar;

import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.FormParam;
Expand All @@ -56,6 +59,7 @@
import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.VerificationException;
import org.keycloak.common.util.Time;
import org.keycloak.crypto.KeyUse;
import org.keycloak.dom.saml.v2.assertion.AssertionType;
import org.keycloak.dom.saml.v2.assertion.AttributeStatementType;
Expand All @@ -75,6 +79,7 @@
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.jose.jws.crypto.HashUtils;
import org.keycloak.keys.PublicKeyLoader;
import org.keycloak.keys.PublicKeyStorageProvider;
import org.keycloak.keys.PublicKeyStorageUtils;
Expand Down Expand Up @@ -297,7 +302,8 @@ protected KeyLocator getIDPKeyLocator() {
}

public Response execute(String samlRequest, String samlResponse, String samlArt, String relayState, String clientId) {
event = new EventBuilder(realm, session, clientConnection);
event = new EventBuilder(realm, session, clientConnection)
.detail(Details.IDENTITY_PROVIDER, config.getAlias());
Response response = basicChecks(samlRequest, samlResponse, samlArt);
if (response != null) return response;
if (samlRequest != null) return handleSamlRequest(samlRequest, relayState);
Expand Down Expand Up @@ -557,6 +563,7 @@ protected Response handleLoginResponse(String samlResponse, SAMLDocumentHolder h
return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR);
}
session.getContext().setAuthenticationSession(authSession);
event.client(authSession.getClient());

if (! isSuccessfulSamlResponse(responseType)) {
String statusMessage = responseType.getStatus() == null || responseType.getStatus().getStatusMessage() == null ? Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR : responseType.getStatus().getStatusMessage();
Expand Down Expand Up @@ -687,7 +694,8 @@ protected Response handleLoginResponse(String samlResponse, SAMLDocumentHolder h

ConditionsValidator.Builder cvb = new ConditionsValidator.Builder(assertion.getID(), assertion.getConditions(), destinationValidator)
.clockSkewInMillis(1000 * config.getAllowedClockSkew())
.rejectProxyRestriction(config.isProxyRestrictionValidationEnabled());
.rejectProxyRestriction(config.isProxyRestrictionValidationEnabled())
.oneTimeUseHandler(assertionId -> registerOneTimeUse(assertion));
try {
String issuerURL = getEntityId(session.getContext().getUri(), realm);
cvb.addAllowedAudience(URI.create(issuerURL));
Expand All @@ -699,8 +707,13 @@ protected Response handleLoginResponse(String samlResponse, SAMLDocumentHolder h
// warning has been already emitted in DeploymentBuilder
}
if (! cvb.build().isValid()) {
logger.error("Assertion expired.");
logger.debug("Assertion conditions are not valid.");
event.event(EventType.IDENTITY_PROVIDER_RESPONSE);
// a OneTimeUse rejection has already recorded a more specific reason; don't overwrite it
Map<String, String> details = event.getEvent().getDetails();
if (details == null || !details.containsKey(Details.REASON)) {
event.detail(Details.REASON, "Assertion conditions are not valid (expired, not yet valid, or audience mismatch)");
}
event.error(Errors.INVALID_SAML_RESPONSE);
return ErrorPage.error(session, authSession, Response.Status.BAD_REQUEST, Messages.EXPIRED_CODE);
}
Expand Down Expand Up @@ -854,6 +867,83 @@ private String getExpectedDestination(String providerAlias, String clientId) {
}
return Urls.identityProviderAuthnResponse(session.getContext().getUri().getBaseUri(), providerAlias, realm.getName()).toString();
}

/**
* Marks a OneTimeUse assertion as used. Design decisions to keep in mind when changing this:
* <ul>
* <li>The marker lifetime is derived from IssueInstant (not from "now") so the accepted window is bounded
* by the configured maximum age, even if the IdP sets a very distant NotOnOrAfter. This also bounds the
* storage an IdP can consume.</li>
* <li>IssueInstant is mandatory here: without it the age cannot be bounded, so the assertion is rejected.</li>
* <li>The marker is also written when signature validation is disabled (the default today). Skipping it
* would silently turn off replay protection; an unsigned assertion can be forged anyway, so the
* admin is expected to enable signature validation.</li>
* <li>Known limitation: this runs while ConditionsValidator iterates the conditions, so an assertion that
* fails a later condition (for example the audience) is still marked as used.</li>
* </ul>
*/
private boolean registerOneTimeUse(AssertionType assertion) {
String issuer = assertion.getIssuer() != null ? assertion.getIssuer().getValue() : "";

if (assertion.getID() == null) {
return rejectOneTimeUse(assertion, "OneTimeUse assertion has no ID");
}

if (assertion.getIssueInstant() == null) {
return rejectOneTimeUse(assertion, "OneTimeUse assertion has no IssueInstant");
}

long now = Time.currentTimeSeconds();
long skewSeconds = config.getAllowedClockSkew();
long issueInstant = assertion.getIssueInstant().toGregorianCalendar().getTimeInMillis() / 1000;
if (issueInstant > now + skewSeconds) {
return rejectOneTimeUse(assertion, "OneTimeUse assertion has an IssueInstant in the future");
}

// The assertion is only accepted until the earlier of its maximum age and its NotOnOrAfter (plus skew).
// After that point a replay is rejected anyway, so the marker only needs to be kept until then.
long expiry = issueInstant + config.getOneTimeUseMaxAgeSeconds();
XMLGregorianCalendar notOnOrAfter = assertion.getConditions() == null ? null : assertion.getConditions().getNotOnOrAfter();
if (notOnOrAfter != null) {
expiry = Math.min(expiry, notOnOrAfter.toGregorianCalendar().getTimeInMillis() / 1000);
}
long remainingSeconds = expiry + skewSeconds - now;
if (remainingSeconds <= 0) {
return rejectOneTimeUse(assertion, "OneTimeUse assertion exceeds the maximum age");
}

// Keep the marker for one additional second because the absolute timestamps above are truncated to
// whole seconds, while ConditionsValidator evaluates NotOnOrAfter with sub-second precision.
long ttlSeconds = remainingSeconds + 1;

// length-prefixed parts keep the key unambiguous, whatever characters the IdP puts in issuer or ID
String rawKey = lengthPrefixed(realm.getId()) + lengthPrefixed(issuer) + lengthPrefixed(assertion.getID());
String key = "saml.oneTimeUse." + HashUtils.sha256UrlEncodedHash(rawKey, StandardCharsets.UTF_8);

// Add a few seconds to the TTL to account for clock skew between the IdP and Keycloak.
// TODO: consolidate that within Keycloak, and put a hard requirement for time synchronization in the docs.
ttlSeconds = ttlSeconds + 10;

boolean firstUse = session.revokedTokens().put(key, ttlSeconds);
if (!firstUse) {
return rejectOneTimeUse(assertion, "OneTimeUse assertion has already been used");
}

return true;
}

private boolean rejectOneTimeUse(AssertionType assertion, String reason) {
// the ID is chosen by the IdP, so keep the event detail bounded
String id = assertion.getID();
String message = reason + " (assertion ID: " + (id != null && id.length() > 128 ? id.substring(0, 128) + "..." : id) + ")";
logger.debugf("%s. Issuer: %s", message, assertion.getIssuer() != null ? assertion.getIssuer().getValue() : "");
event.detail(Details.REASON, message);
return false;
}

private static String lengthPrefixed(String value) {
return value.length() + ":" + value;
}
}

protected class PostBinding extends Binding {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ public class SAMLIdentityProviderConfig extends IdentityProviderModel {
public static final String USE_METADATA_DESCRIPTOR_URL = "useMetadataDescriptorUrl";
public static final String DESCRIPTOR_CACHE_SECONDS = "descriptorCacheSeconds";
public static final String PROXY_RESTRICTION_VALIDATION = "proxyRestrictionValidation";
public static final String ONE_TIME_USE_MAX_AGE_SECONDS = "oneTimeUseMaxAgeSeconds";
public static final long DEFAULT_ONE_TIME_USE_MAX_AGE_SECONDS = 60 * 60;

public SAMLIdentityProviderConfig() {
}
Expand Down Expand Up @@ -484,6 +486,29 @@ public void validate(RealmModel realm) {

}

public long getOneTimeUseMaxAgeSeconds() {
String value = getConfig().get(ONE_TIME_USE_MAX_AGE_SECONDS);
if (value != null && !value.isEmpty()) {
try {
long result = Long.parseLong(value);
if (result > 0) {
return result;
}
} catch (NumberFormatException e) {
// ignore and use default
}
}
return DEFAULT_ONE_TIME_USE_MAX_AGE_SECONDS;
}

public void setOneTimeUseMaxAgeSeconds(long oneTimeUseMaxAgeSeconds) {
if (oneTimeUseMaxAgeSeconds <= 0) {
getConfig().remove(ONE_TIME_USE_MAX_AGE_SECONDS);
} else {
getConfig().put(ONE_TIME_USE_MAX_AGE_SECONDS, String.valueOf(oneTimeUseMaxAgeSeconds));
}
}

@Override
public boolean isStoreTokenInSession() {
// for saml is false by default
Expand Down
Loading
Loading
0