Skip to content

Commit 607fe57

Browse files
authored
fix(bb-auth-oidc): make handleRedirectCallback() idempotent under double invocation (#88)
1 parent 37c0b8b commit 607fe57

4 files changed

Lines changed: 320 additions & 56 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@aws-blocks/bb-auth-oidc": patch
3+
---
4+
5+
fix(bb-auth-oidc): make `handleRedirectCallback()` idempotent under double invocation
6+
7+
`handleRedirectCallback()` consumed the single-use PKCE pending entry from
8+
`sessionStorage` and only removed it **after** the `/aws-blocks/auth/exchange`
9+
round-trip. A second concurrent invocation — most commonly React StrictMode's
10+
mount → unmount → mount, which fires the callback effect twice synchronously —
11+
either replayed the already-consumed code (failing the second exchange) or
12+
found the pending entry gone and resolved `null`, stranding the app on a
13+
signed-out screen despite a successful sign-in.
14+
15+
The callback now guards on an in-flight promise keyed by the PKCE `code`:
16+
concurrent/duplicate invocations for the same code share the first call's
17+
promise instead of starting a second exchange, so both callers resolve to the
18+
same user and subscribers are notified exactly once. The pending entry is also
19+
consumed up front (before the network round-trip) so a late duplicate can't
20+
replay it, and the guard is released once the exchange settles so a genuinely
21+
new sign-in flow on the same page is never blocked.

packages/bb-auth-oidc/DESIGN.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,34 @@ encode to ~43 characters, comfortably exceeding the floor. The contract is
264264
stated once here: `csrf` MUST be ≥32 characters; SDKs SHOULD generate 32
265265
random bytes (≈43 base64url chars). Source files defer to this decision rather
266266
than restating the threshold in bytes.
267+
268+
### D12 — `handleRedirectCallback` shares one in-flight exchange per `(exchangePath, code)`
269+
270+
**Status:** Accepted.
271+
272+
**Rationale.** A PKCE `code` is single-use and must be exchanged exactly once. A
273+
double invocation — React StrictMode's mount → unmount → mount fires the callback
274+
effect twice synchronously — would otherwise race to exchange the same code twice;
275+
the loser finds the pending entry already consumed and resolves `null`, stranding
276+
the app on a signed-out screen despite a successful sign-in.
277+
278+
`_callbackInflight` (in `index.browser.ts`) is a **module-scoped** variable, so the
279+
guard is shared across every `AuthOIDCClient` instance in the tab — it is not a
280+
per-instance field. It is keyed on `(exchangePath, code)`:
281+
282+
- **`code`** — distinct sign-in flows carry distinct single-use codes, so they get
283+
distinct exchanges. Two instances in one app (the D4 admin-auth + customer-auth
284+
scenario) share the default `exchangePath`, so their codes being different is the
285+
only thing keeping their in-flight exchanges isolated.
286+
- **`exchangePath`** — two clients configured with different exchange endpoints never
287+
share each other's in-flight exchange, even if a code somehow collided.
288+
289+
The guard is released in a `finally` once the exchange settles (success or failure),
290+
so a genuinely new flow on the same page is never blocked.
291+
292+
**Cross-reload fallback.** The module variable does **not** survive a real page
293+
reload. Cross-reload coordination falls back to the up-front `sessionStorage`
294+
removal in `_exchangeCallback`: the pending PKCE entry is consumed *before* the
295+
network round-trip, so a late duplicate in a fresh page load (after the in-flight
296+
guard is gone) finds no pending entry and resolves `null` rather than replaying the
297+
code.

packages/bb-auth-oidc/src/index.browser.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,3 +263,158 @@ describe('AuthOIDCClient.handleRedirectCallback — return shape', () => {
263263
assert.strictEqual('iss' in lastExchangeBody, false, 'iss should be omitted, not sent as undefined');
264264
});
265265
});
266+
267+
describe('AuthOIDCClient.handleRedirectCallback — idempotency under double invocation', () => {
268+
const STATE = 'state-dbl';
269+
const BARE_USER = { userId: 'iss:sub', username: 'alice', email: 'alice@example.invalid', provider: 'google' };
270+
let originalFetch: typeof globalThis.fetch;
271+
272+
beforeEach(() => {
273+
installBrowserGlobals(`http://localhost:3000/spa-callback?code=auth-code-dbl&state=${STATE}`);
274+
process.env.BLOCKS_API_URL = 'http://localhost:3000/aws-blocks/api';
275+
store.set('__blocks_oidc_pending', JSON.stringify({
276+
provider: 'google',
277+
verifier: 'v',
278+
state: STATE,
279+
nonce: 'n',
280+
callbackUrl: 'http://localhost:3000/spa-callback',
281+
appState: 'app-state',
282+
}));
283+
originalFetch = globalThis.fetch;
284+
});
285+
286+
afterEach(() => {
287+
globalThis.fetch = originalFetch;
288+
delete process.env.BLOCKS_API_URL;
289+
clearBrowserGlobals();
290+
});
291+
292+
test('concurrent double invocation shares one exchange and both resolve to the same user', async () => {
293+
// React StrictMode mounts → unmounts → mounts, firing the callback effect
294+
// twice synchronously. Count the exchange POSTs to prove the single-use
295+
// PKCE code is exchanged exactly once and neither caller is stranded.
296+
let exchangeCalls = 0;
297+
globalThis.fetch = (async (_url: any, init?: any) => {
298+
if (init?.method === 'POST') exchangeCalls++;
299+
// Settle on a later tick so both calls are genuinely in flight together.
300+
await new Promise((r) => setTimeout(r, 5));
301+
return { ok: true, json: async () => ({ user: BARE_USER }) };
302+
}) as unknown as typeof globalThis.fetch;
303+
304+
const client = makeClient();
305+
let notifyCount = 0;
306+
client.onAuthStateChange(() => { notifyCount++; });
307+
// onAuthStateChange fires synchronously on subscribe with the last-known
308+
// state; capture that baseline so the assertion below measures only the
309+
// callback-driven notify as a delta, independent of cross-test module state.
310+
const notifyBaseline = notifyCount;
311+
312+
// Fire twice WITHOUT awaiting the first — the double-mount race.
313+
const [r1, r2] = await Promise.all([
314+
client.handleRedirectCallback(),
315+
client.handleRedirectCallback(),
316+
]);
317+
318+
assert.ok(r1, 'first call must resolve a user');
319+
assert.ok(r2, 'second (concurrent) call must resolve a user — not null/throw');
320+
assert.strictEqual(r1!.userId, 'iss:sub');
321+
assert.strictEqual(r2!.userId, 'iss:sub');
322+
assert.strictEqual(exchangeCalls, 1, 'single-use PKCE code must be exchanged exactly once');
323+
assert.strictEqual(notifyCount - notifyBaseline, 1, 'callback should notify subscribers exactly once');
324+
assert.strictEqual(store.get('__blocks_oidc_pending'), undefined, 'pending entry should be consumed');
325+
});
326+
327+
test('a sequential double invocation also shares the in-flight result', async () => {
328+
// Same race, expressed as two calls captured before awaiting either.
329+
let exchangeCalls = 0;
330+
globalThis.fetch = (async (_url: any, init?: any) => {
331+
if (init?.method === 'POST') exchangeCalls++;
332+
await new Promise((r) => setTimeout(r, 5));
333+
return { ok: true, json: async () => ({ user: BARE_USER }) };
334+
}) as unknown as typeof globalThis.fetch;
335+
336+
const client = makeClient();
337+
const p1 = client.handleRedirectCallback();
338+
const p2 = client.handleRedirectCallback();
339+
const r1 = await p1;
340+
const r2 = await p2;
341+
assert.strictEqual(r1!.userId, 'iss:sub');
342+
assert.strictEqual(r2!.userId, 'iss:sub');
343+
assert.strictEqual(exchangeCalls, 1, 'only one exchange for the shared in-flight code');
344+
});
345+
346+
test('releases the guard after settling so a fresh flow on the same page can run', async () => {
347+
let exchangeCalls = 0;
348+
globalThis.fetch = (async (_url: any, init?: any) => {
349+
if (init?.method === 'POST') exchangeCalls++;
350+
return { ok: true, json: async () => ({ user: BARE_USER }) };
351+
}) as unknown as typeof globalThis.fetch;
352+
353+
const client = makeClient();
354+
const first = await client.handleRedirectCallback();
355+
assert.ok(first, 'first flow resolves');
356+
assert.strictEqual(exchangeCalls, 1);
357+
358+
// Simulate a brand-new flow (new code/state + freshly stored pending blob).
359+
installBrowserGlobals('http://localhost:3000/spa-callback?code=auth-code-2&state=state-2');
360+
process.env.BLOCKS_API_URL = 'http://localhost:3000/aws-blocks/api';
361+
store.set('__blocks_oidc_pending', JSON.stringify({
362+
provider: 'google', verifier: 'v', state: 'state-2', nonce: 'n',
363+
callbackUrl: 'http://localhost:3000/spa-callback',
364+
}));
365+
366+
const second = await client.handleRedirectCallback();
367+
assert.ok(second, 'second independent flow resolves — guard released after the first settled');
368+
assert.strictEqual(exchangeCalls, 2, 'the second flow runs its own exchange');
369+
});
370+
371+
test('error path under concurrent double invocation rejects both callers identically and releases the guard', async () => {
372+
// The guard must propagate ONE shared rejection to both callers and
373+
// release on failure. Without this, a refactor that mishandled the shared
374+
// rejection (stranding the page) or failed to release the guard (blocking
375+
// a same-page retry) would keep the success-path tests green.
376+
let exchangeCalls = 0;
377+
globalThis.fetch = (async (_url: any, init?: any) => {
378+
if (init?.method === 'POST') exchangeCalls++;
379+
// Settle on a later tick so both calls are genuinely in flight together.
380+
await new Promise((r) => setTimeout(r, 5));
381+
return { ok: false, json: async () => ({ error: 'invalid_grant' }) };
382+
}) as unknown as typeof globalThis.fetch;
383+
384+
const client = makeClient();
385+
386+
// Fire twice WITHOUT awaiting the first; allSettled captures both outcomes.
387+
const [s1, s2] = await Promise.allSettled([
388+
client.handleRedirectCallback(),
389+
client.handleRedirectCallback(),
390+
]);
391+
392+
assert.strictEqual(s1.status, 'rejected', 'first call must reject when the exchange fails');
393+
assert.strictEqual(s2.status, 'rejected', 'second (concurrent) call must reject too — never resolve null');
394+
// Both callers share the one in-flight promise, so the rejection is the
395+
// identical Error instance — not two independently-thrown errors.
396+
const reason1 = (s1 as PromiseRejectedResult).reason;
397+
const reason2 = (s2 as PromiseRejectedResult).reason;
398+
assert.strictEqual(reason1, reason2, 'both callers must reject with the identical shared error');
399+
assert.match(reason1.message, /exchange failed/i);
400+
assert.strictEqual(exchangeCalls, 1, 'single-use PKCE code must be exchanged exactly once, even on failure');
401+
402+
// The finally must release the guard on failure: a fresh-code flow on the
403+
// same page runs its own exchange instead of being blocked by a stale entry.
404+
installBrowserGlobals('http://localhost:3000/spa-callback?code=auth-code-retry&state=state-retry');
405+
process.env.BLOCKS_API_URL = 'http://localhost:3000/aws-blocks/api';
406+
store.set('__blocks_oidc_pending', JSON.stringify({
407+
provider: 'google', verifier: 'v', state: 'state-retry', nonce: 'n',
408+
callbackUrl: 'http://localhost:3000/spa-callback',
409+
}));
410+
globalThis.fetch = (async (_url: any, init?: any) => {
411+
if (init?.method === 'POST') exchangeCalls++;
412+
return { ok: true, json: async () => ({ user: BARE_USER }) };
413+
}) as unknown as typeof globalThis.fetch;
414+
415+
const retry = await client.handleRedirectCallback();
416+
assert.ok(retry, 'a fresh-code flow resolves — the guard was released after the failure');
417+
assert.strictEqual(retry!.userId, 'iss:sub');
418+
assert.strictEqual(exchangeCalls, 2, 'the fresh flow runs its own second exchange');
419+
});
420+
});

0 commit comments

Comments
 (0)