Skip to content

Commit ef54d36

Browse files
committed
Support custom validation in OidcLogoutAuthenticationProvider
- Similar to custom validation in OAuth2AuthorizationCodeRequestAuthenticationProvider - Closes gh-1693
1 parent 052a0a6 commit ef54d36

File tree

4 files changed

+298
-11
lines changed

4 files changed

+298
-11
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
* Copyright 2020-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.security.oauth2.server.authorization.oidc.authentication;
17+
18+
import java.util.Map;
19+
import java.util.function.Consumer;
20+
21+
import org.springframework.lang.Nullable;
22+
import org.springframework.security.core.Authentication;
23+
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
24+
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
25+
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthenticationContext;
26+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
27+
import org.springframework.util.Assert;
28+
29+
/**
30+
* An {@link OAuth2AuthenticationContext} that holds an
31+
* {@link OidcLogoutAuthenticationToken} and additional information and is used when
32+
* validating the OpenID Connect RP-Initiated Logout Request parameters.
33+
*
34+
* @author Daniel Garnier-Moiroux
35+
* @since 1.4
36+
* @see OAuth2AuthenticationContext
37+
* @see OidcLogoutAuthenticationToken
38+
* @see OidcLogoutAuthenticationProvider#setAuthenticationValidator(Consumer)
39+
*/
40+
public final class OidcLogoutAuthenticationContext implements OAuth2AuthenticationContext {
41+
42+
private final Map<Object, Object> context;
43+
44+
private OidcLogoutAuthenticationContext(Map<Object, Object> context) {
45+
this.context = context;
46+
}
47+
48+
@SuppressWarnings("unchecked")
49+
@Nullable
50+
@Override
51+
public <V> V get(Object key) {
52+
return hasKey(key) ? (V) this.context.get(key) : null;
53+
}
54+
55+
@Override
56+
public boolean hasKey(Object key) {
57+
Assert.notNull(key, "key cannot be null");
58+
return this.context.containsKey(key);
59+
}
60+
61+
/**
62+
* Returns the {@link RegisteredClient registered client}.
63+
* @return the {@link RegisteredClient}
64+
*/
65+
public RegisteredClient getRegisteredClient() {
66+
return get(RegisteredClient.class);
67+
}
68+
69+
/**
70+
* Returns the {@link OAuth2Authorization authorization request}.
71+
* @return the {@link OAuth2Authorization}
72+
*/
73+
public OAuth2Authorization getAuthorizationRequest() {
74+
return get(OAuth2Authorization.class);
75+
}
76+
77+
/**
78+
* Returns the {@link OidcIdToken id_token}.
79+
* @return the {@link OidcIdToken}
80+
*/
81+
public OidcIdToken getIdToken() {
82+
return get(OidcIdToken.class);
83+
}
84+
85+
/**
86+
* Constructs a new {@link Builder} with the provided
87+
* {@link OidcLogoutAuthenticationToken}.
88+
* @param authentication the {@link OidcLogoutAuthenticationToken}
89+
* @return the {@link Builder}
90+
*/
91+
public static Builder with(OidcLogoutAuthenticationToken authentication) {
92+
return new Builder(authentication);
93+
}
94+
95+
/**
96+
* A builder for {@link OidcLogoutAuthenticationContext}.
97+
*/
98+
public static final class Builder extends AbstractBuilder<OidcLogoutAuthenticationContext, Builder> {
99+
100+
private Builder(Authentication authentication) {
101+
super(authentication);
102+
}
103+
104+
/**
105+
* Sets the {@link RegisteredClient registered client}.
106+
* @param registeredClient the {@link RegisteredClient}
107+
* @return the {@link Builder} for further configuration
108+
*/
109+
public Builder registeredClient(RegisteredClient registeredClient) {
110+
return put(RegisteredClient.class, registeredClient);
111+
}
112+
113+
/**
114+
* Sets the {@link OAuth2Authorization registered client}.
115+
* @param authorization the {@link OAuth2Authorization}
116+
* @return the {@link Builder} for further configuration
117+
*/
118+
public Builder authorization(OAuth2Authorization authorization) {
119+
return put(OAuth2Authorization.class, authorization);
120+
}
121+
122+
/**
123+
* Sets the {@link OidcIdToken id_token}.
124+
* @param idToken the {@link OidcIdToken}
125+
* @return the {@link Builder} for further configuration
126+
*/
127+
public Builder idToken(OidcIdToken idToken) {
128+
return put(OidcIdToken.class, idToken);
129+
}
130+
131+
/**
132+
* Builds a new {@link OidcLogoutAuthenticationContext}.
133+
* @return the {@link OidcLogoutAuthenticationContext}
134+
*/
135+
@Override
136+
public OidcLogoutAuthenticationContext build() {
137+
return new OidcLogoutAuthenticationContext(getContext());
138+
}
139+
140+
}
141+
142+
}

oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationProvider.java

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2020-2023 the original author or authors.
2+
* Copyright 2020-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -21,6 +21,7 @@
2121
import java.security.Principal;
2222
import java.util.Base64;
2323
import java.util.List;
24+
import java.util.function.Consumer;
2425

2526
import org.apache.commons.logging.Log;
2627
import org.apache.commons.logging.LogFactory;
@@ -70,6 +71,8 @@ public final class OidcLogoutAuthenticationProvider implements AuthenticationPro
7071

7172
private final SessionRegistry sessionRegistry;
7273

74+
private Consumer<OidcLogoutAuthenticationContext> authenticationValidator = new OidcLogoutAuthenticationValidator();
75+
7376
/**
7477
* Constructs an {@code OidcLogoutAuthenticationProvider} using the provided
7578
* parameters.
@@ -118,19 +121,16 @@ public Authentication authenticate(Authentication authentication) throws Authent
118121
OidcIdToken idToken = authorizedIdToken.getToken();
119122

120123
// Validate client identity
121-
List<String> audClaim = idToken.getAudience();
122-
if (CollectionUtils.isEmpty(audClaim) || !audClaim.contains(registeredClient.getClientId())) {
123-
throwError(OAuth2ErrorCodes.INVALID_TOKEN, IdTokenClaimNames.AUD);
124-
}
125124
if (StringUtils.hasText(oidcLogoutAuthentication.getClientId())
126125
&& !oidcLogoutAuthentication.getClientId().equals(registeredClient.getClientId())) {
127126
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID);
128127
}
129-
if (StringUtils.hasText(oidcLogoutAuthentication.getPostLogoutRedirectUri())
130-
&& !registeredClient.getPostLogoutRedirectUris()
131-
.contains(oidcLogoutAuthentication.getPostLogoutRedirectUri())) {
132-
throwError(OAuth2ErrorCodes.INVALID_REQUEST, "post_logout_redirect_uri");
133-
}
128+
OidcLogoutAuthenticationContext context = OidcLogoutAuthenticationContext.with(oidcLogoutAuthentication)
129+
.registeredClient(registeredClient)
130+
.authorization(authorization)
131+
.idToken(idToken)
132+
.build();
133+
this.authenticationValidator.accept(context);
134134

135135
if (this.logger.isTraceEnabled()) {
136136
this.logger.trace("Validated logout request parameters");
@@ -182,6 +182,26 @@ public boolean supports(Class<?> authentication) {
182182
return OidcLogoutAuthenticationToken.class.isAssignableFrom(authentication);
183183
}
184184

185+
/**
186+
* Sets the {@code Consumer} providing access to the
187+
* {@link OidcLogoutAuthenticationContext} and is responsible for validating specific
188+
* Open ID Connect RP-Initiated Logout Request parameters associated in the
189+
* {@link OidcLogoutAuthenticationToken}. The default authentication validator is
190+
* {@link OidcLogoutAuthenticationValidator}.
191+
*
192+
* <p>
193+
* <b>NOTE:</b> The authentication validator MUST throw
194+
* {@link OAuth2AuthenticationException} if validation fails.
195+
* @param authenticationValidator the {@code Consumer} providing access to the
196+
* {@link OidcLogoutAuthenticationContext} and is responsible for validating specific
197+
* Open ID Connect RP-Initiated Logout Request parameters
198+
* @since 1.4
199+
*/
200+
public void setAuthenticationValidator(Consumer<OidcLogoutAuthenticationContext> authenticationValidator) {
201+
Assert.notNull(authenticationValidator, "authenticationValidator cannot be null");
202+
this.authenticationValidator = authenticationValidator;
203+
}
204+
185205
private SessionInformation findSessionInformation(Authentication principal, String sessionId) {
186206
List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(principal.getPrincipal(), true);
187207
SessionInformation sessionInformation = null;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* Copyright 2020-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.security.oauth2.server.authorization.oidc.authentication;
17+
18+
import java.util.List;
19+
import java.util.function.Consumer;
20+
21+
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
22+
import org.springframework.security.oauth2.core.OAuth2Error;
23+
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
24+
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
25+
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
26+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
27+
import org.springframework.util.CollectionUtils;
28+
import org.springframework.util.StringUtils;
29+
30+
/**
31+
* A {@code Consumer} providing access to the {@link OidcLogoutAuthenticationContext}
32+
* containing an {@link OidcLogoutAuthenticationToken} and is the default
33+
* {@link OidcLogoutAuthenticationProvider#setAuthenticationValidator(Consumer)
34+
* authentication validator} used for validating specific OpenID Connect RP-Initiated
35+
* Logout parameters used in the Authorization Code Grant.
36+
*
37+
* <p>
38+
* The default implementation first validates {@link OidcIdToken#getAudience()}, and then
39+
* {@link OidcLogoutAuthenticationToken#getPostLogoutRedirectUri()}. If validation fails,
40+
* an {@link OAuth2AuthenticationException} is thrown.
41+
*
42+
* @author Daniel Garnier-Moiroux
43+
* @since 1.4
44+
* @see OidcLogoutAuthenticationContext
45+
* @see OidcLogoutAuthenticationToken
46+
* @see OidcLogoutAuthenticationProvider#setAuthenticationValidator(Consumer)
47+
*/
48+
public final class OidcLogoutAuthenticationValidator implements Consumer<OidcLogoutAuthenticationContext> {
49+
50+
/**
51+
* The default validator for {@link OidcIdToken#getAudience()}.
52+
*/
53+
public static final Consumer<OidcLogoutAuthenticationContext> DEFAULT_AUDIENCE_VALIDATOR = OidcLogoutAuthenticationValidator::validateAudience;
54+
55+
/**
56+
* The default validator for
57+
* {@link OidcLogoutAuthenticationToken#getPostLogoutRedirectUri()}.
58+
*/
59+
public static final Consumer<OidcLogoutAuthenticationContext> DEFAULT_POST_LOGOUT_REDIRECT_URI_VALIDATOR = OidcLogoutAuthenticationValidator::validatePostLogoutRedirectUri;
60+
61+
private final Consumer<OidcLogoutAuthenticationContext> authenticationValidator = DEFAULT_AUDIENCE_VALIDATOR
62+
.andThen(DEFAULT_POST_LOGOUT_REDIRECT_URI_VALIDATOR);
63+
64+
@Override
65+
public void accept(OidcLogoutAuthenticationContext authenticationContext) {
66+
this.authenticationValidator.accept(authenticationContext);
67+
}
68+
69+
private static void validatePostLogoutRedirectUri(OidcLogoutAuthenticationContext authenticationContext) {
70+
OidcLogoutAuthenticationToken oidcLogoutAuthentication = authenticationContext.getAuthentication();
71+
RegisteredClient registeredClient = authenticationContext.getRegisteredClient();
72+
if (StringUtils.hasText(oidcLogoutAuthentication.getPostLogoutRedirectUri())
73+
&& !registeredClient.getPostLogoutRedirectUris()
74+
.contains(oidcLogoutAuthentication.getPostLogoutRedirectUri())) {
75+
throwError(OAuth2ErrorCodes.INVALID_REQUEST, "post_logout_redirect_uri");
76+
}
77+
}
78+
79+
private static void validateAudience(OidcLogoutAuthenticationContext authenticationContext) {
80+
OidcIdToken idToken = authenticationContext.getIdToken();
81+
List<String> audClaim = idToken.getAudience();
82+
RegisteredClient registeredClient = authenticationContext.getRegisteredClient();
83+
if (CollectionUtils.isEmpty(audClaim) || !audClaim.contains(registeredClient.getClientId())) {
84+
throwError(OAuth2ErrorCodes.INVALID_TOKEN, IdTokenClaimNames.AUD);
85+
}
86+
}
87+
88+
private static void throwError(String errorCode, String parameterName) {
89+
OAuth2Error error = new OAuth2Error(errorCode, "OpenID Connect 1.0 Logout Request Parameter: " + parameterName,
90+
"https://openid.net/specs/openid-connect-rpinitiated-1_0.html#ValidationAndErrorHandling");
91+
throw new OAuth2AuthenticationException(error);
92+
}
93+
94+
}

oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationProviderTests.java

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2020-2023 the original author or authors.
2+
* Copyright 2020-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -24,6 +24,7 @@
2424
import java.util.Collections;
2525
import java.util.Date;
2626
import java.util.List;
27+
import java.util.function.Consumer;
2728

2829
import org.junit.jupiter.api.AfterEach;
2930
import org.junit.jupiter.api.BeforeEach;
@@ -53,6 +54,7 @@
5354
import static org.assertj.core.api.Assertions.assertThat;
5455
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
5556
import static org.assertj.core.api.Assertions.assertThatThrownBy;
57+
import static org.mockito.ArgumentMatchers.any;
5658
import static org.mockito.ArgumentMatchers.eq;
5759
import static org.mockito.BDDMockito.given;
5860
import static org.mockito.Mockito.mock;
@@ -314,6 +316,35 @@ public void authenticateWhenInvalidPostLogoutRedirectUriThenThrowOAuth2Authentic
314316
verify(this.registeredClientRepository).findById(eq(authorization.getRegisteredClientId()));
315317
}
316318

319+
@Test
320+
void setAuthenticationValidatorWhenNullThenThrowIllegalArgumentException() {
321+
assertThatThrownBy(() -> this.authenticationProvider.setAuthenticationValidator(null))
322+
.isInstanceOf(IllegalArgumentException.class)
323+
.hasMessage("authenticationValidator cannot be null");
324+
}
325+
326+
@Test
327+
public void authenticateWhenCustomAuthenticationValidatorThenUsed() throws NoSuchAlgorithmException {
328+
TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");
329+
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
330+
String sessionId = "session-1";
331+
OidcIdToken idToken = OidcIdToken.withTokenValue("id-token")
332+
.issuer("https://provider.com")
333+
.subject(principal.getName())
334+
.audience(Collections.singleton(registeredClient.getClientId()))
335+
.issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS))
336+
.expiresAt(Instant.now().plusSeconds(60).truncatedTo(ChronoUnit.MILLIS))
337+
.claim("sid", createHash(sessionId))
338+
.build();
339+
340+
@SuppressWarnings("unchecked")
341+
Consumer<OidcLogoutAuthenticationContext> authenticationValidator = mock(Consumer.class);
342+
this.authenticationProvider.setAuthenticationValidator(authenticationValidator);
343+
344+
authenticateValidIdToken(principal, registeredClient, sessionId, idToken);
345+
verify(authenticationValidator).accept(any());
346+
}
347+
317348
@Test
318349
public void authenticateWhenMissingSubThenThrowOAuth2AuthenticationException() {
319350
TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");

0 commit comments

Comments
 (0)