From ca0e29d95f8e5a3184e12a930157fcba6fbeb006 Mon Sep 17 00:00:00 2001 From: Qynn Schwaab Date: Thu, 10 Oct 2024 13:59:01 -0400 Subject: [PATCH] feat: implement authenticateRequest and verifyToken helper methods --- .genignore | 3 +- build.gradle | 9 + .../clerk/backend_api/helpers/JwtHelper.java | 212 ---------------- .../helpers/jwks/AuthErrorReason.java | 30 +++ .../backend_api/helpers/jwks/AuthStatus.java | 19 ++ .../helpers/jwks/AuthenticateRequest.java | 98 ++++++++ .../jwks/AuthenticateRequestOptions.java | 158 ++++++++++++ .../backend_api/helpers/jwks/ErrorReason.java | 11 + .../helpers/jwks/RequestState.java | 80 ++++++ .../jwks/TokenVerificationErrorReason.java | 63 +++++ .../jwks/TokenVerificationException.java | 23 ++ .../backend_api/helpers/jwks/VerifyToken.java | 235 ++++++++++++++++++ .../helpers/jwks/VerifyTokenOptions.java | 204 +++++++++++++++ .../backend_api/helpers/JwtHelperTest.java | 172 ------------- .../helpers/jwks/AuthenticateRequestTest.java | 177 +++++++++++++ .../helpers/jwks/VerifyTokenTest.java | 233 +++++++++++++++++ 16 files changed, 1341 insertions(+), 386 deletions(-) delete mode 100644 src/main/java/com/clerk/backend_api/helpers/JwtHelper.java create mode 100644 src/main/java/com/clerk/backend_api/helpers/jwks/AuthErrorReason.java create mode 100644 src/main/java/com/clerk/backend_api/helpers/jwks/AuthStatus.java create mode 100644 src/main/java/com/clerk/backend_api/helpers/jwks/AuthenticateRequest.java create mode 100644 src/main/java/com/clerk/backend_api/helpers/jwks/AuthenticateRequestOptions.java create mode 100644 src/main/java/com/clerk/backend_api/helpers/jwks/ErrorReason.java create mode 100644 src/main/java/com/clerk/backend_api/helpers/jwks/RequestState.java create mode 100644 src/main/java/com/clerk/backend_api/helpers/jwks/TokenVerificationErrorReason.java create mode 100644 src/main/java/com/clerk/backend_api/helpers/jwks/TokenVerificationException.java create mode 100644 src/main/java/com/clerk/backend_api/helpers/jwks/VerifyToken.java create mode 100644 src/main/java/com/clerk/backend_api/helpers/jwks/VerifyTokenOptions.java delete mode 100644 src/test/java/com/clerk/backend_api/helpers/JwtHelperTest.java create mode 100644 src/test/java/com/clerk/backend_api/helpers/jwks/AuthenticateRequestTest.java create mode 100644 src/test/java/com/clerk/backend_api/helpers/jwks/VerifyTokenTest.java diff --git a/.genignore b/.genignore index 17a82cd..c8caf01 100644 --- a/.genignore +++ b/.genignore @@ -1,2 +1 @@ -src/main/java/com/clerk/backend_api/helpers/JwtHelper.java -src/test/java/com/clerk/backend_api/helpers/JwtHelperTest.java +src/main/java/com/clerk/backend_api/helpers/ \ No newline at end of file diff --git a/build.gradle b/build.gradle index d719ade..094deb2 100644 --- a/build.gradle +++ b/build.gradle @@ -158,3 +158,12 @@ dependencies { apply from: 'build-extras.gradle' + +test { + testLogging { + showStandardStreams true + showStackTraces true + showExceptions true + exceptionFormat "full" + } +} diff --git a/src/main/java/com/clerk/backend_api/helpers/JwtHelper.java b/src/main/java/com/clerk/backend_api/helpers/JwtHelper.java deleted file mode 100644 index 2d55f52..0000000 --- a/src/main/java/com/clerk/backend_api/helpers/JwtHelper.java +++ /dev/null @@ -1,212 +0,0 @@ -package com.clerk.backend_api.helpers; - -import java.security.Key; -import java.util.Collection; -import java.util.Date; -import java.util.HashSet; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.TimeUnit; - -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.JwtParser; -import io.jsonwebtoken.JwtParserBuilder; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.impl.security.ConstantKeyLocator; - -/** - * Helper methods for use with JSON Web Token (JWT). - */ -public final class JwtHelper { - - private JwtHelper() { - // prevent instantiation (this is a utility class) - } - - /** - * Verifies JWT according to the given options. If verified this method will - * return the claims otherwise it will throw. - * - * @param token token to verify - * @param options options associated with parsing and verifying the JWT - * @return Claims (being a map of properties with specialized accessors for - * standard claim properties) - * @throws {@link TokenVerificationException} if token does not verify. A - * causing exception if present should not be considered part of - * the public API (subject to change). - */ - public static Claims verifyJwt(String token, VerifyJwtOptions options) { - JwtParserBuilder builder = Jwts // - .parser() // - .clockSkewSeconds(options.clockSkewInMs() / 1000) // - .keyLocator(new ConstantKeyLocator(options.key(), null)); - - options.audience().ifPresent(a -> builder.requireAudience(a)); - - JwtParser parser = builder.build(); - - Claims payload; - - try { - // note that exp (expiration) and nbf (not before) are enforced by the parser - // so we don't have to make additional checks for them - // ExpiredJwtException, PrematureJwtException are thrown - - // the presence of a subject field is also enforced by the parser - // JwtException is thrown - - payload = parser.parseSignedClaims(token).getPayload(); - } catch (RuntimeException e) { - throw new TokenVerificationException(e.getMessage(), e); - } - - String azp = (String) payload.get("azp"); - if (azp != null && !options.authorizedParties.isEmpty()) { - if (!options.authorizedParties().contains(azp)) { - throw new TokenVerificationException("Invalid JWT Authorized party claim (azp) \"" + azp - + "\". Expected \"" + options.authorizedParties() + "\""); - } - } - - Date iat = payload.getIssuedAt(); - Date now = new Date(); - if (iat != null && iat.getTime() > now.getTime() + options.clockSkewInMs()) { - throw new TokenVerificationException("JWT issued-at-date claim (iat) is in the future. Issued at date: " - + iat + "; Current date: " + now + ";"); - } - return payload; - } - - @SuppressWarnings("serial") - public static final class TokenVerificationException extends RuntimeException { - - public TokenVerificationException(String message, Throwable cause) { - super(message, cause); - } - - public TokenVerificationException(String message) { - super(message); - } - - } - - public static final class VerifyJwtOptions { - - private static final long DEFAULT_CLOCK_SKEW_MS = 5000L; - - private final Optional audience; - private final Set authorizedParties; - private final long clockSkewInMs; - private final Key key; - - public VerifyJwtOptions( // - Optional audience, // - Set authorizedParties, // - Optional clockSkewInMs, // - Key key) { - checkNotNull(audience, "audience"); - checkNotNull(authorizedParties, "authorizedParties"); - checkNotNull(clockSkewInMs, "clockSkewInMs"); - checkNotNull(key, "key"); - this.audience = audience; - this.authorizedParties = authorizedParties; - this.clockSkewInMs = clockSkewInMs.orElse(DEFAULT_CLOCK_SKEW_MS); - this.key = key; - } - - public Key key() { - return key; - } - - public Optional audience() { - return audience; - } - - public Set authorizedParties() { - return authorizedParties; - } - - public long clockSkewInMs() { - return clockSkewInMs; - } - - public static Builder builder() { - return new Builder(); - } - - public static BuilderWithKey key(Key key) { - return builder().key(key); - } - - public static final class Builder { - - public BuilderWithKey key(Key key) { - checkNotNull(key, "key"); - return new BuilderWithKey(key); - } - } - - public static final class BuilderWithKey { - - private final Key key; - private Optional audience = Optional.empty(); - private Set authorizedParties = new HashSet<>(); - private long clockSkewInMs = 5000L; - - BuilderWithKey(Key key) { - this.key = key; - } - - public BuilderWithKey audience(String audience) { - checkNotNull(audience, "audience"); - return audience(Optional.of(audience)); - } - - public BuilderWithKey audience(Optional audience) { - checkNotNull(audience, "audience"); - this.audience = audience; - return this; - } - - public BuilderWithKey authorizedParty(String authorizedParty) { - checkNotNull(authorizedParty, "authorizedParty"); - this.authorizedParties.add(authorizedParty); - return this; - } - - public BuilderWithKey authorizedParties(Collection authorizedParties) { - checkNotNull(authorizedParties, "authorizedParties"); - this.authorizedParties.addAll(authorizedParties); - return this; - } - - public BuilderWithKey clockSkew(long duration, TimeUnit unit) { - this.clockSkewInMs = unit.toMillis(duration); - return this; - } - - public BuilderWithKey clockSkew(Optional duration, TimeUnit unit) { - checkNotNull(clockSkewInMs, "clockSkewInMs"); - if (duration.isPresent()) { - return clockSkew(duration.get(), unit); - } else { - return clockSkew(DEFAULT_CLOCK_SKEW_MS, TimeUnit.MILLISECONDS); - } - } - - public VerifyJwtOptions build() { - return new VerifyJwtOptions(audience, authorizedParties, Optional.of(clockSkewInMs), key); - } - } - } - - private static T checkNotNull(T t, String name) { - if (t == null) { - // IllegalArgumentException is more appropriate than NullPointerException - // which is often associated with bugs. - throw new IllegalArgumentException(name + " cannot be null"); - } else { - return t; - } - } -} diff --git a/src/main/java/com/clerk/backend_api/helpers/jwks/AuthErrorReason.java b/src/main/java/com/clerk/backend_api/helpers/jwks/AuthErrorReason.java new file mode 100644 index 0000000..c96275b --- /dev/null +++ b/src/main/java/com/clerk/backend_api/helpers/jwks/AuthErrorReason.java @@ -0,0 +1,30 @@ +package com.clerk.backend_api.helpers.jwks; + +/** + * AuthErrorReason - The reason for request authentication failure. + */ +public enum AuthErrorReason implements ErrorReason { + + SESSION_TOKEN_MISSING( + "session-token-missing", + "Could not retrieve session token. Please make sure that the __session cookie or the HTTP authorization header contain a Clerk-generated session JWT"), + SECRET_KEY_MISSING( + "secret-key-missing", + "Missing Clerk Secret Key. Go to https://dashboard.clerk.com and get your key for your instance."); + + private final String id; + private final String message; + + private AuthErrorReason(String id, String message) { + this.id = id; + this.message = message; + } + + public String id() { + return id; + } + + public String message() { + return message; + } +} diff --git a/src/main/java/com/clerk/backend_api/helpers/jwks/AuthStatus.java b/src/main/java/com/clerk/backend_api/helpers/jwks/AuthStatus.java new file mode 100644 index 0000000..16281f9 --- /dev/null +++ b/src/main/java/com/clerk/backend_api/helpers/jwks/AuthStatus.java @@ -0,0 +1,19 @@ +package com.clerk.backend_api.helpers.jwks; + +/** + * AuthStatus - The request authentication status. + */ +public enum AuthStatus { + SIGNED_IN("signed-in"), + SIGNED_OUT("signed-out"); + + private final String value; + + private AuthStatus(String value) { + this.value = value; + } + + public String value() { + return value; + } +} diff --git a/src/main/java/com/clerk/backend_api/helpers/jwks/AuthenticateRequest.java b/src/main/java/com/clerk/backend_api/helpers/jwks/AuthenticateRequest.java new file mode 100644 index 0000000..321912b --- /dev/null +++ b/src/main/java/com/clerk/backend_api/helpers/jwks/AuthenticateRequest.java @@ -0,0 +1,98 @@ +package com.clerk.backend_api.helpers.jwks; + +import java.net.HttpCookie; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * AuthenticateRequest - Helper methods to authenticate requests. + */ +public final class AuthenticateRequest { + + private static final String SESSION_COOKIE_NAME = "__session"; + + private AuthenticateRequest() { + // prevent instantiation (this is a utility class) + } + + /** + * Checks if the HTTP request is authenticated. + * + * First the session token is retrieved from either the __session cookie + * or the HTTP Authorization header. + * Then the session token is verified: networklessly if the options.jwtKey + * is provided, otherwise by fetching the JWKS from Clerk's Backend API. + * + * @param options The request authentication options + * @return The request state. + * + * WARNING: authenticateRequest is applicable in the context of Backend + * APIs only. + */ + public static final RequestState authenticateRequest(HttpRequest request, AuthenticateRequestOptions options) { + + String sessionToken = getSessionToken(request); + if (sessionToken == null) { + return RequestState.signedOut(AuthErrorReason.SESSION_TOKEN_MISSING); + } + + VerifyTokenOptions verifyTokenOptions; + + if (options.jwtKey().isPresent()) { + verifyTokenOptions = VerifyTokenOptions // + .jwtKey(options.jwtKey().get()) // + .audience(options.audience()) // + .authorizedParties(options.authorizedParties()) // + .clockSkew(options.clockSkewInMs(), TimeUnit.MILLISECONDS) // + .build(); + } else if (options.secretKey().isPresent()) { + verifyTokenOptions = VerifyTokenOptions // + .secretKey(options.secretKey().get()) // + .audience(options.audience()) // + .authorizedParties(options.authorizedParties()) // + .clockSkew(options.clockSkewInMs(), TimeUnit.MILLISECONDS) // + .build(); + } else { + return RequestState.signedOut(AuthErrorReason.SECRET_KEY_MISSING); + } + + try { + VerifyToken.verifyToken(sessionToken, verifyTokenOptions); + } catch (TokenVerificationException e) { + return RequestState.signedOut(e.reason()); + } + + return RequestState.signedIn(sessionToken); + } + + /** + * Retrieve token from __session cookie or Authorization header. + * + * @param request The HTTP request + * @return The session token, if present + */ + private static String getSessionToken(HttpRequest request) { + HttpHeaders headers = request.headers(); + + Optional bearerToken = headers.firstValue("Authorization"); + if (bearerToken.isPresent()) { + return bearerToken.get().replace("Bearer ", ""); + } + + Optional cookieHeader = headers.firstValue("cookie"); + if (cookieHeader.isPresent()) { + String cookieHeaderValue = cookieHeader.get(); + List cookies = HttpCookie.parse(cookieHeaderValue); + for (HttpCookie cookie : cookies) { + if (SESSION_COOKIE_NAME.equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + + return null; + } +} diff --git a/src/main/java/com/clerk/backend_api/helpers/jwks/AuthenticateRequestOptions.java b/src/main/java/com/clerk/backend_api/helpers/jwks/AuthenticateRequestOptions.java new file mode 100644 index 0000000..d8d6971 --- /dev/null +++ b/src/main/java/com/clerk/backend_api/helpers/jwks/AuthenticateRequestOptions.java @@ -0,0 +1,158 @@ +package com.clerk.backend_api.helpers.jwks; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import com.clerk.backend_api.utils.Utils; + +/** + * AuthenticateRequestOptions - Options to configure AuthenticateRequest. + */ +public final class AuthenticateRequestOptions { + + private static final long DEFAULT_CLOCK_SKEW_MS = 5000L; + + private final Optional secretKey; + private final Optional jwtKey; + private final Optional audience; + private final Set authorizedParties; + private final long clockSkewInMs; + + /** + * Options to configure AuthenticateRequest. + * + * @param secretKey The Clerk secret key from the API Keys page in the + * Clerk Dashboard. (Optional) + * @param jwtKey PEM Public String used to verify the session token + * in a networkless manner. (Optional) + * @param audience An audience to verify against. (Optional) + * @param authorizedParties An allowlist of origins to verify against. + * (Optional) + * @param clockSkewInMs Allowed time difference (in milliseconds) between + * the Clerk server (which generates the token) + * and the clock of the user's application server when + * validating a token. Defaults to 5000 ms. + */ + public AuthenticateRequestOptions( + Optional secretKey, + Optional jwtKey, + Optional audience, + Set authorizedParties, + Optional clockSkewInMs) { + + Utils.checkNotNull(secretKey, "secretKey"); + Utils.checkNotNull(jwtKey, "jwtKey"); + Utils.checkNotNull(audience, "audience"); + Utils.checkNotNull(authorizedParties, "authorizedParties"); + Utils.checkNotNull(clockSkewInMs, "clockSkewInMs"); + + this.secretKey = secretKey; + this.jwtKey = jwtKey; + this.audience = audience; + this.authorizedParties = authorizedParties; + this.clockSkewInMs = clockSkewInMs.orElse(DEFAULT_CLOCK_SKEW_MS); + } + + public Optional secretKey() { + return secretKey; + } + + public Optional jwtKey() { + return jwtKey; + } + + public Optional audience() { + return audience; + } + + public Set authorizedParties() { + return authorizedParties; + } + + public long clockSkewInMs() { + return clockSkewInMs; + } + + public static Builder secretKey(String secretKey) { + return Builder.withSecretKey(secretKey); + } + + public static Builder jwtKey(String jwtKey) { + return Builder.withJwtKey(jwtKey); + } + + public static final class Builder { + + private Optional secretKey = Optional.empty(); + private Optional jwtKey = Optional.empty(); + private Optional audience = Optional.empty(); + private Set authorizedParties = new HashSet<>(); + private long clockSkewInMs = DEFAULT_CLOCK_SKEW_MS; + + private Builder() {} + + public static Builder withSecretKey(String secretKey) { + Utils.checkNotNull(secretKey, "secretKey"); + Builder builder = new Builder(); + builder.secretKey = Optional.of(secretKey); + return builder; + } + + public static Builder withJwtKey(String jwtKey) { + Utils.checkNotNull(jwtKey, "jwtKey"); + Builder builder = new Builder(); + builder.jwtKey = Optional.of(jwtKey); + return builder; + } + + public Builder audience(String audience) { + Utils.checkNotNull(audience, "audience"); + return audience(Optional.of(audience)); + } + + public Builder audience(Optional audience) { + Utils.checkNotNull(audience, "audience"); + this.audience = audience; + return this; + } + + public Builder authorizedParty(String authorizedParty) { + Utils.checkNotNull(authorizedParty, "authorizedParty"); + this.authorizedParties.add(authorizedParty); + return this; + } + + public Builder authorizedParties(Collection authorizedParties) { + Utils.checkNotNull(authorizedParties, "authorizedParties"); + this.authorizedParties.addAll(authorizedParties); + return this; + } + + public Builder clockSkew(long duration, TimeUnit unit) { + this.clockSkewInMs = unit.toMillis(duration); + return this; + } + + public Builder clockSkew(Optional duration, TimeUnit unit) { + Utils.checkNotNull(clockSkewInMs, "clockSkewInMs"); + if (duration.isPresent()) { + return clockSkew(duration.get(), unit); + } + return clockSkew(DEFAULT_CLOCK_SKEW_MS, TimeUnit.MILLISECONDS); + } + + + + + public AuthenticateRequestOptions build() { + return new AuthenticateRequestOptions(secretKey, + jwtKey, + audience, + authorizedParties, + Optional.of(clockSkewInMs)); + } + } +} diff --git a/src/main/java/com/clerk/backend_api/helpers/jwks/ErrorReason.java b/src/main/java/com/clerk/backend_api/helpers/jwks/ErrorReason.java new file mode 100644 index 0000000..a9e8739 --- /dev/null +++ b/src/main/java/com/clerk/backend_api/helpers/jwks/ErrorReason.java @@ -0,0 +1,11 @@ +package com.clerk.backend_api.helpers.jwks; + +/** + * ErrorReason - Interface implemented by AuthErrorReason and TokenVerificationErrorReason. + */ +interface ErrorReason { + + String id(); + + String message(); +} diff --git a/src/main/java/com/clerk/backend_api/helpers/jwks/RequestState.java b/src/main/java/com/clerk/backend_api/helpers/jwks/RequestState.java new file mode 100644 index 0000000..8b3f667 --- /dev/null +++ b/src/main/java/com/clerk/backend_api/helpers/jwks/RequestState.java @@ -0,0 +1,80 @@ +package com.clerk.backend_api.helpers.jwks; + +import java.util.Optional; +import com.clerk.backend_api.utils.Utils; + +/** +* RequestState - Authentication State of the request. +*/ +public final class RequestState { + + private final AuthStatus status; + private final Optional authErrorReason; + private final Optional tokenVerificationErrorReason; + private final Optional token; + + public RequestState(AuthStatus status, + Optional authErrorReason, + Optional tokenVerificationErrorReason, + Optional token) { + Utils.checkNotNull(status, "status"); + Utils.checkNotNull(authErrorReason, "authErrorReason"); + Utils.checkNotNull(tokenVerificationErrorReason, "tokenVerificationErrorReason"); + Utils.checkNotNull(token, "token"); + + if (authErrorReason.isPresent() && tokenVerificationErrorReason.isPresent()) { + throw new IllegalArgumentException("Only one of authErrorReason or tokenVerificationErrorReason should be provided."); + } + + this.status = status; + this.authErrorReason = authErrorReason; + this.tokenVerificationErrorReason = tokenVerificationErrorReason; + this.token = token; + } + + public static RequestState signedIn(String token) { + return new RequestState(AuthStatus.SIGNED_IN, Optional.empty(), Optional.empty(), Optional.of(token)); + } + + public static RequestState signedOut(AuthErrorReason reason) { + return new RequestState(AuthStatus.SIGNED_OUT, + Optional.of(reason), + Optional.empty(), + Optional.empty()); + } + + public static RequestState signedOut(TokenVerificationErrorReason reason) { + return new RequestState(AuthStatus.SIGNED_OUT, + Optional.empty(), + Optional.of(reason), + Optional.empty()); + } + + public AuthStatus status() { + return status; + } + + public boolean isSignedIn() { + return status == AuthStatus.SIGNED_IN; + } + + public boolean isSignedOut() { + return status == AuthStatus.SIGNED_OUT; + } + + public Optional reason() { + if (authErrorReason.isPresent()) { + return Optional.of(authErrorReason.get()); + } + + if (tokenVerificationErrorReason.isPresent()) { + return Optional.of(tokenVerificationErrorReason.get()); + } + + return Optional.empty(); + } + + public Optional token() { + return token; + } +} diff --git a/src/main/java/com/clerk/backend_api/helpers/jwks/TokenVerificationErrorReason.java b/src/main/java/com/clerk/backend_api/helpers/jwks/TokenVerificationErrorReason.java new file mode 100644 index 0000000..a65283e --- /dev/null +++ b/src/main/java/com/clerk/backend_api/helpers/jwks/TokenVerificationErrorReason.java @@ -0,0 +1,63 @@ +package com.clerk.backend_api.helpers.jwks; + +/** + * TokenVerificationErrorReason - The reason for token verification failure. + */ +public enum TokenVerificationErrorReason implements ErrorReason { + + JWK_FAILED_TO_LOAD( + "jwk-failed-to-load", + "Failed to load JWKS from Clerk Backend API. Contact support@clerk.com."), + JWK_REMOTE_INVALID( + "jwk-remote-invalid", + "The JWKS endpoint did not contain any signing keys. Contact support@clerk.com."), + JWK_LOCAL_INVALID( + "jwk-local-invalid", + "The provided PEM Public Key is not in the proper format."), + JWK_FAILED_TO_RESOLVE( + "jwk-failed-to-resolve", + "Failed to resolve JWK. Public Key is not in the proper format."), + JWK_KID_MISMATCH( + "jwk-kid-mismatch", + "Unable to find a signing key in JWKS that matches the kid of the provided session token."), + TOKEN_EXPIRED( + "token-expired", + "Token has expired and is no longer valid."), + TOKEN_INVALID( + "token-invalid", + "Token is invalid and could not be verified."), + TOKEN_INVALID_AUTHORIZED_PARTIES( + "token-invalid-authorized-parties", + "Authorized party claim (azp) does not match any of the authorized parties."), + TOKEN_INVALID_AUDIENCE( + "token-invalid-audience", + "Token audience claim (aud) does not match one of the expected audience values."), + TOKEN_IAT_IN_THE_FUTURE( + "token-iat-in-the-future", + "Token Issued At claim (iat) represents a time in the future."), + TOKEN_NOT_ACTIVE_YET( + "token-not-active-yet", + "Token is not yet valid. Not Before claim (nbf) is in the future."), + TOKEN_INVALID_SIGNATURE( + "token-invalid-signature", + "Token signature is invalid and could not be verified."), + SECRET_KEY_MISSING( + "secret-key-missing", + "Missing Clerk Secret Key. Go to https://dashboard.clerk.com and get your key for your instance."); + + private final String id; + private final String message; + + private TokenVerificationErrorReason(String id, String message) { + this.id = id; + this.message = message; + } + + public String id() { + return id; + } + + public String message() { + return message; + } +} diff --git a/src/main/java/com/clerk/backend_api/helpers/jwks/TokenVerificationException.java b/src/main/java/com/clerk/backend_api/helpers/jwks/TokenVerificationException.java new file mode 100644 index 0000000..03a1844 --- /dev/null +++ b/src/main/java/com/clerk/backend_api/helpers/jwks/TokenVerificationException.java @@ -0,0 +1,23 @@ +package com.clerk.backend_api.helpers.jwks; + +/** + * TokenVerificationException - Exception thrown when token verification fails. + */ +@SuppressWarnings("serial") +public final class TokenVerificationException extends Exception { + private final TokenVerificationErrorReason reason; + + public TokenVerificationException(TokenVerificationErrorReason reason) { + super(reason.message()); + this.reason = reason; + } + + public TokenVerificationException(TokenVerificationErrorReason reason, Throwable cause) { + super(reason.message(), cause); + this.reason = reason; + } + + public TokenVerificationErrorReason reason() { + return reason; + } +} diff --git a/src/main/java/com/clerk/backend_api/helpers/jwks/VerifyToken.java b/src/main/java/com/clerk/backend_api/helpers/jwks/VerifyToken.java new file mode 100644 index 0000000..1b569dd --- /dev/null +++ b/src/main/java/com/clerk/backend_api/helpers/jwks/VerifyToken.java @@ -0,0 +1,235 @@ +package com.clerk.backend_api.helpers.jwks; + +import java.io.IOException; +import java.math.BigInteger; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.Key; +import java.security.KeyFactory; +import java.security.spec.RSAPublicKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.Date; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.JwtParserBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.PrematureJwtException; +import io.jsonwebtoken.impl.security.ConstantKeyLocator; + +/** + * VerifyToken - Helper methods for verifying JSON Web Tokens (JWT). + */ +public final class VerifyToken { + + private VerifyToken() { + // prevent instantiation (this is a utility class) + } + + /** + * Verifies a Clerk-generated token signature. Networkless if the options.jwtKey + * is provided. + * Otherwise, permforms a network call to retrieve the JWKS from Clerk's Backend + * API. + * + * @param token The token to verify. + * @param options The options associated with parsing and verifying the JWT. + * @return Claims (being a map of properties with specialized accessors for + * standard claim properties). + * @throws TokenVerificationException if token does not verify. A + * causing exception if present should not be considered part of + * the public API (subject to change). + */ + public static Claims verifyToken(String token, VerifyTokenOptions options) throws TokenVerificationException { + + Key key; + if (options.jwtKey().isPresent()) { + key = getLocalJwtKey(options.jwtKey().get()); + } else { + key = getRemoteJwtKey(token, options); + } + + JwtParserBuilder builder = Jwts // + .parser() // + .clockSkewSeconds(options.clockSkewInMs() / 1000) // + .keyLocator(new ConstantKeyLocator(key, null)); + + options.audience().ifPresent(a -> builder.requireAudience(a)); + + JwtParser parser = builder.build(); + + Claims payload; + + try { + // note that exp (expiration) and nbf (not before) are enforced by the parser + // so we don't have to make additional checks for them + // ExpiredJwtException, PrematureJwtException are thrown + + // the presence of a subject field is also enforced by the parser + // JwtException is thrown + + payload = parser.parseSignedClaims(token).getPayload(); + } catch (ExpiredJwtException e) { + throw new TokenVerificationException(TokenVerificationErrorReason.TOKEN_EXPIRED, e); + } catch (PrematureJwtException e) { + throw new TokenVerificationException(TokenVerificationErrorReason.TOKEN_NOT_ACTIVE_YET, e); + } catch (JwtException e) { + throw new TokenVerificationException(TokenVerificationErrorReason.TOKEN_INVALID, e); + } + + String azp = (String) payload.get("azp"); + if (azp != null && !options.authorizedParties().isEmpty()) { + if (!options.authorizedParties().contains(azp)) { + throw new TokenVerificationException(TokenVerificationErrorReason.TOKEN_INVALID_AUTHORIZED_PARTIES); + } + } + + Date iat = payload.getIssuedAt(); + Date now = new Date(); + if (iat != null && iat.getTime() > now.getTime() + options.clockSkewInMs()) { + throw new TokenVerificationException(TokenVerificationErrorReason.TOKEN_IAT_IN_THE_FUTURE); + } + + return payload; + } + + /** + * Converts a RSA PEM formatted public key to a Key object + * that can be used for networkless verification. + * + * @param jwtKey The PEM formatted public key. + * @return The RSA public key + * @throws TokenVerificationException if the public key could not be resolved. + */ + private static Key getLocalJwtKey(String jwtKey) throws TokenVerificationException { + + String pemContent = jwtKey.replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s", ""); + + try { + byte[] decodedKey = Base64.getDecoder().decode(pemContent); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(decodedKey); + KeyFactory kf = KeyFactory.getInstance("RSA"); + return kf.generatePublic(keySpec); + } catch (Exception e) { + throw new TokenVerificationException(TokenVerificationErrorReason.JWK_LOCAL_INVALID, e); + } + } + + /** + * Retrieves the RSA public key used to sign the token from Clerk's Backend API. + * + * First the key identifier (kid) is parsed from the token header. + * Then the public key is retrieved from Clerk's JWKS by looking for a matching + * kid. + * + * @param token The token to parse. + * @param options The options used for token verification. + * @return The RSA public key. + * @throws TokenVerificationException if the public key could not be resolved. + */ + private static final Key getRemoteJwtKey(String token, VerifyTokenOptions options) throws TokenVerificationException { + + String kid = parseKid(token); + + for (JsonNode node : fetchJwks(options)) { + if (kid.equals(node.get("kid").asText())) { + try { + KeyFactory kf = KeyFactory.getInstance("RSA"); + BigInteger n = new BigInteger(1, Base64.getUrlDecoder().decode(node.get("n").asText())); + BigInteger e = new BigInteger(1, Base64.getUrlDecoder().decode(node.get("e").asText())); + return kf.generatePublic(new RSAPublicKeySpec(n, e)); + + } catch (Exception e) { + throw new TokenVerificationException(TokenVerificationErrorReason.JWK_FAILED_TO_RESOLVE, e); + } + } + } + + throw new TokenVerificationException(TokenVerificationErrorReason.JWK_KID_MISMATCH); + } + + /** + * Retrives the key identifier (kid) from the token header. + * + * @param token The token to parse. + * @return The key identifier (kid). + * @throws TokenVerificationException if the kid cannot be parsed. + */ + private static final String parseKid(String token) throws TokenVerificationException { + + // https://github.com/jwtk/jjwt/discussions/923 + // parseClaimsJwt() is deprecated since version 0.12.0 and + // parseUnsecuredClaims() + // does not allow to parse the token header without verifying the JWS signature. + // Since we are only interested in the kid, the header is parsed manually. + + String[] parts = token.split("\\."); + if (parts.length != 3) { + throw new TokenVerificationException(TokenVerificationErrorReason.TOKEN_INVALID); + } + + String header = new String(Base64.getUrlDecoder().decode(parts[0])); + ObjectMapper mapper = new ObjectMapper(); + String kid; + try { + kid = mapper.readTree(header).get("kid").asText(); + } catch (IOException | NullPointerException e) { + throw new TokenVerificationException(TokenVerificationErrorReason.TOKEN_INVALID, e); + } + + return kid; + } + + /** + * Fetches the JSON Web Key Set (JWKS) from Clerk's Backend API. + * + * @param options The options used for token verification. + * @return The JWKS keys array as a JSON node. + * @throws TokenVerificationException if the JWKS cannot be fetched. + */ + private static final JsonNode fetchJwks(VerifyTokenOptions options) throws TokenVerificationException { + + if (options.secretKey().isEmpty()) { + throw new TokenVerificationException(TokenVerificationErrorReason.SECRET_KEY_MISSING); + } + + String jwksUrl = String.format("%s/%s/jwks", options.apiUrl(), options.apiVersion()); + String bearerAuth = String.format("Bearer %s", options.secretKey().get()); + + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(jwksUrl)) + .header("Accept", "application/json") + .header("Authorization", bearerAuth) + .build(); + + try { + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new TokenVerificationException(TokenVerificationErrorReason.JWK_FAILED_TO_LOAD); + } + + ObjectMapper mapper = new ObjectMapper(); + JsonNode keysNode = mapper.readTree(response.body()).get("keys"); + + if (keysNode.isArray()) { + return keysNode; + } + throw new TokenVerificationException(TokenVerificationErrorReason.JWK_REMOTE_INVALID); + + } catch (IOException | InterruptedException e) { + throw new TokenVerificationException(TokenVerificationErrorReason.JWK_FAILED_TO_LOAD, e); + } + } +} diff --git a/src/main/java/com/clerk/backend_api/helpers/jwks/VerifyTokenOptions.java b/src/main/java/com/clerk/backend_api/helpers/jwks/VerifyTokenOptions.java new file mode 100644 index 0000000..b96e071 --- /dev/null +++ b/src/main/java/com/clerk/backend_api/helpers/jwks/VerifyTokenOptions.java @@ -0,0 +1,204 @@ +package com.clerk.backend_api.helpers.jwks; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import com.clerk.backend_api.utils.Utils; + +/** + * VerifyTokenOptions - Options to configure VerifyToken. + */ +public final class VerifyTokenOptions { + + private static final long DEFAULT_CLOCK_SKEW_MS = 5000L; + private static final String DEFAULT_API_URL = "https://api.clerk.com"; + private static final String DEFAULT_API_VERSION = "v1"; + + private final Optional secretKey; + private final Optional jwtKey; + private final Optional audience; + private final Set authorizedParties; + private final long clockSkewInMs; + private final String apiUrl; + private final String apiVersion; + + /** + * Options to configure VerifyToken. + * + * @param secretKey The Clerk secret key from the API Keys page in the + * Clerk Dashboard. (Optional) + * @param jwtKey PEM Public String used to verify the session token + * in a networkless manner. (Optional) + * @param audience An audience to verify against. (Optional) + * @param authorizedParties An allowlist of origins to verify against. + * (Optional) + * @param clockSkewInMs Allowed time difference (in milliseconds) between + * the Clerk server (which generates the token) + * and the clock of the user's application server when + * validating a token. Defaults to 5000 ms. + * @param apiUrl The Clerk Backend API endpoint. Defaults to + * 'https://api.clerk.com' + * @param apiVersion The version passed to the Clerk API. Defaults to + * 'v1' + */ + public VerifyTokenOptions( + Optional secretKey, + Optional jwtKey, + Optional audience, + Set authorizedParties, + Optional clockSkewInMs, + Optional apiUrl, + Optional apiVersion) { + + Utils.checkNotNull(audience, "audience"); + Utils.checkNotNull(authorizedParties, "authorizedParties"); + Utils.checkNotNull(clockSkewInMs, "clockSkewInMs"); + Utils.checkNotNull(jwtKey, "jwtKey"); + Utils.checkNotNull(secretKey, "secretKey"); + Utils.checkNotNull(apiUrl, "apiUrl"); + Utils.checkNotNull(apiVersion, "apiVersion"); + + this.audience = audience; + this.authorizedParties = authorizedParties; + this.clockSkewInMs = clockSkewInMs.orElse(DEFAULT_CLOCK_SKEW_MS); + this.jwtKey = jwtKey; + this.secretKey = secretKey; + this.apiUrl = apiUrl.orElse(DEFAULT_API_URL); + this.apiVersion = apiVersion.orElse(DEFAULT_API_VERSION); + } + + public Optional audience() { + return audience; + } + + public Set authorizedParties() { + return authorizedParties; + } + + public long clockSkewInMs() { + return clockSkewInMs; + } + + public Optional jwtKey() { + return jwtKey; + } + + public Optional secretKey() { + return secretKey; + } + + public String apiUrl() { + return apiUrl; + } + + public String apiVersion() { + return apiVersion; + } + + public static Builder secretKey(String secretKey) { + return Builder.withSecretKey(secretKey); + } + + public static Builder jwtKey(String jwtKey) { + return Builder.withJwtKey(jwtKey); + } + + public static final class Builder { + + private Optional secretKey = Optional.empty(); + private Optional jwtKey = Optional.empty(); + + private Optional audience = Optional.empty(); + private Set authorizedParties = new HashSet<>(); + private long clockSkewInMs = DEFAULT_CLOCK_SKEW_MS; + private String apiUrl = DEFAULT_API_URL; + private String apiVersion = DEFAULT_API_VERSION; + + public static Builder withSecretKey(String secretKey) { + Utils.checkNotNull(secretKey, "secretKey"); + Builder builder = new Builder(); + builder.secretKey = Optional.of(secretKey); + return builder; + } + + public static Builder withJwtKey(String jwtKey) { + Utils.checkNotNull(jwtKey, "jwtKey"); + Builder builder = new Builder(); + builder.jwtKey = Optional.of(jwtKey); + return builder; + } + + public Builder audience(String audience) { + Utils.checkNotNull(audience, "audience"); + return audience(Optional.of(audience)); + } + + public Builder audience(Optional audience) { + Utils.checkNotNull(audience, "audience"); + this.audience = audience; + return this; + } + + public Builder authorizedParty(String authorizedParty) { + Utils.checkNotNull(authorizedParty, "authorizedParty"); + this.authorizedParties.add(authorizedParty); + return this; + } + + public Builder authorizedParties(Collection authorizedParties) { + Utils.checkNotNull(authorizedParties, "authorizedParties"); + this.authorizedParties.addAll(authorizedParties); + return this; + } + + public Builder clockSkew(long duration, TimeUnit unit) { + this.clockSkewInMs = unit.toMillis(duration); + return this; + } + + public Builder clockSkew(Optional duration, TimeUnit unit) { + Utils.checkNotNull(clockSkewInMs, "clockSkewInMs"); + if (duration.isPresent()) { + return clockSkew(duration.get(), unit); + } + return clockSkew(DEFAULT_CLOCK_SKEW_MS, TimeUnit.MILLISECONDS); + } + + public Builder apiUrl(String apiUrl) { + Utils.checkNotNull(apiUrl, "apiUrl"); + this.apiUrl = apiUrl; + return this; + } + + public Builder apiUrl(Optional apiUrl) { + Utils.checkNotNull(apiUrl, "apiUrl"); + this.apiUrl = apiUrl.orElse(DEFAULT_API_URL); + return this; + } + + public Builder apiVersion(String apiVersion) { + Utils.checkNotNull(apiVersion, "apiVersion"); + this.apiVersion = apiVersion; + return this; + } + + public Builder apiVersion(Optional apiVersion) { + Utils.checkNotNull(apiVersion, "apiVersion"); + this.apiVersion = apiVersion.orElse(DEFAULT_API_VERSION); + return this; + } + + public VerifyTokenOptions build() { + return new VerifyTokenOptions(secretKey, + jwtKey, + audience, + authorizedParties, + Optional.of(clockSkewInMs), + Optional.of(apiUrl), + Optional.of(apiVersion)); + } + } +} diff --git a/src/test/java/com/clerk/backend_api/helpers/JwtHelperTest.java b/src/test/java/com/clerk/backend_api/helpers/JwtHelperTest.java deleted file mode 100644 index 5edf798..0000000 --- a/src/test/java/com/clerk/backend_api/helpers/JwtHelperTest.java +++ /dev/null @@ -1,172 +0,0 @@ -package com.clerk.backend_api.helpers; - -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Date; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.TimeUnit; - -import javax.crypto.SecretKey; - -import org.junit.jupiter.api.Test; - -import com.clerk.backend_api.helpers.JwtHelper.TokenVerificationException; -import com.clerk.backend_api.helpers.JwtHelper.VerifyJwtOptions; -import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.PrematureJwtException; -import io.jsonwebtoken.UnsupportedJwtException; - -public class JwtHelperTest { - - @Test - public void testVerifiesOk() { - SecretKey key = Jwts.SIG.HS256.key().build(); - String token = Jwts.builder() // - .subject("Joe") // - .audience().add("aud1") // - .and() // - .expiration(new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(1))) // - .notBefore(new Date(System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(1))) // - .issuedAt(new Date(System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(1))) // - .signWith(key) // - .compact(); - JwtHelper.verifyJwt(token, VerifyJwtOptions.key(key).build()); - } - - @Test - public void testFailsNoSubject() { - SecretKey key = Jwts.SIG.HS256.key().build(); - String token = Jwts.builder() // - .signWith(key) // - .compact(); - TokenVerificationException t = assertThrows(TokenVerificationException.class, - () -> JwtHelper.verifyJwt(token, VerifyJwtOptions.key(key).build())); - // not part of public API but worth checking - assertTrue(t.getCause() instanceof UnsupportedJwtException); - } - - @Test - public void testFailsNullKey() { - assertThrows(IllegalArgumentException.class, () -> VerifyJwtOptions.key(null)); - } - - - @Test - public void testFailsVerifyExpired() { - SecretKey key = Jwts.SIG.HS256.key().build(); - String token = Jwts.builder() // - .subject("Joe") // - .expiration(new Date(System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(1))) // - .signWith(key) // - .compact(); - TokenVerificationException t = assertThrows(TokenVerificationException.class, - () -> JwtHelper.verifyJwt(token, VerifyJwtOptions // - .key(key) // - .clockSkew(Optional.empty(), TimeUnit.MILLISECONDS) // - .build())); - assertTrue(t.getCause() instanceof ExpiredJwtException); - } - - @Test - public void testVerifyDoesNotExpireWithLargeClockSkew() { - SecretKey key = Jwts.SIG.HS256.key().build(); - String token = Jwts.builder() // - .subject("Joe") // - .expiration(new Date(System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(1))) // - .signWith(key) // - .compact(); - JwtHelper.verifyJwt(token, VerifyJwtOptions // - .key(key) // - .clockSkew(Optional.of(3L), TimeUnit.MINUTES) // - .build()); - } - - @Test - public void testFailsVerifyNotBefore() { - SecretKey key = Jwts.SIG.HS256.key().build(); - String token = Jwts.builder() // - .subject("Joe") // - .notBefore(new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(1))) // - .issuedAt(new Date(System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(1))) // - .signWith(key) // - .compact(); - TokenVerificationException t = assertThrows(TokenVerificationException.class, - () -> JwtHelper.verifyJwt(token, VerifyJwtOptions.key(key).build())); - assertTrue(t.getCause() instanceof PrematureJwtException); - } - - @Test - public void testFailsIssuedAt() { - SecretKey key = Jwts.SIG.HS256.key().build(); - String token = Jwts.builder() // - .subject("Joe") // - .issuedAt(new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(1))) // - .signWith(key) // - .compact(); - TokenVerificationException t = assertThrows(TokenVerificationException.class, - () -> JwtHelper.verifyJwt(token, VerifyJwtOptions.key(key).build())); - assertTrue(t.getMessage().startsWith("JWT issued-at-date claim (iat) is in the future")); - assertNull(t.getCause()); - } - - @Test - public void testFailsAudience() { - SecretKey key = Jwts.SIG.HS256.key().build(); - String token = Jwts.builder() // - .subject("Joe") // - .audience().add("aud1").add("aud2") // - .and() // - .signWith(key) // - .compact(); - TokenVerificationException t = assertThrows(TokenVerificationException.class, - () -> JwtHelper.verifyJwt(token, VerifyJwtOptions.key(key).audience("aud3").build())); - assertTrue(t.getMessage().startsWith("Missing expected")); - } - - @Test - public void testVerifiesEmptyAuthorizedParties() { - SecretKey key = Jwts.SIG.HS256.key().build(); - String token = Jwts.builder() // - .subject("Joe") // - .claim("azp", "partyparty") // - .audience().add("aud1").add("aud2") // - .and() // - .signWith(key) // - .compact(); - JwtHelper.verifyJwt(token, VerifyJwtOptions.key(key).build()); - } - - @Test - public void testVerifiesNonEmptyAuthorizedParties() { - SecretKey key = Jwts.SIG.HS256.key().build(); - String token = Jwts.builder() // - .subject("Joe") // - .claim("azp", "partyparty") // - .audience().add("aud1").add("aud2") // - .and() // - .signWith(key) // - .compact(); - JwtHelper.verifyJwt(token, - VerifyJwtOptions.key(key).authorizedParties(List.of("boo")).authorizedParty("partyparty").build()); - } - - @Test - public void testFailsAuthorizedParties() { - SecretKey key = Jwts.SIG.HS256.key().build(); - String token = Jwts.builder() // - .subject("Joe") // - .claim("azp", "partyparty") // - .audience().add("aud1").add("aud2") // - .and() // - .signWith(key) // - .compact(); - TokenVerificationException t = assertThrows(TokenVerificationException.class, - () -> JwtHelper.verifyJwt(token, VerifyJwtOptions.key(key).authorizedParty("bill").build())); - assertTrue(t.getMessage().startsWith("Invalid JWT Authorized party claim (azp)")); - } - -} diff --git a/src/test/java/com/clerk/backend_api/helpers/jwks/AuthenticateRequestTest.java b/src/test/java/com/clerk/backend_api/helpers/jwks/AuthenticateRequestTest.java new file mode 100644 index 0000000..e0111d2 --- /dev/null +++ b/src/test/java/com/clerk/backend_api/helpers/jwks/AuthenticateRequestTest.java @@ -0,0 +1,177 @@ +package com.clerk.backend_api.helpers.jwks; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpRequest; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +public class AuthenticateRequestTest { + + private static Optional secretKey = Optional.empty(); + private static Optional jwtKey = Optional.empty(); + private static Optional sessionToken = Optional.empty(); + private static String requestUrl = "http://localhost:3000"; + private static Set authorizedParties = Set.of("http://localhost:3000"); + private static Optional audience = Optional.empty(); + + @BeforeAll + static void setupAll() { + boolean skippedTests = false; + + String token = System.getenv("CLERK_SESSION_TOKEN"); + if (token != null) { + sessionToken = Optional.of(token); + } else { + skippedTests = true; + System.out.println("WARNING: CLERK_SESSION_TOKEN is not set"); + } + + String clerkSecretKey = System.getenv("CLERK_SECRET_KEY"); + if (clerkSecretKey != null) { + secretKey = Optional.of(clerkSecretKey); + } else { + skippedTests = true; + System.out.println("WARNING: CLERK_SECRET_KEY is not set"); + } + + String clerkJwtKey = System.getenv("CLERK_JWT_KEY"); + if (clerkJwtKey != null) { + jwtKey = Optional.of(clerkJwtKey); + } else { + skippedTests = true; + System.out.println("WARNING: CLERK_JWT_KEY is not set"); + } + + if (skippedTests) { + System.out.println("WARNING: some tests are skipped due to missing environment variables."); + } + } + + private static void assertRequestState(RequestState state, String token) { + if (state.isSignedIn()) { + assertTrue(state.reason().isEmpty()); + assertEquals(token, state.token().get()); + } else { + assertTrue(state.isSignedOut()); + assertEquals(TokenVerificationErrorReason.TOKEN_EXPIRED, state.reason().get()); + assertTrue(state.token().isEmpty()); + System.out.println("WARNING: the provided session token is expired."); + } + } + + // @EnabledIfEnvironmentVariable(named = "CLERK_SECRET_KEY", matches = ".+") + @Test + public void testAuthenticateRequestNoSessionToken() throws URISyntaxException { + AuthenticateRequestOptions arOptions = AuthenticateRequestOptions // + .secretKey("sk_test_SecretKey") // + .build(); + + HttpRequest request = HttpRequest.newBuilder() // + .uri(new URI(requestUrl)) // + .GET() // + .build(); // + + RequestState state = AuthenticateRequest.authenticateRequest(request, arOptions); + assertTrue(state.isSignedOut()); + assertEquals(AuthErrorReason.SESSION_TOKEN_MISSING, state.reason().get()); + assertTrue(state.token().isEmpty()); + } + + @Test + @EnabledIfEnvironmentVariable(named = "CLERK_SECRET_KEY", matches = ".+") + @EnabledIfEnvironmentVariable(named = "CLERK_SESSION_TOKEN", matches = ".+") + public void testAuthenticateRequestCookie() throws URISyntaxException { + String token = sessionToken.get(); + + AuthenticateRequestOptions arOptions = AuthenticateRequestOptions // + .secretKey(secretKey.get()) // + .authorizedParties(authorizedParties) // + .audience(audience) // + .build(); + + HttpRequest request = HttpRequest.newBuilder() // + .uri(new URI(requestUrl)) // + .header("Cookie", "__session=" + token) // + .GET() // + .build(); // + + RequestState state = AuthenticateRequest.authenticateRequest(request, arOptions); + assertRequestState(state, token); + } + + @Test + @EnabledIfEnvironmentVariable(named = "CLERK_SECRET_KEY", matches = ".+") + @EnabledIfEnvironmentVariable(named = "CLERK_SESSION_TOKEN", matches = ".+") + public void testAuthenticateRequestBearer() throws URISyntaxException { + String token = sessionToken.get(); + + AuthenticateRequestOptions arOptions = AuthenticateRequestOptions // + .secretKey(secretKey.get()) // + .authorizedParties(authorizedParties) // + .audience(audience) // + .build(); + + HttpRequest request = HttpRequest.newBuilder() // + .uri(new URI(requestUrl)) // + .header("Authorization", "Bearer " + token) // + .GET() // + .build(); // + + RequestState state = AuthenticateRequest.authenticateRequest(request, arOptions); + assertRequestState(state, token); + } + + @Test + @EnabledIfEnvironmentVariable(named = "CLERK_JWT_KEY", matches = ".+") + @EnabledIfEnvironmentVariable(named = "CLERK_SESSION_TOKEN", matches = ".+") + public void testAuthenticateRequestLocal() throws URISyntaxException { + String token = sessionToken.get(); + + AuthenticateRequestOptions arOptions = AuthenticateRequestOptions // + .jwtKey(jwtKey.get()) // + .authorizedParties(authorizedParties) // + .audience(audience) // + .build(); + + HttpRequest request = HttpRequest.newBuilder() // + .uri(new URI(requestUrl)) // + .header("Authorization", "Bearer " + token) // + .GET() // + .build(); // + + RequestState state = AuthenticateRequest.authenticateRequest(request, arOptions); + assertRequestState(state, token); + } + + @Test + @EnabledIfEnvironmentVariable(named = "CLERK_SESSION_KEY", matches = ".+") + public void testAuthenticateRequestNoSecretKey() throws URISyntaxException { + String token = sessionToken.get(); + + AuthenticateRequestOptions arOptions = new AuthenticateRequestOptions(Optional.empty(), + Optional.empty(), + Optional.empty(), + Set.of(), + Optional.empty()); + + HttpRequest request = HttpRequest.newBuilder() // + .uri(new URI(requestUrl)) // + .header("Authorization", "Bearer " + token) // + .GET() // + .build(); // + + RequestState state = AuthenticateRequest.authenticateRequest(request, arOptions); + assertTrue(state.isSignedOut()); + assertEquals(AuthErrorReason.SECRET_KEY_MISSING, state.reason().get()); + assertTrue(state.token().isEmpty()); + } + +} diff --git a/src/test/java/com/clerk/backend_api/helpers/jwks/VerifyTokenTest.java b/src/test/java/com/clerk/backend_api/helpers/jwks/VerifyTokenTest.java new file mode 100644 index 0000000..f35c53d --- /dev/null +++ b/src/test/java/com/clerk/backend_api/helpers/jwks/VerifyTokenTest.java @@ -0,0 +1,233 @@ +package com.clerk.backend_api.helpers.jwks; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.ConnectException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.util.Base64; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.IncorrectClaimException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.PrematureJwtException; +import io.jsonwebtoken.UnsupportedJwtException; + + +public class VerifyTokenTest { + + private static PrivateKey privateKey; // private key used to sign JWTs + private static String jwtKey; // PEM formatted public key + private static String validToken; + + @BeforeAll + static void setupAll() { + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + privateKey = keyPair.getPrivate(); + + PublicKey rsaPublicKey = keyPair.getPublic(); + String base64EncodedKey = Base64.getEncoder().encodeToString(rsaPublicKey.getEncoded()); + jwtKey = "-----BEGIN PUBLIC KEY-----\n"; + jwtKey += base64EncodedKey; + jwtKey += "\n-----END PUBLIC KEY-----"; + } catch (Exception e) { + System.out.println("ERROR: could not generate RSA KeyPair. " + e.getMessage()); + } + + validToken = Jwts.builder() // + .subject("Joe") // + .audience().add("aud1") // + .and() // + .header().add("kid", "ins_abcdefghijklmnopqrstuvwxyz0") // + .and() // + .expiration(new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(1))) // + .notBefore(new Date(System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(1))) // + .issuedAt(new Date(System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(1))) // + .signWith(privateKey) // + .compact(); + } + + @Test + public void testVerifiesOk() { + assertDoesNotThrow(() -> VerifyToken.verifyToken(validToken, VerifyTokenOptions.jwtKey(jwtKey).build())); + } + + @Test + public void testInvalidApiUrl() { + + TokenVerificationException t = assertThrows(TokenVerificationException.class, + () -> VerifyToken.verifyToken(validToken, VerifyTokenOptions // + .secretKey("sk_test_SecretKey") // + .apiUrl("https://invalid.com") // + .build())); + assertEquals(TokenVerificationErrorReason.JWK_FAILED_TO_LOAD, t.reason()); + assertTrue(t.getCause() instanceof ConnectException); + } + + @Test + public void testInvalidSecretKey() { + + TokenVerificationException t = assertThrows(TokenVerificationException.class, + () -> VerifyToken.verifyToken(validToken, VerifyTokenOptions // + .secretKey("sk_test_InvalidKey") // + .build())); + assertEquals(TokenVerificationErrorReason.JWK_FAILED_TO_LOAD, t.reason()); + assertNull(t.getCause()); + } + + @Test + public void testFailsNoSubject() { + String token = Jwts.builder() // + .signWith(privateKey) // + .compact(); + TokenVerificationException t = assertThrows(TokenVerificationException.class, + () -> VerifyToken.verifyToken(token, VerifyTokenOptions.jwtKey(jwtKey).build())); + // not part of public API but worth checking + assertTrue(t.getCause() instanceof UnsupportedJwtException); + } + + @Test + public void testFailsNullKey() { + assertThrows(IllegalArgumentException.class, () -> VerifyTokenOptions.jwtKey(null)); + } + + @Test + public void testFailsVerifyExpired() { + String token = Jwts.builder() // + .subject("Joe") // + .expiration(new Date(System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(1))) // + .signWith(privateKey) // + .compact(); + TokenVerificationException t = assertThrows(TokenVerificationException.class, + () -> VerifyToken.verifyToken(token, VerifyTokenOptions // + .jwtKey(jwtKey) // + .clockSkew(Optional.empty(), TimeUnit.MILLISECONDS) // + .build())); + assertEquals(TokenVerificationErrorReason.TOKEN_EXPIRED, t.reason()); + assertTrue(t.getCause() instanceof ExpiredJwtException); + } + + @Test + public void testVerifyDoesNotExpireWithLargeClockSkew() { + String token = Jwts.builder() // + .subject("Joe") // + .expiration(new Date(System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(1))) // + .signWith(privateKey) // + .compact(); + assertDoesNotThrow(() -> VerifyToken.verifyToken(token, VerifyTokenOptions // + .jwtKey(jwtKey) // + .clockSkew(Optional.of(3L), TimeUnit.MINUTES) // + .build())); + } + + @Test + public void testFailsVerifyNotBefore() { + String token = Jwts.builder() // + .subject("Joe") // + .notBefore(new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(1))) // + .issuedAt(new Date(System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(1))) // + .signWith(privateKey) // + .compact(); + TokenVerificationException t = assertThrows(TokenVerificationException.class, + () -> VerifyToken.verifyToken(token, VerifyTokenOptions.jwtKey(jwtKey).build())); + + assertEquals(TokenVerificationErrorReason.TOKEN_NOT_ACTIVE_YET, t.reason()); + assertTrue(t.getCause() instanceof PrematureJwtException); + } + + @Test + public void testFailsIssuedAt() { + String token = Jwts.builder() // + .subject("Joe") // + .issuedAt(new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(1))) // + .signWith(privateKey) // + .compact(); + TokenVerificationException t = assertThrows(TokenVerificationException.class, + () -> VerifyToken.verifyToken(token, VerifyTokenOptions.jwtKey(jwtKey).build())); + assertEquals(TokenVerificationErrorReason.TOKEN_IAT_IN_THE_FUTURE, t.reason()); + assertNull(t.getCause()); + } + + @Test + public void testFailsAudience() { + String token = Jwts.builder() // + .subject("Joe") // + .audience().add("aud1").add("aud2") // + .and() // + .signWith(privateKey) // + .compact(); + TokenVerificationException t = assertThrows(TokenVerificationException.class, + () -> VerifyToken.verifyToken(token, VerifyTokenOptions // + .jwtKey(jwtKey) // + .audience("aud3") // + .build())); + assertEquals(TokenVerificationErrorReason.TOKEN_INVALID, t.reason()); + assertTrue(t.getCause() instanceof IncorrectClaimException); + } + + @Test + public void testVerifiesEmptyAuthorizedParties() { + String token = Jwts.builder() // + .subject("Joe") // + .claim("azp", "partyparty") // + .audience().add("aud1").add("aud2") // + .and() // + .signWith(privateKey) // + .compact(); + + assertDoesNotThrow(() -> VerifyToken.verifyToken(token, VerifyTokenOptions.jwtKey(jwtKey).build())); + } + + @Test + public void testVerifiesNonEmptyAuthorizedParties() { + String token = Jwts.builder() // + .subject("Joe") // + .claim("azp", "partyparty") // + .audience().add("aud1").add("aud2") // + .and() // + .signWith(privateKey) // + .compact(); + + assertDoesNotThrow(() -> VerifyToken.verifyToken(token, VerifyTokenOptions // + .jwtKey(jwtKey) // + .authorizedParties(List.of("boo")) // + .authorizedParty("partyparty") // + .build())); + } + + @Test + public void testFailsAuthorizedParties() { + String token = Jwts.builder() // + .subject("Joe") // + .claim("azp", "partyparty") // + .audience().add("aud1").add("aud2") // + .and() // + .signWith(privateKey) // + .compact(); + TokenVerificationException t = assertThrows(TokenVerificationException.class, + () -> VerifyToken.verifyToken(token, VerifyTokenOptions // + .jwtKey(jwtKey) // + .authorizedParty("bill") // + .build())); + assertEquals(TokenVerificationErrorReason.TOKEN_INVALID_AUTHORIZED_PARTIES, t.reason()); + assertNull(t.getCause()); + } + +}