diff --git a/build-extras.gradle b/build-extras.gradle index e104d4f6..fad8b710 100644 --- a/build-extras.gradle +++ b/build-extras.gradle @@ -2,3 +2,29 @@ // * is referred to in an `apply from` command in `build.gradle` // * can be used to customise `build.gradle` // * is generated once and not overwritten in SDK generation updates + +// additional dependencies +dependencies { + api 'io.jsonwebtoken:jjwt-api:0.12.6' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.6' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.6' +// Uncomment this next dependency if you are using: +// - JDK 10 or earlier, and you want to use RSASSA-PSS (PS256, PS384, PS512) +// signature algorithms. +// - JDK 10 or earlier, and you want to use EdECDH (X25519 or X448) Elliptic Curve +// Diffie-Hellman encryption. +// - JDK 14 or earlier, and you want to use EdDSA (Ed25519 or Ed448) Elliptic +// Curve signature algorithms. +// It is unnecessary for these algorithms on JDK 15 or later. + implementation 'org.bouncycastle:bcprov-jdk18on:1.76' + testImplementation 'org.junit.jupiter:junit-jupiter:5.8.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.3' +} + +tasks.named('test', Test) { + useJUnitPlatform() + + testLogging { + events "passed" + } +} diff --git a/gradlew.bat b/gradlew.bat index 6689b85b..93e3f59f 100755 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,92 +1,92 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/java/com/clerk/backend_api/helpers/JwtHelper.java b/src/main/java/com/clerk/backend_api/helpers/JwtHelper.java new file mode 100644 index 00000000..2d55f52e --- /dev/null +++ b/src/main/java/com/clerk/backend_api/helpers/JwtHelper.java @@ -0,0 +1,212 @@ +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/test/java/com/clerk/backend_api/helpers/JwtHelperTest.java b/src/test/java/com/clerk/backend_api/helpers/JwtHelperTest.java new file mode 100644 index 00000000..5edf798b --- /dev/null +++ b/src/test/java/com/clerk/backend_api/helpers/JwtHelperTest.java @@ -0,0 +1,172 @@ +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)")); + } + +}