diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index c147ee8..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", "main", "v1"] + branches: ["master"] jobs: gradle: @@ -14,13 +15,13 @@ jobs: - uses: actions/setup-java@v5 with: distribution: temurin - java-version: 8 + java-version: 17 - 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 + run: ./gradlew assemble check jacocoTestReport --continue --console=plain - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 with: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4e038cd..fd87aef 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" ] - pull_request: branches: [ "master" ] + pull_request: + branches: [ "master", "v2" ] 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: 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..b4f4de6 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: 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: 17.0.19-tem secrets: ossr-username: ${{ secrets.OSSR_USERNAME }} ossr-token: ${{ secrets.OSSR_TOKEN }} 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/.github/workflows/sca_scan.yml b/.github/workflows/sca_scan.yml index 86239c0..01717f7 100644 --- a/.github/workflows/sca_scan.yml +++ b/.github/workflows/sca_scan.yml @@ -4,12 +4,12 @@ on: push: branches: ["master"] pull_request: - branches: ["master"] + branches: ["master", "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: "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 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) 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) 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 diff --git a/build.gradle b/build.gradle index 460be1f..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,89 +15,36 @@ 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 = [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 +56,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..c93576d 100644 --- a/gradle/maven-publish.gradle +++ b/gradle/maven-publish.gradle @@ -18,8 +18,11 @@ 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") + 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 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..62cee5a 100644 --- a/src/main/java/com/auth0/AuthenticationController.java +++ b/src/main/java/com/auth0/AuthenticationController.java @@ -1,14 +1,12 @@ 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.auth0.net.client.Auth0HttpClient; 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 +31,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 @@ -80,12 +74,12 @@ 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; private String organization; private String invitation; - private HttpOptions httpOptions; private String cookiePath; private DomainResolver domainResolver; @@ -150,21 +144,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 +157,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 +169,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 +182,40 @@ 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 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. * * @param clockSkew the clock-skew to use for ID Token verification, in seconds. @@ -221,8 +228,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 +241,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 +253,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 +265,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 +278,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 +290,23 @@ 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); + } + if (httpClient != null) { + builder.withHttpClient(httpClient); + } + + return new AuthenticationController(builder.build()); } private void validateDomainConfiguration() { @@ -318,39 +317,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 +326,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 +337,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 +361,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..abb2194 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, state, 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..c1e9512 100644 --- a/src/main/java/com/auth0/RequestProcessor.java +++ b/src/main/java/com/auth0/RequestProcessor.java @@ -1,23 +1,35 @@ 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.Auth0HttpClient; +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 +46,34 @@ 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; + private Auth0HttpClient httpClient; - // 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 Auth0HttpClient httpClient; private boolean useLegacySameSiteCookie = true; private Integer clockSkew; private Integer authenticationMaxAge; @@ -75,15 +84,21 @@ 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; + } + + Builder withHttpClient(Auth0HttpClient httpClient) { + this.httpClient = httpClient; + return this; } public Builder withClockSkew(Integer clockSkew) { @@ -117,27 +132,23 @@ 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, httpClient, 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, Auth0HttpClient httpClient, 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.httpClient = httpClient; this.useLegacySameSiteCookie = useLegacySameSiteCookie; - - // Store individual configuration values instead of pre-built verifyOptions this.clockSkew = clockSkew; this.authenticationMaxAge = authenticationMaxAge; this.organization = organization; @@ -153,67 +164,41 @@ 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; - - 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); - } - - return client; - } - - void setupTelemetry(AuthAPI client) { - Telemetry telemetry = new Telemetry("auth0-java-mvc-common", obtainPackageVersion()); - client.setTelemetry(telemetry); + return AuthAPI.newBuilder(domain, clientId, clientSecret) + .withHttpClient(getHttpClient()) + .build(); } - @VisibleForTesting - String obtainPackageVersion() { - return getClass().getPackage().getImplementationVersion(); + 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. + * 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 +211,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 +221,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); } @@ -278,7 +254,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) { @@ -288,45 +264,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, String state) 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, state); 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 +291,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); + } + + 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); } - return requestOptions; + // 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 +380,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 +407,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 +439,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 +482,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/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/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..4e3a40c 100644 --- a/src/main/java/com/auth0/TransientCookieStore.java +++ b/src/main/java/com/auth0/TransientCookieStore.java @@ -2,15 +2,18 @@ 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; /** - * 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,87 +22,131 @@ 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); } /** - * 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..6af69ac 100644 --- a/src/test/java/com/auth0/AuthenticationControllerTest.java +++ b/src/test/java/com/auth0/AuthenticationControllerTest.java @@ -1,7 +1,7 @@ package com.auth0; -import com.auth0.client.HttpOptions; 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; @@ -9,8 +9,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,10 +30,10 @@ public class AuthenticationControllerTest { @Mock private JwkProvider mockJwkProvider; @Mock - private HttpOptions mockHttpOptions; - @Mock private DomainResolver mockDomainResolver; @Mock + private Auth0HttpClient mockHttpClient; + @Mock private Tokens mockTokens; private HttpServletRequest request; @@ -41,7 +41,7 @@ public class AuthenticationControllerTest { @BeforeEach public void setUp() { - MockitoAnnotations.initMocks(this); + MockitoAnnotations.openMocks(this); request = new MockHttpServletRequest(); response = new MockHttpServletResponse(); } @@ -85,12 +85,12 @@ 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) .withOrganization("org_123") .withInvitation("inv_456") - .withHttpOptions(mockHttpOptions) .withCookiePath("/custom") .build(); @@ -135,11 +135,11 @@ 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)); assertThrows(NullPointerException.class, () -> builder.withInvitation(null)); - assertThrows(NullPointerException.class, () -> builder.withHttpOptions(null)); assertThrows(NullPointerException.class, () -> builder.withCookiePath(null)); } @@ -326,15 +326,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) @@ -435,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/AuthorizeUrlTest.java b/src/test/java/com/auth0/AuthorizeUrlTest.java index 5818265..5ccea63 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,46 +80,48 @@ 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") + .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("com.auth0.nonce=asdfghjkl; HttpOnly; Max-Age=600; SameSite=None; Secure")); - assertThat(headers, hasItem("_com.auth0.nonce=asdfghjkl; HttpOnly; Max-Age=600")); + // 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, request, response, "https://redirect.to/me", "id_token token") + 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("com.auth0.nonce=asdfghjkl; HttpOnly; Max-Age=600; SameSite=None; Secure")); + assertThat(headers, hasItem(containsString("com.auth0.nonce.stateVal=asdfghjkl"))); + assertThat(headers, not(hasItem(containsString("_com.auth0.nonce")))); } @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(containsString("com.auth0.state.asdfghjkl=asdfghjkl"))); + assertThat(headers, hasItem(containsString("_com.auth0.state.asdfghjkl=asdfghjkl"))); } @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 +129,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(containsString("com.auth0.state.asdfghjkl=asdfghjkl"))); } @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 +142,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(allOf(containsString("com.auth0.state.asdfghjkl=asdfghjkl"), containsString("Secure"), containsString("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 +155,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(allOf(containsString("com.auth0.state.asdfghjkl=asdfghjkl"), containsString("Secure"), containsString("SameSite=None")))); + assertThat(headers, hasItem(containsString("_com.auth0.state.asdfghjkl=asdfghjkl"))); } @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 +172,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 +180,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 +188,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 +199,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 +208,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 +217,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 +226,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)); + + 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"); - authAPIStub.pushedAuthorizationResponseRequest = requestMock; - String url = new AuthorizeUrl(authAPIStub, request, response, "https://domain.com/callback", "code") + 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 +253,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 +272,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 +291,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 +319,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 +327,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..0851418 100644 --- a/src/test/java/com/auth0/RequestProcessorTest.java +++ b/src/test/java/com/auth0/RequestProcessorTest.java @@ -1,23 +1,28 @@ 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 com.auth0.net.client.Auth0HttpClient; 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 +43,24 @@ public class RequestProcessorTest { @Mock private DomainProvider mockDomainProvider; @Mock - private SignatureVerifier mockSignatureVerifier; + private JwkProvider mockJwkProvider; @Mock - private IdTokenVerifier mockIdTokenVerifier; - @Mock - private HttpOptions mockHttpOptions; + private Auth0HttpClient mockHttpClient; @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 +74,7 @@ public void shouldBuildRequestProcessorWithRequiredParameters() { mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) .build(); assertThat(processor, is(notNullValue())); @@ -88,9 +86,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 +99,8 @@ public void shouldBuildRequestProcessorWithAllOptionalParameters() { assertThat(processor, is(notNullValue())); } + // --- Legacy SameSite Cookie Tests --- + @Test public void shouldSetDefaultLegacySameSiteCookieToTrue() { RequestProcessor processor = createDefaultRequestProcessor(); @@ -115,9 +114,7 @@ public void shouldDisableLegacySameSiteCookie() { mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) .withLegacySameSiteCookie(false) .build(); @@ -142,32 +139,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 +151,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 +161,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 +169,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 +240,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 +508,7 @@ public void shouldBuildAuthorizeUrlWithOrganization() { mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) .withOrganization("org_123") .build(); @@ -342,9 +527,7 @@ public void shouldBuildAuthorizeUrlWithInvitation() { mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) .withInvitation("inv_456") .build(); @@ -363,9 +546,7 @@ public void shouldBuildAuthorizeUrlWithCustomCookiePath() { mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) .withCookiePath("/custom") .build(); @@ -377,190 +558,355 @@ 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"); + 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()); - RequestProcessor processor = createDefaultRequestProcessor(); + AuthorizeUrl result = spy.buildAuthorizeUrl(request, response, "https://redirect.uri/here", "state", "nonce"); - InvalidRequestException exception = assertThrows( - InvalidRequestException.class, - () -> processor.process(request, response)); - - assertThat(exception.getCode(), is("access_denied")); - assertThat(exception.getMessage(), is("The user denied the request")); + assertThat(result, is(notNullValue())); } @Test - public void shouldThrowExceptionWhenStateIsMissing() { - request.setParameter("code", "test_code"); - - RequestProcessor processor = createDefaultRequestProcessor(); + 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()); - InvalidRequestException exception = assertThrows( - InvalidRequestException.class, - () -> processor.process(request, response)); + AuthorizeUrl result = spy.buildAuthorizeUrl(request, response, "https://redirect.uri/here", "state", "nonce"); - assertThat(exception.getCode(), is("a0.invalid_state")); + assertThat(result, is(notNullValue())); } - @Test - public void shouldThrowExceptionWhenIdTokenMissingForImplicitGrant() { - request.setParameter("state", "validState"); - - RequestProcessor processor = createRequestProcessorWithResponseType(RESPONSE_TYPE_ID_TOKEN); + // --- Builder Configuration Tests --- - InvalidRequestException exception = assertThrows( - InvalidRequestException.class, - () -> processor.process(request, response)); + @Test + public void shouldSupportOrganizationParameter() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET) + .withOrganization("org_123") + .build(); - assertThat(exception, is(notNullValue())); - assertThat(exception.getCode(), is(notNullValue())); + assertThat(processor, is(notNullValue())); } @Test - public void shouldThrowExceptionWhenAccessTokenMissingForTokenGrant() { - request.setParameter("state", "validState"); - - RequestProcessor processor = createRequestProcessorWithResponseType(RESPONSE_TYPE_TOKEN); - - InvalidRequestException exception = assertThrows( - InvalidRequestException.class, - () -> processor.process(request, response)); + public void shouldSupportInvitationParameter() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET) + .withInvitation("inv_456") + .build(); - assertThat(exception, is(notNullValue())); - assertThat(exception.getCode(), is(notNullValue())); + assertThat(processor, 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); - - 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"); + public void shouldSupportCustomCookiePath() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET) + .withCookiePath("/custom/path") + .build(); - 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())); - } + assertThat(processor, is(notNullValue())); } @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"); + public void shouldSupportClockSkewConfiguration() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET) + .withClockSkew(180) + .build(); - response.addCookie(new javax.servlet.http.Cookie("com.auth0.state", "validState")); + assertThat(processor, is(notNullValue())); + } - RequestProcessor processor = createRequestProcessorWithResponseType("id_token token"); + @Test + public void shouldSupportAuthenticationMaxAge() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET) + .withAuthenticationMaxAge(7200) + .build(); - 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(processor, is(notNullValue())); } - // --- Builder Configuration Tests --- + // --- Custom HttpClient Tests --- @Test - public void shouldSupportOrganizationParameter() { + public void shouldBuildWithCustomHttpClient() { RequestProcessor processor = new RequestProcessor.Builder( mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) - .withOrganization("org_123") + CLIENT_SECRET) + .withHttpClient(mockHttpClient) .build(); assertThat(processor, is(notNullValue())); } @Test - public void shouldSupportInvitationParameter() { + public void shouldCreateClientForDomainWithCustomHttpClient() { RequestProcessor processor = new RequestProcessor.Builder( mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) - .withInvitation("inv_456") + CLIENT_SECRET) + .withHttpClient(mockHttpClient) .build(); - assertThat(processor, is(notNullValue())); + AuthAPI client = processor.createClientForDomain(DOMAIN); + assertThat(client, is(notNullValue())); } @Test - public void shouldSupportCustomCookiePath() { + public void shouldReuseCustomHttpClientAcrossMultipleDomains() { RequestProcessor processor = new RequestProcessor.Builder( mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) - .withCookiePath("/custom/path") + CLIENT_SECRET) + .withHttpClient(mockHttpClient) .build(); - assertThat(processor, is(notNullValue())); + 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 shouldSupportClockSkewConfiguration() { + public void shouldCreateDefaultHttpClientWhenNoneProvided() { RequestProcessor processor = new RequestProcessor.Builder( mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) - .withClockSkew(180) + CLIENT_SECRET) .build(); - assertThat(processor, is(notNullValue())); + AuthAPI client = processor.createClientForDomain(DOMAIN); + assertThat(client, is(notNullValue())); } @Test - public void shouldSupportAuthenticationMaxAge() { + public void shouldReuseDefaultHttpClientAcrossMultipleCalls() { RequestProcessor processor = new RequestProcessor.Builder( mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) - .withAuthenticationMaxAge(7200) + 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() { @@ -568,9 +914,8 @@ private RequestProcessor createDefaultRequestProcessor() { mockDomainProvider, RESPONSE_TYPE_CODE, CLIENT_ID, - CLIENT_SECRET, - mockHttpOptions, - mockSignatureVerifier) + CLIENT_SECRET) + .withJwkProvider(mockJwkProvider) .build(); } @@ -579,18 +924,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..44ff9e4 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 { @@ -35,25 +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); + + 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); - 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))); + 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 @@ -63,8 +77,10 @@ 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.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 @@ -74,7 +90,8 @@ 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.get(0), containsString("com.auth0.state.123456=123456")); + assertThat(headers.get(0), containsString("SameSite=None")); } @Test @@ -84,7 +101,8 @@ 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.get(0), containsString("Secure")); + assertThat(headers.get(0), containsString("SameSite=Lax")); } @Test @@ -94,66 +112,89 @@ 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.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("com.auth0.state=123456; HttpOnly; Max-Age=600; 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("com.auth0.nonce=123456; HttpOnly; Max-Age=600; SameSite=None; Secure")); - assertThat(headers, hasItem("_com.auth0.nonce=123456; HttpOnly; Max-Age=600")); + 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("com.auth0.nonce=123456; HttpOnly; Max-Age=600; SameSite=None; Secure")); + 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")); @@ -162,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())); @@ -205,78 +274,176 @@ 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"; @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 +456,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,12 +467,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,43 +481,50 @@ 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(); 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; @@ -362,7 +535,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 +550,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)); } }