View Javadoc

1   package com.atlassian.asap.core.validator;
2   
3   import com.atlassian.asap.api.Jwt;
4   import com.atlassian.asap.api.JwtClaims;
5   import com.atlassian.asap.api.exception.InvalidTokenException;
6   import com.atlassian.asap.core.exception.InvalidClaimException;
7   import com.atlassian.asap.core.exception.TokenExpiredException;
8   import com.atlassian.asap.core.exception.TokenTooEarlyException;
9   import org.apache.commons.lang3.StringUtils;
10  import org.slf4j.Logger;
11  import org.slf4j.LoggerFactory;
12  
13  import java.time.Clock;
14  import java.time.Duration;
15  import java.time.Instant;
16  import java.util.Collections;
17  import java.util.Objects;
18  import java.util.Optional;
19  import java.util.Set;
20  
21  
22  /**
23   * Validates the claims contained in a JWT.
24   */
25  public class JwtClaimsValidator {
26      /**
27       * The JWT spec says that implementers "MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew".
28       * Calculations of the current time for the purposes of accepting or rejecting time-based claims (e.g. "exp" and "nbf") will allow for the current time
29       * being plus or minus this leeway, resulting in some time-based claims that are marginally before or after the current time being accepted instead of rejected.
30       */
31      public static final Duration TIME_CLAIM_LEEWAY = Duration.ofSeconds(Long.parseLong(
32              System.getProperty("asap.resource.server.leeway.seconds", "30")));
33  
34      /**
35       * No matter what the claims say, the server should reject tokens that are too long-lived.
36       */
37      public static final Duration DEFAULT_MAX_LIFETIME = Duration.ofHours(1);
38  
39      private static final Logger logger = LoggerFactory.getLogger(JwtClaimsValidator.class);
40  
41      private final Clock clock;
42      private final Duration maxLifetime;
43  
44      public JwtClaimsValidator(Clock clock) {
45          this(clock, DEFAULT_MAX_LIFETIME);
46      }
47  
48      public JwtClaimsValidator(Clock clock, Duration maxTokenLifetime) {
49          this.clock = Objects.requireNonNull(clock);
50          this.maxLifetime = Objects.requireNonNull(maxTokenLifetime);
51      }
52  
53      /**
54       * Checks the validity of the claims contained in a JWT in a given authentication context.
55       *
56       * @param jwt                     a JWT token
57       * @param resourceServerAudiences the JWT token must be addressed to one of these audiences
58       * @throws InvalidTokenException if the claims are invalid or could not be verified
59       */
60      public void validate(Jwt jwt,
61                           Set<String> resourceServerAudiences)
62              throws InvalidTokenException {
63          final JwtClaims claims = jwt.getClaims();
64          final String issuer = claims.getIssuer();
65          final Set<String> audience = claims.getAudience();
66          final Instant issuedAt = claims.getIssuedAt();
67          final Instant expiry = claims.getExpiry();
68          final Optional<Instant> mayBeNotBefore = claims.getNotBefore();
69          final String keyId = jwt.getHeader().getKeyId();
70  
71          issuerValidation(issuer, keyId);
72          audienceValidation(audience, resourceServerAudiences);
73          formalTimeClaimsValidation(issuedAt, expiry, mayBeNotBefore);
74          relativeTimeValidation(issuedAt, expiry, mayBeNotBefore);
75          // This implementation does not currently validate that the JWT id has not been seen before
76      }
77  
78      private void issuerValidation(String issuer, String keyId)
79              throws InvalidClaimException {
80          // Since we use the issuer as a prefix, it cannot be blank
81          if (StringUtils.isBlank(issuer)) {
82              logger.debug("Rejecting blank issuer");
83              throw new InvalidClaimException(JwtClaims.RegisteredClaim.ISSUER, "Issuer cannot be blank");
84          }
85  
86          // The "iss" claim value must be the prefix of the key id. This is required to ensure that the issuer
87          // owns the key used to sign the token. In the future we may have other mechanisms to ensure that and
88          // we may be able to remove this restriction.
89          if (!keyId.startsWith(issuer + "/")) {
90              logger.debug("The issuer {} does not match the key id {}", issuer, keyId);
91              throw new InvalidClaimException(JwtClaims.RegisteredClaim.ISSUER, "The issuer claim does not match the key id");
92          }
93      }
94  
95      private void audienceValidation(Set<String> audience,
96                                      Set<String> resourceServerAudiences) throws InvalidClaimException {
97          // The audience must contain one of the audiences in this resource server
98          if (Collections.disjoint(audience, resourceServerAudiences)) {
99              logger.debug("Rejected unrecognised audience {}", audience);
100             throw new InvalidClaimException(JwtClaims.RegisteredClaim.AUDIENCE, "Unrecognised audience");
101         }
102     }
103 
104     /**
105      * Formal validation of the time claims that are not relative to the current time.
106      */
107     private void formalTimeClaimsValidation(Instant issuedAt, Instant expiry, Optional<Instant> mayBeNotBefore)
108             throws InvalidClaimException {
109         // The token must have been issued before its expiry
110         if (expiry.isBefore(issuedAt)) {
111             logger.debug("Expiry time {} set before issue time {}", expiry, issuedAt);
112             throw new InvalidClaimException(JwtClaims.RegisteredClaim.ISSUED_AT, "Expiry time set before issue time");
113         }
114 
115         // The lifetime of the tokens is limited
116         if (issuedAt.plus(maxLifetime).isBefore(expiry)) {
117             logger.debug("Token exceeds lifetime limit, issued at {} and expires at {}", issuedAt, expiry);
118             throw new InvalidClaimException(JwtClaims.RegisteredClaim.EXPIRY, "Token exceeds lifetime limit");
119         }
120 
121         if (mayBeNotBefore.isPresent()) {
122             Instant nbf = mayBeNotBefore.get();
123 
124             // Sanity check: the token must be valid at some point in time
125             if (nbf.isAfter(expiry)) {
126                 logger.debug("The expiry time {} must be after the not-before time {}", expiry, nbf);
127                 throw new InvalidClaimException(JwtClaims.RegisteredClaim.NOT_BEFORE, "The expiry time must be after the not-before time");
128             }
129 
130             // Reject tokens that were valid strictly before they were issued
131             if (nbf.isBefore(issuedAt)) {
132                 logger.debug("The token was valid since {} but was issued later at {}", nbf, issuedAt);
133                 throw new InvalidClaimException(JwtClaims.RegisteredClaim.NOT_BEFORE, "The token must not be valid before it was issued");
134             }
135         }
136     }
137 
138     /**
139      * Validations that are relative to the current time.
140      */
141     private void relativeTimeValidation(Instant issuedAt, Instant expiry, Optional<Instant> mayBeNotBefore)
142             throws TokenExpiredException, TokenTooEarlyException {
143         Instant now = Instant.now(clock);
144 
145         // Calculate leeways for the jwt processing
146         final Instant nowMinusLeeway = now.minus(TIME_CLAIM_LEEWAY);
147         final Instant nowPlusLeeway = now.plus(TIME_CLAIM_LEEWAY);
148 
149         // The token must not have expired (with some tolerance)
150         if (expiry.isBefore(nowMinusLeeway)) {
151             logger.info("Rejecting expired token, now={}, expiry={}, leeway={}", now, expiry, TIME_CLAIM_LEEWAY);
152             throw new TokenExpiredException(expiry, now);
153         }
154 
155         // If "nbf" claim is optional, and it defaults to the "iat" value if absent.
156         Instant effectiveNbf = mayBeNotBefore.orElse(issuedAt);
157         if (effectiveNbf.isAfter(nowPlusLeeway)) {
158             logger.info("Rejecting token that arrives too early, now={}, not before={}, leeway={}", now, effectiveNbf, TIME_CLAIM_LEEWAY);
159             throw new TokenTooEarlyException(effectiveNbf, now);
160         }
161     }
162 }