Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,38 @@ public void requestWhenClientConfigurationRequestAuthorizedThenClientRegistratio
assertThat(clientConfigurationResponse.getRegistrationAccessToken()).isNull();
}

@Test
public void requestWhenClientRegistersWithBackChannelLogoutThenReturnedInClientConfiguration() throws Exception {
this.spring.register(AuthorizationServerConfiguration.class).autowire();

// @formatter:off
OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
.clientName("client-name")
.redirectUri("https://client.example.com")
.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
.backChannelLogoutUri("https://client.example.com/logout/connect/back-channel")
.backChannelLogoutSessionRequired(true)
.build();
// @formatter:on

OidcClientRegistration clientRegistrationResponse = registerClient(clientRegistration);
assertThat(clientRegistrationResponse.getBackChannelLogoutUri().toString())
.isEqualTo("https://client.example.com/logout/connect/back-channel");
assertThat(clientRegistrationResponse.isBackChannelLogoutSessionRequired()).isTrue();

HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setBearerAuth(clientRegistrationResponse.getRegistrationAccessToken());
MvcResult mvcResult = this.mvc
.perform(get(clientRegistrationResponse.getRegistrationClientUrl().toURI()).headers(httpHeaders))
.andExpect(status().isOk())
.andReturn();
OidcClientRegistration clientConfigurationResponse = readClientRegistrationResponse(mvcResult.getResponse());

assertThat(clientConfigurationResponse.getBackChannelLogoutUri().toString())
.isEqualTo("https://client.example.com/logout/connect/back-channel");
assertThat(clientConfigurationResponse.isBackChannelLogoutSessionRequired()).isTrue();
}

@Test
public void requestWhenClientRegistrationEndpointCustomizedThenUsed() throws Exception {
this.spring.register(CustomClientRegistrationConfiguration.class).autowire();
Expand Down Expand Up @@ -622,6 +654,19 @@ public void requestWhenHttpJwkSetUriThenBadRequest() throws Exception {
""")).isEqualTo(HttpStatus.BAD_REQUEST.value());
}

@Test
public void requestWhenHttpBackChannelLogoutUriThenBadRequest() throws Exception {
this.spring.register(DefaultValidatorConfiguration.class).autowire();
assertThat(requestWhenInvalidClientMetadataThenBadRequest("""
{
"client_name": "client-name",
"redirect_uris": ["https://client.example.com"],
"grant_types": ["authorization_code"],
"backchannel_logout_uri": "http://169.254.169.254/logout"
}
""")).isEqualTo(HttpStatus.BAD_REQUEST.value());
}

@Test
public void requestWhenArbitraryScopeThenBadRequest() throws Exception {
this.spring.register(DefaultValidatorConfiguration.class).autowire();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ public final class ClientSettings extends AbstractSettings {

public String getX509CertificateSubjectDN() ... <5>

public String getBackChannelLogoutUri() ... <6>

public boolean isBackChannelLogoutSessionRequired() ... <7>

...

}
Expand All @@ -122,6 +126,8 @@ public final class ClientSettings extends AbstractSettings {
<3> `getJwkSetUrl()`: The `URL` for the client's JSON Web Key Set. Used for `private_key_jwt`, `self_signed_tls_client_auth` and `client_secret_jwt` client authentication methods.
<4> `getTokenEndpointAuthenticationSigningAlgorithm()`: The `JwsAlgorithm` that must be used for signing the JWT used to authenticate the client at the Token Endpoint for `private_key_jwt` and `client_secret_jwt` authentication methods.
<5> `getX509CertificateSubjectDN()`: The expected subject distinguished name associated to the client `X509Certificate` received during client authentication when using the `tls_client_auth` method.
<6> `getBackChannelLogoutUri()`: The `URL` that the client exposes to receive a Logout Token, as defined by https://openid.net/specs/openid-connect-backchannel-1_0.html[OpenID Connect Back-Channel Logout 1.0].
<7> `isBackChannelLogoutSessionRequired()`: If `true`, the client requires a `sid` (session ID) claim in the Logout Token. The default is `false`.

[NOTE]
https://datatracker.ietf.org/doc/html/rfc7636[Proof Key for Code Exchange (PKCE)] is enabled by default for all clients using the Authorization Code grant. To disable PKCE, set `requireProofKey` to `false`.
Expand Down
1 change: 1 addition & 0 deletions docs/modules/ROOT/pages/whats-new.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@
== OAuth 2.0

* https://github.com/spring-projects/spring-security/pull/18895[gh-18895] - Add `authenticationSuccessHandler` to the Reactive Resource Server DSL
* https://github.com/spring-projects/spring-security/issues/18296[gh-18296] - Add OpenID Connect Back-Channel Logout client and provider metadata to the Authorization Server

Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ private void acceptClaimValues(String name, Consumer<List<String>> valuesConsume
valuesConsumer.accept(values);
}

private static void validateURL(Object url, String errorMessage) {
protected static void validateURL(Object url, String errorMessage) {
if (URL.class.isAssignableFrom(url.getClass())) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,29 @@ public interface OidcClientMetadataClaimAccessor extends OAuth2ClientMetadataCla
return getClaimAsString(OidcClientMetadataClaimNames.ID_TOKEN_SIGNED_RESPONSE_ALG);
}

/**
* Returns the {@code URL} that will cause the Client to log itself out when sent a
* Logout Token by the OpenID Provider {@code (backchannel_logout_uri)}.
* @return the {@code URL} that will cause the Client to log itself out when sent a
* Logout Token, or {@code null} if not set
* @since 7.2
*/
default @Nullable URL getBackChannelLogoutUri() {
return getClaimAsURL(OidcClientMetadataClaimNames.BACKCHANNEL_LOGOUT_URI);
}

/**
* Returns {@code true} if the Client requires that a {@code sid} (session ID) Claim
* be included in the Logout Token {@code (backchannel_logout_session_required)}. The
* default is {@code false}.
* @return {@code true} if the Client requires a {@code sid} Claim in the Logout
* Token, {@code false} otherwise
* @since 7.2
*/
default boolean isBackChannelLogoutSessionRequired() {
return Boolean.TRUE.equals(getClaimAsBoolean(OidcClientMetadataClaimNames.BACKCHANNEL_LOGOUT_SESSION_REQUIRED));
}

/**
* Returns the Registration Access Token that can be used at the Client Configuration
* Endpoint.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
* @see <a target="_blank" href=
* "https://openid.net/specs/openid-connect-rpinitiated-1_0.html#ClientMetadata">3.1.
* Client Registration Metadata</a>
* @see <a target="_blank" href=
* "https://openid.net/specs/openid-connect-backchannel-1_0.html#BCRegistration">2.2.
* Indicating RP Support for Back-Channel Logout</a>
*/
public final class OidcClientMetadataClaimNames extends OAuth2ClientMetadataClaimNames {

Expand All @@ -62,6 +65,22 @@ public final class OidcClientMetadataClaimNames extends OAuth2ClientMetadataClai
*/
public static final String ID_TOKEN_SIGNED_RESPONSE_ALG = "id_token_signed_response_alg";

/**
* {@code backchannel_logout_uri} - the {@code URL} that will cause the Client to log
* itself out when sent a Logout Token by the OpenID Provider.
* @since 7.2
*/
public static final String BACKCHANNEL_LOGOUT_URI = "backchannel_logout_uri";

/**
* {@code backchannel_logout_session_required} - {@code true} if the Client requires
* that a {@code sid} (session ID) Claim be included in the Logout Token to identify
* the Client session with the OpenID Provider when the {@code backchannel_logout_uri}
* is used.
* @since 7.2
*/
public static final String BACKCHANNEL_LOGOUT_SESSION_REQUIRED = "backchannel_logout_session_required";

/**
* {@code registration_access_token} - the Registration Access Token that can be used
* at the Client Configuration Endpoint.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,31 @@ public Builder idTokenSignedResponseAlgorithm(String idTokenSignedResponseAlgori
return claim(OidcClientMetadataClaimNames.ID_TOKEN_SIGNED_RESPONSE_ALG, idTokenSignedResponseAlgorithm);
}

/**
* Sets the {@code URL} that will cause the Client to log itself out when sent a
* Logout Token by the OpenID Provider, OPTIONAL.
* @param backChannelLogoutUri the {@code URL} that will cause the Client to log
* itself out when sent a Logout Token
* @return the {@link Builder} for further configuration
* @since 7.2
*/
public Builder backChannelLogoutUri(String backChannelLogoutUri) {
return claim(OidcClientMetadataClaimNames.BACKCHANNEL_LOGOUT_URI, backChannelLogoutUri);
}

/**
* Set to {@code true} if the Client requires that a {@code sid} (session ID)
* Claim be included in the Logout Token, OPTIONAL.
* @param backChannelLogoutSessionRequired {@code true} if the Client requires a
* {@code sid} Claim in the Logout Token
* @return the {@link Builder} for further configuration
* @since 7.2
*/
public Builder backChannelLogoutSessionRequired(boolean backChannelLogoutSessionRequired) {
return claim(OidcClientMetadataClaimNames.BACKCHANNEL_LOGOUT_SESSION_REQUIRED,
backChannelLogoutSessionRequired);
}

/**
* Sets the Registration Access Token that can be used at the Client Configuration
* Endpoint, OPTIONAL.
Expand Down Expand Up @@ -188,6 +213,10 @@ protected void validate() {
Assert.notEmpty((List<?>) getClaims().get(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS),
"post_logout_redirect_uris cannot be empty");
}
if (getClaims().get(OidcClientMetadataClaimNames.BACKCHANNEL_LOGOUT_URI) != null) {
validateURL(getClaims().get(OidcClientMetadataClaimNames.BACKCHANNEL_LOGOUT_URI),
"backchannel_logout_uri must be a valid URL");
}
}

@SuppressWarnings("unchecked")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,31 @@ public Builder endSessionEndpoint(String endSessionEndpoint) {
return claim(OidcProviderMetadataClaimNames.END_SESSION_ENDPOINT, endSessionEndpoint);
}

/**
* Use this {@code backchannel_logout_supported} in the resulting
* {@link OidcProviderConfiguration}, OPTIONAL.
* @param backChannelLogoutSupported {@code true} if the OpenID Provider supports
* back-channel logout
* @return the {@link Builder} for further configuration
* @since 7.2
*/
public Builder backChannelLogoutSupported(boolean backChannelLogoutSupported) {
return claim(OidcProviderMetadataClaimNames.BACKCHANNEL_LOGOUT_SUPPORTED, backChannelLogoutSupported);
}

/**
* Use this {@code backchannel_logout_session_supported} in the resulting
* {@link OidcProviderConfiguration}, OPTIONAL.
* @param backChannelLogoutSessionSupported {@code true} if the OpenID Provider
* can pass a {@code sid} Claim in the Logout Token
* @return the {@link Builder} for further configuration
* @since 7.2
*/
public Builder backChannelLogoutSessionSupported(boolean backChannelLogoutSessionSupported) {
return claim(OidcProviderMetadataClaimNames.BACKCHANNEL_LOGOUT_SESSION_SUPPORTED,
backChannelLogoutSessionSupported);
}

/**
* Validate the claims and build the {@link OidcProviderConfiguration}.
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,28 @@ default URL getEndSessionEndpoint() {
return endSessionEndpoint;
}

/**
* Returns {@code true} if the OpenID Provider supports back-channel logout
* {@code (backchannel_logout_supported)}. The default is {@code false}.
* @return {@code true} if the OpenID Provider supports back-channel logout,
* {@code false} otherwise
* @since 7.2
*/
default boolean isBackChannelLogoutSupported() {
return Boolean.TRUE.equals(getClaimAsBoolean(OidcProviderMetadataClaimNames.BACKCHANNEL_LOGOUT_SUPPORTED));
}

/**
* Returns {@code true} if the OpenID Provider can pass a {@code sid} (session ID)
* Claim in the Logout Token {@code (backchannel_logout_session_supported)}. The
* default is {@code false}.
* @return {@code true} if the OpenID Provider can pass a {@code sid} Claim in the
* Logout Token, {@code false} otherwise
* @since 7.2
*/
default boolean isBackChannelLogoutSessionSupported() {
return Boolean.TRUE
.equals(getClaimAsBoolean(OidcProviderMetadataClaimNames.BACKCHANNEL_LOGOUT_SESSION_SUPPORTED));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
* @see <a target="_blank" href=
* "https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata">3. OpenID
* Provider Metadata</a>
* @see <a target="_blank" href=
* "https://openid.net/specs/openid-connect-backchannel-1_0.html#BCSupport">2.1.
* Indicating OP Support for Back-Channel Logout</a>
*/
public final class OidcProviderMetadataClaimNames extends OAuth2AuthorizationServerMetadataClaimNames {

Expand All @@ -57,6 +60,21 @@ public final class OidcProviderMetadataClaimNames extends OAuth2AuthorizationSer
*/
public static final String END_SESSION_ENDPOINT = "end_session_endpoint";

/**
* {@code backchannel_logout_supported} - {@code true} if the OpenID Provider supports
* back-channel logout.
* @since 7.2
*/
public static final String BACKCHANNEL_LOGOUT_SUPPORTED = "backchannel_logout_supported";

/**
* {@code backchannel_logout_session_supported} - {@code true} if the OpenID Provider
* can pass a {@code sid} (session ID) Claim in the Logout Token to identify the
* Client session with the OpenID Provider.
* @since 7.2
*/
public static final String BACKCHANNEL_LOGOUT_SESSION_SUPPORTED = "backchannel_logout_session_supported";

private OidcProviderMetadataClaimNames() {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
* <p>
* The default implementation validates {@link OidcClientRegistration#getRedirectUris()
* redirect_uris}, {@link OidcClientRegistration#getPostLogoutRedirectUris()
* post_logout_redirect_uris}, {@link OidcClientRegistration#getJwkSetUrl() jwks_uri}, and
* post_logout_redirect_uris}, {@link OidcClientRegistration#getJwkSetUrl() jwks_uri},
* {@link OidcClientRegistration#getBackChannelLogoutUri() backchannel_logout_uri}, and
* {@link OidcClientRegistration#getScopes() scope}. If validation fails, an
* {@link OAuth2AuthenticationException} is thrown.
*
Expand Down Expand Up @@ -117,6 +118,14 @@ public final class OidcClientRegistrationAuthenticationValidator
*/
public static final Consumer<OidcClientRegistrationAuthenticationContext> SIMPLE_JWK_SET_URI_VALIDATOR = OidcClientRegistrationAuthenticationValidator::validateJwkSetUriSimple;

/**
* The default validator for {@link OidcClientRegistration#getBackChannelLogoutUri()
* backchannel_logout_uri}. Rejects URIs that contain a fragment or do not use the
* {@code https} scheme.
* @since 7.2
*/
public static final Consumer<OidcClientRegistrationAuthenticationContext> DEFAULT_BACK_CHANNEL_LOGOUT_URI_VALIDATOR = OidcClientRegistrationAuthenticationValidator::validateBackChannelLogoutUri;

/**
* The default validator for {@link OidcClientRegistration#getScopes() scope}. Rejects
* any request that includes a non-empty scope value. Deployers that need to accept
Expand All @@ -135,6 +144,7 @@ public final class OidcClientRegistrationAuthenticationValidator
private final Consumer<OidcClientRegistrationAuthenticationContext> authenticationValidator = DEFAULT_REDIRECT_URI_VALIDATOR
.andThen(DEFAULT_POST_LOGOUT_REDIRECT_URI_VALIDATOR)
.andThen(DEFAULT_JWK_SET_URI_VALIDATOR)
.andThen(DEFAULT_BACK_CHANNEL_LOGOUT_URI_VALIDATOR)
.andThen(DEFAULT_SCOPE_VALIDATOR);

@Override
Expand Down Expand Up @@ -272,6 +282,31 @@ private static void validateJwkSetUriSimple(OidcClientRegistrationAuthentication
// No validation. Preserves prior behavior.
}

private static void validateBackChannelLogoutUri(
OidcClientRegistrationAuthenticationContext authenticationContext) {
OidcClientRegistrationAuthenticationToken clientRegistrationAuthentication = authenticationContext
.getAuthentication();
Assert.notNull(clientRegistrationAuthentication.getClientRegistration(), "clientRegistration cannot be null");
URL backChannelLogoutUri = clientRegistrationAuthentication.getClientRegistration().getBackChannelLogoutUri();
if (backChannelLogoutUri == null) {
return;
}
if (!"https".equalsIgnoreCase(backChannelLogoutUri.getProtocol())) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(LogMessage.format("Invalid request: backchannel_logout_uri does not use https ('%s')",
backChannelLogoutUri));
}
throw createException("invalid_client_metadata", OidcClientMetadataClaimNames.BACKCHANNEL_LOGOUT_URI);
}
if (backChannelLogoutUri.getRef() != null) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(LogMessage.format("Invalid request: backchannel_logout_uri contains a fragment ('%s')",
backChannelLogoutUri));
}
throw createException("invalid_client_metadata", OidcClientMetadataClaimNames.BACKCHANNEL_LOGOUT_URI);
}
}

private static void validateScope(OidcClientRegistrationAuthenticationContext authenticationContext) {
OidcClientRegistrationAuthenticationToken clientRegistrationAuthentication = authenticationContext
.getAuthentication();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,13 @@ else if (ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue().equals(clientRegi
clientSettingsBuilder.jwkSetUrl(jwkSetUrl.toString());
}

URL backChannelLogoutUri = clientRegistration.getBackChannelLogoutUri();
if (backChannelLogoutUri != null) {
clientSettingsBuilder
.backChannelLogoutUri(backChannelLogoutUri.toString())
.backChannelLogoutSessionRequired(clientRegistration.isBackChannelLogoutSessionRequired());
}

builder
.clientSettings(clientSettingsBuilder.build())
.tokenSettings(TokenSettings.builder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ public OidcClientRegistration convert(RegisteredClient registeredClient) {
builder.tokenEndpointAuthenticationSigningAlgorithm(clientSettings.getTokenEndpointAuthenticationSigningAlgorithm().getName());
}

if (clientSettings.getBackChannelLogoutUri() != null) {
builder
.backChannelLogoutUri(clientSettings.getBackChannelLogoutUri())
.backChannelLogoutSessionRequired(clientSettings.isBackChannelLogoutSessionRequired());
}

return builder.build();
// @formatter:on
}
Expand Down
Loading