Skip to content

Commit 53adfb8

Browse files
authored
fix(bb-auth-oidc): wire broadcastAuthChange on successful OIDC callback (#89)
1 parent f946736 commit 53adfb8

4 files changed

Lines changed: 330 additions & 3 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@aws-blocks/bb-auth-oidc": patch
3+
---
4+
5+
fix(bb-auth-oidc): bridge a successful client callback into auth-common's onAuthChange
6+
7+
A successful client-PKCE `handleRedirectCallback()` only notified this OIDC
8+
client's own `onAuthStateChange` listeners. Components subscribed via
9+
`@aws-blocks/auth-common`'s `onAuthChange` — and `<AuthenticatedContent>`
10+
never heard about the sign-in, so a React SPA wouldn't re-render after
11+
completing the redirect exchange (only server-initiated sign-in updated them).
12+
13+
`handleRedirectCallback()` now also calls `broadcastAuthChange(user)` on success,
14+
and `signOut()` calls `broadcastAuthChange(null)`, firing the same-window
15+
`blocks-auth-change` event and the cross-tab `BroadcastChannel`, so every
16+
auth-common consumer (and other open tabs) re-render on both sign-in and sign-out.
17+
The README documents the `onAuthChange`/`broadcastAuthChange` wiring and adds an
18+
OIDC + React SPA example.

packages/bb-auth-oidc/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,51 @@ auth.signIn('google', { redirectPath: '/auth-return' });
6363

6464
`redirectPath` becomes the OAuth `redirect_uri`, so it must be a page your frontend serves **and** a redirect URI registered with the provider (the stub IdP accepts any HTTPS or localhost URL, so local/sandbox needs no registration).
6565

66+
### Re-rendering your UI on sign-in (React SPA)
67+
68+
`signIn()` and `handleRedirectCallback()` drive the OIDC exchange; to make your app re-render once it completes, subscribe to auth-state changes. Two complementary hooks are available:
69+
70+
- **`auth.onAuthStateChange(cb)`** — this OIDC client's own listener. Fires for this client instance on `signIn()` kickoff, on a successful `handleRedirectCallback()`, and on `signOut()`.
71+
- **`onAuthChange(authApi, cb)`** from `@aws-blocks/blocks/ui` — the shared `@aws-blocks/auth-common` subscription that also backs `<AuthenticatedContent>`. It updates **across components and browser tabs**.
72+
73+
A successful `handleRedirectCallback()` notifies **both**: it calls the local listeners *and* bridges into `@aws-blocks/auth-common` by calling `broadcastAuthChange(user)` for you, so `onAuthChange` consumers (and `<AuthenticatedContent>`) re-render on client-PKCE sign-in — not just on server-initiated sign-in. You don't call `broadcastAuthChange()` yourself for sign-in; the client does. Because it fires both, a component that subscribes to **both** `auth.onAuthStateChange()` and `onAuthChange()` will have its handler invoked twice on a single client-PKCE sign-in — harmless if your handler is idempotent, but prefer one per component.
74+
75+
```tsx
76+
import { useEffect, useState } from 'react';
77+
import { authApi } from 'aws-blocks';
78+
import { onAuthChange } from '@aws-blocks/blocks/ui';
79+
80+
// Dedicated callback route (e.g. /auth-return) — completes the PKCE exchange.
81+
export function AuthCallback() {
82+
useEffect(() => {
83+
authApi.getClient()
84+
.then((auth) => auth.handleRedirectCallback())
85+
.catch((err) => console.error('OIDC callback failed', err));
86+
}, []);
87+
return <p>Signing you in…</p>;
88+
}
89+
90+
// Any component — re-renders when auth state changes (this tab + other tabs).
91+
export function useUser() {
92+
const [user, setUser] = useState(null);
93+
// onAuthChange returns an unsubscribe fn; returning it cleans up on unmount.
94+
useEffect(() => onAuthChange(authApi, setUser), []);
95+
return user;
96+
}
97+
98+
export function SignInButton() {
99+
const user = useUser();
100+
if (user) return <span>Hi, {user.username}</span>;
101+
return (
102+
<button onClick={async () => (await authApi.getClient()).signIn('google')}>
103+
Sign in with Google
104+
</button>
105+
);
106+
}
107+
```
108+
109+
`onAuthChange` invokes your callback **synchronously** with the current user (from a shared cache) for the first paint, then again whenever auth state changes, and returns an unsubscribe function — return it from `useEffect` to wire up cleanup. The same broadcast also reaches other open tabs, so signing in (or out) in one tab updates them all. In the dedicated-callback pattern above, though, `handleRedirectCallback()` *broadcasts* the sign-in rather than priming that shared cache, so a `useUser()` that mounts **after** the callback fired starts from a cache miss: it paints once as signed-out, then self-corrects when its own async `getAuthState()` resolves — an expected, transient flash.
110+
66111
### Which flow to use
67112

68113
- **Server-initiated** (`GET /aws-blocks/auth/signin/<provider>` — a link or the `<Authenticator>` button): the backend owns the callback and sets the session cookie. This is the default for **same-origin** apps (frontend and API on one origin: local dev, single deployed origin, or the sandbox front door).

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

Lines changed: 237 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,62 @@ const CURRENT_PAGE = 'http://localhost:3000/dashboard';
1919
const AUTHORIZE_URL = 'https://idp.example.com/authorize';
2020

2121
let navigatedTo = '';
22+
let reloaded = false;
2223
let store: Map<string, string>;
2324

25+
/**
26+
* Cross-tab payloads captured from `BroadcastChannel.postMessage` so tests can
27+
* assert the OIDC client bridged its sign-in into `@aws-blocks/auth-common`.
28+
* Cleared (in place — the stub closes over this exact array) on each install.
29+
*/
30+
const broadcasts: unknown[] = [];
31+
let savedBroadcastChannel: unknown;
32+
33+
/**
34+
* A no-op `BroadcastChannel`. `auth-common`'s `broadcastAuthChange()` lazily
35+
* opens a real channel via `getChannel()`; a real one is a ref'd libuv handle
36+
* that keeps `node --test` alive and hangs the run. This records posts instead.
37+
*
38+
* `auth-common` caches that channel in a module-level singleton it never resets,
39+
* so whichever test triggers the first `broadcastAuthChange()` pins the live
40+
* instance for the rest of the run. Every stub instance posts to the SAME
41+
* module-level `broadcasts` array (emptied per test in `installBrowserGlobals`),
42+
* so the captured posts stay correct regardless of which describe block ran
43+
* first — preserve that shared-array invariant if this stub is refactored.
44+
*/
45+
class StubBroadcastChannel {
46+
name: string;
47+
onmessage: ((ev: unknown) => void) | null = null;
48+
constructor(name: string) { this.name = name; }
49+
postMessage(msg: unknown): void { broadcasts.push(msg); }
50+
addEventListener(): void {}
51+
removeEventListener(): void {}
52+
close(): void {}
53+
}
54+
2455
function installBrowserGlobals(currentHref: string): void {
2556
const url = new URL(currentHref);
2657
const locationStub = {
2758
get href() { return currentHref; },
2859
set href(v: string) { navigatedTo = v; },
2960
origin: url.origin,
3061
pathname: url.pathname,
62+
reload() { reloaded = true; },
3163
};
3264
store = new Map<string, string>();
33-
34-
(globalThis as any).window = { location: locationStub };
65+
broadcasts.length = 0;
66+
reloaded = false;
67+
68+
// Back `window` with a real EventTarget so auth-common's
69+
// `window.dispatchEvent(new CustomEvent('blocks-auth-change', …))` works and
70+
// tests can listen for the same-window auth-change event.
71+
const target = new EventTarget();
72+
(globalThis as any).window = {
73+
location: locationStub,
74+
addEventListener: target.addEventListener.bind(target),
75+
removeEventListener: target.removeEventListener.bind(target),
76+
dispatchEvent: target.dispatchEvent.bind(target),
77+
};
3578
(globalThis as any).sessionStorage = {
3679
getItem: (k: string) => store.get(k) ?? null,
3780
setItem: (k: string, v: string) => { store.set(k, v); },
@@ -40,12 +83,19 @@ function installBrowserGlobals(currentHref: string): void {
4083
// The client builds `redirect_uri` against window.location.href; some
4184
// code paths also read the global `location`. Mirror it.
4285
(globalThis as any).location = locationStub;
86+
87+
// Swap in the no-op BroadcastChannel before any broadcastAuthChange() call
88+
// caches a (real) channel instance.
89+
savedBroadcastChannel = (globalThis as any).BroadcastChannel;
90+
(globalThis as any).BroadcastChannel = StubBroadcastChannel;
4391
}
4492

4593
function clearBrowserGlobals(): void {
4694
delete (globalThis as any).window;
4795
delete (globalThis as any).sessionStorage;
4896
delete (globalThis as any).location;
97+
if (savedBroadcastChannel === undefined) delete (globalThis as any).BroadcastChannel;
98+
else (globalThis as any).BroadcastChannel = savedBroadcastChannel;
4999
navigatedTo = '';
50100
}
51101

@@ -418,3 +468,188 @@ describe('AuthOIDCClient.handleRedirectCallback — idempotency under double inv
418468
assert.strictEqual(exchangeCalls, 2, 'the fresh flow runs its own second exchange');
419469
});
420470
});
471+
472+
describe('AuthOIDCClient.handleRedirectCallback — @aws-blocks/auth-common bridge', () => {
473+
const STATE = 'state-bridge';
474+
const BARE_USER = { userId: 'iss:sub', username: 'alice', email: 'alice@example.invalid', provider: 'google' };
475+
let originalFetch: typeof globalThis.fetch;
476+
477+
beforeEach(() => {
478+
installBrowserGlobals(`http://localhost:3000/spa-callback?code=auth-code&state=${STATE}`);
479+
process.env.BLOCKS_API_URL = 'http://localhost:3000/aws-blocks/api';
480+
store.set('__blocks_oidc_pending', JSON.stringify({
481+
provider: 'google',
482+
verifier: 'v',
483+
state: STATE,
484+
nonce: 'n',
485+
callbackUrl: 'http://localhost:3000/spa-callback',
486+
appState: 'app-state',
487+
}));
488+
originalFetch = globalThis.fetch;
489+
});
490+
491+
afterEach(() => {
492+
globalThis.fetch = originalFetch;
493+
delete process.env.BLOCKS_API_URL;
494+
clearBrowserGlobals();
495+
});
496+
497+
function stubExchangeOk(body: unknown): void {
498+
globalThis.fetch = (async () => ({ ok: true, json: async () => body })) as unknown as typeof globalThis.fetch;
499+
}
500+
501+
test('dispatches a same-window auth-change event so on-page onAuthChange consumers re-render', async () => {
502+
stubExchangeOk({ user: BARE_USER });
503+
// auth-common's broadcastAuthChange() fires a 'blocks-auth-change'
504+
// CustomEvent on window; onAuthChange listeners on THIS page rely on it.
505+
let detail: any = null;
506+
(globalThis as any).window.addEventListener('blocks-auth-change', (e: any) => { detail = e.detail; });
507+
508+
const client = makeClient();
509+
const user = await client.handleRedirectCallback();
510+
511+
assert.ok(user, 'callback should resolve a user');
512+
assert.ok(detail, 'a blocks-auth-change event should have been dispatched on window');
513+
assert.strictEqual(detail.type, 'auth-change');
514+
assert.strictEqual(detail.user.userId, 'iss:sub');
515+
assert.strictEqual(detail.user.username, 'alice');
516+
});
517+
518+
test('posts the signed-in user across tabs via BroadcastChannel', async () => {
519+
stubExchangeOk({ user: BARE_USER });
520+
const client = makeClient();
521+
await client.handleRedirectCallback();
522+
523+
assert.strictEqual(broadcasts.length, 1, 'exactly one cross-tab post should have been made');
524+
const msg = broadcasts[0] as any;
525+
assert.strictEqual(msg.type, 'auth-change');
526+
assert.strictEqual(msg.user.userId, 'iss:sub');
527+
});
528+
529+
test('does NOT broadcast when the callback fails (state mismatch)', async () => {
530+
stubExchangeOk({ user: BARE_USER });
531+
// Tamper the stored state so validation throws before any exchange.
532+
store.set('__blocks_oidc_pending', JSON.stringify({
533+
provider: 'google', verifier: 'v', state: 'a-different-state', nonce: 'n',
534+
callbackUrl: 'http://localhost:3000/spa-callback',
535+
}));
536+
let dispatched = false;
537+
(globalThis as any).window.addEventListener('blocks-auth-change', () => { dispatched = true; });
538+
539+
const client = makeClient();
540+
await assert.rejects(() => client.handleRedirectCallback(), /state mismatch/);
541+
542+
assert.strictEqual(dispatched, false, 'no auth-change event on a failed callback');
543+
assert.strictEqual(broadcasts.length, 0, 'no cross-tab post on a failed callback');
544+
});
545+
});
546+
547+
describe('AuthOIDCClient.signOut — @aws-blocks/auth-common bridge', () => {
548+
let originalFetch: typeof globalThis.fetch;
549+
550+
beforeEach(() => {
551+
installBrowserGlobals('http://localhost:3000/dashboard');
552+
process.env.BLOCKS_API_URL = 'http://localhost:3000/aws-blocks/api';
553+
originalFetch = globalThis.fetch;
554+
// The /aws-blocks/auth/signout POST just needs to resolve OK.
555+
globalThis.fetch = (async () => ({ ok: true, json: async () => ({}) })) as unknown as typeof globalThis.fetch;
556+
});
557+
558+
afterEach(() => {
559+
globalThis.fetch = originalFetch;
560+
delete process.env.BLOCKS_API_URL;
561+
clearBrowserGlobals();
562+
});
563+
564+
test('posts a signed-out (null) user across tabs via BroadcastChannel', async () => {
565+
const client = makeClient();
566+
await client.signOut();
567+
568+
assert.strictEqual(broadcasts.length, 1, 'exactly one cross-tab post should have been made');
569+
const msg = broadcasts[0] as any;
570+
assert.strictEqual(msg.type, 'auth-change');
571+
assert.strictEqual(msg.user, null, 'sign-out broadcasts a null user so other tabs re-render');
572+
});
573+
574+
test('dispatches a same-window auth-change(null) event before reloading', async () => {
575+
// Other tabs rely on the cross-tab post above; same-tab onAuthChange
576+
// consumers rely on this same-window event. The page then reloads.
577+
let detail: any = 'unset';
578+
(globalThis as any).window.addEventListener('blocks-auth-change', (e: any) => { detail = e.detail; });
579+
580+
const client = makeClient();
581+
await client.signOut();
582+
583+
assert.notStrictEqual(detail, 'unset', 'a blocks-auth-change event should have been dispatched on window');
584+
assert.strictEqual(detail.type, 'auth-change');
585+
assert.strictEqual(detail.user, null);
586+
assert.strictEqual(reloaded, true, 'signOut should reload the page after broadcasting');
587+
});
588+
});
589+
590+
describe('AuthOIDCClient.signOut — server-side (no window / BroadcastChannel)', () => {
591+
let originalFetch: typeof globalThis.fetch;
592+
let savedWindow: unknown;
593+
let savedLocation: unknown;
594+
let savedSessionStorage: unknown;
595+
let savedBroadcastChannelGlobal: unknown;
596+
let signoutPosted: boolean;
597+
598+
beforeEach(() => {
599+
// Emulate SSR: strip the browser globals that broadcastAuthChange() (a
600+
// BroadcastChannel + window.dispatchEvent) and the reload depend on.
601+
// Snapshot first so a sibling describe that installed them isn't disturbed.
602+
// Note: Node ships a real global BroadcastChannel, so it must be removed
603+
// too — otherwise an un-guarded broadcast would open a live channel.
604+
savedWindow = (globalThis as any).window;
605+
savedLocation = (globalThis as any).location;
606+
savedSessionStorage = (globalThis as any).sessionStorage;
607+
savedBroadcastChannelGlobal = (globalThis as any).BroadcastChannel;
608+
delete (globalThis as any).window;
609+
delete (globalThis as any).location;
610+
delete (globalThis as any).sessionStorage;
611+
delete (globalThis as any).BroadcastChannel;
612+
613+
// Reset the shared capture state (installBrowserGlobals normally does this)
614+
// so the assertions below can't observe a sibling test's broadcast/reload.
615+
broadcasts.length = 0;
616+
reloaded = false;
617+
618+
// _getBaseUrl() resolves from this without touching window.
619+
process.env.BLOCKS_API_URL = 'http://localhost:3000/aws-blocks/api';
620+
originalFetch = globalThis.fetch;
621+
signoutPosted = false;
622+
globalThis.fetch = (async (url: any, init?: any) => {
623+
if (String(url).endsWith('/aws-blocks/auth/signout') && init?.method === 'POST') {
624+
signoutPosted = true;
625+
}
626+
return { ok: true, json: async () => ({}) };
627+
}) as unknown as typeof globalThis.fetch;
628+
});
629+
630+
afterEach(() => {
631+
globalThis.fetch = originalFetch;
632+
delete process.env.BLOCKS_API_URL;
633+
// Restore exactly what we snapshotted so sibling tests are unaffected.
634+
if (savedWindow === undefined) delete (globalThis as any).window;
635+
else (globalThis as any).window = savedWindow;
636+
if (savedLocation === undefined) delete (globalThis as any).location;
637+
else (globalThis as any).location = savedLocation;
638+
if (savedSessionStorage === undefined) delete (globalThis as any).sessionStorage;
639+
else (globalThis as any).sessionStorage = savedSessionStorage;
640+
if (savedBroadcastChannelGlobal === undefined) delete (globalThis as any).BroadcastChannel;
641+
else (globalThis as any).BroadcastChannel = savedBroadcastChannelGlobal;
642+
});
643+
644+
test('completes the server-side sign-out without a window and never broadcasts', async () => {
645+
const client = makeClient();
646+
// Pre-fix this rejected: broadcastAuthChange(null) ran before the window
647+
// guard, so getChannel()/window.dispatchEvent threw a ReferenceError after
648+
// the sign-out POST had already completed — stranding the returned promise.
649+
await assert.doesNotReject(() => client.signOut());
650+
651+
assert.strictEqual(signoutPosted, true, 'the server-side sign-out POST should still run');
652+
assert.strictEqual(broadcasts.length, 0, 'no cross-tab broadcast should be attempted with no window');
653+
assert.strictEqual(reloaded, false, 'no page reload server-side');
654+
});
655+
});

0 commit comments

Comments
 (0)