Skip to content

Commit dd57b0e

Browse files
committed
Feat: Added MCD Support
1 parent 4b545ed commit dd57b0e

18 files changed

Lines changed: 1994 additions & 1279 deletions

.github/copilot-instructions.md

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# Copilot Instructions for auth0-java-mvc-common
2+
3+
## Overview
4+
5+
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.
6+
7+
## Core Architecture
8+
9+
### Main Components
10+
11+
- **`AuthenticationController`**: Primary entry point with Builder pattern for configuration
12+
- **`RequestProcessor`**: Internal handler for OAuth callbacks and token processing
13+
- **`AuthorizeUrl`**: Fluent builder for constructing OAuth authorization URLs
14+
- **Cookie Management**: Custom `AuthCookie`/`TransientCookieStore` for SameSite cookie support
15+
16+
### Key Design Patterns
17+
18+
- **Non-reusable builders**: `AuthenticationController.Builder` throws `IllegalStateException` if `build()` called twice
19+
- **One-time URL builders**: `AuthorizeUrl` instances cannot be reused (throws on second `build()`)
20+
- **Fallback authentication storage**: State/nonce stored in both cookies AND session for compatibility
21+
22+
## Critical Cookie Handling
23+
24+
The library implements sophisticated cookie management for browser compatibility:
25+
26+
### SameSite Cookie Strategy
27+
28+
- **Code flow**: Uses `SameSite=Lax` (single cookie)
29+
- **ID token flows**: Uses `SameSite=None; Secure` with legacy fallback cookie (prefixed with `_`)
30+
- **Legacy fallback**: Automatically creates fallback cookies for browsers that don't support `SameSite=None`
31+
32+
### Cookie Configuration
33+
34+
```java
35+
// Configure cookie behavior
36+
.withLegacySameSiteCookie(false) // Disable fallback cookies
37+
.withSecureCookie(true) // Force Secure attribute
38+
.withCookiePath("/custom") // Set cookie Path attribute
39+
```
40+
41+
## Builder Pattern Usage
42+
43+
### Standard Authentication Controller Setup
44+
45+
```java
46+
AuthenticationController controller = AuthenticationController.newBuilder(domain, clientId, clientSecret)
47+
.withJwkProvider(jwkProvider) // Required for RS256
48+
.withResponseType("code") // Default: "code"
49+
.withClockSkew(120) // Default: 60 seconds
50+
.withOrganization("org_id") // For organization login
51+
.build();
52+
```
53+
54+
### URL Building (Modern Pattern)
55+
56+
```java
57+
// CORRECT: Use request + response for cookie storage
58+
String url = controller.buildAuthorizeUrl(request, response, redirectUri)
59+
.withState("custom-state")
60+
.withAudience("https://api.example.com")
61+
.withParameter("custom", "value")
62+
.build();
63+
```
64+
65+
## Response Type Behavior
66+
67+
- **`code`**: Authorization Code flow, uses `SameSite=Lax` cookies
68+
- **`id_token`** or **`token`**: Implicit Grant, requires `SameSite=None; Secure` + fallback cookies
69+
- **Mixed**: `id_token code` combinations follow implicit grant cookie rules
70+
71+
## Testing Patterns
72+
73+
### Mock Setup
74+
75+
```java
76+
// Standard test setup pattern
77+
@Mock private AuthAPI client;
78+
@Mock private IdTokenVerifier.Options verificationOptions;
79+
@Captor private ArgumentCaptor<SignatureVerifier> signatureVerifierCaptor;
80+
81+
AuthenticationController.Builder builderSpy = spy(AuthenticationController.newBuilder(...));
82+
doReturn(client).when(builderSpy).createAPIClient(...);
83+
```
84+
85+
### Cookie Assertions
86+
87+
```java
88+
// Verify cookie headers in tests
89+
List<String> headers = response.getHeaders("Set-Cookie");
90+
assertThat(headers, hasItem("com.auth0.state=value; HttpOnly; Max-Age=600; SameSite=Lax"));
91+
```
92+
93+
## Development Workflow
94+
95+
### Build & Test
96+
97+
```bash
98+
./gradlew build # Build with Gradle wrapper
99+
./gradlew test # Run tests
100+
./gradlew jacocoTestReport # Generate coverage
101+
```
102+
103+
### Key Dependencies
104+
105+
- **Auth0 Java SDK**: Core Auth0 API client (`com.auth0:auth0`)
106+
- **java-jwt**: JWT token handling (`com.auth0:java-jwt`)
107+
- **jwks-rsa**: RS256 signature verification (`com.auth0:jwks-rsa`)
108+
- **Servlet API**: `javax.servlet-api` (compile-only)
109+
110+
## Migration Considerations
111+
112+
### Deprecated Methods
113+
114+
- `handle(HttpServletRequest)`: Session-based, incompatible with SameSite restrictions
115+
- `buildAuthorizeUrl(HttpServletRequest, String)`: Session-only storage
116+
117+
### Modern Alternatives
118+
119+
- Use `handle(HttpServletRequest, HttpServletResponse)` for cookie-based auth
120+
- Use `buildAuthorizeUrl(HttpServletRequest, HttpServletResponse, String)` for proper cookie storage
121+
122+
## Common Integration Points
123+
124+
- Organizations: Use `.withOrganization()` and validate `org_id` claims manually
125+
- Custom parameters: Use `.withParameter()` on AuthorizeUrl (but not for `state`, `nonce`, `response_type`)
126+
- Error handling: Catch `IdentityVerificationException` from `.handle()` calls
127+
- HTTP customization: Use `.withHttpOptions()` for timeouts/proxy configuration

Dockerfile

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
FROM gradle:6.9.2-jdk8
2+
3+
WORKDIR /home/gradle
4+
# Copy your project files
5+
COPY . .
6+
7+
# Ensure the Gradle wrapper is executable
8+
RUN chmod +x ./gradlew
9+
10+
# Expose both ports for your MCD test
11+
EXPOSE 3000
12+
EXPOSE 8080
13+
EXPOSE 5005
14+
15+
# Use --no-daemon to keep the container process alive
16+
# We use the wrapper (./gradlew) to ensure consistency
17+
#CMD ["./gradlew", "appRun", "--no-daemon", "-Pgretty.managed=false"]
18+
ENV GRADLE_OPTS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005"
19+
CMD ["gradle", "appRun", "--no-daemon"]

build.gradle

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ plugins {
1515
id 'jacoco'
1616
id 'me.champeau.gradle.japicmp' version '0.4.6'
1717
id 'io.github.gradle-nexus.publish-plugin' version '2.0.0'
18+
id "war"
19+
id "org.gretty" version "3.1.1"
20+
}
21+
22+
gretty {
23+
httpPort = 3000
24+
host = '0.0.0.0' // Required for Docker to communicate
25+
contextPath = '/'
26+
servletContainer = 'tomcat9'
1827
}
1928

2029
repositories {
@@ -125,6 +134,7 @@ dependencies {
125134
implementation 'org.apache.commons:commons-lang3:3.18.0'
126135
implementation 'com.google.guava:guava-annotations:r03'
127136
implementation 'commons-codec:commons-codec:1.20.0'
137+
implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2'
128138

129139
api 'com.auth0:auth0:1.45.1'
130140
api 'com.auth0:java-jwt:3.19.4'

src/main/java/com/auth0/AuthenticationController.java

Lines changed: 115 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,32 @@ RequestProcessor getRequestProcessor() {
4444
* @return a new Builder instance ready to configure
4545
*/
4646
public static Builder newBuilder(String domain, String clientId, String clientSecret) {
47-
return new Builder(domain, clientId, clientSecret);
47+
Validate.notNull(domain, "domain must not be null");
48+
return new Builder(clientId, clientSecret).withDomain(domain);
49+
}
50+
51+
/**
52+
* Create a new {@link Builder} instance to configure the {@link AuthenticationController} response type and algorithm used on the verification.
53+
* 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
54+
* the Implicit Grant using the HS256 algorithm with the Client Secret as secret.
55+
*
56+
* @param domainResolver the Auth0 domain resolver function
57+
* @param clientId the Auth0 application's client id
58+
* @param clientSecret the Auth0 application's client secret
59+
* @return a new Builder instance ready to configure
60+
*/
61+
public static Builder newBuilder(DomainResolver domainResolver,
62+
String clientId,
63+
String clientSecret) {
64+
Validate.notNull(domainResolver, "domainResolver must not be null");
65+
return new Builder(clientId, clientSecret).withDomainResolver(domainResolver);
4866
}
4967

5068

5169
public static class Builder {
5270
private static final String RESPONSE_TYPE_CODE = "code";
5371

54-
private final String domain;
72+
private String domain;
5573
private final String clientId;
5674
private final String clientSecret;
5775
private String responseType;
@@ -63,6 +81,7 @@ public static class Builder {
6381
private String invitation;
6482
private HttpOptions httpOptions;
6583
private String cookiePath;
84+
private DomainResolver domainResolver;
6685

6786
Builder(String domain, String clientId, String clientSecret) {
6887
Validate.notNull(domain);
@@ -76,6 +95,54 @@ public static class Builder {
7695
this.useLegacySameSiteCookie = true;
7796
}
7897

98+
Builder(String clientId, String clientSecret) {
99+
if (clientId == null) {
100+
throw new IllegalArgumentException("clientId cannot be null");
101+
}
102+
if (clientSecret == null) {
103+
throw new IllegalArgumentException("clientSecret cannot be null");
104+
}
105+
106+
this.clientId = clientId;
107+
this.clientSecret = clientSecret;
108+
this.responseType = RESPONSE_TYPE_CODE;
109+
this.useLegacySameSiteCookie = true;
110+
}
111+
112+
/**
113+
* Sets the Auth0 domain to use.
114+
* Note: The `domainResolver` must be null when setting the `domain`.
115+
*
116+
* @param domain the Auth0 domain to use, a non-null value.
117+
* @return this same builder instance.
118+
* @throws IllegalStateException if `domainResolver` is already set.
119+
*/
120+
public Builder withDomain(String domain) {
121+
if (this.domainResolver != null) {
122+
throw new IllegalStateException("Cannot specify both 'domain' and 'domainResolver'.");
123+
}
124+
Validate.notNull(domain, "domain must not be null");
125+
this.domain = domain;
126+
return this;
127+
}
128+
129+
/**
130+
* Sets the Auth0 domain resolver function to use.
131+
* Note: The `domain` must be null when setting the `domainResolver`.
132+
*
133+
* @param domainResolver the domain resolver function to use, a non-null value.
134+
* @return this same builder instance.
135+
* @throws IllegalStateException if `domain` is already set.
136+
*/
137+
public Builder withDomainResolver(DomainResolver domainResolver) {
138+
if (this.domain != null) {
139+
throw new IllegalStateException("Cannot specify both 'domain' and 'domainResolver'.");
140+
}
141+
Validate.notNull(domainResolver, "domainResolver must not be null");
142+
this.domainResolver = domainResolver;
143+
return this;
144+
}
145+
79146
/**
80147
* Customize certain aspects of the underlying HTTP client networking library, such as timeouts and proxy configuration.
81148
*
@@ -196,29 +263,18 @@ public Builder withInvitation(String invitation) {
196263
* @throws UnsupportedOperationException if the Implicit Grant is chosen and the environment doesn't support UTF-8 encoding.
197264
*/
198265
public AuthenticationController build() throws UnsupportedOperationException {
199-
AuthAPI apiClient = createAPIClient(domain, clientId, clientSecret, httpOptions);
200-
setupTelemetry(apiClient);
201-
202-
final boolean expectedAlgorithmIsExplicitlySetAndAsymmetric = jwkProvider != null;
203-
final SignatureVerifier signatureVerifier;
204-
if (expectedAlgorithmIsExplicitlySetAndAsymmetric) {
205-
signatureVerifier = new AsymmetricSignatureVerifier(jwkProvider);
206-
} else if (responseType.contains(RESPONSE_TYPE_CODE)) {
207-
// Old behavior: To maintain backwards-compatibility when
208-
// no explicit algorithm is set by the user, we
209-
// must skip ID Token signature check.
210-
signatureVerifier = new AlgorithmNameVerifier();
211-
} else {
212-
signatureVerifier = new SymmetricSignatureVerifier(clientSecret);
213-
}
266+
validateDomainConfiguration();
267+
268+
DomainProvider domainProvider =
269+
domain != null
270+
? new StaticDomainProvider(domain)
271+
: new ResolverDomainProvider(domainResolver);
214272

215-
String issuer = getIssuer(domain);
216-
IdTokenVerifier.Options verifyOptions = createIdTokenVerificationOptions(issuer, clientId, signatureVerifier);
217-
verifyOptions.setClockSkew(clockSkew);
218-
verifyOptions.setMaxAge(authenticationMaxAge);
219-
verifyOptions.setOrganization(this.organization);
273+
SignatureVerifier signatureVerifier = buildSignatureVerifier();
220274

221-
RequestProcessor processor = new RequestProcessor.Builder(apiClient, responseType, verifyOptions)
275+
RequestProcessor processor = new RequestProcessor.Builder(domainProvider, responseType, clientId, clientSecret, httpOptions, signatureVerifier)
276+
.withClockSkew(clockSkew)
277+
.withAuthenticationMaxAge(authenticationMaxAge)
222278
.withLegacySameSiteCookie(useLegacySameSiteCookie)
223279
.withOrganization(organization)
224280
.withInvitation(invitation)
@@ -228,6 +284,25 @@ public AuthenticationController build() throws UnsupportedOperationException {
228284
return new AuthenticationController(processor);
229285
}
230286

287+
private void validateDomainConfiguration() {
288+
if (domain == null && domainResolver == null) {
289+
throw new IllegalStateException("Either domain or domainResolver must be provided.");
290+
}
291+
if (domain != null && domainResolver != null) {
292+
throw new IllegalStateException("Cannot specify both domain and domainResolver.");
293+
}
294+
}
295+
296+
private SignatureVerifier buildSignatureVerifier() {
297+
if (jwkProvider != null) {
298+
return new AsymmetricSignatureVerifier(jwkProvider);
299+
}
300+
if (responseType.contains(RESPONSE_TYPE_CODE)) {
301+
return new AlgorithmNameVerifier(); // legacy behavior
302+
}
303+
return new SymmetricSignatureVerifier(clientSecret);
304+
}
305+
231306
@VisibleForTesting
232307
IdTokenVerifier.Options createIdTokenVerificationOptions(String issuer, String audience, SignatureVerifier signatureVerifier) {
233308
return new IdTokenVerifier.Options(issuer, audience, signatureVerifier);
@@ -243,6 +318,7 @@ AuthAPI createAPIClient(String domain, String clientId, String clientSecret, Htt
243318

244319
@VisibleForTesting
245320
void setupTelemetry(AuthAPI client) {
321+
if (client == null) return;
246322
Telemetry telemetry = new Telemetry("auth0-java-mvc-common", obtainPackageVersion());
247323
client.setTelemetry(telemetry);
248324
}
@@ -265,22 +341,22 @@ private String getIssuer(String domain) {
265341
}
266342
}
267343

268-
/**
269-
* Whether to enable or not the HTTP Logger for every Request and Response.
270-
* Enabling this can expose sensitive information.
271-
*
272-
* @param enabled whether to enable the HTTP logger or not.
273-
*/
274-
public void setLoggingEnabled(boolean enabled) {
275-
requestProcessor.getClient().setLoggingEnabled(enabled);
276-
}
277-
278-
/**
279-
* Disable sending the Telemetry header on every request to the Auth0 API
280-
*/
281-
public void doNotSendTelemetry() {
282-
requestProcessor.getClient().doNotSendTelemetry();
283-
}
344+
// /**
345+
// * Whether to enable or not the HTTP Logger for every Request and Response.
346+
// * Enabling this can expose sensitive information.
347+
// *
348+
// * @param enabled whether to enable the HTTP logger or not.
349+
// */
350+
// public void setLoggingEnabled(boolean enabled) {
351+
// requestProcessor.getClient().setLoggingEnabled(enabled);
352+
// }
353+
//
354+
// /**
355+
// * Disable sending the Telemetry header on every request to the Auth0 API
356+
// */
357+
// public void doNotSendTelemetry() {
358+
// requestProcessor.getClient().doNotSendTelemetry();
359+
// }
284360

285361
/**
286362
* Process a request to obtain a set of {@link Tokens} that represent successful authentication or authorization.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.auth0;
2+
3+
import javax.servlet.http.HttpServletRequest;
4+
5+
public interface DomainProvider {
6+
String getDomain(HttpServletRequest request);
7+
8+
}

0 commit comments

Comments
 (0)