parent
adb8c60173
commit
3ddde473f2
|
@ -15,6 +15,10 @@
|
|||
*/
|
||||
package org.springframework.security.oauth2.client.oidc.authentication;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
|
@ -40,16 +44,8 @@ import org.springframework.security.oauth2.jwt.Jwt;
|
|||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoderJwkSupport;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.net.URL;
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* An implementation of an {@link AuthenticationProvider}
|
||||
* for the OpenID Connect Core 1.0 Authorization Code Grant Flow.
|
||||
|
@ -151,11 +147,7 @@ public class OidcAuthorizationCodeAuthenticationProvider implements Authenticati
|
|||
throw new OAuth2AuthenticationException(invalidIdTokenError, invalidIdTokenError.toString());
|
||||
}
|
||||
|
||||
JwtDecoder jwtDecoder = this.getJwtDecoder(clientRegistration);
|
||||
Jwt jwt = jwtDecoder.decode((String) accessTokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN));
|
||||
OidcIdToken idToken = new OidcIdToken(jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(), jwt.getClaims());
|
||||
|
||||
this.validateIdToken(idToken, clientRegistration);
|
||||
OidcIdToken idToken = createOidcToken(clientRegistration, accessTokenResponse);
|
||||
|
||||
OidcUser oidcUser = this.userService.loadUser(
|
||||
new OidcUserRequest(clientRegistration, accessTokenResponse.getAccessToken(), idToken));
|
||||
|
@ -191,15 +183,24 @@ public class OidcAuthorizationCodeAuthenticationProvider implements Authenticati
|
|||
return OAuth2LoginAuthenticationToken.class.isAssignableFrom(authentication);
|
||||
}
|
||||
|
||||
private OidcIdToken createOidcToken(ClientRegistration clientRegistration, OAuth2AccessTokenResponse accessTokenResponse) {
|
||||
JwtDecoder jwtDecoder = getJwtDecoder(clientRegistration);
|
||||
Jwt jwt = jwtDecoder.decode((String) accessTokenResponse.getAdditionalParameters().get(
|
||||
OidcParameterNames.ID_TOKEN));
|
||||
OidcIdToken idToken = new OidcIdToken(jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(), jwt.getClaims());
|
||||
OidcTokenValidator.validateIdToken(idToken, clientRegistration);
|
||||
return idToken;
|
||||
}
|
||||
|
||||
private JwtDecoder getJwtDecoder(ClientRegistration clientRegistration) {
|
||||
JwtDecoder jwtDecoder = this.jwtDecoders.get(clientRegistration.getRegistrationId());
|
||||
if (jwtDecoder == null) {
|
||||
if (!StringUtils.hasText(clientRegistration.getProviderDetails().getJwkSetUri())) {
|
||||
OAuth2Error oauth2Error = new OAuth2Error(
|
||||
MISSING_SIGNATURE_VERIFIER_ERROR_CODE,
|
||||
"Failed to find a Signature Verifier for Client Registration: '" +
|
||||
clientRegistration.getRegistrationId() + "'. Check to ensure you have configured the JwkSet URI.",
|
||||
null
|
||||
MISSING_SIGNATURE_VERIFIER_ERROR_CODE,
|
||||
"Failed to find a Signature Verifier for Client Registration: '" +
|
||||
clientRegistration.getRegistrationId() + "'. Check to ensure you have configured the JwkSet URI.",
|
||||
null
|
||||
);
|
||||
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
|
||||
}
|
||||
|
@ -208,88 +209,4 @@ public class OidcAuthorizationCodeAuthenticationProvider implements Authenticati
|
|||
}
|
||||
return jwtDecoder;
|
||||
}
|
||||
|
||||
private void validateIdToken(OidcIdToken idToken, ClientRegistration clientRegistration) {
|
||||
// 3.1.3.7 ID Token Validation
|
||||
// http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
|
||||
|
||||
// Validate REQUIRED Claims
|
||||
URL issuer = idToken.getIssuer();
|
||||
if (issuer == null) {
|
||||
this.throwInvalidIdTokenException();
|
||||
}
|
||||
String subject = idToken.getSubject();
|
||||
if (subject == null) {
|
||||
this.throwInvalidIdTokenException();
|
||||
}
|
||||
List<String> audience = idToken.getAudience();
|
||||
if (CollectionUtils.isEmpty(audience)) {
|
||||
this.throwInvalidIdTokenException();
|
||||
}
|
||||
Instant expiresAt = idToken.getExpiresAt();
|
||||
if (expiresAt == null) {
|
||||
this.throwInvalidIdTokenException();
|
||||
}
|
||||
Instant issuedAt = idToken.getIssuedAt();
|
||||
if (issuedAt == null) {
|
||||
this.throwInvalidIdTokenException();
|
||||
}
|
||||
|
||||
// 2. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
|
||||
// MUST exactly match the value of the iss (issuer) Claim.
|
||||
// TODO Depends on gh-4413
|
||||
|
||||
// 3. The Client MUST validate that the aud (audience) Claim contains its client_id value
|
||||
// registered at the Issuer identified by the iss (issuer) Claim as an audience.
|
||||
// The aud (audience) Claim MAY contain an array with more than one element.
|
||||
// The ID Token MUST be rejected if the ID Token does not list the Client as a valid audience,
|
||||
// or if it contains additional audiences not trusted by the Client.
|
||||
if (!audience.contains(clientRegistration.getClientId())) {
|
||||
this.throwInvalidIdTokenException();
|
||||
}
|
||||
|
||||
// 4. If the ID Token contains multiple audiences,
|
||||
// the Client SHOULD verify that an azp Claim is present.
|
||||
String authorizedParty = idToken.getAuthorizedParty();
|
||||
if (audience.size() > 1 && authorizedParty == null) {
|
||||
this.throwInvalidIdTokenException();
|
||||
}
|
||||
|
||||
// 5. If an azp (authorized party) Claim is present,
|
||||
// the Client SHOULD verify that its client_id is the Claim Value.
|
||||
if (authorizedParty != null && !authorizedParty.equals(clientRegistration.getClientId())) {
|
||||
this.throwInvalidIdTokenException();
|
||||
}
|
||||
|
||||
// 7. The alg value SHOULD be the default of RS256 or the algorithm sent by the Client
|
||||
// in the id_token_signed_response_alg parameter during Registration.
|
||||
// TODO Depends on gh-4413
|
||||
|
||||
// 9. The current time MUST be before the time represented by the exp Claim.
|
||||
Instant now = Instant.now();
|
||||
if (!now.isBefore(expiresAt)) {
|
||||
this.throwInvalidIdTokenException();
|
||||
}
|
||||
|
||||
// 10. The iat Claim can be used to reject tokens that were issued too far away from the current time,
|
||||
// limiting the amount of time that nonces need to be stored to prevent attacks.
|
||||
// The acceptable range is Client specific.
|
||||
Instant maxIssuedAt = Instant.now().plusSeconds(30);
|
||||
if (issuedAt.isAfter(maxIssuedAt)) {
|
||||
this.throwInvalidIdTokenException();
|
||||
}
|
||||
|
||||
// 11. If a nonce value was sent in the Authentication Request,
|
||||
// a nonce Claim MUST be present and its value checked to verify
|
||||
// that it is the same value as the one that was sent in the Authentication Request.
|
||||
// The Client SHOULD check the nonce value for replay attacks.
|
||||
// The precise method for detecting replay attacks is Client specific.
|
||||
// TODO Depends on gh-4442
|
||||
|
||||
}
|
||||
|
||||
private void throwInvalidIdTokenException() {
|
||||
OAuth2Error invalidIdTokenError = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE);
|
||||
throw new OAuth2AuthenticationException(invalidIdTokenError, invalidIdTokenError.toString());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* Copyright 2002-2018 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.oauth2.client.oidc.authentication;
|
||||
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
import java.net.URL;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author Rob Winch
|
||||
* @since 5.1
|
||||
*/
|
||||
final class OidcTokenValidator {
|
||||
private static final String INVALID_ID_TOKEN_ERROR_CODE = "invalid_id_token";
|
||||
|
||||
static void validateIdToken(OidcIdToken idToken, ClientRegistration clientRegistration) {
|
||||
// 3.1.3.7 ID Token Validation
|
||||
// http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
|
||||
|
||||
// Validate REQUIRED Claims
|
||||
URL issuer = idToken.getIssuer();
|
||||
if (issuer == null) {
|
||||
throwInvalidIdTokenException();
|
||||
}
|
||||
String subject = idToken.getSubject();
|
||||
if (subject == null) {
|
||||
throwInvalidIdTokenException();
|
||||
}
|
||||
List<String> audience = idToken.getAudience();
|
||||
if (CollectionUtils.isEmpty(audience)) {
|
||||
throwInvalidIdTokenException();
|
||||
}
|
||||
Instant expiresAt = idToken.getExpiresAt();
|
||||
if (expiresAt == null) {
|
||||
throwInvalidIdTokenException();
|
||||
}
|
||||
Instant issuedAt = idToken.getIssuedAt();
|
||||
if (issuedAt == null) {
|
||||
throwInvalidIdTokenException();
|
||||
}
|
||||
|
||||
// 2. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
|
||||
// MUST exactly match the value of the iss (issuer) Claim.
|
||||
// TODO Depends on gh-4413
|
||||
|
||||
// 3. The Client MUST validate that the aud (audience) Claim contains its client_id value
|
||||
// registered at the Issuer identified by the iss (issuer) Claim as an audience.
|
||||
// The aud (audience) Claim MAY contain an array with more than one element.
|
||||
// The ID Token MUST be rejected if the ID Token does not list the Client as a valid audience,
|
||||
// or if it contains additional audiences not trusted by the Client.
|
||||
if (!audience.contains(clientRegistration.getClientId())) {
|
||||
throwInvalidIdTokenException();
|
||||
}
|
||||
|
||||
// 4. If the ID Token contains multiple audiences,
|
||||
// the Client SHOULD verify that an azp Claim is present.
|
||||
String authorizedParty = idToken.getAuthorizedParty();
|
||||
if (audience.size() > 1 && authorizedParty == null) {
|
||||
throwInvalidIdTokenException();
|
||||
}
|
||||
|
||||
// 5. If an azp (authorized party) Claim is present,
|
||||
// the Client SHOULD verify that its client_id is the Claim Value.
|
||||
if (authorizedParty != null && !authorizedParty.equals(clientRegistration.getClientId())) {
|
||||
throwInvalidIdTokenException();
|
||||
}
|
||||
|
||||
// 7. The alg value SHOULD be the default of RS256 or the algorithm sent by the Client
|
||||
// in the id_token_signed_response_alg parameter during Registration.
|
||||
// TODO Depends on gh-4413
|
||||
|
||||
// 9. The current time MUST be before the time represented by the exp Claim.
|
||||
Instant now = Instant.now();
|
||||
if (!now.isBefore(expiresAt)) {
|
||||
throwInvalidIdTokenException();
|
||||
}
|
||||
|
||||
// 10. The iat Claim can be used to reject tokens that were issued too far away from the current time,
|
||||
// limiting the amount of time that nonces need to be stored to prevent attacks.
|
||||
// The acceptable range is Client specific.
|
||||
Instant maxIssuedAt = now.plusSeconds(30);
|
||||
if (issuedAt.isAfter(maxIssuedAt)) {
|
||||
throwInvalidIdTokenException();
|
||||
}
|
||||
|
||||
// 11. If a nonce value was sent in the Authentication Request,
|
||||
// a nonce Claim MUST be present and its value checked to verify
|
||||
// that it is the same value as the one that was sent in the Authentication Request.
|
||||
// The Client SHOULD check the nonce value for replay attacks.
|
||||
// The precise method for detecting replay attacks is Client specific.
|
||||
// TODO Depends on gh-4442
|
||||
|
||||
}
|
||||
|
||||
private static void throwInvalidIdTokenException() {
|
||||
OAuth2Error invalidIdTokenError = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE);
|
||||
throw new OAuth2AuthenticationException(invalidIdTokenError, invalidIdTokenError.toString());
|
||||
}
|
||||
|
||||
private OidcTokenValidator() {}
|
||||
}
|
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* Copyright 2002-2018 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.security.oauth2.client.oidc.authentication;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
/**
|
||||
* @author Rob Winch
|
||||
* @since 5.1
|
||||
*/
|
||||
public class OidcTokenValidatorTests {
|
||||
private ClientRegistration.Builder registration = ClientRegistration.withRegistrationId("client-foo-bar")
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
|
||||
.authorizationUri("https://example.com/oauth2/authorize")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.userInfoUri("https://example.com/users/me")
|
||||
.clientId("client-id")
|
||||
.clientName("client-name")
|
||||
.clientSecret("client-secret")
|
||||
.redirectUriTemplate("{baseUrl}/login/oauth2/code/{registrationId}")
|
||||
.scope("user")
|
||||
.tokenUri("https://example.com/oauth/access_token");
|
||||
|
||||
private Map<String, Object> claims = new HashMap<>();
|
||||
private Instant issuedAt = Instant.now();
|
||||
private Instant expiresAt = Instant.now().plusSeconds(3600);
|
||||
|
||||
@Before
|
||||
public void setup() {
|
||||
this.claims.put(IdTokenClaimNames.ISS, "https://issuer.example.com");
|
||||
this.claims.put(IdTokenClaimNames.SUB, "rob");
|
||||
this.claims.put(IdTokenClaimNames.AUD, Arrays.asList("client-id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateIdTokenWhenValidThenNoException() {
|
||||
assertThatCode(() -> validateIdToken())
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateIdTokenWhenIssuerNullThenException() {
|
||||
this.claims.remove(IdTokenClaimNames.ISS);
|
||||
assertThatCode(() -> validateIdToken())
|
||||
.isInstanceOf(OAuth2AuthenticationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateIdTokenWhenSubNullThenException() {
|
||||
this.claims.remove(IdTokenClaimNames.SUB);
|
||||
assertThatCode(() -> validateIdToken())
|
||||
.isInstanceOf(OAuth2AuthenticationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateIdTokenWhenAudNullThenException() {
|
||||
this.claims.remove(IdTokenClaimNames.AUD);
|
||||
assertThatCode(() -> validateIdToken())
|
||||
.isInstanceOf(OAuth2AuthenticationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateIdTokenWhenIssuedAtNullThenException() {
|
||||
this.issuedAt = null;
|
||||
assertThatCode(() -> validateIdToken())
|
||||
.isInstanceOf(OAuth2AuthenticationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateIdTokenWhenExpiresAtNullThenException() {
|
||||
this.expiresAt = null;
|
||||
assertThatCode(() -> validateIdToken())
|
||||
.isInstanceOf(OAuth2AuthenticationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateIdTokenWhenAudMultipleAndAzpNullThenException() {
|
||||
this.claims.put(IdTokenClaimNames.AUD, Arrays.asList("client-id", "other"));
|
||||
assertThatCode(() -> validateIdToken())
|
||||
.isInstanceOf(OAuth2AuthenticationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateIdTokenWhenAzpNotClientIdThenException() {
|
||||
this.claims.put(IdTokenClaimNames.AZP, "other");
|
||||
assertThatCode(() -> validateIdToken())
|
||||
.isInstanceOf(OAuth2AuthenticationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateIdTokenWhenMulitpleAudAzpClientIdThenNoException() {
|
||||
this.claims.put(IdTokenClaimNames.AUD, Arrays.asList("client-id", "other"));
|
||||
this.claims.put(IdTokenClaimNames.AZP, "client-id");
|
||||
assertThatCode(() -> validateIdToken())
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateIdTokenWhenExpiredThenException() {
|
||||
this.issuedAt = Instant.now().minus(Duration.ofMinutes(1));
|
||||
this.expiresAt = this.issuedAt.plus(Duration.ofSeconds(1));
|
||||
assertThatCode(() -> validateIdToken())
|
||||
.isInstanceOf(OAuth2AuthenticationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateIdTokenWhenIssuedAtWayInFutureThenException() {
|
||||
this.issuedAt = Instant.now().plus(Duration.ofMinutes(5));
|
||||
this.expiresAt = this.issuedAt.plus(Duration.ofSeconds(1));
|
||||
assertThatCode(() -> validateIdToken())
|
||||
.isInstanceOf(OAuth2AuthenticationException.class);
|
||||
}
|
||||
|
||||
private void validateIdToken() {
|
||||
OidcIdToken token = new OidcIdToken("token123", this.issuedAt, this.expiresAt, this.claims);
|
||||
OidcTokenValidator.validateIdToken(token, this.registration.build());
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue