From 75b664f2d1840eb398ffafef4a603266ab88ba10 Mon Sep 17 00:00:00 2001 From: Tanya Sinha Date: Tue, 19 May 2026 09:22:39 +0530 Subject: [PATCH 01/14] Java-MVC-V2-Base-PR (#154) Co-authored-by: Tareq Kirresh Co-authored-by: Kailash B --- .github/workflows/build-and-test.yml | 2 +- build.gradle | 150 ++-- gradle/maven-publish.gradle | 4 +- gradle/wrapper/gradle-wrapper.properties | 2 +- .../java/com/auth0/AlgorithmNameVerifier.java | 10 - .../auth0/AsymmetricSignatureVerifier.java | 50 -- .../com/auth0/AuthenticationController.java | 261 ++----- src/main/java/com/auth0/AuthorizeUrl.java | 39 +- src/main/java/com/auth0/DomainProvider.java | 2 +- src/main/java/com/auth0/DomainResolver.java | 2 +- src/main/java/com/auth0/IdTokenVerifier.java | 202 ------ .../com/auth0/InvalidRequestException.java | 11 - src/main/java/com/auth0/RandomStorage.java | 52 -- src/main/java/com/auth0/RequestProcessor.java | 338 ++++----- .../com/auth0/ResolverDomainProvider.java | 2 +- src/main/java/com/auth0/SessionUtils.java | 64 -- .../java/com/auth0/SignatureVerifier.java | 59 -- .../java/com/auth0/SignedCookieUtils.java | 55 ++ .../java/com/auth0/StaticDomainProvider.java | 2 +- .../com/auth0/SymmetricSignatureVerifier.java | 20 - .../com/auth0/TokenValidationException.java | 12 - .../java/com/auth0/TransientCookieStore.java | 29 +- src/main/java/module-info.java | 20 + .../auth0/AuthenticationControllerTest.java | 20 +- src/test/java/com/auth0/AuthorizeUrlTest.java | 147 ++-- .../java/com/auth0/IdTokenVerifierTest.java | 680 ------------------ .../auth0/InvalidRequestExceptionTest.java | 5 +- .../java/com/auth0/RandomStorageTest.java | 80 --- .../java/com/auth0/RequestProcessorTest.java | 518 +++++++------ src/test/java/com/auth0/SessionUtilsTest.java | 45 -- .../java/com/auth0/SignatureVerifierTest.java | 177 ----- .../java/com/auth0/SignedCookieUtilsTest.java | 66 ++ .../com/auth0/TransientCookieStoreTest.java | 67 +- 33 files changed, 843 insertions(+), 2350 deletions(-) delete mode 100644 src/main/java/com/auth0/AlgorithmNameVerifier.java delete mode 100644 src/main/java/com/auth0/AsymmetricSignatureVerifier.java delete mode 100644 src/main/java/com/auth0/IdTokenVerifier.java delete mode 100644 src/main/java/com/auth0/RandomStorage.java delete mode 100644 src/main/java/com/auth0/SessionUtils.java delete mode 100644 src/main/java/com/auth0/SignatureVerifier.java delete mode 100644 src/main/java/com/auth0/SymmetricSignatureVerifier.java delete mode 100644 src/main/java/com/auth0/TokenValidationException.java create mode 100644 src/main/java/module-info.java delete mode 100644 src/test/java/com/auth0/IdTokenVerifierTest.java delete mode 100644 src/test/java/com/auth0/RandomStorageTest.java delete mode 100644 src/test/java/com/auth0/SessionUtilsTest.java delete mode 100644 src/test/java/com/auth0/SignatureVerifierTest.java diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index c147ee8..bbd90e1 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -20,7 +20,7 @@ jobs: uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 - name: Test and Assemble and ApiDiff with Gradle - run: ./gradlew assemble apiDiff check jacocoTestReport --continue --console=plain + run: ./gradlew assemble check jacocoTestReport --continue --console=plain - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 with: diff --git a/build.gradle b/build.gradle index 460be1f..763c525 100644 --- a/build.gradle +++ b/build.gradle @@ -29,87 +29,88 @@ logger.lifecycle("Using version ${version} for ${name} group $group") import me.champeau.gradle.japicmp.JapicmpTask -project.afterEvaluate { - def versions = project.ext.testInJavaVersions - for (pluginJavaTestVersion in versions) { - def taskName = "testInJava-${pluginJavaTestVersion}" - tasks.register(taskName, Test) { - def versionToUse = taskName.split("-").getAt(1) as Integer - description = "Runs unit tests on Java version ${versionToUse}." - project.logger.quiet("Test will be running in ${versionToUse}") - group = 'verification' - javaLauncher.set(javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(versionToUse) - }) - shouldRunAfter(tasks.named('test')) - } - tasks.named('check') { - dependsOn(taskName) - } - } - - project.configure(project) { - def baselineVersion = project.ext.baselineCompareVersion - task('apiDiff', type: JapicmpTask, dependsOn: 'jar') { - oldClasspath.from(files(getBaselineJar(project, baselineVersion))) - newClasspath.from(files(jar.archiveFile)) - onlyModified = true - failOnModification = true - ignoreMissingClasses = true - htmlOutputFile = file("$buildDir/reports/apiDiff/apiDiff.html") - txtOutputFile = file("$buildDir/reports/apiDiff/apiDiff.txt") - doLast { - project.logger.quiet("Comparing against baseline version ${baselineVersion}") - } - } - } -} - -private static File getBaselineJar(Project project, String baselineVersion) { - // Use detached configuration: https://github.com/square/okhttp/blob/master/build.gradle#L270 - def group = project.group - try { - def baseline = "${project.group}:${project.name}:$baselineVersion" - project.group = 'virtual_group_for_japicmp' - def dependency = project.dependencies.create(baseline + "@jar") - return project.configurations.detachedConfiguration(dependency).files.find { - it.name == "${project.name}-${baselineVersion}.jar" - } - } finally { - project.group = group - } -} +//project.afterEvaluate { +// def versions = project.ext.testInJavaVersions +// for (pluginJavaTestVersion in versions) { +// def taskName = "testInJava-${pluginJavaTestVersion}" +// tasks.register(taskName, Test) { +// def versionToUse = taskName.split("-").getAt(1) as Integer +// description = "Runs unit tests on Java version ${versionToUse}." +// project.logger.quiet("Test will be running in ${versionToUse}") +// group = 'verification' +// javaLauncher.set(javaToolchains.launcherFor { +// languageVersion = JavaLanguageVersion.of(versionToUse) +// }) +// shouldRunAfter(tasks.named('test')) +// } +// tasks.named('check') { +// dependsOn(taskName) +// } +// } +// +// project.configure(project) { +// def baselineVersion = project.ext.baselineCompareVersion +// task('apiDiff', type: JapicmpTask, dependsOn: 'jar') { +// oldClasspath.from(files(getBaselineJar(project, baselineVersion))) +// newClasspath.from(files(jar.archiveFile)) +// onlyModified = true +// failOnModification = true +// ignoreMissingClasses = true +// htmlOutputFile = file("$buildDir/reports/apiDiff/apiDiff.html") +// txtOutputFile = file("$buildDir/reports/apiDiff/apiDiff.txt") +// doLast { +// project.logger.quiet("Comparing against baseline version ${baselineVersion}") +// } +// } +// } +//} +// +//private static File getBaselineJar(Project project, String baselineVersion) { +// // Use detached configuration: https://github.com/square/okhttp/blob/master/build.gradle#L270 +// def group = project.group +// try { +// def baseline = "${project.group}:${project.name}:$baselineVersion" +// project.group = 'virtual_group_for_japicmp' +// def dependency = project.dependencies.create(baseline + "@jar") +// return project.configurations.detachedConfiguration(dependency).files.find { +// it.name == "${project.name}-${baselineVersion}.jar" +// } +// } finally { +// project.group = group +// } +//} ext { baselineCompareVersion = '1.5.0' - testInJavaVersions = [8, 11, 17, 21] + testInJavaVersions = [17, 21] } jacocoTestReport { reports { - xml.enabled = true - html.enabled = true + xml.required = true + html.required = true } } java { toolchain { - languageVersion = JavaLanguageVersion.of(8) - } - // Needed because of broken gradle metadata, see https://github.com/google/guava/issues/6612#issuecomment-1614992368 - sourceSets.all { - configurations.getByName(runtimeClasspathConfigurationName) { - attributes.attribute(Attribute.of("org.gradle.jvm.environment", String), "standard-jvm") - } - configurations.getByName(compileClasspathConfigurationName) { - attributes.attribute(Attribute.of("org.gradle.jvm.environment", String), "standard-jvm") - } + languageVersion = JavaLanguageVersion.of(17) } + modularity.inferModulePath = true } + compileJava { - sourceCompatibility '1.8' - targetCompatibility '1.8' + sourceCompatibility '17' + targetCompatibility '17' +} + +jar { +} + +javadoc { + exclude 'module-info.java' + modularity.inferModulePath = false } test { @@ -121,21 +122,20 @@ test { } dependencies { - implementation 'javax.servlet:javax.servlet-api:3.1.0' + implementation 'jakarta.servlet:jakarta.servlet-api:6.0.0' implementation 'org.apache.commons:commons-lang3:3.20.0' - implementation 'com.google.guava:guava-annotations:r03' + implementation 'com.google.guava:guava:32.0.1-jre' implementation 'commons-codec:commons-codec:1.22.0' - api 'com.auth0:auth0:1.45.1' - api 'com.auth0:java-jwt:3.19.4' - api 'com.auth0:jwks-rsa:0.24.0' + api 'com.auth0:auth0:3.5.1' + api 'com.auth0:java-jwt:4.5.0' + api 'com.auth0:jwks-rsa:0.24.1' - testImplementation 'org.bouncycastle:bcprov-jdk15on:1.70' - testImplementation 'org.hamcrest:java-hamcrest:2.0.0.0' - testImplementation 'org.hamcrest:hamcrest-core:1.3' - testImplementation 'org.mockito:mockito-core:2.28.2' + testImplementation 'org.hamcrest:hamcrest:2.2' + testImplementation 'org.mockito:mockito-core:4.11.0' testImplementation 'org.junit.jupiter:junit-jupiter:5.8.1' - testImplementation 'org.springframework:spring-test:4.3.30.RELEASE' + testImplementation 'org.springframework:spring-test:6.0.14' + testImplementation 'org.springframework:spring-web:6.0.14' testImplementation 'com.squareup.okhttp3:okhttp:4.12.0' } diff --git a/gradle/maven-publish.gradle b/gradle/maven-publish.gradle index b27ee88..e5c51a9 100644 --- a/gradle/maven-publish.gradle +++ b/gradle/maven-publish.gradle @@ -18,8 +18,8 @@ tasks.withType(Javadoc).configureEach { } javadoc { - // Specify the Java version that the project will use - options.addStringOption('-release', "8") + // Specify the Java version that the project targets + options.addStringOption('-release', "17") } artifacts { archives sourcesJar, javadocJar diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c51cbf1..e1adfb4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.9.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/java/com/auth0/AlgorithmNameVerifier.java b/src/main/java/com/auth0/AlgorithmNameVerifier.java deleted file mode 100644 index 2779b80..0000000 --- a/src/main/java/com/auth0/AlgorithmNameVerifier.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.auth0; - -@SuppressWarnings("unused") -class AlgorithmNameVerifier extends SignatureVerifier { - - AlgorithmNameVerifier() { - //Must only allow supported algorithms and never "none" algorithm - super(null, "HS256", "RS256"); - } -} diff --git a/src/main/java/com/auth0/AsymmetricSignatureVerifier.java b/src/main/java/com/auth0/AsymmetricSignatureVerifier.java deleted file mode 100644 index b8b82b4..0000000 --- a/src/main/java/com/auth0/AsymmetricSignatureVerifier.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.auth0; - -import com.auth0.jwk.Jwk; -import com.auth0.jwk.JwkException; -import com.auth0.jwk.JwkProvider; -import com.auth0.jwt.JWT; -import com.auth0.jwt.JWTVerifier; -import com.auth0.jwt.algorithms.Algorithm; -import com.auth0.jwt.interfaces.RSAKeyProvider; - -import java.security.interfaces.RSAPrivateKey; -import java.security.interfaces.RSAPublicKey; - -@SuppressWarnings("unused") -class AsymmetricSignatureVerifier extends SignatureVerifier { - - AsymmetricSignatureVerifier(JwkProvider jwkProvider) { - super(createJWTVerifier(jwkProvider), "RS256"); - } - - private static JWTVerifier createJWTVerifier(final JwkProvider jwkProvider) { - Algorithm alg = Algorithm.RSA256(new RSAKeyProvider() { - @Override - public RSAPublicKey getPublicKeyById(String keyId) { - try { - Jwk jwk = jwkProvider.get(keyId); - return (RSAPublicKey) jwk.getPublicKey(); - } catch (JwkException ignored) { - // JwkException handled by Algorithm verify implementation from java-jwt - } - return null; - } - - @Override - public RSAPrivateKey getPrivateKey() { - //NO-OP - return null; - } - - @Override - public String getPrivateKeyId() { - //NO-OP - return null; - } - }); - return JWT.require(alg) - .ignoreIssuedAt() - .build(); - } -} diff --git a/src/main/java/com/auth0/AuthenticationController.java b/src/main/java/com/auth0/AuthenticationController.java index 9fc2724..c768765 100644 --- a/src/main/java/com/auth0/AuthenticationController.java +++ b/src/main/java/com/auth0/AuthenticationController.java @@ -1,14 +1,11 @@ package com.auth0; -import com.auth0.client.HttpOptions; -import com.auth0.client.auth.AuthAPI; import com.auth0.jwk.JwkProvider; -import com.auth0.net.Telemetry; import com.google.common.annotations.VisibleForTesting; import org.apache.commons.lang3.Validate; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; /** * Base Auth0 Authenticator class. @@ -33,13 +30,9 @@ RequestProcessor getRequestProcessor() { } /** - * Create a new {@link Builder} instance to configure the - * {@link AuthenticationController} response type and algorithm used on the - * verification. - * By default it will request response type 'code' and later perform the Code - * Exchange, but if the response type is changed to 'token' it will handle - * the Implicit Grant using the HS256 algorithm with the Client Secret as - * secret. + * Create a new {@link Builder} instance to configure the {@link AuthenticationController} response type and algorithm used on the verification. + * By default it will request response type 'code' and later perform the Code Exchange, but if the response type is changed to 'token' it will handle + * the Implicit Grant using the HS256 algorithm with the Client Secret as secret. * * @param domain the Auth0 domain * @param clientId the Auth0 application's client id @@ -85,7 +78,6 @@ public static class Builder { private boolean useLegacySameSiteCookie; private String organization; private String invitation; - private HttpOptions httpOptions; private String cookiePath; private DomainResolver domainResolver; @@ -150,21 +142,7 @@ public Builder withDomainResolver(DomainResolver domainResolver) { } /** - * Customize certain aspects of the underlying HTTP client networking library, - * such as timeouts and proxy configuration. - * - * @param httpOptions a non-null {@code HttpOptions} - * @return this same builder instance. - */ - public Builder withHttpOptions(HttpOptions httpOptions) { - Validate.notNull(httpOptions); - this.httpOptions = httpOptions; - return this; - } - - /** - * Specify that transient authentication-based cookies such as state and nonce - * are created with the specified + * Specify that transient authentication-based cookies such as state and nonce are created with the specified * {@code Path} cookie attribute. * * @param cookiePath the path to set on the cookie. @@ -177,12 +155,9 @@ public Builder withCookiePath(String cookiePath) { } /** - * Change the response type to request in the Authorization step. Default value - * is 'code'. + * Change the response type to request in the Authorization step. Default value is 'code'. * - * @param responseType the response type to request. Any combination of 'code', - * 'token' and 'id_token' but 'token id_token' is allowed, - * using a space as separator. + * @param responseType the response type to request. Any combination of 'code', 'token' and 'id_token' but 'token id_token' is allowed, using a space as separator. * @return this same builder instance. */ public Builder withResponseType(String responseType) { @@ -192,10 +167,8 @@ public Builder withResponseType(String responseType) { } /** - * Sets the Jwk Provider that will return the Public Key required to verify the - * token in case of Implicit Grant flows. - * This is required if the Auth0 Application is signing the tokens with the - * RS256 algorithm. + * Sets the Jwk Provider that will return the Public Key required to verify the token in case of Implicit Grant flows. + * This is required if the Auth0 Application is signing the tokens with the RS256 algorithm. * * @param jwkProvider a valid Jwk provider. * @return this same builder instance. @@ -207,8 +180,7 @@ public Builder withJwkProvider(JwkProvider jwkProvider) { } /** - * Sets the clock-skew or leeway value to use in the ID Token verification. The - * value must be in seconds. + * Sets the clock-skew or leeway value to use in the ID Token verification. The value must be in seconds. * Defaults to 60 seconds. * * @param clockSkew the clock-skew to use for ID Token verification, in seconds. @@ -221,8 +193,7 @@ public Builder withClockSkew(Integer clockSkew) { } /** - * Sets the allowable elapsed time in seconds since the last time user was - * authenticated. + * Sets the allowable elapsed time in seconds since the last time user was authenticated. * By default there is no limit. * * @param maxAge the max age of the authentication, in seconds. @@ -235,14 +206,10 @@ public Builder withAuthenticationMaxAge(Integer maxAge) { } /** - * Sets whether fallback cookies will be set for clients that do not support - * SameSite=None cookie attribute. - * The SameSite Cookie attribute will only be set to "None" if the reponseType - * includes "id_token". + * Sets whether fallback cookies will be set for clients that do not support SameSite=None cookie attribute. + * The SameSite Cookie attribute will only be set to "None" if the reponseType includes "id_token". * By default this is true. - * - * @param useLegacySameSiteCookie whether fallback auth-based cookies should be - * set. + * @param useLegacySameSiteCookie whether fallback auth-based cookies should be set. * @return this same builder instance. */ public Builder withLegacySameSiteCookie(boolean useLegacySameSiteCookie) { @@ -251,8 +218,7 @@ public Builder withLegacySameSiteCookie(boolean useLegacySameSiteCookie) { } /** - * Sets the organization query string parameter value used to login to an - * organization. + * Sets the organization query string parameter value used to login to an organization. * * @param organization The ID or name of the organization to log the user in to. * @return the builder instance. @@ -264,12 +230,10 @@ public Builder withOrganization(String organization) { } /** - * Sets the invitation query string parameter to join an organization. If using - * this, you must also specify the + * Sets the invitation query string parameter to join an organization. If using this, you must also specify the * organization using {@linkplain Builder#withOrganization(String)}. * - * @param invitation The ID of the invitation to accept. This is available on - * the URL that is provided when accepting an invitation. + * @param invitation The ID of the invitation to accept. This is available on the URL that is provided when accepting an invitation. * @return the builder instance. */ public Builder withInvitation(String invitation) { @@ -279,14 +243,10 @@ public Builder withInvitation(String invitation) { } /** - * Create a new {@link AuthenticationController} instance that will handle both - * Code Grant and Implicit Grant flows using either Code Exchange or Token - * Signature verification. + * Create a new {@link AuthenticationController} instance that will handle both Code Grant and Implicit Grant flows using either Code Exchange or Token Signature verification. * * @return a new instance of {@link AuthenticationController}. - * @throws UnsupportedOperationException if the Implicit Grant is chosen and the - * environment doesn't support UTF-8 - * encoding. + * @throws UnsupportedOperationException if the Implicit Grant is chosen and the environment doesn't support UTF-8 encoding. */ public AuthenticationController build() throws UnsupportedOperationException { validateDomainConfiguration(); @@ -295,19 +255,20 @@ public AuthenticationController build() throws UnsupportedOperationException { ? new StaticDomainProvider(domain) : new ResolverDomainProvider(domainResolver); - SignatureVerifier signatureVerifier = buildSignatureVerifier(); - - RequestProcessor processor = new RequestProcessor.Builder(domainProvider, responseType, clientId, - clientSecret, httpOptions, signatureVerifier) + RequestProcessor.Builder builder = new RequestProcessor.Builder( + domainProvider, responseType, clientId, clientSecret) .withClockSkew(clockSkew) .withAuthenticationMaxAge(authenticationMaxAge) .withLegacySameSiteCookie(useLegacySameSiteCookie) .withOrganization(organization) .withInvitation(invitation) - .withCookiePath(cookiePath) - .build(); + .withCookiePath(cookiePath); - return new AuthenticationController(processor); + if (jwkProvider != null) { + builder.withJwkProvider(jwkProvider); + } + + return new AuthenticationController(builder.build()); } private void validateDomainConfiguration() { @@ -318,39 +279,6 @@ private void validateDomainConfiguration() { throw new IllegalStateException("Cannot specify both domain and domainResolver."); } } - - private SignatureVerifier buildSignatureVerifier() { - if (jwkProvider != null) { - return new AsymmetricSignatureVerifier(jwkProvider); - } - if (responseType.contains(RESPONSE_TYPE_CODE)) { - return new AlgorithmNameVerifier(); // legacy behavior - } - return new SymmetricSignatureVerifier(clientSecret); - } - - @VisibleForTesting - AuthAPI createAPIClient(String domain, String clientId, String clientSecret, HttpOptions httpOptions) { - if (httpOptions != null) { - return new AuthAPI(domain, clientId, clientSecret, httpOptions); - } - return new AuthAPI(domain, clientId, clientSecret); - } - - @VisibleForTesting - void setupTelemetry(AuthAPI client) { - if (client == null) - return; - Telemetry telemetry = new Telemetry("auth0-java-mvc-common", obtainPackageVersion()); - client.setTelemetry(telemetry); - } - - @VisibleForTesting - String obtainPackageVersion() { - // Value if taken from jar's manifest file. - // Call will return null on dev environment (outside of a jar) - return getClass().getPackage().getImplementationVersion(); - } } /** @@ -360,7 +288,6 @@ String obtainPackageVersion() { * @param enabled whether to enable the HTTP logger or not. */ public void setLoggingEnabled(boolean enabled) { - // No longer requestProcessor.getClient()... (which was null) requestProcessor.setLoggingEnabled(enabled); } @@ -372,35 +299,23 @@ public void doNotSendTelemetry() { } /** - * Process a request to obtain a set of {@link Tokens} that represent successful - * authentication or authorization. + * Process a request to obtain a set of {@link Tokens} that represent successful authentication or authorization. * - * This method should be called when processing the callback request to your - * application. It will validate - * authentication-related request parameters, handle performing a Code Exchange - * request if using - * the "code" response type, and verify the integrity of the ID token (if - * present). + * This method should be called when processing the callback request to your application. It will validate + * authentication-related request parameters, handle performing a Code Exchange request if using + * the "code" response type, and verify the integrity of the ID token (if present). * - *

- * Important: When using this API, you must - * also use - * {@link AuthenticationController#buildAuthorizeUrl(HttpServletRequest, HttpServletResponse, String)} - * when building the {@link AuthorizeUrl} that the user will be redirected to to - * login. Failure to do so may result - * in a broken login experience for the user. - *

+ *

Important: When using this API, you must also use {@link AuthenticationController#buildAuthorizeUrl(HttpServletRequest, HttpServletResponse, String)} + * when building the {@link AuthorizeUrl} that the user will be redirected to to login. Failure to do so may result + * in a broken login experience for the user.

* - * @param request the received request to process. + * @param request the received request to process. * @param response the received response to process. * @return the Tokens obtained after the user authentication. - * @throws InvalidRequestException if the error is result of making an - * invalid authentication request. - * @throws IdentityVerificationException if an error occurred while verifying - * the request tokens. + * @throws InvalidRequestException if the error is result of making an invalid authentication request. + * @throws IdentityVerificationException if an error occurred while verifying the request tokens. */ - public Tokens handle(HttpServletRequest request, HttpServletResponse response) - throws IdentityVerificationException { + public Tokens handle(HttpServletRequest request, HttpServletResponse response) throws IdentityVerificationException { Validate.notNull(request, "request must not be null"); Validate.notNull(response, "response must not be null"); @@ -408,104 +323,18 @@ public Tokens handle(HttpServletRequest request, HttpServletResponse response) } /** - * Process a request to obtain a set of {@link Tokens} that represent successful - * authentication or authorization. - * - * This method should be called when processing the callback request to your - * application. It will validate - * authentication-related request parameters, handle performing a Code Exchange - * request if using - * the "code" response type, and verify the integrity of the ID token (if - * present). - * - *

- * Important: When using this API, you must - * also use the - * {@link AuthenticationController#buildAuthorizeUrl(HttpServletRequest, String)} - * when building the {@link AuthorizeUrl} that the user will be redirected to to - * login. Failure to do so may result - * in a broken login experience for the user. - *

- * - * @deprecated This method uses the {@link javax.servlet.http.HttpSession} for - * auth-based data, and is incompatible - * with clients that are using the "id_token" or "token" - * responseType with browsers that enforce SameSite cookie - * restrictions. This method will be removed in version 2.0.0. Use - * {@link AuthenticationController#handle(HttpServletRequest, HttpServletResponse)} - * instead. - * - * @param request the received request to process. - * @return the Tokens obtained after the user authentication. - * @throws InvalidRequestException if the error is result of making an - * invalid authentication request. - * @throws IdentityVerificationException if an error occurred while verifying - * the request tokens. - */ - @Deprecated - public Tokens handle(HttpServletRequest request) throws IdentityVerificationException { - Validate.notNull(request, "request must not be null"); - - return requestProcessor.process(request, null); - } - - /** - * Pre builds an Auth0 Authorize Url with the given redirect URI using a random - * state and a random nonce if applicable. - * - *

- * Important: When using this API, you must - * also obtain the tokens using the - * {@link AuthenticationController#handle(HttpServletRequest)} method. Failure - * to do so may result in a broken login - * experience for users. - *

- * - * @deprecated This method stores data in the - * {@link javax.servlet.http.HttpSession}, and is incompatible with - * clients - * that are using the "id_token" or "token" responseType with - * browsers that enforce SameSite cookie restrictions. - * This method will be removed in version 2.0.0. Use - * {@link AuthenticationController#buildAuthorizeUrl(HttpServletRequest, HttpServletResponse, String)} - * instead. - * - * @param request the caller request. Used to keep the session context. - * @param redirectUri the url to call back with the authentication result. - * @return the authorize url builder to continue any further parameter - * customization. - */ - @Deprecated - public AuthorizeUrl buildAuthorizeUrl(HttpServletRequest request, String redirectUri) { - Validate.notNull(request, "request must not be null"); - Validate.notNull(redirectUri, "redirectUri must not be null"); - - String state = StorageUtils.secureRandomString(); - String nonce = StorageUtils.secureRandomString(); - - return requestProcessor.buildAuthorizeUrl(request, null, redirectUri, state, nonce); - } - - /** - * Pre builds an Auth0 Authorize Url with the given redirect URI using a random - * state and a random nonce if applicable. + * Pre builds an Auth0 Authorize Url with the given redirect URI using a random state and a random nonce if applicable. * - *

- * Important: When using this API, you must - * also obtain the tokens using the - * {@link AuthenticationController#handle(HttpServletRequest, HttpServletResponse)} - * method. Failure to do so will result in a broken login - * experience for users. - *

+ *

Important: When using this API, you must also obtain the tokens using the + * {@link AuthenticationController#handle(HttpServletRequest, HttpServletResponse)} method. Failure to do so will result in a broken login + * experience for users.

* * @param request the HTTP request * @param response the HTTP response. Used to store auth-based cookies. * @param redirectUri the url to call back with the authentication result. - * @return the authorize url builder to continue any further parameter - * customization. + * @return the authorize url builder to continue any further parameter customization. */ - public AuthorizeUrl buildAuthorizeUrl(HttpServletRequest request, HttpServletResponse response, - String redirectUri) { + public AuthorizeUrl buildAuthorizeUrl(HttpServletRequest request, HttpServletResponse response, String redirectUri) { Validate.notNull(request, "request must not be null"); Validate.notNull(response, "response must not be null"); Validate.notNull(redirectUri, "redirectUri must not be null"); diff --git a/src/main/java/com/auth0/AuthorizeUrl.java b/src/main/java/com/auth0/AuthorizeUrl.java index 092b0fd..2d41b10 100644 --- a/src/main/java/com/auth0/AuthorizeUrl.java +++ b/src/main/java/com/auth0/AuthorizeUrl.java @@ -5,8 +5,7 @@ import com.auth0.exception.Auth0Exception; import com.auth0.json.auth.PushedAuthorizationResponse; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletResponse; import java.util.*; import static com.auth0.IdentityVerificationException.API_ERROR; @@ -21,7 +20,6 @@ public class AuthorizeUrl { private static final String SCOPE_OPENID = "openid"; private HttpServletResponse response; - private HttpServletRequest request; private final String responseType; private boolean useLegacySameSiteCookie = true; private boolean setSecureCookie = false; @@ -39,19 +37,15 @@ public class AuthorizeUrl { /** * Creates a new instance that can be used to build an Auth0 Authorization URL. * - * Using this constructor with a non-null {@link HttpServletResponse} will store the state and nonce as - * cookies when the {@link AuthorizeUrl#build()} method is called, with the appropriate SameSite attribute depending - * on the responseType. State and nonce will also be stored in the {@link javax.servlet.http.HttpSession} as a fallback, - * but this behavior will be removed in a future release, and only cookies will be used. + * Stores the state and nonce as cookies when the {@link AuthorizeUrl#build()} method is called, + * with the appropriate SameSite attribute depending on the responseType. * * @param client the Auth0 Authentication API client - * @parem request the HTTP request. Used to store state and nonce as a fallback if cookies not set. * @param response the response where the state and nonce will be stored as cookies * @param redirectUri the url to redirect to after authentication * @param responseType the response type to use */ - AuthorizeUrl(AuthAPI client, HttpServletRequest request, HttpServletResponse response, String redirectUri, String responseType) { - this.request = request; + AuthorizeUrl(AuthAPI client, HttpServletResponse response, String redirectUri, String responseType) { this.response = response; this.responseType = responseType; this.authAPI = client; @@ -113,7 +107,7 @@ public AuthorizeUrl withSecureCookie(boolean secureCookie) { /** * Sets whether a fallback cookie should be used for clients that do not support "SameSite=None". - * Only applicable when this instance is created with {@link AuthorizeUrl#AuthorizeUrl(AuthAPI, HttpServletRequest, HttpServletResponse, String, String)}. + * Only applicable when this instance is created with {@link AuthorizeUrl#AuthorizeUrl(AuthAPI, HttpServletResponse, String, String)}. * * @param useLegacySameSiteCookie whether or not to set fallback auth cookies for clients that do not support "SameSite=None" * @return the builder instance @@ -236,7 +230,7 @@ public String fromPushedAuthorizationRequest() throws InvalidRequestException { storeTransient(); try { - PushedAuthorizationResponse pushedAuthResponse = authAPI.pushedAuthorizationRequest(redirectUri, responseType, params).execute(); + PushedAuthorizationResponse pushedAuthResponse = authAPI.pushedAuthorizationRequest(redirectUri, responseType, params).execute().getBody(); String requestUri = pushedAuthResponse.getRequestURI(); if (requestUri == null || requestUri.isEmpty()) { throw new InvalidRequestException(API_ERROR, "The PAR request returned a missing or empty request_uri value"); @@ -255,24 +249,17 @@ private void storeTransient() { throw new IllegalStateException("The AuthorizeUrl instance must not be reused."); } - if (response != null) { - SameSite sameSiteValue = containsFormPost() ? SameSite.NONE : SameSite.LAX; + SameSite sameSiteValue = containsFormPost() ? SameSite.NONE : SameSite.LAX; - TransientCookieStore.storeState(response, state, sameSiteValue, useLegacySameSiteCookie, setSecureCookie, cookiePath); - TransientCookieStore.storeNonce(response, nonce, sameSiteValue, useLegacySameSiteCookie, setSecureCookie, cookiePath); + TransientCookieStore.storeState(response, state, sameSiteValue, useLegacySameSiteCookie, setSecureCookie, cookiePath); + TransientCookieStore.storeNonce(response, nonce, sameSiteValue, useLegacySameSiteCookie, setSecureCookie, cookiePath); - // Store HMAC-signed origin domain with the same SameSite value as state/nonce - if (originDomain != null && clientSecret != null) { - TransientCookieStore.storeSignedOriginDomain(response, originDomain, - sameSiteValue, cookiePath, setSecureCookie, clientSecret); - } + // Store HMAC-signed origin domain bound to this transaction's state + if (originDomain != null && clientSecret != null && state != null) { + TransientCookieStore.storeSignedOriginDomain(response, originDomain, state, + sameSiteValue, cookiePath, setSecureCookie, clientSecret); } - // Also store in Session just in case developer uses deprecated - // AuthenticationController.handle(HttpServletRequest) API - RandomStorage.setSessionState(request, state); - RandomStorage.setSessionNonce(request, nonce); - used = true; } diff --git a/src/main/java/com/auth0/DomainProvider.java b/src/main/java/com/auth0/DomainProvider.java index 081a3e7..edd8330 100644 --- a/src/main/java/com/auth0/DomainProvider.java +++ b/src/main/java/com/auth0/DomainProvider.java @@ -1,6 +1,6 @@ package com.auth0; -import javax.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequest; interface DomainProvider { String getDomain(HttpServletRequest request); diff --git a/src/main/java/com/auth0/DomainResolver.java b/src/main/java/com/auth0/DomainResolver.java index ea441e4..725e560 100644 --- a/src/main/java/com/auth0/DomainResolver.java +++ b/src/main/java/com/auth0/DomainResolver.java @@ -1,6 +1,6 @@ package com.auth0; -import javax.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequest; public interface DomainResolver { /** diff --git a/src/main/java/com/auth0/IdTokenVerifier.java b/src/main/java/com/auth0/IdTokenVerifier.java deleted file mode 100644 index 3ef0e32..0000000 --- a/src/main/java/com/auth0/IdTokenVerifier.java +++ /dev/null @@ -1,202 +0,0 @@ -package com.auth0; - -import com.auth0.jwt.interfaces.DecodedJWT; -import org.apache.commons.lang3.Validate; - -import java.util.Calendar; -import java.util.Date; -import java.util.List; - -/** - * Token verification utility class. - * Supported signing algorithms: HS256 and RS256 - */ -class IdTokenVerifier { - - private static final Integer DEFAULT_CLOCK_SKEW = 60; //1 min = 60 sec - - private static final String NONCE_CLAIM = "nonce"; - private static final String AZP_CLAIM = "azp"; - private static final String AUTH_TIME_CLAIM = "auth_time"; - - /** - * Verifies a provided ID Token follows the OIDC specification. - * @see Open ID Connect Specification - * - * @param token the ID Token to verify. - * @param verifyOptions the verification options, like audience, issuer, algorithm. - * @throws TokenValidationException If the ID Token is null, its signing algorithm not supported, its signature invalid or one of its claim invalid. - */ - void verify(String token, Options verifyOptions) throws TokenValidationException { - Validate.notNull(verifyOptions); - - if (isEmpty(token)) { - throw new TokenValidationException("ID token is required but missing"); - } - - DecodedJWT decoded = verifyOptions.verifier.verifySignature(token); - - if (isEmpty(decoded.getIssuer())) { - throw new TokenValidationException("Issuer (iss) claim must be a string present in the ID token"); - } - if (!decoded.getIssuer().equals(verifyOptions.issuer)) { - throw new TokenValidationException(String.format("Issuer (iss) claim mismatch in the ID token, expected \"%s\", found \"%s\"", verifyOptions.issuer, decoded.getIssuer())); - } - - if (isEmpty(decoded.getSubject())) { - throw new TokenValidationException("Subject (sub) claim must be a string present in the ID token"); - } - - final List audience = decoded.getAudience(); - if (audience == null) { - throw new TokenValidationException("Audience (aud) claim must be a string or array of strings present in the ID token"); - } - if (!audience.contains(verifyOptions.audience)) { - throw new TokenValidationException(String.format("Audience (aud) claim mismatch in the ID token; expected \"%s\" but found \"%s\"", verifyOptions.audience, decoded.getAudience())); - } - - // validate org if set - if (verifyOptions.organization != null) { - String org = verifyOptions.organization.trim(); - if (org.startsWith("org_")) { - // org ID - String orgIdClaim = decoded.getClaim("org_id").asString(); - if (isEmpty(orgIdClaim)) { - throw new TokenValidationException("Organization Id (org_id) claim must be a string present in the ID token"); - } - if (!org.equals(orgIdClaim)) { - throw new TokenValidationException(String.format("Organization (org_id) claim mismatch in the ID token; expected \"%s\" but found \"%s\"", verifyOptions.organization, orgIdClaim)); - } - } - else { - // org name - String orgNameClaim = decoded.getClaim("org_name").asString(); - if (isEmpty(orgNameClaim)) { - throw new TokenValidationException("Organization name (org_name) claim must be a string present in the ID token"); - } - if (!org.toLowerCase().equals(orgNameClaim)) { - throw new TokenValidationException(String.format("Organization (org_name) claim mismatch in the ID token; expected \"%s\" but found \"%s\"", verifyOptions.organization, orgNameClaim)); - } - } - } - - // TODO refactor to modern date/time APIs - final Calendar cal = Calendar.getInstance(); - final Date now = verifyOptions.clock != null ? verifyOptions.clock : cal.getTime(); - final int clockSkew = verifyOptions.clockSkew != null ? verifyOptions.clockSkew : DEFAULT_CLOCK_SKEW; - - if (decoded.getExpiresAt() == null) { - throw new TokenValidationException("Expiration Time (exp) claim must be a number present in the ID token"); - } - - cal.setTime(decoded.getExpiresAt()); - cal.add(Calendar.SECOND, clockSkew); - Date expDate = cal.getTime(); - - if (now.after(expDate)) { - throw new TokenValidationException(String.format("Expiration Time (exp) claim error in the ID token; current time (%d) is after expiration time (%d)", now.getTime() / 1000, expDate.getTime() / 1000)); - } - - if (decoded.getIssuedAt() == null) { - throw new TokenValidationException("Issued At (iat) claim must be a number present in the ID token"); - } - - cal.setTime(decoded.getIssuedAt()); - cal.add(Calendar.SECOND, -1 * clockSkew); - - if (verifyOptions.nonce != null) { - String nonceClaim = decoded.getClaim(NONCE_CLAIM).asString(); - if (isEmpty(nonceClaim)) { - throw new TokenValidationException("Nonce (nonce) claim must be a string present in the ID token"); - } - if (!verifyOptions.nonce.equals(nonceClaim)) { - throw new TokenValidationException(String.format("Nonce (nonce) claim mismatch in the ID token; expected \"%s\", found \"%s\"", verifyOptions.nonce, nonceClaim)); - } - } - - if (audience.size() > 1) { - String azpClaim = decoded.getClaim(AZP_CLAIM).asString(); - if (isEmpty(azpClaim)) { - throw new TokenValidationException("Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values"); - } - if (!verifyOptions.audience.equals(azpClaim)) { - throw new TokenValidationException(String.format("Authorized Party (azp) claim mismatch in the ID token; expected \"%s\", found \"%s\"", verifyOptions.audience, azpClaim)); - } - } - - if (verifyOptions.maxAge != null) { - Date authTime = decoded.getClaim(AUTH_TIME_CLAIM).asDate(); - if (authTime == null) { - throw new TokenValidationException("Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified"); - } - - cal.setTime(authTime); - cal.add(Calendar.SECOND, verifyOptions.maxAge); - cal.add(Calendar.SECOND, clockSkew); - Date authTimeDate = cal.getTime(); - - if (now.after(authTimeDate)) { - throw new TokenValidationException(String.format("Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication. Current time (%d) is after last auth at (%d)", now.getTime() / 1000, authTimeDate.getTime() / 1000)); - } - } - } - - private boolean isEmpty(String value) { - return value == null || value.isEmpty(); - } - - static class Options { - String issuer; - final String audience; - final SignatureVerifier verifier; - String nonce; - private Integer maxAge; - Integer clockSkew; - Date clock; - String organization; - - public Options(String issuer, String audience, SignatureVerifier verifier) { - Validate.notNull(issuer, "Issuer must not be null"); - Validate.notNull(audience, "Audience must not be null"); - Validate.notNull(verifier, "SignatureVerifier must not be null"); - this.issuer = issuer; - this.audience = audience; - this.verifier = verifier; - } - - public Options(String audience, SignatureVerifier verifier) { - Validate.notNull(audience, "Audience must not be null"); - Validate.notNull(verifier, "SignatureVerifier must not be null"); - this.audience = audience; - this.verifier = verifier; - } - - void setIssuer(String issuer) { - this.issuer = issuer; - } - - void setNonce(String nonce) { - this.nonce = nonce; - } - - void setMaxAge(Integer maxAge) { - this.maxAge = maxAge; - } - - void setClockSkew(Integer clockSkew) { - this.clockSkew = clockSkew; - } - - void setClock(Date now) { - this.clock = now; - } - - Integer getMaxAge() { - return maxAge; - } - - void setOrganization(String organization) { - this.organization = organization; - } - } -} diff --git a/src/main/java/com/auth0/InvalidRequestException.java b/src/main/java/com/auth0/InvalidRequestException.java index 037338f..03690ca 100644 --- a/src/main/java/com/auth0/InvalidRequestException.java +++ b/src/main/java/com/auth0/InvalidRequestException.java @@ -18,15 +18,4 @@ public class InvalidRequestException extends IdentityVerificationException { super(code, description != null ? description : DEFAULT_DESCRIPTION, cause); } - /** - * Getter for the description of the error. - * - * @return the error description if available, null otherwise. - * @deprecated use {@link #getMessage()} - */ - @Deprecated - public String getDescription() { - return getMessage(); - } - } diff --git a/src/main/java/com/auth0/RandomStorage.java b/src/main/java/com/auth0/RandomStorage.java deleted file mode 100644 index 66659a0..0000000 --- a/src/main/java/com/auth0/RandomStorage.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.auth0; - -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpSession; - -class RandomStorage extends SessionUtils { - - /** - * Check's if the request {@link HttpSession} saved state is equal to the given state. - * After the check, the value will be removed from the session. - * - * @param req the request - * @param state the state value to compare against. - * @return whether the state matches the expected one or not. - */ - static boolean checkSessionState(HttpServletRequest req, String state) { - String currentState = (String) remove(req, StorageUtils.STATE_KEY); - return (currentState == null && state == null) || currentState != null && currentState.equals(state); - } - - /** - * Saves the given state in the request {@link HttpSession}. - * If a state is already bound to the session, the value is replaced. - * - * @param req the request. - * @param state the state value to set. - */ - static void setSessionState(HttpServletRequest req, String state) { - set(req, StorageUtils.STATE_KEY, state); - } - - /** - * Saves the given nonce in the request {@link HttpSession}. - * If a nonce is already bound to the session, the value is replaced. - * - * @param req the request. - * @param nonce the nonce value to set. - */ - static void setSessionNonce(HttpServletRequest req, String nonce) { - set(req, StorageUtils.NONCE_KEY, nonce); - } - - /** - * Removes the nonce present in the request {@link HttpSession} and then returns it. - * - * @param req the HTTP Servlet request. - * @return the nonce value or null if it was not set. - */ - static String removeSessionNonce(HttpServletRequest req) { - return (String) remove(req, StorageUtils.NONCE_KEY); - } -} \ No newline at end of file diff --git a/src/main/java/com/auth0/RequestProcessor.java b/src/main/java/com/auth0/RequestProcessor.java index cc58e7c..5616114 100644 --- a/src/main/java/com/auth0/RequestProcessor.java +++ b/src/main/java/com/auth0/RequestProcessor.java @@ -1,23 +1,34 @@ package com.auth0; -import com.auth0.client.HttpOptions; +import com.auth0.client.LoggingOptions; import com.auth0.client.auth.AuthAPI; import com.auth0.exception.Auth0Exception; +import com.auth0.exception.IdTokenValidationException; +import com.auth0.exception.PublicKeyProviderException; +import com.auth0.jwt.JWT; import com.auth0.json.auth.TokenHolder; -import com.auth0.net.Telemetry; -import com.google.common.annotations.VisibleForTesting; - -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +import com.auth0.jwk.Jwk; +import com.auth0.jwk.JwkException; +import com.auth0.jwk.JwkProvider; +import com.auth0.jwk.UrlJwkProvider; +import com.auth0.net.client.DefaultHttpClient; +import com.auth0.utils.tokens.IdTokenVerifier; +import com.auth0.utils.tokens.SignatureVerifier; +import org.apache.commons.lang3.Validate; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.security.interfaces.RSAPublicKey; import java.util.Arrays; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import static com.auth0.InvalidRequestException.*; /** * Main class to handle the Authorize Redirect request. - * It will try to parse the parameters looking for tokens or an authorization - * code to perform a Code Exchange against the Auth0 servers. + * It will try to parse the parameters looking for tokens or an authorization code to perform a Code Exchange against the Auth0 servers. */ class RequestProcessor { @@ -34,37 +45,32 @@ class RequestProcessor { private static final String KEY_FORM_POST = "form_post"; private static final String KEY_MAX_AGE = "max_age"; - // Visible for testing - private final DomainProvider domainProvider; private final String responseType; private final String clientId; private final String clientSecret; - private final HttpOptions httpOptions; - private SignatureVerifier signatureVerifier; + private final JwkProvider jwkProvider; - // Configuration values passed from Builder for creating per-request - // verification options private final Integer clockSkew; private final Integer authenticationMaxAge; private final String organization; private final String invitation; final boolean useLegacySameSiteCookie; - private AuthAPI client; - private final IdTokenVerifier tokenVerifier; private final String cookiePath; private boolean loggingEnabled = false; private boolean telemetryDisabled = false; + // Cache JwkProviders per domain for MCD support + private final ConcurrentMap jwkProviders = new ConcurrentHashMap<>(); + static class Builder { private final DomainProvider domainProvider; private final String responseType; private final String clientId; private final String clientSecret; - private final HttpOptions httpOptions; - private final SignatureVerifier signatureVerifier; + private JwkProvider jwkProvider; private boolean useLegacySameSiteCookie = true; private Integer clockSkew; private Integer authenticationMaxAge; @@ -75,15 +81,16 @@ static class Builder { public Builder(DomainProvider domainProvider, String responseType, String clientId, - String clientSecret, - HttpOptions httpOptions, - SignatureVerifier signatureVerifier) { + String clientSecret) { this.domainProvider = domainProvider; this.responseType = responseType; this.clientId = clientId; this.clientSecret = clientSecret; - this.httpOptions = httpOptions; - this.signatureVerifier = signatureVerifier; + } + + Builder withJwkProvider(JwkProvider jwkProvider) { + this.jwkProvider = jwkProvider; + return this; } public Builder withClockSkew(Integer clockSkew) { @@ -117,27 +124,22 @@ Builder withInvitation(String invitation) { } RequestProcessor build() { - - return new RequestProcessor(domainProvider, responseType, clientId, clientSecret, httpOptions, - signatureVerifier, new IdTokenVerifier(), - useLegacySameSiteCookie, clockSkew, authenticationMaxAge, organization, invitation, cookiePath); + return new RequestProcessor(domainProvider, responseType, clientId, clientSecret, + jwkProvider, useLegacySameSiteCookie, clockSkew, authenticationMaxAge, + organization, invitation, cookiePath); } } - private RequestProcessor(DomainProvider domainProvider, String responseType, String clientId, String clientSecret, - HttpOptions httpOptions, SignatureVerifier signatureVerifier, IdTokenVerifier tokenVerifier, + private RequestProcessor(DomainProvider domainProvider, String responseType, String clientId, + String clientSecret, JwkProvider jwkProvider, boolean useLegacySameSiteCookie, Integer clockSkew, Integer authenticationMaxAge, String organization, String invitation, String cookiePath) { this.domainProvider = domainProvider; this.responseType = responseType; this.clientId = clientId; this.clientSecret = clientSecret; - this.httpOptions = httpOptions; - this.signatureVerifier = signatureVerifier; - this.tokenVerifier = tokenVerifier; + this.jwkProvider = jwkProvider; this.useLegacySameSiteCookie = useLegacySameSiteCookie; - - // Store individual configuration values instead of pre-built verifyOptions this.clockSkew = clockSkew; this.authenticationMaxAge = authenticationMaxAge; this.organization = organization; @@ -153,67 +155,36 @@ void doNotSendTelemetry() { this.telemetryDisabled = true; } - /** - * Getter for the AuthAPI client instance. - * Used to customize options such as Telemetry and Logging. - * - * @return the AuthAPI client. - */ - AuthAPI getClient() { - return client; - } - AuthAPI createClientForDomain(String domain) { - final AuthAPI client; + DefaultHttpClient.Builder httpBuilder = DefaultHttpClient.newBuilder() + .telemetryEnabled(!telemetryDisabled); - if (httpOptions != null) { - client = new AuthAPI(domain, clientId, clientSecret, httpOptions); - } else { - client = new AuthAPI(domain, clientId, clientSecret); - } - - // Apply deferred settings - client.setLoggingEnabled(loggingEnabled); - if (telemetryDisabled) { - client.doNotSendTelemetry(); - } else { - setupTelemetry(client); + if (loggingEnabled) { + httpBuilder.withLogging(new LoggingOptions(LoggingOptions.LogLevel.BODY)); } - return client; - } - - void setupTelemetry(AuthAPI client) { - Telemetry telemetry = new Telemetry("auth0-java-mvc-common", obtainPackageVersion()); - client.setTelemetry(telemetry); - } - - @VisibleForTesting - String obtainPackageVersion() { - return getClass().getPackage().getImplementationVersion(); + return AuthAPI.newBuilder(domain, clientId, clientSecret) + .withHttpClient(httpBuilder.build()) + .build(); } /** - * Pre builds an Auth0 Authorize Url with the given redirect URI, state and - * nonce parameters. + * Pre builds an Auth0 Authorize Url with the given redirect URI, state and nonce parameters. * - * @param request the request, used to store state and nonce in the Session - * @param response the response, used to set state and nonce as cookies. If - * null, session will be used instead. + * @param request the HTTP request. + * @param response the HTTP response, used to set state and nonce as cookies. * @param redirectUri the url to call with the authentication result. * @param state a valid state value. - * @param nonce the nonce value that will be used if the response type - * contains 'id_token'. Can be null. - * @return the authorize url builder to continue any further parameter - * customization. + * @param nonce the nonce value that will be used if the response type contains 'id_token'. Can be null. + * @return the authorize url builder to continue any further parameter customization. */ AuthorizeUrl buildAuthorizeUrl(HttpServletRequest request, HttpServletResponse response, String redirectUri, - String state, String nonce) { + String state, String nonce) { String originDomain = domainProvider.getDomain(request); AuthAPI client = createClientForDomain(originDomain); - AuthorizeUrl creator = new AuthorizeUrl(client, request, response, redirectUri, responseType) + AuthorizeUrl creator = new AuthorizeUrl(client, response, redirectUri, responseType) .withState(state); if (this.organization != null) { @@ -226,12 +197,8 @@ AuthorizeUrl buildAuthorizeUrl(HttpServletRequest request, HttpServletResponse r creator.withCookiePath(this.cookiePath); } - // null response means state and nonce will be stored in session, so legacy - // cookie flag does not apply and origin domain cookie cannot be set - if (response != null) { - creator.withLegacySameSiteCookie(useLegacySameSiteCookie); - creator.withOriginDomain(originDomain, clientSecret); - } + creator.withLegacySameSiteCookie(useLegacySameSiteCookie); + creator.withOriginDomain(originDomain, clientSecret); return getAuthorizeUrl(nonce, creator); } @@ -240,27 +207,22 @@ AuthorizeUrl buildAuthorizeUrl(HttpServletRequest request, HttpServletResponse r * Entrypoint for HTTP request *

* 1). Responsible for validating the request. - * 2). Exchanging the authorization code received with this HTTP request for - * Auth0 tokens. + * 2). Exchanging the authorization code received with this HTTP request for Auth0 tokens. * 3). Validating the ID Token. * 4). Clearing the stored state, nonce and max_age values. * 5). Handling success and any failure outcomes. * - * @throws IdentityVerificationException if an error occurred while processing - * the request + * @throws IdentityVerificationException if an error occurred while processing the request */ Tokens process(HttpServletRequest request, HttpServletResponse response) throws IdentityVerificationException { assertNoError(request); - assertValidState(request, response); + String state = assertValidState(request, response); - // Extract origin_domain from the HMAC-signed transaction state cookie. - // If the cookie was tampered with, getSignedOriginDomain returns null. - String originDomain = null; - if (response != null) { - originDomain = TransientCookieStore.getSignedOriginDomain(request, response, clientSecret); - } + // Extract origin_domain from the HMAC-signed cookie, bound to this transaction's state. + // If the cookie was tampered with or replayed from a different transaction, returns null. + String originDomain = TransientCookieStore.getSignedOriginDomain(request, response, state, clientSecret); - // Fallback for session-based (deprecated) flow or if cookie was not set + // Fallback if cookie was not set (e.g., single-domain setup without MCD) if (originDomain == null) { originDomain = domainProvider.getDomain(request); } @@ -288,45 +250,24 @@ static boolean requiresFormPostResponseMode(List responseType) { /** * Obtains code request tokens (if using Code flow) and validates the ID token. - * - * @param request the HTTP request - * @param response the HTTP response + * @param request the HTTP request * @param frontChannelTokens the tokens obtained from the front channel - * @param responseTypeList the reponse types - * @param originDomain the domain for this specific request - * @param originIssuer the issuer for this specific request - * @return a Tokens object that wraps the values obtained from the front-channel - * and/or the code request response. + * @param responseTypeList the response types + * @return a Tokens object that wraps the values obtained from the front-channel and/or the code request response. * @throws IdentityVerificationException */ - private Tokens getVerifiedTokens(HttpServletRequest request, HttpServletResponse response, - Tokens frontChannelTokens, - List responseTypeList, String originDomain, String originIssuer) + private Tokens getVerifiedTokens(HttpServletRequest request, HttpServletResponse response, Tokens frontChannelTokens, List responseTypeList, String originDomain, String originIssuer) throws IdentityVerificationException { String authorizationCode = request.getParameter(KEY_CODE); Tokens codeExchangeTokens = null; - // Get nonce for this specific request - String nonce; - if (response != null) { - nonce = TransientCookieStore.getNonce(request, response); - // Fallback to session if cookie was not set (deprecated API path) - if (nonce == null) { - nonce = RandomStorage.removeSessionNonce(request); - } - } else { - nonce = RandomStorage.removeSessionNonce(request); - } - - IdTokenVerifier.Options requestVerifyOptions = createRequestVerifyOptions(originIssuer, nonce); + String nonce = TransientCookieStore.getNonce(request, response); try { if (responseTypeList.contains(KEY_ID_TOKEN)) { // Implicit/Hybrid flow: must verify front-channel ID Token first. - // The issuer is derived from the HMAC-verified domain, so this check - // validates the token's iss against a trusted value. - tokenVerifier.verify(frontChannelTokens.getIdToken(), requestVerifyOptions); + verifyIdToken(frontChannelTokens.getIdToken(), originIssuer, originDomain, nonce); } if (responseTypeList.contains(KEY_CODE)) { // Code/Hybrid flow @@ -336,43 +277,74 @@ private Tokens getVerifiedTokens(HttpServletRequest request, HttpServletResponse // If we already verified the front-channel token, don't verify it again. String idTokenFromCodeExchange = codeExchangeTokens.getIdToken(); if (idTokenFromCodeExchange != null) { - tokenVerifier.verify(idTokenFromCodeExchange, requestVerifyOptions); + verifyIdToken(idTokenFromCodeExchange, originIssuer, originDomain, nonce); } } } - } catch (TokenValidationException e) { - throw new IdentityVerificationException(JWT_VERIFICATION_ERROR, - "An error occurred while trying to verify the ID Token.", e); + } catch (IdTokenValidationException e) { + throw new IdentityVerificationException(JWT_VERIFICATION_ERROR, "An error occurred while trying to verify the ID Token.", e); } catch (Auth0Exception e) { - throw new IdentityVerificationException(API_ERROR, - "An error occurred while exchanging the authorization code.", e); + throw new IdentityVerificationException(API_ERROR, "An error occurred while exchanging the authorization code.", e); } // Keep the front-channel ID Token and the code-exchange Access Token. return mergeTokens(frontChannelTokens, codeExchangeTokens); } /** - * Creates per-request verification options to avoid thread safety issues. - * This creates fresh options from the stored configuration values. + * Verifies an ID token using auth0-java v3's IdTokenVerifier. + * The signature verification strategy is determined by the token's alg header: + * - RS256: uses JwkProvider (customer-provided or auto-discovered per domain) + * - HS256: uses client secret */ - private IdTokenVerifier.Options createRequestVerifyOptions(String issuer, String nonce) { - // Create fresh verification options for this specific request - IdTokenVerifier.Options requestOptions = new IdTokenVerifier.Options(clientId, signatureVerifier); + private void verifyIdToken(String idToken, String issuer, String domain, String nonce) throws IdTokenValidationException { + SignatureVerifier sigVerifier = buildSignatureVerifier(idToken, domain); - requestOptions.setIssuer(issuer); - requestOptions.setNonce(nonce); + IdTokenVerifier.Builder verifierBuilder = IdTokenVerifier.init(issuer, clientId, sigVerifier); if (clockSkew != null) { - requestOptions.setClockSkew(clockSkew); - } - if (authenticationMaxAge != null) { - requestOptions.setMaxAge(authenticationMaxAge); + verifierBuilder.withLeeway(clockSkew); } if (organization != null) { - requestOptions.setOrganization(organization); + verifierBuilder.withOrganization(organization); } - return requestOptions; + IdTokenVerifier verifier = verifierBuilder.build(); + verifier.verify(idToken, nonce, authenticationMaxAge); + } + + /** + * Builds the appropriate SignatureVerifier based on the token's algorithm header. + * - If alg is HS256: use client secret + * - If alg is RS256: use JwkProvider (customer-provided or auto-discovered from domain) + */ + private SignatureVerifier buildSignatureVerifier(String idToken, String domain) { + String algorithm = JWT.decode(idToken).getAlgorithm(); + + if ("HS256".equals(algorithm)) { + return SignatureVerifier.forHS256(clientSecret); + } + + // RS256 (default): use JwkProvider + JwkProvider provider = getJwkProvider(domain); + return SignatureVerifier.forRS256(keyId -> { + try { + Jwk jwk = provider.get(keyId); + return (RSAPublicKey) jwk.getPublicKey(); + } catch (JwkException e) { + throw new PublicKeyProviderException("Failed to get public key for key ID: " + keyId, e); + } + }); + } + + /** + * Gets the JwkProvider for the given domain. If the customer provided one, it is used. + * Otherwise, a UrlJwkProvider is auto-created and cached per domain. + */ + private JwkProvider getJwkProvider(String domain) { + if (jwkProvider != null) { + return jwkProvider; + } + return jwkProviders.computeIfAbsent(domain, d -> new UrlJwkProvider(d)); } List getResponseType() { @@ -394,20 +366,16 @@ private AuthorizeUrl getAuthorizeUrl(String nonce, AuthorizeUrl creator) { } /** - * Extract the tokens from the request parameters, present when using the - * Implicit or Hybrid Grant. + * Extract the tokens from the request parameters, present when using the Implicit or Hybrid Grant. * - * @param request the request + * @param request the request * @param originDomain the domain that issued these tokens * @param originIssuer the issuer that issued these tokens - * @return a new instance of Tokens wrapping the values present in the request - * parameters. + * @return a new instance of Tokens wrapping the values present in the request parameters. */ private Tokens getFrontChannelTokens(HttpServletRequest request, String originDomain, String originIssuer) { - Long expiresIn = request.getParameter(KEY_EXPIRES_IN) == null ? null - : Long.parseLong(request.getParameter(KEY_EXPIRES_IN)); - return new Tokens(request.getParameter(KEY_ACCESS_TOKEN), request.getParameter(KEY_ID_TOKEN), null, - request.getParameter(KEY_TOKEN_TYPE), expiresIn, originDomain, originIssuer); + Long expiresIn = request.getParameter(KEY_EXPIRES_IN) == null ? null : Long.parseLong(request.getParameter(KEY_EXPIRES_IN)); + return new Tokens(request.getParameter(KEY_ACCESS_TOKEN), request.getParameter(KEY_ID_TOKEN), null, request.getParameter(KEY_TOKEN_TYPE), expiresIn, originDomain, originIssuer); } /** @@ -425,63 +393,31 @@ private void assertNoError(HttpServletRequest request) throws InvalidRequestExce } /** - * Checks whether the state received in the request parameters is the same as - * the one in the state cookie or session + * Checks whether the state received in the request parameters is the same as the one in the state cookie * for this request. * - * @param request the request - * @throws InvalidRequestException if the request contains a different state - * from the expected one + * @param request the request + * @param response the response, used to remove the state cookie + * @throws InvalidRequestException if the request contains a different state from the expected one */ - private void assertValidState(HttpServletRequest request, HttpServletResponse response) - throws InvalidRequestException { - // TODO in v2: - // - only store state/nonce in cookies, remove session storage - // - create specific exception classes for various state validation failures - // (missing from auth response, missing - // state cookie, mismatch) - + private String assertValidState(HttpServletRequest request, HttpServletResponse response) throws InvalidRequestException { String stateFromRequest = request.getParameter(KEY_STATE); if (stateFromRequest == null) { - throw new InvalidRequestException(INVALID_STATE_ERROR, - "The received state doesn't match the expected one. No state parameter was found on the authorization response."); - } - - // If response is null, check the Session. - // This can happen when the deprecated handle method that only takes the request - // parameter is called - if (response == null) { - checkSessionState(request, stateFromRequest); - return; + throw new InvalidRequestException(INVALID_STATE_ERROR, "The received state doesn't match the expected one. No state parameter was found on the authorization response."); } String cookieState = TransientCookieStore.getState(request, response); - // Just in case state was stored in Session by building auth URL with deprecated - // method, but then called the - // supported handle method with the request and response if (cookieState == null) { - if (SessionUtils.get(request, StorageUtils.STATE_KEY) == null) { - throw new InvalidRequestException(INVALID_STATE_ERROR, - "The received state doesn't match the expected one. No state cookie or state session attribute found. Check that you are using non-deprecated methods and that cookies are not being removed on the server."); - } - checkSessionState(request, stateFromRequest); - return; + throw new InvalidRequestException(INVALID_STATE_ERROR, "The received state doesn't match the expected one. No state cookie found. Check that cookies are not being removed on the server."); } if (!cookieState.equals(stateFromRequest)) { - throw new InvalidRequestException(INVALID_STATE_ERROR, - "The received state doesn't match the expected one."); + throw new InvalidRequestException(INVALID_STATE_ERROR, "The received state doesn't match the expected one."); } - } - private void checkSessionState(HttpServletRequest request, String stateFromRequest) throws InvalidRequestException { - boolean valid = RandomStorage.checkSessionState(request, stateFromRequest); - if (!valid) { - throw new InvalidRequestException(INVALID_STATE_ERROR, - "The received state doesn't match the expected one."); - } + return stateFromRequest; } /** @@ -489,26 +425,23 @@ private void checkSessionState(HttpServletRequest request, String stateFromReque * * @param authorizationCode the code received on the login response. * @param redirectUri the redirect uri used on login request. - * @param originDomain the domain that issued these tokens. * @return a new instance of {@link Tokens} with the received credentials. * @throws Auth0Exception if the request to the Auth0 server failed. * @see AuthAPI#exchangeCode(String, String) */ - private Tokens exchangeCodeForTokens(String authorizationCode, String redirectUri, String originDomain) - throws Auth0Exception { + private Tokens exchangeCodeForTokens(String authorizationCode, String redirectUri, String originDomain) throws Auth0Exception { AuthAPI client = createClientForDomain(originDomain); TokenHolder holder = client .exchangeCode(authorizationCode, redirectUri) - .execute(); + .execute() + .getBody(); String originIssuer = constructIssuer(originDomain); - return new Tokens(holder.getAccessToken(), holder.getIdToken(), holder.getRefreshToken(), holder.getTokenType(), - holder.getExpiresIn(), originDomain, originIssuer); + return new Tokens(holder.getAccessToken(), holder.getIdToken(), holder.getRefreshToken(), holder.getTokenType(), holder.getExpiresIn(), originDomain, originIssuer); } /** * Used to keep the best version of each token. - * It will prioritize the ID Token received in the front-channel, and the Access - * Token received in the code exchange request. + * It will prioritize the ID Token received in the front-channel, and the Access Token received in the code exchange request. * * @param frontChannelTokens the front-channel obtained tokens. * @param codeExchangeTokens the code-exchange obtained tokens. @@ -535,8 +468,7 @@ private Tokens mergeTokens(Tokens frontChannelTokens, Tokens codeExchangeTokens) } // Prefer ID token from the front-channel - String idToken = frontChannelTokens.getIdToken() != null ? frontChannelTokens.getIdToken() - : codeExchangeTokens.getIdToken(); + String idToken = frontChannelTokens.getIdToken() != null ? frontChannelTokens.getIdToken() : codeExchangeTokens.getIdToken(); // Refresh token only available from the code exchange String refreshToken = codeExchangeTokens.getRefreshToken(); diff --git a/src/main/java/com/auth0/ResolverDomainProvider.java b/src/main/java/com/auth0/ResolverDomainProvider.java index e3ed73e..86dd9eb 100644 --- a/src/main/java/com/auth0/ResolverDomainProvider.java +++ b/src/main/java/com/auth0/ResolverDomainProvider.java @@ -1,6 +1,6 @@ package com.auth0; -import javax.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequest; class ResolverDomainProvider implements DomainProvider { private final DomainResolver resolver; diff --git a/src/main/java/com/auth0/SessionUtils.java b/src/main/java/com/auth0/SessionUtils.java deleted file mode 100644 index a6906dc..0000000 --- a/src/main/java/com/auth0/SessionUtils.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.auth0; - -import org.apache.commons.lang3.Validate; - -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpSession; - -/** - * Helper class to handle easy session key-value storage. - */ -@SuppressWarnings({"WeakerAccess", "unused"}) -public abstract class SessionUtils { - - /** - * Extracts the HttpSession from the given request. - * - * @param req a valid request to get the session from - * @return the session of the request - */ - protected static HttpSession getSession(HttpServletRequest req) { - return req.getSession(true); - } - - /** - * Set's the attribute value to the request session. - * - * @param req a valid request to get the session from - * @param name the name of the attribute - * @param value the value to set - */ - public static void set(HttpServletRequest req, String name, Object value) { - Validate.notNull(req); - Validate.notNull(name); - getSession(req).setAttribute(name, value); - } - - /** - * Get the attribute with the given name from the request session. - * - * @param req a valid request to get the session from - * @param name the name of the attribute - * @return the attribute stored in the session or null if it doesn't exists - */ - public static Object get(HttpServletRequest req, String name) { - Validate.notNull(req); - Validate.notNull(name); - return getSession(req).getAttribute(name); - } - - /** - * Same as {@link #get(HttpServletRequest, String)} but it also removes the value from the request session. - * - * @param req a valid request to get the session from - * @param name the name of the attribute - * @return the attribute stored in the session or null if it doesn't exists - */ - public static Object remove(HttpServletRequest req, String name) { - Validate.notNull(req); - Validate.notNull(name); - Object value = get(req, name); - getSession(req).removeAttribute(name); - return value; - } -} diff --git a/src/main/java/com/auth0/SignatureVerifier.java b/src/main/java/com/auth0/SignatureVerifier.java deleted file mode 100644 index 3d41df0..0000000 --- a/src/main/java/com/auth0/SignatureVerifier.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.auth0; - -import com.auth0.jwt.JWT; -import com.auth0.jwt.JWTVerifier; -import com.auth0.jwt.exceptions.JWTDecodeException; -import com.auth0.jwt.exceptions.JWTVerificationException; -import com.auth0.jwt.exceptions.SignatureVerificationException; -import com.auth0.jwt.interfaces.DecodedJWT; -import org.apache.commons.lang3.Validate; - -import java.util.Arrays; -import java.util.List; - -abstract class SignatureVerifier { - - private final JWTVerifier verifier; - private final List acceptedAlgorithms; - - /** - * Creates a new JWT Signature Verifier. - * This instance will validate the token was signed using an expected algorithm - * and then proceed to verify its signature - * - * @param verifier the instance that knows how to verify the signature. When null, the signature will not be checked. - * @param algorithm the accepted algorithms. Must never be null! - */ - SignatureVerifier(JWTVerifier verifier, String... algorithm) { - Validate.notEmpty(algorithm); - this.verifier = verifier; - this.acceptedAlgorithms = Arrays.asList(algorithm); - } - - private DecodedJWT decodeToken(String token) throws TokenValidationException { - try { - return JWT.decode(token); - } catch (JWTDecodeException e) { - throw new TokenValidationException("ID token could not be decoded", e); - } - } - - DecodedJWT verifySignature(String token) throws TokenValidationException { - DecodedJWT decoded = decodeToken(token); - if (!this.acceptedAlgorithms.contains(decoded.getAlgorithm())) { - throw new TokenValidationException(String.format("Signature algorithm of \"%s\" is not supported. Expected the ID token to be signed with \"%s\".", decoded.getAlgorithm(), this.acceptedAlgorithms)); - } - if (verifier != null) { - try { - verifier.verify(decoded); - } catch (SignatureVerificationException e) { - throw new TokenValidationException("Invalid token signature", e); - } catch (JWTVerificationException ignored) { - //NO-OP. Will be catch on a different step - //Would only trigger for "expired tokens" (invalid exp) - } - } - - return decoded; - } -} diff --git a/src/main/java/com/auth0/SignedCookieUtils.java b/src/main/java/com/auth0/SignedCookieUtils.java index 544d750..33a10cc 100644 --- a/src/main/java/com/auth0/SignedCookieUtils.java +++ b/src/main/java/com/auth0/SignedCookieUtils.java @@ -37,6 +37,25 @@ static String sign(String value, String secret) { return value + SEPARATOR + signature; } + /** + * Signs a value using HMAC-SHA256 with the provided secret, binding it to a + * context value (e.g., state). The context is included in the HMAC computation + * but not stored in the cookie — the verifier must supply the same context. + * + * @param value the value to sign and store + * @param context the binding context (e.g., state parameter) included in HMAC + * @param secret the secret key for HMAC + * @return the signed value in the format {@code value|signature} + * @throws IllegalArgumentException if any argument is null + */ + static String sign(String value, String context, String secret) { + if (value == null || context == null || secret == null) { + throw new IllegalArgumentException("Value, context, and secret must not be null"); + } + String signature = computeHmac(value + SEPARATOR + context, secret); + return value + SEPARATOR + signature; + } + /** * Verifies the HMAC signature and extracts the original value. * @@ -70,6 +89,42 @@ static String verifyAndExtract(String signedValue, String secret) { return null; } + /** + * Verifies the HMAC signature (which was computed with a binding context) and + * extracts the original value. + * + * @param signedValue the signed value in the format {@code value|signature} + * @param context the binding context that was used during signing + * @param secret the secret key used to verify the HMAC + * @return the original value if the signature is valid, or {@code null} if + * the signature is invalid, the context doesn't match, or the format + * is unexpected + */ + static String verifyAndExtract(String signedValue, String context, String secret) { + if (signedValue == null || context == null || secret == null) { + return null; + } + + int separatorIndex = signedValue.lastIndexOf(SEPARATOR); + if (separatorIndex <= 0 || separatorIndex >= signedValue.length() - 1) { + return null; + } + + String value = signedValue.substring(0, separatorIndex); + String signature = signedValue.substring(separatorIndex + 1); + + String expectedSignature = computeHmac(value + SEPARATOR + context, secret); + + // Constant-time comparison to prevent timing attacks + if (MessageDigest.isEqual( + expectedSignature.getBytes(StandardCharsets.UTF_8), + signature.getBytes(StandardCharsets.UTF_8))) { + return value; + } + + return null; + } + private static String computeHmac(String value, String secret) { try { Mac mac = Mac.getInstance(HMAC_ALGORITHM); diff --git a/src/main/java/com/auth0/StaticDomainProvider.java b/src/main/java/com/auth0/StaticDomainProvider.java index c0421ca..1f806a3 100644 --- a/src/main/java/com/auth0/StaticDomainProvider.java +++ b/src/main/java/com/auth0/StaticDomainProvider.java @@ -1,6 +1,6 @@ package com.auth0; -import javax.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequest; class StaticDomainProvider implements DomainProvider { private final String domain; diff --git a/src/main/java/com/auth0/SymmetricSignatureVerifier.java b/src/main/java/com/auth0/SymmetricSignatureVerifier.java deleted file mode 100644 index 81b013f..0000000 --- a/src/main/java/com/auth0/SymmetricSignatureVerifier.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.auth0; - -import com.auth0.jwt.JWT; -import com.auth0.jwt.JWTVerifier; -import com.auth0.jwt.algorithms.Algorithm; - -@SuppressWarnings("unused") -class SymmetricSignatureVerifier extends SignatureVerifier { - - SymmetricSignatureVerifier(String secret) { - super(createJWTVerifier(secret), "HS256"); - } - - private static JWTVerifier createJWTVerifier(String secret) { - Algorithm alg = Algorithm.HMAC256(secret); - return JWT.require(alg) - .ignoreIssuedAt() - .build(); - } -} diff --git a/src/main/java/com/auth0/TokenValidationException.java b/src/main/java/com/auth0/TokenValidationException.java deleted file mode 100644 index e0616ac..0000000 --- a/src/main/java/com/auth0/TokenValidationException.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.auth0; - -class TokenValidationException extends RuntimeException { - - TokenValidationException(String message) { - super(message); - } - - TokenValidationException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/src/main/java/com/auth0/TransientCookieStore.java b/src/main/java/com/auth0/TransientCookieStore.java index 8ede8f3..7fb6b34 100644 --- a/src/main/java/com/auth0/TransientCookieStore.java +++ b/src/main/java/com/auth0/TransientCookieStore.java @@ -2,9 +2,9 @@ import org.apache.commons.lang3.Validate; -import javax.servlet.http.Cookie; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; @@ -67,39 +67,42 @@ static String getNonce(HttpServletRequest request, HttpServletResponse response) } /** - * Stores the origin domain as an HMAC-signed cookie. The issuer is not stored - * separately — it is always derived from the domain on callback to prevent - * tampering. + * Stores the origin domain as an HMAC-signed cookie, bound to the state parameter. + * The HMAC is computed over both the domain and the state, ensuring the cookie + * cannot be replayed across different transactions. * * @param response the response to set the cookie on * @param domain the resolved Auth0 domain + * @param state the state parameter for this transaction (used as HMAC binding context) * @param sameSite the SameSite attribute value * @param path the cookie path, or null * @param isSecure whether to set the Secure attribute * @param secret the client secret used for HMAC signing */ - static void storeSignedOriginDomain(HttpServletResponse response, String domain, + static void storeSignedOriginDomain(HttpServletResponse response, String domain, String state, SameSite sameSite, String path, boolean isSecure, String secret) { - String signedDomain = SignedCookieUtils.sign(domain, secret); + String signedDomain = SignedCookieUtils.sign(domain, state, secret); store(response, StorageUtils.ORIGIN_DOMAIN_KEY, signedDomain, sameSite, true, isSecure, path); } /** - * Retrieves and verifies the HMAC-signed origin domain cookie. + * Retrieves and verifies the HMAC-signed origin domain cookie, checking that + * the HMAC was computed with the given state (transaction binding). * * @param request the request to read the cookie from * @param response the response used to delete the cookie after reading + * @param state the state parameter from this callback request * @param secret the client secret used for HMAC verification - * @return the verified domain value, or {@code null} if the cookie is missing - * or the signature is invalid (tampered) + * @return the verified domain value, or {@code null} if the cookie is missing, + * the signature is invalid, or the state doesn't match (replay attempt) */ static String getSignedOriginDomain(HttpServletRequest request, HttpServletResponse response, - String secret) { + String state, String secret) { String signedValue = getOnce(StorageUtils.ORIGIN_DOMAIN_KEY, request, response); if (signedValue == null) { return null; } - return SignedCookieUtils.verifyAndExtract(signedValue, secret); + return SignedCookieUtils.verifyAndExtract(signedValue, state, secret); } private static void store(HttpServletResponse response, String key, String value, SameSite sameSite, boolean useLegacySameSiteCookie, boolean isSecureCookie, String cookiePath) { diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java new file mode 100644 index 0000000..f82d7ae --- /dev/null +++ b/src/main/java/module-info.java @@ -0,0 +1,20 @@ +module com.auth0.mvc.commons { + + // Public API + exports com.auth0; + + // Auth0 SDKs + requires transitive com.auth0.java; + requires transitive com.auth0.jwt; + requires transitive com.auth0.jwks; + + // Jakarta Servlet + requires transitive jakarta.servlet; + + // Apache Commons + requires org.apache.commons.lang3; + requires org.apache.commons.codec; + + // Guava (used for @VisibleForTesting) + requires com.google.common; +} diff --git a/src/test/java/com/auth0/AuthenticationControllerTest.java b/src/test/java/com/auth0/AuthenticationControllerTest.java index 32239d8..4fc78ea 100644 --- a/src/test/java/com/auth0/AuthenticationControllerTest.java +++ b/src/test/java/com/auth0/AuthenticationControllerTest.java @@ -1,6 +1,5 @@ package com.auth0; -import com.auth0.client.HttpOptions; import com.auth0.jwk.JwkProvider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -9,8 +8,8 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.Is.is; @@ -30,8 +29,6 @@ public class AuthenticationControllerTest { @Mock private JwkProvider mockJwkProvider; @Mock - private HttpOptions mockHttpOptions; - @Mock private DomainResolver mockDomainResolver; @Mock private Tokens mockTokens; @@ -41,7 +38,7 @@ public class AuthenticationControllerTest { @BeforeEach public void setUp() { - MockitoAnnotations.initMocks(this); + MockitoAnnotations.openMocks(this); request = new MockHttpServletRequest(); response = new MockHttpServletResponse(); } @@ -90,7 +87,6 @@ public void shouldConfigureBuilderWithAllOptions() { .withLegacySameSiteCookie(false) .withOrganization("org_123") .withInvitation("inv_456") - .withHttpOptions(mockHttpOptions) .withCookiePath("/custom") .build(); @@ -139,7 +135,6 @@ public void shouldValidateNullParameters() { assertThrows(NullPointerException.class, () -> builder.withAuthenticationMaxAge(null)); assertThrows(NullPointerException.class, () -> builder.withOrganization(null)); assertThrows(NullPointerException.class, () -> builder.withInvitation(null)); - assertThrows(NullPointerException.class, () -> builder.withHttpOptions(null)); assertThrows(NullPointerException.class, () -> builder.withCookiePath(null)); } @@ -326,15 +321,6 @@ public void shouldBuildWithDomainResolver() { assertThat(controller, is(notNullValue())); } - @Test - public void shouldBuildWithCustomHttpOptions() { - AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) - .withHttpOptions(mockHttpOptions) - .build(); - - assertThat(controller, is(notNullValue())); - } - @Test public void shouldBuildWithOrganizationAndInvitation() { AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) diff --git a/src/test/java/com/auth0/AuthorizeUrlTest.java b/src/test/java/com/auth0/AuthorizeUrlTest.java index 5818265..d8058ec 100644 --- a/src/test/java/com/auth0/AuthorizeUrlTest.java +++ b/src/test/java/com/auth0/AuthorizeUrlTest.java @@ -1,23 +1,21 @@ package com.auth0; -import com.auth0.client.HttpOptions; import com.auth0.client.auth.AuthAPI; import com.auth0.exception.Auth0Exception; import com.auth0.json.auth.PushedAuthorizationResponse; import com.auth0.net.Request; +import com.auth0.net.Response; import okhttp3.HttpUrl; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletResponse; import java.util.Collection; -import java.util.Map; import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.matchesPattern; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.*; @@ -28,18 +26,16 @@ public class AuthorizeUrlTest { private AuthAPI client; private HttpServletResponse response; - private HttpServletRequest request; @BeforeEach public void setUp() { - client = new AuthAPI("domain.auth0.com", "clientId", "clientSecret"); - request = new MockHttpServletRequest(); + client = AuthAPI.newBuilder("domain.auth0.com", "clientId", "clientSecret").build(); response = new MockHttpServletResponse(); } @Test public void shouldBuildValidStringUrl() { - String url = new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token token") + String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") .build(); assertThat(url, is(notNullValue())); assertThat(HttpUrl.parse(url), is(notNullValue())); @@ -47,28 +43,28 @@ public void shouldBuildValidStringUrl() { @Test public void shouldSetDefaultScope() { - String url = new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token token") + String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") .build(); assertThat(HttpUrl.parse(url).queryParameter("scope"), is("openid")); } @Test public void shouldSetResponseType() { - String url = new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token token") + String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") .build(); assertThat(HttpUrl.parse(url).queryParameter("response_type"), is("id_token token")); } @Test public void shouldSetRedirectUrl() { - String url = new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token token") + String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") .build(); assertThat(HttpUrl.parse(url).queryParameter("redirect_uri"), is("https://redirect.to/me")); } @Test public void shouldSetConnection() { - String url = new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token token") + String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") .withConnection("facebook") .build(); assertThat(HttpUrl.parse(url).queryParameter("connection"), is("facebook")); @@ -76,7 +72,7 @@ public void shouldSetConnection() { @Test public void shouldSetAudience() { - String url = new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token token") + String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") .withAudience("https://api.auth0.com/") .build(); assertThat(HttpUrl.parse(url).queryParameter("audience"), is("https://api.auth0.com/")); @@ -84,20 +80,20 @@ public void shouldSetAudience() { @Test public void shouldSetNonceSameSiteAndLegacyCookieByDefault() { - String url = new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token token") + String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") .withNonce("asdfghjkl") .build(); assertThat(HttpUrl.parse(url).queryParameter("nonce"), is("asdfghjkl")); Collection headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(2)); - assertThat(headers, hasItem("com.auth0.nonce=asdfghjkl; HttpOnly; Max-Age=600; SameSite=None; Secure")); - assertThat(headers, hasItem("_com.auth0.nonce=asdfghjkl; HttpOnly; Max-Age=600")); + assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.nonce=asdfghjkl; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); + assertThat(headers, hasItem(matchesPattern("_com\\.auth0\\.nonce=asdfghjkl; Max-Age=600; Expires=.*?; HttpOnly"))); } @Test public void shouldSetNonceSameSiteAndNotLegacyCookieWhenConfigured() { - String url = new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token token") + String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") .withNonce("asdfghjkl") .withLegacySameSiteCookie(false) .build(); @@ -105,25 +101,25 @@ public void shouldSetNonceSameSiteAndNotLegacyCookieWhenConfigured() { Collection headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(1)); - assertThat(headers, hasItem("com.auth0.nonce=asdfghjkl; HttpOnly; Max-Age=600; SameSite=None; Secure")); + assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.nonce=asdfghjkl; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); } @Test public void shouldSetStateSameSiteAndLegacyCookieByDefault() { - String url = new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token token") + String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") .withState("asdfghjkl") .build(); assertThat(HttpUrl.parse(url).queryParameter("state"), is("asdfghjkl")); Collection headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(2)); - assertThat(headers, hasItem("com.auth0.state=asdfghjkl; HttpOnly; Max-Age=600; SameSite=None; Secure")); - assertThat(headers, hasItem("_com.auth0.state=asdfghjkl; HttpOnly; Max-Age=600")); + assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=asdfghjkl; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); + assertThat(headers, hasItem(matchesPattern("_com\\.auth0\\.state=asdfghjkl; Max-Age=600; Expires=.*?; HttpOnly"))); } @Test public void shouldSetStateSameSiteAndNotLegacyCookieWhenConfigured() { - String url = new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token token") + String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") .withState("asdfghjkl") .withLegacySameSiteCookie(false) .build(); @@ -131,12 +127,12 @@ public void shouldSetStateSameSiteAndNotLegacyCookieWhenConfigured() { Collection headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(1)); - assertThat(headers, hasItem("com.auth0.state=asdfghjkl; HttpOnly; Max-Age=600; SameSite=None; Secure")); + assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=asdfghjkl; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); } @Test public void shouldSetSecureCookieWhenConfiguredTrue() { - String url = new AuthorizeUrl(client, request, response, "https://redirect.to/me", "code") + String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "code") .withState("asdfghjkl") .withSecureCookie(true) .build(); @@ -144,12 +140,12 @@ public void shouldSetSecureCookieWhenConfiguredTrue() { Collection headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(1)); - assertThat(headers, hasItem("com.auth0.state=asdfghjkl; HttpOnly; Max-Age=600; SameSite=Lax; Secure")); + assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=asdfghjkl; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=Lax"))); } @Test public void shouldSetSecureCookieWhenConfiguredFalseAndSameSiteNone() { - String url = new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token") + String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token") .withState("asdfghjkl") .withSecureCookie(false) .build(); @@ -157,24 +153,13 @@ public void shouldSetSecureCookieWhenConfiguredFalseAndSameSiteNone() { Collection headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(2)); - assertThat(headers, hasItem("com.auth0.state=asdfghjkl; HttpOnly; Max-Age=600; SameSite=None; Secure")); - assertThat(headers, hasItem("_com.auth0.state=asdfghjkl; HttpOnly; Max-Age=600")); + assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=asdfghjkl; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); + assertThat(headers, hasItem(matchesPattern("_com\\.auth0\\.state=asdfghjkl; Max-Age=600; Expires=.*?; HttpOnly"))); } @Test public void shouldSetNoCookiesWhenNonceAndStateNotSet() { - String url = new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token token") - .build(); - assertThat(HttpUrl.parse(url).queryParameter("state"), nullValue()); - assertThat(HttpUrl.parse(url).queryParameter("nonce"), nullValue()); - - Collection headers = response.getHeaders("Set-Cookie"); - assertThat(headers.size(), is(0)); - } - - @Test - public void shouldSetNoSessionValuesWhenNonceAndStateNotSet() { - String url = new AuthorizeUrl(client, request, null, "https://redirect.to/me", "id_token token") + String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") .build(); assertThat(HttpUrl.parse(url).queryParameter("state"), nullValue()); assertThat(HttpUrl.parse(url).queryParameter("nonce"), nullValue()); @@ -185,7 +170,7 @@ public void shouldSetNoSessionValuesWhenNonceAndStateNotSet() { @Test public void shouldSetScope() { - String url = new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token token") + String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") .withScope("openid profile email") .build(); assertThat(HttpUrl.parse(url).queryParameter("scope"), is("openid profile email")); @@ -193,7 +178,7 @@ public void shouldSetScope() { @Test public void shouldSetCustomParameterScope() { - String url = new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token token") + String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") .withParameter("custom", "value") .build(); assertThat(HttpUrl.parse(url).queryParameter("custom"), is("value")); @@ -201,7 +186,7 @@ public void shouldSetCustomParameterScope() { @Test public void shouldThrowWhenReusingTheInstance() { - AuthorizeUrl builder = new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token token"); + AuthorizeUrl builder = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token"); String firstCall = builder.build(); assertThat(firstCall, is(notNullValue())); IllegalStateException e = assertThrows(IllegalStateException.class, builder::build); @@ -212,7 +197,7 @@ public void shouldThrowWhenReusingTheInstance() { public void shouldThrowWhenChangingTheRedirectURI() { IllegalArgumentException e = assertThrows( IllegalArgumentException.class, - () -> new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token token") + () -> new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") .withParameter("redirect_uri", "new_value")); assertEquals("Redirect URI cannot be changed once set.", e.getMessage()); } @@ -221,7 +206,7 @@ public void shouldThrowWhenChangingTheRedirectURI() { public void shouldThrowWhenChangingTheResponseType() { IllegalArgumentException e = assertThrows( IllegalArgumentException.class, - () -> new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token token") + () -> new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") .withParameter("response_type", "new_value")); assertEquals("Response type cannot be changed once set.", e.getMessage()); } @@ -230,7 +215,7 @@ public void shouldThrowWhenChangingTheResponseType() { public void shouldThrowWhenChangingTheStateUsingCustomParameterSetter() { IllegalArgumentException e = assertThrows( IllegalArgumentException.class, - () -> new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token token") + () -> new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") .withParameter("state", "new_value")); assertEquals("Please, use the dedicated methods for setting the 'nonce' and 'state' parameters.", e.getMessage()); } @@ -239,20 +224,26 @@ public void shouldThrowWhenChangingTheStateUsingCustomParameterSetter() { public void shouldThrowWhenChangingTheNonceUsingCustomParameterSetter() { IllegalArgumentException e = assertThrows( IllegalArgumentException.class, - () -> new AuthorizeUrl(client, request, response, "https://redirect.to/me", "id_token token") + () -> new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") .withParameter("nonce", "new_value")); assertEquals("Please, use the dedicated methods for setting the 'nonce' and 'state' parameters.", e.getMessage()); } @Test public void shouldGetAuthorizeUrlFromPAR() throws Exception { - AuthAPIStub authAPIStub = new AuthAPIStub("https://domain.com", "clientId", "clientSecret"); + AuthAPI authAPIMock = mock(AuthAPI.class); Request requestMock = mock(Request.class); - when(requestMock.execute()).thenReturn(new PushedAuthorizationResponse("urn:example:bwc4JK-ESC0w8acc191e-Y1LTC2", 90)); + Response pushedAuthorizationResponseResponse = mock(Response.class); + when(requestMock.execute()).thenReturn(pushedAuthorizationResponseResponse); + when(requestMock.execute().getBody()).thenReturn(new PushedAuthorizationResponse("urn:example:bwc4JK-ESC0w8acc191e-Y1LTC2", 90)); - authAPIStub.pushedAuthorizationResponseRequest = requestMock; - String url = new AuthorizeUrl(authAPIStub, request, response, "https://domain.com/callback", "code") + when(authAPIMock.pushedAuthorizationRequest(eq("https://domain.com/callback"), eq("code"), anyMap())) + .thenReturn(requestMock); + when(authAPIMock.authorizeUrlWithPAR("urn:example:bwc4JK-ESC0w8acc191e-Y1LTC2")) + .thenReturn("https://domain.com/authorize?client_id=clientId&request_uri=urn%3Aexample%3Abwc4JK-ESC0w8acc191e-Y1LTC2"); + + String url = new AuthorizeUrl(authAPIMock, response, "https://domain.com/callback", "code") .fromPushedAuthorizationRequest(); assertThat(url, is("https://domain.com/authorize?client_id=clientId&request_uri=urn%3Aexample%3Abwc4JK-ESC0w8acc191e-Y1LTC2")); @@ -260,14 +251,17 @@ public void shouldGetAuthorizeUrlFromPAR() throws Exception { @Test public void fromPushedAuthorizationRequestThrowsWhenRequestUriIsNull() throws Exception { - AuthAPIStub authAPIStub = new AuthAPIStub("https://domain.com", "clientId", "clientSecret"); + AuthAPI authAPIMock = mock(AuthAPI.class); Request requestMock = mock(Request.class); - when(requestMock.execute()).thenReturn(new PushedAuthorizationResponse(null, 90)); + Response pushedAuthorizationResponseResponse = mock(Response.class); + when(requestMock.execute()).thenReturn(pushedAuthorizationResponseResponse); + when(requestMock.execute().getBody()).thenReturn(new PushedAuthorizationResponse(null, 90)); - authAPIStub.pushedAuthorizationResponseRequest = requestMock; + when(authAPIMock.pushedAuthorizationRequest(eq("https://domain.com/callback"), eq("code"), anyMap())) + .thenReturn(requestMock); InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> { - new AuthorizeUrl(authAPIStub, request, response, "https://domain.com/callback", "code") + new AuthorizeUrl(authAPIMock, response, "https://domain.com/callback", "code") .fromPushedAuthorizationRequest(); }); @@ -276,14 +270,17 @@ public void fromPushedAuthorizationRequestThrowsWhenRequestUriIsNull() throws Ex @Test public void fromPushedAuthorizationRequestThrowsWhenRequestUriIsEmpty() throws Exception { - AuthAPIStub authAPIStub = new AuthAPIStub("https://domain.com", "clientId", "clientSecret"); + AuthAPI authAPIMock = mock(AuthAPI.class); Request requestMock = mock(Request.class); - when(requestMock.execute()).thenReturn(new PushedAuthorizationResponse("urn:example:bwc4JK-ESC0w8acc191e-Y1LTC2", null)); + Response pushedAuthorizationResponseResponse = mock(Response.class); + when(requestMock.execute()).thenReturn(pushedAuthorizationResponseResponse); + when(requestMock.execute().getBody()).thenReturn(new PushedAuthorizationResponse("urn:example:bwc4JK-ESC0w8acc191e-Y1LTC2", null)); - authAPIStub.pushedAuthorizationResponseRequest = requestMock; + when(authAPIMock.pushedAuthorizationRequest(eq("https://domain.com/callback"), eq("code"), anyMap())) + .thenReturn(requestMock); InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> { - new AuthorizeUrl(authAPIStub, request, response, "https://domain.com/callback", "code") + new AuthorizeUrl(authAPIMock, response, "https://domain.com/callback", "code") .fromPushedAuthorizationRequest(); }); @@ -292,14 +289,17 @@ public void fromPushedAuthorizationRequestThrowsWhenRequestUriIsEmpty() throws E @Test public void fromPushedAuthorizationRequestThrowsWhenExpiresInIsNull() throws Exception { - AuthAPIStub authAPIStub = new AuthAPIStub("https://domain.com", "clientId", "clientSecret"); + AuthAPI authAPIMock = mock(AuthAPI.class); Request requestMock = mock(Request.class); - when(requestMock.execute()).thenReturn(new PushedAuthorizationResponse(null, 90)); + Response pushedAuthorizationResponseResponse = mock(Response.class); + when(requestMock.execute()).thenReturn(pushedAuthorizationResponseResponse); + when(requestMock.execute().getBody()).thenReturn(new PushedAuthorizationResponse(null, 90)); - authAPIStub.pushedAuthorizationResponseRequest = requestMock; + when(authAPIMock.pushedAuthorizationRequest(eq("https://domain.com/callback"), eq("code"), anyMap())) + .thenReturn(requestMock); InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> { - new AuthorizeUrl(authAPIStub, request, response, "https://domain.com/callback", "code") + new AuthorizeUrl(authAPIMock, response, "https://domain.com/callback", "code") .fromPushedAuthorizationRequest(); }); @@ -317,7 +317,7 @@ public void fromPushedAuthorizationRequestThrowsWhenRequestThrows() throws Excep .thenReturn(requestMock); InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> { - new AuthorizeUrl(authAPIMock, request, response, "https://domain.com/callback", "code") + new AuthorizeUrl(authAPIMock, response, "https://domain.com/callback", "code") .fromPushedAuthorizationRequest(); }); @@ -325,21 +325,4 @@ public void fromPushedAuthorizationRequestThrowsWhenRequestThrows() throws Excep assertThat(exception.getCause(), instanceOf(Auth0Exception.class)); } - static class AuthAPIStub extends AuthAPI { - - Request pushedAuthorizationResponseRequest; - - public AuthAPIStub(String domain, String clientId, String clientSecret, HttpOptions options) { - super(domain, clientId, clientSecret, options); - } - - public AuthAPIStub(String domain, String clientId, String clientSecret) { - super(domain, clientId, clientSecret); - } - - @Override - public Request pushedAuthorizationRequest(String redirectUri, String responseType, Map params) { - return pushedAuthorizationResponseRequest; - } - } } diff --git a/src/test/java/com/auth0/IdTokenVerifierTest.java b/src/test/java/com/auth0/IdTokenVerifierTest.java deleted file mode 100644 index ced54c7..0000000 --- a/src/test/java/com/auth0/IdTokenVerifierTest.java +++ /dev/null @@ -1,680 +0,0 @@ -package com.auth0; - -import com.auth0.jwt.JWT; -import com.auth0.jwt.algorithms.Algorithm; -import com.auth0.jwt.interfaces.DecodedJWT; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.Calendar; -import java.util.Date; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -public class IdTokenVerifierTest { - - private final static String DOMAIN = "tokens-test.auth0.com"; - private final static String AUDIENCE = "tokens-test-123"; - - // Default clock time of September 2, 2019 5:00:00 AM GMT - private final static Date DEFAULT_CLOCK = new Date(1567400400000L); - private final static Integer DEFAULT_CLOCK_SKEW = 60; - - private SignatureVerifier signatureVerifier; - - @BeforeEach - public void setUp() { - signatureVerifier = mock(SignatureVerifier.class); - } - - @Test - public void failsToCreateOptionsWhenIssuerIsNull() { - assertThrows(NullPointerException.class, - () -> new IdTokenVerifier.Options(null, "audience", signatureVerifier)); - } - - @Test - public void failsToCreateOptionsWhenAudienceIsNull() { - assertThrows(NullPointerException.class, - () -> new IdTokenVerifier.Options("issuer", null, signatureVerifier)); - } - - @Test - public void failsToCreateOptionsWhenVerifierIsNull() { - assertThrows(NullPointerException.class, - () -> new IdTokenVerifier.Options("issuer", "audience", null)); - } - - @Test - public void failsWhenIDTokenMissing() { - IdTokenVerifier.Options opts = new IdTokenVerifier.Options("issuer", "audience", signatureVerifier); - IdTokenVerifier verifier = new IdTokenVerifier(); - TokenValidationException e = assertThrows(TokenValidationException.class, () -> verifier.verify(null, opts)); - assertEquals("ID token is required but missing", e.getMessage()); - } - - @Test - public void failsWhenIDTokenEmpty() { - IdTokenVerifier.Options opts = new IdTokenVerifier.Options("issuer", "audience", signatureVerifier); - IdTokenVerifier verifier = new IdTokenVerifier(); - TokenValidationException e = assertThrows(TokenValidationException.class, () -> verifier.verify("", opts)); - assertEquals("ID token is required but missing", e.getMessage()); - } - - @Test - public void failsWhenOptionsIsNull() { - assertThrows(NullPointerException.class, () -> new IdTokenVerifier().verify("token", null)); - } - - @Test - public void failsWhenTokenCannotBeDecoded() { - String token = "boom!"; - - SignatureVerifier signatureVerifier = new SymmetricSignatureVerifier("secret"); - IdTokenVerifier.Options opts = new IdTokenVerifier.Options(DOMAIN, AUDIENCE, signatureVerifier); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, opts)); - assertEquals("ID token could not be decoded", e.getMessage()); - } - - @Test - public void failsWhenSignatureIsInvalid() { - String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6IjEyMzQiLCJpc3MiOiJodHRwczovL21lLmF1dGgwLmNvbS8iLCJhdWQiOiJkYU9nbkdzUlloa3d1NjIxdmYiLCJzdWIiOiJhdXRoMHx1c2VyMTIzIn0.a7ayNmFTxS2D-EIoUikoJ6dck7I8veWyxnje_mYD3qY"; - - SignatureVerifier verifier = new SymmetricSignatureVerifier("asdlk59ckvkr"); - IdTokenVerifier.Options opts = new IdTokenVerifier.Options(DOMAIN, AUDIENCE, verifier); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, opts)); - assertEquals("Invalid token signature", e.getMessage()); - } - - @Test - public void failsWhenIssuerMissing() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3NDg2ODAwLCJpYXQiOjE1NjczMTQwMDAsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMiLCJhdXRoX3RpbWUiOjE1NjczMTQwMDB9.B4PGlucyy-fJ4v5NNK2hntvjAf5m8dJf84WttwVnzV0ZlfPbYUSJm7Vc1ys7iMqXAQzAl2I8bDf2qhtLjaLpDKAH9JUvowUpCL7Bgjd7AEc1Te_IUwwxlpCupgseOEL2nrY8enP6On7BO7BBpngmVwnD1DvuA4lNoaaFyWUopha5Dxd5jw64wMqP4lz13C6Kqs8mINZkkw-NgE8DvWszaXeyPaowy-QpfXmPBnw75YLZlGcjr-WQsWQV7rUezq4Tl_11uPivR-fNcEWdG1mAtsnQnB_zJJKaHYlE0g4fey_6H9FKmCvcNkpBGo9ylbitb7jIuExbFEvEd2r_4wKl0g"; - IdTokenVerifier.Options options = configureOptions(token); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, options)); - assertEquals("Issuer (iss) claim must be a string present in the ID token", e.getMessage()); - } - - @Test - public void failsWhenIssuerInvalid() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJzb21ldGhpbmctZWxzZSIsInN1YiI6ImF1dGgwfDEyMzQ1Njc4OSIsImF1ZCI6WyJ0b2tlbnMtdGVzdC0xMjMiLCJleHRlcm5hbC10ZXN0LTEyMyJdLCJleHAiOjE1Njc0ODY4MDAsImlhdCI6MTU2NzMxNDAwMCwibm9uY2UiOiJhNTl2azU5MiIsImF6cCI6InRva2Vucy10ZXN0LTEyMyIsImF1dGhfdGltZSI6MTU2NzMxNDAwMH0.lHFHyg1ei3hK2vB7X1xB9nqksAEnxtv2KKpE_Gih6RezTruF9uZu1PAZTEwxhfj2UrQxwLqCb-t6wyVnxVpCsymSCq9JIiCVgg_cYV38siMs38N9y26BrVeyifj_VOP9Om_vI_hHjOzhi8WmysK2KKAQnn0skKAkq8epY4axCX3NkRaEIMhhTaITYia3GbJ5Qki8WDD9UVucUVOhgSZBV5p1dL39FKgc9k1MOVZJG-zAd_r5GsUIRk-xUwNX0WYwCR9sC2G-FjJTvlFph_4vksponoUWJ-LPTLM0RwGgmEUPhhnIG23UjsNwpnElY4gWfIL0hsO98-5DpGjn8Ejr0w"; - IdTokenVerifier.Options options = configureOptions(token); - - String errorMessage = String.format("Issuer (iss) claim mismatch in the ID token, expected \"%s\", found \"%s\"", - "https://" + DOMAIN + "/", "something-else"); - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, options)); - assertEquals(errorMessage, e.getMessage()); - } - - @Test - public void failsWhenSubMissing() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3NDg2ODAwLCJpYXQiOjE1NjczMTQwMDAsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMiLCJhdXRoX3RpbWUiOjE1NjczMTQwMDB9.fDR9NSbbt75w9nzhL-eBfGjOp16HP2vfnO6m_Oav0xrmmgyYsBZSLOPd2C0O46bp6_2hKjeOUhnwYwjocsdXI4hvfQkyACERtneCkwHwSZPZK-1h6vgGF7b_7ILUywEcgo7F6e1qgFTM93Prqk63cCP53KgOBPyx02y0rqkhUOApCWRVBFrfP92tXvFN7E2phmpf9G68PPjwnEvvQtYOGjvFkaWSja7MKT98f7OxgbenBI_mAZy9LmOqUl3SKJOBe5Fibs1snI0l4nzrgQ1GNxVwyfHOdyq-srdGe8rlFx5kdhWh313EOzWxxGTg4RhGY7Tiz1QWago0VQ5yOt0w8A"; - IdTokenVerifier.Options options = configureOptions(token); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, options)); - assertEquals("Subject (sub) claim must be a string present in the ID token", e.getMessage()); - } - - @Test - public void failsWhenAudienceMissing() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJleHAiOjE1Njc0ODY4MDAsImlhdCI6MTU2NzMxNDAwMCwibm9uY2UiOiJhNTl2azU5MiIsImF6cCI6InRva2Vucy10ZXN0LTEyMyIsImF1dGhfdGltZSI6MTU2NzMxNDAwMH0.XM-IM9CIZ2cJpZZaKooMSmNgvwHPTse6kcIOPATgewRZxrDdCEjtPHmzmSuyDGy84vSR__DJS_kM2jWWwbkjB_PahXes210dpUqitRW3is9xV0-k0LkVwxmhHCM-e9sClbTbcs4zLv6WWFRq4UEU5DU6HhuHLQeeH0eO2Nv_tkvu-JdpmoepHPjW3ecMs0lhzXRT6_2o-ErTPdYt4W6yqpBG57HRIMzs9F72AWcPC6vhLY0IhMqXaq68Ma3jnEPIXUmv52bll0PuQVBqKd-eDH_jD0ZHFUCkwbfWPrkhJz5Q5qLzSzUjnrWKA3KgP4_Z1KfHY2-nQA2ynMgNFSn_eA"; - IdTokenVerifier.Options options = configureOptions(token); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, options)); - assertEquals("Audience (aud) claim must be a string or array of strings present in the ID token", e.getMessage()); - } - - @Test - public void failsWhenAudienceDoesNotContainClientId() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOiJleHRlcm5hbC10ZXN0LTEyMyIsImV4cCI6MTU2NzQ4NjgwMCwiaWF0IjoxNTY3MzE0MDAwLCJub25jZSI6ImE1OXZrNTkyIiwiYXpwIjoidG9rZW5zLXRlc3QtMTIzIiwiYXV0aF90aW1lIjoxNTY3MzE0MDAwfQ.SxeNIhm8reywgtSSkZ6jCpbZ8KyC09couFjpcrJFktAYKmJZnGQkv0gQLNUuGejORvysznOlhfO2nkF10yT6pKBiye9xZ8TstWQBorDKHL-74n6ZAxjPg1F0vHNokZq0zpPkwV-gKIFY6aPw3vyZTxzR6CMyoJdwc19A0RXPzPt6T7csQeqX0lzGEqqeIbU4VI5XM5RG1VvN82CgTlOQXlFZrKhyJx_xwslyWWDzx7tpPNid1wusvfznTGxoWO2wUBCyW6EhmyHp2euFi1gdJqHQVbrydutPtQ-FGQEwyWACNN8kBWqQ7UEbqimg6C0NTGrRkkKkJ79DmiW7aULHZQ"; - IdTokenVerifier.Options options = configureOptions(token); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, options)); - assertEquals(String.format("Audience (aud) claim mismatch in the ID token; expected \"%s\" but found \"%s\"", AUDIENCE, "[external-test-123]"), e.getMessage()); - } - - @Test - public void failsWhenExpClaimMissing() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiaWF0IjoxNTY3MzE0MDAwLCJub25jZSI6ImE1OXZrNTkyIiwiYXpwIjoidG9rZW5zLXRlc3QtMTIzIiwiYXV0aF90aW1lIjoxNTY3MzE0MDAwfQ.b6saYAZCnCSzpVO0nrAUKVSC1n3GoqUfwrjOXG5gVxda0oFohpYJe68QwzsTmS4fOm7JtbN1FqjVRN6S4i-BnH-XGnciGOMFF4EfaOzsgo7DCrrLrjfx6rmqW8UPYalbfJTQL8mXYnLOxzMGP3DEXNlk-41GSZoFujwTAIqYjrV_Y3MUGYmzcVxdL_h2psLm_p07knMLCm7Cuo8znzKrU4PtuaLflvzorg57S4BD79oLv4uv0_dmhwPUgJDvqWeicR5Qry4aX2L5BT6V-nBWAcu3qVZDymSKcjtTebxszxY1siyA7BQe88ZmgP1bW1KXtMk_fOGsgWHFdu_AH77yow"; - IdTokenVerifier.Options options = configureOptions(token); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, options)); - assertEquals("Expiration Time (exp) claim must be a number present in the ID token", e.getMessage()); - } - - @Test - public void failsWhenExpClaimInvalidOutsideDefaultLeeway() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3MzE0MDAwLCJpYXQiOjE1NjczMTQwMDAsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMiLCJhdXRoX3RpbWUiOjE1NjczMTQwMDB9.uDn-4wtiigGddUw2kis_QyfDE3w75rWvu9NolMgD3b7l4_fedhQOk-z_mYID588ZXpnpLRKKiD5I2IFsXl7Qcc10rx1LIZxNqdzyc3VrgFf677x7fFZ4guR2WalH-zdJEluruMRdCIFQczIjXnGKPHGQ8gPH1LRozv43dl-bO2viX6MU4pTgNq3GIsU4ureyHrx1o9JSqF4b_RzuYvVWVVX7ABC2csMJP_ocVbEIQjUBhp1V7VcQY-Zgq0prk_HvY13g8FxK4KvSza637ZWAfonn599SKuy22PeMJqDfd64SbunWrt-mKBz9PHeAo9t4LJPLsAqSd3IQ2aJTsnqJRA"; - - Integer actualExpTime = 1567314000; - - // set clock to September 1, 2019 5:00:00 AM GMT - Date clock = new Date(1567314000000L); - clock.setTime(clock.getTime() + ((DEFAULT_CLOCK_SKEW + 1) * 1000)); - - IdTokenVerifier.Options options = configureOptions(token); - options.setClock(clock); - - String errorMessage = String.format("Expiration Time (exp) claim error in the ID token; current time (%d) is after expiration time (%d)", - clock.getTime() / 1000, actualExpTime + DEFAULT_CLOCK_SKEW); - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, options)); - assertEquals(errorMessage, e.getMessage()); - } - - @Test - public void succeedsWhenExpClaimInPastButWithinDefaultLeeway() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3MzE0MDAwLCJpYXQiOjE1NjczMTQwMDAsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMiLCJhdXRoX3RpbWUiOjE1NjczMTQwMDB9.uDn-4wtiigGddUw2kis_QyfDE3w75rWvu9NolMgD3b7l4_fedhQOk-z_mYID588ZXpnpLRKKiD5I2IFsXl7Qcc10rx1LIZxNqdzyc3VrgFf677x7fFZ4guR2WalH-zdJEluruMRdCIFQczIjXnGKPHGQ8gPH1LRozv43dl-bO2viX6MU4pTgNq3GIsU4ureyHrx1o9JSqF4b_RzuYvVWVVX7ABC2csMJP_ocVbEIQjUBhp1V7VcQY-Zgq0prk_HvY13g8FxK4KvSza637ZWAfonn599SKuy22PeMJqDfd64SbunWrt-mKBz9PHeAo9t4LJPLsAqSd3IQ2aJTsnqJRA"; - - // set clock to September 1, 2019 5:00:00 AM GMT - Date clock = new Date(1567314000000L); - clock.setTime(clock.getTime() + ((DEFAULT_CLOCK_SKEW - 1) * 1000)); - - IdTokenVerifier.Options options = configureOptions(token); - options.setClock(clock); - - new IdTokenVerifier().verify(token, options); - } - - @Test - public void failsWhenExpClaimInvalidOutsideCustomLeeway() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3MzE0MDAwLCJpYXQiOjE1NjczMTQwMDAsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMiLCJhdXRoX3RpbWUiOjE1NjczMTQwMDB9.uDn-4wtiigGddUw2kis_QyfDE3w75rWvu9NolMgD3b7l4_fedhQOk-z_mYID588ZXpnpLRKKiD5I2IFsXl7Qcc10rx1LIZxNqdzyc3VrgFf677x7fFZ4guR2WalH-zdJEluruMRdCIFQczIjXnGKPHGQ8gPH1LRozv43dl-bO2viX6MU4pTgNq3GIsU4ureyHrx1o9JSqF4b_RzuYvVWVVX7ABC2csMJP_ocVbEIQjUBhp1V7VcQY-Zgq0prk_HvY13g8FxK4KvSza637ZWAfonn599SKuy22PeMJqDfd64SbunWrt-mKBz9PHeAo9t4LJPLsAqSd3IQ2aJTsnqJRA"; - Integer leeway = 120; - - Date actualExp = JWT.decode(token).getExpiresAt(); - - // set clock to September 1, 2019 5:00:00 AM GMT - Date clock = new Date(1567314000000L); - clock.setTime(clock.getTime() + ((leeway + 1) * 1000)); - - IdTokenVerifier.Options options = configureOptions(token); - options.setClockSkew(leeway); - options.setClock(clock); - - String errorMessage = String.format("Expiration Time (exp) claim error in the ID token; current time (%d) is after expiration time (%d)", - clock.getTime() / 1000, ((actualExp.getTime() / 1000) + leeway)); - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, options)); - assertEquals(errorMessage, e.getMessage()); - } - - @Test - public void succeedsWhenExpClaimInPastButWithinCustomLeeway() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3MzE0MDAwLCJpYXQiOjE1NjczMTQwMDAsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMiLCJhdXRoX3RpbWUiOjE1NjczMTQwMDB9.uDn-4wtiigGddUw2kis_QyfDE3w75rWvu9NolMgD3b7l4_fedhQOk-z_mYID588ZXpnpLRKKiD5I2IFsXl7Qcc10rx1LIZxNqdzyc3VrgFf677x7fFZ4guR2WalH-zdJEluruMRdCIFQczIjXnGKPHGQ8gPH1LRozv43dl-bO2viX6MU4pTgNq3GIsU4ureyHrx1o9JSqF4b_RzuYvVWVVX7ABC2csMJP_ocVbEIQjUBhp1V7VcQY-Zgq0prk_HvY13g8FxK4KvSza637ZWAfonn599SKuy22PeMJqDfd64SbunWrt-mKBz9PHeAo9t4LJPLsAqSd3IQ2aJTsnqJRA"; - Integer leeway = 120; - - // set clock to September 1, 2019 5:00:00 AM GMTExpiration Time (exp) claim error in the ID token; current time - Date clock = new Date(1567314000000L); - clock.setTime(clock.getTime() + ((leeway - 1) * 1000)); - - IdTokenVerifier.Options options = configureOptions(token); - options.setClockSkew(leeway); - options.setClock(clock); - new IdTokenVerifier().verify(token, options); - } - - @Test - public void failsWhenIatClaimMissing() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3NDg2ODAwLCJub25jZSI6ImE1OXZrNTkyIiwiYXpwIjoidG9rZW5zLXRlc3QtMTIzIiwiYXV0aF90aW1lIjoxNTY3MzE0MDAwfQ.SJDgK8W9Y8stMtE9LG2OzHzXzbIDCXg8lRhKyOim4rRXbkg3k0on7gCzN-sy2d5z5TQ-lQzbY3V4z-so3ltVDUYd_8RjmUiKgNK_95UsxfTDM2BlBEQ6USMVl3ojC5jcTBhg5MF16ZBEn94IjIGC9Uks9GPseM-JrtUPx4Uj5VvsBtmeKxLc3rSGt7rYC4JU65Oa-O5pFYRSCbNzRFNHRlmnb5b2uPHxoVLjrJAT0FhlXcsNgfz65MlbXBgAyz7xjCEhw_tTpvptaCwPTeG0mgBYlGQ7Sl3xHJzgG4jLbA7Pvvfcx0MpBPHUZxADh1FFQnf2nHB0ppddiDfOq2mHNA"; - - IdTokenVerifier.Options options = configureOptions(token); - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, options)); - assertEquals("Issued At (iat) claim must be a number present in the ID token", e.getMessage()); - } - - @Test - public void failsWhenNonceConfiguredButNoNonceClaimSent() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3NDg2ODAwLCJpYXQiOjE1NjczMTQwMDAsImF6cCI6InRva2Vucy10ZXN0LTEyMyIsImF1dGhfdGltZSI6MTU2NzMxNDAwMH0.ZRYK4s72pKXJUSadByPp_MNyuaACmPCyj9RaIfxuTTLXE45YJ0toLK6XjjDv_861E_fRmEKMthnJAmHcKXiDWGb73l3iDtD7clockBOo3KJO2cwkM1uYNpG1kbNkg6WDvgGlVsC7buxr8dbL8fI2e0g53Jl48lE9Ohi5Z_7iRmRoVAx5HE60UDfEqFeAKZyu5VsAahp9q3PwhLfaJVDobtAzWP0LcRA3x8FOA0ZdBBNpvRmeBRugU2GQTSDLSMtGzgi5xXUwXly7pr5bX-lIYICU1Q9R5n-8uYlEaFuiaYTqzxY0fmSzzGeFkwrj7b0yTQ2OwAFVT3MWCSbvjKsy-JWQ"; - IdTokenVerifier.Options options = configureOptions(token); - options.setNonce("kssllk59akth"); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, options)); - assertEquals("Nonce (nonce) claim must be a string present in the ID token", e.getMessage()); - } - - @Test - public void failsWhenNonceIsInvalid() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3NDg2ODAwLCJpYXQiOjE1NjczMTQwMDAsIm5vbmNlIjoiMDAwOTk5IiwiYXpwIjoidG9rZW5zLXRlc3QtMTIzIiwiYXV0aF90aW1lIjoxNTY3MzE0MDAwfQ.n4jIX01mNucMs92F8IZtKJeCvgUYPwrrOsaZX91fnzVkDC5tAqi4HLRGHjtUJe1PwmIijJk63FskeuApVPfxfAbITL1KBVDHiin2RVeDSAl5lhSnsSYW-k5MfzXx11MJxhS_VD5zvOgbWmuRYUHlc1zh48YyJZQE-OaEFvxGyyEM7Zhgzfz4D5_kjd2qV890WsXGs_GadyzxATfP59XENnPzMo3VLXyBC4cQ0e7rzBIqquBKo9-MT6rhy_qSwMrZJhyzSzE5gTtMd2Od9YgPUtLznBt34rBD1uJaSs_a4s1Ox3h4jTCm85xWFabGx3kz7xkD33nCiMKQ_FSy1d-toQ"; - - String expectedNonce = "nonce"; - String actualNonce = "000999"; - - IdTokenVerifier.Options options = configureOptions(token); - options.setNonce("nonce"); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, options)); - assertEquals(String.format("Nonce (nonce) claim mismatch in the ID token; expected \"%s\", found \"%s\"", expectedNonce, actualNonce), e.getMessage()); - } - - @Test - public void failsWhenAudClaimHasMultipleItemsButAzpMissing() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3NDg2ODAwLCJpYXQiOjE1NjczMTQwMDAsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOjQyLCJhdXRoX3RpbWUiOjE1NjczMTQwMDB9.SliF71jOX9JsGeUPCySf3ucY_tGr3uh183cbcUN9ze3qRiOAc5bi7vdsBtODtlVJgsx0Elt0JrISTJ8SoNkpA4SxrjFpxSsfzPBwQtJrlg7pqflgBH7g6zKGVGRs2Z0jxZaCvXQvRuUZRZwFIncZ2zTFIDI3X5xLeJAGRGWaInOvLLlumGzWzfNLUG_G5uHZQW6sRgyIw9qrdqEWXO6sGjOBG9Au6jIo2IH0I53-UujAnNHWeJRPsM5xw2bHPteIde1xn4N0w26BlZ4GEQifVQDFw3ukah35SQ-ENMMS58Siu-sysF5F3oxdwVaMidyYgrD2VUN_iXIaMPwA2i0M5Q"; - - IdTokenVerifier.Options options = configureOptions(token); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, options)); - assertEquals("Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values", e.getMessage()); - } - - @Test - public void failsWhenAudClaimHasMultipleItemsButAzpInvalid() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3NDg2ODAwLCJpYXQiOjE1NjczMTQwMDAsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJleHRlcm5hbC10ZXN0LTEyMyIsImF1dGhfdGltZSI6MTU2NzMxNDAwMH0.GLuChuSum2S6h79rfRbJrJfe_7Fw_D6RHXj9zrAhixoNLMyBosO2GBPsOgoaLTDMonJzCyqskjan-w-SJ5nw7fUmDkWfPVjXcS0x5pt72j0dgfLMu6eOFIA9jWHWN4hsN3XKJktZ9202AohI8fXO5BYQ-jMi0HWQaiUj3f6wITHEN6fTydLo_t24hriExkO1670AgzM22BVTfb-JJlrs32t6ffY77zrF5ahIg_h4ROgrcf_3LejF7ZnubHbpJ-wX-byxW9YXT5tN_JjD5EP6jC37s9iL8ArGEZtBzHVfCO0kqlaH-9PVZXgz8SjMSJ8iA2fXXN0L35ySdzida3hhzw"; - - String actualAzp = "external-test-123"; - - IdTokenVerifier.Options options = configureOptions(token); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, options)); - assertEquals(String.format("Authorized Party (azp) claim mismatch in the ID token; expected \"%s\", found \"%s\"", AUDIENCE, actualAzp), e.getMessage()); - } - - @Test - public void failsWhenMaxAgeSentButAuthTimeClaimMissing() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3NDg2ODAwLCJpYXQiOjE1NjczMTQwMDAsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMifQ.Gb36qNHgQgac1fXh9AHX7ZMroymT0j4TjNol3ZirbIOyxuHV4OxCbGcoAAxC8Zt_dIc3DH9SX3QUIwTkE3DsFxS-VJ58R2d9RbXJl5p8pO1sJNFjo59njLKbiBxVil4z8PUsw77c_4f2QtKn6LHzhGqL9CS84LUCgNPPBsBHYyNRJDwIauPrrLyOsZAS3dWlZiUDBFurSYe0Y-O6d8zF_uKOcTD8A2E3SQQlZJQ12T94IprQ9V0tbbWI8VSGQ23JghR62QwZC-rBOF9pQMcLLCNRLFTTF9sXqZuS9XRv7PZ6rRjaonHDWn8WqGjSleWSycPsvwvjjSUVR8Z3iDBZig"; - - IdTokenVerifier.Options options = configureOptions(token); - options.setMaxAge(200); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, options)); - assertEquals("Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified", e.getMessage()); - } - - @Test - public void failsWhenMaxSentButAuthTimeInvalidWithinLeeway() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3NDg2ODAwLCJpYXQiOjE1NjczMTQwMDAsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMiLCJhdXRoX3RpbWUiOjE1NjczMTQwMDB9.AbSYZ_Tu0-ZelCRPuu9jOd9y1M19yIlk8bjSQDVVgAekRZLdRA_T_gi_JeWyFysKZVpRcHC1YJhTH4YH8CCMRTwviq3woIsLmdUecjydyZkHcUlhHXj2DbC15cyELalPNe3T9eZ4ySwk9qRJSOkjBAgXAT0a7M6rwri6QHnL0WxTLX4us4rGu8Ui3kuf1WaZH9DNoeWYs1N3xUclockTkRKaqXnuKjnwSVmsuwxFSlnIPJOiMUUZksiaBq_OUvOkB-dEG7OFiDX9XWj1m62yBHkvZHun8LBr9VW3mt1IrcBdbbtzjWwfn6ioK2c4dbtPFhuYohXsmRDaSekP63Dmlw3A"; - - int actualAuthTime = 1567314000; - Integer maxAge = 120; - - // set clock to September 1, 2019 5:00:00 AM GMT - Date clock = new Date(1567314000000L); - clock.setTime(clock.getTime() + ((maxAge + (DEFAULT_CLOCK_SKEW + 1)) * 1000)); - - IdTokenVerifier.Options options = configureOptions(token); - options.setClock(clock); - options.setMaxAge(maxAge); - - String errorMessage = String.format("Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication. Current time (%d) is after last auth at (%d)", - clock.getTime() / 1000, actualAuthTime + maxAge + DEFAULT_CLOCK_SKEW); - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, options)); - assertEquals(errorMessage, e.getMessage()); - } - - @Test - public void succeedsWhenMaxSentAndAuthTimeWithinLeeway() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3NDg2ODAwLCJpYXQiOjE1NjczMTQwMDAsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMiLCJhdXRoX3RpbWUiOjE1NjczMTQwMDB9.AbSYZ_Tu0-ZelCRPuu9jOd9y1M19yIlk8bjSQDVVgAekRZLdRA_T_gi_JeWyFysKZVpRcHC1YJhTH4YH8CCMRTwviq3woIsLmdUecjydyZkHcUlhHXj2DbC15cyELalPNe3T9eZ4ySwk9qRJSOkjBAgXAT0a7M6rwri6QHnL0WxTLX4us4rGu8Ui3kuf1WaZH9DNoeWYs1N3xUclockTkRKaqXnuKjnwSVmsuwxFSlnIPJOiMUUZksiaBq_OUvOkB-dEG7OFiDX9XWj1m62yBHkvZHun8LBr9VW3mt1IrcBdbbtzjWwfn6ioK2c4dbtPFhuYohXsmRDaSekP63Dmlw3A"; - - Integer maxAge = 120; - - // set clock to September 1, 2019 5:00:00 AM GMT - Date clock = new Date(1567314000000L); - clock.setTime(clock.getTime() + ((maxAge + (DEFAULT_CLOCK_SKEW - 1)) * 1000)); - - IdTokenVerifier.Options options = configureOptions(token); - options.setClock(clock); - options.setMaxAge(maxAge); - - new IdTokenVerifier().verify(token, options); - } - - @Test - public void failsWhenMaxSentButAuthTimeInvalidWithCustomLeeway() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3NDg2ODAwLCJpYXQiOjE1NjczMTQwMDAsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMiLCJhdXRoX3RpbWUiOjE1NjczMTQwMDB9.AbSYZ_Tu0-ZelCRPuu9jOd9y1M19yIlk8bjSQDVVgAekRZLdRA_T_gi_JeWyFysKZVpRcHC1YJhTH4YH8CCMRTwviq3woIsLmdUecjydyZkHcUlhHXj2DbC15cyELalPNe3T9eZ4ySwk9qRJSOkjBAgXAT0a7M6rwri6QHnL0WxTLX4us4rGu8Ui3kuf1WaZH9DNoeWYs1N3xUclockTkRKaqXnuKjnwSVmsuwxFSlnIPJOiMUUZksiaBq_OUvOkB-dEG7OFiDX9XWj1m62yBHkvZHun8LBr9VW3mt1IrcBdbbtzjWwfn6ioK2c4dbtPFhuYohXsmRDaSekP63Dmlw3A"; - - int actualAuthTime = 1567314000; - Integer maxAge = 120; - Integer customLeeway = 120; - - // set clock to September 1, 2019 5:00:00 AM GMT - Date clock = new Date(1567314000000L); - clock.setTime(clock.getTime() + ((maxAge + customLeeway + 1) * 1000)); - - IdTokenVerifier.Options options = configureOptions(token); - options.setClock(clock); - options.setMaxAge(maxAge); - options.setClockSkew(customLeeway); - - String errorMessage = String.format("Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication. Current time (%d) is after last auth at (%d)", - clock.getTime() / 1000, actualAuthTime + maxAge + customLeeway); - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, options)); - assertEquals(errorMessage, e.getMessage()); - } - - @Test - public void succeedsWhenMaxSentAndAuthTimeWithCustomLeeway() { - String token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3Rva2Vucy10ZXN0LmF1dGgwLmNvbS8iLCJzdWIiOiJhdXRoMHwxMjM0NTY3ODkiLCJhdWQiOlsidG9rZW5zLXRlc3QtMTIzIiwiZXh0ZXJuYWwtdGVzdC0xMjMiXSwiZXhwIjoxNTY3NDg2ODAwLCJpYXQiOjE1NjczMTQwMDAsIm5vbmNlIjoiYTU5dms1OTIiLCJhenAiOiJ0b2tlbnMtdGVzdC0xMjMiLCJhdXRoX3RpbWUiOjE1NjczMTQwMDB9.AbSYZ_Tu0-ZelCRPuu9jOd9y1M19yIlk8bjSQDVVgAekRZLdRA_T_gi_JeWyFysKZVpRcHC1YJhTH4YH8CCMRTwviq3woIsLmdUecjydyZkHcUlhHXj2DbC15cyELalPNe3T9eZ4ySwk9qRJSOkjBAgXAT0a7M6rwri6QHnL0WxTLX4us4rGu8Ui3kuf1WaZH9DNoeWYs1N3xUclockTkRKaqXnuKjnwSVmsuwxFSlnIPJOiMUUZksiaBq_OUvOkB-dEG7OFiDX9XWj1m62yBHkvZHun8LBr9VW3mt1IrcBdbbtzjWwfn6ioK2c4dbtPFhuYohXsmRDaSekP63Dmlw3A"; - - Integer maxAge = 120; - Integer customLeeway = 120; - - // set clock to September 1, 2019 5:00:00 AM GMT - Date clock = new Date(1567314000000L); - clock.setTime(clock.getTime() + ((maxAge + customLeeway - 1) * 1000)); - - IdTokenVerifier.Options options = configureOptions(token); - options.setClock(clock); - options.setMaxAge(maxAge); - options.setClockSkew(customLeeway); - - new IdTokenVerifier().verify(token, options); - } - - @Test - public void succeedsWithValidTokenUsingDefaultClock() { - String token = JWT.create() - .withSubject("auth0|sdk458fks") - .withAudience(AUDIENCE) - .withIssuedAt(getYesterday()) - .withExpiresAt(getTomorrow()) - .withIssuer("https://" + DOMAIN + "/") - .withClaim("nonce", "nonce") - .sign(Algorithm.HMAC256("secret")); - - DecodedJWT decodedJWT = JWT.decode(token); - SignatureVerifier verifier = mock(SignatureVerifier.class); - when(verifier.verifySignature(token)).thenReturn(decodedJWT); - - IdTokenVerifier.Options opts = new IdTokenVerifier.Options("https://" + DOMAIN + "/", AUDIENCE, verifier); - opts.setNonce("nonce"); - - new IdTokenVerifier().verify(token, opts); - } - - @Test - public void succeedsWithValidTokenUsingDefaultClockAndHttpDomain() { - String token = JWT.create() - .withSubject("auth0|sdk458fks") - .withAudience(AUDIENCE) - .withIssuedAt(getYesterday()) - .withExpiresAt(getTomorrow()) - .withIssuer("http://" + DOMAIN + "/") - .withClaim("nonce", "nonce") - .sign(Algorithm.HMAC256("secret")); - - DecodedJWT decodedJWT = JWT.decode(token); - SignatureVerifier verifier = mock(SignatureVerifier.class); - when(verifier.verifySignature(token)).thenReturn(decodedJWT); - - IdTokenVerifier.Options opts = new IdTokenVerifier.Options("http://" + DOMAIN + "/", AUDIENCE, verifier); - opts.setNonce("nonce"); - - new IdTokenVerifier().verify(token, opts); - } - - @Test - public void succeedsWithValidTokenUsingDefaultClockAndHttpsDomain() { - String token = JWT.create() - .withSubject("auth0|sdk458fks") - .withAudience(AUDIENCE) - .withIssuedAt(getYesterday()) - .withExpiresAt(getTomorrow()) - .withIssuer("https://" + DOMAIN + "/") - .withClaim("nonce", "nonce") - .sign(Algorithm.HMAC256("secret")); - - DecodedJWT decodedJWT = JWT.decode(token); - SignatureVerifier verifier = mock(SignatureVerifier.class); - when(verifier.verifySignature(token)).thenReturn(decodedJWT); - - IdTokenVerifier.Options opts = new IdTokenVerifier.Options("https://" + DOMAIN + "/", AUDIENCE, verifier); - opts.setNonce("nonce"); - - new IdTokenVerifier().verify(token, opts); - } - - @Test - public void succeedsWhenOrganizationNameMatchesExpected() { - String token = JWT.create() - .withSubject("auth0|sdk458fks") - .withAudience(AUDIENCE) - .withIssuedAt(getYesterday()) - .withExpiresAt(getTomorrow()) - .withIssuer("https://" + DOMAIN + "/") - .withClaim("org_name", "my org") - .sign(Algorithm.HMAC256("secret")); - - String jwt = JWT.decode(token).getToken(); - - IdTokenVerifier.Options opts = configureOptions(jwt); - opts.setOrganization("my org"); - - new IdTokenVerifier().verify(token, opts); - } - - @Test - public void failsWhenOrganizationNameDoesNotMatchExpected() { - String token = JWT.create() - .withSubject("auth0|sdk458fks") - .withAudience(AUDIENCE) - .withIssuedAt(getYesterday()) - .withExpiresAt(getTomorrow()) - .withIssuer("https://" + DOMAIN + "/") - .withClaim("org_name", "my org") - .sign(Algorithm.HMAC256("secret")); - - String jwt = JWT.decode(token).getToken(); - - IdTokenVerifier.Options opts = configureOptions(jwt); - opts.setOrganization("other org"); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, opts)); - assertEquals("Organization (org_name) claim mismatch in the ID token; expected \"other org\" but found \"my org\"", e.getMessage()); - } - - @Test - public void succeedsWhenOrganizationNameDoesNotMatchExpected_caseInsensitive() { - String token = JWT.create() - .withSubject("auth0|sdk458fks") - .withAudience(AUDIENCE) - .withIssuedAt(getYesterday()) - .withExpiresAt(getTomorrow()) - .withIssuer("https://" + DOMAIN + "/") - .withClaim("org_name", "my org") - .sign(Algorithm.HMAC256("secret")); - - String jwt = JWT.decode(token).getToken(); - - IdTokenVerifier.Options opts = configureOptions(jwt); - opts.setOrganization("My org"); - - new IdTokenVerifier().verify(token, opts); - } - - @Test - public void failsWhenOrganizationNameExpectedButNotPresent() { - String token = JWT.create() - .withSubject("auth0|sdk458fks") - .withAudience(AUDIENCE) - .withIssuedAt(getYesterday()) - .withExpiresAt(getTomorrow()) - .withIssuer("https://" + DOMAIN + "/") - .sign(Algorithm.HMAC256("secret")); - - String jwt = JWT.decode(token).getToken(); - - IdTokenVerifier.Options opts = configureOptions(jwt); - opts.setOrganization("my org"); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, opts)); - assertEquals("Organization name (org_name) claim must be a string present in the ID token", e.getMessage()); - } - - @Test - public void failsWhenOrganizationNameExpectedButClaimIsNotString() { - String token = JWT.create() - .withSubject("auth0|sdk458fks") - .withAudience(AUDIENCE) - .withIssuedAt(getYesterday()) - .withExpiresAt(getTomorrow()) - .withIssuer("https://" + DOMAIN + "/") - .withClaim("org_name", 42) - .sign(Algorithm.HMAC256("secret")); - - String jwt = JWT.decode(token).getToken(); - - IdTokenVerifier.Options opts = configureOptions(jwt); - opts.setOrganization("my org"); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, opts)); - assertEquals("Organization name (org_name) claim must be a string present in the ID token", e.getMessage()); - } - - @Test - public void succeedsWhenOrganizationNameNotSpecifiedButIsPresent() { - String token = JWT.create() - .withSubject("auth0|sdk458fks") - .withAudience(AUDIENCE) - .withIssuedAt(getYesterday()) - .withExpiresAt(getTomorrow()) - .withIssuer("https://" + DOMAIN + "/") - .withClaim("org_name", "my org") - .sign(Algorithm.HMAC256("secret")); - - String jwt = JWT.decode(token).getToken(); - - IdTokenVerifier.Options opts = configureOptions(jwt); - new IdTokenVerifier().verify(token, opts); - } - - @Test - public void succeedsWhenOrganizationIdMatchesExpected() { - String token = JWT.create() - .withSubject("auth0|sdk458fks") - .withAudience(AUDIENCE) - .withIssuedAt(getYesterday()) - .withExpiresAt(getTomorrow()) - .withIssuer("https://" + DOMAIN + "/") - .withClaim("org_id", "org_123") - .sign(Algorithm.HMAC256("secret")); - - String jwt = JWT.decode(token).getToken(); - - IdTokenVerifier.Options opts = configureOptions(jwt); - opts.setOrganization("org_123"); - - new IdTokenVerifier().verify(token, opts); - } - - @Test - public void failsWhenOrganizationIdDoesNotMatchExpected() { - String token = JWT.create() - .withSubject("auth0|sdk458fks") - .withAudience(AUDIENCE) - .withIssuedAt(getYesterday()) - .withExpiresAt(getTomorrow()) - .withIssuer("https://" + DOMAIN + "/") - .withClaim("org_id", "org_123") - .sign(Algorithm.HMAC256("secret")); - - String jwt = JWT.decode(token).getToken(); - - IdTokenVerifier.Options opts = configureOptions(jwt); - opts.setOrganization("org_abc"); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, opts)); - assertEquals("Organization (org_id) claim mismatch in the ID token; expected \"org_abc\" but found \"org_123\"", e.getMessage()); - } - - @Test - public void failsWhenOrganizationIdDoesNotMatchExpected_caseSensitive() { - String token = JWT.create() - .withSubject("auth0|sdk458fks") - .withAudience(AUDIENCE) - .withIssuedAt(getYesterday()) - .withExpiresAt(getTomorrow()) - .withIssuer("https://" + DOMAIN + "/") - .withClaim("org_id", "org_123") - .sign(Algorithm.HMAC256("secret")); - - String jwt = JWT.decode(token).getToken(); - - IdTokenVerifier.Options opts = configureOptions(jwt); - opts.setOrganization("org_aBc"); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, opts)); - assertEquals("Organization (org_id) claim mismatch in the ID token; expected \"org_aBc\" but found \"org_123\"", e.getMessage()); - } - - @Test - public void failsWhenOrganizationIdExpectedButNotPresent() { - String token = JWT.create() - .withSubject("auth0|sdk458fks") - .withAudience(AUDIENCE) - .withIssuedAt(getYesterday()) - .withExpiresAt(getTomorrow()) - .withIssuer("https://" + DOMAIN + "/") - .sign(Algorithm.HMAC256("secret")); - - String jwt = JWT.decode(token).getToken(); - - IdTokenVerifier.Options opts = configureOptions(jwt); - opts.setOrganization("org_123"); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, opts)); - assertEquals("Organization Id (org_id) claim must be a string present in the ID token", e.getMessage()); - } - - @Test - public void failsWhenOrganizationIdExpectedButClaimIsNotString() { - String token = JWT.create() - .withSubject("auth0|sdk458fks") - .withAudience(AUDIENCE) - .withIssuedAt(getYesterday()) - .withExpiresAt(getTomorrow()) - .withIssuer("https://" + DOMAIN + "/") - .withClaim("org_id", 42) - .sign(Algorithm.HMAC256("secret")); - - String jwt = JWT.decode(token).getToken(); - - IdTokenVerifier.Options opts = configureOptions(jwt); - opts.setOrganization("org_123"); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> new IdTokenVerifier().verify(token, opts)); - assertEquals("Organization Id (org_id) claim must be a string present in the ID token", e.getMessage()); - } - - @Test - public void succeedsWhenOrganizationIdNotSpecifiedButIsPresent() { - String token = JWT.create() - .withSubject("auth0|sdk458fks") - .withAudience(AUDIENCE) - .withIssuedAt(getYesterday()) - .withExpiresAt(getTomorrow()) - .withIssuer("https://" + DOMAIN + "/") - .withClaim("org_id", "org_123") - .sign(Algorithm.HMAC256("secret")); - - String jwt = JWT.decode(token).getToken(); - - IdTokenVerifier.Options opts = configureOptions(jwt); - new IdTokenVerifier().verify(token, opts); - } - - private IdTokenVerifier.Options configureOptions(String token) { - DecodedJWT decodedJWT = JWT.decode(token); - SignatureVerifier verifier = mock(SignatureVerifier.class); - when(verifier.verifySignature(token)).thenReturn(decodedJWT); - - IdTokenVerifier.Options opts = new IdTokenVerifier.Options("https://" + DOMAIN + "/", AUDIENCE, verifier); - opts.setClock(DEFAULT_CLOCK); - return opts; - } - - private Date getYesterday() { - Calendar cal = Calendar.getInstance(); - cal.add(Calendar.DATE, -1); - - return cal.getTime(); - } - - private Date getTomorrow() { - Calendar cal = Calendar.getInstance(); - cal.add(Calendar.DATE, 1); - - return cal.getTime(); - } -} diff --git a/src/test/java/com/auth0/InvalidRequestExceptionTest.java b/src/test/java/com/auth0/InvalidRequestExceptionTest.java index e513d58..848c01a 100644 --- a/src/test/java/com/auth0/InvalidRequestExceptionTest.java +++ b/src/test/java/com/auth0/InvalidRequestExceptionTest.java @@ -15,10 +15,9 @@ public void setUp() { exception = new InvalidRequestException("error", "message"); } - @SuppressWarnings("deprecation") @Test - public void shouldGetDescription() { - assertThat(exception.getDescription(), is("message")); + public void shouldGetMessage() { + assertThat(exception.getMessage(), is("message")); } @Test diff --git a/src/test/java/com/auth0/RandomStorageTest.java b/src/test/java/com/auth0/RandomStorageTest.java deleted file mode 100644 index 49a4af7..0000000 --- a/src/test/java/com/auth0/RandomStorageTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.auth0; - -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockHttpServletRequest; - -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.nullValue; -import static org.hamcrest.MatcherAssert.assertThat; - -public class RandomStorageTest { - - @Test - public void shouldSetState() { - MockHttpServletRequest req = new MockHttpServletRequest(); - - RandomStorage.setSessionState(req, "123456"); - assertThat(req.getSession().getAttribute("com.auth0.state"), is("123456")); - } - - @Test - public void shouldAcceptBothNullStates() { - MockHttpServletRequest req = new MockHttpServletRequest(); - boolean validState = RandomStorage.checkSessionState(req, null); - assertThat(validState, is(true)); - } - - @Test - public void shouldFailIfSessionStateIsNullButCurrentStateNotNull() { - MockHttpServletRequest req = new MockHttpServletRequest(); - boolean validState = RandomStorage.checkSessionState(req, "12345"); - assertThat(validState, is(false)); - } - - @Test - public void shouldCheckAndRemoveInvalidState() { - MockHttpServletRequest req = new MockHttpServletRequest(); - req.getSession().setAttribute("com.auth0.state", "123456"); - - boolean validState = RandomStorage.checkSessionState(req, "abcdef"); - assertThat(validState, is(false)); - assertThat(req.getSession().getAttribute("com.auth0.state"), is(nullValue())); - } - - @Test - public void shouldCheckAndRemoveCorrectState() { - MockHttpServletRequest req = new MockHttpServletRequest(); - req.getSession().setAttribute("com.auth0.state", "123456"); - - boolean validState = RandomStorage.checkSessionState(req, "123456"); - assertThat(validState, is(true)); - assertThat(req.getSession().getAttribute("com.auth0.state"), is(nullValue())); - } - - @Test - public void shouldSetNonce() { - MockHttpServletRequest req = new MockHttpServletRequest(); - - RandomStorage.setSessionNonce(req, "123456"); - assertThat(req.getSession().getAttribute("com.auth0.nonce"), is("123456")); - } - - @Test - public void shouldGetAndRemoveNonce() { - MockHttpServletRequest req = new MockHttpServletRequest(); - req.getSession().setAttribute("com.auth0.nonce", "123456"); - - String nonce = RandomStorage.removeSessionNonce(req); - assertThat(nonce, is("123456")); - assertThat(req.getSession().getAttribute("com.auth0.nonce"), is(nullValue())); - } - - @Test - public void shouldGetAndRemoveNonceIfMissing() { - MockHttpServletRequest req = new MockHttpServletRequest(); - - String nonce = RandomStorage.removeSessionNonce(req); - assertThat(nonce, is(nullValue())); - assertThat(req.getSession().getAttribute("com.auth0.nonce"), is(nullValue())); - } -} diff --git a/src/test/java/com/auth0/RequestProcessorTest.java b/src/test/java/com/auth0/RequestProcessorTest.java index 7205b37..ec30c56 100644 --- a/src/test/java/com/auth0/RequestProcessorTest.java +++ b/src/test/java/com/auth0/RequestProcessorTest.java @@ -1,23 +1,27 @@ package com.auth0; -import com.auth0.client.HttpOptions; import com.auth0.client.auth.AuthAPI; +import com.auth0.exception.Auth0Exception; import com.auth0.json.auth.TokenHolder; +import com.auth0.jwk.JwkProvider; +import com.auth0.net.Response; import com.auth0.net.TokenRequest; -import com.auth0.net.Telemetry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.Is.is; import static org.hamcrest.core.IsNull.notNullValue; @@ -38,29 +42,22 @@ public class RequestProcessorTest { @Mock private DomainProvider mockDomainProvider; @Mock - private SignatureVerifier mockSignatureVerifier; - @Mock - private IdTokenVerifier mockIdTokenVerifier; - @Mock - private HttpOptions mockHttpOptions; + private JwkProvider mockJwkProvider; @Mock private AuthAPI mockAuthAPI; @Mock private TokenRequest mockTokenRequest; @Mock + private Response mockTokenResponse; + @Mock private TokenHolder mockTokenHolder; - @Captor - private ArgumentCaptor stringCaptor; - @Captor - private ArgumentCaptor verifyOptionsCaptor; - private MockHttpServletRequest request; private MockHttpServletResponse response; @BeforeEach public void setUp() { - MockitoAnnotations.initMocks(this); + MockitoAnnotations.openMocks(this); request = new MockHttpServletRequest(); response = new MockHttpServletResponse(); request.setSecure(true); @@ -74,9 +71,7 @@ public void shouldBuildRequestProcessorWithRequiredParameters() { mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) .build(); assertThat(processor, is(notNullValue())); @@ -88,9 +83,8 @@ public void shouldBuildRequestProcessorWithAllOptionalParameters() { mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) + .withJwkProvider(mockJwkProvider) .withClockSkew(120) .withAuthenticationMaxAge(3600) .withCookiePath("/custom") @@ -102,6 +96,8 @@ public void shouldBuildRequestProcessorWithAllOptionalParameters() { assertThat(processor, is(notNullValue())); } + // --- Legacy SameSite Cookie Tests --- + @Test public void shouldSetDefaultLegacySameSiteCookieToTrue() { RequestProcessor processor = createDefaultRequestProcessor(); @@ -115,9 +111,7 @@ public void shouldDisableLegacySameSiteCookie() { mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) .withLegacySameSiteCookie(false) .build(); @@ -142,32 +136,8 @@ public void shouldGetDomainFromProvider() { } @Test - public void shouldCreateClientForDomainWithHttpOptions() { - HttpOptions httpOptions = new HttpOptions(); - RequestProcessor processor = new RequestProcessor.Builder( - mockDomainProvider, - RESPONSE_TYPE_CODE, - CLIENT_ID, - CLIENT_SECRET, - httpOptions, - mockSignatureVerifier) - .build(); - - AuthAPI result = processor.createClientForDomain(DOMAIN); - - assertThat(result, is(notNullValue())); - } - - @Test - public void shouldCreateClientForDomainWithoutHttpOptions() { - RequestProcessor processor = new RequestProcessor.Builder( - mockDomainProvider, - RESPONSE_TYPE_CODE, - CLIENT_ID, - CLIENT_SECRET, - null, - mockSignatureVerifier) - .build(); + public void shouldCreateClientForDomain() { + RequestProcessor processor = createDefaultRequestProcessor(); AuthAPI result = processor.createClientForDomain(DOMAIN); @@ -178,14 +148,7 @@ public void shouldCreateClientForDomainWithoutHttpOptions() { @Test public void shouldSetLoggingEnabled() { - RequestProcessor processor = new RequestProcessor.Builder( - mockDomainProvider, - RESPONSE_TYPE_CODE, - CLIENT_ID, - CLIENT_SECRET, - null, - mockSignatureVerifier) - .build(); + RequestProcessor processor = createDefaultRequestProcessor(); processor.setLoggingEnabled(true); @@ -195,14 +158,7 @@ public void shouldSetLoggingEnabled() { @Test public void shouldDisableTelemetry() { - RequestProcessor processor = new RequestProcessor.Builder( - mockDomainProvider, - RESPONSE_TYPE_CODE, - CLIENT_ID, - CLIENT_SECRET, - null, - mockSignatureVerifier) - .build(); + RequestProcessor processor = createDefaultRequestProcessor(); processor.doNotSendTelemetry(); @@ -210,24 +166,6 @@ public void shouldDisableTelemetry() { assertThat(client, is(notNullValue())); } - @Test - public void shouldSetupTelemetryWithVersion() { - RequestProcessor processor = createDefaultRequestProcessor(); - - processor.setupTelemetry(mockAuthAPI); - - verify(mockAuthAPI).setTelemetry(any(Telemetry.class)); - } - - @Test - public void shouldReturnNullPackageVersionInDevEnvironment() { - RequestProcessor processor = createDefaultRequestProcessor(); - - String version = processor.obtainPackageVersion(); - - assertThat(version, is(nullValue())); - } - // --- Response Type Parsing Tests --- @Test @@ -299,6 +237,252 @@ public void shouldNotRequireFormPostForNullResponseType() { assertThat(requiresFormPost, is(false)); } + // --- Error Handling Tests --- + + @Test + public void shouldThrowOnProcessIfRequestHasError() { + request.setParameter("error", "access_denied"); + request.setParameter("error_description", "The user denied the request"); + + RequestProcessor processor = createDefaultRequestProcessor(); + + InvalidRequestException e = assertThrows( + InvalidRequestException.class, + () -> processor.process(request, response)); + + assertThat(e.getCode(), is("access_denied")); + assertThat(e.getMessage(), is("The user denied the request")); + } + + @Test + public void shouldThrowOnProcessIfRequestHasErrorWithDescription() { + Map params = new HashMap<>(); + params.put("error", "something happened"); + params.put("error_description", "something happened description"); + MockHttpServletRequest request = getRequest(params); + + RequestProcessor handler = createDefaultRequestProcessor(); + + InvalidRequestException e = assertThrows(InvalidRequestException.class, () -> handler.process(request, response)); + assertThat(e.getCode(), is("something happened")); + assertThat(e.getMessage(), is("something happened description")); + } + + @Test + public void shouldThrowOnProcessIfRequestHasInvalidStateInCookie() { + Map params = new HashMap<>(); + params.put("state", "1234"); + MockHttpServletRequest request = getRequest(params); + request.setCookies(new Cookie("com.auth0.state", "9999")); + + RequestProcessor handler = createDefaultRequestProcessor(); + + InvalidRequestException e = assertThrows(InvalidRequestException.class, () -> handler.process(request, response)); + assertThat(e.getCode(), is("a0.invalid_state")); + assertThat(e.getMessage(), is("The received state doesn't match the expected one.")); + } + + @Test + public void shouldThrowOnProcessIfRequestHasMissingStateParameter() { + MockHttpServletRequest request = getRequest(Collections.emptyMap()); + request.setCookies(new Cookie("com.auth0.state", "1234")); + + RequestProcessor handler = createDefaultRequestProcessor(); + + InvalidRequestException e = assertThrows(InvalidRequestException.class, () -> handler.process(request, response)); + assertThat(e.getCode(), is("a0.invalid_state")); + assertThat(e.getMessage(), is("The received state doesn't match the expected one. No state parameter was found on the authorization response.")); + } + + @Test + public void shouldThrowOnProcessIfRequestHasMissingStateCookie() { + Map params = new HashMap<>(); + params.put("state", "1234"); + MockHttpServletRequest request = getRequest(params); + + RequestProcessor handler = createDefaultRequestProcessor(); + + InvalidRequestException e = assertThrows(InvalidRequestException.class, () -> handler.process(request, response)); + assertThat(e.getCode(), is("a0.invalid_state")); + } + + @Test + public void shouldThrowOnProcessIfIdTokenRequestIsMissingIdToken() { + when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + + Map params = new HashMap<>(); + params.put("state", "1234"); + MockHttpServletRequest request = getRequest(params); + request.setCookies(new Cookie("com.auth0.state", "1234")); + + RequestProcessor handler = createRequestProcessorWithResponseType(RESPONSE_TYPE_ID_TOKEN); + + InvalidRequestException e = assertThrows(InvalidRequestException.class, () -> handler.process(request, response)); + assertThat(e.getCode(), is("a0.missing_id_token")); + assertThat(e.getMessage(), is("ID Token is missing from the response.")); + } + + @Test + public void shouldThrowOnProcessIfTokenRequestIsMissingAccessToken() { + when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + + Map params = new HashMap<>(); + params.put("state", "1234"); + MockHttpServletRequest request = getRequest(params); + request.setCookies(new Cookie("com.auth0.state", "1234")); + + RequestProcessor handler = createRequestProcessorWithResponseType(RESPONSE_TYPE_TOKEN); + + InvalidRequestException e = assertThrows(InvalidRequestException.class, () -> handler.process(request, response)); + assertThat(e.getCode(), is("a0.missing_access_token")); + assertThat(e.getMessage(), is("Access Token is missing from the response.")); + } + + // --- Code Exchange Flow Tests --- + + @Test + public void shouldThrowOnProcessIfCodeRequestFailsToExecuteCodeExchange() throws Exception { + when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + + Map params = new HashMap<>(); + params.put("code", "abc123"); + params.put("state", "1234"); + MockHttpServletRequest request = getRequest(params); + request.setCookies(new Cookie("com.auth0.state", "1234")); + + when(mockTokenRequest.execute()).thenThrow(Auth0Exception.class); + when(mockAuthAPI.exchangeCode(eq("abc123"), anyString())).thenReturn(mockTokenRequest); + + RequestProcessor handler = createDefaultRequestProcessor(); + RequestProcessor spy = spy(handler); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + IdentityVerificationException e = assertThrows(IdentityVerificationException.class, () -> spy.process(request, response)); + assertThat(e.getCode(), is("a0.api_error")); + assertThat(e.getMessage(), is("An error occurred while exchanging the authorization code.")); + } + + @Test + public void shouldThrowOnProcessIfCodeRequestSucceedsButDoesNotPassIdTokenVerification() throws Exception { + when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + + Map params = new HashMap<>(); + params.put("code", "abc123"); + params.put("state", "1234"); + MockHttpServletRequest request = getRequest(params); + request.setCookies(new Cookie("com.auth0.state", "1234")); + + // Return a structurally valid JWT with invalid signature so verification fails + String fakeJwt = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3dyb25nLyIsInN1YiI6InVzZXIxMjMiLCJhdWQiOiJ0ZXN0Q2xpZW50SWQiLCJleHAiOjk5OTk5OTk5OTksImlhdCI6MTYwMDAwMDAwMH0.signature"; + when(mockTokenHolder.getIdToken()).thenReturn(fakeJwt); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockAuthAPI.exchangeCode(eq("abc123"), anyString())).thenReturn(mockTokenRequest); + + // Use mockJwkProvider — token has invalid signature so RS256 verification will fail + RequestProcessor handler = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET) + .withJwkProvider(mockJwkProvider) + .build(); + RequestProcessor spy = spy(handler); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + IdentityVerificationException e = assertThrows(IdentityVerificationException.class, () -> spy.process(request, response)); + assertThat(e.getCode(), is("a0.invalid_jwt_error")); + assertThat(e.getMessage(), is("An error occurred while trying to verify the ID Token.")); + } + + @Test + public void shouldReturnTokensOnProcessIfCodeRequestSucceeds() throws Exception { + when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + + Map params = new HashMap<>(); + params.put("code", "abc123"); + params.put("state", "1234"); + MockHttpServletRequest request = getRequest(params); + request.setCookies(new Cookie("com.auth0.state", "1234")); + + // Return no ID token so verification is skipped + when(mockTokenHolder.getIdToken()).thenReturn(null); + when(mockTokenHolder.getAccessToken()).thenReturn("backAccessToken"); + when(mockTokenHolder.getRefreshToken()).thenReturn("backRefreshToken"); + when(mockTokenHolder.getTokenType()).thenReturn("Bearer"); + when(mockTokenHolder.getExpiresIn()).thenReturn(3600L); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockAuthAPI.exchangeCode(eq("abc123"), anyString())).thenReturn(mockTokenRequest); + + RequestProcessor handler = createDefaultRequestProcessor(); + RequestProcessor spy = spy(handler); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + Tokens tokens = spy.process(request, response); + + assertThat(tokens, is(notNullValue())); + assertThat(tokens.getAccessToken(), is("backAccessToken")); + assertThat(tokens.getRefreshToken(), is("backRefreshToken")); + assertThat(tokens.getType(), is("Bearer")); + assertThat(tokens.getExpiresIn(), is(3600L)); + } + + @Test + public void shouldReturnEmptyTokensWhenCodeRequestReturnsNoTokens() throws Exception { + when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + + Map params = new HashMap<>(); + params.put("code", "abc123"); + params.put("state", "1234"); + MockHttpServletRequest request = getRequest(params); + request.setCookies(new Cookie("com.auth0.state", "1234")); + + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockAuthAPI.exchangeCode(eq("abc123"), anyString())).thenReturn(mockTokenRequest); + + RequestProcessor handler = createDefaultRequestProcessor(); + RequestProcessor spy = spy(handler); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + Tokens tokens = spy.process(request, response); + + assertThat(tokens, is(notNullValue())); + assertThat(tokens.getIdToken(), is(nullValue())); + assertThat(tokens.getAccessToken(), is(nullValue())); + assertThat(tokens.getRefreshToken(), is(nullValue())); + } + + // --- Implicit / Hybrid Flow Tests --- + + @Test + public void shouldThrowOnProcessIfIdTokenRequestDoesNotPassIdTokenVerification() { + when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + + // Structurally valid JWT with invalid signature so verification fails + String fakeJwt = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3dyb25nLyIsInN1YiI6InVzZXIxMjMiLCJhdWQiOiJ0ZXN0Q2xpZW50SWQiLCJleHAiOjk5OTk5OTk5OTksImlhdCI6MTYwMDAwMDAwMH0.signature"; + + Map params = new HashMap<>(); + params.put("state", "1234"); + params.put("id_token", fakeJwt); + MockHttpServletRequest request = getRequest(params); + request.setCookies(new Cookie("com.auth0.state", "1234")); + + // Use mockJwkProvider — token has invalid signature so RS256 verification will fail + RequestProcessor handler = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_ID_TOKEN, + CLIENT_ID, + CLIENT_SECRET) + .withJwkProvider(mockJwkProvider) + .build(); + + IdentityVerificationException e = assertThrows(IdentityVerificationException.class, () -> handler.process(request, response)); + assertThat(e.getCode(), is("a0.invalid_jwt_error")); + assertThat(e.getMessage(), is("An error occurred while trying to verify the ID Token.")); + } + // --- AuthorizeUrl Building Tests --- @Test @@ -321,9 +505,7 @@ public void shouldBuildAuthorizeUrlWithOrganization() { mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) .withOrganization("org_123") .build(); @@ -342,9 +524,7 @@ public void shouldBuildAuthorizeUrlWithInvitation() { mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) .withInvitation("inv_456") .build(); @@ -363,9 +543,7 @@ public void shouldBuildAuthorizeUrlWithCustomCookiePath() { mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) .withCookiePath("/custom") .build(); @@ -377,111 +555,28 @@ public void shouldBuildAuthorizeUrlWithCustomCookiePath() { assertThat(result, is(notNullValue())); } - // --- Error Handling Tests --- - - @Test - public void shouldThrowExceptionWhenErrorInRequest() { - request.setParameter("error", "access_denied"); - request.setParameter("error_description", "The user denied the request"); - - RequestProcessor processor = createDefaultRequestProcessor(); - - InvalidRequestException exception = assertThrows( - InvalidRequestException.class, - () -> processor.process(request, response)); - - assertThat(exception.getCode(), is("access_denied")); - assertThat(exception.getMessage(), is("The user denied the request")); - } - @Test - public void shouldThrowExceptionWhenStateIsMissing() { - request.setParameter("code", "test_code"); - - RequestProcessor processor = createDefaultRequestProcessor(); - - InvalidRequestException exception = assertThrows( - InvalidRequestException.class, - () -> processor.process(request, response)); - - assertThat(exception.getCode(), is("a0.invalid_state")); - } - - @Test - public void shouldThrowExceptionWhenIdTokenMissingForImplicitGrant() { - request.setParameter("state", "validState"); - - RequestProcessor processor = createRequestProcessorWithResponseType(RESPONSE_TYPE_ID_TOKEN); - - InvalidRequestException exception = assertThrows( - InvalidRequestException.class, - () -> processor.process(request, response)); - - assertThat(exception, is(notNullValue())); - assertThat(exception.getCode(), is(notNullValue())); - } - - @Test - public void shouldThrowExceptionWhenAccessTokenMissingForTokenGrant() { - request.setParameter("state", "validState"); - - RequestProcessor processor = createRequestProcessorWithResponseType(RESPONSE_TYPE_TOKEN); + public void shouldBuildAuthorizeUrlWithFormPostIfResponseTypeIsToken() { + when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + RequestProcessor handler = createRequestProcessorWithResponseType(RESPONSE_TYPE_TOKEN); + RequestProcessor spy = spy(handler); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); - InvalidRequestException exception = assertThrows( - InvalidRequestException.class, - () -> processor.process(request, response)); + AuthorizeUrl result = spy.buildAuthorizeUrl(request, response, "https://redirect.uri/here", "state", "nonce"); - assertThat(exception, is(notNullValue())); - assertThat(exception.getCode(), is(notNullValue())); + assertThat(result, is(notNullValue())); } - // --- Token Processing Tests --- - @Test - public void shouldProcessCodeGrantFlow() throws Exception { - request.setParameter("code", "auth_code_123"); - request.setParameter("state", "validState"); - - RequestProcessor processor = createDefaultRequestProcessor(); - RequestProcessor spy = spy(processor); - + public void shouldBuildAuthorizeUrlWithNonceAndFormPostIfResponseTypeIsIdToken() { + when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + RequestProcessor handler = createRequestProcessorWithResponseType(RESPONSE_TYPE_ID_TOKEN); + RequestProcessor spy = spy(handler); doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); - when(mockAuthAPI.exchangeCode(anyString(), anyString())).thenReturn(mockTokenRequest); - when(mockTokenRequest.execute()).thenReturn(mockTokenHolder); - when(mockTokenHolder.getAccessToken()).thenReturn("access_token_123"); - try { - Tokens result = spy.process(request, response); - assertThat(result, is(notNullValue())); - } catch (InvalidRequestException e) { - // Expected due to state cookie validation - assertThat(e.getCode(), is(notNullValue())); - } - } + AuthorizeUrl result = spy.buildAuthorizeUrl(request, response, "https://redirect.uri/here", "state", "nonce"); - @Test - public void shouldProcessImplicitGrantFlow() throws Exception { - request.setParameter("access_token", "access_token_123"); - request.setParameter("id_token", createMockIdToken()); - request.setParameter("token_type", "Bearer"); - request.setParameter("expires_in", "3600"); - request.setParameter("state", "validState"); - - response.addCookie(new javax.servlet.http.Cookie("com.auth0.state", "validState")); - - RequestProcessor processor = createRequestProcessorWithResponseType("id_token token"); - - try { - Tokens result = processor.process(request, response); - assertThat(result, is(notNullValue())); - assertThat(result.getAccessToken(), is("access_token_123")); - assertThat(result.getIdToken(), is(notNullValue())); - assertThat(result.getType(), is("Bearer")); - assertThat(result.getExpiresIn(), is(3600L)); - } catch (IdentityVerificationException e) { - // Expected due to token verification - assertThat(e, is(notNullValue())); - } + assertThat(result, is(notNullValue())); } // --- Builder Configuration Tests --- @@ -492,9 +587,7 @@ public void shouldSupportOrganizationParameter() { mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) .withOrganization("org_123") .build(); @@ -507,9 +600,7 @@ public void shouldSupportInvitationParameter() { mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) .withInvitation("inv_456") .build(); @@ -522,9 +613,7 @@ public void shouldSupportCustomCookiePath() { mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) .withCookiePath("/custom/path") .build(); @@ -537,9 +626,7 @@ public void shouldSupportClockSkewConfiguration() { mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) .withClockSkew(180) .build(); @@ -552,9 +639,7 @@ public void shouldSupportAuthenticationMaxAge() { mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) .withAuthenticationMaxAge(7200) .build(); @@ -568,9 +653,8 @@ private RequestProcessor createDefaultRequestProcessor() { mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) + .withJwkProvider(mockJwkProvider) .build(); } @@ -579,18 +663,18 @@ private RequestProcessor createRequestProcessorWithResponseType(String responseT mockDomainProvider, responseType, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) + .withJwkProvider(mockJwkProvider) .build(); } - private String createMockIdToken() { - String header = java.util.Base64.getUrlEncoder().withoutPadding() - .encodeToString("{\"typ\":\"JWT\",\"alg\":\"RS256\"}".getBytes()); - String payload = java.util.Base64.getUrlEncoder().withoutPadding() - .encodeToString(("{\"iss\":\"https://" + DOMAIN + "/\",\"sub\":\"user123\"}").getBytes()); - String signature = "signature"; - return header + "." + payload + "." + signature; + private MockHttpServletRequest getRequest(Map parameters) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setScheme("https"); + request.setServerName("me.auth0.com"); + request.setServerPort(80); + request.setRequestURI("/callback"); + request.setParameters(parameters); + return request; } } diff --git a/src/test/java/com/auth0/SessionUtilsTest.java b/src/test/java/com/auth0/SessionUtilsTest.java deleted file mode 100644 index d7edf62..0000000 --- a/src/test/java/com/auth0/SessionUtilsTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.auth0; - -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockHttpServletRequest; - -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.nullValue; -import static org.hamcrest.MatcherAssert.assertThat; - -public class SessionUtilsTest { - @Test - public void shouldGetAndRemoveAttribute() { - MockHttpServletRequest req = new MockHttpServletRequest(); - req.getSession().setAttribute("name", "value"); - - assertThat(SessionUtils.remove(req, "name"), is("value")); - assertThat(req.getSession().getAttribute("name"), is(nullValue())); - } - - @Test - public void shouldGetAttribute() { - MockHttpServletRequest req = new MockHttpServletRequest(); - req.getSession().setAttribute("name", "value"); - - assertThat(SessionUtils.get(req, "name"), is("value")); - assertThat(req.getSession().getAttribute("name"), is("value")); - } - - @Test - public void shouldGetNullAttributeIfMissing() { - MockHttpServletRequest req = new MockHttpServletRequest(); - - assertThat(SessionUtils.get(req, "name"), is(nullValue())); - assertThat(req.getSession().getAttribute("name"), is(nullValue())); - } - - @Test - public void shouldSetAttribute() { - MockHttpServletRequest req = new MockHttpServletRequest(); - - SessionUtils.set(req, "name", "value"); - assertThat(req.getSession().getAttribute("name"), is("value")); - } - -} diff --git a/src/test/java/com/auth0/SignatureVerifierTest.java b/src/test/java/com/auth0/SignatureVerifierTest.java deleted file mode 100644 index 326387f..0000000 --- a/src/test/java/com/auth0/SignatureVerifierTest.java +++ /dev/null @@ -1,177 +0,0 @@ -package com.auth0; - -import com.auth0.jwk.Jwk; -import com.auth0.jwk.JwkException; -import com.auth0.jwk.JwkProvider; -import com.auth0.jwt.interfaces.DecodedJWT; -import org.bouncycastle.util.io.pem.PemReader; -import org.junit.jupiter.api.Test; - -import java.io.FileInputStream; -import java.io.FileReader; -import java.io.IOException; -import java.nio.file.Paths; -import java.security.KeyFactory; -import java.security.PublicKey; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import java.security.interfaces.RSAPublicKey; -import java.security.spec.EncodedKeySpec; -import java.security.spec.X509EncodedKeySpec; -import java.util.Scanner; - -import static org.hamcrest.CoreMatchers.notNullValue; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -public class SignatureVerifierTest { - - private static final String EXPIRED_HS_JWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6IjEyMzQiLCJpc3MiOiJodHRwczovL21lLmF1dGgwLmNvbS8iLCJhdWQiOiJkYU9nbkdzUlloa3d1NjIxdmYiLCJzdWIiOiJhdXRoMHx1c2VyMTIzIiwiZXhwIjo5NzE3ODkzMTd9.5_VOXBmOVMSi8OGgonyfyiJSq3A03PwOEuZlPD-Gxik"; - private static final String NONE_JWT = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJub25jZSI6IjEyMzQiLCJpc3MiOiJodHRwczovL21lLmF1dGgwLmNvbS8iLCJhdWQiOiJkYU9nbkdzUlloa3d1NjIxdmYiLCJzdWIiOiJhdXRoMHx1c2VyMTIzIn0."; - private static final String HS_JWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6IjEyMzQiLCJpc3MiOiJodHRwczovL21lLmF1dGgwLmNvbS8iLCJhdWQiOiJkYU9nbkdzUlloa3d1NjIxdmYiLCJzdWIiOiJhdXRoMHx1c2VyMTIzIn0.a7ayNmFTxS2D-EIoUikoJ6dck7I8veWyxnje_mYD3qY"; - private static final String RS_JWT = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImFiYzEyMyJ9.eyJub25jZSI6IjEyMzQiLCJpc3MiOiJodHRwczovL21lLmF1dGgwLmNvbS8iLCJhdWQiOiJkYU9nbkdzUlloa3d1NjIxdmYiLCJzdWIiOiJhdXRoMHx1c2VyMTIzIn0.PkPWdoZNfXz8EB0SBPH83lNSOhyhdhdqYIgIwgY2nHozUnFOaUjVewlAXxP_3LBGibQ_ng4s5fEEOCJjaKBy04McryvOuL6nqb1dPQseeyxuv2zQitfrs-7kEtfeS3umywM-tV6guw9_W3nmIgaXOiYiF4WJM23ItbdCmvwdXLaf9-xHkQbRY_zEwEFbprFttKUXFbkPt6XjZ3zZwZbNZn64bx2PBiSJ2KMZAE3Lghmci-RXdhi7hXpmN30Tzze1ZsjvVeRRKNzShByKK9ZGZPmQ5yggJOXFy32ehjGkYwFMCqgMQomcGbcYhsd97huKHMHl3HOE5GDYjIq9o9oKRA"; - private static final String RS_PUBLIC_KEY = "src/test/resources/public.pem"; - private static final String RS_PUBLIC_KEY_BAD = "src/test/resources/bad-public.pem"; - private static final String RS_JWT_INVALID_SIGNATURE = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImFiYzEyMyJ9.eyJub25jZSI6IjEyMzQiLCJpc3MiOiJodHRwczovL21lLmF1dGgwLmNvbS8iLCJhdWQiOiJkYU9nbkdzUlloa3d1NjIxdmYiLCJzdWIiOiJhdXRoMHx1c2VyMTIzIn0.PkPWdoZNfXz8EB0SBPH83lNSOhyhdhdqYIgIwgY2nHozUnFOaUjVewlAXxP_3LBGibQ_ng4s5fEEOCJjaKBy04McryvOuL6nqb1dPQseeyxuv2zQitfrs-7kEtfeS3umywM-tV6guw9_W3nmIgaXOiYiF4WJM23ItbdCmvwdXLaf9-xHkQbRY_zEwEFbprFttKUXFbkPt6XjZ3zZwZbNZn64bx2PBiSJ2KMZAE3Lghmci-RXdhi7hXpmN30Tzze1ZsjvVeRRKNzShByKK9ZGZPmQ5yggJOXFy32ehjGkYwFMCqgMQomcGbcYhsd97huKHMHl3HOE5GDYjIq9o9oABC"; - private static final String HS_JWT_INVALID_SIGNATURE = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6IjEyMzQiLCJpc3MiOiJodHRwczovL21lLmF1dGgwLmNvbS8iLCJhdWQiOiJkYU9nbkdzUlloa3d1NjIxdmYiLCJzdWIiOiJhdXRoMHx1c2VyMTIzIn0.eTxhYFIHNii1zjxGr9QZvPcqofOd_4bHcjxGq7CQluY"; - - @Test - public void failsWhenAlgorithmIsNotExpected() { - SignatureVerifier verifier = new AlgorithmNameVerifier(); - TokenValidationException e = assertThrows(TokenValidationException.class, () -> verifier.verifySignature(NONE_JWT)); - assertEquals("Signature algorithm of \"none\" is not supported. Expected the ID token to be signed with \"[HS256, RS256]\".", e.getMessage()); - } - - @Test - public void failsWhenTokenCannotBeDecoded() { - SignatureVerifier verifier = new SymmetricSignatureVerifier("secret"); - TokenValidationException e = assertThrows(TokenValidationException.class, () -> verifier.verifySignature("boom")); - assertEquals("ID token could not be decoded", e.getMessage()); - } - - @Test - public void failsWhenAlgorithmRS256IsNotExpected() { - SignatureVerifier verifier = new SymmetricSignatureVerifier("secret"); - TokenValidationException e = assertThrows(TokenValidationException.class, () -> verifier.verifySignature(RS_JWT)); - assertEquals("Signature algorithm of \"RS256\" is not supported. Expected the ID token to be signed with \"[HS256]\".", e.getMessage()); - } - - @Test - public void failsWhenAlgorithmHS256IsNotExpected() throws Exception { - SignatureVerifier verifier = new AsymmetricSignatureVerifier(getRSProvider(RS_PUBLIC_KEY)); - TokenValidationException e = assertThrows(TokenValidationException.class, () -> verifier.verifySignature(HS_JWT)); - assertEquals("Signature algorithm of \"HS256\" is not supported. Expected the ID token to be signed with \"[RS256]\".", e.getMessage()); - } - - @Test - public void succeedsSkippingSignatureCheckOnHS256Token() { - SignatureVerifier verifier = new AlgorithmNameVerifier(); - DecodedJWT decodedJWT1 = verifier.verifySignature(HS_JWT); - DecodedJWT decodedJWT2 = verifier.verifySignature(HS_JWT_INVALID_SIGNATURE); - - assertThat(decodedJWT1, notNullValue()); - assertThat(decodedJWT2, notNullValue()); - } - - @Test - public void succeedsSkippingSignatureCheckOnRS256Token() { - SignatureVerifier verifier = new AlgorithmNameVerifier(); - DecodedJWT decodedJWT1 = verifier.verifySignature(RS_JWT); - DecodedJWT decodedJWT2 = verifier.verifySignature(RS_JWT_INVALID_SIGNATURE); - - assertThat(decodedJWT1, notNullValue()); - assertThat(decodedJWT2, notNullValue()); - } - - @Test - public void succeedsWithValidSignatureHS256Token() { - SignatureVerifier verifier = new SymmetricSignatureVerifier("secret"); - DecodedJWT decodedJWT = verifier.verifySignature(HS_JWT); - - assertThat(decodedJWT, notNullValue()); - } - - @Test - public void succeedsAndIgnoresExpiredTokenException() { - SignatureVerifier verifier = new SymmetricSignatureVerifier("secret"); - DecodedJWT decodedJWT = verifier.verifySignature(EXPIRED_HS_JWT); - - assertThat(decodedJWT, notNullValue()); - } - - @Test - public void failsWithInvalidSignatureHS256Token() { - SignatureVerifier verifier = new SymmetricSignatureVerifier("badsecret"); - TokenValidationException e = assertThrows(TokenValidationException.class, () -> verifier.verifySignature(HS_JWT)); - assertEquals("Invalid token signature", e.getMessage()); - } - - @Test - public void succeedsWithValidSignatureRS256Token() throws Exception { - SignatureVerifier verifier = new AsymmetricSignatureVerifier(getRSProvider(RS_PUBLIC_KEY)); - DecodedJWT decodedJWT = verifier.verifySignature(RS_JWT); - - assertThat(decodedJWT, notNullValue()); - } - - @Test - public void failsWithInvalidSignatureRS256Token() throws Exception { - SignatureVerifier verifier = new AsymmetricSignatureVerifier(getRSProvider(RS_PUBLIC_KEY_BAD)); - - TokenValidationException e = assertThrows(TokenValidationException.class, () -> verifier.verifySignature(RS_JWT)); - assertEquals("Invalid token signature", e.getMessage()); - } - - @Test - public void failsWhenErrorGettingJwk() throws Exception { - JwkProvider jwkProvider = mock(JwkProvider.class); - when(jwkProvider.get("abc123")).thenThrow(JwkException.class); - - SignatureVerifier verifier = new AsymmetricSignatureVerifier(jwkProvider); - TokenValidationException e = assertThrows(TokenValidationException.class, () -> verifier.verifySignature(RS_JWT)); - assertEquals("Invalid token signature", e.getMessage()); - } - - private JwkProvider getRSProvider(String rsaPath) throws Exception { - JwkProvider jwkProvider = mock(JwkProvider.class); - Jwk jwk = mock(Jwk.class); - when(jwkProvider.get("abc123")).thenReturn(jwk); - RSAPublicKey key = readPublicKeyFromFile(rsaPath); - when(jwk.getPublicKey()).thenReturn(key); - return jwkProvider; - } - - private static RSAPublicKey readPublicKeyFromFile(final String path) throws IOException { - Scanner scanner = null; - PemReader pemReader = null; - try { - scanner = new Scanner(Paths.get(path)); - if (scanner.hasNextLine() && scanner.nextLine().startsWith("-----BEGIN CERTIFICATE-----")) { - FileInputStream fs = new FileInputStream(path); - CertificateFactory fact = CertificateFactory.getInstance("X.509"); - X509Certificate cer = (X509Certificate) fact.generateCertificate(fs); - PublicKey key = cer.getPublicKey(); - fs.close(); - return (RSAPublicKey) key; - } else { - pemReader = new PemReader(new FileReader(path)); - byte[] keyBytes = pemReader.readPemObject().getContent(); - KeyFactory kf = KeyFactory.getInstance("RSA"); - EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); - return (RSAPublicKey) kf.generatePublic(keySpec); - } - } catch (Exception e) { - throw new IOException("Couldn't parse the RSA Public Key / Certificate file.", e); - } finally { - if (scanner != null) { - scanner.close(); - } - if (pemReader != null) { - pemReader.close(); - } - } - } -} diff --git a/src/test/java/com/auth0/SignedCookieUtilsTest.java b/src/test/java/com/auth0/SignedCookieUtilsTest.java index 4fce5b3..9981701 100644 --- a/src/test/java/com/auth0/SignedCookieUtilsTest.java +++ b/src/test/java/com/auth0/SignedCookieUtilsTest.java @@ -164,4 +164,70 @@ public void shouldRejectCompletelyFabricatedValue() { assertThat(extracted, is(nullValue())); } + + // --- Context-bound sign/verify tests (transaction binding) --- + + private static final String STATE = "randomState123"; + + @Test + public void shouldSignWithContext() { + String signed = SignedCookieUtils.sign(DOMAIN, STATE, SECRET); + + assertThat(signed, is(notNullValue())); + String[] parts = signed.split("\\|"); + assertThat(parts.length, is(2)); + assertThat(parts[0], is(DOMAIN)); + assertThat(parts[1].length(), is(64)); + } + + @Test + public void shouldVerifyWithMatchingContext() { + String signed = SignedCookieUtils.sign(DOMAIN, STATE, SECRET); + + String extracted = SignedCookieUtils.verifyAndExtract(signed, STATE, SECRET); + + assertThat(extracted, is(DOMAIN)); + } + + @Test + public void shouldRejectWrongContext() { + String signed = SignedCookieUtils.sign(DOMAIN, STATE, SECRET); + + String extracted = SignedCookieUtils.verifyAndExtract(signed, "different-state", SECRET); + + assertThat(extracted, is(nullValue())); + } + + @Test + public void shouldProduceDifferentSignaturesForDifferentContexts() { + String signed1 = SignedCookieUtils.sign(DOMAIN, "state-1", SECRET); + String signed2 = SignedCookieUtils.sign(DOMAIN, "state-2", SECRET); + + assertThat(signed1, is(not(signed2))); + } + + @Test + public void shouldNotVerifyContextBoundCookieWithoutContext() { + // Cookie signed with context should NOT verify via the context-less overload + String signed = SignedCookieUtils.sign(DOMAIN, STATE, SECRET); + + String extracted = SignedCookieUtils.verifyAndExtract(signed, SECRET); + + assertThat(extracted, is(nullValue())); + } + + @Test + public void shouldThrowWhenSigningWithNullContext() { + assertThrows(IllegalArgumentException.class, () -> + SignedCookieUtils.sign(DOMAIN, null, SECRET)); + } + + @Test + public void shouldReturnNullWhenVerifyingWithNullContext() { + String signed = SignedCookieUtils.sign(DOMAIN, STATE, SECRET); + + String extracted = SignedCookieUtils.verifyAndExtract(signed, null, SECRET); + + assertThat(extracted, is(nullValue())); + } } diff --git a/src/test/java/com/auth0/TransientCookieStoreTest.java b/src/test/java/com/auth0/TransientCookieStoreTest.java index 9db31f4..d210cb8 100644 --- a/src/test/java/com/auth0/TransientCookieStoreTest.java +++ b/src/test/java/com/auth0/TransientCookieStoreTest.java @@ -6,13 +6,14 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; -import javax.servlet.http.Cookie; +import jakarta.servlet.http.Cookie; import java.net.URLEncoder; import java.util.Arrays; import java.util.List; import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.matchesPattern; public class TransientCookieStoreTest { @@ -49,11 +50,9 @@ public void shouldHandleSpecialCharsWhenStoringState() throws Exception { List headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(2)); - String expectedEncodedState = URLEncoder.encode(stateVal, "UTF-8"); - assertThat(headers, hasItem( - String.format("com.auth0.state=%s; HttpOnly; Max-Age=600; SameSite=None; Secure", expectedEncodedState))); - assertThat(headers, hasItem( - String.format("_com.auth0.state=%s; HttpOnly; Max-Age=600", expectedEncodedState))); + String expectedEncodedState = URLEncoder.encode(stateVal, "UTF-8").replaceAll("\\+", "\\\\+"); + assertThat(headers, hasItem(matchesPattern(String.format("com\\.auth0\\.state=%s; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None", expectedEncodedState)))); + assertThat(headers, hasItem(matchesPattern(String.format("_com\\.auth0\\.state=%s; Max-Age=600; Expires=.*?; HttpOnly", expectedEncodedState)))); } @Test @@ -63,8 +62,8 @@ public void shouldSetStateSameSiteCookieAndFallbackCookie() { List headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(2)); - assertThat(headers, hasItem("com.auth0.state=123456; HttpOnly; Max-Age=600; SameSite=None; Secure")); - assertThat(headers, hasItem("_com.auth0.state=123456; HttpOnly; Max-Age=600")); + assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=123456; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); + assertThat(headers, hasItem(matchesPattern("_com\\.auth0\\.state=123456; Max-Age=600; Expires=.*?; HttpOnly"))); } @Test @@ -74,7 +73,7 @@ public void shouldSetStateSameSiteCookieAndNoFallbackCookie() { List headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(1)); - assertThat(headers, hasItem("com.auth0.state=123456; HttpOnly; Max-Age=600; SameSite=None; Secure")); + assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=123456; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); } @Test @@ -84,7 +83,7 @@ public void shouldSetSecureCookieWhenSameSiteLaxAndConfigured() { List headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(1)); - assertThat(headers, hasItem("com.auth0.state=123456; HttpOnly; Max-Age=600; SameSite=Lax; Secure")); + assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=123456; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=Lax"))); } @Test @@ -94,8 +93,8 @@ public void shouldSetSecureFallbackCookieWhenSameSiteNoneAndConfigured() { List headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(2)); - assertThat(headers, hasItem("com.auth0.state=123456; HttpOnly; Max-Age=600; SameSite=None; Secure")); - assertThat(headers, hasItem("_com.auth0.state=123456; HttpOnly; Max-Age=600; Secure")); + assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=123456; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); + assertThat(headers, hasItem(matchesPattern("_com\\.auth0\\.state=123456; Max-Age=600; Expires=.*?; Secure; HttpOnly"))); } @Test @@ -105,7 +104,7 @@ public void shouldNotSetSecureCookieWhenSameSiteLaxAndConfigured() { List headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(1)); - assertThat(headers, hasItem("com.auth0.state=123456; HttpOnly; Max-Age=600; SameSite=Lax")); + assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=123456; Max-Age=600; Expires=.*?; HttpOnly; SameSite=Lax"))); } @Test @@ -115,8 +114,8 @@ public void shouldSetNonceSameSiteCookieAndFallbackCookie() { List headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(2)); - assertThat(headers, hasItem("com.auth0.nonce=123456; HttpOnly; Max-Age=600; SameSite=None; Secure")); - assertThat(headers, hasItem("_com.auth0.nonce=123456; HttpOnly; Max-Age=600")); + assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.nonce=123456; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); + assertThat(headers, hasItem(matchesPattern("_com\\.auth0\\.nonce=123456; Max-Age=600; Expires=.*?; HttpOnly"))); } @Test @@ -126,7 +125,7 @@ public void shouldSetNonceSameSiteCookieAndNoFallbackCookie() { List headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(1)); - assertThat(headers, hasItem("com.auth0.nonce=123456; HttpOnly; Max-Age=600; SameSite=None; Secure")); + assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.nonce=123456; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); } @Test @@ -273,10 +272,11 @@ public void shouldReturnEmptyWhenNoNonceCookie() { private static final String TEST_SECRET = "testClientSecret123"; private static final String TEST_DOMAIN = "tenant-a.auth0.com"; + private static final String TEST_STATE = "abc123state"; @Test public void shouldStoreSignedOriginDomainCookie() { - TransientCookieStore.storeSignedOriginDomain(response, TEST_DOMAIN, + TransientCookieStore.storeSignedOriginDomain(response, TEST_DOMAIN, TEST_STATE, SameSite.LAX, null, false, TEST_SECRET); List headers = response.getHeaders("Set-Cookie"); @@ -289,7 +289,7 @@ public void shouldStoreSignedOriginDomainCookie() { @Test public void shouldStoreSignedOriginDomainWithSameSiteNone() { - TransientCookieStore.storeSignedOriginDomain(response, TEST_DOMAIN, + TransientCookieStore.storeSignedOriginDomain(response, TEST_DOMAIN, TEST_STATE, SameSite.NONE, null, false, TEST_SECRET); List headers = response.getHeaders("Set-Cookie"); @@ -300,11 +300,11 @@ public void shouldStoreSignedOriginDomainWithSameSiteNone() { @Test public void shouldRetrieveAndVerifySignedOriginDomain() { - String signedValue = SignedCookieUtils.sign(TEST_DOMAIN, TEST_SECRET); + String signedValue = SignedCookieUtils.sign(TEST_DOMAIN, TEST_STATE, TEST_SECRET); Cookie cookie = new Cookie("com.auth0.origin_domain", signedValue); request.setCookies(cookie); - String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_SECRET); + String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_STATE, TEST_SECRET); assertThat(domain, is(TEST_DOMAIN)); } @@ -315,36 +315,47 @@ public void shouldReturnNullForTamperedOriginDomain() { "evil.auth0.com|aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); request.setCookies(cookie); - String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_SECRET); + String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_STATE, TEST_SECRET); assertThat(domain, is(nullValue())); } @Test public void shouldReturnNullForMissingOriginDomainCookie() { - String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_SECRET); + String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_STATE, TEST_SECRET); assertThat(domain, is(nullValue())); } @Test public void shouldReturnNullForWrongSecret() { - String signedValue = SignedCookieUtils.sign(TEST_DOMAIN, TEST_SECRET); + String signedValue = SignedCookieUtils.sign(TEST_DOMAIN, TEST_STATE, TEST_SECRET); Cookie cookie = new Cookie("com.auth0.origin_domain", signedValue); request.setCookies(cookie); - String domain = TransientCookieStore.getSignedOriginDomain(request, response, "wrong-secret"); + String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_STATE, "wrong-secret"); + + assertThat(domain, is(nullValue())); + } + + @Test + public void shouldReturnNullForWrongState() { + String signedValue = SignedCookieUtils.sign(TEST_DOMAIN, TEST_STATE, TEST_SECRET); + Cookie cookie = new Cookie("com.auth0.origin_domain", signedValue); + request.setCookies(cookie); + + String domain = TransientCookieStore.getSignedOriginDomain(request, response, "different-state", TEST_SECRET); assertThat(domain, is(nullValue())); } @Test public void shouldDeleteOriginDomainCookieAfterReading() { - String signedValue = SignedCookieUtils.sign(TEST_DOMAIN, TEST_SECRET); + String signedValue = SignedCookieUtils.sign(TEST_DOMAIN, TEST_STATE, TEST_SECRET); Cookie cookie = new Cookie("com.auth0.origin_domain", signedValue); request.setCookies(cookie); - String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_SECRET); + String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_STATE, TEST_SECRET); assertThat(domain, is(TEST_DOMAIN)); Cookie[] responseCookies = response.getCookies(); @@ -362,7 +373,7 @@ public void shouldDeleteOriginDomainCookieAfterReading() { @Test public void shouldStoreAndRetrieveSignedOriginDomainEndToEnd() { - TransientCookieStore.storeSignedOriginDomain(response, TEST_DOMAIN, + TransientCookieStore.storeSignedOriginDomain(response, TEST_DOMAIN, TEST_STATE, SameSite.LAX, null, false, TEST_SECRET); List headers = response.getHeaders("Set-Cookie"); @@ -377,7 +388,7 @@ public void shouldStoreAndRetrieveSignedOriginDomainEndToEnd() { MockHttpServletResponse callbackResponse = new MockHttpServletResponse(); callbackRequest.setCookies(cookie); - String domain = TransientCookieStore.getSignedOriginDomain(callbackRequest, callbackResponse, TEST_SECRET); + String domain = TransientCookieStore.getSignedOriginDomain(callbackRequest, callbackResponse, TEST_STATE, TEST_SECRET); assertThat(domain, is(TEST_DOMAIN)); } } From cd48c86fc00a1e7b8733f10efa18bb80d7795994 Mon Sep 17 00:00:00 2001 From: Tanya Sinha Date: Tue, 19 May 2026 09:37:27 +0530 Subject: [PATCH 02/14] chore: Update CI/CD workflows for dual-version (v1/v2) support (#227) Co-authored-by: Tareq Kirresh Co-authored-by: Kailash B --- .github/workflows/build-and-test.yml | 4 ++-- .github/workflows/codeql.yml | 9 +++++++-- .github/workflows/release.yml | 4 ++-- .github/workflows/sca_scan.yml | 6 +++--- .version | 2 +- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index bbd90e1..01fc2b7 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -4,7 +4,7 @@ on: pull_request: merge_group: push: - branches: ["master", "main", "v1"] + branches: ["master", "v1", "v2"] jobs: gradle: @@ -14,7 +14,7 @@ jobs: - uses: actions/setup-java@v5 with: distribution: temurin - java-version: 8 + java-version: ${{ (github.ref == 'refs/heads/v1' || github.base_ref == 'v1') && '8' || '17' }} - name: Set up Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4e038cd..d2d2850 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,9 @@ name: "CodeQL" on: push: - branches: [ "master", "2.0.0-dev" ] + branches: [ "master", "v1", "v2"] pull_request: - branches: [ "master" ] + branches: [ "master", "v1" ] schedule: - cron: "30 19 * * 6" @@ -26,6 +26,11 @@ jobs: - name: Checkout uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: ${{ (github.ref == 'refs/heads/v1' || github.base_ref == 'v1') && '8' || '17' }} + - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9829b1a..0c87ece 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: rl-scanner: uses: ./.github/workflows/rl-secure.yml with: - java-version: 8 + java-version: ${{ (github.ref == 'refs/heads/v1' || github.base_ref == 'v1') && '8' || '17' }} artifact-name: "auth0-java-mvc-common.tgz" secrets: RLSECURE_LICENSE: ${{ secrets.RLSECURE_LICENSE }} @@ -32,7 +32,7 @@ jobs: uses: ./.github/workflows/java-release.yml needs: rl-scanner with: - java-version: 8.0.382-tem + java-version: ${{ (github.ref == 'refs/heads/v1' || github.base_ref == 'v1') && '8' || '17' }} secrets: ossr-username: ${{ secrets.OSSR_USERNAME }} ossr-token: ${{ secrets.OSSR_TOKEN }} diff --git a/.github/workflows/sca_scan.yml b/.github/workflows/sca_scan.yml index 86239c0..5f82e75 100644 --- a/.github/workflows/sca_scan.yml +++ b/.github/workflows/sca_scan.yml @@ -2,14 +2,14 @@ name: SCA on: push: - branches: ["master"] + branches: ["master", "v1", "v2"] pull_request: - branches: ["master"] + branches: ["master", "v1", "v2"] jobs: snyk-cli: uses: auth0/devsecops-tooling/.github/workflows/sca-scan.yml@main with: additional-arguments: "--exclude=README.md" - java-version: "8" + java-version: ${{ (github.ref == 'refs/heads/v1' || github.base_ref == 'v1') && '8' || '17' }} secrets: inherit diff --git a/.version b/.version index 32bd932..60b5ccb 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -1.12.0 \ No newline at end of file +2.0.0-beta.0 \ No newline at end of file From 98a02b63e02afff8487b80be58852f3487643f1c Mon Sep 17 00:00:00 2001 From: Tanya Sinha Date: Tue, 19 May 2026 09:58:56 +0530 Subject: [PATCH 03/14] chore: Update CI/CD workflows for v2 (#230) --- .github/workflows/build-and-test.yml | 5 +++-- .github/workflows/codeql.yml | 6 +++--- .github/workflows/release.yml | 4 ++-- .github/workflows/sca_scan.yml | 6 +++--- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 01fc2b7..86cccb2 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -2,9 +2,10 @@ name: auth0/auth0-java-mvc-common/build-and-test on: pull_request: + branches: ["master", "v2"] merge_group: push: - branches: ["master", "v1", "v2"] + branches: ["master"] jobs: gradle: @@ -14,7 +15,7 @@ jobs: - uses: actions/setup-java@v5 with: distribution: temurin - java-version: ${{ (github.ref == 'refs/heads/v1' || github.base_ref == 'v1') && '8' || '17' }} + java-version: 17 - name: Set up Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d2d2850..fd87aef 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,9 @@ name: "CodeQL" on: push: - branches: [ "master", "v1", "v2"] + branches: [ "master" ] pull_request: - branches: [ "master", "v1" ] + branches: [ "master", "v2" ] schedule: - cron: "30 19 * * 6" @@ -29,7 +29,7 @@ jobs: - uses: actions/setup-java@v5 with: distribution: temurin - java-version: ${{ (github.ref == 'refs/heads/v1' || github.base_ref == 'v1') && '8' || '17' }} + java-version: 17 - name: Initialize CodeQL uses: github/codeql-action/init@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c87ece..1f582e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: rl-scanner: uses: ./.github/workflows/rl-secure.yml with: - java-version: ${{ (github.ref == 'refs/heads/v1' || github.base_ref == 'v1') && '8' || '17' }} + java-version: 17 artifact-name: "auth0-java-mvc-common.tgz" secrets: RLSECURE_LICENSE: ${{ secrets.RLSECURE_LICENSE }} @@ -32,7 +32,7 @@ jobs: uses: ./.github/workflows/java-release.yml needs: rl-scanner with: - java-version: ${{ (github.ref == 'refs/heads/v1' || github.base_ref == 'v1') && '8' || '17' }} + java-version: 17 secrets: ossr-username: ${{ secrets.OSSR_USERNAME }} ossr-token: ${{ secrets.OSSR_TOKEN }} diff --git a/.github/workflows/sca_scan.yml b/.github/workflows/sca_scan.yml index 5f82e75..01717f7 100644 --- a/.github/workflows/sca_scan.yml +++ b/.github/workflows/sca_scan.yml @@ -2,14 +2,14 @@ name: SCA on: push: - branches: ["master", "v1", "v2"] + branches: ["master"] pull_request: - branches: ["master", "v1", "v2"] + branches: ["master", "v2"] jobs: snyk-cli: uses: auth0/devsecops-tooling/.github/workflows/sca-scan.yml@main with: additional-arguments: "--exclude=README.md" - java-version: ${{ (github.ref == 'refs/heads/v1' || github.base_ref == 'v1') && '8' || '17' }} + java-version: "17" secrets: inherit From 70ed4b210a971752927e1efc0d10fd1579f4caa7 Mon Sep 17 00:00:00 2001 From: Tanya Sinha Date: Fri, 22 May 2026 15:37:38 +0530 Subject: [PATCH 04/14] =?UTF-8?q?Fix:=20Transaction-keyed=20cookies=20to?= =?UTF-8?q?=20prevent=20multi-tab=20OAuth=20state=20race=20=E2=80=A6=20(#2?= =?UTF-8?q?31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/auth0/AuthorizeUrl.java | 2 +- src/main/java/com/auth0/RequestProcessor.java | 6 +- src/main/java/com/auth0/StorageUtils.java | 21 ++ .../java/com/auth0/TransientCookieStore.java | 62 ++- src/test/java/com/auth0/AuthorizeUrlTest.java | 24 +- .../com/auth0/TransientCookieStoreTest.java | 354 +++++++++++++----- 6 files changed, 349 insertions(+), 120 deletions(-) diff --git a/src/main/java/com/auth0/AuthorizeUrl.java b/src/main/java/com/auth0/AuthorizeUrl.java index 2d41b10..abb2194 100644 --- a/src/main/java/com/auth0/AuthorizeUrl.java +++ b/src/main/java/com/auth0/AuthorizeUrl.java @@ -252,7 +252,7 @@ private void storeTransient() { SameSite sameSiteValue = containsFormPost() ? SameSite.NONE : SameSite.LAX; TransientCookieStore.storeState(response, state, sameSiteValue, useLegacySameSiteCookie, setSecureCookie, cookiePath); - TransientCookieStore.storeNonce(response, nonce, sameSiteValue, useLegacySameSiteCookie, setSecureCookie, cookiePath); + TransientCookieStore.storeNonce(response, nonce, state, sameSiteValue, useLegacySameSiteCookie, setSecureCookie, cookiePath); // Store HMAC-signed origin domain bound to this transaction's state if (originDomain != null && clientSecret != null && state != null) { diff --git a/src/main/java/com/auth0/RequestProcessor.java b/src/main/java/com/auth0/RequestProcessor.java index 5616114..36a53cc 100644 --- a/src/main/java/com/auth0/RequestProcessor.java +++ b/src/main/java/com/auth0/RequestProcessor.java @@ -240,7 +240,7 @@ Tokens process(HttpServletRequest request, HttpServletResponse response) throws throw new InvalidRequestException(MISSING_ACCESS_TOKEN, "Access Token is missing from the response."); } - return getVerifiedTokens(request, response, frontChannelTokens, responseTypeList, originDomain, originIssuer); + return getVerifiedTokens(request, response, frontChannelTokens, responseTypeList, originDomain, originIssuer, state); } static boolean requiresFormPostResponseMode(List responseType) { @@ -256,13 +256,13 @@ static boolean requiresFormPostResponseMode(List responseType) { * @return a Tokens object that wraps the values obtained from the front-channel and/or the code request response. * @throws IdentityVerificationException */ - private Tokens getVerifiedTokens(HttpServletRequest request, HttpServletResponse response, Tokens frontChannelTokens, List responseTypeList, String originDomain, String originIssuer) + private Tokens getVerifiedTokens(HttpServletRequest request, HttpServletResponse response, Tokens frontChannelTokens, List responseTypeList, String originDomain, String originIssuer, String state) throws IdentityVerificationException { String authorizationCode = request.getParameter(KEY_CODE); Tokens codeExchangeTokens = null; - String nonce = TransientCookieStore.getNonce(request, response); + String nonce = TransientCookieStore.getNonce(request, response, state); try { if (responseTypeList.contains(KEY_ID_TOKEN)) { diff --git a/src/main/java/com/auth0/StorageUtils.java b/src/main/java/com/auth0/StorageUtils.java index 3c41de5..4a3b4d5 100644 --- a/src/main/java/com/auth0/StorageUtils.java +++ b/src/main/java/com/auth0/StorageUtils.java @@ -12,6 +12,27 @@ private StorageUtils() {} static final String NONCE_KEY = "com.auth0.nonce"; static final String ORIGIN_DOMAIN_KEY = "com.auth0.origin_domain"; + /** + * Constructs a transaction-keyed state cookie name. + * Each login transaction gets its own cookie, preventing multi-tab overwrites. + * + * @param state the state value for this transaction + * @return the cookie name in the form "com.auth0.state.{state}" + */ + static String transactionStateKey(String state) { + return STATE_KEY + "." + state; + } + + /** + * Constructs a transaction-keyed nonce cookie name. + * + * @param state the state value for this transaction (used as key, not the nonce itself) + * @return the cookie name in the form "com.auth0.nonce.{state}" + */ + static String transactionNonceKey(String state) { + return NONCE_KEY + "." + state; + } + /** * Generates a new random string using {@link SecureRandom}. * The output can be used as State or Nonce values for API requests. diff --git a/src/main/java/com/auth0/TransientCookieStore.java b/src/main/java/com/auth0/TransientCookieStore.java index 7fb6b34..4e3a40c 100644 --- a/src/main/java/com/auth0/TransientCookieStore.java +++ b/src/main/java/com/auth0/TransientCookieStore.java @@ -10,7 +10,10 @@ import java.nio.charset.StandardCharsets; /** - * Allows storage and retrieval/removal of cookies. + * Allows storage and retrieval/removal of transient cookies used during the OAuth transaction. + * + *

Each login transaction gets its own uniquely-named cookies (keyed by state value), + * preventing multi-tab race conditions where concurrent logins would overwrite each other's state.

*/ class TransientCookieStore { @@ -19,50 +22,91 @@ private TransientCookieStore() {} /** - * Stores a state value as a cookie on the response. + * Stores a state value as a transaction-keyed cookie on the response. + * The cookie name includes the state value itself, ensuring each login flow + * gets its own isolated cookie (e.g., "com.auth0.state.{state_value}"). * * @param response the response object to set the cookie on * @param state the value for the state cookie. If null, no cookie will be set. * @param sameSite the value for the SameSite attribute on the cookie * @param useLegacySameSiteCookie whether to set a fallback cookie or not * @param isSecureCookie whether to always set the Secure cookie attribute or not + * @param cookiePath the path for the cookie */ static void storeState(HttpServletResponse response, String state, SameSite sameSite, boolean useLegacySameSiteCookie, boolean isSecureCookie, String cookiePath) { - store(response, StorageUtils.STATE_KEY, state, sameSite, useLegacySameSiteCookie, isSecureCookie, cookiePath); + if (state == null) { + return; + } + store(response, StorageUtils.transactionStateKey(state), state, sameSite, useLegacySameSiteCookie, isSecureCookie, cookiePath); } /** - * Stores a nonce value as a cookie on the response. + * Stores a nonce value as a transaction-keyed cookie on the response. + * The cookie is keyed by the state value (not the nonce), so it can be + * retrieved during callback using the state parameter from the URL. * * @param response the response object to set the cookie on * @param nonce the value for the nonce cookie. If null, no cookie will be set. + * @param state the state value for this transaction (used as key in cookie name) * @param sameSite the value for the SameSite attribute on the cookie * @param useLegacySameSiteCookie whether to set a fallback cookie or not * @param isSecureCookie whether to always set the Secure cookie attribute or not + * @param cookiePath the path for the cookie */ - static void storeNonce(HttpServletResponse response, String nonce, SameSite sameSite, boolean useLegacySameSiteCookie, boolean isSecureCookie, String cookiePath) { - store(response, StorageUtils.NONCE_KEY, nonce, sameSite, useLegacySameSiteCookie, isSecureCookie, cookiePath); + static void storeNonce(HttpServletResponse response, String nonce, String state, SameSite sameSite, boolean useLegacySameSiteCookie, boolean isSecureCookie, String cookiePath) { + if (nonce == null || state == null) { + return; + } + store(response, StorageUtils.transactionNonceKey(state), nonce, sameSite, useLegacySameSiteCookie, isSecureCookie, cookiePath); } /** - * Gets the value associated with the state cookie and removes it. + * Gets the value associated with the state cookie for this transaction and removes it. + * Uses the state parameter from the callback request to look up the correct transaction cookie. + * Falls back to the legacy fixed-name cookie for backward compatibility during rolling upgrades. * * @param request the request object * @param response the response object * @return the value of the state cookie, if it exists */ static String getState(HttpServletRequest request, HttpServletResponse response) { + String stateParam = request.getParameter("state"); + if (stateParam == null) { + return null; + } + + // Try transaction-keyed cookie first (new behavior) + String value = getOnce(StorageUtils.transactionStateKey(stateParam), request, response); + if (value != null) { + return value; + } + + // Fallback: legacy fixed-name cookie (for in-flight transactions during upgrade from v1) return getOnce(StorageUtils.STATE_KEY, request, response); } /** - * Gets the value associated with the nonce cookie and removes it. + * Gets the value associated with the nonce cookie for this transaction and removes it. + * Uses the state parameter to look up the correct transaction-keyed nonce cookie. + * Falls back to the legacy fixed-name cookie for backward compatibility. * * @param request the request object * @param response the response object + * @param state the state value from the callback (used to find the correct nonce cookie) * @return the value of the nonce cookie, if it exists */ - static String getNonce(HttpServletRequest request, HttpServletResponse response) { + static String getNonce(HttpServletRequest request, HttpServletResponse response, String state) { + if (state == null) { + return null; + } + + // Try transaction-keyed cookie first (new behavior) + String value = getOnce(StorageUtils.transactionNonceKey(state), request, response); + if (value != null) { + return value; + } + + // Fallback: legacy fixed-name cookie (for in-flight transactions during upgrade from v1) return getOnce(StorageUtils.NONCE_KEY, request, response); } diff --git a/src/test/java/com/auth0/AuthorizeUrlTest.java b/src/test/java/com/auth0/AuthorizeUrlTest.java index d8058ec..5ccea63 100644 --- a/src/test/java/com/auth0/AuthorizeUrlTest.java +++ b/src/test/java/com/auth0/AuthorizeUrlTest.java @@ -81,27 +81,29 @@ public void shouldSetAudience() { @Test public void shouldSetNonceSameSiteAndLegacyCookieByDefault() { String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") + .withState("stateVal") .withNonce("asdfghjkl") .build(); assertThat(HttpUrl.parse(url).queryParameter("nonce"), is("asdfghjkl")); Collection headers = response.getHeaders("Set-Cookie"); - assertThat(headers.size(), is(2)); - assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.nonce=asdfghjkl; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); - assertThat(headers, hasItem(matchesPattern("_com\\.auth0\\.nonce=asdfghjkl; Max-Age=600; Expires=.*?; HttpOnly"))); + // state (2: main + legacy) + nonce (2: main + legacy) = 4 + assertThat(headers, hasItem(containsString("com.auth0.nonce.stateVal=asdfghjkl"))); + assertThat(headers, hasItem(containsString("_com.auth0.nonce.stateVal=asdfghjkl"))); } @Test public void shouldSetNonceSameSiteAndNotLegacyCookieWhenConfigured() { String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token") + .withState("stateVal") .withNonce("asdfghjkl") .withLegacySameSiteCookie(false) .build(); assertThat(HttpUrl.parse(url).queryParameter("nonce"), is("asdfghjkl")); Collection headers = response.getHeaders("Set-Cookie"); - assertThat(headers.size(), is(1)); - assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.nonce=asdfghjkl; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); + assertThat(headers, hasItem(containsString("com.auth0.nonce.stateVal=asdfghjkl"))); + assertThat(headers, not(hasItem(containsString("_com.auth0.nonce")))); } @Test @@ -113,8 +115,8 @@ public void shouldSetStateSameSiteAndLegacyCookieByDefault() { Collection headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(2)); - assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=asdfghjkl; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); - assertThat(headers, hasItem(matchesPattern("_com\\.auth0\\.state=asdfghjkl; Max-Age=600; Expires=.*?; HttpOnly"))); + assertThat(headers, hasItem(containsString("com.auth0.state.asdfghjkl=asdfghjkl"))); + assertThat(headers, hasItem(containsString("_com.auth0.state.asdfghjkl=asdfghjkl"))); } @Test @@ -127,7 +129,7 @@ public void shouldSetStateSameSiteAndNotLegacyCookieWhenConfigured() { Collection headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(1)); - assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=asdfghjkl; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); + assertThat(headers, hasItem(containsString("com.auth0.state.asdfghjkl=asdfghjkl"))); } @Test @@ -140,7 +142,7 @@ public void shouldSetSecureCookieWhenConfiguredTrue() { Collection headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(1)); - assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=asdfghjkl; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=Lax"))); + assertThat(headers, hasItem(allOf(containsString("com.auth0.state.asdfghjkl=asdfghjkl"), containsString("Secure"), containsString("SameSite=Lax")))); } @Test @@ -153,8 +155,8 @@ public void shouldSetSecureCookieWhenConfiguredFalseAndSameSiteNone() { Collection headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(2)); - assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=asdfghjkl; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); - assertThat(headers, hasItem(matchesPattern("_com\\.auth0\\.state=asdfghjkl; Max-Age=600; Expires=.*?; HttpOnly"))); + assertThat(headers, hasItem(allOf(containsString("com.auth0.state.asdfghjkl=asdfghjkl"), containsString("Secure"), containsString("SameSite=None")))); + assertThat(headers, hasItem(containsString("_com.auth0.state.asdfghjkl=asdfghjkl"))); } @Test diff --git a/src/test/java/com/auth0/TransientCookieStoreTest.java b/src/test/java/com/auth0/TransientCookieStoreTest.java index d210cb8..44ff9e4 100644 --- a/src/test/java/com/auth0/TransientCookieStoreTest.java +++ b/src/test/java/com/auth0/TransientCookieStoreTest.java @@ -36,23 +36,38 @@ public void shouldNotSetCookieIfStateIsNull() { @Test public void shouldNotSetCookieIfNonceIsNull() { - TransientCookieStore.storeNonce(response, null, SameSite.NONE, true, false, null); + TransientCookieStore.storeNonce(response, null, "someState", SameSite.NONE, true, false, null); List headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(0)); } @Test - public void shouldHandleSpecialCharsWhenStoringState() throws Exception { - String stateVal = ";state = ,va\\lu;e\""; - TransientCookieStore.storeState(response, stateVal, SameSite.NONE, true, false, null); + public void shouldNotSetNonceCookieIfStateIsNull() { + TransientCookieStore.storeNonce(response, "nonceValue", null, SameSite.NONE, true, false, null); List headers = response.getHeaders("Set-Cookie"); - assertThat(headers.size(), is(2)); + assertThat(headers.size(), is(0)); + } + + @Test + public void shouldSetStateCookieWithTransactionKey() { + TransientCookieStore.storeState(response, "myState123", SameSite.LAX, false, false, null); - String expectedEncodedState = URLEncoder.encode(stateVal, "UTF-8").replaceAll("\\+", "\\\\+"); - assertThat(headers, hasItem(matchesPattern(String.format("com\\.auth0\\.state=%s; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None", expectedEncodedState)))); - assertThat(headers, hasItem(matchesPattern(String.format("_com\\.auth0\\.state=%s; Max-Age=600; Expires=.*?; HttpOnly", expectedEncodedState)))); + List headers = response.getHeaders("Set-Cookie"); + assertThat(headers.size(), is(1)); + // Cookie name should be "com.auth0.state.myState123" + assertThat(headers.get(0), containsString("com.auth0.state.myState123=myState123")); + } + + @Test + public void shouldSetNonceCookieWithTransactionKey() { + TransientCookieStore.storeNonce(response, "nonceVal", "myState123", SameSite.LAX, false, false, null); + + List headers = response.getHeaders("Set-Cookie"); + assertThat(headers.size(), is(1)); + // Cookie name should be "com.auth0.nonce.myState123" + assertThat(headers.get(0), containsString("com.auth0.nonce.myState123=nonceVal")); } @Test @@ -62,8 +77,10 @@ public void shouldSetStateSameSiteCookieAndFallbackCookie() { List headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(2)); - assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=123456; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); - assertThat(headers, hasItem(matchesPattern("_com\\.auth0\\.state=123456; Max-Age=600; Expires=.*?; HttpOnly"))); + assertThat(headers.get(0), containsString("com.auth0.state.123456=123456")); + assertThat(headers.get(0), containsString("SameSite=None")); + assertThat(headers.get(0), containsString("Secure")); + assertThat(headers.get(1), containsString("_com.auth0.state.123456=123456")); } @Test @@ -73,7 +90,8 @@ public void shouldSetStateSameSiteCookieAndNoFallbackCookie() { List headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(1)); - assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=123456; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); + assertThat(headers.get(0), containsString("com.auth0.state.123456=123456")); + assertThat(headers.get(0), containsString("SameSite=None")); } @Test @@ -83,7 +101,8 @@ public void shouldSetSecureCookieWhenSameSiteLaxAndConfigured() { List headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(1)); - assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=123456; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=Lax"))); + assertThat(headers.get(0), containsString("Secure")); + assertThat(headers.get(0), containsString("SameSite=Lax")); } @Test @@ -93,66 +112,89 @@ public void shouldSetSecureFallbackCookieWhenSameSiteNoneAndConfigured() { List headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(2)); - assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=123456; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); - assertThat(headers, hasItem(matchesPattern("_com\\.auth0\\.state=123456; Max-Age=600; Expires=.*?; Secure; HttpOnly"))); + assertThat(headers.get(0), containsString("SameSite=None")); + assertThat(headers.get(0), containsString("Secure")); + assertThat(headers.get(1), containsString("Secure")); } @Test - public void shouldNotSetSecureCookieWhenSameSiteLaxAndConfigured() { + public void shouldNotSetSecureCookieWhenSameSiteLaxAndNotConfigured() { TransientCookieStore.storeState(response, "123456", SameSite.LAX, true, false, null); List headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(1)); - assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=123456; Max-Age=600; Expires=.*?; HttpOnly; SameSite=Lax"))); + assertThat(headers.get(0), not(containsString("Secure"))); + assertThat(headers.get(0), containsString("SameSite=Lax")); } @Test public void shouldSetNonceSameSiteCookieAndFallbackCookie() { - TransientCookieStore.storeNonce(response, "123456", SameSite.NONE, true, false, null); + TransientCookieStore.storeNonce(response, "nonceVal", "stateVal", SameSite.NONE, true, false, null); List headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(2)); - assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.nonce=123456; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); - assertThat(headers, hasItem(matchesPattern("_com\\.auth0\\.nonce=123456; Max-Age=600; Expires=.*?; HttpOnly"))); + assertThat(headers.get(0), containsString("com.auth0.nonce.stateVal=nonceVal")); + assertThat(headers.get(0), containsString("SameSite=None")); + assertThat(headers.get(1), containsString("_com.auth0.nonce.stateVal=nonceVal")); } @Test public void shouldSetNonceSameSiteCookieAndNoFallbackCookie() { - TransientCookieStore.storeNonce(response, "123456", SameSite.NONE, false, false, null); + TransientCookieStore.storeNonce(response, "nonceVal", "stateVal", SameSite.NONE, false, false, null); List headers = response.getHeaders("Set-Cookie"); assertThat(headers.size(), is(1)); - assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.nonce=123456; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None"))); + assertThat(headers.get(0), containsString("com.auth0.nonce.stateVal=nonceVal")); } - @Test - public void shouldRemoveStateSameSiteCookieAndFallbackCookie() { - Cookie cookie1 = new Cookie("com.auth0.state", "123456"); - Cookie cookie2 = new Cookie("_com.auth0.state", "123456"); + // --- State retrieval tests (transaction-keyed) --- - request.setCookies(cookie1, cookie2); + @Test + public void shouldRetrieveTransactionKeyedStateCookie() { + Cookie cookie = new Cookie("com.auth0.state.myState", "myState"); + request.setCookies(cookie); + request.setParameter("state", "myState"); String state = TransientCookieStore.getState(request, response); - assertThat(state, is("123456")); + assertThat(state, is("myState")); + // Should delete the cookie Cookie[] cookies = response.getCookies(); assertThat(cookies, is(notNullValue())); + assertThat(cookies[0].getMaxAge(), is(0)); + } - List cookieList = Arrays.asList(cookies); - assertThat(cookieList.size(), is(2)); + @Test + public void shouldFallbackToLegacyFixedStateCookie() { + // Legacy cookie (from v1 SDK or in-flight transaction during upgrade) + Cookie cookie = new Cookie("com.auth0.state", "legacyState"); + request.setCookies(cookie); + request.setParameter("state", "legacyState"); - assertThat(Arrays.asList(cookies), everyItem(HasPropertyWithValue.hasProperty("value", is("")))); - assertThat(Arrays.asList(cookies), everyItem(HasPropertyWithValue.hasProperty("maxAge", is(0)))); + String state = TransientCookieStore.getState(request, response); + assertThat(state, is("legacyState")); } @Test - public void shouldRemoveStateSameSiteCookie() { - Cookie cookie1 = new Cookie("com.auth0.state", "123456"); + public void shouldPreferTransactionKeyedOverLegacy() { + Cookie txCookie = new Cookie("com.auth0.state.txState", "txState"); + Cookie legacyCookie = new Cookie("com.auth0.state", "oldState"); + request.setCookies(txCookie, legacyCookie); + request.setParameter("state", "txState"); - request.setCookies(cookie1); + String state = TransientCookieStore.getState(request, response); + assertThat(state, is("txState")); + } + + @Test + public void shouldRemoveStateSameSiteCookieAndFallbackCookie() { + Cookie cookie1 = new Cookie("com.auth0.state.123456", "123456"); + Cookie cookie2 = new Cookie("_com.auth0.state.123456", "123456"); + request.setCookies(cookie1, cookie2); + request.setParameter("state", "123456"); String state = TransientCookieStore.getState(request, response); assertThat(state, is("123456")); @@ -161,38 +203,66 @@ public void shouldRemoveStateSameSiteCookie() { assertThat(cookies, is(notNullValue())); List cookieList = Arrays.asList(cookies); - assertThat(cookieList.size(), is(1)); + assertThat(cookieList.size(), is(2)); + assertThat(Arrays.asList(cookies), everyItem(HasPropertyWithValue.hasProperty("value", is("")))); assertThat(Arrays.asList(cookies), everyItem(HasPropertyWithValue.hasProperty("maxAge", is(0)))); } @Test - public void shouldRemoveStateFallbackCookie() { - Cookie cookie1 = new Cookie("_com.auth0.state", "123456"); + public void shouldReturnNullStateWhenNoCookies() { + request.setParameter("state", "someState"); + String state = TransientCookieStore.getState(request, response); + assertThat(state, is(nullValue())); + } + + @Test + public void shouldReturnNullStateWhenNoStateParam() { + // No state parameter in request → null + String state = TransientCookieStore.getState(request, response); + assertThat(state, is(nullValue())); + } + @Test + public void shouldReturnEmptyWhenNoStateCookie() { + Cookie cookie1 = new Cookie("someCookie", "123456"); request.setCookies(cookie1); + request.setParameter("state", "someState"); String state = TransientCookieStore.getState(request, response); - assertThat(state, is("123456")); + assertThat(state, is(nullValue())); + } + + @Test + public void shouldRetrieveTransactionKeyedNonceCookie() { + Cookie cookie = new Cookie("com.auth0.nonce.myState", "nonceValue"); + request.setCookies(cookie); + + String nonce = TransientCookieStore.getNonce(request, response, "myState"); + assertThat(nonce, is("nonceValue")); Cookie[] cookies = response.getCookies(); assertThat(cookies, is(notNullValue())); + assertThat(cookies[0].getMaxAge(), is(0)); + } - List cookieList = Arrays.asList(cookies); - assertThat(cookieList.size(), is(1)); - assertThat(Arrays.asList(cookies), everyItem(HasPropertyWithValue.hasProperty("value", is("")))); - assertThat(Arrays.asList(cookies), everyItem(HasPropertyWithValue.hasProperty("maxAge", is(0)))); + @Test + public void shouldFallbackToLegacyFixedNonceCookie() { + Cookie cookie = new Cookie("com.auth0.nonce", "legacyNonce"); + request.setCookies(cookie); + + String nonce = TransientCookieStore.getNonce(request, response, "someState"); + assertThat(nonce, is("legacyNonce")); } @Test public void shouldRemoveNonceSameSiteCookieAndFallbackCookie() { - Cookie cookie1 = new Cookie("com.auth0.nonce", "123456"); - Cookie cookie2 = new Cookie("_com.auth0.nonce", "123456"); - + Cookie cookie1 = new Cookie("com.auth0.nonce.stateVal", "nonceVal"); + Cookie cookie2 = new Cookie("_com.auth0.nonce.stateVal", "nonceVal"); request.setCookies(cookie1, cookie2); - String state = TransientCookieStore.getNonce(request, response); - assertThat(state, is("123456")); + String nonce = TransientCookieStore.getNonce(request, response, "stateVal"); + assertThat(nonce, is("nonceVal")); Cookie[] cookies = response.getCookies(); assertThat(cookies, is(notNullValue())); @@ -204,72 +274,169 @@ public void shouldRemoveNonceSameSiteCookieAndFallbackCookie() { } @Test - public void shouldRemoveNonceSameSiteCookie() { - Cookie cookie1 = new Cookie("com.auth0.nonce", "123456"); - - request.setCookies(cookie1); - - String state = TransientCookieStore.getNonce(request, response); - assertThat(state, is("123456")); - - Cookie[] cookies = response.getCookies(); - assertThat(cookies, is(notNullValue())); - - List cookieList = Arrays.asList(cookies); - assertThat(cookieList.size(), is(1)); - assertThat(Arrays.asList(cookies), everyItem(HasPropertyWithValue.hasProperty("value", is("")))); - assertThat(Arrays.asList(cookies), everyItem(HasPropertyWithValue.hasProperty("maxAge", is(0)))); + public void shouldReturnNullNonceWhenNoCookies() { + String nonce = TransientCookieStore.getNonce(request, response, "someState"); + assertThat(nonce, is(nullValue())); } @Test - public void shouldRemoveNonceFallbackCookie() { - Cookie cookie1 = new Cookie("_com.auth0.nonce", "123456"); + public void shouldReturnNullNonceWhenStateIsNull() { + String nonce = TransientCookieStore.getNonce(request, response, null); + assertThat(nonce, is(nullValue())); + } + @Test + public void shouldReturnNullWhenNoNonceCookie() { + Cookie cookie1 = new Cookie("someCookie", "123456"); request.setCookies(cookie1); - String state = TransientCookieStore.getNonce(request, response); - assertThat(state, is("123456")); + String nonce = TransientCookieStore.getNonce(request, response, "someState"); + assertThat(nonce, is(nullValue())); + } - Cookie[] cookies = response.getCookies(); - assertThat(cookies, is(notNullValue())); + @Test + public void shouldIsolateMultipleTransactions() { + // Simulate two tabs storing state cookies + TransientCookieStore.storeState(response, "stateA", SameSite.LAX, false, false, null); + TransientCookieStore.storeState(response, "stateB", SameSite.LAX, false, false, null); - List cookieList = Arrays.asList(cookies); - assertThat(cookieList.size(), is(1)); - assertThat(Arrays.asList(cookies), everyItem(HasPropertyWithValue.hasProperty("value", is("")))); - assertThat(Arrays.asList(cookies), everyItem(HasPropertyWithValue.hasProperty("maxAge", is(0)))); + List headers = response.getHeaders("Set-Cookie"); + assertThat(headers.size(), is(2)); + assertThat(headers.get(0), containsString("com.auth0.state.stateA=stateA")); + assertThat(headers.get(1), containsString("com.auth0.state.stateB=stateB")); } @Test - public void shouldReturnEmptyStateWhenNoCookies() { - String state = TransientCookieStore.getState(request, response); - assertThat(state, is(nullValue())); + public void shouldRetrieveCorrectStateForEachTransaction() { + // Both transaction cookies exist + Cookie cookieA = new Cookie("com.auth0.state.stateA", "stateA"); + Cookie cookieB = new Cookie("com.auth0.state.stateB", "stateB"); + request.setCookies(cookieA, cookieB); + + // Tab A callback + request.setParameter("state", "stateA"); + String stateA = TransientCookieStore.getState(request, response); + assertThat(stateA, is("stateA")); + + // Tab B callback (new request) + MockHttpServletRequest requestB = new MockHttpServletRequest(); + MockHttpServletResponse responseB = new MockHttpServletResponse(); + requestB.setCookies(cookieA, cookieB); + requestB.setParameter("state", "stateB"); + String stateB = TransientCookieStore.getState(requestB, responseB); + assertThat(stateB, is("stateB")); } @Test - public void shouldReturnEmptyNonceWhenNoCookies() { - String nonce = TransientCookieStore.getNonce(request, response); - assertThat(nonce, is(nullValue())); + public void shouldNotDeleteOtherTransactionCookies() { + Cookie cookieA = new Cookie("com.auth0.state.stateA", "stateA"); + Cookie cookieB = new Cookie("com.auth0.state.stateB", "stateB"); + request.setCookies(cookieA, cookieB); + request.setParameter("state", "stateA"); + + TransientCookieStore.getState(request, response); + + // Only stateA's cookie should be deleted + Cookie[] deletedCookies = response.getCookies(); + assertThat(deletedCookies.length, is(1)); + assertThat(deletedCookies[0].getName(), is("com.auth0.state.stateA")); + assertThat(deletedCookies[0].getMaxAge(), is(0)); } - @Test - public void shouldReturnEmptyWhenNoStateCookie() { - Cookie cookie1 = new Cookie("someCookie", "123456"); - request.setCookies(cookie1); + // --- Nonce multi-tab isolation tests --- - String state = TransientCookieStore.getState(request, response); - assertThat(state, is(nullValue())); + @Test + public void shouldIsolateMultipleNonceTransactionsOnRetrieval() { + // Both nonce cookies exist (two concurrent tabs) + Cookie nonceA = new Cookie("com.auth0.nonce.stateA", "nonceA"); + Cookie nonceB = new Cookie("com.auth0.nonce.stateB", "nonceB"); + request.setCookies(nonceA, nonceB); + + // Retrieve nonce for Tab A — should get nonceA without touching nonceB + String resultA = TransientCookieStore.getNonce(request, response, "stateA"); + assertThat(resultA, is("nonceA")); + + // Only stateA's nonce cookie should be deleted + Cookie[] deletedCookies = response.getCookies(); + assertThat(deletedCookies.length, is(1)); + assertThat(deletedCookies[0].getName(), is("com.auth0.nonce.stateA")); + assertThat(deletedCookies[0].getMaxAge(), is(0)); + + // Tab B can still retrieve its nonce independently (new request) + MockHttpServletRequest requestB = new MockHttpServletRequest(); + MockHttpServletResponse responseB = new MockHttpServletResponse(); + requestB.setCookies(nonceA, nonceB); + + String resultB = TransientCookieStore.getNonce(requestB, responseB, "stateB"); + assertThat(resultB, is("nonceB")); } @Test - public void shouldReturnEmptyWhenNoNonceCookie() { - Cookie cookie1 = new Cookie("someCookie", "123456"); - request.setCookies(cookie1); + public void shouldPreferTransactionKeyedNonceOverLegacyAndNotDeleteLegacy() { + Cookie txCookie = new Cookie("com.auth0.nonce.stateA", "txNonce"); + Cookie legacyCookie = new Cookie("com.auth0.nonce", "legacyNonce"); + request.setCookies(txCookie, legacyCookie); + + String nonce = TransientCookieStore.getNonce(request, response, "stateA"); + assertThat(nonce, is("txNonce")); + + // Transaction-keyed cookie was consumed; legacy cookie should NOT be deleted + // (it may belong to another in-flight flow upgrading from v1) + Cookie[] deletedCookies = response.getCookies(); + assertThat(deletedCookies.length, is(1)); + assertThat(deletedCookies[0].getName(), is("com.auth0.nonce.stateA")); + } - String nonce = TransientCookieStore.getNonce(request, response); - assertThat(nonce, is(nullValue())); - assertThat(nonce, is(nullValue())); + @Test + public void shouldRetrieveCorrectStateAndNonceForEachTabEndToEnd() { + // Simulate two concurrent login flows storing state + nonce + MockHttpServletResponse storeResponse = new MockHttpServletResponse(); + TransientCookieStore.storeState(storeResponse, "stateA", SameSite.LAX, false, false, null); + TransientCookieStore.storeNonce(storeResponse, "nonceA", "stateA", SameSite.LAX, false, false, null); + TransientCookieStore.storeState(storeResponse, "stateB", SameSite.LAX, false, false, null); + TransientCookieStore.storeNonce(storeResponse, "nonceB", "stateB", SameSite.LAX, false, false, null); + + // Tab A callback + MockHttpServletRequest requestA = new MockHttpServletRequest(); + MockHttpServletResponse responseA = new MockHttpServletResponse(); + requestA.setCookies( + new Cookie("com.auth0.state.stateA", "stateA"), + new Cookie("com.auth0.nonce.stateA", "nonceA"), + new Cookie("com.auth0.state.stateB", "stateB"), + new Cookie("com.auth0.nonce.stateB", "nonceB") + ); + requestA.setParameter("state", "stateA"); + + String stateA = TransientCookieStore.getState(requestA, responseA); + String nonceA = TransientCookieStore.getNonce(requestA, responseA, "stateA"); + assertThat(stateA, is("stateA")); + assertThat(nonceA, is("nonceA")); + + // Tab B callback + MockHttpServletRequest requestB = new MockHttpServletRequest(); + MockHttpServletResponse responseB = new MockHttpServletResponse(); + requestB.setCookies( + new Cookie("com.auth0.state.stateA", "stateA"), + new Cookie("com.auth0.nonce.stateA", "nonceA"), + new Cookie("com.auth0.state.stateB", "stateB"), + new Cookie("com.auth0.nonce.stateB", "nonceB") + ); + requestB.setParameter("state", "stateB"); + + String stateB = TransientCookieStore.getState(requestB, responseB); + String nonceB = TransientCookieStore.getNonce(requestB, responseB, "stateB"); + assertThat(stateB, is("stateB")); + assertThat(nonceB, is("nonceB")); + + // Verify Tab A's retrieval didn't affect Tab B's cookies + Cookie[] deletedByA = responseA.getCookies(); + for (Cookie c : deletedByA) { + assertThat(c.getName(), not(containsString("stateB"))); + } } + // --- Origin domain tests --- + private static final String TEST_SECRET = "testClientSecret123"; private static final String TEST_DOMAIN = "tenant-a.auth0.com"; private static final String TEST_STATE = "abc123state"; @@ -305,7 +472,6 @@ public void shouldRetrieveAndVerifySignedOriginDomain() { request.setCookies(cookie); String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_STATE, TEST_SECRET); - assertThat(domain, is(TEST_DOMAIN)); } @@ -316,14 +482,12 @@ public void shouldReturnNullForTamperedOriginDomain() { request.setCookies(cookie); String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_STATE, TEST_SECRET); - assertThat(domain, is(nullValue())); } @Test public void shouldReturnNullForMissingOriginDomainCookie() { String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_STATE, TEST_SECRET); - assertThat(domain, is(nullValue())); } @@ -334,7 +498,6 @@ public void shouldReturnNullForWrongSecret() { request.setCookies(cookie); String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_STATE, "wrong-secret"); - assertThat(domain, is(nullValue())); } @@ -345,7 +508,6 @@ public void shouldReturnNullForWrongState() { request.setCookies(cookie); String domain = TransientCookieStore.getSignedOriginDomain(request, response, "different-state", TEST_SECRET); - assertThat(domain, is(nullValue())); } @@ -362,7 +524,7 @@ public void shouldDeleteOriginDomainCookieAfterReading() { assertThat(responseCookies, is(notNullValue())); boolean foundDeleted = false; for (Cookie c : responseCookies) { - if ("com.auth0.origin_domain".equals(c.getName())) { + if (c.getName().equals("com.auth0.origin_domain")) { assertThat(c.getMaxAge(), is(0)); assertThat(c.getValue(), is("")); foundDeleted = true; From 206267328ebc85c5ad1ea432e41c0bbb5ed9299e Mon Sep 17 00:00:00 2001 From: Tanya Sinha Date: Tue, 26 May 2026 13:55:49 +0530 Subject: [PATCH 05/14] Update Readme.md (#232) --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e03a4eb..d746375 100644 --- a/README.md +++ b/README.md @@ -19,15 +19,20 @@ - [Quickstart](https://auth0.com/docs/quickstart/webapp/java) - our interactive guide for quickly adding login, logout and user information to a Java Servlet application using Auth0. - [Sample App](https://github.com/auth0-samples/auth0-servlet-sample/tree/master/01-Login) - a sample Java Servlet application integrated with Auth0. - [Examples](./EXAMPLES.md) - code samples for common scenarios. +- [Migration Guide](./MIGRATION_GUIDE.md) - migrating from v1.x to v2.0.0. - [Docs site](https://www.auth0.com/docs) - explore our docs site and learn more about Auth0. ## Getting Started ### Requirements -Java 8 or above and `javax.servlet` version 3. +Java 17 or above and `jakarta.servlet` version 6.0 (Jakarta EE 10). + +Compatible containers: Tomcat 10.1+, Jetty 12+, WildFly 27+. > If you are using Spring, we recommend leveraging Spring's OIDC and OAuth2 support, as demonstrated by the [Spring Boot Quickstart](https://auth0.com/docs/quickstart/webapp/java-spring-boot). +> +> **Upgrading from v1.x?** See the [Migration Guide](./MIGRATION_GUIDE.md) for a complete list of breaking changes. ### Installation @@ -37,14 +42,14 @@ Add the dependency via Maven: com.auth0 mvc-auth-commons - 1.12.0 + 2.0.0-beta.0 ``` or Gradle: ```gradle -implementation 'com.auth0:mvc-auth-commons:1.12.0' +implementation 'com.auth0:mvc-auth-commons:2.0.0-beta.0' ``` ### Configure Auth0 From a852db3817d04f334240105317e04f8b02b3067f Mon Sep 17 00:00:00 2001 From: Tanya Sinha Date: Fri, 29 May 2026 12:46:52 +0530 Subject: [PATCH 06/14] Add Migration Guide (#233) --- MIGRATION_GUIDE.md | 456 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 MIGRATION_GUIDE.md diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000..9e4c34d --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,456 @@ +# Migrating from v1.x to v2.0.0 + +This guide covers the changes required to migrate your application from `mvc-auth-commons` v1.x to v2.0.0. + +## Overview of Changes + +v2.0.0 is a major release that includes: + +- **Platform upgrade:** Java 17 minimum, Jakarta Servlet 6.0 (replaces `javax.servlet`) +- **Security hardening:** HMAC-signed origin domain cookies bound to state, ID Token signature always verified, algorithm auto-detection +- **Deprecated API removal:** Session-based methods and classes removed +- **ID Token validation rewrite:** Delegated to `auth0-java` v3 (removes custom verification stack) +- **Multi-tab login fix:** Transaction-keyed cookies prevent concurrent login race conditions +- **JPMS module support:** Declared as `com.auth0.mvc.commons` module +- **Dependency upgrades:** auth0-java v3.5.1, java-jwt v4.5.0, jwks-rsa v0.24.1, Gradle 8.x + +--- + +## Requirements + +| | v1.x | v2.0.0 | +|---|---|---| +| **Java** | 8+ | 17+ | +| **Servlet API** | `javax.servlet` 3.1+ | `jakarta.servlet` 6.0+ | +| **Servlet Container** | Tomcat 8.5+, Jetty 9+, WildFly 14+ | Tomcat 10.1+, Jetty 12+, WildFly 27+ | +| **auth0-java** | 1.x / 2.x | 3.x (3.5.1+) | +| **java-jwt** | 3.x | 4.x (4.5.0+) | +| **jwks-rsa** | 0.21.x | 0.24.x | +| **Spring Boot** (if applicable) | 2.x | 3.x | +| **Gradle** (if building from source) | 6.x | 8.x | + +--- + +## Installation + +Update your dependency version: + +**Maven:** +```xml + + com.auth0 + mvc-auth-commons + 2.0.0-beta.0 + +``` + +**Gradle:** +```groovy +implementation 'com.auth0:mvc-auth-commons:2.0.0-beta.0' +``` + +--- + +## Breaking Changes + +### 1. Namespace: `javax.servlet` to `jakarta.servlet` + +All servlet imports must be updated: + +```diff +- import javax.servlet.http.HttpServletRequest; +- import javax.servlet.http.HttpServletResponse; +- import javax.servlet.http.HttpSession; +- import javax.servlet.http.Cookie; ++ import jakarta.servlet.http.HttpServletRequest; ++ import jakarta.servlet.http.HttpServletResponse; ++ import jakarta.servlet.http.HttpSession; ++ import jakarta.servlet.http.Cookie; +``` + +This applies to all classes that reference `HttpServletRequest`, `HttpServletResponse`, `HttpSession`, `Cookie`, `Filter`, `Servlet`, etc. + +> **Note:** If you are using Spring Boot, upgrading from Spring Boot 2.x to 3.x handles this namespace change for your application code. However, your dependency on `mvc-auth-commons` must also be upgraded to v2. + +--- + +### 2. Removed: `handle(HttpServletRequest)` + +The single-parameter `handle()` method that used the HTTP session for state management has been removed. + +**Before (v1):** +```java +Tokens tokens = authController.handle(request); +``` + +**After (v2):** +```java +Tokens tokens = authController.handle(request, response); +``` + +The two-parameter version uses secure, transient cookies (instead of server-side sessions) for state and nonce storage. This is required for compatibility with SameSite cookie restrictions in modern browsers. + +--- + +### 3. Removed: `buildAuthorizeUrl(HttpServletRequest, String)` + +The two-parameter `buildAuthorizeUrl()` that used the HTTP session has been removed. + +**Before (v1):** +```java +String authorizeUrl = authController.buildAuthorizeUrl(request, redirectUri).build(); +``` + +**After (v2):** +```java +String authorizeUrl = authController.buildAuthorizeUrl(request, response, redirectUri).build(); +``` + +The `response` parameter is required so that state and nonce cookies can be set on the response. + +--- + +### 4. Removed: `InvalidRequestException.getDescription()` + +The deprecated `getDescription()` method has been removed. Use `getMessage()` instead. + +**Before (v1):** +```java +catch (InvalidRequestException e) { + String desc = e.getDescription(); +} +``` + +**After (v2):** +```java +catch (InvalidRequestException e) { + String desc = e.getMessage(); +} +``` + +--- + +### 5. Removed: `withHttpOptions(HttpOptions)` on Builder + +The `HttpOptions` configuration on `AuthenticationController.Builder` has been removed. The underlying HTTP client is now managed by `auth0-java` v3's `DefaultHttpClient`. + +**Before (v1):** +```java +HttpOptions options = new HttpOptions(); +options.setConnectTimeout(10); +options.setReadTimeout(10); + +AuthenticationController controller = AuthenticationController + .newBuilder(domain, clientId, clientSecret) + .withHttpOptions(options) + .build(); +``` + +**After (v2):** +```java +// HTTP client configuration is managed internally by auth0-java v3. +// If you need custom HTTP settings, configure them via AuthAPI directly. +AuthenticationController controller = AuthenticationController + .newBuilder(domain, clientId, clientSecret) + .build(); +``` + +--- + +### 6. Removed: Custom Signature Verifier Classes + +The following classes have been removed. ID Token verification is now handled internally by `auth0-java` v3's `IdTokenVerifier`: + +| Removed Class | v2 Replacement | +|---|---| +| `com.auth0.IdTokenVerifier` | `com.auth0.utils.tokens.IdTokenVerifier` (internal, from auth0-java v3) | +| `com.auth0.SignatureVerifier` | `com.auth0.utils.tokens.SignatureVerifier` (internal, from auth0-java v3) | +| `com.auth0.AsymmetricSignatureVerifier` | Per-domain JwkProvider resolution (internal) | +| `com.auth0.SymmetricSignatureVerifier` | `SignatureVerifier.forHS256(clientSecret)` (internal) | +| `com.auth0.AlgorithmNameVerifier` | Removed entirely — signature is always verified | +| `com.auth0.TokenValidationException` | `com.auth0.exception.IdTokenValidationException` (from auth0-java v3) | + +If your code references any of these classes directly, remove those references. The library now handles all token verification internally. + +--- + +### 7. Removed: Session-Based Storage Classes + +| Removed Class | Purpose | v2 Replacement | +|---|---|---| +| `RandomStorage` | Session-based state/nonce storage | `TransientCookieStore` (cookie-based) | +| `SessionUtils` | HTTP session utilities | Removed — cookies only | + +If your code references `RandomStorage` or `SessionUtils`, remove those references. The library exclusively uses transient cookies for state management. + +--- + +### 8. Algorithm Auto-Detection (Behavior Change) + +In v1, you had to configure the signing algorithm explicitly: +- HS256 was the default for implicit flows +- RS256 required configuring a `JwkProvider` + +In v2, the algorithm is read automatically from the token's `alg` header: + +- **RS256 tokens:** Verified using the configured `JwkProvider`, or one auto-discovered from the domain's `/.well-known/jwks.json` endpoint +- **HS256 tokens:** Verified using the client secret + +You should still configure a `JwkProvider` if you want to control caching, rate-limiting, or connection settings: + +```java +JwkProvider jwkProvider = new JwkProviderBuilder("your-tenant.auth0.com") + .cached(10, 24, TimeUnit.HOURS) + .rateLimited(10, 1, TimeUnit.MINUTES) + .build(); + +AuthenticationController controller = AuthenticationController + .newBuilder(domain, clientId, clientSecret) + .withJwkProvider(jwkProvider) + .build(); +``` + +--- + +### 9. auth0-java v3 API Changes + +If your application imports `auth0-java` classes directly, note these changes: + +| v1 (auth0-java 1.x/2.x) | v2 (auth0-java 3.x) | +|---|---| +| `new AuthAPI(domain, clientId, clientSecret)` | `AuthAPI.newBuilder(domain, clientId, clientSecret).build()` | +| `AuthAPI.authorizeUrl(redirectUri)` | Same (unchanged) | +| `AuthAPI.exchangeCode(code, redirectUri)` | Same (unchanged) | +| `TokenHolder.getIdToken()` | Same (unchanged) | + +--- + +## What's New in v2 + +### MCD Security Hardening + +Multiple Custom Domains (MCD) support was introduced in v1.12.0. In v2, MCD has been hardened with: + +- **HMAC-signed origin domain cookie:** The origin domain is cryptographically bound to the `state` parameter using HMAC-SHA256 with the client secret. This prevents cookie replay or tampering across different authentication transactions. +- **Per-domain JwkProvider cache:** The library maintains an internal `ConcurrentHashMap` cache. JWKS endpoints are only contacted once per domain (per JVM lifetime). If a customer-provided `JwkProvider` is configured via `withJwkProvider()`, it takes precedence for all domains. +- **ID Token signature always verified per issuer:** In v1, the `AlgorithmNameVerifier` could skip signature verification in certain MCD code flow paths. In v2, the ID Token signature is always verified against the correct issuer's JWKS. + +These improvements are transparent to application code — no changes required if you're already using MCD in v1. + +--- + +### Transaction-Keyed Cookies (Multi-Tab Login Fix) + +v1 used a single fixed cookie name (`com.auth0.state`) shared across all browser tabs. Concurrent logins would overwrite each other, causing state validation failures. + +v2 embeds the state value in the cookie name, isolating each login flow: + +``` +# v1 — race condition: last tab wins +com.auth0.state = +com.auth0.nonce = + +# v2 — each tab gets its own cookie +com.auth0.state.abc123 = abc123 +com.auth0.nonce.abc123 = +com.auth0.state.xyz789 = xyz789 +com.auth0.nonce.xyz789 = +``` + +**Backward compatible during rolling upgrades:** On callback, v2 checks for the transaction-keyed cookie first, then falls back to the legacy fixed-name cookie for in-flight transactions that started before the upgrade. + +--- + +### ID Token Signature Always Verified + +In v1, the `AlgorithmNameVerifier` could skip signature verification in certain code flow paths. In v2, the ID Token signature is **always** verified — either via RS256 (JwkProvider) or HS256 (client secret). There is no code path that allows unverified tokens. + +--- + +### Java Module System (JPMS) Support + +v2 includes a `module-info.java` descriptor. The module name is `com.auth0.mvc.commons`: + +```java +module com.auth0.mvc.commons { + exports com.auth0; + + requires transitive com.auth0.java; + requires transitive com.auth0.jwt; + requires transitive com.auth0.jwks; + requires transitive jakarta.servlet; + requires org.apache.commons.lang3; + requires org.apache.commons.codec; + requires com.google.common; +} +``` + +If your application uses JPMS, add `requires com.auth0.mvc.commons;` to your `module-info.java`. + +--- + +## Removed APIs — Complete Reference + +### Deleted Classes + +| Class | Purpose | v2 Replacement | +|---|---|---| +| `IdTokenVerifier` | Custom ID token validation | auth0-java v3's `IdTokenVerifier` (internal) | +| `SignatureVerifier` | Base class for token signature verification | Auto-detection from `alg` header (internal) | +| `AsymmetricSignatureVerifier` | RS256 signature verification | Per-domain `JwkProvider` resolution (internal) | +| `SymmetricSignatureVerifier` | HS256 signature verification | `SignatureVerifier.forHS256()` (internal) | +| `AlgorithmNameVerifier` | Algorithm allowlist check | Removed — always verifies signature | +| `TokenValidationException` | Custom validation exception | `com.auth0.exception.IdTokenValidationException` | +| `RandomStorage` | Session-based state/nonce storage | `TransientCookieStore` (cookie-based) | +| `SessionUtils` | HTTP session utilities | Removed — cookies only | + +### Deleted Methods + +| Class | Method | Replacement | +|---|---|---| +| `AuthenticationController` | `handle(HttpServletRequest)` | `handle(HttpServletRequest, HttpServletResponse)` | +| `AuthenticationController` | `buildAuthorizeUrl(HttpServletRequest, String)` | `buildAuthorizeUrl(HttpServletRequest, HttpServletResponse, String)` | +| `AuthenticationController.Builder` | `withHttpOptions(HttpOptions)` | Removed (auth0-java v3 manages HTTP client) | +| `InvalidRequestException` | `getDescription()` | `getMessage()` | + +--- + +## Migration Checklist + +Use this checklist to verify your migration is complete: + +- [ ] **Runtime:** Upgrade Java to 17+ +- [ ] **Container:** Upgrade servlet container to Jakarta EE 10 compatible (Tomcat 10.1+, Jetty 12+, WildFly 27+) +- [ ] **Imports:** Update all `javax.servlet.*` imports to `jakarta.servlet.*` +- [ ] **handle():** Replace `handle(request)` with `handle(request, response)` +- [ ] **buildAuthorizeUrl():** Replace `buildAuthorizeUrl(request, uri)` with `buildAuthorizeUrl(request, response, uri)` +- [ ] **getDescription():** Replace `InvalidRequestException.getDescription()` with `getMessage()` +- [ ] **HttpOptions:** Remove any `withHttpOptions()` calls from Builder +- [ ] **Deleted classes:** Remove references to `SignatureVerifier`, `AsymmetricSignatureVerifier`, `SymmetricSignatureVerifier`, `IdTokenVerifier`, `AlgorithmNameVerifier`, `TokenValidationException`, `RandomStorage`, `SessionUtils` +- [ ] **auth0-java:** Update `auth0-java` dependency to v3.x if used directly in your app +- [ ] **Spring Boot:** If applicable, upgrade to Spring Boot 3.x +- [ ] **JPMS:** If using Java modules, add `requires com.auth0.mvc.commons;` +- [ ] **Test:** Verify login flow works end-to-end (authorize -> callback -> tokens) +- [ ] **Test:** Verify multi-tab login works (open login in two tabs simultaneously) +- [ ] **Test:** If using MCD, verify each custom domain resolves and validates correctly + +--- + +## Full Example (v2) + +### Configuration + +```java +import com.auth0.AuthenticationController; +import com.auth0.jwk.JwkProvider; +import com.auth0.jwk.JwkProviderBuilder; + +import java.util.concurrent.TimeUnit; + +public class Auth0Config { + private static final String DOMAIN = "your-tenant.auth0.com"; + private static final String CLIENT_ID = "YOUR_CLIENT_ID"; + private static final String CLIENT_SECRET = "YOUR_CLIENT_SECRET"; + + private static final AuthenticationController controller; + + static { + JwkProvider jwkProvider = new JwkProviderBuilder(DOMAIN) + .cached(10, 24, TimeUnit.HOURS) + .rateLimited(10, 1, TimeUnit.MINUTES) + .build(); + + controller = AuthenticationController + .newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) + .withJwkProvider(jwkProvider) + .build(); + } + + public static AuthenticationController getController() { + return controller; + } +} +``` + +### Login Servlet + +```java +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; + +@WebServlet(urlPatterns = {"/login"}) +public class LoginServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException { + String authorizeUrl = Auth0Config.getController() + .buildAuthorizeUrl(req, res, "http://localhost:3000/callback") + .withScope("openid profile email") + .build(); + res.sendRedirect(authorizeUrl); + } +} +``` + +### Callback Servlet + +```java +import com.auth0.IdentityVerificationException; +import com.auth0.Tokens; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; + +@WebServlet(urlPatterns = {"/callback"}) +public class CallbackServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException { + try { + Tokens tokens = Auth0Config.getController().handle(req, res); + req.getSession().setAttribute("id_token", tokens.getIdToken()); + req.getSession().setAttribute("access_token", tokens.getAccessToken()); + res.sendRedirect("/dashboard"); + } catch (IdentityVerificationException e) { + res.sendRedirect("/login?error=" + e.getCode()); + } + } +} +``` + +--- + +## Troubleshooting + +### "The received state doesn't match the expected one" + +This error occurs when the state cookie is not found on callback. Common causes: + +1. **Cookie blocked by SameSite policy:** Ensure your callback is served over HTTPS in production. Use `.withSecureCookie(true)` if needed. +2. **Cookie path mismatch:** If your app is deployed at a sub-path, configure `.withCookiePath("/your-app")`. +3. **Third-party cookie restrictions:** Some browsers block cookies in cross-origin iframes. Avoid embedding the login flow in an iframe. + +### "Failed to get public key for key ID" + +This error occurs when the JwkProvider cannot fetch the signing key. Common causes: + +1. **Network connectivity:** Ensure the server can reach `https://your-tenant.auth0.com/.well-known/jwks.json`. +2. **Rate limiting:** If using the default auto-discovered JwkProvider, it has no rate limiting configured. For high-traffic apps, configure a `JwkProvider` with caching. + +### ClassNotFoundException for `javax.servlet.*` + +Your application or a dependency still references the old `javax.servlet` namespace. Check: +1. All your code uses `jakarta.servlet.*` imports +2. Your servlet container is Jakarta EE 10 compatible +3. No transitive dependencies pull in the old `javax.servlet-api` + +--- + +## Support + +- [API Reference (JavaDocs)](https://javadoc.io/doc/com.auth0/mvc-auth-commons) +- [Examples](./EXAMPLES.md) +- [Report an issue](https://github.com/auth0/auth0-java-mvc-common/issues) From bc46337afcb453d84d90de750b8ad81903188f7c Mon Sep 17 00:00:00 2001 From: Tanya Sinha Date: Fri, 29 May 2026 16:03:37 +0530 Subject: [PATCH 07/14] Feat: Add withHttpClient support (#234) Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../com/auth0/AuthenticationController.java | 38 +++ src/main/java/com/auth0/RequestProcessor.java | 34 ++- .../auth0/AuthenticationControllerTest.java | 44 +++ .../java/com/auth0/RequestProcessorTest.java | 261 ++++++++++++++++++ 4 files changed, 367 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/auth0/AuthenticationController.java b/src/main/java/com/auth0/AuthenticationController.java index c768765..62cee5a 100644 --- a/src/main/java/com/auth0/AuthenticationController.java +++ b/src/main/java/com/auth0/AuthenticationController.java @@ -1,6 +1,7 @@ package com.auth0; import com.auth0.jwk.JwkProvider; +import com.auth0.net.client.Auth0HttpClient; import com.google.common.annotations.VisibleForTesting; import org.apache.commons.lang3.Validate; @@ -73,6 +74,7 @@ public static class Builder { private final String clientSecret; private String responseType; private JwkProvider jwkProvider; + private Auth0HttpClient httpClient; private Integer clockSkew; private Integer authenticationMaxAge; private boolean useLegacySameSiteCookie; @@ -179,6 +181,39 @@ public Builder withJwkProvider(JwkProvider jwkProvider) { return this; } + /** + * Sets a custom {@link Auth0HttpClient} to use for all HTTP requests made by this library + * (token exchange, PAR, etc.). Use this to configure timeouts, proxies, or other HTTP settings. + * + *

Note: When a custom {@code Auth0HttpClient} is provided, the + * {@link AuthenticationController#setLoggingEnabled(boolean)} and + * {@link AuthenticationController#doNotSendTelemetry()} settings will have no effect, + * as those are configured at the HTTP client level. You should configure logging and + * telemetry directly on the client instance before passing it here.

+ * + *
{@code
+         * Auth0HttpClient httpClient = DefaultHttpClient.newBuilder()
+         *     .withConnectTimeout(10)
+         *     .withReadTimeout(10)
+         *     .telemetryEnabled(false)
+         *     .withLogging(new LoggingOptions(LoggingOptions.LogLevel.BODY))
+         *     .build();
+         *
+         * AuthenticationController controller = AuthenticationController
+         *     .newBuilder(domain, clientId, clientSecret)
+         *     .withHttpClient(httpClient)
+         *     .build();
+         * }
+ * + * @param httpClient a configured {@link Auth0HttpClient} instance. + * @return this same builder instance. + */ + public Builder withHttpClient(Auth0HttpClient httpClient) { + Validate.notNull(httpClient, "httpClient must not be null"); + this.httpClient = httpClient; + return this; + } + /** * Sets the clock-skew or leeway value to use in the ID Token verification. The value must be in seconds. * Defaults to 60 seconds. @@ -267,6 +302,9 @@ public AuthenticationController build() throws UnsupportedOperationException { if (jwkProvider != null) { builder.withJwkProvider(jwkProvider); } + if (httpClient != null) { + builder.withHttpClient(httpClient); + } return new AuthenticationController(builder.build()); } diff --git a/src/main/java/com/auth0/RequestProcessor.java b/src/main/java/com/auth0/RequestProcessor.java index 36a53cc..c1e9512 100644 --- a/src/main/java/com/auth0/RequestProcessor.java +++ b/src/main/java/com/auth0/RequestProcessor.java @@ -11,6 +11,7 @@ import com.auth0.jwk.JwkException; import com.auth0.jwk.JwkProvider; import com.auth0.jwk.UrlJwkProvider; +import com.auth0.net.client.Auth0HttpClient; import com.auth0.net.client.DefaultHttpClient; import com.auth0.utils.tokens.IdTokenVerifier; import com.auth0.utils.tokens.SignatureVerifier; @@ -50,6 +51,7 @@ class RequestProcessor { private final String clientId; private final String clientSecret; private final JwkProvider jwkProvider; + private Auth0HttpClient httpClient; private final Integer clockSkew; private final Integer authenticationMaxAge; @@ -71,6 +73,7 @@ static class Builder { private final String clientSecret; private JwkProvider jwkProvider; + private Auth0HttpClient httpClient; private boolean useLegacySameSiteCookie = true; private Integer clockSkew; private Integer authenticationMaxAge; @@ -93,6 +96,11 @@ Builder withJwkProvider(JwkProvider jwkProvider) { return this; } + Builder withHttpClient(Auth0HttpClient httpClient) { + this.httpClient = httpClient; + return this; + } + public Builder withClockSkew(Integer clockSkew) { this.clockSkew = clockSkew; return this; @@ -125,13 +133,13 @@ Builder withInvitation(String invitation) { RequestProcessor build() { return new RequestProcessor(domainProvider, responseType, clientId, clientSecret, - jwkProvider, useLegacySameSiteCookie, clockSkew, authenticationMaxAge, + jwkProvider, httpClient, useLegacySameSiteCookie, clockSkew, authenticationMaxAge, organization, invitation, cookiePath); } } private RequestProcessor(DomainProvider domainProvider, String responseType, String clientId, - String clientSecret, JwkProvider jwkProvider, + String clientSecret, JwkProvider jwkProvider, Auth0HttpClient httpClient, boolean useLegacySameSiteCookie, Integer clockSkew, Integer authenticationMaxAge, String organization, String invitation, String cookiePath) { this.domainProvider = domainProvider; @@ -139,6 +147,7 @@ private RequestProcessor(DomainProvider domainProvider, String responseType, Str this.clientId = clientId; this.clientSecret = clientSecret; this.jwkProvider = jwkProvider; + this.httpClient = httpClient; this.useLegacySameSiteCookie = useLegacySameSiteCookie; this.clockSkew = clockSkew; this.authenticationMaxAge = authenticationMaxAge; @@ -156,18 +165,23 @@ void doNotSendTelemetry() { } AuthAPI createClientForDomain(String domain) { - DefaultHttpClient.Builder httpBuilder = DefaultHttpClient.newBuilder() - .telemetryEnabled(!telemetryDisabled); - - if (loggingEnabled) { - httpBuilder.withLogging(new LoggingOptions(LoggingOptions.LogLevel.BODY)); - } - return AuthAPI.newBuilder(domain, clientId, clientSecret) - .withHttpClient(httpBuilder.build()) + .withHttpClient(getHttpClient()) .build(); } + private Auth0HttpClient getHttpClient() { + if (this.httpClient == null) { + DefaultHttpClient.Builder httpBuilder = DefaultHttpClient.newBuilder() + .telemetryEnabled(!telemetryDisabled); + if (loggingEnabled) { + httpBuilder.withLogging(new LoggingOptions(LoggingOptions.LogLevel.BODY)); + } + this.httpClient = httpBuilder.build(); + } + return this.httpClient; + } + /** * Pre builds an Auth0 Authorize Url with the given redirect URI, state and nonce parameters. * diff --git a/src/test/java/com/auth0/AuthenticationControllerTest.java b/src/test/java/com/auth0/AuthenticationControllerTest.java index 4fc78ea..6af69ac 100644 --- a/src/test/java/com/auth0/AuthenticationControllerTest.java +++ b/src/test/java/com/auth0/AuthenticationControllerTest.java @@ -1,6 +1,7 @@ package com.auth0; import com.auth0.jwk.JwkProvider; +import com.auth0.net.client.Auth0HttpClient; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; @@ -31,6 +32,8 @@ public class AuthenticationControllerTest { @Mock private DomainResolver mockDomainResolver; @Mock + private Auth0HttpClient mockHttpClient; + @Mock private Tokens mockTokens; private HttpServletRequest request; @@ -82,6 +85,7 @@ public void shouldConfigureBuilderWithAllOptions() { AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) .withResponseType("id_token token") .withJwkProvider(mockJwkProvider) + .withHttpClient(mockHttpClient) .withClockSkew(120) .withAuthenticationMaxAge(3600) .withLegacySameSiteCookie(false) @@ -131,6 +135,7 @@ public void shouldValidateNullParameters() { assertThrows(NullPointerException.class, () -> builder.withDomain(null)); assertThrows(NullPointerException.class, () -> builder.withResponseType(null)); assertThrows(NullPointerException.class, () -> builder.withJwkProvider(null)); + assertThrows(NullPointerException.class, () -> builder.withHttpClient(null)); assertThrows(NullPointerException.class, () -> builder.withClockSkew(null)); assertThrows(NullPointerException.class, () -> builder.withAuthenticationMaxAge(null)); assertThrows(NullPointerException.class, () -> builder.withOrganization(null)); @@ -421,4 +426,43 @@ public void shouldHandleImplicitGrantResponseType() { assertThat(controller, is(notNullValue())); } + + // --- HttpClient Configuration Tests --- + + @Test + public void shouldBuildWithCustomHttpClient() { + AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) + .withHttpClient(mockHttpClient) + .build(); + + assertThat(controller, is(notNullValue())); + assertThat(controller.getRequestProcessor(), is(notNullValue())); + } + + @Test + public void shouldBuildWithCustomHttpClientAndJwkProvider() { + AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) + .withHttpClient(mockHttpClient) + .withJwkProvider(mockJwkProvider) + .build(); + + assertThat(controller, is(notNullValue())); + } + + @Test + public void shouldBuildWithCustomHttpClientAndDomainResolver() { + AuthenticationController controller = AuthenticationController + .newBuilder(mockDomainResolver, CLIENT_ID, CLIENT_SECRET) + .withHttpClient(mockHttpClient) + .build(); + + assertThat(controller, is(notNullValue())); + } + + @Test + public void shouldThrowExceptionWhenHttpClientIsNull() { + AuthenticationController.Builder builder = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET); + + assertThrows(NullPointerException.class, () -> builder.withHttpClient(null)); + } } diff --git a/src/test/java/com/auth0/RequestProcessorTest.java b/src/test/java/com/auth0/RequestProcessorTest.java index ec30c56..0851418 100644 --- a/src/test/java/com/auth0/RequestProcessorTest.java +++ b/src/test/java/com/auth0/RequestProcessorTest.java @@ -6,6 +6,7 @@ import com.auth0.jwk.JwkProvider; import com.auth0.net.Response; import com.auth0.net.TokenRequest; +import com.auth0.net.client.Auth0HttpClient; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; @@ -44,6 +45,8 @@ public class RequestProcessorTest { @Mock private JwkProvider mockJwkProvider; @Mock + private Auth0HttpClient mockHttpClient; + @Mock private AuthAPI mockAuthAPI; @Mock private TokenRequest mockTokenRequest; @@ -646,6 +649,264 @@ public void shouldSupportAuthenticationMaxAge() { assertThat(processor, is(notNullValue())); } + // --- Custom HttpClient Tests --- + + @Test + public void shouldBuildWithCustomHttpClient() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET) + .withHttpClient(mockHttpClient) + .build(); + + assertThat(processor, is(notNullValue())); + } + + @Test + public void shouldCreateClientForDomainWithCustomHttpClient() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET) + .withHttpClient(mockHttpClient) + .build(); + + AuthAPI client = processor.createClientForDomain(DOMAIN); + assertThat(client, is(notNullValue())); + } + + @Test + public void shouldReuseCustomHttpClientAcrossMultipleDomains() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET) + .withHttpClient(mockHttpClient) + .build(); + + AuthAPI client1 = processor.createClientForDomain("domain1.auth0.com"); + AuthAPI client2 = processor.createClientForDomain("domain2.auth0.com"); + + assertThat(client1, is(notNullValue())); + assertThat(client2, is(notNullValue())); + } + + @Test + public void shouldCreateDefaultHttpClientWhenNoneProvided() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET) + .build(); + + AuthAPI client = processor.createClientForDomain(DOMAIN); + assertThat(client, is(notNullValue())); + } + + @Test + public void shouldReuseDefaultHttpClientAcrossMultipleCalls() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET) + .build(); + + AuthAPI client1 = processor.createClientForDomain("domain1.auth0.com"); + AuthAPI client2 = processor.createClientForDomain("domain2.auth0.com"); + + assertThat(client1, is(notNullValue())); + assertThat(client2, is(notNullValue())); + } + + @Test + public void shouldBuildWithHttpClientAndJwkProvider() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET) + .withHttpClient(mockHttpClient) + .withJwkProvider(mockJwkProvider) + .build(); + + assertThat(processor, is(notNullValue())); + } + + // --- Transaction-Keyed Cookie Tests --- + + @Test + public void shouldValidateStateFromTransactionKeyedCookie() { + when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + + Map params = new HashMap<>(); + params.put("state", "txn-state-123"); + params.put("code", "auth-code"); + MockHttpServletRequest request = getRequest(params); + // Transaction-keyed cookie: com.auth0.state.{state_value} + request.setCookies(new Cookie("com.auth0.state.txn-state-123", "txn-state-123")); + + when(mockTokenHolder.getIdToken()).thenReturn(null); + when(mockTokenHolder.getAccessToken()).thenReturn("access"); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + try { + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + } catch (Auth0Exception e) { + fail("Unexpected exception"); + } + when(mockAuthAPI.exchangeCode(eq("auth-code"), anyString())).thenReturn(mockTokenRequest); + + RequestProcessor handler = createDefaultRequestProcessor(); + RequestProcessor spy = spy(handler); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + assertDoesNotThrow(() -> spy.process(request, response)); + } + + @Test + public void shouldFallbackToLegacyStateCookieWhenTransactionKeyedMissing() { + when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + + Map params = new HashMap<>(); + params.put("state", "legacy-state-456"); + params.put("code", "auth-code"); + MockHttpServletRequest request = getRequest(params); + // Legacy fixed-name cookie (v1 compatibility) + request.setCookies(new Cookie("com.auth0.state", "legacy-state-456")); + + when(mockTokenHolder.getIdToken()).thenReturn(null); + when(mockTokenHolder.getAccessToken()).thenReturn("access"); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + try { + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + } catch (Auth0Exception e) { + fail("Unexpected exception"); + } + when(mockAuthAPI.exchangeCode(eq("auth-code"), anyString())).thenReturn(mockTokenRequest); + + RequestProcessor handler = createDefaultRequestProcessor(); + RequestProcessor spy = spy(handler); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + assertDoesNotThrow(() -> spy.process(request, response)); + } + + @Test + public void shouldRejectWhenNoStateCookieExists() { + Map params = new HashMap<>(); + params.put("state", "orphan-state"); + MockHttpServletRequest request = getRequest(params); + // No cookies at all + + RequestProcessor handler = createDefaultRequestProcessor(); + + InvalidRequestException e = assertThrows(InvalidRequestException.class, () -> handler.process(request, response)); + assertThat(e.getCode(), is("a0.invalid_state")); + } + + // --- MCD Origin Domain Binding Tests --- + + @Test + public void shouldUseDomainFromSignedCookieWhenPresent() throws Exception { + String state = "mcd-state-789"; + String domain = "brand-a.auth0.com"; + + // Create a signed origin domain cookie + String signedDomain = SignedCookieUtils.sign(domain, state, CLIENT_SECRET); + + Map params = new HashMap<>(); + params.put("state", state); + params.put("code", "auth-code"); + MockHttpServletRequest request = getRequest(params); + request.setCookies( + new Cookie("com.auth0.state." + state, state), + new Cookie("com.auth0.origin_domain", signedDomain) + ); + + when(mockDomainProvider.getDomain(any())).thenReturn("fallback.auth0.com"); + when(mockTokenHolder.getIdToken()).thenReturn(null); + when(mockTokenHolder.getAccessToken()).thenReturn("access"); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockAuthAPI.exchangeCode(eq("auth-code"), anyString())).thenReturn(mockTokenRequest); + + RequestProcessor handler = createDefaultRequestProcessor(); + RequestProcessor spy = spy(handler); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + spy.process(request, response); + + // Should use the domain from the signed cookie, not the fallback + verify(spy).createClientForDomain(domain); + } + + @Test + public void shouldFallbackToDomainProviderWhenSignedCookieMissing() throws Exception { + String state = "no-cookie-state"; + String fallbackDomain = "fallback.auth0.com"; + + Map params = new HashMap<>(); + params.put("state", state); + params.put("code", "auth-code"); + MockHttpServletRequest request = getRequest(params); + request.setCookies(new Cookie("com.auth0.state." + state, state)); + + when(mockDomainProvider.getDomain(any())).thenReturn(fallbackDomain); + when(mockTokenHolder.getIdToken()).thenReturn(null); + when(mockTokenHolder.getAccessToken()).thenReturn("access"); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockAuthAPI.exchangeCode(eq("auth-code"), anyString())).thenReturn(mockTokenRequest); + + RequestProcessor handler = createDefaultRequestProcessor(); + RequestProcessor spy = spy(handler); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + spy.process(request, response); + + // Should fall back to domainProvider when no signed origin cookie + verify(spy).createClientForDomain(fallbackDomain); + } + + @Test + public void shouldFallbackToDomainProviderWhenSignedCookieTampered() throws Exception { + String state = "tampered-state"; + String fallbackDomain = "fallback.auth0.com"; + + // Tampered cookie — signed with different state + String signedDomain = SignedCookieUtils.sign("evil.auth0.com", "different-state", CLIENT_SECRET); + + Map params = new HashMap<>(); + params.put("state", state); + params.put("code", "auth-code"); + MockHttpServletRequest request = getRequest(params); + request.setCookies( + new Cookie("com.auth0.state." + state, state), + new Cookie("com.auth0.origin_domain", signedDomain) + ); + + when(mockDomainProvider.getDomain(any())).thenReturn(fallbackDomain); + when(mockTokenHolder.getIdToken()).thenReturn(null); + when(mockTokenHolder.getAccessToken()).thenReturn("access"); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockAuthAPI.exchangeCode(eq("auth-code"), anyString())).thenReturn(mockTokenRequest); + + RequestProcessor handler = createDefaultRequestProcessor(); + RequestProcessor spy = spy(handler); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + spy.process(request, response); + + // Tampered cookie should be rejected, fallback to domainProvider + verify(spy).createClientForDomain(fallbackDomain); + } + // --- Helper Methods --- private RequestProcessor createDefaultRequestProcessor() { From 868736fa00378f05735fbc70a9fd873e1e8319fc Mon Sep 17 00:00:00 2001 From: Tanya Sinha Date: Fri, 29 May 2026 17:53:53 +0530 Subject: [PATCH 08/14] Release 2.0.0-beta.0 (#235) --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1801ff6..e92911c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Change Log +## [2.0.0-beta.0](https://github.com/auth0/auth0-java-mvc-common/tree/2.0.0-beta.0) (2026-05-29) + +This is the first beta release of the v2 major version. See the [Migration Guide](MIGRATION_GUIDE.md) for full upgrade instructions. + +**Added** +- Jakarta EE 10 / Jakarta Servlet 6.0 support (replaces `javax.servlet`) [\#154](https://github.com/auth0/auth0-java-mvc-common/pull/154) ([tanya732](https://github.com/tanya732)) +- Transaction-keyed cookies to prevent multi-tab OAuth state race conditions [\#231](https://github.com/auth0/auth0-java-mvc-common/pull/231) ([tanya732](https://github.com/tanya732)) +- `withHttpClient(Auth0HttpClient)` builder method for custom HTTP client configuration [\#234](https://github.com/auth0/auth0-java-mvc-common/pull/234) ([tanya732](https://github.com/tanya732)) +- Algorithm auto-detection from token `alg` header (RS256/HS256) [\#154](https://github.com/auth0/auth0-java-mvc-common/pull/154) ([tanya732](https://github.com/tanya732)) +- ID Token signature is now always verified — no code path allows unverified tokens [\#154](https://github.com/auth0/auth0-java-mvc-common/pull/154) ([tanya732](https://github.com/tanya732)) +- MCD security fix: HMAC-signed origin domain cookies bound to state parameter [\#154](https://github.com/auth0/auth0-java-mvc-common/pull/154) ([tanya732](https://github.com/tanya732)) +- JPMS module support (`com.auth0.mvc.commons`) [\#154](https://github.com/auth0/auth0-java-mvc-common/pull/154) ([tanya732](https://github.com/tanya732)) +- ID Token validation delegated to auth0-java v3's `IdTokenVerifier` [\#154](https://github.com/auth0/auth0-java-mvc-common/pull/154) ([tanya732](https://github.com/tanya732)) +- Migration guide for v1 to v2 upgrade [\#233](https://github.com/auth0/auth0-java-mvc-common/pull/233) ([tanya732](https://github.com/tanya732)) + +**Changed** +- Minimum Java version raised from 8 to 17 [\#154](https://github.com/auth0/auth0-java-mvc-common/pull/154) ([tanya732](https://github.com/tanya732)) +- Upgraded auth0-java from v2 to v3.5.1 (`AuthAPI.newBuilder()` pattern) [\#154](https://github.com/auth0/auth0-java-mvc-common/pull/154) ([tanya732](https://github.com/tanya732)) +- Upgraded java-jwt from v3 to v4.5.0 [\#154](https://github.com/auth0/auth0-java-mvc-common/pull/154) ([tanya732](https://github.com/tanya732)) +- Upgraded jwks-rsa to v0.24.1 [\#154](https://github.com/auth0/auth0-java-mvc-common/pull/154) ([tanya732](https://github.com/tanya732)) + +**Removed** +- Deprecated `handle(HttpServletRequest)` — use `handle(HttpServletRequest, HttpServletResponse)` instead [\#154](https://github.com/auth0/auth0-java-mvc-common/pull/154) ([tanya732](https://github.com/tanya732)) +- Deprecated `buildAuthorizeUrl(HttpServletRequest, String)` — use `buildAuthorizeUrl(HttpServletRequest, HttpServletResponse, String)` instead [\#154](https://github.com/auth0/auth0-java-mvc-common/pull/154) ([tanya732](https://github.com/tanya732)) +- `withHttpOptions(HttpOptions)` on Builder — use `withHttpClient(Auth0HttpClient)` instead [\#154](https://github.com/auth0/auth0-java-mvc-common/pull/154) ([tanya732](https://github.com/tanya732)) +- `InvalidRequestException.getDescription()` — use `getMessage()` instead [\#154](https://github.cm/auth0/auth0-java-mvc-common/pull/154) ([tanya732](https://github.com/tanya732)) +- Custom signature verifier classes: `IdTokenVerifier`, `SignatureVerifier`, `AsymmetricSignatureVerifier`, `SymmetricSignatureVerifier`, `AlgorithmNameVerifier`, `TokenValidationException` [\#154](https://github.com/auth0/auth0-java-mvc-common/pull/154) ([tanya732](https://github.com/tanya732)) +- Session-based storage classes: `RandomStorage`, `SessionUtils` [\#154](https://github.com/auth0/auth0-java-mvc-common/pull/154) ([tanya732](https://github.com/tanya732)) + +> **Note:** All deprecated endpoints from v1.x have been removed in this release. Session (HTTP Session) based state/nonce storage has been replaced with secure transient cookies — the library no longer uses `HttpSession` for OAuth state management. + +--- + ## [1.12.0](https://github.com/auth0/auth0-java-mvc-common/tree/1.12.0) (2026-04-09) [Full Changelog](https://github.com/auth0/auth0-java-mvc-common/compare/1.11.1...1.12.0) From a7554a23b3ff4ab0a529bd225790f72f4000657d Mon Sep 17 00:00:00 2001 From: Tanya Sinha Date: Fri, 29 May 2026 20:46:43 +0530 Subject: [PATCH 09/14] Release/2.0.0 beta.0 (#236) --- .github/workflows/rl-secure.yml | 4 +- build.gradle | 66 --------------------------------- 2 files changed, 2 insertions(+), 68 deletions(-) diff --git a/.github/workflows/rl-secure.yml b/.github/workflows/rl-secure.yml index a9bfda6..89af142 100644 --- a/.github/workflows/rl-secure.yml +++ b/.github/workflows/rl-secure.yml @@ -45,8 +45,8 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 - - name: Test and Assemble and ApiDiff with Gradle - run: ./gradlew assemble apiDiff check jacocoTestReport --continue --console=plain + - name: Test and Assemble with Gradle + run: ./gradlew assemble check jacocoTestReport --continue --console=plain - name: Get Artifact Version id: get_version diff --git a/build.gradle b/build.gradle index 763c525..11648f1 100644 --- a/build.gradle +++ b/build.gradle @@ -1,19 +1,7 @@ -buildscript { - repositories { - jcenter() - } - - dependencies { - // https://github.com/melix/japicmp-gradle-plugin/issues/36 - classpath 'com.google.guava:guava:31.1-jre' - } -} - plugins { id 'java' id 'java-library' id 'jacoco' - id 'me.champeau.gradle.japicmp' version '0.4.6' id 'io.github.gradle-nexus.publish-plugin' version '2.0.0' } @@ -27,61 +15,7 @@ version = getVersionFromFile() group = GROUP logger.lifecycle("Using version ${version} for ${name} group $group") -import me.champeau.gradle.japicmp.JapicmpTask - -//project.afterEvaluate { -// def versions = project.ext.testInJavaVersions -// for (pluginJavaTestVersion in versions) { -// def taskName = "testInJava-${pluginJavaTestVersion}" -// tasks.register(taskName, Test) { -// def versionToUse = taskName.split("-").getAt(1) as Integer -// description = "Runs unit tests on Java version ${versionToUse}." -// project.logger.quiet("Test will be running in ${versionToUse}") -// group = 'verification' -// javaLauncher.set(javaToolchains.launcherFor { -// languageVersion = JavaLanguageVersion.of(versionToUse) -// }) -// shouldRunAfter(tasks.named('test')) -// } -// tasks.named('check') { -// dependsOn(taskName) -// } -// } -// -// project.configure(project) { -// def baselineVersion = project.ext.baselineCompareVersion -// task('apiDiff', type: JapicmpTask, dependsOn: 'jar') { -// oldClasspath.from(files(getBaselineJar(project, baselineVersion))) -// newClasspath.from(files(jar.archiveFile)) -// onlyModified = true -// failOnModification = true -// ignoreMissingClasses = true -// htmlOutputFile = file("$buildDir/reports/apiDiff/apiDiff.html") -// txtOutputFile = file("$buildDir/reports/apiDiff/apiDiff.txt") -// doLast { -// project.logger.quiet("Comparing against baseline version ${baselineVersion}") -// } -// } -// } -//} -// -//private static File getBaselineJar(Project project, String baselineVersion) { -// // Use detached configuration: https://github.com/square/okhttp/blob/master/build.gradle#L270 -// def group = project.group -// try { -// def baseline = "${project.group}:${project.name}:$baselineVersion" -// project.group = 'virtual_group_for_japicmp' -// def dependency = project.dependencies.create(baseline + "@jar") -// return project.configurations.detachedConfiguration(dependency).files.find { -// it.name == "${project.name}-${baselineVersion}.jar" -// } -// } finally { -// project.group = group -// } -//} - ext { - baselineCompareVersion = '1.5.0' testInJavaVersions = [17, 21] } From b17fc880cfe11ce2f0e28b2006438408eb1ee68c Mon Sep 17 00:00:00 2001 From: Tanya Sinha Date: Fri, 29 May 2026 21:06:32 +0530 Subject: [PATCH 10/14] Release 2.0.0 beta.0 (#237) --- .github/workflows/release.yml | 2 +- gradle/maven-publish.gradle | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f582e1..b4f4de6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: uses: ./.github/workflows/java-release.yml needs: rl-scanner with: - java-version: 17 + java-version: 17.0.19-tem secrets: ossr-username: ${{ secrets.OSSR_USERNAME }} ossr-token: ${{ secrets.OSSR_TOKEN }} diff --git a/gradle/maven-publish.gradle b/gradle/maven-publish.gradle index e5c51a9..c93576d 100644 --- a/gradle/maven-publish.gradle +++ b/gradle/maven-publish.gradle @@ -20,6 +20,9 @@ tasks.withType(Javadoc).configureEach { javadoc { // Specify the Java version that the project targets options.addStringOption('-release', "17") + if(JavaVersion.current().isJava9Compatible()) { + options.addBooleanOption('html5', true) + } } artifacts { archives sourcesJar, javadocJar @@ -88,12 +91,6 @@ signing { sign publishing.publications.mavenJava } -javadoc { - if(JavaVersion.current().isJava9Compatible()) { - options.addBooleanOption('html5', true) - } -} - tasks.named('publish').configure { dependsOn tasks.named('assemble') } \ No newline at end of file From b37e552b127ba3e7a42eb6176dc0295ca49eff6b Mon Sep 17 00:00:00 2001 From: Tanya Sinha Date: Thu, 2 Jul 2026 00:16:41 +0530 Subject: [PATCH 11/14] chore: Migrate RL Scanner to usable Action (#248) --- .github/actions/rl-scanner/action.yml | 71 --------------------------- .github/workflows/release.yml | 2 + .github/workflows/rl-secure.yml | 16 ++++-- 3 files changed, 13 insertions(+), 76 deletions(-) delete mode 100644 .github/actions/rl-scanner/action.yml diff --git a/.github/actions/rl-scanner/action.yml b/.github/actions/rl-scanner/action.yml deleted file mode 100644 index eb01713..0000000 --- a/.github/actions/rl-scanner/action.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: 'Reversing Labs Scanner' -description: 'Runs the Reversing Labs scanner on a specified artifact.' -inputs: - artifact-path: - description: 'Path to the artifact to be scanned.' - required: true - version: - description: 'Version of the artifact.' - required: true - -runs: - using: 'composite' - steps: - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.10' - - - name: Install Python dependencies - shell: bash - run: | - pip install boto3 requests - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - role-to-assume: ${{ env.PRODSEC_TOOLS_ARN }} - aws-region: us-east-1 - mask-aws-account-id: true - - - name: Install RL Wrapper - shell: bash - run: | - pip install rl-wrapper>=1.0.0 --index-url "https://${{ env.PRODSEC_TOOLS_USER }}:${{ env.PRODSEC_TOOLS_TOKEN }}@a0us.jfrog.io/artifactory/api/pypi/python-local/simple" - - - name: Run RL Scanner - shell: bash - env: - RLSECURE_LICENSE: ${{ env.RLSECURE_LICENSE }} - RLSECURE_SITE_KEY: ${{ env.RLSECURE_SITE_KEY }} - SIGNAL_HANDLER_TOKEN: ${{ env.SIGNAL_HANDLER_TOKEN }} - PYTHONUNBUFFERED: 1 - run: | - if [ ! -f "${{ inputs.artifact-path }}" ]; then - echo "Artifact not found: ${{ inputs.artifact-path }}" - exit 1 - fi - - rl-wrapper \ - --artifact "${{ inputs.artifact-path }}" \ - --name "${{ github.event.repository.name }}" \ - --version "${{ inputs.version }}" \ - --repository "${{ github.repository }}" \ - --commit "${{ github.sha }}" \ - --build-env "github_actions" \ - --suppress_output - - # Check the outcome of the scanner - if [ $? -ne 0 ]; then - echo "RL Scanner failed." - echo "scan-status=failed" >> $GITHUB_ENV - exit 1 - else - echo "RL Scanner passed." - echo "scan-status=success" >> $GITHUB_ENV - fi - -outputs: - scan-status: - description: 'The outcome of the scan process.' - value: ${{ env.scan-status }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4f4de6..5cd6e7a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,9 +24,11 @@ jobs: RLSECURE_LICENSE: ${{ secrets.RLSECURE_LICENSE }} RLSECURE_SITE_KEY: ${{ secrets.RLSECURE_SITE_KEY }} SIGNAL_HANDLER_TOKEN: ${{ secrets.SIGNAL_HANDLER_TOKEN }} + SIGNAL_HANDLER_DOMAIN: ${{ secrets.SIGNAL_HANDLER_DOMAIN }} PRODSEC_TOOLS_USER: ${{ secrets.PRODSEC_TOOLS_USER }} PRODSEC_TOOLS_TOKEN: ${{ secrets.PRODSEC_TOOLS_TOKEN }} PRODSEC_TOOLS_ARN: ${{ secrets.PRODSEC_TOOLS_ARN }} + PRODSEC_PYTHON_TOOLS_REPO: ${{ secrets.PRODSEC_PYTHON_TOOLS_REPO }} release: uses: ./.github/workflows/java-release.yml diff --git a/.github/workflows/rl-secure.yml b/.github/workflows/rl-secure.yml index 89af142..22154bf 100644 --- a/.github/workflows/rl-secure.yml +++ b/.github/workflows/rl-secure.yml @@ -16,12 +16,16 @@ on: required: true SIGNAL_HANDLER_TOKEN: required: true + SIGNAL_HANDLER_DOMAIN: + required: true PRODSEC_TOOLS_USER: required: true PRODSEC_TOOLS_TOKEN: required: true PRODSEC_TOOLS_ARN: required: true + PRODSEC_PYTHON_TOOLS_REPO: + required: true jobs: checkout-build-scan-only: @@ -58,17 +62,19 @@ jobs: - name: Run RL Scanner id: rl-scan-conclusion - uses: ./.github/actions/rl-scanner + uses: auth0/devsecops-tooling/.github/actions/rl-scan@main with: - artifact-path: "$(pwd)/${{ inputs.artifact-name }}" - version: "${{ steps.get_version.outputs.version }}" - env: + artifact-name: "${{ github.event.repository.name }}" + artifact-path: "${{ github.workspace }}/${{ inputs.artifact-name }}" + version: ${{ steps.get_version.outputs.version }} RLSECURE_LICENSE: ${{ secrets.RLSECURE_LICENSE }} RLSECURE_SITE_KEY: ${{ secrets.RLSECURE_SITE_KEY }} SIGNAL_HANDLER_TOKEN: ${{ secrets.SIGNAL_HANDLER_TOKEN }} + SIGNAL_HANDLER_DOMAIN: ${{ secrets.SIGNAL_HANDLER_DOMAIN }} + PRODSEC_TOOLS_ARN: ${{ secrets.PRODSEC_TOOLS_ARN }} PRODSEC_TOOLS_USER: ${{ secrets.PRODSEC_TOOLS_USER }} PRODSEC_TOOLS_TOKEN: ${{ secrets.PRODSEC_TOOLS_TOKEN }} - PRODSEC_TOOLS_ARN: ${{ secrets.PRODSEC_TOOLS_ARN }} + PRODSEC_PYTHON_TOOLS_REPO: ${{ secrets.PRODSEC_PYTHON_TOOLS_REPO }} - name: Output scan result run: echo "scan-status=${{ steps.rl-scan-conclusion.outcome }}" >> $GITHUB_ENV From a801616433e62337fb9c8b78840aad8fcdb63d8c Mon Sep 17 00:00:00 2001 From: Tanya Sinha Date: Tue, 14 Jul 2026 17:01:06 +0530 Subject: [PATCH 12/14] feat: add MRRT, CTE and CIBA Support (#255) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- build.gradle | 2 +- .../com/auth0/AuthenticationController.java | 232 +++++++++ .../BackChannelAuthorizationException.java | 69 +++ .../auth0/BackChannelAuthorizeRequest.java | 100 ++++ .../com/auth0/BackChannelTokenRequest.java | 50 ++ .../auth0/CustomTokenExchangeException.java | 37 ++ src/main/java/com/auth0/RenewAuthRequest.java | 88 ++++ src/main/java/com/auth0/RequestProcessor.java | 242 +++++++++- .../java/com/auth0/TokenExchangeRequest.java | 144 ++++++ src/main/java/com/auth0/Tokens.java | 28 ++ .../auth0/AuthenticationControllerTest.java | 344 +++++++++++++- ...BackChannelAuthorizationExceptionTest.java | 49 ++ .../BackChannelAuthorizeRequestTest.java | 118 +++++ .../auth0/BackChannelTokenRequestTest.java | 62 +++ .../java/com/auth0/RenewAuthRequestTest.java | 103 ++++ .../java/com/auth0/RequestProcessorTest.java | 440 ++++++++++++++++++ .../com/auth0/TokenExchangeRequestTest.java | 170 +++++++ src/test/java/com/auth0/TokensTest.java | 27 ++ 18 files changed, 2300 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/auth0/BackChannelAuthorizationException.java create mode 100644 src/main/java/com/auth0/BackChannelAuthorizeRequest.java create mode 100644 src/main/java/com/auth0/BackChannelTokenRequest.java create mode 100644 src/main/java/com/auth0/CustomTokenExchangeException.java create mode 100644 src/main/java/com/auth0/RenewAuthRequest.java create mode 100644 src/main/java/com/auth0/TokenExchangeRequest.java create mode 100644 src/test/java/com/auth0/BackChannelAuthorizationExceptionTest.java create mode 100644 src/test/java/com/auth0/BackChannelAuthorizeRequestTest.java create mode 100644 src/test/java/com/auth0/BackChannelTokenRequestTest.java create mode 100644 src/test/java/com/auth0/RenewAuthRequestTest.java create mode 100644 src/test/java/com/auth0/TokenExchangeRequestTest.java diff --git a/build.gradle b/build.gradle index 11648f1..a5be0b0 100644 --- a/build.gradle +++ b/build.gradle @@ -61,7 +61,7 @@ dependencies { implementation 'com.google.guava:guava:32.0.1-jre' implementation 'commons-codec:commons-codec:1.22.0' - api 'com.auth0:auth0:3.5.1' + api 'com.auth0:auth0:3.10.0' api 'com.auth0:java-jwt:4.5.0' api 'com.auth0:jwks-rsa:0.24.1' diff --git a/src/main/java/com/auth0/AuthenticationController.java b/src/main/java/com/auth0/AuthenticationController.java index 62cee5a..6ca85aa 100644 --- a/src/main/java/com/auth0/AuthenticationController.java +++ b/src/main/java/com/auth0/AuthenticationController.java @@ -7,6 +7,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.util.Map; /** * Base Auth0 Authenticator class. @@ -383,4 +384,235 @@ public AuthorizeUrl buildAuthorizeUrl(HttpServletRequest request, HttpServletRes return requestProcessor.buildAuthorizeUrl(request, response, redirectUri, state, nonce); } + /** + * Builds a request to exchange a refresh token for a new set of {@link Tokens}, optionally + * targeting a specific audience and/or scope. This exposes Auth0's refresh-token grant, + * enabling Multi-Resource Refresh Token (MRRT) flows where one refresh token can obtain access + * tokens for multiple APIs. + * + *

The application supplies the {@code domain} it stored from {@link Tokens#getDomain()} at + * login. This is required because a refresh can occur outside of an HTTP request, where the + * domain cannot otherwise be resolved. For applications configured with a fixed domain, the + * {@link AuthenticationController#renewAuth(String)} overload may be used instead.

+ * + * @param refreshToken the refresh token to exchange. + * @param domain the Auth0 domain to target. + * @return a {@link RenewAuthRequest} to configure and execute. + */ + public RenewAuthRequest renewAuth(String refreshToken, String domain) { + Validate.notNull(refreshToken, "refreshToken must not be null"); + Validate.notNull(domain, "domain must not be null"); + return requestProcessor.buildRenewAuthRequest(refreshToken, domain); + } + + /** + * Builds a request to exchange a refresh token for a new set of {@link Tokens} using the + * statically configured domain. See {@link AuthenticationController#renewAuth(String, String)} + * for details. + * + *

This overload is only valid when the controller was configured with a fixed domain. When a + * {@code DomainResolver} is in use, call {@link AuthenticationController#renewAuth(String, String)} + * with the domain instead.

+ * + * @param refreshToken the refresh token to exchange. + * @return a {@link RenewAuthRequest} to configure and execute. + * @throws IllegalStateException if the controller was configured with a {@code DomainResolver}. + */ + public RenewAuthRequest renewAuth(String refreshToken) { + Validate.notNull(refreshToken, "refreshToken must not be null"); + return requestProcessor.buildRenewAuthRequest(refreshToken); + } + + /** + * Builds a request to exchange a refresh token for a new set of {@link Tokens}, resolving the + * Auth0 domain from the given request via the configured domain or {@code DomainResolver}. + * See {@link AuthenticationController#renewAuth(String, String)} for details. + * + *

This overload works for both a fixed domain and a {@code DomainResolver}, and is convenient + * when refreshing within an active request. Note: a refresh token is bound to + * the domain it was issued for at login; if the resolver resolves the given request to a + * different domain, Auth0 will reject the grant. Use this overload only when the request + * resolves to the same domain as login; otherwise use + * {@link AuthenticationController#renewAuth(String, String)} with the domain stored from + * {@link Tokens#getDomain()} at login.

+ * + * @param refreshToken the refresh token to exchange. + * @param request the current HTTP request, used to resolve the domain. + * @return a {@link RenewAuthRequest} to configure and execute. + */ + public RenewAuthRequest renewAuth(String refreshToken, HttpServletRequest request) { + Validate.notNull(refreshToken, "refreshToken must not be null"); + Validate.notNull(request, "request must not be null"); + return requestProcessor.buildRenewAuthRequest(refreshToken, request); + } + + /** + * Initiates a Client-Initiated + * Backchannel Authentication (CIBA) request. This is the first step of the CIBA flow: it + * asks Auth0 to authenticate a user out-of-band (on their own device) and returns an + * {@code auth_req_id} used to poll for the result via {@link #backChannelPoll(String, String)}. + * + *

The application supplies the {@code domain} to target; store it alongside the returned + * {@code auth_req_id} so the poll step can target the same domain. For applications configured + * with a fixed domain, {@link #backChannelAuthorize(String, String, java.util.Map)} may be used + * instead.

+ * + *

The library remains stateless: the application owns the polling loop, honoring the + * {@code interval} and {@code expires_in} returned by the initiate step.

+ * + * @param scope the requested scope (e.g. {@code "openid profile"}). + * @param bindingMessage the human-readable message displayed to the user on their device. + * @param loginHint a map identifying the user, serialized to the {@code login_hint} JSON. + * Auth0 expects the {@code iss_sub} shape, e.g. + * {@code {"format": "iss_sub", "iss": "https://your-tenant.auth0.com/", + * "sub": "auth0|abc123"}}. + * @param domain the Auth0 domain to target. + * @return a {@link BackChannelAuthorizeRequest} to configure and execute. + */ + public BackChannelAuthorizeRequest backChannelAuthorize(String scope, String bindingMessage, Map loginHint, String domain) { + Validate.notNull(scope, "scope must not be null"); + Validate.notNull(bindingMessage, "bindingMessage must not be null"); + Validate.notNull(loginHint, "loginHint must not be null"); + Validate.notNull(domain, "domain must not be null"); + return requestProcessor.buildBackChannelAuthorizeRequest(scope, bindingMessage, loginHint, domain); + } + + /** + * Initiates a CIBA backchannel authentication request using the statically configured domain. + * See {@link #backChannelAuthorize(String, String, java.util.Map, String)} for details. + * + *

This overload is only valid when the controller was configured with a fixed domain. When a + * {@code DomainResolver} is in use, call the overload that accepts a domain.

+ * + * @param scope the requested scope. + * @param bindingMessage the human-readable message displayed to the user on their device. + * @param loginHint a map identifying the user, serialized to the {@code login_hint} JSON. + * @return a {@link BackChannelAuthorizeRequest} to configure and execute. + * @throws IllegalStateException if the controller was configured with a {@code DomainResolver}. + */ + public BackChannelAuthorizeRequest backChannelAuthorize(String scope, String bindingMessage, Map loginHint) { + Validate.notNull(scope, "scope must not be null"); + Validate.notNull(bindingMessage, "bindingMessage must not be null"); + Validate.notNull(loginHint, "loginHint must not be null"); + return requestProcessor.buildBackChannelAuthorizeRequest(scope, bindingMessage, loginHint); + } + + /** + * Builds a request to poll for the result of a CIBA backchannel authentication request. This is + * the second step of the CIBA flow: the application calls {@link BackChannelTokenRequest#execute()} + * repeatedly (no more frequently than the {@code interval} returned by the authorize step) until + * the user approves (yielding verified {@link Tokens}) or a terminal error occurs. + * + *

The application supplies the {@code domain} it stored at the authorize step, since polling + * commonly happens outside the initiating HTTP request. For applications configured with a fixed + * domain, {@link #backChannelPoll(String)} may be used instead.

+ * + * @param authReqId the {@code auth_req_id} returned from the authorize step. + * @param domain the Auth0 domain to target. + * @return a {@link BackChannelTokenRequest} to execute. + */ + public BackChannelTokenRequest backChannelPoll(String authReqId, String domain) { + Validate.notNull(authReqId, "authReqId must not be null"); + Validate.notNull(domain, "domain must not be null"); + return requestProcessor.buildBackChannelTokenRequest(authReqId, domain); + } + + /** + * Builds a request to poll for the result of a CIBA backchannel authentication request using the + * statically configured domain. See {@link #backChannelPoll(String, String)} for details. + * + *

This overload is only valid when the controller was configured with a fixed domain. When a + * {@code DomainResolver} is in use, call the overload that accepts a domain.

+ * + * @param authReqId the {@code auth_req_id} returned from the authorize step. + * @return a {@link BackChannelTokenRequest} to execute. + * @throws IllegalStateException if the controller was configured with a {@code DomainResolver}. + */ + public BackChannelTokenRequest backChannelPoll(String authReqId) { + Validate.notNull(authReqId, "authReqId must not be null"); + return requestProcessor.buildBackChannelTokenRequest(authReqId); + } + + /** + * Builds a request to exchange an external {@code subject_token} for a new set of + * {@link Tokens} via Custom + * Token Exchange, without login semantics. The returned tokens are not verified beyond the + * exchange itself, making this suitable for obtaining tokens for a downstream API. + * + *

The application supplies the {@code domain} to target. This is required because a token + * exchange can occur outside of an HTTP request, where the domain cannot otherwise be resolved. + * For applications configured with a fixed domain, the + * {@link AuthenticationController#customTokenExchange(String, String)} overload may be used + * instead.

+ * + * @param subjectToken the external token to exchange. + * @param subjectTokenType the customer-defined URI describing the subject token. + * @param domain the Auth0 domain to target. + * @return a {@link TokenExchangeRequest} to configure and execute. + */ + public TokenExchangeRequest customTokenExchange(String subjectToken, String subjectTokenType, String domain) { + Validate.notNull(subjectToken, "subjectToken must not be null"); + Validate.notNull(subjectTokenType, "subjectTokenType must not be null"); + Validate.notNull(domain, "domain must not be null"); + return requestProcessor.buildTokenExchangeRequest(subjectToken, subjectTokenType, domain, false); + } + + /** + * Builds a Custom Token Exchange request using the statically configured domain. See + * {@link AuthenticationController#customTokenExchange(String, String, String)} for details. + * + *

This overload is only valid when the controller was configured with a fixed domain. When a + * {@code DomainResolver} is in use, call the overload that accepts a domain.

+ * + * @param subjectToken the external token to exchange. + * @param subjectTokenType the customer-defined URI describing the subject token. + * @return a {@link TokenExchangeRequest} to configure and execute. + * @throws IllegalStateException if the controller was configured with a {@code DomainResolver}. + */ + public TokenExchangeRequest customTokenExchange(String subjectToken, String subjectTokenType) { + Validate.notNull(subjectToken, "subjectToken must not be null"); + Validate.notNull(subjectTokenType, "subjectTokenType must not be null"); + return requestProcessor.buildTokenExchangeRequest(subjectToken, subjectTokenType, false); + } + + /** + * Builds a request to exchange an external {@code subject_token} for a login-ready set of + * {@link Tokens} via Custom + * Token Exchange. Unlike {@link #customTokenExchange(String, String, String)}, the returned + * ID token is verified (including {@code org_id}/{@code org_name} claims when an organization is + * configured), yielding tokens suitable for establishing an application session. + * + *

The application supplies the {@code domain} to target; see + * {@link #customTokenExchange(String, String, String)} for why.

+ * + * @param subjectToken the external token to exchange. + * @param subjectTokenType the customer-defined URI describing the subject token. + * @param domain the Auth0 domain to target. + * @return a {@link TokenExchangeRequest} to configure and execute. + */ + public TokenExchangeRequest loginWithCustomTokenExchange(String subjectToken, String subjectTokenType, String domain) { + Validate.notNull(subjectToken, "subjectToken must not be null"); + Validate.notNull(subjectTokenType, "subjectTokenType must not be null"); + Validate.notNull(domain, "domain must not be null"); + return requestProcessor.buildTokenExchangeRequest(subjectToken, subjectTokenType, domain, true); + } + + /** + * Builds a login-shaped Custom Token Exchange request using the statically configured domain. + * See {@link #loginWithCustomTokenExchange(String, String, String)} for details. + * + *

This overload is only valid when the controller was configured with a fixed domain. When a + * {@code DomainResolver} is in use, call the overload that accepts a domain.

+ * + * @param subjectToken the external token to exchange. + * @param subjectTokenType the customer-defined URI describing the subject token. + * @return a {@link TokenExchangeRequest} to configure and execute. + * @throws IllegalStateException if the controller was configured with a {@code DomainResolver}. + */ + public TokenExchangeRequest loginWithCustomTokenExchange(String subjectToken, String subjectTokenType) { + Validate.notNull(subjectToken, "subjectToken must not be null"); + Validate.notNull(subjectTokenType, "subjectTokenType must not be null"); + return requestProcessor.buildTokenExchangeRequest(subjectToken, subjectTokenType, true); + } + } diff --git a/src/main/java/com/auth0/BackChannelAuthorizationException.java b/src/main/java/com/auth0/BackChannelAuthorizationException.java new file mode 100644 index 0000000..38faa2b --- /dev/null +++ b/src/main/java/com/auth0/BackChannelAuthorizationException.java @@ -0,0 +1,69 @@ +package com.auth0; + +/** + * Represents an error returned while polling the Auth0 token endpoint for CIBA (Client-Initiated + * Backchannel Authentication) login status. The OAuth error codes returned during polling are + * {@code authorization_pending} (user hasn't approved yet — normal, keep polling), {@code + * slow_down} (poll less frequently), {@code expired_token} (the auth_req_id expired), and {@code + * access_denied} (user rejected). + * + *

The non-terminal cases ({@link #isAuthorizationPending()} and {@link #isSlowDown()}) indicate + * the caller should sleep and retry. The terminal cases ({@link #isExpiredToken()} and {@link + * #isAccessDenied()}) indicate the polling loop must stop. + * + *

Note on the type hierarchy: this extends {@link IdentityVerificationException} so a + * single catch clause can cover the whole CIBA poll path, but {@code authorization_pending} and + * {@code slow_down} are polling control-flow signals, not ID token verification failures. + * Callers should branch on the {@code isX()} helpers rather than treating every instance as a + * verification error. + * + * @see AuthenticationController#backChannelPoll(String, String) + */ +@SuppressWarnings("WeakerAccess") +public class BackChannelAuthorizationException extends IdentityVerificationException { + + public static final String AUTHORIZATION_PENDING = "authorization_pending"; + public static final String SLOW_DOWN = "slow_down"; + public static final String EXPIRED_TOKEN = "expired_token"; + public static final String ACCESS_DENIED = "access_denied"; + + BackChannelAuthorizationException(String code, String message) { + this(code, message, null); + } + + BackChannelAuthorizationException(String code, String message, Throwable cause) { + super(code, message, cause); + } + + /** + * @return true if the error is due to the user not yet approving the authentication request. + * The caller should sleep and retry. + */ + public boolean isAuthorizationPending() { + return AUTHORIZATION_PENDING.equals(getCode()); + } + + /** + * @return true if the polling interval should be increased. The caller should back off — the + * common convention is to add 5 seconds to the current interval — and then retry. + */ + public boolean isSlowDown() { + return SLOW_DOWN.equals(getCode()); + } + + /** + * @return true if the {@code auth_req_id} has expired. This is a terminal error; the polling + * loop must stop. + */ + public boolean isExpiredToken() { + return EXPIRED_TOKEN.equals(getCode()); + } + + /** + * @return true if the user rejected the authentication request. This is a terminal error; the + * polling loop must stop. + */ + public boolean isAccessDenied() { + return ACCESS_DENIED.equals(getCode()); + } +} diff --git a/src/main/java/com/auth0/BackChannelAuthorizeRequest.java b/src/main/java/com/auth0/BackChannelAuthorizeRequest.java new file mode 100644 index 0000000..21dd590 --- /dev/null +++ b/src/main/java/com/auth0/BackChannelAuthorizeRequest.java @@ -0,0 +1,100 @@ +package com.auth0; + +import com.auth0.client.auth.AuthAPI; +import com.auth0.exception.Auth0Exception; +import com.auth0.json.auth.BackChannelAuthorizeResponse; + +import java.util.Map; + +/** + * Class to initiate a Client-Initiated Backchannel Authentication (CIBA) backchannel authorization + * request (POST /bc-authorize). This is the first step of the CIBA flow: the application requests + * authentication from Auth0 for a user identified via login hints, and Auth0 returns an + * {@code auth_req_id} that can be polled to obtain the authentication result. + *

+ * The returned {@link BackChannelAuthorizeResponse} carries: + *

    + *
  • {@code auth_req_id}: a unique identifier for this authentication request, used in the + * subsequent poll step to fetch the result.
  • + *
  • {@code expires_in}: the lifetime of the authentication request in seconds; after this + * period, the auth_req_id is no longer valid.
  • + *
  • {@code interval}: the minimum number of seconds the application should wait between + * consecutive poll requests.
  • + *
+ *

+ * The library remains stateless: the application owns the polling loop, storage of the + * {@code auth_req_id}, and any state associated with the authentication request. See + * {@link AuthenticationController} for the entry point to obtain an instance. + *

+ * Optional parameters ({@code audience}, {@code requested_expiry}) can be configured via + * {@link #withAudience(String)} and {@link #withRequestedExpiry(Integer)}. + */ +@SuppressWarnings({"UnusedReturnValue", "WeakerAccess", "unused"}) +public class BackChannelAuthorizeRequest { + + private final AuthAPI client; + private final String scope; + private final String bindingMessage; + private final Map loginHint; + private final String domain; + private final String issuer; + private String audience; + private Integer requestedExpiry; + + BackChannelAuthorizeRequest(AuthAPI client, String scope, String bindingMessage, + Map loginHint, String domain, String issuer) { + this.client = client; + this.scope = scope; + this.bindingMessage = bindingMessage; + this.loginHint = loginHint; + this.domain = domain; + this.issuer = issuer; + } + + /** + * Sets the audience (API identifier) for the access token to be obtained during the + * authentication flow. When not set, Auth0 uses the default audience configured for the + * application. + * + * @param audience the audience to request a token for. + * @return this request instance for fluent chaining. + */ + public BackChannelAuthorizeRequest withAudience(String audience) { + this.audience = audience; + return this; + } + + /** + * Sets the requested expiry (lifetime) of the authentication request in seconds. This value + * is sent to Auth0 as {@code requested_expiry} and controls how long the {@code auth_req_id} + * remains valid for polling. + * + * @param seconds the requested lifetime of the authentication request. + * @return this request instance for fluent chaining. + */ + public BackChannelAuthorizeRequest withRequestedExpiry(Integer seconds) { + this.requestedExpiry = seconds; + return this; + } + + /** + * Executes the backchannel authorization request against Auth0 and returns the response + * containing the {@code auth_req_id}, expiration, and polling interval. + * + * @return the {@link BackChannelAuthorizeResponse} containing {@code auth_req_id}, + * {@code expires_in}, and {@code interval}. + * @throws Auth0Exception if the request to the Auth0 server failed. + */ + public BackChannelAuthorizeResponse execute() throws Auth0Exception { + com.auth0.net.Request request; + + if (audience == null && requestedExpiry == null) { + request = client.authorizeBackChannel(scope, bindingMessage, loginHint); + } else { + request = client.authorizeBackChannel(scope, bindingMessage, loginHint, + audience, requestedExpiry); + } + + return request.execute().getBody(); + } +} diff --git a/src/main/java/com/auth0/BackChannelTokenRequest.java b/src/main/java/com/auth0/BackChannelTokenRequest.java new file mode 100644 index 0000000..58ee1ca --- /dev/null +++ b/src/main/java/com/auth0/BackChannelTokenRequest.java @@ -0,0 +1,50 @@ +package com.auth0; + +import com.auth0.exception.Auth0Exception; + +/** + * Class to poll Auth0's token endpoint for the result of a CIBA (Client-Initiated Backchannel + * Authentication) backchannel authentication request. + *

+ * The library remains stateless: the application owns the polling loop. It must call + * {@link #execute()} no more frequently than the {@code interval} returned by the initiate step, + * and handle {@link BackChannelAuthorizationException} to decide whether to keep polling + * ({@code authorization_pending} / {@code slow_down}) or stop ({@code expired_token} / + * {@code access_denied}). + *

+ * On success, the returned ID token is verified (signature, issuer, org claims) just like the + * Custom Token Exchange login path. + *

+ * Obtain an instance via {@link AuthenticationController#backChannelPoll(String, String)}. + */ +@SuppressWarnings({"UnusedReturnValue", "WeakerAccess", "unused"}) +public class BackChannelTokenRequest { + + private final RequestProcessor processor; + private final String authReqId; + private final String domain; + private final String issuer; + + BackChannelTokenRequest(RequestProcessor processor, String authReqId, String domain, + String issuer) { + this.processor = processor; + this.authReqId = authReqId; + this.domain = domain; + this.issuer = issuer; + } + + /** + * Polls the Auth0 token endpoint for the result of the backchannel authentication request. + * + * @return the verified {@link Tokens} once the user has approved the request. + * @throws BackChannelAuthorizationException while the request is still pending + * ({@code authorization_pending} / {@code slow_down}) + * or on a terminal poll error ({@code expired_token} + * / {@code access_denied}). + * @throws IdentityVerificationException if the returned ID token fails verification. + * @throws Auth0Exception if the request to the Auth0 server failed. + */ + public Tokens execute() throws IdentityVerificationException, Auth0Exception { + return processor.executeBackChannelPoll(authReqId, domain, issuer); + } +} diff --git a/src/main/java/com/auth0/CustomTokenExchangeException.java b/src/main/java/com/auth0/CustomTokenExchangeException.java new file mode 100644 index 0000000..9996d10 --- /dev/null +++ b/src/main/java/com/auth0/CustomTokenExchangeException.java @@ -0,0 +1,37 @@ +package com.auth0; + +/** + * Represents a client-side validation error raised before performing a Custom Token Exchange + * request against the Auth0 Authentication API. These are thrown for malformed inputs (an empty or + * {@code "Bearer "}-prefixed {@code subject_token}, or a {@code subject_token_type} that is not a + * valid URI) so the caller fails fast without a network round-trip. + * + * @see AuthenticationController#customTokenExchange(String, String) + * @see AuthenticationController#loginWithCustomTokenExchange(String, String) + */ +@SuppressWarnings("WeakerAccess") +public class CustomTokenExchangeException extends IdentityVerificationException { + + static final String INVALID_TOKEN_FORMAT = "a0.cte_invalid_token_format"; + static final String INVALID_TOKEN_TYPE_URI = "a0.cte_invalid_token_type_uri"; + + CustomTokenExchangeException(String code, String message) { + super(code, message, null); + } + + /** + * @return true if the error is due to an empty, whitespace-only, or {@code "Bearer "}-prefixed + * token value. + */ + public boolean isInvalidTokenFormat() { + return INVALID_TOKEN_FORMAT.equals(getCode()); + } + + /** + * @return true if the error is due to a {@code subject_token_type} value that is not a valid + * URI. + */ + public boolean isInvalidTokenTypeUri() { + return INVALID_TOKEN_TYPE_URI.equals(getCode()); + } +} diff --git a/src/main/java/com/auth0/RenewAuthRequest.java b/src/main/java/com/auth0/RenewAuthRequest.java new file mode 100644 index 0000000..60895bc --- /dev/null +++ b/src/main/java/com/auth0/RenewAuthRequest.java @@ -0,0 +1,88 @@ +package com.auth0; + +import com.auth0.client.auth.AuthAPI; +import com.auth0.exception.Auth0Exception; +import com.auth0.json.auth.TokenHolder; +import com.auth0.net.TokenRequest; + +/** + * Class to exchange a refresh token for a new set of {@link Tokens}, optionally targeting a + * specific {@code audience} and/or {@code scope}. This exposes Auth0's refresh-token grant, + * enabling Multi-Resource Refresh Token (MRRT) flows where one refresh token can obtain access + * tokens for multiple APIs. + *

+ * The library remains stateless: the application owns storage of the refresh token, caching of + * the resulting access tokens, and any concurrency control around refresh-token rotation. + *

+ * Obtain an instance via {@link AuthenticationController#renewAuth(String, String)}, + * {@link AuthenticationController#renewAuth(String)}, or + * {@link AuthenticationController#renewAuth(String, jakarta.servlet.http.HttpServletRequest)}. + */ +@SuppressWarnings({"UnusedReturnValue", "WeakerAccess", "unused"}) +public class RenewAuthRequest { + + private final AuthAPI client; + private final String refreshToken; + private final String domain; + private final String issuer; + private String audience; + private String scope; + + RenewAuthRequest(AuthAPI client, String refreshToken, String domain, String issuer) { + this.client = client; + this.refreshToken = refreshToken; + this.domain = domain; + this.issuer = issuer; + } + + /** + * Sets the audience to request an access token for. When not set, Auth0 uses the default + * audience configured for the application. + *

+ * Note: if the requested audience is not permitted by the application's MRRT policy, Auth0 + * does not error; it returns a token for the default audience instead. Callers must verify + * the {@code aud} claim of the returned access token. + * + * @param audience the audience (API identifier) to request a token for. + * @return this request instance for fluent chaining. + */ + public RenewAuthRequest withAudience(String audience) { + this.audience = audience; + return this; + } + + /** + * Sets the scope to request for the access token. + * + * @param scope the requested scope. + * @return this request instance for fluent chaining. + */ + public RenewAuthRequest withScope(String scope) { + this.scope = scope; + return this; + } + + /** + * Executes the refresh-token grant against Auth0 and returns the resulting tokens. + *

+ * The refresh-token grant does not return an ID token, so {@link Tokens#getIdToken()} is + * typically null. When refresh-token rotation is enabled, the returned + * {@link Tokens#getRefreshToken()} is a new refresh token that supersedes the one used here; + * the application is responsible for persisting it. + * + * @return the {@link Tokens} obtained from the grant, including the granted scope. + * @throws Auth0Exception if the request to the Auth0 server failed. + */ + public Tokens execute() throws Auth0Exception { + TokenRequest request = client.renewAuth(refreshToken); + if (audience != null) { + request.setAudience(audience); + } + if (scope != null) { + request.setScope(scope); + } + TokenHolder holder = request.execute().getBody(); + return new Tokens(holder.getAccessToken(), holder.getIdToken(), holder.getRefreshToken(), + holder.getTokenType(), holder.getExpiresIn(), holder.getScope(), domain, issuer); + } +} diff --git a/src/main/java/com/auth0/RequestProcessor.java b/src/main/java/com/auth0/RequestProcessor.java index c1e9512..8dcb591 100644 --- a/src/main/java/com/auth0/RequestProcessor.java +++ b/src/main/java/com/auth0/RequestProcessor.java @@ -2,11 +2,14 @@ import com.auth0.client.LoggingOptions; import com.auth0.client.auth.AuthAPI; +import com.auth0.exception.APIException; import com.auth0.exception.Auth0Exception; import com.auth0.exception.IdTokenValidationException; import com.auth0.exception.PublicKeyProviderException; import com.auth0.jwt.JWT; +import com.auth0.json.auth.BackChannelTokenResponse; import com.auth0.json.auth.TokenHolder; +import com.auth0.net.TokenRequest; import com.auth0.jwk.Jwk; import com.auth0.jwk.JwkException; import com.auth0.jwk.JwkProvider; @@ -45,6 +48,7 @@ class RequestProcessor { private static final String KEY_RESPONSE_MODE = "response_mode"; private static final String KEY_FORM_POST = "form_post"; private static final String KEY_MAX_AGE = "max_age"; + private static final String CIBA_GRANT_TYPE = "urn:openid:params:grant-type:ciba"; private final DomainProvider domainProvider; private final String responseType; @@ -170,6 +174,233 @@ AuthAPI createClientForDomain(String domain) { .build(); } + /** + * Builds a {@link RenewAuthRequest} to exchange a refresh token for new tokens against the + * given domain. The domain is supplied explicitly because a refresh can occur outside of an + * HTTP request (e.g. a background refresh), where the {@link DomainProvider} cannot resolve it. + * + * @param refreshToken the refresh token to exchange. + * @param domain the Auth0 domain to target. + * @return a {@link RenewAuthRequest} ready to configure and execute. + */ + RenewAuthRequest buildRenewAuthRequest(String refreshToken, String domain) { + AuthAPI client = createClientForDomain(domain); + String issuer = constructIssuer(domain); + return new RenewAuthRequest(client, refreshToken, domain, issuer); + } + + /** + * Builds a {@link RenewAuthRequest} using the statically configured domain. Only valid when the + * controller was configured with a fixed domain; when a {@link DomainResolver} is in use there + * is no fixed domain to target and the domain must be supplied explicitly. + * + * @param refreshToken the refresh token to exchange. + * @return a {@link RenewAuthRequest} ready to configure and execute. + * @throws IllegalStateException if the controller was configured with a {@link DomainResolver}. + */ + RenewAuthRequest buildRenewAuthRequest(String refreshToken) { + if (!(domainProvider instanceof StaticDomainProvider)) { + throw new IllegalStateException("A domain is required when using a DomainResolver; call renewAuth(refreshToken, domain)."); + } + return buildRenewAuthRequest(refreshToken, domainProvider.getDomain(null)); + } + + /** + * Builds a {@link RenewAuthRequest} resolving the domain from the given request via the + * configured {@link DomainProvider}. Works for both a fixed domain and a {@link DomainResolver}. + * + * @param refreshToken the refresh token to exchange. + * @param request the current HTTP request, used to resolve the domain. + * @return a {@link RenewAuthRequest} ready to configure and execute. + */ + RenewAuthRequest buildRenewAuthRequest(String refreshToken, HttpServletRequest request) { + return buildRenewAuthRequest(refreshToken, domainProvider.getDomain(request)); + } + + /** + * Builds a {@link TokenExchangeRequest} to exchange an external {@code subject_token} for Auth0 + * tokens against the given domain. The domain is supplied explicitly because a token exchange + * can occur outside of an HTTP request, where the {@link DomainProvider} cannot resolve it. + * + * @param subjectToken the external token to exchange. + * @param subjectTokenType the (customer-defined) URI describing the subject token. + * @param domain the Auth0 domain to target. + * @param loginSemantics whether to verify the returned ID token (incl. organization claims). + * @return a {@link TokenExchangeRequest} ready to configure and execute. + */ + TokenExchangeRequest buildTokenExchangeRequest(String subjectToken, String subjectTokenType, String domain, boolean loginSemantics) { + String issuer = constructIssuer(domain); + return new TokenExchangeRequest(this, subjectToken, subjectTokenType, domain, issuer, loginSemantics, this.organization); + } + + /** + * Builds a {@link TokenExchangeRequest} using the statically configured domain. Only valid when + * the controller was configured with a fixed domain; when a {@link DomainResolver} is in use the + * domain must be supplied explicitly. + * + * @param subjectToken the external token to exchange. + * @param subjectTokenType the (customer-defined) URI describing the subject token. + * @param loginSemantics whether to verify the returned ID token (incl. organization claims). + * @return a {@link TokenExchangeRequest} ready to configure and execute. + * @throws IllegalStateException if the controller was configured with a {@link DomainResolver}. + */ + TokenExchangeRequest buildTokenExchangeRequest(String subjectToken, String subjectTokenType, boolean loginSemantics) { + if (!(domainProvider instanceof StaticDomainProvider)) { + throw new IllegalStateException("A domain is required when using a DomainResolver; call the customTokenExchange overload that accepts a domain."); + } + return buildTokenExchangeRequest(subjectToken, subjectTokenType, domainProvider.getDomain(null), loginSemantics); + } + + /** + * Builds a {@link BackChannelAuthorizeRequest} to initiate a CIBA backchannel authorization + * against the given domain. The domain is supplied explicitly because the subsequent poll can + * occur outside of the initiating HTTP request, and the application must store the domain + * (alongside the {@code auth_req_id}) to target the same domain when polling. + * + * @param scope the requested scope. + * @param bindingMessage the human-readable message shown to the user on their device. + * @param loginHint the login hint identifying the user, serialized to JSON by the SDK. + * @param domain the Auth0 domain to target. + * @return a {@link BackChannelAuthorizeRequest} ready to configure and execute. + */ + BackChannelAuthorizeRequest buildBackChannelAuthorizeRequest(String scope, String bindingMessage, + java.util.Map loginHint, String domain) { + AuthAPI client = createClientForDomain(domain); + String issuer = constructIssuer(domain); + return new BackChannelAuthorizeRequest(client, scope, bindingMessage, loginHint, domain, issuer); + } + + /** + * Builds a {@link BackChannelAuthorizeRequest} using the statically configured domain. Only + * valid when the controller was configured with a fixed domain. + * + * @throws IllegalStateException if the controller was configured with a {@link DomainResolver}. + */ + BackChannelAuthorizeRequest buildBackChannelAuthorizeRequest(String scope, String bindingMessage, + java.util.Map loginHint) { + if (!(domainProvider instanceof StaticDomainProvider)) { + throw new IllegalStateException("A domain is required when using a DomainResolver; call the backChannelAuthorize overload that accepts a domain."); + } + return buildBackChannelAuthorizeRequest(scope, bindingMessage, loginHint, domainProvider.getDomain(null)); + } + + /** + * Builds a {@link BackChannelTokenRequest} to poll for the result of a CIBA backchannel + * authorization against the given domain. The domain is supplied explicitly because polling + * typically occurs outside of the initiating HTTP request; it must match the domain the + * {@code auth_req_id} was issued for. + * + * @param authReqId the {@code auth_req_id} returned from the authorize step. + * @param domain the Auth0 domain to target. + * @return a {@link BackChannelTokenRequest} ready to execute. + */ + BackChannelTokenRequest buildBackChannelTokenRequest(String authReqId, String domain) { + String issuer = constructIssuer(domain); + return new BackChannelTokenRequest(this, authReqId, domain, issuer); + } + + /** + * Builds a {@link BackChannelTokenRequest} using the statically configured domain. Only valid + * when the controller was configured with a fixed domain. + * + * @throws IllegalStateException if the controller was configured with a {@link DomainResolver}. + */ + BackChannelTokenRequest buildBackChannelTokenRequest(String authReqId) { + if (!(domainProvider instanceof StaticDomainProvider)) { + throw new IllegalStateException("A domain is required when using a DomainResolver; call the backChannelPoll overload that accepts a domain."); + } + return buildBackChannelTokenRequest(authReqId, domainProvider.getDomain(null)); + } + + /** + * Polls the Auth0 token endpoint for the result of a CIBA backchannel authentication request. + *

+ * While the user has not yet completed authentication, Auth0 responds with an OAuth error that + * is surfaced here as a {@link BackChannelAuthorizationException}: {@code authorization_pending} + * and {@code slow_down} are non-terminal (the caller should keep polling), while + * {@code expired_token} and {@code access_denied} are terminal. On success the returned ID + * token is verified (reusing the code-flow verification path, including organization-claim + * validation when an organization is configured). + * + * @throws BackChannelAuthorizationException if Auth0 returned a CIBA poll error. + * @throws IdentityVerificationException if the returned ID token fails verification. + * @throws Auth0Exception if the request to the Auth0 server failed. + */ + Tokens executeBackChannelPoll(String authReqId, String domain, String issuer) + throws IdentityVerificationException, Auth0Exception { + BackChannelTokenResponse response; + try { + response = createClientForDomain(domain) + .getBackChannelLoginStatus(authReqId, CIBA_GRANT_TYPE) + .execute() + .getBody(); + } catch (APIException e) { + // Translate the OAuth poll error codes into a typed exception so callers can drive + // their polling loop (pending/slow_down = keep polling; expired/denied = stop). + throw new BackChannelAuthorizationException(e.getError(), e.getDescription(), e); + } + + if (response.getIdToken() == null) { + // CIBA establishes a user session, so an ID token is required on success. + throw new InvalidRequestException(MISSING_ID_TOKEN, "ID Token is missing from the CIBA token response."); + } + try { + verifyIdToken(response.getIdToken(), issuer, domain, null, organization); + } catch (IdTokenValidationException e) { + throw new IdentityVerificationException(JWT_VERIFICATION_ERROR, "An error occurred while trying to verify the ID Token.", e); + } + + return new Tokens(response.getAccessToken(), response.getIdToken(), null, + "Bearer", response.getExpiresIn(), response.getScope(), domain, issuer); + } + + /** + * Performs the Custom Token Exchange grant against Auth0 and returns the resulting tokens. + *

+ * The request is built via {@link AuthAPI#exchangeToken(String, String)} (RFC 8693 token-exchange + * grant), which also applies client authentication. When {@code loginSemantics} is true the + * returned ID token is verified (reusing the code-flow verification path, including + * organization-claim validation). + * + * @throws IdentityVerificationException if the returned ID token fails verification. + * @throws Auth0Exception if the request to the Auth0 server failed. + */ + Tokens executeCustomTokenExchange(String subjectToken, String subjectTokenType, String audience, + String scope, String organization, String domain, String issuer, + boolean loginSemantics) throws IdentityVerificationException, Auth0Exception { + TokenRequest request = createClientForDomain(domain).exchangeToken(subjectToken, subjectTokenType); + + if (audience != null) { + request.setAudience(audience); + } + if (scope != null) { + request.setScope(scope); + } + if (organization != null) { + request.addParameter("organization", organization); + } + + TokenHolder holder = request.execute().getBody(); + + if (holder.getIdToken() == null) { + // The login path establishes a user session, so an ID token is required. + if (loginSemantics) { + throw new InvalidRequestException(MISSING_ID_TOKEN, "ID Token is missing from the token exchange response."); + } + } else if (loginSemantics || organization != null) { + // Verify the returned ID token on the login path; also verify (for org_id/org_name claim + // validation) on the utility path whenever an organization is in play. + try { + verifyIdToken(holder.getIdToken(), issuer, domain, null, organization); + } catch (IdTokenValidationException e) { + throw new IdentityVerificationException(JWT_VERIFICATION_ERROR, "An error occurred while trying to verify the ID Token.", e); + } + } + + return new Tokens(holder.getAccessToken(), holder.getIdToken(), holder.getRefreshToken(), + holder.getTokenType(), holder.getExpiresIn(), holder.getScope(), domain, issuer); + } + private Auth0HttpClient getHttpClient() { if (this.httpClient == null) { DefaultHttpClient.Builder httpBuilder = DefaultHttpClient.newBuilder() @@ -311,6 +542,10 @@ private Tokens getVerifiedTokens(HttpServletRequest request, HttpServletResponse * - HS256: uses client secret */ private void verifyIdToken(String idToken, String issuer, String domain, String nonce) throws IdTokenValidationException { + verifyIdToken(idToken, issuer, domain, nonce, organization); + } + + private void verifyIdToken(String idToken, String issuer, String domain, String nonce, String organization) throws IdTokenValidationException { SignatureVerifier sigVerifier = buildSignatureVerifier(idToken, domain); IdTokenVerifier.Builder verifierBuilder = IdTokenVerifier.init(issuer, clientId, sigVerifier); @@ -450,7 +685,7 @@ private Tokens exchangeCodeForTokens(String authorizationCode, String redirectUr .execute() .getBody(); String originIssuer = constructIssuer(originDomain); - return new Tokens(holder.getAccessToken(), holder.getIdToken(), holder.getRefreshToken(), holder.getTokenType(), holder.getExpiresIn(), originDomain, originIssuer); + return new Tokens(holder.getAccessToken(), holder.getIdToken(), holder.getRefreshToken(), holder.getTokenType(), holder.getExpiresIn(), holder.getScope(), originDomain, originIssuer); } /** @@ -470,15 +705,18 @@ private Tokens mergeTokens(Tokens frontChannelTokens, Tokens codeExchangeTokens) String accessToken; String type; Long expiresIn; + String scope; if (codeExchangeTokens.getAccessToken() != null) { accessToken = codeExchangeTokens.getAccessToken(); type = codeExchangeTokens.getType(); expiresIn = codeExchangeTokens.getExpiresIn(); + scope = codeExchangeTokens.getScope(); } else { accessToken = frontChannelTokens.getAccessToken(); type = frontChannelTokens.getType(); expiresIn = frontChannelTokens.getExpiresIn(); + scope = frontChannelTokens.getScope(); } // Prefer ID token from the front-channel @@ -493,7 +731,7 @@ private Tokens mergeTokens(Tokens frontChannelTokens, Tokens codeExchangeTokens) String issuer = frontChannelTokens.getIssuer() != null ? frontChannelTokens.getIssuer() : codeExchangeTokens.getIssuer(); - return new Tokens(accessToken, idToken, refreshToken, type, expiresIn, domain, issuer); + return new Tokens(accessToken, idToken, refreshToken, type, expiresIn, scope, domain, issuer); } private String constructIssuer(String domain) { diff --git a/src/main/java/com/auth0/TokenExchangeRequest.java b/src/main/java/com/auth0/TokenExchangeRequest.java new file mode 100644 index 0000000..9424ef9 --- /dev/null +++ b/src/main/java/com/auth0/TokenExchangeRequest.java @@ -0,0 +1,144 @@ +package com.auth0; + +import com.auth0.exception.Auth0Exception; + +import java.net.URI; +import java.net.URISyntaxException; + +/** + * Class to perform a Custom + * Token Exchange: exchanging an external {@code subject_token} for Auth0 {@link Tokens} via the + * RFC 8693 grant {@code urn:ietf:params:oauth:grant-type:token-exchange}, optionally targeting an + * {@code audience}, {@code scope}, and/or {@code organization}. + *

+ * The library remains stateless: the application owns storage of the returned tokens. + *

+ * Obtain an instance via {@link AuthenticationController#customTokenExchange(String, String)} / + * {@link AuthenticationController#loginWithCustomTokenExchange(String, String)} (and their + * domain-qualified overloads). The {@code loginWith*} variants always verify the returned ID token. + * The utility variant returns the raw exchanged tokens without verification, except when an + * organization is configured: to validate the {@code org_id}/{@code org_name} claim it must verify + * the ID token that carries it, so a returned ID token is fully verified on either path whenever an + * organization is in play. See {@link #withOrganization(String)}. + */ +@SuppressWarnings({"UnusedReturnValue", "WeakerAccess", "unused"}) +public class TokenExchangeRequest { + + private final RequestProcessor processor; + private final String subjectToken; + private final String subjectTokenType; + private final String domain; + private final String issuer; + private final boolean loginSemantics; + + private String audience; + private String scope; + private String organization; + + TokenExchangeRequest(RequestProcessor processor, String subjectToken, String subjectTokenType, + String domain, String issuer, boolean loginSemantics, String organization) { + this.processor = processor; + this.subjectToken = subjectToken; + this.subjectTokenType = subjectTokenType; + this.domain = domain; + this.issuer = issuer; + this.loginSemantics = loginSemantics; + this.organization = organization; + } + + /** + * Sets the audience (API identifier) to request an access token for. When not set, Auth0 uses + * the default audience configured for the application. + * + * @param audience the audience to request a token for. + * @return this request instance for fluent chaining. + */ + public TokenExchangeRequest withAudience(String audience) { + this.audience = audience; + return this; + } + + /** + * Sets the scope to request for the access token. + * + * @param scope the requested scope. + * @return this request instance for fluent chaining. + */ + public TokenExchangeRequest withScope(String scope) { + this.scope = scope; + return this; + } + + /** + * Sets the organization to associate with the exchange, overriding any client-level default + * configured on the {@link AuthenticationController}. When an organization is set, the returned + * ID token's {@code org_id}/{@code org_name} claim is validated against this value on both the + * {@code loginWith*} and the utility path (whenever the exchange returns an ID token). Because + * an organization claim cannot be trusted without verifying the token that carries it, the + * utility path performs full ID-token verification (signature, issuer, expiry) in this case, so + * an {@link IdentityVerificationException} may be thrown even when only an access token was + * wanted. + * + * @param organization the organization ID or name. + * @return this request instance for fluent chaining. + */ + public TokenExchangeRequest withOrganization(String organization) { + this.organization = organization; + return this; + } + + /** + * Executes the token exchange against Auth0 and returns the resulting tokens. + * + * @return the {@link Tokens} obtained from the exchange, including the granted scope. + * @throws CustomTokenExchangeException if the request fails client-side validation. + * @throws IdentityVerificationException if the exchange fails or, on the {@code loginWith*} + * path, the returned ID token fails verification. + * @throws Auth0Exception if the request to the Auth0 server failed. + */ + public Tokens execute() throws IdentityVerificationException, Auth0Exception { + validate(); + return processor.executeCustomTokenExchange( + subjectToken, subjectTokenType, audience, scope, organization, + domain, issuer, loginSemantics); + } + + private void validate() throws CustomTokenExchangeException { + assertValidTokenFormat(subjectToken); + assertValidTokenTypeUri(subjectTokenType); + } + + private void assertValidTokenFormat(String token) throws CustomTokenExchangeException { + if (token == null || token.trim().isEmpty()) { + throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_FORMAT, + "The subject token must not be empty."); + } + // Reject surrounding whitespace so a stray space/newline can't slip a malformed token + // through (and so a leading space doesn't hide a "Bearer " prefix from the check below). + if (!token.equals(token.trim())) { + throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_FORMAT, + "The subject token must not contain leading or trailing whitespace."); + } + if (token.regionMatches(true, 0, "Bearer ", 0, 7)) { + throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_FORMAT, + "The subject token must not carry a \"Bearer \" prefix."); + } + } + + private void assertValidTokenTypeUri(String tokenType) throws CustomTokenExchangeException { + if (tokenType == null || tokenType.trim().isEmpty()) { + throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_TYPE_URI, + "The subject token type must be a valid URI."); + } + try { + URI uri = new URI(tokenType); + if (!uri.isAbsolute()) { + throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_TYPE_URI, + "The subject token type must be an absolute URI."); + } + } catch (URISyntaxException e) { + throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_TYPE_URI, + "The subject token type must be a valid URI."); + } + } +} diff --git a/src/main/java/com/auth0/Tokens.java b/src/main/java/com/auth0/Tokens.java index cd3951d..fa02682 100644 --- a/src/main/java/com/auth0/Tokens.java +++ b/src/main/java/com/auth0/Tokens.java @@ -22,6 +22,7 @@ public class Tokens implements Serializable { private final String refreshToken; private final String type; private final Long expiresIn; + private final String scope; private final String domain; private final String issuer; @@ -49,11 +50,29 @@ public Tokens(String accessToken, String idToken, String refreshToken, String ty * @param issuer the issuer URL from the ID token */ public Tokens(String accessToken, String idToken, String refreshToken, String type, Long expiresIn, String domain, String issuer) { + this(accessToken, idToken, refreshToken, type, expiresIn, null, domain, issuer); + } + + /** + * Full constructor including the granted scope. + * + * @param accessToken access token for Auth0 API + * @param idToken identity token with user information + * @param refreshToken refresh token that can be used to request new tokens + * without signing in again + * @param type token type + * @param expiresIn token expiration + * @param scope the scope granted for the access token, or null if not provided + * @param domain the Auth0 domain that issued these tokens + * @param issuer the issuer URL from the ID token + */ + public Tokens(String accessToken, String idToken, String refreshToken, String type, Long expiresIn, String scope, String domain, String issuer) { this.accessToken = accessToken; this.idToken = idToken; this.refreshToken = refreshToken; this.type = type; this.expiresIn = expiresIn; + this.scope = scope; this.domain = domain; this.issuer = issuer; } @@ -103,6 +122,15 @@ public Long getExpiresIn() { return expiresIn; } + /** + * Getter for the scope granted for the Access Token. + * + * @return the granted scope, or null if not provided. + */ + public String getScope() { + return scope; + } + /** * Getter for the Auth0 domain that issued these tokens. diff --git a/src/test/java/com/auth0/AuthenticationControllerTest.java b/src/test/java/com/auth0/AuthenticationControllerTest.java index 6af69ac..fed0d2d 100644 --- a/src/test/java/com/auth0/AuthenticationControllerTest.java +++ b/src/test/java/com/auth0/AuthenticationControllerTest.java @@ -11,10 +11,13 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.util.Collections; +import java.util.Map; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.Is.is; import static org.hamcrest.core.IsNull.notNullValue; +import static org.hamcrest.core.StringContains.containsString; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -250,6 +253,345 @@ public void shouldThrowExceptionWhenBuildAuthorizeUrlRedirectUriIsNull() { assertThat(exception.getMessage(), is("redirectUri must not be null")); } + // --- renewAuth Tests --- + + @Test + public void shouldRenewAuthWithDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + RenewAuthRequest mockRenewAuthRequest = mock(RenewAuthRequest.class); + when(mockRequestProcessor.buildRenewAuthRequest("refreshToken", DOMAIN)).thenReturn(mockRenewAuthRequest); + + RenewAuthRequest result = controller.renewAuth("refreshToken", DOMAIN); + + assertThat(result, is(mockRenewAuthRequest)); + verify(mockRequestProcessor).buildRenewAuthRequest("refreshToken", DOMAIN); + } + + @Test + public void shouldRenewAuthWithoutDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + RenewAuthRequest mockRenewAuthRequest = mock(RenewAuthRequest.class); + when(mockRequestProcessor.buildRenewAuthRequest("refreshToken")).thenReturn(mockRenewAuthRequest); + + RenewAuthRequest result = controller.renewAuth("refreshToken"); + + assertThat(result, is(mockRenewAuthRequest)); + verify(mockRequestProcessor).buildRenewAuthRequest("refreshToken"); + } + + @Test + public void shouldThrowExceptionWhenRenewAuthRefreshTokenIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.renewAuth(null, DOMAIN)); + assertThat(exception.getMessage(), is("refreshToken must not be null")); + } + + @Test + public void shouldThrowExceptionWhenRenewAuthDomainIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.renewAuth("refreshToken", (String) null)); + assertThat(exception.getMessage(), is("domain must not be null")); + } + + @Test + public void shouldThrowExceptionWhenNoArgRenewAuthRefreshTokenIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.renewAuth(null)); + assertThat(exception.getMessage(), is("refreshToken must not be null")); + } + + @Test + public void shouldRenewAuthWithRequest() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + RenewAuthRequest mockRenewAuthRequest = mock(RenewAuthRequest.class); + when(mockRequestProcessor.buildRenewAuthRequest("refreshToken", request)).thenReturn(mockRenewAuthRequest); + + RenewAuthRequest result = controller.renewAuth("refreshToken", request); + + assertThat(result, is(mockRenewAuthRequest)); + verify(mockRequestProcessor).buildRenewAuthRequest("refreshToken", request); + } + + @Test + public void shouldThrowExceptionWhenRenewAuthWithRequestRefreshTokenIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.renewAuth((String) null, request)); + assertThat(exception.getMessage(), is("refreshToken must not be null")); + } + + @Test + public void shouldThrowExceptionWhenRenewAuthRequestIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.renewAuth("refreshToken", (HttpServletRequest) null)); + assertThat(exception.getMessage(), is("request must not be null")); + } + + // --- customTokenExchange Tests --- + + @Test + public void shouldCustomTokenExchangeWithDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + TokenExchangeRequest mockRequest = mock(TokenExchangeRequest.class); + when(mockRequestProcessor.buildTokenExchangeRequest("subjectToken", "custom:token", DOMAIN, false)).thenReturn(mockRequest); + + TokenExchangeRequest result = controller.customTokenExchange("subjectToken", "custom:token", DOMAIN); + + assertThat(result, is(mockRequest)); + verify(mockRequestProcessor).buildTokenExchangeRequest("subjectToken", "custom:token", DOMAIN, false); + } + + @Test + public void shouldCustomTokenExchangeWithoutDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + TokenExchangeRequest mockRequest = mock(TokenExchangeRequest.class); + when(mockRequestProcessor.buildTokenExchangeRequest("subjectToken", "custom:token", false)).thenReturn(mockRequest); + + TokenExchangeRequest result = controller.customTokenExchange("subjectToken", "custom:token"); + + assertThat(result, is(mockRequest)); + verify(mockRequestProcessor).buildTokenExchangeRequest("subjectToken", "custom:token", false); + } + + @Test + public void shouldLoginWithCustomTokenExchangeWithDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + TokenExchangeRequest mockRequest = mock(TokenExchangeRequest.class); + when(mockRequestProcessor.buildTokenExchangeRequest("subjectToken", "custom:token", DOMAIN, true)).thenReturn(mockRequest); + + TokenExchangeRequest result = controller.loginWithCustomTokenExchange("subjectToken", "custom:token", DOMAIN); + + assertThat(result, is(mockRequest)); + verify(mockRequestProcessor).buildTokenExchangeRequest("subjectToken", "custom:token", DOMAIN, true); + } + + @Test + public void shouldLoginWithCustomTokenExchangeWithoutDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + TokenExchangeRequest mockRequest = mock(TokenExchangeRequest.class); + when(mockRequestProcessor.buildTokenExchangeRequest("subjectToken", "custom:token", true)).thenReturn(mockRequest); + + TokenExchangeRequest result = controller.loginWithCustomTokenExchange("subjectToken", "custom:token"); + + assertThat(result, is(mockRequest)); + verify(mockRequestProcessor).buildTokenExchangeRequest("subjectToken", "custom:token", true); + } + + @Test + public void shouldThrowExceptionWhenCustomTokenExchangeSubjectTokenIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.customTokenExchange(null, "custom:token", DOMAIN)); + assertThat(exception.getMessage(), is("subjectToken must not be null")); + } + + @Test + public void shouldThrowExceptionWhenCustomTokenExchangeSubjectTokenTypeIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.customTokenExchange("subjectToken", null, DOMAIN)); + assertThat(exception.getMessage(), is("subjectTokenType must not be null")); + } + + @Test + public void shouldThrowExceptionWhenCustomTokenExchangeDomainIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.customTokenExchange("subjectToken", "custom:token", null)); + assertThat(exception.getMessage(), is("domain must not be null")); + } + + @Test + public void shouldThrowExceptionWhenLoginWithCustomTokenExchangeSubjectTokenIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.loginWithCustomTokenExchange(null, "custom:token")); + assertThat(exception.getMessage(), is("subjectToken must not be null")); + } + + // --- backChannelAuthorize Tests --- + + @Test + public void shouldBackChannelAuthorizeWithDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + BackChannelAuthorizeRequest mockRequest = mock(BackChannelAuthorizeRequest.class); + Map loginHint = Collections.singletonMap("format", "iss_sub"); + when(mockRequestProcessor.buildBackChannelAuthorizeRequest("openid profile", "Approve login", loginHint, DOMAIN)) + .thenReturn(mockRequest); + + BackChannelAuthorizeRequest result = controller.backChannelAuthorize("openid profile", "Approve login", loginHint, DOMAIN); + + assertThat(result, is(mockRequest)); + verify(mockRequestProcessor).buildBackChannelAuthorizeRequest("openid profile", "Approve login", loginHint, DOMAIN); + } + + @Test + public void shouldBackChannelAuthorizeWithoutDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + BackChannelAuthorizeRequest mockRequest = mock(BackChannelAuthorizeRequest.class); + Map loginHint = Collections.singletonMap("format", "iss_sub"); + when(mockRequestProcessor.buildBackChannelAuthorizeRequest("openid profile", "Approve login", loginHint)) + .thenReturn(mockRequest); + + BackChannelAuthorizeRequest result = controller.backChannelAuthorize("openid profile", "Approve login", loginHint); + + assertThat(result, is(mockRequest)); + verify(mockRequestProcessor).buildBackChannelAuthorizeRequest("openid profile", "Approve login", loginHint); + } + + @Test + public void shouldThrowExceptionWhenBackChannelAuthorizeScopeIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.backChannelAuthorize(null, "Approve login", Collections.emptyMap(), DOMAIN)); + assertThat(exception.getMessage(), is("scope must not be null")); + } + + @Test + public void shouldThrowExceptionWhenBackChannelAuthorizeBindingMessageIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.backChannelAuthorize("openid profile", null, Collections.emptyMap(), DOMAIN)); + assertThat(exception.getMessage(), is("bindingMessage must not be null")); + } + + @Test + public void shouldThrowExceptionWhenBackChannelAuthorizeLoginHintIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.backChannelAuthorize("openid profile", "Approve login", null, DOMAIN)); + assertThat(exception.getMessage(), is("loginHint must not be null")); + } + + @Test + public void shouldThrowExceptionWhenBackChannelAuthorizeDomainIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.backChannelAuthorize("openid profile", "Approve login", Collections.emptyMap(), null)); + assertThat(exception.getMessage(), is("domain must not be null")); + } + + @Test + public void shouldThrowExceptionWhenNoDomainBackChannelAuthorizeScopeIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.backChannelAuthorize(null, "Approve login", Collections.emptyMap())); + assertThat(exception.getMessage(), is("scope must not be null")); + } + + @Test + public void shouldPropagateIllegalStateWhenBackChannelAuthorizeWithoutDomainUsesResolver() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + Map loginHint = Collections.singletonMap("format", "iss_sub"); + when(mockRequestProcessor.buildBackChannelAuthorizeRequest("openid profile", "Approve login", loginHint)) + .thenThrow(new IllegalStateException("A domain is required when using a DomainResolver")); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> controller.backChannelAuthorize("openid profile", "Approve login", loginHint)); + assertThat(exception.getMessage(), containsString("A domain is required when using a DomainResolver")); + } + + // --- backChannelPoll Tests --- + + @Test + public void shouldBackChannelPollWithDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + BackChannelTokenRequest mockRequest = mock(BackChannelTokenRequest.class); + when(mockRequestProcessor.buildBackChannelTokenRequest("auth-req-123", DOMAIN)).thenReturn(mockRequest); + + BackChannelTokenRequest result = controller.backChannelPoll("auth-req-123", DOMAIN); + + assertThat(result, is(mockRequest)); + verify(mockRequestProcessor).buildBackChannelTokenRequest("auth-req-123", DOMAIN); + } + + @Test + public void shouldBackChannelPollWithoutDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + BackChannelTokenRequest mockRequest = mock(BackChannelTokenRequest.class); + when(mockRequestProcessor.buildBackChannelTokenRequest("auth-req-123")).thenReturn(mockRequest); + + BackChannelTokenRequest result = controller.backChannelPoll("auth-req-123"); + + assertThat(result, is(mockRequest)); + verify(mockRequestProcessor).buildBackChannelTokenRequest("auth-req-123"); + } + + @Test + public void shouldThrowExceptionWhenBackChannelPollAuthReqIdIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.backChannelPoll(null, DOMAIN)); + assertThat(exception.getMessage(), is("authReqId must not be null")); + } + + @Test + public void shouldThrowExceptionWhenBackChannelPollDomainIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.backChannelPoll("auth-req-123", (String) null)); + assertThat(exception.getMessage(), is("domain must not be null")); + } + + @Test + public void shouldThrowExceptionWhenNoDomainBackChannelPollAuthReqIdIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.backChannelPoll(null)); + assertThat(exception.getMessage(), is("authReqId must not be null")); + } + + @Test + public void shouldPropagateIllegalStateWhenBackChannelPollWithoutDomainUsesResolver() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + when(mockRequestProcessor.buildBackChannelTokenRequest("auth-req-123")) + .thenThrow(new IllegalStateException("A domain is required when using a DomainResolver")); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> controller.backChannelPoll("auth-req-123")); + assertThat(exception.getMessage(), containsString("A domain is required when using a DomainResolver")); + } + // --- Logging and Telemetry Tests --- @Test @@ -270,8 +612,6 @@ public void shouldDisableTelemetry() { verify(mockRequestProcessor).doNotSendTelemetry(); } - // --- Exception Propagation --- - @Test public void shouldPropagateIdentityVerificationException() throws IdentityVerificationException { AuthenticationController controller = new AuthenticationController(mockRequestProcessor); diff --git a/src/test/java/com/auth0/BackChannelAuthorizationExceptionTest.java b/src/test/java/com/auth0/BackChannelAuthorizationExceptionTest.java new file mode 100644 index 0000000..e810182 --- /dev/null +++ b/src/test/java/com/auth0/BackChannelAuthorizationExceptionTest.java @@ -0,0 +1,49 @@ +package com.auth0; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.Is.is; + +public class BackChannelAuthorizationExceptionTest { + + @Test + public void shouldIdentifyAuthorizationPending() { + BackChannelAuthorizationException e = new BackChannelAuthorizationException( + BackChannelAuthorizationException.AUTHORIZATION_PENDING, "pending"); + assertThat(e.isAuthorizationPending(), is(true)); + assertThat(e.isSlowDown(), is(false)); + assertThat(e.isExpiredToken(), is(false)); + assertThat(e.isAccessDenied(), is(false)); + assertThat(e.getCode(), is("authorization_pending")); + } + + @Test + public void shouldIdentifySlowDown() { + BackChannelAuthorizationException e = new BackChannelAuthorizationException( + BackChannelAuthorizationException.SLOW_DOWN, "slow down"); + assertThat(e.isSlowDown(), is(true)); + assertThat(e.isAuthorizationPending(), is(false)); + } + + @Test + public void shouldIdentifyExpiredToken() { + BackChannelAuthorizationException e = new BackChannelAuthorizationException( + BackChannelAuthorizationException.EXPIRED_TOKEN, "expired"); + assertThat(e.isExpiredToken(), is(true)); + } + + @Test + public void shouldIdentifyAccessDenied() { + BackChannelAuthorizationException e = new BackChannelAuthorizationException( + BackChannelAuthorizationException.ACCESS_DENIED, "denied"); + assertThat(e.isAccessDenied(), is(true)); + } + + @Test + public void shouldBeIdentityVerificationException() { + BackChannelAuthorizationException e = new BackChannelAuthorizationException( + BackChannelAuthorizationException.ACCESS_DENIED, "denied"); + assertThat(e instanceof IdentityVerificationException, is(true)); + } +} diff --git a/src/test/java/com/auth0/BackChannelAuthorizeRequestTest.java b/src/test/java/com/auth0/BackChannelAuthorizeRequestTest.java new file mode 100644 index 0000000..fdb6b5e --- /dev/null +++ b/src/test/java/com/auth0/BackChannelAuthorizeRequestTest.java @@ -0,0 +1,118 @@ +package com.auth0; + +import com.auth0.client.auth.AuthAPI; +import com.auth0.exception.Auth0Exception; +import com.auth0.json.auth.BackChannelAuthorizeResponse; +import com.auth0.net.Request; +import com.auth0.net.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Collections; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.Is.is; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class BackChannelAuthorizeRequestTest { + + private static final String DOMAIN = "domain.auth0.com"; + private static final String ISSUER = "https://domain.auth0.com/"; + private static final String SCOPE = "openid profile"; + private static final String BINDING_MESSAGE = "Approve login 1234"; + private static final Map LOGIN_HINT = + Collections.singletonMap("format", "iss_sub"); + + @Mock + private AuthAPI mockClient; + @Mock + private Request mockRequest; + @Mock + private Response mockResponse; + @Mock + private BackChannelAuthorizeResponse mockBody; + + @BeforeEach + public void setUp() throws Exception { + MockitoAnnotations.openMocks(this); + when(mockRequest.execute()).thenReturn(mockResponse); + when(mockResponse.getBody()).thenReturn(mockBody); + } + + private BackChannelAuthorizeRequest newRequest() { + return new BackChannelAuthorizeRequest(mockClient, SCOPE, BINDING_MESSAGE, LOGIN_HINT, DOMAIN, ISSUER); + } + + @Test + public void shouldUseThreeArgOverloadWhenNoOptionalsSet() throws Exception { + when(mockClient.authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT)).thenReturn(mockRequest); + + BackChannelAuthorizeResponse result = newRequest().execute(); + + assertSame(mockBody, result); + verify(mockClient).authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT); + verify(mockClient, never()).authorizeBackChannel( + eq(SCOPE), eq(BINDING_MESSAGE), eq(LOGIN_HINT), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyInt()); + } + + @Test + public void shouldUseFiveArgOverloadWhenAudienceSet() throws Exception { + when(mockClient.authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT, "api", null)) + .thenReturn(mockRequest); + + newRequest().withAudience("api").execute(); + + verify(mockClient).authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT, "api", null); + } + + @Test + public void shouldUseFiveArgOverloadWhenRequestedExpirySet() throws Exception { + when(mockClient.authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT, null, 300)) + .thenReturn(mockRequest); + + newRequest().withRequestedExpiry(300).execute(); + + verify(mockClient).authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT, null, 300); + } + + @Test + public void shouldPassBothOptionalsWhenSet() throws Exception { + when(mockClient.authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT, "api", 120)) + .thenReturn(mockRequest); + + newRequest().withAudience("api").withRequestedExpiry(120).execute(); + + verify(mockClient).authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT, "api", 120); + } + + @Test + public void shouldReturnResponseBody() throws Exception { + when(mockClient.authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT)).thenReturn(mockRequest); + when(mockBody.getAuthReqId()).thenReturn("auth-req-123"); + when(mockBody.getInterval()).thenReturn(5); + when(mockBody.getExpiresIn()).thenReturn(600L); + + BackChannelAuthorizeResponse result = newRequest().execute(); + + assertThat(result.getAuthReqId(), is("auth-req-123")); + assertThat(result.getInterval(), is(5)); + assertThat(result.getExpiresIn(), is(600L)); + } + + @Test + public void shouldPropagateAuth0Exception() throws Exception { + when(mockClient.authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT)).thenReturn(mockRequest); + when(mockRequest.execute()).thenThrow(new Auth0Exception("boom")); + + assertThrows(Auth0Exception.class, () -> newRequest().execute()); + } +} diff --git a/src/test/java/com/auth0/BackChannelTokenRequestTest.java b/src/test/java/com/auth0/BackChannelTokenRequestTest.java new file mode 100644 index 0000000..1b44aff --- /dev/null +++ b/src/test/java/com/auth0/BackChannelTokenRequestTest.java @@ -0,0 +1,62 @@ +package com.auth0; + +import com.auth0.exception.Auth0Exception; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class BackChannelTokenRequestTest { + + private static final String DOMAIN = "domain.auth0.com"; + private static final String ISSUER = "https://domain.auth0.com/"; + private static final String AUTH_REQ_ID = "auth-req-123"; + + @Mock + private RequestProcessor mockProcessor; + @Mock + private Tokens mockTokens; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + private BackChannelTokenRequest newRequest() { + return new BackChannelTokenRequest(mockProcessor, AUTH_REQ_ID, DOMAIN, ISSUER); + } + + @Test + public void shouldDelegateToProcessor() throws Exception { + when(mockProcessor.executeBackChannelPoll(AUTH_REQ_ID, DOMAIN, ISSUER)).thenReturn(mockTokens); + + Tokens result = newRequest().execute(); + + assertSame(mockTokens, result); + verify(mockProcessor).executeBackChannelPoll(AUTH_REQ_ID, DOMAIN, ISSUER); + } + + @Test + public void shouldPropagatePendingException() throws Exception { + BackChannelAuthorizationException pending = new BackChannelAuthorizationException( + BackChannelAuthorizationException.AUTHORIZATION_PENDING, "pending"); + when(mockProcessor.executeBackChannelPoll(AUTH_REQ_ID, DOMAIN, ISSUER)).thenThrow(pending); + + BackChannelAuthorizationException thrown = assertThrows( + BackChannelAuthorizationException.class, () -> newRequest().execute()); + org.hamcrest.MatcherAssert.assertThat(thrown.isAuthorizationPending(), org.hamcrest.core.Is.is(true)); + } + + @Test + public void shouldPropagateAuth0Exception() throws Exception { + when(mockProcessor.executeBackChannelPoll(AUTH_REQ_ID, DOMAIN, ISSUER)) + .thenThrow(new Auth0Exception("boom")); + + assertThrows(Auth0Exception.class, () -> newRequest().execute()); + } +} diff --git a/src/test/java/com/auth0/RenewAuthRequestTest.java b/src/test/java/com/auth0/RenewAuthRequestTest.java new file mode 100644 index 0000000..5f6dbf5 --- /dev/null +++ b/src/test/java/com/auth0/RenewAuthRequestTest.java @@ -0,0 +1,103 @@ +package com.auth0; + +import com.auth0.client.auth.AuthAPI; +import com.auth0.exception.Auth0Exception; +import com.auth0.json.auth.TokenHolder; +import com.auth0.net.Response; +import com.auth0.net.TokenRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.Is.is; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class RenewAuthRequestTest { + + private static final String REFRESH_TOKEN = "refreshToken"; + private static final String DOMAIN = "domain.auth0.com"; + private static final String ISSUER = "https://domain.auth0.com/"; + + @Mock + private AuthAPI mockClient; + @Mock + private TokenRequest mockTokenRequest; + @Mock + private Response mockTokenResponse; + @Mock + private TokenHolder mockTokenHolder; + + @BeforeEach + public void setUp() throws Exception { + MockitoAnnotations.openMocks(this); + when(mockClient.renewAuth(REFRESH_TOKEN)).thenReturn(mockTokenRequest); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + } + + @Test + public void shouldNotSetAudienceOrScopeWhenNotProvided() throws Exception { + RenewAuthRequest request = new RenewAuthRequest(mockClient, REFRESH_TOKEN, DOMAIN, ISSUER); + + request.execute(); + + verify(mockTokenRequest, never()).setAudience(org.mockito.ArgumentMatchers.anyString()); + verify(mockTokenRequest, never()).setScope(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + public void shouldSetAudienceWhenProvided() throws Exception { + RenewAuthRequest request = new RenewAuthRequest(mockClient, REFRESH_TOKEN, DOMAIN, ISSUER); + + request.withAudience("https://api.example.com").execute(); + + verify(mockTokenRequest).setAudience("https://api.example.com"); + verify(mockTokenRequest, never()).setScope(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + public void shouldSetScopeWhenProvided() throws Exception { + RenewAuthRequest request = new RenewAuthRequest(mockClient, REFRESH_TOKEN, DOMAIN, ISSUER); + + request.withScope("openid profile").execute(); + + verify(mockTokenRequest).setScope("openid profile"); + verify(mockTokenRequest, never()).setAudience(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + public void shouldMapTokenHolderToTokensIncludingScope() throws Exception { + when(mockTokenHolder.getAccessToken()).thenReturn("newAccessToken"); + when(mockTokenHolder.getIdToken()).thenReturn(null); + when(mockTokenHolder.getRefreshToken()).thenReturn("rotatedRefreshToken"); + when(mockTokenHolder.getTokenType()).thenReturn("Bearer"); + when(mockTokenHolder.getExpiresIn()).thenReturn(86400L); + when(mockTokenHolder.getScope()).thenReturn("openid profile"); + + RenewAuthRequest request = new RenewAuthRequest(mockClient, REFRESH_TOKEN, DOMAIN, ISSUER); + + Tokens tokens = request.withAudience("https://api.example.com").withScope("openid profile").execute(); + + assertThat(tokens.getAccessToken(), is("newAccessToken")); + assertThat(tokens.getRefreshToken(), is("rotatedRefreshToken")); + assertThat(tokens.getType(), is("Bearer")); + assertThat(tokens.getExpiresIn(), is(86400L)); + assertThat(tokens.getScope(), is("openid profile")); + assertThat(tokens.getDomain(), is(DOMAIN)); + assertThat(tokens.getIssuer(), is(ISSUER)); + } + + @Test + public void shouldPropagateAuth0Exception() throws Exception { + when(mockTokenRequest.execute()).thenThrow(Auth0Exception.class); + + RenewAuthRequest request = new RenewAuthRequest(mockClient, REFRESH_TOKEN, DOMAIN, ISSUER); + + assertThrows(Auth0Exception.class, request::execute); + } +} diff --git a/src/test/java/com/auth0/RequestProcessorTest.java b/src/test/java/com/auth0/RequestProcessorTest.java index 0851418..d76610e 100644 --- a/src/test/java/com/auth0/RequestProcessorTest.java +++ b/src/test/java/com/auth0/RequestProcessorTest.java @@ -1,9 +1,15 @@ package com.auth0; import com.auth0.client.auth.AuthAPI; +import com.auth0.exception.APIException; import com.auth0.exception.Auth0Exception; +import com.auth0.json.auth.BackChannelTokenResponse; import com.auth0.json.auth.TokenHolder; import com.auth0.jwk.JwkProvider; +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTCreator; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.net.Request; import com.auth0.net.Response; import com.auth0.net.TokenRequest; import com.auth0.net.client.Auth0HttpClient; @@ -18,6 +24,7 @@ import jakarta.servlet.http.HttpServletRequest; import java.util.Arrays; import java.util.Collections; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -34,6 +41,7 @@ public class RequestProcessorTest { private static final String DOMAIN = "test-domain.auth0.com"; + private static final String ISSUER = "https://test-domain.auth0.com/"; private static final String CLIENT_ID = "testClientId"; private static final String CLIENT_SECRET = "testClientSecret"; private static final String RESPONSE_TYPE_CODE = "code"; @@ -147,6 +155,396 @@ public void shouldCreateClientForDomain() { assertThat(result, is(notNullValue())); } + // --- RenewAuth Tests --- + + @Test + public void shouldBuildRenewAuthRequestForExplicitDomain() { + RequestProcessor processor = createDefaultRequestProcessor(); + RequestProcessor spy = spy(processor); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + RenewAuthRequest result = spy.buildRenewAuthRequest("refreshToken", DOMAIN); + + assertThat(result, is(notNullValue())); + verify(spy).createClientForDomain(DOMAIN); + } + + @Test + public void shouldBuildRenewAuthRequestFromStaticDomain() { + RequestProcessor processor = new RequestProcessor.Builder( + new StaticDomainProvider(DOMAIN), + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET) + .withJwkProvider(mockJwkProvider) + .build(); + RequestProcessor spy = spy(processor); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + RenewAuthRequest result = spy.buildRenewAuthRequest("refreshToken"); + + assertThat(result, is(notNullValue())); + verify(spy).createClientForDomain(DOMAIN); + } + + @Test + public void shouldThrowOnNoArgRenewAuthWhenUsingResolver() { + RequestProcessor processor = createDefaultRequestProcessor(); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> processor.buildRenewAuthRequest("refreshToken")); + assertThat(exception.getMessage(), containsString("A domain is required when using a DomainResolver")); + } + + @Test + public void shouldBuildRenewAuthRequestResolvingDomainFromRequest() { + String resolvedDomain = "resolved-domain.auth0.com"; + when(mockDomainProvider.getDomain(request)).thenReturn(resolvedDomain); + + RequestProcessor processor = createDefaultRequestProcessor(); + RequestProcessor spy = spy(processor); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + RenewAuthRequest result = spy.buildRenewAuthRequest("refreshToken", request); + + assertThat(result, is(notNullValue())); + verify(mockDomainProvider).getDomain(request); + verify(spy).createClientForDomain(resolvedDomain); + } + + // --- Custom Token Exchange Tests --- + + @Test + public void shouldBuildTokenExchangeRequestForExplicitDomain() { + RequestProcessor processor = createDefaultRequestProcessor(); + + TokenExchangeRequest result = processor.buildTokenExchangeRequest("subjectToken", "custom:token", DOMAIN, false); + + assertThat(result, is(notNullValue())); + } + + @Test + public void shouldBuildLoginTokenExchangeRequestForExplicitDomain() { + RequestProcessor processor = createDefaultRequestProcessor(); + + TokenExchangeRequest result = processor.buildTokenExchangeRequest("subjectToken", "custom:token", DOMAIN, true); + + assertThat(result, is(notNullValue())); + } + + @Test + public void shouldBuildTokenExchangeRequestFromStaticDomain() { + RequestProcessor processor = new RequestProcessor.Builder( + new StaticDomainProvider(DOMAIN), + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET) + .withJwkProvider(mockJwkProvider) + .build(); + + TokenExchangeRequest result = processor.buildTokenExchangeRequest("subjectToken", "custom:token", false); + + assertThat(result, is(notNullValue())); + } + + @Test + public void shouldThrowOnNoDomainTokenExchangeWhenUsingResolver() { + RequestProcessor processor = createDefaultRequestProcessor(); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> processor.buildTokenExchangeRequest("subjectToken", "custom:token", false)); + assertThat(exception.getMessage(), containsString("A domain is required when using a DomainResolver")); + } + + @Test + public void shouldExecuteCustomTokenExchangeViaAuthApiAndReturnTokens() throws Exception { + when(mockAuthAPI.exchangeToken("subjectToken", "custom:token")).thenReturn(mockTokenRequest); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenHolder.getIdToken()).thenReturn(null); + when(mockTokenHolder.getAccessToken()).thenReturn("cteAccessToken"); + when(mockTokenHolder.getRefreshToken()).thenReturn("cteRefreshToken"); + when(mockTokenHolder.getTokenType()).thenReturn("Bearer"); + when(mockTokenHolder.getExpiresIn()).thenReturn(3600L); + when(mockTokenHolder.getScope()).thenReturn("openid profile"); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + + Tokens tokens = spy.executeCustomTokenExchange( + "subjectToken", "custom:token", null, null, null, DOMAIN, ISSUER, false); + + assertThat(tokens, is(notNullValue())); + assertThat(tokens.getAccessToken(), is("cteAccessToken")); + assertThat(tokens.getRefreshToken(), is("cteRefreshToken")); + assertThat(tokens.getType(), is("Bearer")); + assertThat(tokens.getExpiresIn(), is(3600L)); + verify(mockAuthAPI).exchangeToken("subjectToken", "custom:token"); + } + + @Test + public void shouldForwardAudienceScopeAndOrganizationOnCustomTokenExchange() throws Exception { + when(mockAuthAPI.exchangeToken("subjectToken", "custom:token")).thenReturn(mockTokenRequest); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenHolder.getIdToken()).thenReturn(null); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + + spy.executeCustomTokenExchange( + "subjectToken", "custom:token", "https://api/", "openid profile", null, + DOMAIN, ISSUER, false); + + verify(mockTokenRequest).setAudience("https://api/"); + verify(mockTokenRequest).setScope("openid profile"); + verify(mockTokenRequest, never()).addParameter(eq("organization"), any()); + } + + @Test + public void shouldPassOrganizationOnCustomTokenExchangeWhenProvided() throws Exception { + when(mockAuthAPI.exchangeToken("subjectToken", "custom:token")).thenReturn(mockTokenRequest); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + // Utility path with an organization set still returns tokens; ID token verification is + // skipped here because the holder has no ID token. + when(mockTokenHolder.getIdToken()).thenReturn(null); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + + spy.executeCustomTokenExchange( + "subjectToken", "custom:token", null, null, "org_123", + DOMAIN, ISSUER, false); + + verify(mockTokenRequest).addParameter("organization", "org_123"); + } + + @Test + public void shouldThrowMissingIdTokenOnLoginCustomTokenExchangeWhenNoIdTokenReturned() throws Exception { + when(mockAuthAPI.exchangeToken("subjectToken", "custom:token")).thenReturn(mockTokenRequest); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenHolder.getIdToken()).thenReturn(null); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + + InvalidRequestException exception = assertThrows( + InvalidRequestException.class, + () -> spy.executeCustomTokenExchange( + "subjectToken", "custom:token", null, null, null, + DOMAIN, ISSUER, true)); + assertThat(exception.getCode(), is("a0.missing_id_token")); + } + + @Test + public void shouldThrowOnLoginCustomTokenExchangeWhenIdTokenVerificationFails() throws Exception { + // Structurally valid JWT with an invalid signature so RS256 verification fails. + String fakeJwt = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3dyb25nLyIsInN1YiI6InVzZXIxMjMiLCJhdWQiOiJ0ZXN0Q2xpZW50SWQiLCJleHAiOjk5OTk5OTk5OTksImlhdCI6MTYwMDAwMDAwMH0.signature"; + when(mockAuthAPI.exchangeToken("subjectToken", "custom:token")).thenReturn(mockTokenRequest); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenHolder.getIdToken()).thenReturn(fakeJwt); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + + IdentityVerificationException exception = assertThrows( + IdentityVerificationException.class, + () -> spy.executeCustomTokenExchange( + "subjectToken", "custom:token", null, "openid", null, + DOMAIN, ISSUER, true)); + assertThat(exception.getCode(), is("a0.invalid_jwt_error")); + } + + @Test + public void shouldReturnVerifiedTokensOnLoginCustomTokenExchangeWhenIdTokenValid() throws Exception { + String idToken = signedIdToken(null); + when(mockAuthAPI.exchangeToken("subjectToken", "custom:token")).thenReturn(mockTokenRequest); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenHolder.getIdToken()).thenReturn(idToken); + when(mockTokenHolder.getAccessToken()).thenReturn("cteAccessToken"); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + + Tokens tokens = spy.executeCustomTokenExchange( + "subjectToken", "custom:token", null, "openid", null, DOMAIN, ISSUER, true); + + assertThat(tokens, is(notNullValue())); + assertThat(tokens.getIdToken(), is(idToken)); + assertThat(tokens.getAccessToken(), is("cteAccessToken")); + } + + @Test + public void shouldReturnTokensOnUtilityCustomTokenExchangeWhenOrgClaimMatches() throws Exception { + String idToken = signedIdToken("org_123"); + when(mockAuthAPI.exchangeToken("subjectToken", "custom:token")).thenReturn(mockTokenRequest); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenHolder.getIdToken()).thenReturn(idToken); + when(mockTokenHolder.getAccessToken()).thenReturn("cteAccessToken"); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + + Tokens tokens = spy.executeCustomTokenExchange( + "subjectToken", "custom:token", null, "openid", "org_123", DOMAIN, ISSUER, false); + + assertThat(tokens, is(notNullValue())); + assertThat(tokens.getAccessToken(), is("cteAccessToken")); + verify(mockTokenRequest).addParameter("organization", "org_123"); + } + + @Test + public void shouldThrowOnUtilityCustomTokenExchangeWhenOrgClaimMismatches() throws Exception { + String idToken = signedIdToken("org_other"); + when(mockAuthAPI.exchangeToken("subjectToken", "custom:token")).thenReturn(mockTokenRequest); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenHolder.getIdToken()).thenReturn(idToken); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + + IdentityVerificationException exception = assertThrows( + IdentityVerificationException.class, + () -> spy.executeCustomTokenExchange( + "subjectToken", "custom:token", null, "openid", "org_123", DOMAIN, ISSUER, false)); + assertThat(exception.getCode(), is("a0.invalid_jwt_error")); + } + + // --- CIBA Backchannel Poll Tests --- + + @Test + public void shouldTranslateAuthorizationPendingIntoBackChannelException() throws Exception { + Request pollRequest = stubPollRequestThatThrows( + apiException("authorization_pending", "User has not yet approved.")); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + when(mockAuthAPI.getBackChannelLoginStatus("auth-req-123", CIBA_GRANT_TYPE)).thenReturn(pollRequest); + + BackChannelAuthorizationException exception = assertThrows( + BackChannelAuthorizationException.class, + () -> spy.executeBackChannelPoll("auth-req-123", DOMAIN, ISSUER)); + assertThat(exception.getCode(), is("authorization_pending")); + assertThat(exception.isAuthorizationPending(), is(true)); + assertThat(exception.isSlowDown(), is(false)); + } + + @Test + public void shouldTranslateSlowDownIntoBackChannelException() throws Exception { + Request pollRequest = stubPollRequestThatThrows( + apiException("slow_down", "Polling too fast.")); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + when(mockAuthAPI.getBackChannelLoginStatus("auth-req-123", CIBA_GRANT_TYPE)).thenReturn(pollRequest); + + BackChannelAuthorizationException exception = assertThrows( + BackChannelAuthorizationException.class, + () -> spy.executeBackChannelPoll("auth-req-123", DOMAIN, ISSUER)); + assertThat(exception.getCode(), is("slow_down")); + assertThat(exception.isSlowDown(), is(true)); + } + + @Test + public void shouldTranslateExpiredTokenIntoBackChannelException() throws Exception { + Request pollRequest = stubPollRequestThatThrows( + apiException("expired_token", "The auth_req_id expired.")); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + when(mockAuthAPI.getBackChannelLoginStatus("auth-req-123", CIBA_GRANT_TYPE)).thenReturn(pollRequest); + + BackChannelAuthorizationException exception = assertThrows( + BackChannelAuthorizationException.class, + () -> spy.executeBackChannelPoll("auth-req-123", DOMAIN, ISSUER)); + assertThat(exception.getCode(), is("expired_token")); + assertThat(exception.isExpiredToken(), is(true)); + } + + @Test + public void shouldTranslateAccessDeniedIntoBackChannelException() throws Exception { + Request pollRequest = stubPollRequestThatThrows( + apiException("access_denied", "The user rejected the request.")); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + when(mockAuthAPI.getBackChannelLoginStatus("auth-req-123", CIBA_GRANT_TYPE)).thenReturn(pollRequest); + + BackChannelAuthorizationException exception = assertThrows( + BackChannelAuthorizationException.class, + () -> spy.executeBackChannelPoll("auth-req-123", DOMAIN, ISSUER)); + assertThat(exception.getCode(), is("access_denied")); + assertThat(exception.isAccessDenied(), is(true)); + } + + @Test + public void shouldThrowMissingIdTokenWhenPollResponseHasNoIdToken() throws Exception { + BackChannelTokenResponse body = mock(BackChannelTokenResponse.class); + when(body.getIdToken()).thenReturn(null); + Request pollRequest = stubPollRequestReturning(body); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + when(mockAuthAPI.getBackChannelLoginStatus("auth-req-123", CIBA_GRANT_TYPE)).thenReturn(pollRequest); + + InvalidRequestException exception = assertThrows( + InvalidRequestException.class, + () -> spy.executeBackChannelPoll("auth-req-123", DOMAIN, ISSUER)); + assertThat(exception.getCode(), is("a0.missing_id_token")); + } + + @Test + public void shouldThrowWhenPollIdTokenVerificationFails() throws Exception { + // Structurally valid JWT with an invalid signature so verification fails. + String fakeJwt = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3dyb25nLyIsInN1YiI6InVzZXIxMjMiLCJhdWQiOiJ0ZXN0Q2xpZW50SWQiLCJleHAiOjk5OTk5OTk5OTksImlhdCI6MTYwMDAwMDAwMH0.signature"; + BackChannelTokenResponse body = mock(BackChannelTokenResponse.class); + when(body.getIdToken()).thenReturn(fakeJwt); + Request pollRequest = stubPollRequestReturning(body); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + when(mockAuthAPI.getBackChannelLoginStatus("auth-req-123", CIBA_GRANT_TYPE)).thenReturn(pollRequest); + + IdentityVerificationException exception = assertThrows( + IdentityVerificationException.class, + () -> spy.executeBackChannelPoll("auth-req-123", DOMAIN, ISSUER)); + assertThat(exception.getCode(), is("a0.invalid_jwt_error")); + } + + @Test + public void shouldReturnVerifiedTokensOnSuccessfulPoll() throws Exception { + String idToken = signedIdToken(null); + BackChannelTokenResponse body = mock(BackChannelTokenResponse.class); + when(body.getIdToken()).thenReturn(idToken); + when(body.getAccessToken()).thenReturn("cibaAccessToken"); + when(body.getExpiresIn()).thenReturn(86400L); + when(body.getScope()).thenReturn("openid profile"); + Request pollRequest = stubPollRequestReturning(body); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + when(mockAuthAPI.getBackChannelLoginStatus("auth-req-123", CIBA_GRANT_TYPE)).thenReturn(pollRequest); + + Tokens tokens = spy.executeBackChannelPoll("auth-req-123", DOMAIN, ISSUER); + + assertThat(tokens, is(notNullValue())); + assertThat(tokens.getIdToken(), is(idToken)); + assertThat(tokens.getAccessToken(), is("cibaAccessToken")); + assertThat(tokens.getType(), is("Bearer")); + assertThat(tokens.getExpiresIn(), is(86400L)); + assertThat(tokens.getScope(), is("openid profile")); + // CIBA never returns a refresh token. + assertThat(tokens.getRefreshToken(), is(nullValue())); + } + // --- Logging and Telemetry Tests --- @Test @@ -909,6 +1307,48 @@ public void shouldFallbackToDomainProviderWhenSignedCookieTampered() throws Exce // --- Helper Methods --- + private static final String CIBA_GRANT_TYPE = "urn:openid:params:grant-type:ciba"; + + // Builds an APIException carrying the given OAuth error code and description, matching the + // wire shape Auth0 returns for a failed CIBA poll (400 with error/error_description). + private APIException apiException(String error, String description) { + Map body = new HashMap<>(); + body.put("error", error); + body.put("error_description", description); + return new APIException(body, 400); + } + + @SuppressWarnings("unchecked") + private Request stubPollRequestThatThrows(APIException e) throws Exception { + Request pollRequest = mock(Request.class); + when(pollRequest.execute()).thenThrow(e); + return pollRequest; + } + + @SuppressWarnings("unchecked") + private Request stubPollRequestReturning(BackChannelTokenResponse body) throws Exception { + Request pollRequest = mock(Request.class); + Response pollResponse = mock(Response.class); + when(pollRequest.execute()).thenReturn(pollResponse); + when(pollResponse.getBody()).thenReturn(body); + return pollRequest; + } + + // Builds an HS256 ID token signed with CLIENT_SECRET so the RS256/HS256-aware verifier accepts + // it (HS256 path uses the client secret). Pass a non-null orgId to include an org_id claim. + private String signedIdToken(String orgId) { + JWTCreator.Builder builder = JWT.create() + .withIssuer(ISSUER) + .withSubject("user123") + .withAudience(CLIENT_ID) + .withIssuedAt(new Date(System.currentTimeMillis() - 1000)) + .withExpiresAt(new Date(System.currentTimeMillis() + 3600_000)); + if (orgId != null) { + builder.withClaim("org_id", orgId); + } + return builder.sign(Algorithm.HMAC256(CLIENT_SECRET)); + } + private RequestProcessor createDefaultRequestProcessor() { return new RequestProcessor.Builder( mockDomainProvider, diff --git a/src/test/java/com/auth0/TokenExchangeRequestTest.java b/src/test/java/com/auth0/TokenExchangeRequestTest.java new file mode 100644 index 0000000..0408c8b --- /dev/null +++ b/src/test/java/com/auth0/TokenExchangeRequestTest.java @@ -0,0 +1,170 @@ +package com.auth0; + +import com.auth0.exception.Auth0Exception; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.Is.is; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +public class TokenExchangeRequestTest { + + private static final String DOMAIN = "test-domain.auth0.com"; + private static final String ISSUER = "https://test-domain.auth0.com/"; + private static final String SUBJECT_TOKEN = "ext-token"; + private static final String SUBJECT_TOKEN_TYPE = "custom:legacy-token"; + + @Mock + private RequestProcessor mockProcessor; + @Mock + private Tokens mockTokens; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + private TokenExchangeRequest newRequest(String subjectToken, String subjectTokenType, boolean loginSemantics) { + return new TokenExchangeRequest(mockProcessor, subjectToken, subjectTokenType, DOMAIN, ISSUER, loginSemantics, null); + } + + @Test + public void shouldDelegateToProcessorWithConfiguredParameters() throws Exception { + when(mockProcessor.executeCustomTokenExchange( + eq(SUBJECT_TOKEN), eq(SUBJECT_TOKEN_TYPE), eq("api"), eq("read:foo"), eq("org_1"), + eq(DOMAIN), eq(ISSUER), eq(false))).thenReturn(mockTokens); + + Tokens result = newRequest(SUBJECT_TOKEN, SUBJECT_TOKEN_TYPE, false) + .withAudience("api") + .withScope("read:foo") + .withOrganization("org_1") + .execute(); + + assertSame(mockTokens, result); + verify(mockProcessor).executeCustomTokenExchange( + SUBJECT_TOKEN, SUBJECT_TOKEN_TYPE, "api", "read:foo", "org_1", DOMAIN, ISSUER, false); + } + + @Test + public void shouldPassNullOptionalParametersWhenNotSet() throws Exception { + when(mockProcessor.executeCustomTokenExchange( + any(), any(), isNull(), isNull(), isNull(), any(), any(), eq(true))).thenReturn(mockTokens); + + newRequest(SUBJECT_TOKEN, SUBJECT_TOKEN_TYPE, true).execute(); + + verify(mockProcessor).executeCustomTokenExchange( + SUBJECT_TOKEN, SUBJECT_TOKEN_TYPE, null, null, null, DOMAIN, ISSUER, true); + } + + @Test + public void shouldUseClientLevelOrganizationDefault() throws Exception { + TokenExchangeRequest request = new TokenExchangeRequest( + mockProcessor, SUBJECT_TOKEN, SUBJECT_TOKEN_TYPE, DOMAIN, ISSUER, true, "org_default"); + when(mockProcessor.executeCustomTokenExchange( + any(), any(), any(), any(), eq("org_default"), any(), any(), anyBoolean())).thenReturn(mockTokens); + + request.execute(); + + verify(mockProcessor).executeCustomTokenExchange( + SUBJECT_TOKEN, SUBJECT_TOKEN_TYPE, null, null, "org_default", DOMAIN, ISSUER, true); + } + + @Test + public void shouldOverrideClientLevelOrganization() throws Exception { + TokenExchangeRequest request = new TokenExchangeRequest( + mockProcessor, SUBJECT_TOKEN, SUBJECT_TOKEN_TYPE, DOMAIN, ISSUER, true, "org_default"); + when(mockProcessor.executeCustomTokenExchange( + any(), any(), any(), any(), eq("org_override"), any(), any(), anyBoolean())).thenReturn(mockTokens); + + request.withOrganization("org_override").execute(); + + verify(mockProcessor).executeCustomTokenExchange( + SUBJECT_TOKEN, SUBJECT_TOKEN_TYPE, null, null, "org_override", DOMAIN, ISSUER, true); + } + + @Test + public void shouldThrowOnEmptySubjectToken() { + CustomTokenExchangeException exception = assertThrows( + CustomTokenExchangeException.class, + () -> newRequest(" ", SUBJECT_TOKEN_TYPE, false).execute()); + assertThat(exception.isInvalidTokenFormat(), is(true)); + } + + @Test + public void shouldThrowOnBearerPrefixedSubjectToken() { + CustomTokenExchangeException exception = assertThrows( + CustomTokenExchangeException.class, + () -> newRequest("Bearer ext-token", SUBJECT_TOKEN_TYPE, false).execute()); + assertThat(exception.isInvalidTokenFormat(), is(true)); + } + + @Test + public void shouldThrowOnSubjectTokenWithSurroundingWhitespace() { + CustomTokenExchangeException exception = assertThrows( + CustomTokenExchangeException.class, + () -> newRequest(" ext-token\n", SUBJECT_TOKEN_TYPE, false).execute()); + assertThat(exception.isInvalidTokenFormat(), is(true)); + } + + @Test + public void shouldThrowOnSubjectTokenWithLeadingSpaceBeforeBearerPrefix() { + CustomTokenExchangeException exception = assertThrows( + CustomTokenExchangeException.class, + () -> newRequest(" Bearer ext-token", SUBJECT_TOKEN_TYPE, false).execute()); + assertThat(exception.isInvalidTokenFormat(), is(true)); + } + + @Test + public void shouldThrowOnNonUriSubjectTokenType() { + CustomTokenExchangeException exception = assertThrows( + CustomTokenExchangeException.class, + () -> newRequest(SUBJECT_TOKEN, "not a uri", false).execute()); + assertThat(exception.isInvalidTokenTypeUri(), is(true)); + } + + @Test + public void shouldThrowOnRelativeUriSubjectTokenType() { + CustomTokenExchangeException exception = assertThrows( + CustomTokenExchangeException.class, + () -> newRequest(SUBJECT_TOKEN, "legacy-token", false).execute()); + assertThat(exception.isInvalidTokenTypeUri(), is(true)); + } + + @Test + public void shouldAcceptCustomSchemeSubjectTokenType() throws Exception { + when(mockProcessor.executeCustomTokenExchange(any(), any(), any(), any(), any(), any(), any(), anyBoolean())) + .thenReturn(mockTokens); + + newRequest(SUBJECT_TOKEN, "urn:partner:session", false).execute(); + + verify(mockProcessor).executeCustomTokenExchange( + SUBJECT_TOKEN, "urn:partner:session", null, null, null, DOMAIN, ISSUER, false); + } + + @Test + public void shouldNotValidateBeforeReservedNamespaceSubjectTokenType() throws Exception { + // Reserved urn:ietf:* namespaces are accepted client-side; the server enforces rejection. + when(mockProcessor.executeCustomTokenExchange(any(), any(), any(), any(), any(), any(), any(), anyBoolean())) + .thenReturn(mockTokens); + + newRequest(SUBJECT_TOKEN, "urn:ietf:params:oauth:token-type:id_token", false).execute(); + + verify(mockProcessor).executeCustomTokenExchange( + SUBJECT_TOKEN, "urn:ietf:params:oauth:token-type:id_token", null, null, null, DOMAIN, ISSUER, false); + } + + @Test + public void shouldPropagateAuth0Exception() throws Exception { + when(mockProcessor.executeCustomTokenExchange(any(), any(), any(), any(), any(), any(), any(), anyBoolean())) + .thenThrow(new Auth0Exception("boom")); + + assertThrows(Auth0Exception.class, + () -> newRequest(SUBJECT_TOKEN, SUBJECT_TOKEN_TYPE, false).execute()); + } +} diff --git a/src/test/java/com/auth0/TokensTest.java b/src/test/java/com/auth0/TokensTest.java index c82a0c1..6bcc3d2 100644 --- a/src/test/java/com/auth0/TokensTest.java +++ b/src/test/java/com/auth0/TokensTest.java @@ -31,4 +31,31 @@ public void shouldReturnMissingTokens() { assertThat(tokens.getDomain(), is(nullValue())); assertThat(tokens.getIssuer(), is(nullValue())); } + + @Test + public void shouldHaveNullScopeFromFiveArgConstructor() { + Tokens tokens = new Tokens("accessToken", "idToken", "refreshToken", "bearer", 360000L); + assertThat(tokens.getScope(), is(nullValue())); + } + + @Test + public void shouldHaveNullScopeFromSevenArgConstructor() { + Tokens tokens = new Tokens("accessToken", "idToken", "refreshToken", "bearer", 360000L, "domain.auth0.com", "https://domain.auth0.com/"); + assertThat(tokens.getScope(), is(nullValue())); + assertThat(tokens.getDomain(), is("domain.auth0.com")); + assertThat(tokens.getIssuer(), is("https://domain.auth0.com/")); + } + + @Test + public void shouldReturnScopeFromEightArgConstructor() { + Tokens tokens = new Tokens("accessToken", "idToken", "refreshToken", "bearer", 360000L, "openid profile", "domain.auth0.com", "https://domain.auth0.com/"); + assertThat(tokens.getAccessToken(), is("accessToken")); + assertThat(tokens.getIdToken(), is("idToken")); + assertThat(tokens.getRefreshToken(), is("refreshToken")); + assertThat(tokens.getType(), is("bearer")); + assertThat(tokens.getExpiresIn(), is(360000L)); + assertThat(tokens.getScope(), is("openid profile")); + assertThat(tokens.getDomain(), is("domain.auth0.com")); + assertThat(tokens.getIssuer(), is("https://domain.auth0.com/")); + } } From 337bc85ab8106849a3b57a48e7026ad4d281147f Mon Sep 17 00:00:00 2001 From: Tanya Sinha Date: Wed, 15 Jul 2026 13:24:33 +0530 Subject: [PATCH 13/14] feat: update Examples and readme (#256) --- CHANGELOG.md | 12 ++++ EXAMPLES.md | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 8 +++ 3 files changed, 173 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e92911c..4265ce1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Change Log +## [2.0.0-beta.1](https://github.com/auth0/auth0-java-mvc-common/tree/2.0.0-beta.1) (2026-07-15) +[Full Changelog](https://github.com/auth0/auth0-java-mvc-common/compare/2.0.0-beta.0...2.0.0-beta.1) + +**Added** +- Refresh Token Grant (MRRT): `AuthenticationController.renewAuth(...)` exchanges a refresh token for a new set of tokens, supporting Multi-Resource Refresh Token flows via `withAudience()`/`withScope()` [\#242](https://github.com/auth0/auth0-java-mvc-common/pull/242) ([tanya732](https://github.com/tanya732)) +- Custom Token Exchange (CTE): `AuthenticationController.customTokenExchange(...)` and `loginWithCustomTokenExchange(...)` exchange an external `subject_token` for Auth0 tokens (RFC 8693) [\#253](https://github.com/auth0/auth0-java-mvc-common/pull/253) ([tanya732](https://github.com/tanya732)) +- Client-Initiated Backchannel Authentication (CIBA): `AuthenticationController.backChannelAuthorize(...)` and `backChannelPoll(...)` support the decoupled, app-owned polling flow, with `BackChannelAuthorizationException` typed helpers (`isAuthorizationPending()`, `isSlowDown()`, `isExpiredToken()`, `isAccessDenied()`) [\#254](https://github.com/auth0/auth0-java-mvc-common/pull/254) ([tanya732](https://github.com/tanya732)) + +> All three flows are stateless and support Multiple Custom Domains (MCD) via domain-qualified overloads. Merged via [\#255](https://github.com/auth0/auth0-java-mvc-common/pull/255). + +--- + ## [2.0.0-beta.0](https://github.com/auth0/auth0-java-mvc-common/tree/2.0.0-beta.0) (2026-05-29) This is the first beta release of the v2 major version. See the [Migration Guide](MIGRATION_GUIDE.md) for full upgrade instructions. diff --git a/EXAMPLES.md b/EXAMPLES.md index 6b56503..c2645a8 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -3,6 +3,9 @@ - [Including additional authorization parameters](#including-additional-authorization-parameters) - [Organizations](#organizations) - [Multiple Custom Domains (MCD) Support](#multiple-custom-domains-support) +- [Refresh Token Grant (MRRT)](#refresh-token-grant-mrrt) +- [Custom Token Exchange (CTE)](#custom-token-exchange-cte) +- [Client-Initiated Backchannel Authentication (CIBA)](#client-initiated-backchannel-authentication-ciba) - [Allowing clock skew for token validation](#allow-a-clock-skew-for-token-validation) - [Changing the OAuth response_type](#changing-the-oauth-response_type) - [HTTP logging](#http-logging) @@ -200,6 +203,156 @@ When using MCD, your application must be deployed behind a secure edge or revers Without a trusted proxy layer to validate these headers, an attacker can manipulate the domain resolution process. This can result in malicious redirects, where users are sent to unauthorized or fraudulent endpoints during the login and logout flows. +## Refresh Token Grant (MRRT) + +Exchange a refresh token for a fresh set of tokens using Auth0's [refresh token grant](https://auth0.com/docs/secure/tokens/refresh-tokens). This supports [Multi-Resource Refresh Token (MRRT)](https://auth0.com/docs/secure/tokens/refresh-tokens/multi-resource-refresh-token) flows, where a single refresh token can obtain access tokens for multiple APIs by varying the requested `audience` and `scope`. + +The library remains stateless: your application owns storage of the refresh token, caching of the resulting access tokens, and any concurrency control around refresh-token rotation. + +```java +Tokens tokens = controller.renewAuth("YOUR-REFRESH-TOKEN") + .withAudience("https://my-api.example.com") + .withScope("openid profile read:messages") + .execute(); + +String accessToken = tokens.getAccessToken(); +// When refresh-token rotation is enabled, a new refresh token is returned and supersedes the +// one used here — persist it. +String rotatedRefreshToken = tokens.getRefreshToken(); +``` + +> **Note:** If the requested `audience` is not permitted by the application's MRRT policy, Auth0 does not error — it returns a token for the default audience instead. Always verify the `aud` claim of the returned access token. + +The refresh-token grant does not return an ID token, so `tokens.getIdToken()` is typically `null`. + +### Using MRRT with Multiple Custom Domains + +When using a `DomainResolver`, pass the domain explicitly so the grant targets the correct tenant. This is required because a refresh can occur outside of an HTTP request: + +```java +Tokens tokens = controller.renewAuth("YOUR-REFRESH-TOKEN", "acme.auth0.com") + .withAudience("https://my-api.example.com") + .execute(); +``` + +Alternatively, pass the `HttpServletRequest` to let the resolver derive the domain: + +```java +Tokens tokens = controller.renewAuth("YOUR-REFRESH-TOKEN", request).execute(); +``` + +## Custom Token Exchange (CTE) + +[Custom Token Exchange](https://auth0.com/docs/authenticate/custom-token-exchange) (RFC 8693) exchanges an external `subject_token` for a set of Auth0 tokens. There are two variants: + +- **`loginWithCustomTokenExchange(...)`** — applies login semantics and **always verifies** the returned ID token. Use this to establish an authenticated session. +- **`customTokenExchange(...)`** — a utility exchange that returns the tokens without verification, suitable for obtaining tokens for a downstream API. + +```java +// Login semantics: the returned ID token is verified. +Tokens tokens = controller.loginWithCustomTokenExchange("EXTERNAL-SUBJECT-TOKEN", "urn:acme:legacy-token") + .withAudience("https://my-api.example.com") + .withScope("openid profile") + .execute(); + +// Utility exchange: tokens are returned without ID token verification. +Tokens apiTokens = controller.customTokenExchange("EXTERNAL-SUBJECT-TOKEN", "urn:acme:legacy-token") + .withAudience("https://my-api.example.com") + .execute(); +``` + +The `subjectTokenType` is a customer-defined URI describing the external token. Configure a matching [Custom Token Exchange profile](https://auth0.com/docs/authenticate/custom-token-exchange) in the Auth0 Dashboard. + +### Organizations with Custom Token Exchange + +When an organization is configured via `withOrganization(...)`, the library validates the `org_id`/`org_name` claim. Because that claim lives in the ID token, the ID token is fully verified on either variant whenever an organization is in play: + +```java +Tokens tokens = controller.loginWithCustomTokenExchange("EXTERNAL-SUBJECT-TOKEN", "urn:acme:legacy-token") + .withOrganization("org_123") + .execute(); +``` + +### Using CTE with Multiple Custom Domains + +When using a `DomainResolver`, pass the domain explicitly. A token exchange can occur outside of an HTTP request, where the domain cannot otherwise be resolved: + +```java +Tokens tokens = controller.loginWithCustomTokenExchange("EXTERNAL-SUBJECT-TOKEN", "urn:acme:legacy-token", "acme.auth0.com") + .execute(); +``` + +## Client-Initiated Backchannel Authentication (CIBA) + +[CIBA](https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-initiated-backchannel-authentication-flow) is a decoupled flow: the application initiates authentication and the user approves it out-of-band on a separate device (e.g., a push notification to their phone). It is a **two-step** flow: + +1. **Initiate** (`backChannelAuthorize`) — ask Auth0 to authenticate the user; receive an `auth_req_id`, a polling `interval`, and an `expires_in`. +2. **Poll** (`backChannelPoll`) — poll the token endpoint until the user approves (yielding verified `Tokens`) or a terminal error occurs. + +The library is deliberately stateless and **the application owns the polling loop** — this keeps it safe for horizontally-scaled deployments. Honor the `interval` returned by the initiate step, and stop on terminal errors. + +### Step 1: Initiate + +The `login_hint` identifies the user. Auth0 expects the `iss_sub` shape: + +```java +Map loginHint = new HashMap<>(); +loginHint.put("format", "iss_sub"); +loginHint.put("iss", "https://YOUR-AUTH0-DOMAIN/"); +loginHint.put("sub", "auth0|abc123"); + +BackChannelAuthorizeResponse authorize = controller + .backChannelAuthorize("openid profile", "Approve login request 1234", loginHint) + .withAudience("https://my-api.example.com") // optional + .withRequestedExpiry(300) // optional, seconds + .execute(); + +String authReqId = authorize.getAuthReqId(); +Integer interval = authorize.getInterval(); // seconds between polls +Long expiresIn = authorize.getExpiresIn(); // seconds until auth_req_id expires +``` + +### Step 2: Poll + +Poll no more frequently than `interval`. Use the typed helpers on `BackChannelAuthorizationException` to drive the loop — `authorization_pending` and `slow_down` are non-terminal (keep polling), while `expired_token` and `access_denied` are terminal (stop): + +```java +try { + Tokens tokens = controller.backChannelPoll(authReqId).execute(); + // Success — the ID token has been verified. + String idToken = tokens.getIdToken(); +} catch (BackChannelAuthorizationException e) { + if (e.isAuthorizationPending()) { + // User has not approved yet — wait `interval` seconds and poll again. + } else if (e.isSlowDown()) { + // Polling too fast — increase the interval (commonly by 5 seconds) and poll again. + } else if (e.isExpiredToken()) { + // Terminal — the auth_req_id expired. Start over with backChannelAuthorize. + } else if (e.isAccessDenied()) { + // Terminal — the user rejected the request. + } +} catch (IdentityVerificationException e) { + // The returned ID token failed verification. +} +``` + +> **Note:** CIBA returns no refresh token, and the token type is reported as `Bearer`. `tokens.getRefreshToken()` is `null`. + +### Using CIBA with Multiple Custom Domains + +The `auth_req_id` is bound to the domain it was issued for, so the poll **must** target that same domain. When using a `DomainResolver`, pass the domain explicitly on both steps — polling commonly happens outside the initiating HTTP request, so store the domain alongside the `auth_req_id`: + +```java +BackChannelAuthorizeResponse authorize = controller + .backChannelAuthorize("openid profile", "Approve login", loginHint, "acme.auth0.com") + .execute(); + +// ...later, on the poll timer: +Tokens tokens = controller.backChannelPoll(authReqId, "acme.auth0.com").execute(); +``` + +The no-domain overloads (`backChannelAuthorize(...)` / `backChannelPoll(...)`) are only valid when the controller is configured with a fixed domain; calling them on a `DomainResolver`-backed controller throws `IllegalStateException`. + ## Allow a clock skew for token validation During the authentication flow, the ID token is verified and validated to ensure it is secure. Time-based claims such as the time the token was issued at and the token's expiration are verified to ensure the token is valid. diff --git a/README.md b/README.md index d746375..c720703 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,14 @@ AuthenticationController controller = AuthenticationController The library handles storing and retrieving the resolved domain throughout the authentication flow. The returned `Tokens` object includes `getDomain()` and `getIssuer()` for tenant-specific session management. See [EXAMPLES.md](./EXAMPLES.md#multiple-custom-domains-support) for more details. +### Additional authentication flows + +Beyond the interactive login flow above, the `AuthenticationController` supports: + +- **Refresh Token Grant (MRRT)** — exchange a refresh token for fresh tokens, including Multi-Resource Refresh Token flows that target multiple APIs. See [EXAMPLES.md](./EXAMPLES.md#refresh-token-grant-mrrt). +- **Custom Token Exchange (CTE)** — exchange an external `subject_token` for Auth0 tokens (RFC 8693). See [EXAMPLES.md](./EXAMPLES.md#custom-token-exchange-cte). +- **Client-Initiated Backchannel Authentication (CIBA)** — a decoupled flow where the user approves authentication out-of-band on a separate device. See [EXAMPLES.md](./EXAMPLES.md#client-initiated-backchannel-authentication-ciba). + ## API Reference - [JavaDocs](https://javadoc.io/doc/com.auth0/mvc-auth-commons) From 611b3a5945e0015e44a632183749d07dac3eaaa7 Mon Sep 17 00:00:00 2001 From: Tanya Sinha Date: Wed, 15 Jul 2026 14:19:58 +0530 Subject: [PATCH 14/14] Release 2.0.0 beta.1 (#257) --- .version | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.version b/.version index 60b5ccb..952cca7 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -2.0.0-beta.0 \ No newline at end of file +2.0.0-beta.1 \ No newline at end of file diff --git a/README.md b/README.md index c720703..9733e47 100644 --- a/README.md +++ b/README.md @@ -42,14 +42,14 @@ Add the dependency via Maven: com.auth0 mvc-auth-commons - 2.0.0-beta.0 + 2.0.0-beta.1 ``` or Gradle: ```gradle -implementation 'com.auth0:mvc-auth-commons:2.0.0-beta.0' +implementation 'com.auth0:mvc-auth-commons:2.0.0-beta.1' ``` ### Configure Auth0