Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/main/java/com/auth0/AuthorizeUrl.java
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ private void storeTransient() {
SameSite sameSiteValue = containsFormPost() ? SameSite.NONE : SameSite.LAX;

TransientCookieStore.storeState(response, state, sameSiteValue, useLegacySameSiteCookie, setSecureCookie, cookiePath);
TransientCookieStore.storeNonce(response, nonce, sameSiteValue, useLegacySameSiteCookie, setSecureCookie, cookiePath);
TransientCookieStore.storeNonce(response, nonce, state, sameSiteValue, useLegacySameSiteCookie, setSecureCookie, cookiePath);

// Store HMAC-signed origin domain bound to this transaction's state
if (originDomain != null && clientSecret != null && state != null) {
Expand Down
6 changes: 3 additions & 3 deletions src/main/java/com/auth0/RequestProcessor.java
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ Tokens process(HttpServletRequest request, HttpServletResponse response) throws
throw new InvalidRequestException(MISSING_ACCESS_TOKEN, "Access Token is missing from the response.");
}

return getVerifiedTokens(request, response, frontChannelTokens, responseTypeList, originDomain, originIssuer);
return getVerifiedTokens(request, response, frontChannelTokens, responseTypeList, originDomain, originIssuer, state);
}

static boolean requiresFormPostResponseMode(List<String> responseType) {
Expand All @@ -256,13 +256,13 @@ static boolean requiresFormPostResponseMode(List<String> responseType) {
* @return a Tokens object that wraps the values obtained from the front-channel and/or the code request response.
* @throws IdentityVerificationException
*/
private Tokens getVerifiedTokens(HttpServletRequest request, HttpServletResponse response, Tokens frontChannelTokens, List<String> responseTypeList, String originDomain, String originIssuer)
private Tokens getVerifiedTokens(HttpServletRequest request, HttpServletResponse response, Tokens frontChannelTokens, List<String> responseTypeList, String originDomain, String originIssuer, String state)
throws IdentityVerificationException {

String authorizationCode = request.getParameter(KEY_CODE);
Tokens codeExchangeTokens = null;

String nonce = TransientCookieStore.getNonce(request, response);
String nonce = TransientCookieStore.getNonce(request, response, state);

try {
if (responseTypeList.contains(KEY_ID_TOKEN)) {
Expand Down
27 changes: 27 additions & 0 deletions src/main/java/com/auth0/StorageUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,33 @@ private StorageUtils() {}
static final String NONCE_KEY = "com.auth0.nonce";
static final String ORIGIN_DOMAIN_KEY = "com.auth0.origin_domain";

/**
* Max-Age for transaction cookies in seconds (10 minutes).
* Orphaned cookies from abandoned login flows will auto-expire.
*/
static final int TRANSACTION_COOKIE_MAX_AGE = 600;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used somewhere?


/**
* Constructs a transaction-keyed state cookie name.
* Each login transaction gets its own cookie, preventing multi-tab overwrites.
*
* @param state the state value for this transaction
* @return the cookie name in the form "com.auth0.state.{state}"
*/
static String transactionStateKey(String state) {
return STATE_KEY + "." + state;
}

/**
* Constructs a transaction-keyed nonce cookie name.
*
* @param state the state value for this transaction (used as key, not the nonce itself)
* @return the cookie name in the form "com.auth0.nonce.{state}"
*/
static String transactionNonceKey(String state) {
return NONCE_KEY + "." + state;
}

/**
* Generates a new random string using {@link SecureRandom}.
* The output can be used as State or Nonce values for API requests.
Expand Down
62 changes: 53 additions & 9 deletions src/main/java/com/auth0/TransientCookieStore.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
import java.nio.charset.StandardCharsets;

/**
* Allows storage and retrieval/removal of cookies.
* Allows storage and retrieval/removal of transient cookies used during the OAuth transaction.
*
* <p>Each login transaction gets its own uniquely-named cookies (keyed by state value),
* preventing multi-tab race conditions where concurrent logins would overwrite each other's state.</p>
*/
class TransientCookieStore {

Expand All @@ -19,50 +22,91 @@ private TransientCookieStore() {}


/**
* Stores a state value as a cookie on the response.
* Stores a state value as a transaction-keyed cookie on the response.
* The cookie name includes the state value itself, ensuring each login flow
* gets its own isolated cookie (e.g., "com.auth0.state.{state_value}").
*
* @param response the response object to set the cookie on
* @param state the value for the state cookie. If null, no cookie will be set.
* @param sameSite the value for the SameSite attribute on the cookie
* @param useLegacySameSiteCookie whether to set a fallback cookie or not
* @param isSecureCookie whether to always set the Secure cookie attribute or not
* @param cookiePath the path for the cookie
*/
static void storeState(HttpServletResponse response, String state, SameSite sameSite, boolean useLegacySameSiteCookie, boolean isSecureCookie, String cookiePath) {
store(response, StorageUtils.STATE_KEY, state, sameSite, useLegacySameSiteCookie, isSecureCookie, cookiePath);
if (state == null) {
return;
}
store(response, StorageUtils.transactionStateKey(state), state, sameSite, useLegacySameSiteCookie, isSecureCookie, cookiePath);
}

/**
* Stores a nonce value as a cookie on the response.
* Stores a nonce value as a transaction-keyed cookie on the response.
* The cookie is keyed by the state value (not the nonce), so it can be
* retrieved during callback using the state parameter from the URL.
*
* @param response the response object to set the cookie on
* @param nonce the value for the nonce cookie. If null, no cookie will be set.
* @param state the state value for this transaction (used as key in cookie name)
* @param sameSite the value for the SameSite attribute on the cookie
* @param useLegacySameSiteCookie whether to set a fallback cookie or not
* @param isSecureCookie whether to always set the Secure cookie attribute or not
* @param cookiePath the path for the cookie
*/
static void storeNonce(HttpServletResponse response, String nonce, SameSite sameSite, boolean useLegacySameSiteCookie, boolean isSecureCookie, String cookiePath) {
store(response, StorageUtils.NONCE_KEY, nonce, sameSite, useLegacySameSiteCookie, isSecureCookie, cookiePath);
static void storeNonce(HttpServletResponse response, String nonce, String state, SameSite sameSite, boolean useLegacySameSiteCookie, boolean isSecureCookie, String cookiePath) {
if (nonce == null || state == null) {
return;
}
store(response, StorageUtils.transactionNonceKey(state), nonce, sameSite, useLegacySameSiteCookie, isSecureCookie, cookiePath);
}

/**
* Gets the value associated with the state cookie and removes it.
* Gets the value associated with the state cookie for this transaction and removes it.
* Uses the state parameter from the callback request to look up the correct transaction cookie.
* Falls back to the legacy fixed-name cookie for backward compatibility during rolling upgrades.
*
* @param request the request object
* @param response the response object
* @return the value of the state cookie, if it exists
*/
static String getState(HttpServletRequest request, HttpServletResponse response) {
String stateParam = request.getParameter("state");
if (stateParam == null) {
return null;
}

// Try transaction-keyed cookie first (new behavior)
String value = getOnce(StorageUtils.transactionStateKey(stateParam), request, response);
if (value != null) {
return value;
}

// Fallback: legacy fixed-name cookie (for in-flight transactions during upgrade from v1)
return getOnce(StorageUtils.STATE_KEY, request, response);
}

/**
* Gets the value associated with the nonce cookie and removes it.
* Gets the value associated with the nonce cookie for this transaction and removes it.
* Uses the state parameter to look up the correct transaction-keyed nonce cookie.
* Falls back to the legacy fixed-name cookie for backward compatibility.
*
* @param request the request object
* @param response the response object
* @param state the state value from the callback (used to find the correct nonce cookie)
* @return the value of the nonce cookie, if it exists
*/
static String getNonce(HttpServletRequest request, HttpServletResponse response) {
static String getNonce(HttpServletRequest request, HttpServletResponse response, String state) {
if (state == null) {
return null;
}

// Try transaction-keyed cookie first (new behavior)
String value = getOnce(StorageUtils.transactionNonceKey(state), request, response);
if (value != null) {
return value;
}

// Fallback: legacy fixed-name cookie (for in-flight transactions during upgrade from v1)
return getOnce(StorageUtils.NONCE_KEY, request, response);
}

Expand Down
24 changes: 13 additions & 11 deletions src/test/java/com/auth0/AuthorizeUrlTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -81,27 +81,29 @@ public void shouldSetAudience() {
@Test
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add a few more tests to strengthen coverage of the multi-tab scenario for nonce?

  • A nonce isolation test (similar to shouldIsolateMultipleTransactions but for nonce) - store two nonces keyed by different states, retrieve each independently, confirm they don't interfere.
  • A nonce preference test (similar to shouldPreferTransactionKeyedOverLegacy but for nonce) - when both com.auth0.nonce.stateA and legacy com.auth0.nonce exist, confirm the transaction-keyed one wins.
  • An end-to-end multi-tab test that combines state + nonce together - two concurrent login flows storing their cookies, then each callback retrieves its own state and nonce correctly without affecting the other.

public void shouldSetNonceSameSiteAndLegacyCookieByDefault() {
String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token")
.withState("stateVal")
.withNonce("asdfghjkl")
.build();
assertThat(HttpUrl.parse(url).queryParameter("nonce"), is("asdfghjkl"));

Collection<String> headers = response.getHeaders("Set-Cookie");
assertThat(headers.size(), is(2));
assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.nonce=asdfghjkl; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None")));
assertThat(headers, hasItem(matchesPattern("_com\\.auth0\\.nonce=asdfghjkl; Max-Age=600; Expires=.*?; HttpOnly")));
// state (2: main + legacy) + nonce (2: main + legacy) = 4
assertThat(headers, hasItem(containsString("com.auth0.nonce.stateVal=asdfghjkl")));
assertThat(headers, hasItem(containsString("_com.auth0.nonce.stateVal=asdfghjkl")));
}

@Test
public void shouldSetNonceSameSiteAndNotLegacyCookieWhenConfigured() {
String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token")
.withState("stateVal")
.withNonce("asdfghjkl")
.withLegacySameSiteCookie(false)
.build();
assertThat(HttpUrl.parse(url).queryParameter("nonce"), is("asdfghjkl"));

Collection<String> headers = response.getHeaders("Set-Cookie");
assertThat(headers.size(), is(1));
assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.nonce=asdfghjkl; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None")));
assertThat(headers, hasItem(containsString("com.auth0.nonce.stateVal=asdfghjkl")));
assertThat(headers, not(hasItem(containsString("_com.auth0.nonce"))));
}

@Test
Expand All @@ -113,8 +115,8 @@ public void shouldSetStateSameSiteAndLegacyCookieByDefault() {

Collection<String> headers = response.getHeaders("Set-Cookie");
assertThat(headers.size(), is(2));
assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=asdfghjkl; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None")));
assertThat(headers, hasItem(matchesPattern("_com\\.auth0\\.state=asdfghjkl; Max-Age=600; Expires=.*?; HttpOnly")));
assertThat(headers, hasItem(containsString("com.auth0.state.asdfghjkl=asdfghjkl")));
assertThat(headers, hasItem(containsString("_com.auth0.state.asdfghjkl=asdfghjkl")));
}

@Test
Expand All @@ -127,7 +129,7 @@ public void shouldSetStateSameSiteAndNotLegacyCookieWhenConfigured() {

Collection<String> headers = response.getHeaders("Set-Cookie");
assertThat(headers.size(), is(1));
assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=asdfghjkl; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None")));
assertThat(headers, hasItem(containsString("com.auth0.state.asdfghjkl=asdfghjkl")));
}

@Test
Expand All @@ -140,7 +142,7 @@ public void shouldSetSecureCookieWhenConfiguredTrue() {

Collection<String> headers = response.getHeaders("Set-Cookie");
assertThat(headers.size(), is(1));
assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=asdfghjkl; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=Lax")));
assertThat(headers, hasItem(allOf(containsString("com.auth0.state.asdfghjkl=asdfghjkl"), containsString("Secure"), containsString("SameSite=Lax"))));
}

@Test
Expand All @@ -153,8 +155,8 @@ public void shouldSetSecureCookieWhenConfiguredFalseAndSameSiteNone() {

Collection<String> headers = response.getHeaders("Set-Cookie");
assertThat(headers.size(), is(2));
assertThat(headers, hasItem(matchesPattern("com\\.auth0\\.state=asdfghjkl; Max-Age=600; Expires=.*?; Secure; HttpOnly; SameSite=None")));
assertThat(headers, hasItem(matchesPattern("_com\\.auth0\\.state=asdfghjkl; Max-Age=600; Expires=.*?; HttpOnly")));
assertThat(headers, hasItem(allOf(containsString("com.auth0.state.asdfghjkl=asdfghjkl"), containsString("Secure"), containsString("SameSite=None"))));
assertThat(headers, hasItem(containsString("_com.auth0.state.asdfghjkl=asdfghjkl")));
}

@Test
Expand Down
Loading
Loading