Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/main/java/com/auth0/AuthorizeUrl.java
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,9 @@ 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,
// Store HMAC-signed origin domain bound to this transaction's state
if (originDomain != null && clientSecret != null && state != null) {
TransientCookieStore.storeSignedOriginDomain(response, originDomain, state,
sameSiteValue, cookiePath, setSecureCookie, clientSecret);
}

Expand Down
17 changes: 8 additions & 9 deletions src/main/java/com/auth0/RequestProcessor.java
Original file line number Diff line number Diff line change
Expand Up @@ -216,16 +216,13 @@ AuthorizeUrl buildAuthorizeUrl(HttpServletRequest request, HttpServletResponse r
*/
Tokens process(HttpServletRequest request, HttpServletResponse response) throws IdentityVerificationException {
assertNoError(request);
assertValidState(request, response);
String state = assertValidState(request, response);

// Extract origin_domain from the HMAC-signed transaction state cookie.
// If the cookie was tampered with, getSignedOriginDomain returns null.
String originDomain = null;
// Extract origin_domain from the HMAC-signed cookie, bound to this transaction's state.
// If the cookie was tampered with or replayed from a different transaction, returns null.
String originDomain = TransientCookieStore.getSignedOriginDomain(request, response, state, clientSecret);

originDomain = TransientCookieStore.getSignedOriginDomain(request, response, clientSecret);


// Fallback for session-based (deprecated) flow or if cookie was not set
// Fallback if cookie was not set (e.g., single-domain setup without MCD)
if (originDomain == null) {
originDomain = domainProvider.getDomain(request);
}
Expand Down Expand Up @@ -403,7 +400,7 @@ private void assertNoError(HttpServletRequest request) throws InvalidRequestExce
* @param response the response, used to remove the state cookie
* @throws InvalidRequestException if the request contains a different state from the expected one
*/
private void assertValidState(HttpServletRequest request, HttpServletResponse response) throws InvalidRequestException {
private String assertValidState(HttpServletRequest request, HttpServletResponse response) throws InvalidRequestException {
String stateFromRequest = request.getParameter(KEY_STATE);

if (stateFromRequest == null) {
Expand All @@ -419,6 +416,8 @@ private void assertValidState(HttpServletRequest request, HttpServletResponse re
if (!cookieState.equals(stateFromRequest)) {
throw new InvalidRequestException(INVALID_STATE_ERROR, "The received state doesn't match the expected one.");
}

return stateFromRequest;
}

/**
Expand Down
55 changes: 55 additions & 0 deletions src/main/java/com/auth0/SignedCookieUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,25 @@ static String sign(String value, String secret) {
return value + SEPARATOR + signature;
}

/**
* Signs a value using HMAC-SHA256 with the provided secret, binding it to a
* context value (e.g., state). The context is included in the HMAC computation
* but not stored in the cookie — the verifier must supply the same context.
*
* @param value the value to sign and store
* @param context the binding context (e.g., state parameter) included in HMAC
* @param secret the secret key for HMAC
* @return the signed value in the format {@code value|signature}
* @throws IllegalArgumentException if any argument is null
*/
static String sign(String value, String context, String secret) {
if (value == null || context == null || secret == null) {
throw new IllegalArgumentException("Value, context, and secret must not be null");
}
String signature = computeHmac(value + SEPARATOR + context, secret);
return value + SEPARATOR + signature;
}

/**
* Verifies the HMAC signature and extracts the original value.
*
Expand Down Expand Up @@ -70,6 +89,42 @@ static String verifyAndExtract(String signedValue, String secret) {
return null;
}

/**
* Verifies the HMAC signature (which was computed with a binding context) and
* extracts the original value.
*
* @param signedValue the signed value in the format {@code value|signature}
* @param context the binding context that was used during signing
* @param secret the secret key used to verify the HMAC
* @return the original value if the signature is valid, or {@code null} if
* the signature is invalid, the context doesn't match, or the format
* is unexpected
*/
static String verifyAndExtract(String signedValue, String context, String secret) {
if (signedValue == null || context == null || secret == null) {
return null;
}

int separatorIndex = signedValue.lastIndexOf(SEPARATOR);
if (separatorIndex <= 0 || separatorIndex >= signedValue.length() - 1) {
return null;
}

String value = signedValue.substring(0, separatorIndex);
String signature = signedValue.substring(separatorIndex + 1);

String expectedSignature = computeHmac(value + SEPARATOR + context, secret);

// Constant-time comparison to prevent timing attacks
if (MessageDigest.isEqual(
expectedSignature.getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8))) {
return value;
}

return null;
}

private static String computeHmac(String value, String secret) {
try {
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
Expand Down
23 changes: 13 additions & 10 deletions src/main/java/com/auth0/TransientCookieStore.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,39 +67,42 @@ static String getNonce(HttpServletRequest request, HttpServletResponse response)
}

/**
* Stores the origin domain as an HMAC-signed cookie. The issuer is not stored
* separately — it is always derived from the domain on callback to prevent
* tampering.
* Stores the origin domain as an HMAC-signed cookie, bound to the state parameter.
* The HMAC is computed over both the domain and the state, ensuring the cookie
* cannot be replayed across different transactions.
*
* @param response the response to set the cookie on
* @param domain the resolved Auth0 domain
* @param state the state parameter for this transaction (used as HMAC binding context)
* @param sameSite the SameSite attribute value
* @param path the cookie path, or null
* @param isSecure whether to set the Secure attribute
* @param secret the client secret used for HMAC signing
*/
static void storeSignedOriginDomain(HttpServletResponse response, String domain,
static void storeSignedOriginDomain(HttpServletResponse response, String domain, String state,
SameSite sameSite, String path, boolean isSecure, String secret) {
String signedDomain = SignedCookieUtils.sign(domain, secret);
String signedDomain = SignedCookieUtils.sign(domain, state, secret);
store(response, StorageUtils.ORIGIN_DOMAIN_KEY, signedDomain, sameSite, true, isSecure, path);
}

/**
* Retrieves and verifies the HMAC-signed origin domain cookie.
* Retrieves and verifies the HMAC-signed origin domain cookie, checking that
* the HMAC was computed with the given state (transaction binding).
*
* @param request the request to read the cookie from
* @param response the response used to delete the cookie after reading
* @param state the state parameter from this callback request
* @param secret the client secret used for HMAC verification
* @return the verified domain value, or {@code null} if the cookie is missing
* or the signature is invalid (tampered)
* @return the verified domain value, or {@code null} if the cookie is missing,
* the signature is invalid, or the state doesn't match (replay attempt)
*/
static String getSignedOriginDomain(HttpServletRequest request, HttpServletResponse response,
String secret) {
String state, String secret) {
String signedValue = getOnce(StorageUtils.ORIGIN_DOMAIN_KEY, request, response);
if (signedValue == null) {
return null;
}
return SignedCookieUtils.verifyAndExtract(signedValue, secret);
return SignedCookieUtils.verifyAndExtract(signedValue, state, secret);
}

private static void store(HttpServletResponse response, String key, String value, SameSite sameSite, boolean useLegacySameSiteCookie, boolean isSecureCookie, String cookiePath) {
Expand Down
66 changes: 66 additions & 0 deletions src/test/java/com/auth0/SignedCookieUtilsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,70 @@ public void shouldRejectCompletelyFabricatedValue() {

assertThat(extracted, is(nullValue()));
}

// --- Context-bound sign/verify tests (transaction binding) ---

private static final String STATE = "randomState123";

@Test
public void shouldSignWithContext() {
String signed = SignedCookieUtils.sign(DOMAIN, STATE, SECRET);

assertThat(signed, is(notNullValue()));
String[] parts = signed.split("\\|");
assertThat(parts.length, is(2));
assertThat(parts[0], is(DOMAIN));
assertThat(parts[1].length(), is(64));
}

@Test
public void shouldVerifyWithMatchingContext() {
String signed = SignedCookieUtils.sign(DOMAIN, STATE, SECRET);

String extracted = SignedCookieUtils.verifyAndExtract(signed, STATE, SECRET);

assertThat(extracted, is(DOMAIN));
}

@Test
public void shouldRejectWrongContext() {
String signed = SignedCookieUtils.sign(DOMAIN, STATE, SECRET);

String extracted = SignedCookieUtils.verifyAndExtract(signed, "different-state", SECRET);

assertThat(extracted, is(nullValue()));
}

@Test
public void shouldProduceDifferentSignaturesForDifferentContexts() {
String signed1 = SignedCookieUtils.sign(DOMAIN, "state-1", SECRET);
String signed2 = SignedCookieUtils.sign(DOMAIN, "state-2", SECRET);

assertThat(signed1, is(not(signed2)));
}

@Test
public void shouldNotVerifyContextBoundCookieWithoutContext() {
// Cookie signed with context should NOT verify via the context-less overload
String signed = SignedCookieUtils.sign(DOMAIN, STATE, SECRET);

String extracted = SignedCookieUtils.verifyAndExtract(signed, SECRET);

assertThat(extracted, is(nullValue()));
}

@Test
public void shouldThrowWhenSigningWithNullContext() {
assertThrows(IllegalArgumentException.class, () ->
SignedCookieUtils.sign(DOMAIN, null, SECRET));
}

@Test
public void shouldReturnNullWhenVerifyingWithNullContext() {
String signed = SignedCookieUtils.sign(DOMAIN, STATE, SECRET);

String extracted = SignedCookieUtils.verifyAndExtract(signed, null, SECRET);

assertThat(extracted, is(nullValue()));
}
}
36 changes: 24 additions & 12 deletions src/test/java/com/auth0/TransientCookieStoreTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -272,10 +272,11 @@ public void shouldReturnEmptyWhenNoNonceCookie() {

private static final String TEST_SECRET = "testClientSecret123";
private static final String TEST_DOMAIN = "tenant-a.auth0.com";
private static final String TEST_STATE = "abc123state";

@Test
public void shouldStoreSignedOriginDomainCookie() {
TransientCookieStore.storeSignedOriginDomain(response, TEST_DOMAIN,
TransientCookieStore.storeSignedOriginDomain(response, TEST_DOMAIN, TEST_STATE,
SameSite.LAX, null, false, TEST_SECRET);

List<String> headers = response.getHeaders("Set-Cookie");
Expand All @@ -288,7 +289,7 @@ public void shouldStoreSignedOriginDomainCookie() {

@Test
public void shouldStoreSignedOriginDomainWithSameSiteNone() {
TransientCookieStore.storeSignedOriginDomain(response, TEST_DOMAIN,
TransientCookieStore.storeSignedOriginDomain(response, TEST_DOMAIN, TEST_STATE,
SameSite.NONE, null, false, TEST_SECRET);

List<String> headers = response.getHeaders("Set-Cookie");
Expand All @@ -299,11 +300,11 @@ public void shouldStoreSignedOriginDomainWithSameSiteNone() {

@Test
public void shouldRetrieveAndVerifySignedOriginDomain() {
String signedValue = SignedCookieUtils.sign(TEST_DOMAIN, TEST_SECRET);
String signedValue = SignedCookieUtils.sign(TEST_DOMAIN, TEST_STATE, TEST_SECRET);
Cookie cookie = new Cookie("com.auth0.origin_domain", signedValue);
request.setCookies(cookie);

String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_SECRET);
String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_STATE, TEST_SECRET);

assertThat(domain, is(TEST_DOMAIN));
}
Expand All @@ -314,36 +315,47 @@ public void shouldReturnNullForTamperedOriginDomain() {
"evil.auth0.com|aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
request.setCookies(cookie);

String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_SECRET);
String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_STATE, TEST_SECRET);

assertThat(domain, is(nullValue()));
}

@Test
public void shouldReturnNullForMissingOriginDomainCookie() {
String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_SECRET);
String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_STATE, TEST_SECRET);

assertThat(domain, is(nullValue()));
}

@Test
public void shouldReturnNullForWrongSecret() {
String signedValue = SignedCookieUtils.sign(TEST_DOMAIN, TEST_SECRET);
String signedValue = SignedCookieUtils.sign(TEST_DOMAIN, TEST_STATE, TEST_SECRET);
Cookie cookie = new Cookie("com.auth0.origin_domain", signedValue);
request.setCookies(cookie);

String domain = TransientCookieStore.getSignedOriginDomain(request, response, "wrong-secret");
String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_STATE, "wrong-secret");

assertThat(domain, is(nullValue()));
}

@Test
public void shouldReturnNullForWrongState() {
String signedValue = SignedCookieUtils.sign(TEST_DOMAIN, TEST_STATE, TEST_SECRET);
Cookie cookie = new Cookie("com.auth0.origin_domain", signedValue);
request.setCookies(cookie);

String domain = TransientCookieStore.getSignedOriginDomain(request, response, "different-state", TEST_SECRET);

assertThat(domain, is(nullValue()));
}

@Test
public void shouldDeleteOriginDomainCookieAfterReading() {
String signedValue = SignedCookieUtils.sign(TEST_DOMAIN, TEST_SECRET);
String signedValue = SignedCookieUtils.sign(TEST_DOMAIN, TEST_STATE, TEST_SECRET);
Cookie cookie = new Cookie("com.auth0.origin_domain", signedValue);
request.setCookies(cookie);

String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_SECRET);
String domain = TransientCookieStore.getSignedOriginDomain(request, response, TEST_STATE, TEST_SECRET);
assertThat(domain, is(TEST_DOMAIN));

Cookie[] responseCookies = response.getCookies();
Expand All @@ -361,7 +373,7 @@ public void shouldDeleteOriginDomainCookieAfterReading() {

@Test
public void shouldStoreAndRetrieveSignedOriginDomainEndToEnd() {
TransientCookieStore.storeSignedOriginDomain(response, TEST_DOMAIN,
TransientCookieStore.storeSignedOriginDomain(response, TEST_DOMAIN, TEST_STATE,
SameSite.LAX, null, false, TEST_SECRET);

List<String> headers = response.getHeaders("Set-Cookie");
Expand All @@ -376,7 +388,7 @@ public void shouldStoreAndRetrieveSignedOriginDomainEndToEnd() {
MockHttpServletResponse callbackResponse = new MockHttpServletResponse();
callbackRequest.setCookies(cookie);

String domain = TransientCookieStore.getSignedOriginDomain(callbackRequest, callbackResponse, TEST_SECRET);
String domain = TransientCookieStore.getSignedOriginDomain(callbackRequest, callbackResponse, TEST_STATE, TEST_SECRET);
assertThat(domain, is(TEST_DOMAIN));
}
}
Loading