Skip to content

Commit 414a352

Browse files
authored
Make SAML relayState max length configurable (#6381)
Motivation: The default `SsoHandler` currently omits `relayState` when its length exceeds 80 characters. Although the SAML spec defines an 80-character limit, many implementations do not strictly enforce it, and this restriction is often too tight in practice. References: - https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf - https://help.salesforce.com/s/articleView?id=000387925&type=1 Modifications: - Introduced `SamlServiceProviderBuilder.relayStateMaxLength()` to configure the maximum length. Result: - You can override the default 80-character restriction for SAML relayState.
1 parent 0339227 commit 414a352

File tree

3 files changed

+95
-38
lines changed

3 files changed

+95
-38
lines changed

saml/src/main/java/com/linecorp/armeria/server/saml/HttpRedirectBindingUtil.java

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,6 @@ static String toRedirectionUrl(SAMLObject msg,
106106
params.add(messageParamName, toDeflatedBase64(msg));
107107

108108
if (relayState != null) {
109-
// RelayState data MAY be included with a SAML protocol message transmitted with this binding.
110-
// The value MUST NOT exceed 80 bytes in length and SHOULD be integrity protected by the entity
111-
// creating the message independent of any other protections that may or may not exist
112-
// during message transmission.
113-
if (relayState.length() > 80) {
114-
throw new IllegalArgumentException("too long relayState string: " + relayState.length());
115-
}
116109
params.add(RELAY_STATE, relayState);
117110
}
118111

saml/src/main/java/com/linecorp/armeria/server/saml/SamlServiceProviderBuilder.java

Lines changed: 74 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
package com.linecorp.armeria.server.saml;
1717

1818
import static com.google.common.base.MoreObjects.firstNonNull;
19+
import static com.google.common.base.Preconditions.checkArgument;
20+
import static com.google.common.base.Preconditions.checkState;
1921
import static com.google.common.collect.ImmutableList.toImmutableList;
2022
import static com.google.common.collect.ImmutableSet.toImmutableSet;
2123
import static com.linecorp.armeria.server.saml.HttpRedirectBindingUtil.responseWithLocation;
@@ -68,8 +70,11 @@
6870
* A builder which builds a {@link SamlServiceProvider}.
6971
*/
7072
public final class SamlServiceProviderBuilder {
73+
7174
private static final Logger logger = LoggerFactory.getLogger(SamlServiceProviderBuilder.class);
7275

76+
private static final int DEFAULT_MIN_RELAY_STATE_MAX_LENGTH = 80;
77+
7378
private final List<SamlIdentityProviderConfigBuilder> idpConfigBuilders = new ArrayList<>();
7479
private final List<SamlAssertionConsumerConfigBuilder> acsConfigBuilders = new ArrayList<>();
7580

@@ -101,35 +106,11 @@ public final class SamlServiceProviderBuilder {
101106

102107
private boolean signatureRequired = true;
103108

104-
private SamlSingleSignOnHandler ssoHandler = new SamlSingleSignOnHandler() {
105-
@Override
106-
public CompletionStage<Void> beforeInitiatingSso(ServiceRequestContext ctx, HttpRequest req,
107-
MessageContext<AuthnRequest> message,
108-
SamlIdentityProviderConfig idpConfig) {
109-
final String requestedPath = req.path();
110-
if (requestedPath.length() <= 80) {
111-
// Relay the requested path by default.
112-
final SAMLBindingContext sub = message.getSubcontext(SAMLBindingContext.class, true);
113-
assert sub != null : "SAMLBindingContext";
114-
sub.setRelayState(requestedPath);
115-
}
116-
return UnmodifiableFuture.completedFuture(null);
117-
}
118-
119-
@Override
120-
public HttpResponse loginSucceeded(ServiceRequestContext ctx, AggregatedHttpRequest req,
121-
MessageContext<Response> message, @Nullable String sessionIndex,
122-
@Nullable String relayState) {
123-
return responseWithLocation(firstNonNull(relayState, "/"));
124-
}
109+
@Nullable
110+
private Integer relayStateMaxLength;
125111

126-
@Override
127-
public HttpResponse loginFailed(ServiceRequestContext ctx, AggregatedHttpRequest req,
128-
@Nullable MessageContext<Response> message, Throwable cause) {
129-
logger.warn("{} SAML SSO failed", ctx, cause);
130-
return responseWithLocation("/error");
131-
}
132-
};
112+
@Nullable
113+
private SamlSingleSignOnHandler ssoHandler;
133114

134115
private SamlSingleLogoutHandler sloHandler = new SamlSingleLogoutHandler() {
135116
@Override
@@ -263,13 +244,33 @@ public SamlServiceProviderBuilder requestIdManager(SamlRequestIdManager requestI
263244
}
264245

265246
/**
266-
* Sets a {@link SamlSingleSignOnHandler} which handles SAML messages for a single sign-on.
247+
* Sets a {@link SamlSingleSignOnHandler} which handles SAML messages for a single sign-on. If this is set,
248+
* {@link #relayStateMaxLength(int)} will be ignored so that the
249+
* {@link SamlSingleSignOnHandler#beforeInitiatingSso(ServiceRequestContext, HttpRequest,
250+
* MessageContext, SamlIdentityProviderConfig)}
251+
* is responsible for handling the {@code RelayState} parameter.
267252
*/
268253
public SamlServiceProviderBuilder ssoHandler(SamlSingleSignOnHandler ssoHandler) {
254+
checkState(relayStateMaxLength == null,
255+
"relayStateMaxLength() and ssoHandler() are mutually exclusive.");
269256
this.ssoHandler = requireNonNull(ssoHandler, "ssoHandler");
270257
return this;
271258
}
272259

260+
/**
261+
* Sets the maximum length of the {@code RelayState} parameter which is sent to an identity provider
262+
* and is returned with the {@code SAMLResponse} parameter. If the length of the {@code RelayState}
263+
* exceeds the specified value, the {@code RelayState} parameter will be ignored.
264+
* The value must be equal to or greater than {@value #DEFAULT_MIN_RELAY_STATE_MAX_LENGTH}.
265+
*/
266+
public SamlServiceProviderBuilder relayStateMaxLength(int maxLength) {
267+
checkState(ssoHandler == null, "relayStateMaxLength() and ssoHandler() are mutually exclusive.");
268+
checkArgument(maxLength >= DEFAULT_MIN_RELAY_STATE_MAX_LENGTH,
269+
"maxLength: %s (expected: >= %s)", maxLength, DEFAULT_MIN_RELAY_STATE_MAX_LENGTH);
270+
relayStateMaxLength = maxLength;
271+
return this;
272+
}
273+
273274
/**
274275
* Sets a {@link SamlSingleLogoutHandler} which handles SAML messages for a single sign-on.
275276
*/
@@ -447,6 +448,15 @@ public SamlServiceProvider build() {
447448
e);
448449
}
449450

451+
final SamlSingleSignOnHandler ssoHandler;
452+
if (this.ssoHandler != null) {
453+
ssoHandler = this.ssoHandler;
454+
} else {
455+
final int relayStateMaxLength = firstNonNull(this.relayStateMaxLength,
456+
DEFAULT_MIN_RELAY_STATE_MAX_LENGTH);
457+
ssoHandler = newDefaultSsoHandler(relayStateMaxLength);
458+
}
459+
450460
return new SamlServiceProvider(authorizer,
451461
entityId,
452462
hostname,
@@ -487,6 +497,41 @@ private static void validateSignatureAlgorithm(String signatureAlgorithm, Creden
487497
}
488498
}
489499

500+
private static SamlSingleSignOnHandler newDefaultSsoHandler(int relayStateMaxLength) {
501+
return new SamlSingleSignOnHandler() {
502+
@Override
503+
public CompletionStage<Void> beforeInitiatingSso(ServiceRequestContext ctx, HttpRequest req,
504+
MessageContext<AuthnRequest> message,
505+
SamlIdentityProviderConfig idpConfig) {
506+
final String requestedPath = req.path();
507+
if (requestedPath.length() <= relayStateMaxLength) {
508+
// Relay the requested path by default.
509+
final SAMLBindingContext sub = message.getSubcontext(SAMLBindingContext.class, true);
510+
assert sub != null : "SAMLBindingContext";
511+
sub.setRelayState(requestedPath);
512+
} else {
513+
logger.debug("requested path length ({}) exceeds the configured maximum length ({}).",
514+
requestedPath.length(), relayStateMaxLength);
515+
}
516+
return UnmodifiableFuture.completedFuture(null);
517+
}
518+
519+
@Override
520+
public HttpResponse loginSucceeded(ServiceRequestContext ctx, AggregatedHttpRequest req,
521+
MessageContext<Response> message, @Nullable String sessionIndex,
522+
@Nullable String relayState) {
523+
return responseWithLocation(firstNonNull(relayState, "/"));
524+
}
525+
526+
@Override
527+
public HttpResponse loginFailed(ServiceRequestContext ctx, AggregatedHttpRequest req,
528+
@Nullable MessageContext<Response> message, Throwable cause) {
529+
logger.warn("{} SAML SSO failed", ctx, cause);
530+
return responseWithLocation("/error");
531+
}
532+
};
533+
}
534+
490535
/**
491536
* An adapter for {@link CredentialResolver} which helps to resolve a {@link Credential} from
492537
* the specified {@code keyName}.

saml/src/test/java/com/linecorp/armeria/server/saml/SamlServiceProviderTest.java

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989

9090
import net.shibboleth.utilities.java.support.resolver.CriteriaSet;
9191

92+
import com.google.common.base.Strings;
9293
import com.google.common.collect.ImmutableMap;
9394

9495
import com.linecorp.armeria.client.WebClient;
@@ -276,8 +277,11 @@ static class CookieBasedSsoHandler implements SamlSingleSignOnHandler {
276277
public CompletionStage<Void> beforeInitiatingSso(ServiceRequestContext ctx, HttpRequest req,
277278
MessageContext<AuthnRequest> message,
278279
SamlIdentityProviderConfig idpConfig) {
279-
message.getSubcontext(SAMLBindingContext.class, true)
280-
.setRelayState(req.path());
280+
final SAMLBindingContext subcontext = message.getSubcontext(SAMLBindingContext.class, true);
281+
assert subcontext != null;
282+
if (req.path().length() <= 80) {
283+
subcontext.setRelayState(req.path());
284+
}
281285
return UnmodifiableFuture.completedFuture(null);
282286
}
283287

@@ -343,6 +347,21 @@ void shouldRespondAuthnRequest_HttpRedirect() throws Exception {
343347
.get(SIGNATURE_ALGORITHM)).isEqualTo(signatureAlgorithm);
344348
}
345349

350+
@Test
351+
void relayStateExceeding80BytesShouldNotBeSent() throws Exception {
352+
final String meaninglessQuery = Strings.repeat("12345678910", 10);
353+
354+
final AggregatedHttpResponse resp = client.get("/redirect?a=" + meaninglessQuery).aggregate().join();
355+
assertThat(resp.status()).isEqualTo(HttpStatus.FOUND);
356+
357+
final String location = resp.headers().get(HttpHeaderNames.LOCATION);
358+
final Pattern p = Pattern.compile(
359+
"http://idp\\.example\\.com/saml/sso/redirect\\?" +
360+
"SAMLRequest=([^&]+)&SigAlg=([^&]+)&Signature=(.+)$");
361+
assertThat(location).isNotNull();
362+
assertThat(p.matcher(location).matches()).isTrue();
363+
}
364+
346365
@Test
347366
void shouldRespondAuthnRequest_HttpPost() throws Exception {
348367
final AggregatedHttpResponse resp = client.get("/post").aggregate().join();

0 commit comments

Comments
 (0)