com.atlassian.connect.spring.internal.jwt.AbstractJwtReader Maven / Gradle / Ivy
package com.atlassian.connect.spring.internal.jwt;
import com.nimbusds.jose.Algorithm;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSObject;
import com.nimbusds.jose.JWSVerifier;
import com.nimbusds.jwt.JWTClaimsSet;
import java.text.ParseException;
import java.util.Calendar;
import java.util.Date;
import java.util.Set;
public abstract class AbstractJwtReader {
/**
* The JWT spec says that implementers "MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew".
* 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
* 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.
*/
private static final int TIME_CLAIM_LEEWAY_SECONDS = 30;
private static final String UNEXPECTED_TYPE_MESSAGE_PREFIX = "Unexpected type of JSON object member with key ";
private static final Set NUMERIC_CLAIM_NAMES = Set.of("exp", "iat", "nbf");
private final JWSVerifier verifier;
protected AbstractJwtReader(JWSVerifier verifier) {
this.verifier = verifier;
}
protected abstract Algorithm getSupportedAlgorithm();
/**
* Reads and verifies the given JWT.
*
* NOTE: If the JWT does not include the qsh
claim, verification will still succeed.
*
* @param jwt the serialized JWT
* @param queryStringHash the expected query-string hash
* @return the claims of the JWT, if it could be successfully parsed and verified
* @throws JwtParseException if the JWT could not be successfully parsed
* @throws JwtVerificationException if the JWT signature did not match
*/
public JWTClaimsSet readAndVerify(final String jwt, final String queryStringHash) throws JwtParseException, JwtVerificationException {
JWSObject jwsObject = verify(jwt);
JWTClaimsSet claims;
try {
claims = JWTClaimsSet.parse(jwsObject.getPayload().toString());
} catch (ParseException e) {
// if possible, provide a hint to the add-on developer
if (e.getMessage().startsWith(UNEXPECTED_TYPE_MESSAGE_PREFIX)) {
String claimName = e.getMessage().replace(UNEXPECTED_TYPE_MESSAGE_PREFIX, "").replace("\"", "");
if (NUMERIC_CLAIM_NAMES.contains(claimName)) {
throw new JwtInvalidClaimException(String.format("Expecting claim '%s' to be numeric but it is a string", claimName), e);
}
throw new JwtParseException("Perhaps a claim is of the wrong type (e.g. expecting integer but found string): " + e.getMessage(), e);
}
throw new JwtParseException(e);
}
if (claims.getIssueTime() == null || claims.getExpirationTime() == null) {
throw new JwtInvalidClaimException("'exp' and 'iat' are required claims. Atlassian JWT does not allow JWTs with " +
"unlimited lifetimes.");
}
Date now = new Date();
Calendar calendar = Calendar.getInstance();
calendar.setTime(now);
calendar.add(Calendar.SECOND, -TIME_CLAIM_LEEWAY_SECONDS);
Date nowMinusLeeway = calendar.getTime();
calendar.setTime(now);
calendar.add(Calendar.SECOND, TIME_CLAIM_LEEWAY_SECONDS);
Date nowPlusLeeway = calendar.getTime();
if (null != claims.getNotBeforeTime()) {
// sanity check: if the token is invalid before, on and after a given time then it is always invalid and the issuer has made a mistake
if (!claims.getExpirationTime().after(claims.getNotBeforeTime())) {
throw new JwtInvalidClaimException(String.format("The expiration time must be after the not-before time but exp=%s and nbf=%s", claims.getExpirationTime(), claims.getNotBeforeTime()));
}
if (claims.getNotBeforeTime().after(nowPlusLeeway)) {
throw new JwtTooEarlyException(claims.getNotBeforeTime(), now, TIME_CLAIM_LEEWAY_SECONDS);
}
}
if (claims.getExpirationTime().before(nowMinusLeeway)) {
throw new JwtExpiredException(claims.getExpirationTime(), now, TIME_CLAIM_LEEWAY_SECONDS);
}
if (queryStringHash != null) {
Object claim = claims.getClaim(HttpRequestCanonicalizer.QUERY_STRING_HASH_CLAIM_NAME);
if (claim != null && !queryStringHash.equals(claim)) {
throw new JwtInvalidClaimException(String.format("Expecting claim '%s' to have value '%s' but instead it has the value '%s'",
HttpRequestCanonicalizer.QUERY_STRING_HASH_CLAIM_NAME, queryStringHash, claim));
}
}
return claims;
}
private JWSObject verify(final String jwt) throws JwtParseException, JwtVerificationException {
try {
final JWSObject jwsObject = JWSObject.parse(jwt);
Algorithm algorithm = jwsObject.getHeader().getAlgorithm();
if (!getSupportedAlgorithm().equals(algorithm)) {
throw new JwtInvalidSigningAlgorithmException(String.format("Expected JWT to be signed with '%s' but it was signed with '%s' instead",
getSupportedAlgorithm(), algorithm));
}
if (!jwsObject.verify(verifier)) {
throw new JwtSignatureMismatchException(jwt);
}
return jwsObject;
} catch (ParseException e) {
throw new JwtParseException(e);
} catch (JOSEException e) {
throw new JwtSignatureMismatchException(e);
}
}
}