Skip to content

Improve error messages in OidcIdTokenValidator #6349

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
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
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2018 the original author or authors.
* Copyright 2002-2019 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.
Expand Down Expand Up @@ -27,7 +27,10 @@

import java.net.URL;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
* An {@link OAuth2TokenValidator} responsible for
Expand All @@ -41,7 +44,6 @@
* @see <a target="_blank" href="http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation">ID Token Validation</a>
*/
public final class OidcIdTokenValidator implements OAuth2TokenValidator<Jwt> {
private static final OAuth2Error INVALID_ID_TOKEN_ERROR = new OAuth2Error("invalid_id_token");
private final ClientRegistration clientRegistration;

public OidcIdTokenValidator(ClientRegistration clientRegistration) {
Expand All @@ -53,27 +55,10 @@ public OidcIdTokenValidator(ClientRegistration clientRegistration) {
public OAuth2TokenValidatorResult validate(Jwt idToken) {
// 3.1.3.7 ID Token Validation
// http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
Map<String, Object> invalidClaims = validateRequiredClaims(idToken);

// Validate REQUIRED Claims
URL issuer = idToken.getIssuer();
if (issuer == null) {
return invalidIdToken();
}
String subject = idToken.getSubject();
if (subject == null) {
return invalidIdToken();
}
List<String> audience = idToken.getAudience();
if (CollectionUtils.isEmpty(audience)) {
return invalidIdToken();
}
Instant expiresAt = idToken.getExpiresAt();
if (expiresAt == null) {
return invalidIdToken();
}
Instant issuedAt = idToken.getIssuedAt();
if (issuedAt == null) {
return invalidIdToken();
if (!invalidClaims.isEmpty()){
return OAuth2TokenValidatorResult.failure(invalidIdToken(invalidClaims));
}

// 2. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
Expand All @@ -85,21 +70,21 @@ public OAuth2TokenValidatorResult validate(Jwt idToken) {
// 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(this.clientRegistration.getClientId())) {
return invalidIdToken();
if (!idToken.getAudience().contains(this.clientRegistration.getClientId())) {
invalidClaims.put(IdTokenClaimNames.AUD, idToken.getAudience());
}

// 4. If the ID Token contains multiple audiences,
// the Client SHOULD verify that an azp Claim is present.
String authorizedParty = idToken.getClaimAsString(IdTokenClaimNames.AZP);
if (audience.size() > 1 && authorizedParty == null) {
return invalidIdToken();
if (idToken.getAudience().size() > 1 && authorizedParty == null) {
invalidClaims.put(IdTokenClaimNames.AZP, authorizedParty);
}

// 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(this.clientRegistration.getClientId())) {
return invalidIdToken();
invalidClaims.put(IdTokenClaimNames.AZP, authorizedParty);
}

// 7. The alg value SHOULD be the default of RS256 or the algorithm sent by the Client
Expand All @@ -108,16 +93,16 @@ public OAuth2TokenValidatorResult validate(Jwt idToken) {

// 9. The current time MUST be before the time represented by the exp Claim.
Instant now = Instant.now();
if (!now.isBefore(expiresAt)) {
return invalidIdToken();
if (!now.isBefore(idToken.getExpiresAt())) {
invalidClaims.put(IdTokenClaimNames.EXP, idToken.getExpiresAt());
}

// 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)) {
return invalidIdToken();
if (idToken.getIssuedAt().isAfter(maxIssuedAt)) {
invalidClaims.put(IdTokenClaimNames.IAT, idToken.getIssuedAt());
}

// 11. If a nonce value was sent in the Authentication Request,
Expand All @@ -127,10 +112,45 @@ public OAuth2TokenValidatorResult validate(Jwt idToken) {
// The precise method for detecting replay attacks is Client specific.
// TODO Depends on gh-4442

if (!invalidClaims.isEmpty()) {
return OAuth2TokenValidatorResult.failure(invalidIdToken(invalidClaims));
}

return OAuth2TokenValidatorResult.success();
}

private static OAuth2TokenValidatorResult invalidIdToken() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's change the method signature to invalidIdToken(Map<String, Object> invalidClaims). This method would return one OAuth2Error with a generic message that lists the invalidClaims in comma-delimited format.

Copy link
Contributor Author

@raphaelDL raphaelDL Jan 8, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then, here I use the keys and join them with a comma to create an OAuth2Error like "Required claims were not found: issuer, iat".
What about the rest of the validations that are performed once we are sure that the required claims are present? Should we keep the message stating why the validation failed?

I submitted new changes... let me know what you think, as always I'm happy to make changes if needed.

Thank you

Copy link
Contributor

@jgrandja jgrandja Jan 8, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep the message generic.

For example:

If one of the required claims is not present than fail-fast and return this message:

The ID Token contains invalid claims: sub (null), aud (null)

NOTE: Check all required claims before failing fast

If all required claims are present than proceed with the rest of the validation and if there is a failure than display the same generic message:

The ID Token contains invalid claims: aud (invalid-aud), azp (invalid-azp)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok Joe, I submitted new changes

return OAuth2TokenValidatorResult.failure(INVALID_ID_TOKEN_ERROR);
private static OAuth2Error invalidIdToken(Map<String, Object> invalidClaims) {
Copy link
Contributor

@jgrandja jgrandja Jan 8, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove the methods requiredClaimError and invalidIdTokenError and build the OAuth2Error in this method. Please ensure the message text is same as mentioned in this comment.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, can you update the tests to assert on the OAuth2Error.description. And add a new that has at least 2 invalid claims present. Thanks!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I've added the changes :)

String claimsDetail = invalidClaims.entrySet().stream()
.map(it -> it.getKey()+ "("+it.getValue()+")")
.collect(Collectors.joining(", "));

return new OAuth2Error("invalid_id_token", "The ID Token contains invalid claims: "+claimsDetail, null);
}

private static Map<String, Object> validateRequiredClaims(Jwt idToken){
Map<String, Object> requiredClaims = new HashMap<>();

URL issuer = idToken.getIssuer();
if (issuer == null) {
requiredClaims.put(IdTokenClaimNames.ISS, issuer);
}
String subject = idToken.getSubject();
if (subject == null) {
requiredClaims.put(IdTokenClaimNames.SUB, subject);
}
List<String> audience = idToken.getAudience();
if (CollectionUtils.isEmpty(audience)) {
requiredClaims.put(IdTokenClaimNames.AUD, audience);
}
Instant expiresAt = idToken.getExpiresAt();
if (expiresAt == null) {
requiredClaims.put(IdTokenClaimNames.EXP, expiresAt);
}
Instant issuedAt = idToken.getIssuedAt();
if (issuedAt == null) {
requiredClaims.put(IdTokenClaimNames.IAT, issuedAt);
}

return requiredClaims;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2018 the original author or authors.
* Copyright 2002-2019 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.
Expand Down Expand Up @@ -64,62 +64,63 @@ public void validateIdTokenWhenIssuerNullThenHasErrors() {
this.claims.remove(IdTokenClaimNames.ISS);
assertThat(this.validateIdToken())
.hasSize(1)
.extracting(OAuth2Error::getErrorCode)
.contains("invalid_id_token");
.extracting(OAuth2Error::getDescription)
.allMatch(msg -> msg.contains(IdTokenClaimNames.ISS));

}

@Test
public void validateIdTokenWhenSubNullThenHasErrors() {
this.claims.remove(IdTokenClaimNames.SUB);
assertThat(this.validateIdToken())
.hasSize(1)
.extracting(OAuth2Error::getErrorCode)
.contains("invalid_id_token");
.extracting(OAuth2Error::getDescription)
.allMatch(msg -> msg.contains(IdTokenClaimNames.SUB));
}

@Test
public void validateIdTokenWhenAudNullThenHasErrors() {
this.claims.remove(IdTokenClaimNames.AUD);
assertThat(this.validateIdToken())
.hasSize(1)
.extracting(OAuth2Error::getErrorCode)
.contains("invalid_id_token");
.extracting(OAuth2Error::getDescription)
.allMatch(msg -> msg.contains(IdTokenClaimNames.AUD));
}

@Test
public void validateIdTokenWhenIssuedAtNullThenHasErrors() {
this.issuedAt = null;
assertThat(this.validateIdToken())
.hasSize(1)
.extracting(OAuth2Error::getErrorCode)
.contains("invalid_id_token");
.extracting(OAuth2Error::getDescription)
.allMatch(msg -> msg.contains(IdTokenClaimNames.IAT));
}

@Test
public void validateIdTokenWhenExpiresAtNullThenHasErrors() {
this.expiresAt = null;
assertThat(this.validateIdToken())
.hasSize(1)
.extracting(OAuth2Error::getErrorCode)
.contains("invalid_id_token");
.extracting(OAuth2Error::getDescription)
.allMatch(msg -> msg.contains(IdTokenClaimNames.EXP));
}

@Test
public void validateIdTokenWhenAudMultipleAndAzpNullThenHasErrors() {
this.claims.put(IdTokenClaimNames.AUD, Arrays.asList("client-id", "other"));
assertThat(this.validateIdToken())
.hasSize(1)
.extracting(OAuth2Error::getErrorCode)
.contains("invalid_id_token");
.extracting(OAuth2Error::getDescription)
.allMatch(msg -> msg.contains(IdTokenClaimNames.AZP));
}

@Test
public void validateIdTokenWhenAzpNotClientIdThenHasErrors() {
this.claims.put(IdTokenClaimNames.AZP, "other");
assertThat(this.validateIdToken())
.hasSize(1)
.extracting(OAuth2Error::getErrorCode)
.contains("invalid_id_token");
.extracting(OAuth2Error::getDescription)
.allMatch(msg -> msg.contains(IdTokenClaimNames.AZP));
}

@Test
Expand All @@ -135,17 +136,17 @@ public void validateIdTokenWhenMultipleAudAzpNotClientIdThenHasErrors() {
this.claims.put(IdTokenClaimNames.AZP, "other-client");
assertThat(this.validateIdToken())
.hasSize(1)
.extracting(OAuth2Error::getErrorCode)
.contains("invalid_id_token");
.extracting(OAuth2Error::getDescription)
.allMatch(msg -> msg.contains(IdTokenClaimNames.AZP));
}

@Test
public void validateIdTokenWhenAudNotClientIdThenHasErrors() {
this.claims.put(IdTokenClaimNames.AUD, Collections.singletonList("other-client"));
assertThat(this.validateIdToken())
.hasSize(1)
.extracting(OAuth2Error::getErrorCode)
.contains("invalid_id_token");
.extracting(OAuth2Error::getDescription)
.allMatch(msg -> msg.contains(IdTokenClaimNames.AUD));
}

@Test
Expand All @@ -154,8 +155,8 @@ public void validateIdTokenWhenExpiredThenHasErrors() {
this.expiresAt = this.issuedAt.plus(Duration.ofSeconds(1));
assertThat(this.validateIdToken())
.hasSize(1)
.extracting(OAuth2Error::getErrorCode)
.contains("invalid_id_token");
.extracting(OAuth2Error::getDescription)
.allMatch(msg -> msg.contains(IdTokenClaimNames.EXP));
}

@Test
Expand All @@ -164,8 +165,8 @@ public void validateIdTokenWhenIssuedAtWayInFutureThenHasErrors() {
this.expiresAt = this.issuedAt.plus(Duration.ofSeconds(1));
assertThat(this.validateIdToken())
.hasSize(1)
.extracting(OAuth2Error::getErrorCode)
.contains("invalid_id_token");
.extracting(OAuth2Error::getDescription)
.allMatch(msg -> msg.contains(IdTokenClaimNames.IAT));
}

@Test
Expand All @@ -174,8 +175,34 @@ public void validateIdTokenWhenExpiresAtBeforeNowThenHasErrors() {
this.expiresAt = Instant.from(this.issuedAt).plusSeconds(5);
assertThat(this.validateIdToken())
.hasSize(1)
.extracting(OAuth2Error::getErrorCode)
.contains("invalid_id_token");
.extracting(OAuth2Error::getDescription)
.allMatch(msg -> msg.contains(IdTokenClaimNames.EXP));
}

@Test
public void validateIdTokenWhenMissingClaimsThenHasErrors() {
this.claims.remove(IdTokenClaimNames.SUB);
this.claims.remove(IdTokenClaimNames.AUD);
this.issuedAt = null;
this.expiresAt = null;
assertThat(this.validateIdToken())
.hasSize(1)
.extracting(OAuth2Error::getDescription)
.allMatch(msg -> msg.contains(IdTokenClaimNames.SUB))
.allMatch(msg -> msg.contains(IdTokenClaimNames.AUD))
.allMatch(msg -> msg.contains(IdTokenClaimNames.IAT))
.allMatch(msg -> msg.contains(IdTokenClaimNames.EXP));
}

@Test(expected = IllegalArgumentException.class)
public void validateIdTokenWhenNoClaimsThenHasErrors() {
this.claims.remove(IdTokenClaimNames.ISS);
this.claims.remove(IdTokenClaimNames.SUB);
this.claims.remove(IdTokenClaimNames.AUD);
this.issuedAt = null;
this.expiresAt = null;
assertThat(this.validateIdToken())
.hasSize(1);
}

private Collection<OAuth2Error> validateIdToken() {
Expand Down