Skip to content

Commit 70ed4b2

Browse files
authored
Fix: Transaction-keyed cookies to prevent multi-tab OAuth state race … (#231)
1 parent 98a02b6 commit 70ed4b2

6 files changed

Lines changed: 349 additions & 120 deletions

File tree

src/main/java/com/auth0/AuthorizeUrl.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ private void storeTransient() {
252252
SameSite sameSiteValue = containsFormPost() ? SameSite.NONE : SameSite.LAX;
253253

254254
TransientCookieStore.storeState(response, state, sameSiteValue, useLegacySameSiteCookie, setSecureCookie, cookiePath);
255-
TransientCookieStore.storeNonce(response, nonce, sameSiteValue, useLegacySameSiteCookie, setSecureCookie, cookiePath);
255+
TransientCookieStore.storeNonce(response, nonce, state, sameSiteValue, useLegacySameSiteCookie, setSecureCookie, cookiePath);
256256

257257
// Store HMAC-signed origin domain bound to this transaction's state
258258
if (originDomain != null && clientSecret != null && state != null) {

src/main/java/com/auth0/RequestProcessor.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ Tokens process(HttpServletRequest request, HttpServletResponse response) throws
240240
throw new InvalidRequestException(MISSING_ACCESS_TOKEN, "Access Token is missing from the response.");
241241
}
242242

243-
return getVerifiedTokens(request, response, frontChannelTokens, responseTypeList, originDomain, originIssuer);
243+
return getVerifiedTokens(request, response, frontChannelTokens, responseTypeList, originDomain, originIssuer, state);
244244
}
245245

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

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

265-
String nonce = TransientCookieStore.getNonce(request, response);
265+
String nonce = TransientCookieStore.getNonce(request, response, state);
266266

267267
try {
268268
if (responseTypeList.contains(KEY_ID_TOKEN)) {

src/main/java/com/auth0/StorageUtils.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,27 @@ private StorageUtils() {}
1212
static final String NONCE_KEY = "com.auth0.nonce";
1313
static final String ORIGIN_DOMAIN_KEY = "com.auth0.origin_domain";
1414

15+
/**
16+
* Constructs a transaction-keyed state cookie name.
17+
* Each login transaction gets its own cookie, preventing multi-tab overwrites.
18+
*
19+
* @param state the state value for this transaction
20+
* @return the cookie name in the form "com.auth0.state.{state}"
21+
*/
22+
static String transactionStateKey(String state) {
23+
return STATE_KEY + "." + state;
24+
}
25+
26+
/**
27+
* Constructs a transaction-keyed nonce cookie name.
28+
*
29+
* @param state the state value for this transaction (used as key, not the nonce itself)
30+
* @return the cookie name in the form "com.auth0.nonce.{state}"
31+
*/
32+
static String transactionNonceKey(String state) {
33+
return NONCE_KEY + "." + state;
34+
}
35+
1536
/**
1637
* Generates a new random string using {@link SecureRandom}.
1738
* The output can be used as State or Nonce values for API requests.

src/main/java/com/auth0/TransientCookieStore.java

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
import java.nio.charset.StandardCharsets;
1111

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

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

2023

2124
/**
22-
* Stores a state value as a cookie on the response.
25+
* Stores a state value as a transaction-keyed cookie on the response.
26+
* The cookie name includes the state value itself, ensuring each login flow
27+
* gets its own isolated cookie (e.g., "com.auth0.state.{state_value}").
2328
*
2429
* @param response the response object to set the cookie on
2530
* @param state the value for the state cookie. If null, no cookie will be set.
2631
* @param sameSite the value for the SameSite attribute on the cookie
2732
* @param useLegacySameSiteCookie whether to set a fallback cookie or not
2833
* @param isSecureCookie whether to always set the Secure cookie attribute or not
34+
* @param cookiePath the path for the cookie
2935
*/
3036
static void storeState(HttpServletResponse response, String state, SameSite sameSite, boolean useLegacySameSiteCookie, boolean isSecureCookie, String cookiePath) {
31-
store(response, StorageUtils.STATE_KEY, state, sameSite, useLegacySameSiteCookie, isSecureCookie, cookiePath);
37+
if (state == null) {
38+
return;
39+
}
40+
store(response, StorageUtils.transactionStateKey(state), state, sameSite, useLegacySameSiteCookie, isSecureCookie, cookiePath);
3241
}
3342

3443
/**
35-
* Stores a nonce value as a cookie on the response.
44+
* Stores a nonce value as a transaction-keyed cookie on the response.
45+
* The cookie is keyed by the state value (not the nonce), so it can be
46+
* retrieved during callback using the state parameter from the URL.
3647
*
3748
* @param response the response object to set the cookie on
3849
* @param nonce the value for the nonce cookie. If null, no cookie will be set.
50+
* @param state the state value for this transaction (used as key in cookie name)
3951
* @param sameSite the value for the SameSite attribute on the cookie
4052
* @param useLegacySameSiteCookie whether to set a fallback cookie or not
4153
* @param isSecureCookie whether to always set the Secure cookie attribute or not
54+
* @param cookiePath the path for the cookie
4255
*/
43-
static void storeNonce(HttpServletResponse response, String nonce, SameSite sameSite, boolean useLegacySameSiteCookie, boolean isSecureCookie, String cookiePath) {
44-
store(response, StorageUtils.NONCE_KEY, nonce, sameSite, useLegacySameSiteCookie, isSecureCookie, cookiePath);
56+
static void storeNonce(HttpServletResponse response, String nonce, String state, SameSite sameSite, boolean useLegacySameSiteCookie, boolean isSecureCookie, String cookiePath) {
57+
if (nonce == null || state == null) {
58+
return;
59+
}
60+
store(response, StorageUtils.transactionNonceKey(state), nonce, sameSite, useLegacySameSiteCookie, isSecureCookie, cookiePath);
4561
}
4662

4763
/**
48-
* Gets the value associated with the state cookie and removes it.
64+
* Gets the value associated with the state cookie for this transaction and removes it.
65+
* Uses the state parameter from the callback request to look up the correct transaction cookie.
66+
* Falls back to the legacy fixed-name cookie for backward compatibility during rolling upgrades.
4967
*
5068
* @param request the request object
5169
* @param response the response object
5270
* @return the value of the state cookie, if it exists
5371
*/
5472
static String getState(HttpServletRequest request, HttpServletResponse response) {
73+
String stateParam = request.getParameter("state");
74+
if (stateParam == null) {
75+
return null;
76+
}
77+
78+
// Try transaction-keyed cookie first (new behavior)
79+
String value = getOnce(StorageUtils.transactionStateKey(stateParam), request, response);
80+
if (value != null) {
81+
return value;
82+
}
83+
84+
// Fallback: legacy fixed-name cookie (for in-flight transactions during upgrade from v1)
5585
return getOnce(StorageUtils.STATE_KEY, request, response);
5686
}
5787

5888
/**
59-
* Gets the value associated with the nonce cookie and removes it.
89+
* Gets the value associated with the nonce cookie for this transaction and removes it.
90+
* Uses the state parameter to look up the correct transaction-keyed nonce cookie.
91+
* Falls back to the legacy fixed-name cookie for backward compatibility.
6092
*
6193
* @param request the request object
6294
* @param response the response object
95+
* @param state the state value from the callback (used to find the correct nonce cookie)
6396
* @return the value of the nonce cookie, if it exists
6497
*/
65-
static String getNonce(HttpServletRequest request, HttpServletResponse response) {
98+
static String getNonce(HttpServletRequest request, HttpServletResponse response, String state) {
99+
if (state == null) {
100+
return null;
101+
}
102+
103+
// Try transaction-keyed cookie first (new behavior)
104+
String value = getOnce(StorageUtils.transactionNonceKey(state), request, response);
105+
if (value != null) {
106+
return value;
107+
}
108+
109+
// Fallback: legacy fixed-name cookie (for in-flight transactions during upgrade from v1)
66110
return getOnce(StorageUtils.NONCE_KEY, request, response);
67111
}
68112

src/test/java/com/auth0/AuthorizeUrlTest.java

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -81,27 +81,29 @@ public void shouldSetAudience() {
8181
@Test
8282
public void shouldSetNonceSameSiteAndLegacyCookieByDefault() {
8383
String url = new AuthorizeUrl(client, response, "https://redirect.to/me", "id_token token")
84+
.withState("stateVal")
8485
.withNonce("asdfghjkl")
8586
.build();
8687
assertThat(HttpUrl.parse(url).queryParameter("nonce"), is("asdfghjkl"));
8788

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

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

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

107109
@Test
@@ -113,8 +115,8 @@ public void shouldSetStateSameSiteAndLegacyCookieByDefault() {
113115

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

120122
@Test
@@ -127,7 +129,7 @@ public void shouldSetStateSameSiteAndNotLegacyCookieWhenConfigured() {
127129

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

133135
@Test
@@ -140,7 +142,7 @@ public void shouldSetSecureCookieWhenConfiguredTrue() {
140142

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

146148
@Test
@@ -153,8 +155,8 @@ public void shouldSetSecureCookieWhenConfiguredFalseAndSameSiteNone() {
153155

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

160162
@Test

0 commit comments

Comments
 (0)