diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..fd97652 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,127 @@ +# Copilot Instructions for auth0-java-mvc-common + +## Overview + +This is an Auth0 SDK for Java Servlet applications that simplifies OAuth2/OpenID Connect authentication flows. The library provides secure cookie-based state/nonce management and handles both Authorization Code and Implicit Grant flows. + +## Core Architecture + +### Main Components + +- **`AuthenticationController`**: Primary entry point with Builder pattern for configuration +- **`RequestProcessor`**: Internal handler for OAuth callbacks and token processing +- **`AuthorizeUrl`**: Fluent builder for constructing OAuth authorization URLs +- **Cookie Management**: Custom `AuthCookie`/`TransientCookieStore` for SameSite cookie support + +### Key Design Patterns + +- **Non-reusable builders**: `AuthenticationController.Builder` throws `IllegalStateException` if `build()` called twice +- **One-time URL builders**: `AuthorizeUrl` instances cannot be reused (throws on second `build()`) +- **Fallback authentication storage**: State/nonce stored in both cookies AND session for compatibility + +## Critical Cookie Handling + +The library implements sophisticated cookie management for browser compatibility: + +### SameSite Cookie Strategy + +- **Code flow**: Uses `SameSite=Lax` (single cookie) +- **ID token flows**: Uses `SameSite=None; Secure` with legacy fallback cookie (prefixed with `_`) +- **Legacy fallback**: Automatically creates fallback cookies for browsers that don't support `SameSite=None` + +### Cookie Configuration + +```java +// Configure cookie behavior +.withLegacySameSiteCookie(false) // Disable fallback cookies +.withSecureCookie(true) // Force Secure attribute +.withCookiePath("/custom") // Set cookie Path attribute +``` + +## Builder Pattern Usage + +### Standard Authentication Controller Setup + +```java +AuthenticationController controller = AuthenticationController.newBuilder(domain, clientId, clientSecret) + .withJwkProvider(jwkProvider) // Required for RS256 + .withResponseType("code") // Default: "code" + .withClockSkew(120) // Default: 60 seconds + .withOrganization("org_id") // For organization login + .build(); +``` + +### URL Building (Modern Pattern) + +```java +// CORRECT: Use request + response for cookie storage +String url = controller.buildAuthorizeUrl(request, response, redirectUri) + .withState("custom-state") + .withAudience("https://api.example.com") + .withParameter("custom", "value") + .build(); +``` + +## Response Type Behavior + +- **`code`**: Authorization Code flow, uses `SameSite=Lax` cookies +- **`id_token`** or **`token`**: Implicit Grant, requires `SameSite=None; Secure` + fallback cookies +- **Mixed**: `id_token code` combinations follow implicit grant cookie rules + +## Testing Patterns + +### Mock Setup + +```java +// Standard test setup pattern +@Mock private AuthAPI client; +@Mock private IdTokenVerifier.Options verificationOptions; +@Captor private ArgumentCaptor signatureVerifierCaptor; + +AuthenticationController.Builder builderSpy = spy(AuthenticationController.newBuilder(...)); +doReturn(client).when(builderSpy).createAPIClient(...); +``` + +### Cookie Assertions + +```java +// Verify cookie headers in tests +List headers = response.getHeaders("Set-Cookie"); +assertThat(headers, hasItem("com.auth0.state=value; HttpOnly; Max-Age=600; SameSite=Lax")); +``` + +## Development Workflow + +### Build & Test + +```bash +./gradlew build # Build with Gradle wrapper +./gradlew test # Run tests +./gradlew jacocoTestReport # Generate coverage +``` + +### Key Dependencies + +- **Auth0 Java SDK**: Core Auth0 API client (`com.auth0:auth0`) +- **java-jwt**: JWT token handling (`com.auth0:java-jwt`) +- **jwks-rsa**: RS256 signature verification (`com.auth0:jwks-rsa`) +- **Servlet API**: `javax.servlet-api` (compile-only) + +## Migration Considerations + +### Deprecated Methods + +- `handle(HttpServletRequest)`: Session-based, incompatible with SameSite restrictions +- `buildAuthorizeUrl(HttpServletRequest, String)`: Session-only storage + +### Modern Alternatives + +- Use `handle(HttpServletRequest, HttpServletResponse)` for cookie-based auth +- Use `buildAuthorizeUrl(HttpServletRequest, HttpServletResponse, String)` for proper cookie storage + +## Common Integration Points + +- Organizations: Use `.withOrganization()` and validate `org_id` claims manually +- Custom parameters: Use `.withParameter()` on AuthorizeUrl (but not for `state`, `nonce`, `response_type`) +- Error handling: Catch `IdentityVerificationException` from `.handle()` calls +- HTTP customization: Use `.withHttpOptions()` for timeouts/proxy configuration diff --git a/EXAMPLES.md b/EXAMPLES.md index 975df40..6b56503 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -2,6 +2,7 @@ - [Including additional authorization parameters](#including-additional-authorization-parameters) - [Organizations](#organizations) +- [Multiple Custom Domains (MCD) Support](#multiple-custom-domains-support) - [Allowing clock skew for token validation](#allow-a-clock-skew-for-token-validation) - [Changing the OAuth response_type](#changing-the-oauth-response_type) - [HTTP logging](#http-logging) @@ -73,6 +74,132 @@ AuthenticationController controller = AuthenticationController.newBuilder("{DOMA The ID of the invitation and organization are available as query parameters on the invitation URL, e.g., `https://your-domain.auth0.com/login?invitation={INVITATION_ID}&organization={ORG_ID}&organization_name={ORG_NAME}` +## Multiple Custom Domains Support + +Multiple Custom Domains (MCD) lets you resolve the Auth0 domain per request while keeping a single SDK instance. This is useful when one application serves multiple custom domains (for example, `brand-1.my-app.com` and `brand-2.my-app.com`), each mapped to a different `Auth0` custom domain. + +`MCD` is enabled by providing a `DomainResolver` function instead of a static domain string, enabling you to dynamically define the `Auth0` custom domain at run-time. + +Resolver mode is intended for the custom domains of a single `Auth0` tenant. It is not a supported way to connect multiple `Auth0` tenants to one application. + +### Dynamic Domain Resolver + +Provide a resolver function to select the domain at runtime. The resolver should return the `Auth0 Custom Domain` (for example, `brand-1.custom-domain.com`). Returning `null` or an empty value throws `IllegalStateException`. + +### Configure with a DomainResolver + +Implement the `DomainResolver` interface to resolve the domain dynamically based on the incoming request. The domain can be derived from a subdomain, request header, query parameter, or any other request attribute: + +```java +DomainResolver domainResolver = (HttpServletRequest request) -> { + // Example: resolve from a custom header + String tenant = request.getHeader("X-Tenant-Domain"); + return tenant != null ? tenant : "default-tenant.auth0.com"; +}; + +AuthenticationController controller = AuthenticationController + .newBuilder(domainResolver, "YOUR-CLIENT-ID", "YOUR-CLIENT-SECRET") + .build(); +``` + +### Resolve domain from subdomain + +```java +DomainResolver domainResolver = (HttpServletRequest request) -> { + // e.g., "acme.myapp.com" -> "acme.auth0.com" + String host = request.getServerName(); + String subdomain = host.split("\\.")[0]; + return subdomain + ".auth0.com"; +}; + +AuthenticationController controller = AuthenticationController + .newBuilder(domainResolver, "YOUR-CLIENT-ID", "YOUR-CLIENT-SECRET") + .build(); +``` + +### Building the authorize URL and handling the callback + +The login and callback servlets work the same way as with a static domain. The library automatically stores the resolved domain and issuer in transient cookies during the authorization flow, and retrieves them when handling the callback: + +```java +// LoginServlet - domain is resolved automatically per request +@WebServlet(urlPatterns = {"/login"}) +public class LoginServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException { + String authorizeUrl = controller + .buildAuthorizeUrl(req, res, "https://myapp.com/callback") + .build(); + res.sendRedirect(authorizeUrl); + } +} + +// CallbackServlet - domain/issuer retrieved from cookies and validated +@WebServlet(urlPatterns = {"/callback"}) +public class CallbackServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException, ServletException { + try { + Tokens tokens = controller.handle(req, res); + + // Access the domain and issuer that were used for this authentication + String domain = tokens.getDomain(); // e.g., "acme.auth0.com" + String issuer = tokens.getIssuer(); // e.g., "https://acme.auth0.com/" + + // Use domain/issuer for tenant-specific session management + req.getSession().setAttribute("auth0_domain", domain); + + res.sendRedirect("/dashboard"); + } catch (IdentityVerificationException e) { + // handle authentication error + } + } +} +``` + +### How it works + +1. When `buildAuthorizeUrl()` is called, the `DomainResolver` resolves the domain from the current request. The resolved domain and its issuer are stored as transient cookies (`com.auth0.origin_domain`, `com.auth0.origin_issuer`). +2. When the callback is handled via `handle()`, the stored domain and issuer are retrieved from cookies. The library creates a domain-specific API client for the code exchange and validates that the ID token's `iss` claim matches the expected issuer. +3. The returned `Tokens` object includes `getDomain()` and `getIssuer()` for use in tenant-specific logic. + +### Redirect URI requirements + +When using MCD, the `redirectUri` passed to `buildAuthorizeUrl()` must be an **absolute URL**. The SDK does not infer it from the request. In MCD deployments, you will typically resolve the redirect URI per request so each domain uses the correct callback URL: + +```java +@Override +protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException { + // Resolve redirect URI based on the incoming request's host + String redirectUri = req.getScheme() + "://" + req.getServerName() + "/callback"; + + String authorizeUrl = controller + .buildAuthorizeUrl(req, res, redirectUri) + .build(); + res.sendRedirect(authorizeUrl); +} +``` + +You must validate the host and scheme safely for your deployment to prevent open redirect attacks. + +### Legacy sessions and migration + +When moving from a static domain setup to a `DomainResolver`, existing sessions can continue to work if the resolver returns the same Auth0 custom domain that was used for those legacy sessions. + +If the resolver returns a different domain, the SDK treats the session as missing and requires the user to sign in again. This is intentional to keep sessions isolated per domain. + +### Security requirements + +When configuring the `DomainResolver`, you are responsible for ensuring that all resolved domains are trusted. Mis-configuring the domain resolver is a critical security risk that can lead to authentication bypass on the relying party (RP) or expose the application to Server-Side Request Forgery (SSRF). + +**Single tenant limitation:** +The `DomainResolver` is intended solely for multiple custom domains belonging to the same Auth0 tenant. It is not a supported mechanism for connecting multiple Auth0 tenants to a single application. + +**Secure proxy requirement:** +When using MCD, your application must be deployed behind a secure edge or reverse proxy (e.g., Cloudflare, Nginx, or AWS ALB). The proxy must be configured to sanitize and overwrite `Host` and `X-Forwarded-Host` headers before they reach your application. + +Without a trusted proxy layer to validate these headers, an attacker can manipulate the domain resolution process. This can result in malicious redirects, where users are sent to unauthorized or fraudulent endpoints during the login and logout flows. + ## Allow a clock skew for token validation During the authentication flow, the ID token is verified and validated to ensure it is secure. Time-based claims such as the time the token was issued at and the token's expiration are verified to ensure the token is valid. diff --git a/README.md b/README.md index 43e8747..1c15ba4 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,23 @@ public class CallbackServlet extends HttpServlet { That's it! You have authenticated the user using Auth0. +### Multiple Custom Domains Support + +If your application needs to authenticate users against different Auth0 domains per request, use a `DomainResolver` instead of a static domain: + +```java +DomainResolver domainResolver = (request) -> { + // resolve the Auth0 domain from the request (e.g., header, subdomain, etc.) + return request.getHeader("X-Tenant-Domain"); +}; + +AuthenticationController controller = AuthenticationController + .newBuilder(domainResolver, clientId, clientSecret) + .build(); +``` + +The library handles storing and retrieving the resolved domain throughout the authentication flow. The returned `Tokens` object includes `getDomain()` and `getIssuer()` for tenant-specific session management. See [EXAMPLES.md](./EXAMPLES.md#multiple-custom-domains-support) for more details. + ## API Reference - [JavaDocs](https://javadoc.io/doc/com.auth0/mvc-auth-commons) diff --git a/src/main/java/com/auth0/AuthenticationController.java b/src/main/java/com/auth0/AuthenticationController.java index 1aed380..9fc2724 100644 --- a/src/main/java/com/auth0/AuthenticationController.java +++ b/src/main/java/com/auth0/AuthenticationController.java @@ -10,12 +10,11 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; - /** * Base Auth0 Authenticator class. * Allows to easily authenticate using the Auth0 Hosted Login Page. */ -@SuppressWarnings({"WeakerAccess", "UnusedReturnValue", "SameParameterValue"}) +@SuppressWarnings({ "WeakerAccess", "UnusedReturnValue", "SameParameterValue" }) public class AuthenticationController { private final RequestProcessor requestProcessor; @@ -34,9 +33,13 @@ 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 @@ -44,14 +47,35 @@ RequestProcessor getRequestProcessor() { * @return a new Builder instance ready to configure */ public static Builder newBuilder(String domain, String clientId, String clientSecret) { - return new Builder(domain, clientId, clientSecret); + Validate.notNull(domain, "domain must not be null"); + return new Builder(clientId, clientSecret).withDomain(domain); } + /** + * 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 domainResolver the Auth0 domain resolver function + * @param clientId the Auth0 application's client id + * @param clientSecret the Auth0 application's client secret + * @return a new Builder instance ready to configure + */ + public static Builder newBuilder(DomainResolver domainResolver, + String clientId, + String clientSecret) { + Validate.notNull(domainResolver, "domainResolver must not be null"); + return new Builder(clientId, clientSecret).withDomainResolver(domainResolver); + } public static class Builder { private static final String RESPONSE_TYPE_CODE = "code"; - private final String domain; + private String domain; private final String clientId; private final String clientSecret; private String responseType; @@ -63,6 +87,7 @@ public static class Builder { private String invitation; private HttpOptions httpOptions; private String cookiePath; + private DomainResolver domainResolver; Builder(String domain, String clientId, String clientSecret) { Validate.notNull(domain); @@ -76,8 +101,57 @@ public static class Builder { this.useLegacySameSiteCookie = true; } + Builder(String clientId, String clientSecret) { + if (clientId == null) { + throw new IllegalArgumentException("clientId cannot be null"); + } + if (clientSecret == null) { + throw new IllegalArgumentException("clientSecret cannot be null"); + } + + this.clientId = clientId; + this.clientSecret = clientSecret; + this.responseType = RESPONSE_TYPE_CODE; + this.useLegacySameSiteCookie = true; + } + + /** + * Sets the Auth0 domain to use. + * Note: The `domainResolver` must be null when setting the `domain`. + * + * @param domain the Auth0 domain to use, a non-null value. + * @return this same builder instance. + * @throws IllegalStateException if `domainResolver` is already set. + */ + public Builder withDomain(String domain) { + if (this.domainResolver != null) { + throw new IllegalStateException("Cannot specify both 'domain' and 'domainResolver'."); + } + Validate.notNull(domain, "domain must not be null"); + this.domain = domain; + return this; + } + + /** + * Sets the Auth0 domain resolver function to use. + * Note: The `domain` must be null when setting the `domainResolver`. + * + * @param domainResolver the domain resolver function to use, a non-null value. + * @return this same builder instance. + * @throws IllegalStateException if `domain` is already set. + */ + public Builder withDomainResolver(DomainResolver domainResolver) { + if (this.domain != null) { + throw new IllegalStateException("Cannot specify both 'domain' and 'domainResolver'."); + } + Validate.notNull(domainResolver, "domainResolver must not be null"); + this.domainResolver = domainResolver; + return this; + } + /** - * Customize certain aspects of the underlying HTTP client networking library, such as timeouts and proxy configuration. + * 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. @@ -89,7 +163,8 @@ public Builder withHttpOptions(HttpOptions httpOptions) { } /** - * 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. @@ -102,9 +177,12 @@ 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) { @@ -114,8 +192,10 @@ 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. @@ -127,7 +207,8 @@ public Builder withJwkProvider(JwkProvider jwkProvider) { } /** - * Sets the clock-skew or leeway value to use in the ID Token verification. The value must be in seconds. + * Sets the clock-skew or leeway value to use in the ID Token verification. The + * value must be in seconds. * Defaults to 60 seconds. * * @param clockSkew the clock-skew to use for ID Token verification, in seconds. @@ -140,7 +221,8 @@ 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. @@ -153,10 +235,14 @@ 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) { @@ -165,7 +251,8 @@ 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. @@ -177,10 +264,12 @@ 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) { @@ -190,35 +279,28 @@ 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 { - AuthAPI apiClient = createAPIClient(domain, clientId, clientSecret, httpOptions); - setupTelemetry(apiClient); - - final boolean expectedAlgorithmIsExplicitlySetAndAsymmetric = jwkProvider != null; - final SignatureVerifier signatureVerifier; - if (expectedAlgorithmIsExplicitlySetAndAsymmetric) { - signatureVerifier = new AsymmetricSignatureVerifier(jwkProvider); - } else if (responseType.contains(RESPONSE_TYPE_CODE)) { - // Old behavior: To maintain backwards-compatibility when - // no explicit algorithm is set by the user, we - // must skip ID Token signature check. - signatureVerifier = new AlgorithmNameVerifier(); - } else { - signatureVerifier = new SymmetricSignatureVerifier(clientSecret); - } + validateDomainConfiguration(); + + DomainProvider domainProvider = domain != null + ? new StaticDomainProvider(domain) + : new ResolverDomainProvider(domainResolver); - String issuer = getIssuer(domain); - IdTokenVerifier.Options verifyOptions = createIdTokenVerificationOptions(issuer, clientId, signatureVerifier); - verifyOptions.setClockSkew(clockSkew); - verifyOptions.setMaxAge(authenticationMaxAge); - verifyOptions.setOrganization(this.organization); + SignatureVerifier signatureVerifier = buildSignatureVerifier(); - RequestProcessor processor = new RequestProcessor.Builder(apiClient, responseType, verifyOptions) + RequestProcessor processor = new RequestProcessor.Builder(domainProvider, responseType, clientId, + clientSecret, httpOptions, signatureVerifier) + .withClockSkew(clockSkew) + .withAuthenticationMaxAge(authenticationMaxAge) .withLegacySameSiteCookie(useLegacySameSiteCookie) .withOrganization(organization) .withInvitation(invitation) @@ -228,9 +310,23 @@ public AuthenticationController build() throws UnsupportedOperationException { return new AuthenticationController(processor); } - @VisibleForTesting - IdTokenVerifier.Options createIdTokenVerificationOptions(String issuer, String audience, SignatureVerifier signatureVerifier) { - return new IdTokenVerifier.Options(issuer, audience, signatureVerifier); + private void validateDomainConfiguration() { + if (domain == null && domainResolver == null) { + throw new IllegalStateException("Either domain or domainResolver must be provided."); + } + if (domain != null && domainResolver != null) { + 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 @@ -243,26 +339,18 @@ AuthAPI createAPIClient(String domain, String clientId, String clientSecret, Htt @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) + // Value if taken from jar's manifest file. + // Call will return null on dev environment (outside of a jar) return getClass().getPackage().getImplementationVersion(); } - - private String getIssuer(String domain) { - if (!domain.startsWith("http://") && !domain.startsWith("https://")) { - domain = "https://" + domain; - } - if (!domain.endsWith("/")) { - domain = domain + "/"; - } - return domain; - } } /** @@ -272,34 +360,47 @@ private String getIssuer(String domain) { * @param enabled whether to enable the HTTP logger or not. */ public void setLoggingEnabled(boolean enabled) { - requestProcessor.getClient().setLoggingEnabled(enabled); + // No longer requestProcessor.getClient()... (which was null) + requestProcessor.setLoggingEnabled(enabled); } /** * Disable sending the Telemetry header on every request to the Auth0 API */ public void doNotSendTelemetry() { - requestProcessor.getClient().doNotSendTelemetry(); + requestProcessor.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"); @@ -307,25 +408,39 @@ public Tokens handle(HttpServletRequest request, HttpServletResponse response) t } /** - * 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 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.

+ *

+ * 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. + * @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. + * @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 { @@ -335,20 +450,30 @@ public Tokens handle(HttpServletRequest request) throws IdentityVerificationExce } /** - * 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)} method. Failure to do so may 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)} 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. + * @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. + * @return the authorize url builder to continue any further parameter + * customization. */ @Deprecated public AuthorizeUrl buildAuthorizeUrl(HttpServletRequest request, String redirectUri) { @@ -362,18 +487,25 @@ public AuthorizeUrl buildAuthorizeUrl(HttpServletRequest request, String redirec } /** - * 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 e871ca6..092b0fd 100644 --- a/src/main/java/com/auth0/AuthorizeUrl.java +++ b/src/main/java/com/auth0/AuthorizeUrl.java @@ -29,6 +29,8 @@ public class AuthorizeUrl { private String state; private final AuthAPI authAPI; private String cookiePath; + private String originDomain; + private String clientSecret; private boolean used; private Map params; @@ -142,6 +144,16 @@ AuthorizeUrl withCookiePath(String cookiePath) { return this; } + /** + * Sets the origin domain and client secret for HMAC-signed origin domain cookie storage. + * Called internally by RequestProcessor for MCD support. + */ + AuthorizeUrl withOriginDomain(String originDomain, String clientSecret) { + this.originDomain = originDomain; + this.clientSecret = clientSecret; + return this; + } + /** * Sets the state value. * @@ -248,6 +260,12 @@ private void storeTransient() { TransientCookieStore.storeState(response, state, sameSiteValue, useLegacySameSiteCookie, setSecureCookie, cookiePath); TransientCookieStore.storeNonce(response, nonce, sameSiteValue, useLegacySameSiteCookie, setSecureCookie, cookiePath); + + // Store HMAC-signed origin domain with the same SameSite value as state/nonce + if (originDomain != null && clientSecret != null) { + TransientCookieStore.storeSignedOriginDomain(response, originDomain, + sameSiteValue, cookiePath, setSecureCookie, clientSecret); + } } // Also store in Session just in case developer uses deprecated diff --git a/src/main/java/com/auth0/DomainProvider.java b/src/main/java/com/auth0/DomainProvider.java new file mode 100644 index 0000000..081a3e7 --- /dev/null +++ b/src/main/java/com/auth0/DomainProvider.java @@ -0,0 +1,8 @@ +package com.auth0; + +import javax.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 new file mode 100644 index 0000000..ea441e4 --- /dev/null +++ b/src/main/java/com/auth0/DomainResolver.java @@ -0,0 +1,12 @@ +package com.auth0; + +import javax.servlet.http.HttpServletRequest; + +public interface DomainResolver { + /** + * Resolves the domain to be used for the current request. + * @param request the current HttpServletRequest + * @return a single domain string (e.g., "tenant.auth0.com") + */ + String resolve(HttpServletRequest request); +} diff --git a/src/main/java/com/auth0/IdTokenVerifier.java b/src/main/java/com/auth0/IdTokenVerifier.java index d163e71..3ef0e32 100644 --- a/src/main/java/com/auth0/IdTokenVerifier.java +++ b/src/main/java/com/auth0/IdTokenVerifier.java @@ -146,7 +146,7 @@ private boolean isEmpty(String value) { } static class Options { - final String issuer; + String issuer; final String audience; final SignatureVerifier verifier; String nonce; @@ -156,14 +156,25 @@ static class Options { String organization; public Options(String issuer, String audience, SignatureVerifier verifier) { - Validate.notNull(issuer); - Validate.notNull(audience); - Validate.notNull(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; } diff --git a/src/main/java/com/auth0/RequestProcessor.java b/src/main/java/com/auth0/RequestProcessor.java index 6796982..cc58e7c 100644 --- a/src/main/java/com/auth0/RequestProcessor.java +++ b/src/main/java/com/auth0/RequestProcessor.java @@ -1,9 +1,11 @@ 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 org.apache.commons.lang3.Validate; +import com.auth0.net.Telemetry; +import com.google.common.annotations.VisibleForTesting; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -14,7 +16,8 @@ /** * 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 { @@ -32,34 +35,65 @@ class RequestProcessor { private static final String KEY_MAX_AGE = "max_age"; // Visible for testing - final IdTokenVerifier.Options verifyOptions; - final boolean useLegacySameSiteCookie; + private final DomainProvider domainProvider; private final String responseType; - private final AuthAPI client; - private final IdTokenVerifier tokenVerifier; + private final String clientId; + private final String clientSecret; + private final HttpOptions httpOptions; + private SignatureVerifier signatureVerifier; + + // 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; - private final String cookiePath; + final boolean useLegacySameSiteCookie; + private AuthAPI client; + private final IdTokenVerifier tokenVerifier; + private final String cookiePath; + private boolean loggingEnabled = false; + private boolean telemetryDisabled = false; static class Builder { - private final AuthAPI client; + private final DomainProvider domainProvider; private final String responseType; - private final IdTokenVerifier.Options verifyOptions; + private final String clientId; + private final String clientSecret; + private final HttpOptions httpOptions; + private final SignatureVerifier signatureVerifier; + private boolean useLegacySameSiteCookie = true; - private IdTokenVerifier tokenVerifier; + private Integer clockSkew; + private Integer authenticationMaxAge; private String organization; private String invitation; private String cookiePath; - Builder(AuthAPI client, String responseType, IdTokenVerifier.Options verifyOptions) { - Validate.notNull(client); - Validate.notNull(responseType); - Validate.notNull(verifyOptions); - this.client = client; + public Builder(DomainProvider domainProvider, + String responseType, + String clientId, + String clientSecret, + HttpOptions httpOptions, + SignatureVerifier signatureVerifier) { + this.domainProvider = domainProvider; this.responseType = responseType; - this.verifyOptions = verifyOptions; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.httpOptions = httpOptions; + this.signatureVerifier = signatureVerifier; + } + + public Builder withClockSkew(Integer clockSkew) { + this.clockSkew = clockSkew; + return this; + } + + public Builder withAuthenticationMaxAge(Integer maxAge) { + this.authenticationMaxAge = maxAge; + return this; } Builder withCookiePath(String cookiePath) { @@ -72,11 +106,6 @@ Builder withLegacySameSiteCookie(boolean useLegacySameSiteCookie) { return this; } - Builder withIdTokenVerifier(IdTokenVerifier verifier) { - this.tokenVerifier = verifier; - return this; - } - Builder withOrganization(String organization) { this.organization = organization; return this; @@ -88,26 +117,42 @@ Builder withInvitation(String invitation) { } RequestProcessor build() { - return new RequestProcessor(client, responseType, verifyOptions, - this.tokenVerifier == null ? new IdTokenVerifier() : this.tokenVerifier, - useLegacySameSiteCookie, organization, invitation, cookiePath); + + return new RequestProcessor(domainProvider, responseType, clientId, clientSecret, httpOptions, + signatureVerifier, new IdTokenVerifier(), + useLegacySameSiteCookie, clockSkew, authenticationMaxAge, organization, invitation, cookiePath); } } - private RequestProcessor(AuthAPI client, String responseType, IdTokenVerifier.Options verifyOptions, IdTokenVerifier tokenVerifier, boolean useLegacySameSiteCookie, String organization, String invitation, String cookiePath) { - Validate.notNull(client); - Validate.notNull(responseType); - Validate.notNull(verifyOptions); - this.client = client; + private RequestProcessor(DomainProvider domainProvider, String responseType, String clientId, String clientSecret, + HttpOptions httpOptions, SignatureVerifier signatureVerifier, IdTokenVerifier tokenVerifier, + boolean useLegacySameSiteCookie, Integer clockSkew, Integer authenticationMaxAge, + String organization, String invitation, String cookiePath) { + this.domainProvider = domainProvider; this.responseType = responseType; - this.verifyOptions = verifyOptions; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.httpOptions = httpOptions; + this.signatureVerifier = signatureVerifier; this.tokenVerifier = tokenVerifier; this.useLegacySameSiteCookie = useLegacySameSiteCookie; + + // Store individual configuration values instead of pre-built verifyOptions + this.clockSkew = clockSkew; + this.authenticationMaxAge = authenticationMaxAge; this.organization = organization; this.invitation = invitation; this.cookiePath = cookiePath; } + void setLoggingEnabled(boolean enabled) { + this.loggingEnabled = enabled; + } + + void doNotSendTelemetry() { + this.telemetryDisabled = true; + } + /** * Getter for the AuthAPI client instance. * Used to customize options such as Telemetry and Logging. @@ -118,18 +163,55 @@ 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); + } + + @VisibleForTesting + String obtainPackageVersion() { + return getClass().getPackage().getImplementationVersion(); + } + /** - * 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 response the response, used to set state and nonce as cookies. If + * null, session will be used instead. * @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) .withState(state); @@ -144,12 +226,13 @@ 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 + // 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); } - return getAuthorizeUrl(nonce, creator); } @@ -157,18 +240,35 @@ 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); - Tokens frontChannelTokens = getFrontChannelTokens(request); + // 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); + } + + // Fallback for session-based (deprecated) flow or if cookie was not set + if (originDomain == null) { + originDomain = domainProvider.getDomain(request); + } + + // Always derive the issuer from the verified domain — never from a cookie + String originIssuer = constructIssuer(originDomain); + + Tokens frontChannelTokens = getFrontChannelTokens(request, originDomain, originIssuer); List responseTypeList = getResponseType(); if (responseTypeList.contains(KEY_ID_TOKEN) && frontChannelTokens.getIdToken() == null) { @@ -178,22 +278,7 @@ Tokens process(HttpServletRequest request, HttpServletResponse response) throws throw new InvalidRequestException(MISSING_ACCESS_TOKEN, "Access Token is missing from the response."); } - String nonce; - if (response != null) { - // Nonce dynamically set and changes on every request. - nonce = TransientCookieStore.getNonce(request, response); - - // Just in case the developer created the authorizeUrl that stores state/nonce in the session - if (nonce == null) { - nonce = RandomStorage.removeSessionNonce(request); - } - } else { - nonce = RandomStorage.removeSessionNonce(request); - } - - verifyOptions.setNonce(nonce); - - return getVerifiedTokens(request, frontChannelTokens, responseTypeList); + return getVerifiedTokens(request, response, frontChannelTokens, responseTypeList, originDomain, originIssuer); } static boolean requiresFormPostResponseMode(List responseType) { @@ -203,44 +288,93 @@ static boolean requiresFormPostResponseMode(List responseType) { /** * Obtains code request tokens (if using Code flow) and validates the ID token. - * @param request the HTTP request + * + * @param request the HTTP request + * @param response the HTTP response * @param frontChannelTokens the tokens obtained from the front channel - * @param responseTypeList the reponse types - * @return a Tokens object that wraps the values obtained from the front-channel and/or the code request response. + * @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. * @throws IdentityVerificationException */ - private Tokens getVerifiedTokens(HttpServletRequest request, Tokens frontChannelTokens, List responseTypeList) + private Tokens getVerifiedTokens(HttpServletRequest request, HttpServletResponse response, + Tokens frontChannelTokens, + List responseTypeList, String originDomain, String originIssuer) throws IdentityVerificationException { String authorizationCode = request.getParameter(KEY_CODE); Tokens codeExchangeTokens = null; + // Get nonce for this specific request + String nonce; + if (response != null) { + nonce = TransientCookieStore.getNonce(request, response); + // Fallback to session if cookie was not set (deprecated API path) + if (nonce == null) { + nonce = RandomStorage.removeSessionNonce(request); + } + } else { + nonce = RandomStorage.removeSessionNonce(request); + } + + IdTokenVerifier.Options requestVerifyOptions = createRequestVerifyOptions(originIssuer, nonce); + try { if (responseTypeList.contains(KEY_ID_TOKEN)) { - // Implicit/Hybrid flow: must verify front-channel ID Token first - tokenVerifier.verify(frontChannelTokens.getIdToken(), verifyOptions); + // 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); } if (responseTypeList.contains(KEY_CODE)) { // Code/Hybrid flow String redirectUri = request.getRequestURL().toString(); - codeExchangeTokens = exchangeCodeForTokens(authorizationCode, redirectUri); + codeExchangeTokens = exchangeCodeForTokens(authorizationCode, redirectUri, originDomain); if (!responseTypeList.contains(KEY_ID_TOKEN)) { // If we already verified the front-channel token, don't verify it again. String idTokenFromCodeExchange = codeExchangeTokens.getIdToken(); if (idTokenFromCodeExchange != null) { - tokenVerifier.verify(idTokenFromCodeExchange, verifyOptions); + tokenVerifier.verify(idTokenFromCodeExchange, requestVerifyOptions); } } } } catch (TokenValidationException e) { - throw new IdentityVerificationException(JWT_VERIFICATION_ERROR, "An error occurred while trying to verify the ID Token.", 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. + */ + private IdTokenVerifier.Options createRequestVerifyOptions(String issuer, String nonce) { + // Create fresh verification options for this specific request + IdTokenVerifier.Options requestOptions = new IdTokenVerifier.Options(clientId, signatureVerifier); + + requestOptions.setIssuer(issuer); + requestOptions.setNonce(nonce); + + if (clockSkew != null) { + requestOptions.setClockSkew(clockSkew); + } + if (authenticationMaxAge != null) { + requestOptions.setMaxAge(authenticationMaxAge); + } + if (organization != null) { + requestOptions.setOrganization(organization); + } + + return requestOptions; + } + List getResponseType() { return Arrays.asList(responseType.split(" ")); } @@ -253,21 +387,27 @@ private AuthorizeUrl getAuthorizeUrl(String nonce, AuthorizeUrl creator) { if (requiresFormPostResponseMode(responseTypeList)) { creator.withParameter(KEY_RESPONSE_MODE, KEY_FORM_POST); } - if (verifyOptions.getMaxAge() != null) { - creator.withParameter(KEY_MAX_AGE, verifyOptions.getMaxAge().toString()); + if (authenticationMaxAge != null) { + creator.withParameter(KEY_MAX_AGE, authenticationMaxAge.toString()); } return 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 - * @return a new instance of Tokens wrapping the values present in the request parameters. + * @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. */ - private Tokens getFrontChannelTokens(HttpServletRequest request) { - 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); + 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); } /** @@ -285,26 +425,32 @@ 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 or session * for this request. * * @param request the request - * @throws InvalidRequestException if the request contains a different state from the expected one + * @throws InvalidRequestException if the request contains a different state + * from the expected one */ - private void assertValidState(HttpServletRequest request, HttpServletResponse response) throws InvalidRequestException { + 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) + // - 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) 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."); + 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 + // This can happen when the deprecated handle method that only takes the request + // parameter is called if (response == null) { checkSessionState(request, stateFromRequest); return; @@ -312,25 +458,29 @@ private void assertValidState(HttpServletRequest request, HttpServletResponse re 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 + // 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."); + 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; } 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."); + throw new InvalidRequestException(INVALID_STATE_ERROR, + "The received state doesn't match the expected one."); } } @@ -339,20 +489,26 @@ 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) throws Auth0Exception { + private Tokens exchangeCodeForTokens(String authorizationCode, String redirectUri, String originDomain) + throws Auth0Exception { + AuthAPI client = createClientForDomain(originDomain); TokenHolder holder = client .exchangeCode(authorizationCode, redirectUri) .execute(); - return new Tokens(holder.getAccessToken(), holder.getIdToken(), holder.getRefreshToken(), holder.getTokenType(), holder.getExpiresIn()); + String originIssuer = constructIssuer(originDomain); + 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. @@ -379,12 +535,29 @@ 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(); - return new Tokens(accessToken, idToken, refreshToken, type, expiresIn); + // Preserve domain and issuer from either token set (they should be the same) + String domain = frontChannelTokens.getDomain() != null ? frontChannelTokens.getDomain() + : codeExchangeTokens.getDomain(); + String issuer = frontChannelTokens.getIssuer() != null ? frontChannelTokens.getIssuer() + : codeExchangeTokens.getIssuer(); + + return new Tokens(accessToken, idToken, refreshToken, type, expiresIn, domain, issuer); + } + + private String constructIssuer(String domain) { + if (!domain.startsWith("http://") && !domain.startsWith("https://")) { + domain = "https://" + domain; + } + if (!domain.endsWith("/")) { + domain = domain + "/"; + } + return domain; } } \ No newline at end of file diff --git a/src/main/java/com/auth0/ResolverDomainProvider.java b/src/main/java/com/auth0/ResolverDomainProvider.java new file mode 100644 index 0000000..e3ed73e --- /dev/null +++ b/src/main/java/com/auth0/ResolverDomainProvider.java @@ -0,0 +1,20 @@ +package com.auth0; + +import javax.servlet.http.HttpServletRequest; + +class ResolverDomainProvider implements DomainProvider { + private final DomainResolver resolver; + + ResolverDomainProvider(DomainResolver resolver) { + this.resolver = resolver; + } + + @Override + public String getDomain(HttpServletRequest request) { + String domain = resolver.resolve(request); + if (domain == null || domain.trim().isEmpty()) { + throw new IllegalStateException("DomainResolver returned a null or empty domain"); + } + return domain; + } +} diff --git a/src/main/java/com/auth0/SignedCookieUtils.java b/src/main/java/com/auth0/SignedCookieUtils.java new file mode 100644 index 0000000..544d750 --- /dev/null +++ b/src/main/java/com/auth0/SignedCookieUtils.java @@ -0,0 +1,93 @@ +package com.auth0; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Utility class for HMAC-signing cookie values to prevent tampering. + *

+ * Values are stored as {@code value|signature} where the signature is an + * HMAC-SHA256 hex digest computed using the application's client secret. + * On read, the signature is verified before the value is trusted. + */ +class SignedCookieUtils { + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + private static final String SEPARATOR = "|"; + + private SignedCookieUtils() {} + + /** + * Signs a value using HMAC-SHA256 with the provided secret. + * + * @param value the value to sign + * @param secret the secret key for HMAC + * @return the signed value in the format {@code value|signature} + * @throws IllegalArgumentException if value or secret is null + */ + static String sign(String value, String secret) { + if (value == null || secret == null) { + throw new IllegalArgumentException("Value and secret must not be null"); + } + String signature = computeHmac(value, secret); + return value + SEPARATOR + signature; + } + + /** + * Verifies the HMAC signature and extracts the original value. + * + * @param signedValue the signed value in the format {@code value|signature} + * @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 or the format is unexpected + */ + static String verifyAndExtract(String signedValue, String secret) { + if (signedValue == 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, 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); + SecretKeySpec keySpec = new SecretKeySpec( + secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM); + mac.init(keySpec); + byte[] hmacBytes = mac.doFinal(value.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(hmacBytes); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new RuntimeException("Failed to compute HMAC-SHA256", e); + } + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/auth0/StaticDomainProvider.java b/src/main/java/com/auth0/StaticDomainProvider.java new file mode 100644 index 0000000..c0421ca --- /dev/null +++ b/src/main/java/com/auth0/StaticDomainProvider.java @@ -0,0 +1,16 @@ +package com.auth0; + +import javax.servlet.http.HttpServletRequest; + +class StaticDomainProvider implements DomainProvider { + private final String domain; + + StaticDomainProvider(String domain) { + this.domain = domain; + } + + @Override + public String getDomain(HttpServletRequest request) { + return domain; + } +} diff --git a/src/main/java/com/auth0/StorageUtils.java b/src/main/java/com/auth0/StorageUtils.java index 162d4d3..3c41de5 100644 --- a/src/main/java/com/auth0/StorageUtils.java +++ b/src/main/java/com/auth0/StorageUtils.java @@ -10,6 +10,7 @@ private StorageUtils() {} static final String STATE_KEY = "com.auth0.state"; static final String NONCE_KEY = "com.auth0.nonce"; + static final String ORIGIN_DOMAIN_KEY = "com.auth0.origin_domain"; /** * Generates a new random string using {@link SecureRandom}. diff --git a/src/main/java/com/auth0/Tokens.java b/src/main/java/com/auth0/Tokens.java index 0b42f3d..cd3951d 100644 --- a/src/main/java/com/auth0/Tokens.java +++ b/src/main/java/com/auth0/Tokens.java @@ -22,6 +22,8 @@ public class Tokens implements Serializable { private final String refreshToken; private final String type; private final Long expiresIn; + private final String domain; + private final String issuer; /** * @param accessToken access token for Auth0 API @@ -31,11 +33,29 @@ public class Tokens implements Serializable { * @param expiresIn token expiration */ public Tokens(String accessToken, String idToken, String refreshToken, String type, Long expiresIn) { + this(accessToken, idToken, refreshToken, type, expiresIn, null, null); + } + + /** + * Full constructor with domain information for MCD support + * + * @param accessToken access token for Auth0 API + * @param idToken identity token with user information + * @param refreshToken refresh token that can be used to request new tokens + * without signing in again + * @param type token type + * @param expiresIn token expiration + * @param domain the Auth0 domain that issued these tokens + * @param issuer the issuer URL from the ID token + */ + public Tokens(String accessToken, String idToken, String refreshToken, String type, Long expiresIn, String domain, String issuer) { this.accessToken = accessToken; this.idToken = idToken; this.refreshToken = refreshToken; this.type = type; this.expiresIn = expiresIn; + this.domain = domain; + this.issuer = issuer; } /** @@ -82,4 +102,27 @@ public String getType() { public Long getExpiresIn() { return expiresIn; } + + + /** + * Getter for the Auth0 domain that issued these tokens. + * Used for domain-specific session management in Multiple Custom Domains (MCD) + * scenarios. + * + * @return the domain that issued these tokens, or null for non-MCD scenarios + */ + public String getDomain() { + return domain; + } + + /** + * Getter for the issuer URL from the ID token. + * Used for domain-specific session management in Multiple Custom Domains (MCD) + * scenarios. + * + * @return the issuer URL, or null for non-MCD scenarios + */ + public String getIssuer() { + return issuer; + } } diff --git a/src/main/java/com/auth0/TransientCookieStore.java b/src/main/java/com/auth0/TransientCookieStore.java index df5dd3c..8ede8f3 100644 --- a/src/main/java/com/auth0/TransientCookieStore.java +++ b/src/main/java/com/auth0/TransientCookieStore.java @@ -66,6 +66,42 @@ static String getNonce(HttpServletRequest request, HttpServletResponse response) 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. + * + * @param response the response to set the cookie on + * @param domain the resolved Auth0 domain + * @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, + SameSite sameSite, String path, boolean isSecure, String secret) { + String signedDomain = SignedCookieUtils.sign(domain, secret); + store(response, StorageUtils.ORIGIN_DOMAIN_KEY, signedDomain, sameSite, true, isSecure, path); + } + + /** + * Retrieves and verifies the HMAC-signed origin domain cookie. + * + * @param request the request to read the cookie from + * @param response the response used to delete the cookie after reading + * @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) + */ + static String getSignedOriginDomain(HttpServletRequest request, HttpServletResponse response, + String secret) { + String signedValue = getOnce(StorageUtils.ORIGIN_DOMAIN_KEY, request, response); + if (signedValue == null) { + return null; + } + return SignedCookieUtils.verifyAndExtract(signedValue, secret); + } + private static void store(HttpServletResponse response, String key, String value, SameSite sameSite, boolean useLegacySameSiteCookie, boolean isSecureCookie, String cookiePath) { Validate.notNull(response, "response must not be null"); Validate.notNull(key, "key must not be null"); diff --git a/src/test/java/com/auth0/AuthenticationControllerTest.java b/src/test/java/com/auth0/AuthenticationControllerTest.java index 25302f0..32239d8 100644 --- a/src/test/java/com/auth0/AuthenticationControllerTest.java +++ b/src/test/java/com/auth0/AuthenticationControllerTest.java @@ -1,16 +1,9 @@ package com.auth0; import com.auth0.client.HttpOptions; -import com.auth0.client.auth.AuthAPI; -import com.auth0.client.auth.AuthorizeUrlBuilder; -import com.auth0.json.auth.TokenHolder; import com.auth0.jwk.JwkProvider; -import com.auth0.net.Telemetry; -import com.auth0.net.TokenRequest; 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; @@ -18,569 +11,428 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import java.util.List; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.contains; -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.hamcrest.core.Is.is; +import static org.hamcrest.core.IsNull.notNullValue; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; -@SuppressWarnings("deprecated") public class AuthenticationControllerTest { + private static final String DOMAIN = "domain.auth0.com"; + private static final String CLIENT_ID = "clientId"; + private static final String CLIENT_SECRET = "clientSecret"; + + @Mock + private RequestProcessor mockRequestProcessor; + @Mock + private JwkProvider mockJwkProvider; @Mock - private AuthAPI client; + private HttpOptions mockHttpOptions; @Mock - private IdTokenVerifier.Options verificationOptions; - @Captor - private ArgumentCaptor signatureVerifierCaptor; + private DomainResolver mockDomainResolver; + @Mock + private Tokens mockTokens; - private AuthenticationController.Builder builderSpy; + private HttpServletRequest request; + private HttpServletResponse response; @BeforeEach public void setUp() { MockitoAnnotations.initMocks(this); - - AuthenticationController.Builder builder = AuthenticationController.newBuilder("domain", "clientId", "clientSecret"); - builderSpy = spy(builder); - - doReturn(client).when(builderSpy).createAPIClient(eq("domain"), eq("clientId"), eq("clientSecret"), eq(null)); - doReturn(verificationOptions).when(builderSpy).createIdTokenVerificationOptions(eq("https://domain/"), eq("clientId"), signatureVerifierCaptor.capture()); - doReturn("1.2.3").when(builderSpy).obtainPackageVersion(); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); } - @Test - public void shouldSetupClientWithTelemetry() { - AuthenticationController controller = builderSpy.build(); - - ArgumentCaptor telemetryCaptor = ArgumentCaptor.forClass(Telemetry.class); + // --- Builder Static Factory Methods --- - assertThat(controller, is(notNullValue())); - RequestProcessor requestProcessor = controller.getRequestProcessor(); - assertThat(requestProcessor.getClient(), is(client)); - verify(client).setTelemetry(telemetryCaptor.capture()); + @Test + public void shouldCreateBuilderWithDomain() { + AuthenticationController.Builder builder = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET); - Telemetry capturedTelemetry = telemetryCaptor.getValue(); - assertThat(capturedTelemetry, is(notNullValue())); - assertThat(capturedTelemetry.getName(), is("auth0-java-mvc-common")); - assertThat(capturedTelemetry.getVersion(), is("1.2.3")); + assertThat(builder, is(notNullValue())); } @Test - public void shouldCreateAuthAPIClientWithoutCustomHttpOptions() { - ArgumentCaptor captor = ArgumentCaptor.forClass(HttpOptions.class); - AuthenticationController.Builder spy = spy(AuthenticationController.newBuilder("domain", "clientId", "clientSecret")); - - spy.build(); - verify(spy).createAPIClient(eq("domain"), eq("clientId"), eq("clientSecret"), captor.capture()); + public void shouldCreateBuilderWithDomainResolver() { + AuthenticationController.Builder builder = AuthenticationController.newBuilder(mockDomainResolver, CLIENT_ID, CLIENT_SECRET); - HttpOptions actual = captor.getValue(); - assertThat(actual, is(nullValue())); + assertThat(builder, is(notNullValue())); + } + @Test + public void shouldThrowExceptionWhenDomainIsNull() { + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> AuthenticationController.newBuilder((String) null, CLIENT_ID, CLIENT_SECRET)); + assertThat(exception.getMessage(), is("domain must not be null")); } @Test - public void shouldCreateAuthAPIClientWithCustomHttpOptions() { - HttpOptions options = new HttpOptions(); - options.setConnectTimeout(5); - options.setReadTimeout(6); + public void shouldThrowExceptionWhenDomainResolverIsNull() { + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> AuthenticationController.newBuilder((DomainResolver) null, CLIENT_ID, CLIENT_SECRET)); + assertThat(exception.getMessage(), is("domainResolver must not be null")); + } - ArgumentCaptor captor = ArgumentCaptor.forClass(HttpOptions.class); - AuthenticationController.Builder spy = spy(AuthenticationController.newBuilder("domain", "clientId", "clientSecret") - .withHttpOptions(options)); + // --- Builder Configuration --- - spy.build(); - verify(spy).createAPIClient(eq("domain"), eq("clientId"), eq("clientSecret"), captor.capture()); + @Test + public void shouldConfigureBuilderWithAllOptions() { + AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) + .withResponseType("id_token token") + .withJwkProvider(mockJwkProvider) + .withClockSkew(120) + .withAuthenticationMaxAge(3600) + .withLegacySameSiteCookie(false) + .withOrganization("org_123") + .withInvitation("inv_456") + .withHttpOptions(mockHttpOptions) + .withCookiePath("/custom") + .build(); - HttpOptions actual = captor.getValue(); - assertThat(actual, is(notNullValue())); - assertThat(actual.getConnectTimeout(), is(5)); - assertThat(actual.getReadTimeout(), is(6)); + assertThat(controller, is(notNullValue())); + assertThat(controller.getRequestProcessor(), is(notNullValue())); } @Test - public void shouldDisableTelemetry() { - AuthenticationController controller = builderSpy.build(); - controller.doNotSendTelemetry(); + public void shouldThrowExceptionWhenDomainAndDomainResolverBothSet() { + AuthenticationController.Builder builder = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET); - verify(client).doNotSendTelemetry(); + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> builder.withDomainResolver(mockDomainResolver)); + assertThat(exception.getMessage(), is("Cannot specify both 'domain' and 'domainResolver'.")); } @Test - public void shouldEnableLogging() { - AuthenticationController controller = builderSpy.build(); + public void shouldThrowExceptionWhenDomainResolverAndDomainBothSet() { + AuthenticationController.Builder builder = AuthenticationController.newBuilder(mockDomainResolver, CLIENT_ID, CLIENT_SECRET); - controller.setLoggingEnabled(true); - verify(client).setLoggingEnabled(true); + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> builder.withDomain(DOMAIN)); + assertThat(exception.getMessage(), is("Cannot specify both 'domain' and 'domainResolver'.")); } @Test - public void shouldDisableLogging() { - AuthenticationController controller = builderSpy.build(); + public void shouldThrowExceptionWhenBuildingWithoutDomainOrResolver() { + AuthenticationController.Builder builder = new AuthenticationController.Builder(CLIENT_ID, CLIENT_SECRET); - controller.setLoggingEnabled(true); - verify(client).setLoggingEnabled(true); + IllegalStateException exception = assertThrows( + IllegalStateException.class, + builder::build); + assertThat(exception.getMessage(), is("Either domain or domainResolver must be provided.")); } @Test - public void shouldCreateWithSymmetricSignatureVerifierForNoCodeGrants() { - AuthenticationController controller = builderSpy - .withResponseType("id_token") - .build(); - - SignatureVerifier signatureVerifier = signatureVerifierCaptor.getValue(); - assertThat(signatureVerifier, is(notNullValue())); - assertThat(signatureVerifier, instanceOf(SymmetricSignatureVerifier.class)); - assertThat(verificationOptions, is(controller.getRequestProcessor().verifyOptions)); - - controller = builderSpy - .withResponseType("token") - .build(); + public void shouldValidateNullParameters() { + AuthenticationController.Builder builder = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET); - signatureVerifier = signatureVerifierCaptor.getValue(); - assertThat(signatureVerifier, is(notNullValue())); - assertThat(signatureVerifier, instanceOf(SymmetricSignatureVerifier.class)); - assertThat(verificationOptions, is(controller.getRequestProcessor().verifyOptions)); + assertThrows(NullPointerException.class, () -> builder.withDomain(null)); + assertThrows(NullPointerException.class, () -> builder.withResponseType(null)); + assertThrows(NullPointerException.class, () -> builder.withJwkProvider(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)); } @Test - public void shouldCreateWithAsymmetricSignatureVerifierWhenJwkProviderIsExplicitlySet() { - JwkProvider jwkProvider = mock(JwkProvider.class); - AuthenticationController controller = builderSpy - .withResponseType("code id_token") - .withJwkProvider(jwkProvider) + public void shouldSetDefaultResponseTypeToCode() { + AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) .build(); - SignatureVerifier signatureVerifier = signatureVerifierCaptor.getValue(); - assertThat(signatureVerifier, is(notNullValue())); - assertThat(signatureVerifier, instanceOf(AsymmetricSignatureVerifier.class)); - assertThat(verificationOptions, is(controller.getRequestProcessor().verifyOptions)); + assertThat(controller, is(notNullValue())); + } - controller = builderSpy - .withResponseType("code token") - .withJwkProvider(jwkProvider) + @Test + public void shouldNormalizeResponseTypeToLowerCase() { + AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) + .withResponseType("ID_TOKEN TOKEN") + .withJwkProvider(mockJwkProvider) .build(); - signatureVerifier = signatureVerifierCaptor.getValue(); - assertThat(signatureVerifier, is(notNullValue())); - assertThat(signatureVerifier, instanceOf(AsymmetricSignatureVerifier.class)); - assertThat(verificationOptions, is(controller.getRequestProcessor().verifyOptions)); + assertThat(controller, is(notNullValue())); + } - controller = builderSpy - .withResponseType("code id_token token") - .withJwkProvider(jwkProvider) + @Test + public void shouldTrimResponseType() { + AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) + .withResponseType(" code ") .build(); - signatureVerifier = signatureVerifierCaptor.getValue(); - assertThat(signatureVerifier, is(notNullValue())); - assertThat(signatureVerifier, instanceOf(AsymmetricSignatureVerifier.class)); - assertThat(verificationOptions, is(controller.getRequestProcessor().verifyOptions)); + assertThat(controller, is(notNullValue())); + } - controller = builderSpy - .withResponseType("code") - .withJwkProvider(jwkProvider) - .build(); + // --- handle(request, response) Tests --- - signatureVerifier = signatureVerifierCaptor.getValue(); - assertThat(signatureVerifier, is(notNullValue())); - assertThat(signatureVerifier, instanceOf(AsymmetricSignatureVerifier.class)); - assertThat(verificationOptions, is(controller.getRequestProcessor().verifyOptions)); + @Test + public void shouldHandleRequestWithResponse() throws IdentityVerificationException { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + when(mockRequestProcessor.process(request, response)).thenReturn(mockTokens); - controller = builderSpy - .withResponseType("id_token") - .withJwkProvider(jwkProvider) - .build(); + Tokens result = controller.handle(request, response); - signatureVerifier = signatureVerifierCaptor.getValue(); - assertThat(signatureVerifier, is(notNullValue())); - assertThat(signatureVerifier, instanceOf(AsymmetricSignatureVerifier.class)); - assertThat(verificationOptions, is(controller.getRequestProcessor().verifyOptions)); + assertThat(result, is(mockTokens)); + verify(mockRequestProcessor).process(request, response); + } - controller = builderSpy - .withResponseType("token") - .withJwkProvider(jwkProvider) - .build(); + @Test + public void shouldThrowExceptionWhenRequestIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); - signatureVerifier = signatureVerifierCaptor.getValue(); - assertThat(signatureVerifier, is(notNullValue())); - assertThat(signatureVerifier, instanceOf(AsymmetricSignatureVerifier.class)); - assertThat(verificationOptions, is(controller.getRequestProcessor().verifyOptions)); + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.handle(null, response)); + assertThat(exception.getMessage(), is("request must not be null")); } @Test - public void shouldCreateWithAlgorithmNameSignatureVerifierForResponseTypesIncludingCode() { - AuthenticationController controller = builderSpy - .withResponseType("code id_token") - .build(); + public void shouldThrowExceptionWhenResponseIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); - SignatureVerifier signatureVerifier = signatureVerifierCaptor.getValue(); - assertThat(signatureVerifier, is(notNullValue())); - assertThat(signatureVerifier, instanceOf(AlgorithmNameVerifier.class)); - assertThat(verificationOptions, is(controller.getRequestProcessor().verifyOptions)); + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.handle(request, null)); + assertThat(exception.getMessage(), is("response must not be null")); + } - controller = builderSpy - .withResponseType("code token") - .build(); + // --- buildAuthorizeUrl(request, response, redirectUri) Tests --- - signatureVerifier = signatureVerifierCaptor.getValue(); - assertThat(signatureVerifier, is(notNullValue())); - assertThat(signatureVerifier, instanceOf(AlgorithmNameVerifier.class)); - assertThat(verificationOptions, is(controller.getRequestProcessor().verifyOptions)); + @Test + public void shouldBuildAuthorizeUrlWithRequestAndResponse() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + AuthorizeUrl mockAuthorizeUrl = mock(AuthorizeUrl.class); + String redirectUri = "https://redirect.to/me"; - controller = builderSpy - .withResponseType("code token id_token") - .build(); + when(mockRequestProcessor.buildAuthorizeUrl(eq(request), eq(response), eq(redirectUri), anyString(), anyString())) + .thenReturn(mockAuthorizeUrl); - signatureVerifier = signatureVerifierCaptor.getValue(); - assertThat(signatureVerifier, is(notNullValue())); - assertThat(signatureVerifier, instanceOf(AlgorithmNameVerifier.class)); - assertThat(verificationOptions, is(controller.getRequestProcessor().verifyOptions)); + AuthorizeUrl result = controller.buildAuthorizeUrl(request, response, redirectUri); - controller = builderSpy - .withResponseType("code") - .build(); - - signatureVerifier = signatureVerifierCaptor.getValue(); - assertThat(signatureVerifier, is(notNullValue())); - assertThat(signatureVerifier, instanceOf(AlgorithmNameVerifier.class)); - assertThat(verificationOptions, is(controller.getRequestProcessor().verifyOptions)); + assertThat(result, is(mockAuthorizeUrl)); + verify(mockRequestProcessor).buildAuthorizeUrl(eq(request), eq(response), eq(redirectUri), anyString(), anyString()); } @Test - public void shouldThrowOnMissingDomain() { - assertThrows(NullPointerException.class, - () -> AuthenticationController.newBuilder(null, "clientId", "clientSecret")); - } + public void shouldThrowExceptionWhenBuildAuthorizeUrlRequestIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); - @Test - public void shouldThrowOnMissingClientId() { - assertThrows(NullPointerException.class, - () -> AuthenticationController.newBuilder("domain", null, "clientSecret")); + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.buildAuthorizeUrl(null, response, "https://redirect.to/me")); + assertThat(exception.getMessage(), is("request must not be null")); } @Test - public void shouldThrowOnMissingClientSecret() { - assertThrows(NullPointerException.class, - () -> AuthenticationController.newBuilder("domain", "clientId", null)); - } + public void shouldThrowExceptionWhenBuildAuthorizeUrlResponseIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); - @Test - public void shouldThrowOnMissingJwkProvider() { - assertThrows(NullPointerException.class, - () -> AuthenticationController.newBuilder("domain", "clientId", "clientSecret") - .withJwkProvider(null)); + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.buildAuthorizeUrl(request, null, "https://redirect.to/me")); + assertThat(exception.getMessage(), is("response must not be null")); } @Test - public void shouldThrowOnMissingResponseType() { - assertThrows(NullPointerException.class, - () -> AuthenticationController.newBuilder("domain", "clientId", "clientSecret") - .withResponseType(null)); + public void shouldThrowExceptionWhenBuildAuthorizeUrlRedirectUriIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.buildAuthorizeUrl(request, response, null)); + assertThat(exception.getMessage(), is("redirectUri must not be null")); } + // --- Logging and Telemetry Tests --- + @Test - public void shouldCreateWithDefaultValues() { - AuthenticationController controller = AuthenticationController.newBuilder("domain", "clientId", "clientSecret") - .build(); + public void shouldSetLoggingEnabled() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); - assertThat(controller, is(notNullValue())); - RequestProcessor requestProcessor = controller.getRequestProcessor(); - assertThat(requestProcessor.getResponseType(), contains("code")); - assertThat(requestProcessor.verifyOptions.audience, is("clientId")); - assertThat(requestProcessor.verifyOptions.issuer, is("https://domain/")); - assertThat(requestProcessor.verifyOptions.verifier, is(notNullValue())); + controller.setLoggingEnabled(true); - assertThat(requestProcessor.verifyOptions.clockSkew, is(nullValue())); - assertThat(requestProcessor.verifyOptions.clock, is(nullValue())); - assertThat(requestProcessor.verifyOptions.nonce, is(nullValue())); - assertThat(requestProcessor.verifyOptions.getMaxAge(), is(nullValue())); + verify(mockRequestProcessor).setLoggingEnabled(true); } @Test - public void shouldHandleHttpDomain() { - AuthenticationController controller = AuthenticationController.newBuilder("http://domain/", "clientId", "clientSecret") - .build(); + public void shouldDisableTelemetry() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); - assertThat(controller, is(notNullValue())); - RequestProcessor requestProcessor = controller.getRequestProcessor(); - assertThat(requestProcessor.getResponseType(), contains("code")); - assertThat(requestProcessor.verifyOptions.audience, is("clientId")); - assertThat(requestProcessor.verifyOptions.issuer, is("http://domain/")); - assertThat(requestProcessor.verifyOptions.verifier, is(notNullValue())); + controller.doNotSendTelemetry(); - assertThat(requestProcessor.verifyOptions.clockSkew, is(nullValue())); - assertThat(requestProcessor.verifyOptions.clock, is(nullValue())); - assertThat(requestProcessor.verifyOptions.nonce, is(nullValue())); - assertThat(requestProcessor.verifyOptions.getMaxAge(), is(nullValue())); + verify(mockRequestProcessor).doNotSendTelemetry(); } + // --- Exception Propagation --- + @Test - public void shouldHandleHttpsDomain() { - AuthenticationController controller = AuthenticationController.newBuilder("https://domain/", "clientId", "clientSecret") - .build(); + public void shouldPropagateIdentityVerificationException() throws IdentityVerificationException { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + IdentityVerificationException expectedException = new IdentityVerificationException("test", "error", null); + when(mockRequestProcessor.process(request, response)).thenThrow(expectedException); - assertThat(controller, is(notNullValue())); - RequestProcessor requestProcessor = controller.getRequestProcessor(); - assertThat(requestProcessor.getResponseType(), contains("code")); - assertThat(requestProcessor.verifyOptions.audience, is("clientId")); - assertThat(requestProcessor.verifyOptions.issuer, is("https://domain/")); - assertThat(requestProcessor.verifyOptions.verifier, is(notNullValue())); + IdentityVerificationException actualException = assertThrows( + IdentityVerificationException.class, + () -> controller.handle(request, response)); - assertThat(requestProcessor.verifyOptions.clockSkew, is(nullValue())); - assertThat(requestProcessor.verifyOptions.clock, is(nullValue())); - assertThat(requestProcessor.verifyOptions.nonce, is(nullValue())); - assertThat(requestProcessor.verifyOptions.getMaxAge(), is(nullValue())); + assertThat(actualException, is(expectedException)); } + // --- RequestProcessor Integration --- + @Test - public void shouldCreateWithResponseType() { - AuthenticationController controller = AuthenticationController.newBuilder("domain", "clientId", "clientSecret") - .withResponseType("toKEn Id_TokEn cOdE") - .build(); + public void shouldGetRequestProcessor() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); - RequestProcessor requestProcessor = controller.getRequestProcessor(); - assertThat(requestProcessor.getResponseType(), contains("token", "id_token", "code")); - } + RequestProcessor result = controller.getRequestProcessor(); - @Test - public void shouldCreateWithJwkProvider() { - JwkProvider provider = mock(JwkProvider.class); - AuthenticationController.newBuilder("domain", "clientId", "clientSecret") - .withJwkProvider(provider) - .build(); + assertThat(result, is(mockRequestProcessor)); } + // --- Builder Variations --- + @Test - public void shouldCreateWithIDTokenVerificationLeeway() { - AuthenticationController controller = AuthenticationController.newBuilder("domain", "clientId", "clientSecret") - .withClockSkew(12345) + public void shouldBuildWithCodeResponseTypeAndNoJwkProvider() { + AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) + .withResponseType("code") .build(); - RequestProcessor requestProcessor = controller.getRequestProcessor(); - assertThat(requestProcessor.verifyOptions.clockSkew, is(12345)); + assertThat(controller, is(notNullValue())); } @Test - public void shouldCreateWithMaxAge() { - AuthenticationController controller = AuthenticationController.newBuilder("domain", "clientId", "clientSecret") - .withAuthenticationMaxAge(12345) + public void shouldBuildWithImplicitGrantRequiringJwkProvider() { + AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) + .withResponseType("id_token token") + .withJwkProvider(mockJwkProvider) .build(); - RequestProcessor requestProcessor = controller.getRequestProcessor(); - assertThat(requestProcessor.verifyOptions.getMaxAge(), is(12345)); + assertThat(controller, is(notNullValue())); } @Test - public void shouldProcessRequest() throws IdentityVerificationException { - RequestProcessor requestProcessor = mock(RequestProcessor.class); - AuthenticationController controller = new AuthenticationController(requestProcessor); - - HttpServletRequest req = new MockHttpServletRequest(); - HttpServletResponse response = new MockHttpServletResponse(); - - controller.handle(req, response); + public void shouldBuildWithDomainResolver() { + AuthenticationController controller = AuthenticationController + .newBuilder(mockDomainResolver, CLIENT_ID, CLIENT_SECRET) + .build(); - verify(requestProcessor).process(req, response); + assertThat(controller, is(notNullValue())); } @Test - public void shouldBuildAuthorizeUriWithRandomStateAndNonce() { - RequestProcessor requestProcessor = mock(RequestProcessor.class); - AuthenticationController controller = new AuthenticationController(requestProcessor); - - HttpServletRequest request = new MockHttpServletRequest(); - HttpServletResponse response = new MockHttpServletResponse(); - - controller.buildAuthorizeUrl(request, response,"https://redirect.uri/here"); + public void shouldBuildWithCustomHttpOptions() { + AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) + .withHttpOptions(mockHttpOptions) + .build(); - verify(requestProcessor).buildAuthorizeUrl(eq(request), eq(response), eq("https://redirect.uri/here"), anyString(), anyString()); + assertThat(controller, is(notNullValue())); } @Test - public void shouldSetLaxCookiesAndNoLegacyCookieWhenCodeFlow() { - MockHttpServletResponse response = new MockHttpServletResponse(); - - AuthenticationController controller = AuthenticationController.newBuilder("domain", "clientId", "clientSecret") - .withResponseType("code") - .build(); - - controller.buildAuthorizeUrl(new MockHttpServletRequest(), response, "https://redirect.uri/here") - .withState("state") + public void shouldBuildWithOrganizationAndInvitation() { + AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) + .withOrganization("org_123") + .withInvitation("inv_456") .build(); - List headers = response.getHeaders("Set-Cookie"); - - assertThat(headers.size(), is(1)); - assertThat(headers, everyItem(is("com.auth0.state=state; HttpOnly; Max-Age=600; SameSite=Lax"))); + assertThat(controller, is(notNullValue())); } @Test - public void shouldSetSameSiteNoneCookiesAndLegacyCookieWhenIdTokenResponse() { - MockHttpServletResponse response = new MockHttpServletResponse(); - - AuthenticationController controller = AuthenticationController.newBuilder("domain", "clientId", "clientSecret") - .withResponseType("id_token") - .build(); - - controller.buildAuthorizeUrl(new MockHttpServletRequest(), response, "https://redirect.uri/here") - .withState("state") - .withNonce("nonce") + public void shouldBuildWithCustomCookiePath() { + AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) + .withCookiePath("/custom/path") .build(); - List headers = response.getHeaders("Set-Cookie"); - - assertThat(headers.size(), is(4)); - assertThat(headers, hasItem("com.auth0.state=state; HttpOnly; Max-Age=600; SameSite=None; Secure")); - assertThat(headers, hasItem("_com.auth0.state=state; HttpOnly; Max-Age=600")); - assertThat(headers, hasItem("com.auth0.nonce=nonce; HttpOnly; Max-Age=600; SameSite=None; Secure")); - assertThat(headers, hasItem("_com.auth0.nonce=nonce; HttpOnly; Max-Age=600")); + assertThat(controller, is(notNullValue())); } @Test - public void shouldSetSameSiteNoneCookiesAndNoLegacyCookieWhenIdTokenResponse() { - MockHttpServletResponse response = new MockHttpServletResponse(); - - AuthenticationController controller = AuthenticationController.newBuilder("domain", "clientId", "clientSecret") - .withResponseType("id_token") + public void shouldBuildWithDisabledLegacySameSiteCookie() { + AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) .withLegacySameSiteCookie(false) .build(); - controller.buildAuthorizeUrl(new MockHttpServletRequest(), response, "https://redirect.uri/here") - .withState("state") - .withNonce("nonce") - .build(); - - List headers = response.getHeaders("Set-Cookie"); - - assertThat(headers.size(), is(2)); - assertThat(headers, hasItem("com.auth0.state=state; HttpOnly; Max-Age=600; SameSite=None; Secure")); - assertThat(headers, hasItem("com.auth0.nonce=nonce; HttpOnly; Max-Age=600; SameSite=None; Secure")); + assertThat(controller, is(notNullValue())); } @Test - public void shouldCheckSessionFallbackWhenHandleCalledWithRequestAndResponse() throws Exception { - AuthenticationController controller = builderSpy.withResponseType("code").build(); - - TokenRequest codeExchangeRequest = mock(TokenRequest.class); - TokenHolder tokenHolder = mock(TokenHolder.class); - when(codeExchangeRequest.execute()).thenReturn(tokenHolder); - when(client.exchangeCode("abc123", "http://localhost")).thenReturn(codeExchangeRequest); - - AuthorizeUrlBuilder mockBuilder = mock(AuthorizeUrlBuilder.class); - when(mockBuilder.withResponseType("code")).thenReturn(mockBuilder); - when(mockBuilder.withScope("openid")).thenReturn(mockBuilder); - when(client.authorizeUrl("https://redirect.uri/here")).thenReturn(mockBuilder); - - MockHttpServletRequest request = new MockHttpServletRequest(); - MockHttpServletResponse response = new MockHttpServletResponse(); - - // build auth URL using deprecated method, which stores state and nonce in session - String authUrl = controller.buildAuthorizeUrl(request, "https://redirect.uri/here") - .withState("state") - .withNonce("nonce") + public void shouldBuildWithCustomClockSkewAndMaxAge() { + AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) + .withClockSkew(180) + .withAuthenticationMaxAge(7200) .build(); - String state = (String) request.getSession().getAttribute("com.auth0.state"); - String nonce = (String) request.getSession().getAttribute("com.auth0.nonce"); - assertThat(state, is("state")); - assertThat(nonce, is("nonce")); - - request.setParameter("state", "state"); - request.setParameter("nonce", "nonce"); - request.setParameter("code", "abc123"); - - // handle called with request and response, which should use cookies but fallback to session - controller.handle(request, response); + assertThat(controller, is(notNullValue())); } - @Test - public void shouldCheckSessionFallbackWhenHandleCalledWithRequest() throws Exception { - AuthenticationController controller = builderSpy.withResponseType("code").build(); - - TokenRequest codeExchangeRequest = mock(TokenRequest.class); - TokenHolder tokenHolder = mock(TokenHolder.class); - when(codeExchangeRequest.execute()).thenReturn(tokenHolder); - when(client.exchangeCode("abc123", "http://localhost")).thenReturn(codeExchangeRequest); + // --- MCD Support --- - AuthorizeUrlBuilder mockBuilder = mock(AuthorizeUrlBuilder.class); - when(mockBuilder.withResponseType("code")).thenReturn(mockBuilder); - when(mockBuilder.withScope("openid")).thenReturn(mockBuilder); - when(client.authorizeUrl("https://redirect.uri/here")).thenReturn(mockBuilder); - - MockHttpServletRequest request = new MockHttpServletRequest(); - MockHttpServletResponse response = new MockHttpServletResponse(); - - // build auth URL using request and response, which stores state and nonce in cookies and also session as a fallback - String authUrl = controller.buildAuthorizeUrl(request, response,"https://redirect.uri/here") - .withState("state") - .withNonce("nonce") + @Test + public void shouldSupportMCDWithDomainResolver() { + AuthenticationController controller = AuthenticationController + .newBuilder(mockDomainResolver, CLIENT_ID, CLIENT_SECRET) .build(); - String state = (String) request.getSession().getAttribute("com.auth0.state"); - String nonce = (String) request.getSession().getAttribute("com.auth0.nonce"); - assertThat(state, is("state")); - assertThat(nonce, is("nonce")); - - request.setParameter("state", "state"); - request.setParameter("nonce", "nonce"); - request.setParameter("code", "abc123"); - - // handle called with request, which should use session - controller.handle(request); + assertThat(controller, is(notNullValue())); + assertThat(controller.getRequestProcessor(), is(notNullValue())); } + // --- Response Type Variations --- + @Test - public void shouldAllowOrganizationParameter() { - AuthenticationController controller = AuthenticationController.newBuilder("DOMAIN", "CLIENT_ID", "SECRET") - .withOrganization("orgId_abc123") + public void shouldHandleCodeResponseType() { + AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) + .withResponseType("code") .build(); - String authUrl = controller.buildAuthorizeUrl(new MockHttpServletRequest(), new MockHttpServletResponse(), "https://me.com/redirect") - .build(); - assertThat(authUrl, containsString("organization=orgId_abc123")); + assertThat(controller, is(notNullValue())); } @Test - public void shouldThrowOnNullOrganizationParameter() { - assertThrows(NullPointerException.class, - () -> AuthenticationController.newBuilder("DOMAIN", "CLIENT_ID", "SECRET") - .withOrganization(null)); + public void shouldHandleIdTokenResponseType() { + AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) + .withResponseType("id_token") + .withJwkProvider(mockJwkProvider) + .build(); + + assertThat(controller, is(notNullValue())); } @Test - public void shouldAllowInvitationParameter() { - AuthenticationController controller = AuthenticationController.newBuilder("DOMAIN", "CLIENT_ID", "SECRET") - .withInvitation("invitation_123") + public void shouldHandleTokenResponseType() { + AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) + .withResponseType("token") + .withJwkProvider(mockJwkProvider) .build(); - String authUrl = controller.buildAuthorizeUrl(new MockHttpServletRequest(), new MockHttpServletResponse(), "https://me.com/redirect") - .build(); - assertThat(authUrl, containsString("invitation=invitation_123")); + assertThat(controller, is(notNullValue())); } @Test - public void shouldThrowOnNullInvitationParameter() { - assertThrows(NullPointerException.class, - () -> AuthenticationController.newBuilder("DOMAIN", "CLIENT_ID", "SECRET") - .withInvitation(null)); + public void shouldHandleHybridFlowResponseType() { + AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) + .withResponseType("code id_token") + .withJwkProvider(mockJwkProvider) + .build(); + + assertThat(controller, is(notNullValue())); } @Test - public void shouldConfigureCookiePath() { - MockHttpServletResponse response = new MockHttpServletResponse(); - - AuthenticationController controller = AuthenticationController.newBuilder("domain", "clientId", "clientSecret") - .withCookiePath("/Path") + public void shouldHandleImplicitGrantResponseType() { + AuthenticationController controller = AuthenticationController.newBuilder(DOMAIN, CLIENT_ID, CLIENT_SECRET) + .withResponseType("id_token token") + .withJwkProvider(mockJwkProvider) .build(); - controller.buildAuthorizeUrl(new MockHttpServletRequest(), response, "https://redirect.uri/here") - .withState("state") - .build(); - - List headers = response.getHeaders("Set-Cookie"); - - assertThat(headers.size(), is(1)); - assertThat(headers, everyItem(is("com.auth0.state=state; HttpOnly; Max-Age=600; Path=/Path; SameSite=Lax"))); + assertThat(controller, is(notNullValue())); } } diff --git a/src/test/java/com/auth0/RequestProcessorTest.java b/src/test/java/com/auth0/RequestProcessorTest.java index 7ffcf60..7205b37 100644 --- a/src/test/java/com/auth0/RequestProcessorTest.java +++ b/src/test/java/com/auth0/RequestProcessorTest.java @@ -1,611 +1,596 @@ 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.net.TokenRequest; -import org.hamcrest.CoreMatchers; +import com.auth0.net.Telemetry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; -import javax.servlet.http.Cookie; -import javax.servlet.http.HttpServletRequest; +import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; -import java.util.Map; +import java.util.List; -import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.not; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.hamcrest.core.Is.is; +import static org.hamcrest.core.IsNull.notNullValue; +import static org.hamcrest.core.IsNull.nullValue; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; public class RequestProcessorTest { + private static final String DOMAIN = "test-domain.auth0.com"; + private static final String CLIENT_ID = "testClientId"; + private static final String CLIENT_SECRET = "testClientSecret"; + private static final String RESPONSE_TYPE_CODE = "code"; + private static final String RESPONSE_TYPE_TOKEN = "token"; + private static final String RESPONSE_TYPE_ID_TOKEN = "id_token"; + + @Mock + private DomainProvider mockDomainProvider; + @Mock + private SignatureVerifier mockSignatureVerifier; + @Mock + private IdTokenVerifier mockIdTokenVerifier; @Mock - private AuthAPI client; + private HttpOptions mockHttpOptions; @Mock - private IdTokenVerifier.Options verifyOptions; + private AuthAPI mockAuthAPI; @Mock - private IdTokenVerifier tokenVerifier; + private TokenRequest mockTokenRequest; + @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); + request = new MockHttpServletRequest(); response = new MockHttpServletResponse(); + request.setSecure(true); } + // --- Builder Tests --- + @Test - public void shouldThrowOnMissingAuthAPI() { - assertThrows(NullPointerException.class, () -> new RequestProcessor.Builder(null, "responseType", verifyOptions)); + public void shouldBuildRequestProcessorWithRequiredParameters() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET, + mockHttpOptions, + mockSignatureVerifier) + .build(); + + assertThat(processor, is(notNullValue())); } @Test - public void shouldThrowOnMissingResponseType() { - assertThrows(NullPointerException.class, () -> new RequestProcessor.Builder(client, null, verifyOptions)); + public void shouldBuildRequestProcessorWithAllOptionalParameters() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET, + mockHttpOptions, + mockSignatureVerifier) + .withClockSkew(120) + .withAuthenticationMaxAge(3600) + .withCookiePath("/custom") + .withLegacySameSiteCookie(false) + .withOrganization("org_123") + .withInvitation("inv_456") + .build(); + + assertThat(processor, is(notNullValue())); } @Test - public void shouldNotThrowOnMissingTokenVerifierOptions() { - assertThrows(NullPointerException.class, () -> new RequestProcessor.Builder(client, "responseType", null)); + public void shouldSetDefaultLegacySameSiteCookieToTrue() { + RequestProcessor processor = createDefaultRequestProcessor(); + + assertThat(processor.useLegacySameSiteCookie, is(true)); } @Test - public void shouldThrowOnProcessIfRequestHasError() throws Exception { - Map params = new HashMap<>(); - params.put("error", "something happened"); - HttpServletRequest request = getRequest(params); - - RequestProcessor handler = new RequestProcessor.Builder(client, "code", verifyOptions) + public void shouldDisableLegacySameSiteCookie() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET, + mockHttpOptions, + mockSignatureVerifier) + .withLegacySameSiteCookie(false) .build(); - InvalidRequestException e = assertThrows(InvalidRequestException.class, () -> handler.process(request, response)); - assertThat(e, InvalidRequestExceptionMatcher.hasCode("something happened")); - assertEquals("The request contains an error", e.getMessage()); + + assertThat(processor.useLegacySameSiteCookie, is(false)); } + // --- Domain Handling Tests --- + @Test - public void shouldThrowOnProcessIfRequestHasInvalidState() throws Exception { - Map params = new HashMap<>(); - params.put("state", "1234"); - MockHttpServletRequest request = getRequest(params); - request.setCookies(new Cookie("com.auth0.state", "9999"));; + public void shouldGetDomainFromProvider() { + String expectedDomain = "custom-domain.auth0.com"; + lenient().when(mockDomainProvider.getDomain(any())).thenReturn(expectedDomain); - RequestProcessor handler = new RequestProcessor.Builder(client, "code", verifyOptions) - .build(); - InvalidRequestException e = assertThrows(InvalidRequestException.class, () -> handler.process(request, response)); - assertThat(e, InvalidRequestExceptionMatcher.hasCode("a0.invalid_state")); - assertEquals("The received state doesn't match the expected one.", e.getMessage()); + RequestProcessor processor = createDefaultRequestProcessor(); + RequestProcessor spy = spy(processor); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + spy.buildAuthorizeUrl(request, response, "https://callback.com", "state123", "nonce123"); + + verify(mockDomainProvider).getDomain(request); + verify(spy).createClientForDomain(expectedDomain); } @Test - public void shouldThrowOnProcessIfRequestHasInvalidStateInSession() throws Exception { - Map params = new HashMap<>(); - params.put("state", "1234"); - MockHttpServletRequest request = getRequest(params); - request.getSession().setAttribute("com.auth0.state", "9999"); - - RequestProcessor handler = new RequestProcessor.Builder(client, "code", verifyOptions) + public void shouldCreateClientForDomainWithHttpOptions() { + HttpOptions httpOptions = new HttpOptions(); + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET, + httpOptions, + mockSignatureVerifier) .build(); - InvalidRequestException e = assertThrows(InvalidRequestException.class, () -> handler.process(request, response)); - assertThat(e, InvalidRequestExceptionMatcher.hasCode("a0.invalid_state")); - assertEquals("The received state doesn't match the expected one.", e.getMessage()); + + AuthAPI result = processor.createClientForDomain(DOMAIN); + + assertThat(result, is(notNullValue())); } @Test - public void shouldThrowOnProcessIfRequestHasMissingStateParameter() throws Exception { - MockHttpServletRequest request = getRequest(Collections.emptyMap()); - request.setCookies(new Cookie("com.auth0.state", "1234")); - - RequestProcessor handler = new RequestProcessor.Builder(client, "code", verifyOptions) + public void shouldCreateClientForDomainWithoutHttpOptions() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET, + null, + mockSignatureVerifier) .build(); - InvalidRequestException e = assertThrows(InvalidRequestException.class, () -> handler.process(request, response)); - assertThat(e, InvalidRequestExceptionMatcher.hasCode("a0.invalid_state")); - assertEquals("The received state doesn't match the expected one. No state parameter was found on the authorization response.", e.getMessage()); + + AuthAPI result = processor.createClientForDomain(DOMAIN); + + assertThat(result, is(notNullValue())); } + // --- Logging and Telemetry Tests --- + @Test - public void shouldThrowOnProcessIfRequestHasMissingStateCookie() throws Exception { - Map params = new HashMap<>(); - params.put("state", "1234"); - MockHttpServletRequest request = getRequest(params); + public void shouldSetLoggingEnabled() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET, + null, + mockSignatureVerifier) + .build(); + + processor.setLoggingEnabled(true); - RequestProcessor handler = new RequestProcessor.Builder(client, "code", verifyOptions) + AuthAPI client = processor.createClientForDomain(DOMAIN); + assertThat(client, is(notNullValue())); + } + + @Test + public void shouldDisableTelemetry() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET, + null, + mockSignatureVerifier) .build(); - InvalidRequestException e = assertThrows(InvalidRequestException.class, () -> handler.process(request, response)); - assertThat(e, InvalidRequestExceptionMatcher.hasCode("a0.invalid_state")); - assertEquals("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.", e.getMessage()); + + processor.doNotSendTelemetry(); + + AuthAPI client = processor.createClientForDomain(DOMAIN); + assertThat(client, is(notNullValue())); } @Test - public void shouldThrowOnProcessIfIdTokenRequestIsMissingIdToken() throws Exception { - Map params = new HashMap<>(); - params.put("state", "1234"); - MockHttpServletRequest request = getRequest(params); - request.setCookies(new Cookie("com.auth0.state", "1234")); + public void shouldSetupTelemetryWithVersion() { + RequestProcessor processor = createDefaultRequestProcessor(); - RequestProcessor handler = new RequestProcessor.Builder(client, "id_token", verifyOptions) - .build(); - InvalidRequestException e = assertThrows(InvalidRequestException.class, () -> handler.process(request, response)); - assertThat(e, InvalidRequestExceptionMatcher.hasCode("a0.missing_id_token")); - assertEquals("ID Token is missing from the response.", e.getMessage()); + processor.setupTelemetry(mockAuthAPI); + + verify(mockAuthAPI).setTelemetry(any(Telemetry.class)); } @Test - public void shouldThrowOnProcessIfTokenRequestIsMissingAccessToken() throws Exception { - Map params = new HashMap<>(); - params.put("state", "1234"); - MockHttpServletRequest request = getRequest(params); - request.setCookies(new Cookie("com.auth0.state", "1234")); + public void shouldReturnNullPackageVersionInDevEnvironment() { + RequestProcessor processor = createDefaultRequestProcessor(); - RequestProcessor handler = new RequestProcessor.Builder(client, "token", verifyOptions) - .build(); - InvalidRequestException e = assertThrows(InvalidRequestException.class, () -> handler.process(request, response)); - assertThat(e, InvalidRequestExceptionMatcher.hasCode("a0.missing_access_token")); - assertEquals("Access Token is missing from the response.", e.getMessage()); + String version = processor.obtainPackageVersion(); + + assertThat(version, is(nullValue())); } + // --- Response Type Parsing Tests --- + @Test - public void shouldThrowOnProcessIfIdTokenRequestDoesNotPassIdTokenVerification() throws Exception { - doThrow(TokenValidationException.class).when(tokenVerifier).verify(eq("frontIdToken"), eq(verifyOptions)); + public void shouldParseResponseTypeCode() { + RequestProcessor processor = createRequestProcessorWithResponseType(RESPONSE_TYPE_CODE); - Map params = new HashMap<>(); - params.put("state", "1234"); - params.put("id_token", "frontIdToken"); - MockHttpServletRequest request = getRequest(params); - request.setCookies(new Cookie("com.auth0.state", "1234")); + List responseType = processor.getResponseType(); - RequestProcessor handler = new RequestProcessor.Builder(client, "id_token", verifyOptions) - .withIdTokenVerifier(tokenVerifier) - .build(); - IdentityVerificationException e = assertThrows(IdentityVerificationException.class, () -> handler.process(request, response)); - assertThat(e, IdentityVerificationExceptionMatcher.hasCode("a0.invalid_jwt_error")); - assertEquals("An error occurred while trying to verify the ID Token.", e.getMessage()); + assertThat(responseType, is(Collections.singletonList("code"))); } @Test - public void shouldReturnTokensOnProcessIfIdTokenRequestPassesIdTokenVerification() throws Exception { - doNothing().when(tokenVerifier).verify(eq("frontIdToken"), eq(verifyOptions)); + public void shouldParseResponseTypeToken() { + RequestProcessor processor = createRequestProcessorWithResponseType(RESPONSE_TYPE_TOKEN); - Map params = new HashMap<>(); - params.put("state", "1234"); - params.put("id_token", "frontIdToken"); - MockHttpServletRequest request = getRequest(params); - request.setCookies(new Cookie("com.auth0.state", "1234"), new Cookie("com.auth0.nonce", "5678")); + List responseType = processor.getResponseType(); - RequestProcessor handler = new RequestProcessor.Builder(client, "id_token", verifyOptions) - .withIdTokenVerifier(tokenVerifier) - .build(); - Tokens process = handler.process(request, response); - assertThat(process, is(notNullValue())); - assertThat(process.getIdToken(), is("frontIdToken")); + assertThat(responseType, is(Collections.singletonList("token"))); } @Test - public void shouldThrowOnProcessIfIdTokenCodeRequestDoesNotPassIdTokenVerification() throws Exception { - doThrow(TokenValidationException.class).when(tokenVerifier).verify(eq("frontIdToken"), eq(verifyOptions)); + public void shouldParseResponseTypeIdToken() { + RequestProcessor processor = createRequestProcessorWithResponseType(RESPONSE_TYPE_ID_TOKEN); - Map params = new HashMap<>(); - params.put("code", "abc123"); - params.put("state", "1234"); - params.put("id_token", "frontIdToken"); - MockHttpServletRequest request = getRequest(params); - request.setCookies(new Cookie("com.auth0.state", "1234")); + List responseType = processor.getResponseType(); - RequestProcessor handler = new RequestProcessor.Builder(client, "id_token code", verifyOptions) - .withIdTokenVerifier(tokenVerifier) - .build(); - IdentityVerificationException e = assertThrows(IdentityVerificationException.class, () -> handler.process(request, response)); - assertThat(e, IdentityVerificationExceptionMatcher.hasCode("a0.invalid_jwt_error")); - assertEquals("An error occurred while trying to verify the ID Token.", e.getMessage()); + assertThat(responseType, is(Collections.singletonList("id_token"))); } @Test - public void shouldThrowOnProcessIfCodeRequestFailsToExecuteCodeExchange() throws Exception { - Map params = new HashMap<>(); - params.put("code", "abc123"); - params.put("state", "1234"); - MockHttpServletRequest request = getRequest(params); - request.setCookies(new Cookie("com.auth0.state", "1234")); + public void shouldParseMultipleResponseTypes() { + RequestProcessor processor = createRequestProcessorWithResponseType("code id_token token"); - TokenRequest codeExchangeRequest = mock(TokenRequest.class); - when(codeExchangeRequest.execute()).thenThrow(Auth0Exception.class); - when(client.exchangeCode("abc123", "https://me.auth0.com:80/callback")).thenReturn(codeExchangeRequest); + List responseType = processor.getResponseType(); - RequestProcessor handler = new RequestProcessor.Builder(client, "code", verifyOptions) - .withIdTokenVerifier(tokenVerifier) - .build(); - IdentityVerificationException e = assertThrows(IdentityVerificationException.class, () -> handler.process(request, response)); - assertThat(e, IdentityVerificationExceptionMatcher.hasCode("a0.api_error")); - assertEquals("An error occurred while exchanging the authorization code.", e.getMessage()); + assertThat(responseType, is(Arrays.asList("code", "id_token", "token"))); } + // --- Form Post Response Mode Tests --- + @Test - public void shouldThrowOnProcessIfCodeRequestSucceedsButDoesNotPassIdTokenVerification() throws Exception { - doThrow(TokenValidationException.class).when(tokenVerifier).verify(eq("backIdToken"), eq(verifyOptions)); + public void shouldRequireFormPostForImplicitGrant() { + boolean requiresFormPost = RequestProcessor.requiresFormPostResponseMode( + Arrays.asList("id_token", "token")); - Map params = new HashMap<>(); - params.put("code", "abc123"); - params.put("state", "1234"); - MockHttpServletRequest request = getRequest(params); - request.setCookies(new Cookie("com.auth0.state", "1234")); + assertThat(requiresFormPost, is(true)); + } - TokenRequest codeExchangeRequest = mock(TokenRequest.class); - TokenHolder tokenHolder = mock(TokenHolder.class); - when(tokenHolder.getIdToken()).thenReturn("backIdToken"); - when(codeExchangeRequest.execute()).thenReturn(tokenHolder); - when(client.exchangeCode("abc123", "https://me.auth0.com:80/callback")).thenReturn(codeExchangeRequest); + @Test + public void shouldNotRequireFormPostForCodeGrant() { + boolean requiresFormPost = RequestProcessor.requiresFormPostResponseMode( + Collections.singletonList("code")); - RequestProcessor handler = new RequestProcessor.Builder(client, "code", verifyOptions) - .withIdTokenVerifier(tokenVerifier) - .build(); - IdentityVerificationException e = assertThrows(IdentityVerificationException.class, () -> handler.process(request, response)); - assertThat(e, IdentityVerificationExceptionMatcher.hasCode("a0.invalid_jwt_error")); - assertEquals("An error occurred while trying to verify the ID Token.", e.getMessage()); + assertThat(requiresFormPost, is(false)); + } + + @Test + public void shouldRequireFormPostForHybridFlow() { + boolean requiresFormPost = RequestProcessor.requiresFormPostResponseMode( + Arrays.asList("code", "id_token")); + assertThat(requiresFormPost, is(true)); } @Test - public void shouldReturnTokensOnProcessIfIdTokenCodeRequestPassesIdTokenVerification() throws Exception { - doNothing().when(tokenVerifier).verify(eq("frontIdToken"), eq(verifyOptions)); + public void shouldNotRequireFormPostForNullResponseType() { + boolean requiresFormPost = RequestProcessor.requiresFormPostResponseMode(null); - Map params = new HashMap<>(); - params.put("code", "abc123"); - params.put("state", "1234"); - params.put("id_token", "frontIdToken"); - params.put("expires_in", "8400"); - params.put("token_type", "frontTokenType"); - MockHttpServletRequest request = getRequest(params); - request.setCookies(new Cookie("com.auth0.state", "1234")); + assertThat(requiresFormPost, is(false)); + } - TokenRequest codeExchangeRequest = mock(TokenRequest.class); - TokenHolder tokenHolder = mock(TokenHolder.class); - when(tokenHolder.getIdToken()).thenReturn("backIdToken"); - when(tokenHolder.getExpiresIn()).thenReturn(4800L); - when(tokenHolder.getTokenType()).thenReturn("backTokenType"); - when(codeExchangeRequest.execute()).thenReturn(tokenHolder); - when(client.exchangeCode("abc123", "https://me.auth0.com:80/callback")).thenReturn(codeExchangeRequest); + // --- AuthorizeUrl Building Tests --- - RequestProcessor handler = new RequestProcessor.Builder(client, "id_token code", verifyOptions) - .withIdTokenVerifier(tokenVerifier) - .build(); - Tokens tokens = handler.process(request, response); - - //Should not verify the ID Token twice - verify(tokenVerifier).verify("frontIdToken", verifyOptions); - verify(tokenVerifier, never()).verify("backIdToken", verifyOptions); - verifyNoMoreInteractions(tokenVerifier); - - assertThat(tokens, is(notNullValue())); - assertThat(tokens.getIdToken(), is("frontIdToken")); - assertThat(tokens.getType(), is("frontTokenType")); - assertThat(tokens.getExpiresIn(), is(8400L)); - } - - @Test - public void shouldReturnTokensOnProcessIfIdTokenCodeRequestPassesIdTokenVerificationWhenUsingSessionStorage() throws Exception { - doNothing().when(tokenVerifier).verify(eq("frontIdToken"), eq(verifyOptions)); - - Map params = new HashMap<>(); - params.put("code", "abc123"); - params.put("state", "1234"); - params.put("id_token", "frontIdToken"); - params.put("expires_in", "8400"); - params.put("token_type", "frontTokenType"); - MockHttpServletRequest request = getRequest(params); - request.getSession().setAttribute("com.auth0.state", "1234"); - - TokenRequest codeExchangeRequest = mock(TokenRequest.class); - TokenHolder tokenHolder = mock(TokenHolder.class); - when(tokenHolder.getIdToken()).thenReturn("backIdToken"); - when(tokenHolder.getExpiresIn()).thenReturn(4800L); - when(tokenHolder.getTokenType()).thenReturn("backTokenType"); - when(codeExchangeRequest.execute()).thenReturn(tokenHolder); - when(client.exchangeCode("abc123", "https://me.auth0.com:80/callback")).thenReturn(codeExchangeRequest); - - RequestProcessor handler = new RequestProcessor.Builder(client, "id_token code", verifyOptions) - .withIdTokenVerifier(tokenVerifier) - .build(); - Tokens tokens = handler.process(request, response); - - //Should not verify the ID Token twice - verify(tokenVerifier).verify("frontIdToken", verifyOptions); - verify(tokenVerifier, never()).verify("backIdToken", verifyOptions); - verifyNoMoreInteractions(tokenVerifier); - - assertThat(tokens, is(notNullValue())); - assertThat(tokens.getIdToken(), is("frontIdToken")); - assertThat(tokens.getType(), is("frontTokenType")); - assertThat(tokens.getExpiresIn(), is(8400L)); - } - - @Test - public void shouldReturnTokensOnProcessIfIdTokenCodeRequestPassesIdTokenVerificationWhenUsingSessionStorageWithNullSession() throws Exception { - doNothing().when(tokenVerifier).verify(eq("frontIdToken"), eq(verifyOptions)); - - Map params = new HashMap<>(); - params.put("code", "abc123"); - params.put("state", "1234"); - params.put("id_token", "frontIdToken"); - params.put("expires_in", "8400"); - params.put("token_type", "frontTokenType"); - MockHttpServletRequest request = getRequest(params); - request.getSession().setAttribute("com.auth0.state", "1234"); - - TokenRequest codeExchangeRequest = mock(TokenRequest.class); - TokenHolder tokenHolder = mock(TokenHolder.class); - when(tokenHolder.getIdToken()).thenReturn("backIdToken"); - when(tokenHolder.getExpiresIn()).thenReturn(4800L); - when(tokenHolder.getTokenType()).thenReturn("backTokenType"); - when(codeExchangeRequest.execute()).thenReturn(tokenHolder); - when(client.exchangeCode("abc123", "https://me.auth0.com:80/callback")).thenReturn(codeExchangeRequest); - - RequestProcessor handler = new RequestProcessor.Builder(client, "id_token code", verifyOptions) - .withIdTokenVerifier(tokenVerifier) - .build(); - Tokens tokens = handler.process(request, null); - - //Should not verify the ID Token twice - verify(tokenVerifier).verify("frontIdToken", verifyOptions); - verify(tokenVerifier, never()).verify("backIdToken", verifyOptions); - verifyNoMoreInteractions(tokenVerifier); - - assertThat(tokens, is(notNullValue())); - assertThat(tokens.getIdToken(), is("frontIdToken")); - assertThat(tokens.getType(), is("frontTokenType")); - assertThat(tokens.getExpiresIn(), is(8400L)); - } - - @Test - public void shouldReturnTokensOnProcessIfTokenIdTokenCodeRequestPassesIdTokenVerification() throws Exception { - doNothing().when(tokenVerifier).verify(eq("frontIdToken"), eq(verifyOptions)); - - Map params = new HashMap<>(); - params.put("code", "abc123"); - params.put("state", "1234"); - params.put("id_token", "frontIdToken"); - params.put("access_token", "frontAccessToken"); - params.put("expires_in", "8400"); - params.put("token_type", "frontTokenType"); - MockHttpServletRequest request = getRequest(params); - request.setCookies(new Cookie("com.auth0.state", "1234")); - - TokenRequest codeExchangeRequest = mock(TokenRequest.class); - TokenHolder tokenHolder = mock(TokenHolder.class); - when(tokenHolder.getIdToken()).thenReturn("backIdToken"); - when(tokenHolder.getAccessToken()).thenReturn("backAccessToken"); - when(tokenHolder.getRefreshToken()).thenReturn("backRefreshToken"); - when(tokenHolder.getExpiresIn()).thenReturn(4800L); - when(tokenHolder.getTokenType()).thenReturn("backTokenType"); - when(codeExchangeRequest.execute()).thenReturn(tokenHolder); - when(client.exchangeCode("abc123", "https://me.auth0.com:80/callback")).thenReturn(codeExchangeRequest); - - RequestProcessor handler = new RequestProcessor.Builder(client, "id_token token code", verifyOptions) - .withIdTokenVerifier(tokenVerifier) + @Test + public void shouldBuildAuthorizeUrlWithStateAndNonce() { + lenient().when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + RequestProcessor processor = createDefaultRequestProcessor(); + RequestProcessor spy = spy(processor); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + AuthorizeUrl result = spy.buildAuthorizeUrl(request, response, "https://callback.com", "state123", "nonce123"); + + assertThat(result, is(notNullValue())); + verify(spy).createClientForDomain(DOMAIN); + } + + @Test + public void shouldBuildAuthorizeUrlWithOrganization() { + lenient().when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET, + mockHttpOptions, + mockSignatureVerifier) + .withOrganization("org_123") .build(); - Tokens tokens = handler.process(request, response); - //Should not verify the ID Token twice - verify(tokenVerifier).verify("frontIdToken", verifyOptions); - verify(tokenVerifier, never()).verify("backIdToken", verifyOptions); - verifyNoMoreInteractions(tokenVerifier); + RequestProcessor spy = spy(processor); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + AuthorizeUrl result = spy.buildAuthorizeUrl(request, response, "https://callback.com", "state123", "nonce123"); - assertThat(tokens, is(notNullValue())); - assertThat(tokens.getIdToken(), is("frontIdToken")); - assertThat(tokens.getAccessToken(), is("backAccessToken")); - assertThat(tokens.getRefreshToken(), is("backRefreshToken")); - assertThat(tokens.getExpiresIn(), is(4800L)); - assertThat(tokens.getType(), is("backTokenType")); + assertThat(result, is(notNullValue())); } @Test - public void shouldReturnTokensOnProcessIfCodeRequestPassesIdTokenVerification() throws Exception { - doNothing().when(tokenVerifier).verify(eq("backIdToken"), eq(verifyOptions)); + public void shouldBuildAuthorizeUrlWithInvitation() { + lenient().when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET, + mockHttpOptions, + mockSignatureVerifier) + .withInvitation("inv_456") + .build(); - Map params = new HashMap<>(); - params.put("code", "abc123"); - params.put("state", "1234"); - MockHttpServletRequest request = getRequest(params); - request.setCookies(new Cookie("com.auth0.state", "1234")); + RequestProcessor spy = spy(processor); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); - TokenRequest codeExchangeRequest = mock(TokenRequest.class); - TokenHolder tokenHolder = mock(TokenHolder.class); - when(tokenHolder.getIdToken()).thenReturn("backIdToken"); - when(tokenHolder.getAccessToken()).thenReturn("backAccessToken"); - when(tokenHolder.getRefreshToken()).thenReturn("backRefreshToken"); - when(codeExchangeRequest.execute()).thenReturn(tokenHolder); - when(client.exchangeCode("abc123", "https://me.auth0.com:80/callback")).thenReturn(codeExchangeRequest); + AuthorizeUrl result = spy.buildAuthorizeUrl(request, response, "https://callback.com", "state123", "nonce123"); - RequestProcessor handler = new RequestProcessor.Builder(client, "code", verifyOptions) - .withIdTokenVerifier(tokenVerifier) + assertThat(result, is(notNullValue())); + } + + @Test + public void shouldBuildAuthorizeUrlWithCustomCookiePath() { + lenient().when(mockDomainProvider.getDomain(any())).thenReturn(DOMAIN); + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET, + mockHttpOptions, + mockSignatureVerifier) + .withCookiePath("/custom") .build(); - Tokens tokens = handler.process(request, response); - verify(tokenVerifier).verify("backIdToken", verifyOptions); - verifyNoMoreInteractions(tokenVerifier); + RequestProcessor spy = spy(processor); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); - assertThat(tokens, is(notNullValue())); - assertThat(tokens.getIdToken(), is("backIdToken")); - assertThat(tokens.getAccessToken(), is("backAccessToken")); - assertThat(tokens.getRefreshToken(), is("backRefreshToken")); + AuthorizeUrl result = spy.buildAuthorizeUrl(request, response, "https://callback.com", "state123", "nonce123"); + + assertThat(result, is(notNullValue())); } + // --- Error Handling Tests --- + @Test - public void shouldReturnEmptyTokensWhenCodeRequestReturnsNoTokens() throws Exception { - Map params = new HashMap<>(); - params.put("code", "abc123"); - params.put("state", "1234"); - MockHttpServletRequest request = getRequest(params); - request.setCookies(new Cookie("com.auth0.state", "1234")); + public void shouldThrowExceptionWhenErrorInRequest() { + request.setParameter("error", "access_denied"); + request.setParameter("error_description", "The user denied the request"); - TokenRequest codeExchangeRequest = mock(TokenRequest.class); - TokenHolder tokenHolder = mock(TokenHolder.class); - when(codeExchangeRequest.execute()).thenReturn(tokenHolder); - when(client.exchangeCode("abc123", "https://me.auth0.com:80/callback")).thenReturn(codeExchangeRequest); + RequestProcessor processor = createDefaultRequestProcessor(); - RequestProcessor handler = new RequestProcessor.Builder(client, "code", verifyOptions) - .withIdTokenVerifier(tokenVerifier) - .build(); - Tokens tokens = handler.process(request, response); + InvalidRequestException exception = assertThrows( + InvalidRequestException.class, + () -> processor.process(request, response)); - verifyNoMoreInteractions(tokenVerifier); + assertThat(exception.getCode(), is("access_denied")); + assertThat(exception.getMessage(), is("The user denied the request")); + } + + @Test + public void shouldThrowExceptionWhenStateIsMissing() { + request.setParameter("code", "test_code"); - assertThat(tokens, is(notNullValue())); + RequestProcessor processor = createDefaultRequestProcessor(); - assertThat(tokens.getIdToken(), is(nullValue())); - assertThat(tokens.getAccessToken(), is(nullValue())); - assertThat(tokens.getRefreshToken(), is(nullValue())); + InvalidRequestException exception = assertThrows( + InvalidRequestException.class, + () -> processor.process(request, response)); + + assertThat(exception.getCode(), is("a0.invalid_state")); } @Test - public void shouldBuildAuthorizeUrl() { - AuthAPI client = new AuthAPI("me.auth0.com", "clientId", "clientSecret"); - SignatureVerifier signatureVerifier = mock(SignatureVerifier.class); - IdTokenVerifier.Options verifyOptions = new IdTokenVerifier.Options("issuer", "audience", signatureVerifier); - RequestProcessor handler = new RequestProcessor.Builder(client, "code", verifyOptions) - .build(); - HttpServletRequest request = new MockHttpServletRequest(); - AuthorizeUrl builder = handler.buildAuthorizeUrl(request, response,"https://redirect.uri/here", "state", "nonce"); - String authorizeUrl = builder.build(); - - assertThat(authorizeUrl, is(notNullValue())); - assertThat(authorizeUrl, CoreMatchers.startsWith("https://me.auth0.com/authorize?")); - assertThat(authorizeUrl, containsString("client_id=clientId")); - assertThat(authorizeUrl, containsString("redirect_uri=https://redirect.uri/here")); - assertThat(authorizeUrl, containsString("response_type=code")); - assertThat(authorizeUrl, containsString("scope=openid")); - assertThat(authorizeUrl, containsString("state=state")); - assertThat(authorizeUrl, not(containsString("max_age="))); - assertThat(authorizeUrl, not(containsString("nonce=nonce"))); - assertThat(authorizeUrl, not(containsString("response_mode=form_post"))); - } - - @Test - public void shouldSetMaxAgeIfProvided() { - AuthAPI client = new AuthAPI("me.auth0.com", "clientId", "clientSecret"); - when(verifyOptions.getMaxAge()).thenReturn(906030); - RequestProcessor handler = new RequestProcessor.Builder(client, "code", verifyOptions) - .build(); - HttpServletRequest request = new MockHttpServletRequest(); - AuthorizeUrl builder = handler.buildAuthorizeUrl(request, response,"https://redirect.uri/here", "state", "nonce"); - String authorizeUrl = builder.build(); + public void shouldThrowExceptionWhenIdTokenMissingForImplicitGrant() { + request.setParameter("state", "validState"); - assertThat(authorizeUrl, is(notNullValue())); - assertThat(authorizeUrl, containsString("max_age=906030")); + RequestProcessor processor = createRequestProcessorWithResponseType(RESPONSE_TYPE_ID_TOKEN); + + InvalidRequestException exception = assertThrows( + InvalidRequestException.class, + () -> processor.process(request, response)); + + assertThat(exception, is(notNullValue())); + assertThat(exception.getCode(), is(notNullValue())); } @Test - public void shouldNotSetNonceIfRequestTypeIsNotIdToken() { - AuthAPI client = new AuthAPI("me.auth0.com", "clientId", "clientSecret"); - RequestProcessor handler = new RequestProcessor.Builder(client, "code", verifyOptions) - .build(); - HttpServletRequest request = new MockHttpServletRequest(); - AuthorizeUrl builder = handler.buildAuthorizeUrl(request, response,"https://redirect.uri/here", "state", "nonce"); - String authorizeUrl = builder.build(); + public void shouldThrowExceptionWhenAccessTokenMissingForTokenGrant() { + request.setParameter("state", "validState"); - assertThat(authorizeUrl, is(notNullValue())); - assertThat(authorizeUrl, not(containsString("nonce=nonce"))); + RequestProcessor processor = createRequestProcessorWithResponseType(RESPONSE_TYPE_TOKEN); + + InvalidRequestException exception = assertThrows( + InvalidRequestException.class, + () -> processor.process(request, response)); + + assertThat(exception, is(notNullValue())); + assertThat(exception.getCode(), is(notNullValue())); } + // --- Token Processing Tests --- + @Test - public void shouldSetNonceIfRequestTypeIsIdToken() { - AuthAPI client = new AuthAPI("me.auth0.com", "clientId", "clientSecret"); - RequestProcessor handler = new RequestProcessor.Builder(client, "id_token", verifyOptions) - .build(); - HttpServletRequest request = new MockHttpServletRequest(); - AuthorizeUrl builder = handler.buildAuthorizeUrl(request, response,"https://redirect.uri/here", "state", "nonce"); - String authorizeUrl = builder.build(); + 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"); - assertThat(authorizeUrl, is(notNullValue())); - assertThat(authorizeUrl, containsString("nonce=nonce")); + 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())); + } } @Test - public void shouldNotSetNullNonceIfRequestTypeIsIdToken() { - AuthAPI client = new AuthAPI("me.auth0.com", "clientId", "clientSecret"); - RequestProcessor handler = new RequestProcessor.Builder(client, "id_token", verifyOptions) - .build(); - HttpServletRequest request = new MockHttpServletRequest(); - AuthorizeUrl builder = handler.buildAuthorizeUrl(request, response,"https://redirect.uri/here", "state", null); - String authorizeUrl = builder.build(); + public void shouldProcessImplicitGrantFlow() throws Exception { + request.setParameter("access_token", "access_token_123"); + request.setParameter("id_token", createMockIdToken()); + request.setParameter("token_type", "Bearer"); + request.setParameter("expires_in", "3600"); + request.setParameter("state", "validState"); + + response.addCookie(new javax.servlet.http.Cookie("com.auth0.state", "validState")); + + RequestProcessor processor = createRequestProcessorWithResponseType("id_token token"); - assertThat(authorizeUrl, is(notNullValue())); - assertThat(authorizeUrl, not(containsString("nonce=nonce"))); + 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())); + } } + // --- Builder Configuration Tests --- + @Test - public void shouldBuildAuthorizeUrlWithNonceAndFormPostIfResponseTypeIsIdToken() { - AuthAPI client = new AuthAPI("me.auth0.com", "clientId", "clientSecret"); - RequestProcessor handler = new RequestProcessor.Builder(client, "id_token", verifyOptions) + public void shouldSupportOrganizationParameter() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET, + mockHttpOptions, + mockSignatureVerifier) + .withOrganization("org_123") .build(); - HttpServletRequest request = new MockHttpServletRequest(); - AuthorizeUrl builder = handler.buildAuthorizeUrl(request, response,"https://redirect.uri/here", "state", "nonce"); - String authorizeUrl = builder.build(); - assertThat(authorizeUrl, is(notNullValue())); - assertThat(authorizeUrl, CoreMatchers.startsWith("https://me.auth0.com/authorize?")); - assertThat(authorizeUrl, containsString("client_id=clientId")); - assertThat(authorizeUrl, containsString("redirect_uri=https://redirect.uri/here")); - assertThat(authorizeUrl, containsString("response_type=id_token")); - assertThat(authorizeUrl, containsString("scope=openid")); - assertThat(authorizeUrl, containsString("state=state")); - assertThat(authorizeUrl, containsString("nonce=nonce")); - assertThat(authorizeUrl, containsString("response_mode=form_post")); + assertThat(processor, is(notNullValue())); } @Test - public void shouldBuildAuthorizeUrlWithFormPostIfResponseTypeIsToken() { - AuthAPI client = new AuthAPI("me.auth0.com", "clientId", "clientSecret"); - RequestProcessor handler = new RequestProcessor.Builder(client, "token", verifyOptions) + public void shouldSupportInvitationParameter() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET, + mockHttpOptions, + mockSignatureVerifier) + .withInvitation("inv_456") .build(); - HttpServletRequest request = new MockHttpServletRequest(); - AuthorizeUrl builder = handler.buildAuthorizeUrl(request, response, "https://redirect.uri/here", "state", "nonce"); - String authorizeUrl = builder.build(); - assertThat(authorizeUrl, is(notNullValue())); - assertThat(authorizeUrl, CoreMatchers.startsWith("https://me.auth0.com/authorize?")); - assertThat(authorizeUrl, containsString("client_id=clientId")); - assertThat(authorizeUrl, containsString("redirect_uri=https://redirect.uri/here")); - assertThat(authorizeUrl, containsString("response_type=token")); - assertThat(authorizeUrl, containsString("scope=openid")); - assertThat(authorizeUrl, containsString("state=state")); - assertThat(authorizeUrl, containsString("response_mode=form_post")); + assertThat(processor, is(notNullValue())); } @Test - public void isFormPostReturnsFalseWhenResponseTypeIsNull() { - assertThat(RequestProcessor.requiresFormPostResponseMode(null), is(false)); + public void shouldSupportCustomCookiePath() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET, + mockHttpOptions, + mockSignatureVerifier) + .withCookiePath("/custom/path") + .build(); + + assertThat(processor, is(notNullValue())); } @Test - public void shouldGetAuthAPIClient() { - RequestProcessor handler = new RequestProcessor.Builder(client, "responseType", verifyOptions) + public void shouldSupportClockSkewConfiguration() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET, + mockHttpOptions, + mockSignatureVerifier) + .withClockSkew(180) .build(); - assertThat(handler.getClient(), is(client)); + + assertThat(processor, is(notNullValue())); } @Test - public void legacySameSiteCookieShouldBeFalseByDefault() { - RequestProcessor processor = new RequestProcessor.Builder(client, "responseType", verifyOptions) + public void shouldSupportAuthenticationMaxAge() { + RequestProcessor processor = new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET, + mockHttpOptions, + mockSignatureVerifier) + .withAuthenticationMaxAge(7200) .build(); - assertThat(processor.useLegacySameSiteCookie, is(true)); + + assertThat(processor, is(notNullValue())); } - // Utils + // --- Helper Methods --- + + private RequestProcessor createDefaultRequestProcessor() { + return new RequestProcessor.Builder( + mockDomainProvider, + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET, + mockHttpOptions, + mockSignatureVerifier) + .build(); + } + + private RequestProcessor createRequestProcessorWithResponseType(String responseType) { + return new RequestProcessor.Builder( + mockDomainProvider, + responseType, + CLIENT_ID, + CLIENT_SECRET, + mockHttpOptions, + mockSignatureVerifier) + .build(); + } - 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; + 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; } } diff --git a/src/test/java/com/auth0/SignedCookieUtilsTest.java b/src/test/java/com/auth0/SignedCookieUtilsTest.java new file mode 100644 index 0000000..4fce5b3 --- /dev/null +++ b/src/test/java/com/auth0/SignedCookieUtilsTest.java @@ -0,0 +1,167 @@ +package com.auth0; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class SignedCookieUtilsTest { + + private static final String SECRET = "testClientSecret123"; + private static final String DOMAIN = "tenant-a.auth0.com"; + + // --- sign() tests --- + + @Test + public void shouldSignValue() { + String signed = SignedCookieUtils.sign(DOMAIN, SECRET); + + assertThat(signed, is(notNullValue())); + assertThat(signed, containsString(DOMAIN)); + assertThat(signed, containsString("|")); + + // Should have format: value|hex-signature + String[] parts = signed.split("\\|"); + assertThat(parts.length, is(2)); + assertThat(parts[0], is(DOMAIN)); + // HMAC-SHA256 hex is 64 characters + assertThat(parts[1].length(), is(64)); + } + + @Test + public void shouldProduceDeterministicSignature() { + String signed1 = SignedCookieUtils.sign(DOMAIN, SECRET); + String signed2 = SignedCookieUtils.sign(DOMAIN, SECRET); + + assertThat(signed1, is(signed2)); + } + + @Test + public void shouldProduceDifferentSignaturesForDifferentValues() { + String signed1 = SignedCookieUtils.sign("domain-a.auth0.com", SECRET); + String signed2 = SignedCookieUtils.sign("domain-b.auth0.com", SECRET); + + assertThat(signed1, is(not(signed2))); + } + + @Test + public void shouldProduceDifferentSignaturesForDifferentSecrets() { + String signed1 = SignedCookieUtils.sign(DOMAIN, "secret-1"); + String signed2 = SignedCookieUtils.sign(DOMAIN, "secret-2"); + + assertThat(signed1, is(not(signed2))); + } + + @Test + public void shouldThrowWhenSigningNullValue() { + assertThrows(IllegalArgumentException.class, () -> + SignedCookieUtils.sign(null, SECRET)); + } + + @Test + public void shouldThrowWhenSigningWithNullSecret() { + assertThrows(IllegalArgumentException.class, () -> + SignedCookieUtils.sign(DOMAIN, null)); + } + + // --- verifyAndExtract() tests --- + + @Test + public void shouldVerifyAndExtractValidSignature() { + String signed = SignedCookieUtils.sign(DOMAIN, SECRET); + + String extracted = SignedCookieUtils.verifyAndExtract(signed, SECRET); + + assertThat(extracted, is(DOMAIN)); + } + + @Test + public void shouldRejectTamperedValue() { + String signed = SignedCookieUtils.sign(DOMAIN, SECRET); + // Tamper with the domain portion + String tampered = "evil.attacker.com" + signed.substring(signed.indexOf("|")); + + String extracted = SignedCookieUtils.verifyAndExtract(tampered, SECRET); + + assertThat(extracted, is(nullValue())); + } + + @Test + public void shouldRejectTamperedSignature() { + String signed = SignedCookieUtils.sign(DOMAIN, SECRET); + // Tamper by flipping the last hex character + char lastChar = signed.charAt(signed.length() - 1); + char flipped = (lastChar == 'a') ? 'b' : 'a'; + String tampered = signed.substring(0, signed.length() - 1) + flipped; + + String extracted = SignedCookieUtils.verifyAndExtract(tampered, SECRET); + + assertThat(extracted, is(nullValue())); + } + + @Test + public void shouldRejectWrongSecret() { + String signed = SignedCookieUtils.sign(DOMAIN, SECRET); + + String extracted = SignedCookieUtils.verifyAndExtract(signed, "wrong-secret"); + + assertThat(extracted, is(nullValue())); + } + + @Test + public void shouldReturnNullForNullSignedValue() { + String extracted = SignedCookieUtils.verifyAndExtract(null, SECRET); + + assertThat(extracted, is(nullValue())); + } + + @Test + public void shouldReturnNullForNullSecret() { + String signed = SignedCookieUtils.sign(DOMAIN, SECRET); + + String extracted = SignedCookieUtils.verifyAndExtract(signed, null); + + assertThat(extracted, is(nullValue())); + } + + @Test + public void shouldReturnNullForMissingSeparator() { + String extracted = SignedCookieUtils.verifyAndExtract("noseparatorhere", SECRET); + + assertThat(extracted, is(nullValue())); + } + + @Test + public void shouldReturnNullForEmptyValue() { + String extracted = SignedCookieUtils.verifyAndExtract("|signature", SECRET); + + assertThat(extracted, is(nullValue())); + } + + @Test + public void shouldReturnNullForEmptySignature() { + String extracted = SignedCookieUtils.verifyAndExtract("value|", SECRET); + + assertThat(extracted, is(nullValue())); + } + + @Test + public void shouldHandleDomainWithSpecialCharacters() { + String domain = "my-custom-domain.example.co.uk"; + String signed = SignedCookieUtils.sign(domain, SECRET); + String extracted = SignedCookieUtils.verifyAndExtract(signed, SECRET); + + assertThat(extracted, is(domain)); + } + + @Test + public void shouldRejectCompletelyFabricatedValue() { + // Attacker creates their own domain + fake signature + String fabricated = "evil.auth0.com|aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + String extracted = SignedCookieUtils.verifyAndExtract(fabricated, SECRET); + + assertThat(extracted, is(nullValue())); + } +} diff --git a/src/test/java/com/auth0/TokensTest.java b/src/test/java/com/auth0/TokensTest.java index 5034bce..c82a0c1 100644 --- a/src/test/java/com/auth0/TokensTest.java +++ b/src/test/java/com/auth0/TokensTest.java @@ -16,6 +16,8 @@ public void shouldReturnValidTokens() { assertThat(tokens.getRefreshToken(), is("refreshToken")); assertThat(tokens.getType(), is("bearer")); assertThat(tokens.getExpiresIn(), is(360000L)); + assertThat(tokens.getDomain(), is(nullValue())); + assertThat(tokens.getIssuer(), is(nullValue())); } @Test @@ -26,5 +28,7 @@ public void shouldReturnMissingTokens() { assertThat(tokens.getRefreshToken(), is(nullValue())); assertThat(tokens.getType(), is(nullValue())); assertThat(tokens.getExpiresIn(), is(nullValue())); + assertThat(tokens.getDomain(), is(nullValue())); + assertThat(tokens.getIssuer(), is(nullValue())); } } diff --git a/src/test/java/com/auth0/TransientCookieStoreTest.java b/src/test/java/com/auth0/TransientCookieStoreTest.java index 949fb05..9db31f4 100644 --- a/src/test/java/com/auth0/TransientCookieStoreTest.java +++ b/src/test/java/com/auth0/TransientCookieStoreTest.java @@ -270,4 +270,114 @@ public void shouldReturnEmptyWhenNoNonceCookie() { assertThat(nonce, is(nullValue())); assertThat(nonce, is(nullValue())); } + + private static final String TEST_SECRET = "testClientSecret123"; + private static final String TEST_DOMAIN = "tenant-a.auth0.com"; + + @Test + public void shouldStoreSignedOriginDomainCookie() { + TransientCookieStore.storeSignedOriginDomain(response, TEST_DOMAIN, + SameSite.LAX, null, false, TEST_SECRET); + + List headers = response.getHeaders("Set-Cookie"); + assertThat(headers.size(), is(1)); + String header = headers.get(0); + assertThat(header, containsString("com.auth0.origin_domain=")); + assertThat(header, containsString("SameSite=Lax")); + assertThat(header, containsString("HttpOnly")); + } + + @Test + public void shouldStoreSignedOriginDomainWithSameSiteNone() { + TransientCookieStore.storeSignedOriginDomain(response, TEST_DOMAIN, + SameSite.NONE, null, false, TEST_SECRET); + + List headers = response.getHeaders("Set-Cookie"); + assertThat(headers.size(), is(2)); // primary + legacy fallback + assertThat(headers.get(0), containsString("SameSite=None")); + assertThat(headers.get(0), containsString("Secure")); + } + + @Test + public void shouldRetrieveAndVerifySignedOriginDomain() { + String signedValue = SignedCookieUtils.sign(TEST_DOMAIN, TEST_SECRET); + Cookie cookie = new Cookie("com.auth0.origin_domain", signedValue); + request.setCookies(cookie); + + String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_SECRET); + + assertThat(domain, is(TEST_DOMAIN)); + } + + @Test + public void shouldReturnNullForTamperedOriginDomain() { + Cookie cookie = new Cookie("com.auth0.origin_domain", + "evil.auth0.com|aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + request.setCookies(cookie); + + String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_SECRET); + + assertThat(domain, is(nullValue())); + } + + @Test + public void shouldReturnNullForMissingOriginDomainCookie() { + String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_SECRET); + + assertThat(domain, is(nullValue())); + } + + @Test + public void shouldReturnNullForWrongSecret() { + String signedValue = SignedCookieUtils.sign(TEST_DOMAIN, TEST_SECRET); + Cookie cookie = new Cookie("com.auth0.origin_domain", signedValue); + request.setCookies(cookie); + + String domain = TransientCookieStore.getSignedOriginDomain(request, response, "wrong-secret"); + + assertThat(domain, is(nullValue())); + } + + @Test + public void shouldDeleteOriginDomainCookieAfterReading() { + String signedValue = SignedCookieUtils.sign(TEST_DOMAIN, TEST_SECRET); + Cookie cookie = new Cookie("com.auth0.origin_domain", signedValue); + request.setCookies(cookie); + + String domain = TransientCookieStore.getSignedOriginDomain(request, response, 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())) { + assertThat(c.getMaxAge(), is(0)); + assertThat(c.getValue(), is("")); + foundDeleted = true; + } + } + assertThat(foundDeleted, is(true)); + } + + @Test + public void shouldStoreAndRetrieveSignedOriginDomainEndToEnd() { + TransientCookieStore.storeSignedOriginDomain(response, TEST_DOMAIN, + SameSite.LAX, null, false, TEST_SECRET); + + List headers = response.getHeaders("Set-Cookie"); + assertThat(headers.size(), is(1)); + + String header = headers.get(0); + String cookieValue = header.split(";")[0].split("=", 2)[1]; + + Cookie cookie = new Cookie("com.auth0.origin_domain", cookieValue); + + MockHttpServletRequest callbackRequest = new MockHttpServletRequest(); + MockHttpServletResponse callbackResponse = new MockHttpServletResponse(); + callbackRequest.setCookies(cookie); + + String domain = TransientCookieStore.getSignedOriginDomain(callbackRequest, callbackResponse, TEST_SECRET); + assertThat(domain, is(TEST_DOMAIN)); + } }