From 36da7a7c792d8f5d21dbe971b11f96495f9e5a46 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 18 Oct 2026 20:40:15 -0400 Subject: [PATCH] fix(react): make the web binding cookie transport only AuthProvider's transport no longer accepts mode or tokenStorage, and passing mode 'bearer' throws. Bearer transport stays with @seamless-auth/react-native. Closes #178 --- .changeset/web-binding-cookie-only.md | 5 +++ packages/react/README.md | 19 +++++----- packages/react/src/AuthProvider.tsx | 31 ++++++++------- packages/react/tests/authProvider.test.tsx | 44 ++++++++++++++-------- scripts/check-react-dist.mjs | 7 ++++ 5 files changed, 68 insertions(+), 38 deletions(-) create mode 100644 .changeset/web-binding-cookie-only.md diff --git a/.changeset/web-binding-cookie-only.md b/.changeset/web-binding-cookie-only.md new file mode 100644 index 0000000..31db541 --- /dev/null +++ b/.changeset/web-binding-cookie-only.md @@ -0,0 +1,5 @@ +--- +'@seamless-auth/react': minor +--- + +`AuthProvider` is cookie transport only. Its `transport` prop no longer accepts `mode` or `tokenStorage`, and passing `mode: 'bearer'` throws, so in a browser the tokens stay with the server adapter in `httpOnly` cookies and a script on the page can never read them. `basePath`, `fetch` and `trustedOrigins` are unchanged. Native apps that need bearer transport use `@seamless-auth/react-native`, which is unaffected. No first-party template, the admin console or RoxTarget used bearer mode from `@seamless-auth/react`. diff --git a/packages/react/README.md b/packages/react/README.md index c0afaf9..b1e896c 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -318,25 +318,24 @@ Custom UIs get the same default through `useAuthClient()`, and can still overrid Everything that differs between a browser and another platform sits behind a port on `AuthProvider`. A web application configures nothing; the browser implementations are the -defaults. A binding for another platform (React Native is the first) supplies its own: +defaults. A port can be swapped, for example to drive the passkey ceremonies yourself: ```tsx ``` -- `transport` selects how the session travels. Cookie transport, the default, is the browser - contract: the server adapter holds the tokens in `httpOnly` cookies and the client never sees - them. Bearer transport is the native contract: the client holds the auth API's own tokens, - presents the one a route needs in `Authorization`, stores the pair a sign-in returns through - `tokenStorage`, and refreshes once through `POST /auth/refresh` when a request answers 401. At - most one refresh is in flight at a time, because the auth API treats a replayed refresh token as - theft and revokes the chain. +- `transport` sets where the adapter is mounted (`basePath`, default `/auth`), the `fetch` to use, + and the extra origins `authorizedFetch` may reach (`trustedOrigins`). It is always cookie + transport: the server adapter holds the tokens in `httpOnly` cookies and the browser never sees + them, so a script on the page can ride the session but not read it. Passing `mode: 'bearer'` + throws. Native apps that need bearer transport, where the client holds the tokens in a keystore, + use `@seamless-auth/react-native`. - `ports.passkeys` runs the WebAuthn ceremonies (`create`, `get`, and the two support checks). `usePasskeySupport()` and every passkey flow go through it. - `ports.oauthRedirect` opens the provider. The browser navigates away and the callback route diff --git a/packages/react/src/AuthProvider.tsx b/packages/react/src/AuthProvider.tsx index eb2c19e..19947cb 100644 --- a/packages/react/src/AuthProvider.tsx +++ b/packages/react/src/AuthProvider.tsx @@ -130,10 +130,13 @@ interface AuthProviderProps { */ magicLinkRedirectUri?: string; /** - * How the session travels to the server adapter. Defaults to cookie - * transport, which is what a browser application wants. + * Where the adapter is mounted, the fetch to use, and the extra origins + * `authorizedFetch` may reach. Always cookie transport: the server adapter + * holds the tokens and the browser only HttpOnly cookies, so a script on the + * page can ride the session but never read it. Bearer transport is for + * native apps, through `@seamless-auth/react-native`. */ - transport?: Omit; + transport?: Omit; /** Platform ports. Each one left out falls back to the browser's. */ ports?: Partial; /** @@ -166,22 +169,24 @@ export const AuthProvider: React.FC = ({ [providedPorts?.passkeys, providedPorts?.oauthRedirect] ); - const { - mode, - basePath, - tokenStorage, - fetch: fetchImpl, - trustedOrigins, - } = transport ?? {}; + // The type no longer offers bearer, but a JavaScript caller can still pass it, + // and quietly switching it to cookies would hide that their tokens were never + // where they thought. + if ((transport as { mode?: unknown } | undefined)?.mode === 'bearer') { + throw new TypeError( + '@seamless-auth/react is cookie transport only, so the browser never holds the tokens. Use @seamless-auth/react-native for bearer transport in a native app.' + ); + } + + const { basePath, fetch: fetchImpl, trustedOrigins } = transport ?? {}; // An inline array is a new value on every render, so it is compared by content. const trustedOriginsKey = trustedOrigins ? JSON.stringify(trustedOrigins) : undefined; const stableTransport = useMemo( () => transport ? { - mode, + mode: 'cookie' as const, basePath, - tokenStorage, fetch: fetchImpl, trustedOrigins: trustedOriginsKey ? (JSON.parse(trustedOriginsKey) as string[]) @@ -189,7 +194,7 @@ export const AuthProvider: React.FC = ({ } : undefined, // eslint-disable-next-line react-hooks/exhaustive-deps -- `transport` itself is deliberately not a dependency - [mode, basePath, tokenStorage, fetchImpl, trustedOriginsKey] + [basePath, fetchImpl, trustedOriginsKey] ); // Seeds the store like a `useState` initialiser. A server component hands over diff --git a/packages/react/tests/authProvider.test.tsx b/packages/react/tests/authProvider.test.tsx index 2c2c75f..2a60213 100644 --- a/packages/react/tests/authProvider.test.tsx +++ b/packages/react/tests/authProvider.test.tsx @@ -511,13 +511,8 @@ describe('AuthProvider', () => { expect(typeof (seen[0] as { login: unknown }).login).toBe('function'); }); - it('passes transport and passkey ports through to the client', async () => { + it('passes transport options and passkey ports through to the client, as cookie transport', async () => { mockFetchWithAuthImpl.mockResolvedValue(signedIn()); - const tokenStorage = { - get: jest.fn(async () => null), - set: jest.fn(async () => undefined), - remove: jest.fn(async () => undefined), - }; const passkeys = { isSupported: () => true, isPlatformAuthenticatorAvailable: async () => true, @@ -535,7 +530,7 @@ describe('AuthProvider', () => { render( @@ -544,18 +539,39 @@ describe('AuthProvider', () => { }); expect(createFetchTransport).toHaveBeenCalledWith( - expect.objectContaining({ authHost: apiHost, mode: 'bearer', tokenStorage }) + expect.objectContaining({ + authHost: apiHost, + mode: 'cookie', + basePath: '/identity', + }) + ); + expect(createFetchTransport).not.toHaveBeenCalledWith( + expect.objectContaining({ tokenStorage: expect.anything() }) ); expect((ports as { passkeys: unknown }).passkeys).toBe(passkeys); }); + // The browser binding keeps tokens out of page script. A JavaScript caller + // can still pass bearer, and quietly using cookies instead would hide that. + it('refuses bearer transport', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + expect(() => + render( + + + + ) + ).toThrow(/cookie transport only/); + + spy.mockRestore(); + }); + it('keeps one session across re-renders that pass fresh but equal prop objects', async () => { mockFetchWithAuthImpl.mockResolvedValue(signedIn()); - const tokenStorage = { - get: jest.fn(async () => null), - set: jest.fn(async () => undefined), - remove: jest.fn(async () => undefined), - }; const passkeys = { isSupported: () => true, isPlatformAuthenticatorAvailable: async () => true, @@ -567,8 +583,6 @@ describe('AuthProvider', () => {