From 7f388841f52d0e1304a28081eb4b3434d4ef0f6d Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 20 Oct 2026 21:23:26 -0400 Subject: [PATCH 1/3] fix(oauth): send the state binding back with the OAuth callback The auth server can now bind the OAuth state to the client that started sign-in and refuse a callback without that binding. The session actions store the binding returned by startOAuthLogin with its state, attach it in finishOAuthLogin when the state matches, and clear it afterwards. startOAuthSignIn passes it explicitly on the in-app browser path and refuses a callback whose state differs from the one it started. Refs fells-code/seamless-auth-api#463 --- .changeset/oauth-state-binding.md | 9 + AGENTS.md | 8 + packages/client/README.md | 20 +- .../src/client/createSeamlessAuthClient.ts | 13 +- packages/client/src/flows/oauth.ts | 10 +- .../client/src/session/createAuthSession.ts | 29 ++- packages/client/src/session/oauthBinding.ts | 98 ++++++++ .../tests/createSeamlessAuthClient.test.ts | 18 ++ packages/client/tests/flows.test.ts | 51 +++- .../client/tests/oauthBinding.node.test.ts | 72 ++++++ packages/client/tests/oauthBinding.test.ts | 230 ++++++++++++++++++ packages/react-native/README.md | 7 + packages/react/README.md | 8 + .../react/tests/OAuthProviderButtons.test.tsx | 7 +- 14 files changed, 571 insertions(+), 9 deletions(-) create mode 100644 .changeset/oauth-state-binding.md create mode 100644 packages/client/src/session/oauthBinding.ts create mode 100644 packages/client/tests/oauthBinding.node.test.ts create mode 100644 packages/client/tests/oauthBinding.test.ts diff --git a/.changeset/oauth-state-binding.md b/.changeset/oauth-state-binding.md new file mode 100644 index 0000000..c39273b --- /dev/null +++ b/.changeset/oauth-state-binding.md @@ -0,0 +1,9 @@ +--- +'@seamless-auth/client': patch +'@seamless-auth/react': patch +'@seamless-auth/react-native': patch +--- + +OAuth sign-in now carries the state binding the auth server returns from `POST /oauth/:providerId/start` back to the callback. The session actions `startOAuthLogin` and `finishOAuthLogin` (and so `useAuth()` in React and React Native, and the Angular, Vue and Svelte bindings) store the binding with its state, attach it to the matching callback, and clear it afterwards, so apps that call these actions directly need no code change. The binding is kept in `sessionStorage` under `seamless:oauth:binding` in a browser and in memory elsewhere, and is never sent to the provider or put in a URL. `startOAuthSignIn` passes it explicitly when an in-app browser hands the callback back, and now refuses a callback whose state is not the one it started. + +`StartOAuthLoginResult` gains an optional `binding` and `FinishOAuthLoginInput` an optional `binding`. Older auth servers return no binding and are unaffected. Auth API 0.19.x with fells-code/seamless-auth-api#463 refuses a callback without the binding, so upgrade this SDK before or with that API release. diff --git a/AGENTS.md b/AGENTS.md index 1bdc783..390c01c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -414,6 +414,14 @@ handles it or excludes it deliberately. Listing the wanted names would drop a ne code on the floor and leave the map compiling, which is the silent drift the union exists to prevent. +`POST /oauth/:providerId/start` can return a `binding` that the callback must send +back (fells-code/seamless-auth-api#463). The session actions store it with its +state (`packages/client/src/session/oauthBinding.ts`) and attach it in +`finishOAuthLogin`, so callers of those actions get it without code changes. It +never goes to the provider or into a URL. The optional `binding` field is declared +locally on `StartOAuthLoginResult` and `FinishOAuthLoginInput` until +`@seamless-auth/types` publishes it. + Before documenting new flow behavior, verify the route contract in `seamless-auth-server` or `seamless-auth-api`. ## Migration Status diff --git a/packages/client/README.md b/packages/client/README.md index f96402a..dfb9e5e 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -63,9 +63,23 @@ any framework can call them directly. | `isValidEmail`, `isValidPhoneNumber`, `parseUserAgent`, `formatCountdown` | Small shared helpers | None of them store a token. In a browser the session lives in HttpOnly cookies the -server adapter sets; the only thing written to browser storage is the id of the -OAuth provider in flight (`sessionStorage`) and a flag that this browser has -signed in before (`localStorage`). +server adapter sets; the only things written to browser storage are the id of the +OAuth provider in flight and that sign-in's state binding (`sessionStorage`), and a +flag that this browser has signed in before (`localStorage`). + +### OAuth state binding + +Auth servers that bind the OAuth state to the client that started sign-in return a +`binding` from `POST /oauth/:providerId/start` and refuse a callback that does not +send it back (`400`, `Invalid OAuth state`). The session actions handle it: +`actions.startOAuthLogin` stores the binding with its `state` (`sessionStorage` +under `OAUTH_BINDING_STORAGE_KEY`, or memory where there is none), and +`actions.finishOAuthLogin` attaches it when the callback's `state` matches, then +clears it whether or not the callback succeeds. A `binding` passed to +`finishOAuthLogin` takes precedence. Code that calls `createSeamlessAuthClient`'s +`startOAuthLogin` and `finishOAuthLogin` directly must carry the binding itself. +It goes only to the auth server, never to the provider or into a URL. Older servers +return no binding, and nothing is sent. ## Transport diff --git a/packages/client/src/client/createSeamlessAuthClient.ts b/packages/client/src/client/createSeamlessAuthClient.ts index 7be2f4d..5701751 100644 --- a/packages/client/src/client/createSeamlessAuthClient.ts +++ b/packages/client/src/client/createSeamlessAuthClient.ts @@ -150,12 +150,22 @@ export interface StartOAuthLoginInput { returnTo?: string; } -export type StartOAuthLoginResult = StartOAuthLoginResponse; +/** + * `binding` is returned by auth servers that tie the OAuth state to the client + * that started sign-in, and must come back with the callback. Older servers omit + * it. Declared here until `@seamless-auth/types` publishes it. + */ +export type StartOAuthLoginResult = StartOAuthLoginResponse & { binding?: string }; export interface FinishOAuthLoginInput { providerId: string; code: string; state: string; + /** + * The `binding` from the matching start. The session actions attach the one + * they stored when this is left out. + */ + binding?: string; } /** @@ -712,6 +722,7 @@ export const createSeamlessAuthClient = ( body: JSON.stringify({ code: input.code, state: input.state, + ...(input.binding ? { binding: input.binding } : {}), }), }), 'Failed to finish OAuth login.' diff --git a/packages/client/src/flows/oauth.ts b/packages/client/src/flows/oauth.ts index 830531a..3537f08 100644 --- a/packages/client/src/flows/oauth.ts +++ b/packages/client/src/flows/oauth.ts @@ -9,9 +9,12 @@ import type { AuthSessionActions } from '../session/createAuthSession'; import { oauthErrorMessage } from './messages'; import { inAppPath } from './redirects'; +export { OAUTH_BINDING_STORAGE_KEY } from '../session/oauthBinding'; + /** * Where the provider being signed in with is kept across the redirect. Only the - * provider id is stored: never a code, state, or token. + * provider id is stored here. The session actions keep the OAuth state's binding + * under `OAUTH_BINDING_STORAGE_KEY`; no code or token is stored. */ export const OAUTH_PROVIDER_STORAGE_KEY = 'seamless:oauth:provider'; @@ -70,10 +73,15 @@ export async function startOAuthSignIn( const outcome = await deps.oauthRedirect.open(data.authorizationUrl, redirectUri); if (outcome.type === 'callback') { + if (outcome.state !== data.state) { + return { error: 'Could not finish sign-in with this provider.' }; + } + const finished = await deps.actions.finishOAuthLogin({ providerId, code: outcome.code, state: outcome.state, + ...(data.binding ? { binding: data.binding } : {}), }); if (finished.error) { diff --git a/packages/client/src/session/createAuthSession.ts b/packages/client/src/session/createAuthSession.ts index ddf29a5..9db8267 100644 --- a/packages/client/src/session/createAuthSession.ts +++ b/packages/client/src/session/createAuthSession.ts @@ -28,6 +28,7 @@ import type { SeamlessAuthResult } from '../client/result'; import { PasskeyPrfInput } from '../client/webauthnPrf'; import { hasScopedRole as rolesGrantScopedAccess } from '../scopedRoles'; import { Credential, Organization, User } from '../types'; +import { createOAuthBindingStore } from './oauthBinding'; import { createDefaultStorage, SessionStoragePort } from './storage'; const PREVIOUS_SIGN_IN_KEY = 'seamlessauth_seen'; @@ -176,6 +177,7 @@ export function createAuthSession(options: AuthSessionOptions): AuthSession { providedClient ?? createSeamlessAuthClient({ ...clientOptions, apiHost }); const storage = providedStorage ?? createDefaultStorage(); const listeners = new Set<() => void>(); + const oauthBindings = createOAuthBindingStore(); let destroyed = false; // Guards against a slow earlier session read overwriting a newer one, which @@ -358,8 +360,31 @@ export function createAuthSession(options: AuthSessionOptions): AuthSession { refreshAfter(() => client.switchOrganization(organizationId)), listOAuthProviders: () => client.listOAuthProviders(), - startOAuthLogin: input => client.startOAuthLogin(input), - finishOAuthLogin: input => refreshAfter(() => client.finishOAuthLogin(input)), + startOAuthLogin: async input => { + const result = await client.startOAuthLogin(input); + + if (!result.error) { + if (result.data.binding) { + oauthBindings.save({ state: result.data.state, binding: result.data.binding }); + } else { + oauthBindings.clear(); + } + } + + return result; + }, + + finishOAuthLogin: async input => { + const binding = input.binding ?? oauthBindings.read(input.state); + + try { + return await refreshAfter(() => + client.finishOAuthLogin(binding ? { ...input, binding } : input) + ); + } finally { + oauthBindings.clear(); + } + }, refreshStepUpStatus: async () => { const result = await client.getStepUpStatus(); diff --git a/packages/client/src/session/oauthBinding.ts b/packages/client/src/session/oauthBinding.ts new file mode 100644 index 0000000..6cb8697 --- /dev/null +++ b/packages/client/src/session/oauthBinding.ts @@ -0,0 +1,98 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +/** + * Where the binding the auth server returned from an OAuth start is kept until + * its callback. Stored with the state it belongs to, so a binding left behind by + * an abandoned attempt is never sent with another attempt's state. It stays on + * this client: it is never sent to the provider or put in a URL. + */ +export const OAUTH_BINDING_STORAGE_KEY = 'seamless:oauth:binding'; + +interface PendingBinding { + state: string; + binding: string; +} + +export interface OAuthBindingStore { + save: (pending: PendingBinding) => void; + read: (state: string) => string | null; + clear: () => void; +} + +function browserStore(): Storage | null { + try { + return typeof sessionStorage === 'undefined' ? null : sessionStorage; + } catch { + return null; + } +} + +function parse(raw: string | null): PendingBinding | null { + if (!raw) return null; + + try { + const value: unknown = JSON.parse(raw); + + if ( + typeof value === 'object' && + value !== null && + typeof (value as PendingBinding).state === 'string' && + typeof (value as PendingBinding).binding === 'string' + ) { + return value as PendingBinding; + } + } catch { + // Unreadable values are treated as absent. + } + + return null; +} + +/** + * sessionStorage where there is one, so the binding survives the redirect to the + * provider. The in-memory copy covers environments without it (React Native, + * where the callback returns to the same process) and storage that refuses writes. + */ +export function createOAuthBindingStore(): OAuthBindingStore { + let memory: PendingBinding | null = null; + + const stored = (): PendingBinding | null => { + try { + return parse(browserStore()?.getItem(OAUTH_BINDING_STORAGE_KEY) ?? null); + } catch { + return null; + } + }; + + return { + save: pending => { + memory = pending; + + try { + browserStore()?.setItem(OAUTH_BINDING_STORAGE_KEY, JSON.stringify(pending)); + } catch { + // Storage can be unavailable, for example in private mode. + } + }, + read: state => { + for (const pending of [stored(), memory]) { + if (pending?.state === state) return pending.binding; + } + + return null; + }, + clear: () => { + memory = null; + + try { + browserStore()?.removeItem(OAUTH_BINDING_STORAGE_KEY); + } catch { + // Storage can be unavailable, for example in private mode. + } + }, + }; +} diff --git a/packages/client/tests/createSeamlessAuthClient.test.ts b/packages/client/tests/createSeamlessAuthClient.test.ts index 56335de..2e0d643 100644 --- a/packages/client/tests/createSeamlessAuthClient.test.ts +++ b/packages/client/tests/createSeamlessAuthClient.test.ts @@ -554,6 +554,24 @@ describe('createSeamlessAuthClient', () => { }); }); + it('sends the OAuth binding in the callback body only', async () => { + mockFetchWithAuth.mockResolvedValueOnce({ ok: true }); + + const client = createSeamlessAuthClient({ apiHost: 'https://api.example.com' }); + + await client.finishOAuthLogin({ + providerId: 'google', + code: 'code', + state: 'state', + binding: 'binding', + }); + + expect(mockFetchWithAuth).toHaveBeenCalledWith('/oauth/google/callback', { + method: 'POST', + body: JSON.stringify({ code: 'code', state: 'state', binding: 'binding' }), + }); + }); + it('returns a successful passkey login result when both WebAuthn steps succeed', async () => { mockFetchWithAuth .mockResolvedValueOnce({ diff --git a/packages/client/tests/flows.test.ts b/packages/client/tests/flows.test.ts index 05f1358..daa23a5 100644 --- a/packages/client/tests/flows.test.ts +++ b/packages/client/tests/flows.test.ts @@ -506,7 +506,9 @@ describe('OAuth', () => { const result = await startOAuthSignIn( { actions: { - startOAuthLogin: jest.fn().mockResolvedValue(ok({ authorizationUrl: 'u' })), + startOAuthLogin: jest + .fn() + .mockResolvedValue(ok({ authorizationUrl: 'u', state: 's' })), finishOAuthLogin, }, oauthRedirect: { @@ -523,6 +525,53 @@ describe('OAuth', () => { expect(result.error).toBe('Could not finish sign-in with this provider.'); }); + it('passes the binding from the start when finishing in place', async () => { + const finishOAuthLogin = jest.fn().mockResolvedValue(ok({})); + const result = await startOAuthSignIn( + { + actions: { + startOAuthLogin: jest + .fn() + .mockResolvedValue(ok({ authorizationUrl: 'u', state: 's', binding: 'b' })), + finishOAuthLogin, + }, + oauthRedirect: { + open: jest.fn().mockResolvedValue({ type: 'callback', code: 'c', state: 's' }), + }, + }, + { providerId: 'mock', redirectUri: 'x' } + ); + expect(finishOAuthLogin).toHaveBeenCalledWith({ + providerId: 'mock', + code: 'c', + state: 's', + binding: 'b', + }); + expect(result.error).toBeNull(); + }); + + it('refuses a callback whose state is not the one it started', async () => { + const finishOAuthLogin = jest.fn(); + const result = await startOAuthSignIn( + { + actions: { + startOAuthLogin: jest + .fn() + .mockResolvedValue(ok({ authorizationUrl: 'u', state: 's', binding: 'b' })), + finishOAuthLogin, + }, + oauthRedirect: { + open: jest + .fn() + .mockResolvedValue({ type: 'callback', code: 'c', state: 'other' }), + }, + }, + { providerId: 'mock', redirectUri: 'x' } + ); + expect(finishOAuthLogin).not.toHaveBeenCalled(); + expect(result.error).toBe('Could not finish sign-in with this provider.'); + }); + it('refuses a callback missing its code, state, or provider', async () => { const finishOAuthLogin = jest.fn(); const outcome = await completeOAuthCallback( diff --git a/packages/client/tests/oauthBinding.node.test.ts b/packages/client/tests/oauthBinding.node.test.ts new file mode 100644 index 0000000..92ef573 --- /dev/null +++ b/packages/client/tests/oauthBinding.node.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + * + * @jest-environment node + */ + +import { createAuthSession } from '@/session/createAuthSession'; +import { createMemoryStorage } from '@/session/storage'; + +jest.mock('@/fetchWithAuth', () => ({ + createFetchWithAuth: () => jest.fn(), + createFetchTransport: () => ({ + fetch: jest.fn(), + authorizedFetch: jest.fn(), + mode: 'cookie', + clearTokens: jest.fn(), + }), +})); + +// React Native has no sessionStorage. Its callback returns to the same process, +// so the in-memory copy is enough. +describe('OAuth state binding without sessionStorage', () => { + it('keeps the binding in memory for the callback, then clears it', async () => { + expect(typeof sessionStorage).toBe('undefined'); + + const client = { + startOAuthLogin: jest.fn().mockResolvedValue({ + data: { + provider: { id: 'mock', name: 'Mock' }, + state: 's1', + authorizationUrl: 'https://idp.example/authorize', + binding: 'b1', + }, + error: null, + }), + finishOAuthLogin: jest + .fn() + .mockResolvedValue({ data: null, error: new Error('x') }), + }; + const session = createAuthSession({ + apiHost: 'https://api.example.com', + client: client as never, + storage: createMemoryStorage(), + }); + + await session.actions.startOAuthLogin({ providerId: 'mock' }); + await session.actions.finishOAuthLogin({ + providerId: 'mock', + code: 'c', + state: 's1', + }); + await session.actions.finishOAuthLogin({ + providerId: 'mock', + code: 'c', + state: 's1', + }); + + expect(client.finishOAuthLogin).toHaveBeenNthCalledWith(1, { + providerId: 'mock', + code: 'c', + state: 's1', + binding: 'b1', + }); + expect(client.finishOAuthLogin).toHaveBeenNthCalledWith(2, { + providerId: 'mock', + code: 'c', + state: 's1', + }); + }); +}); diff --git a/packages/client/tests/oauthBinding.test.ts b/packages/client/tests/oauthBinding.test.ts new file mode 100644 index 0000000..f2f96ae --- /dev/null +++ b/packages/client/tests/oauthBinding.test.ts @@ -0,0 +1,230 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { createAuthSession } from '../src/session/createAuthSession'; +import { OAUTH_BINDING_STORAGE_KEY } from '../src/session/oauthBinding'; +import { createMemoryStorage } from '../src/session/storage'; + +jest.mock('../src/fetchWithAuth', () => ({ + createFetchWithAuth: () => jest.fn(), + createFetchTransport: () => ({ + fetch: jest.fn(), + authorizedFetch: jest.fn(), + mode: 'cookie', + clearTokens: jest.fn(), + }), +})); + +const user = { id: '1', email: 'test@example.com', phone: '', roles: [] }; + +const started = (state: string, binding?: string) => ({ + data: { + provider: { id: 'mock', name: 'Mock' }, + state, + authorizationUrl: 'https://idp.example/authorize', + ...(binding ? { binding } : {}), + }, + error: null, +}); + +function buildSession() { + const client = { + startOAuthLogin: jest.fn(), + finishOAuthLogin: jest.fn().mockResolvedValue({ data: {}, error: null }), + getCurrentUser: jest.fn().mockResolvedValue({ + data: { user, credentials: [] }, + error: null, + }), + }; + const session = createAuthSession({ + apiHost: 'https://api.example.com', + client: client as never, + storage: createMemoryStorage(), + }); + + return { client, session }; +} + +beforeEach(() => { + sessionStorage.clear(); +}); + +describe('OAuth state binding', () => { + it('stores the binding with its state when sign-in starts', async () => { + const { client, session } = buildSession(); + client.startOAuthLogin.mockResolvedValue(started('s1', 'b1')); + + await session.actions.startOAuthLogin({ providerId: 'mock' }); + + expect(JSON.parse(sessionStorage.getItem(OAUTH_BINDING_STORAGE_KEY)!)).toEqual({ + state: 's1', + binding: 'b1', + }); + }); + + it('attaches the stored binding to the callback and clears it afterwards', async () => { + const { client, session } = buildSession(); + client.startOAuthLogin.mockResolvedValue(started('s1', 'b1')); + + await session.actions.startOAuthLogin({ providerId: 'mock' }); + const result = await session.actions.finishOAuthLogin({ + providerId: 'mock', + code: 'c', + state: 's1', + }); + + expect(result.error).toBeNull(); + expect(client.finishOAuthLogin).toHaveBeenCalledWith({ + providerId: 'mock', + code: 'c', + state: 's1', + binding: 'b1', + }); + expect(sessionStorage.getItem(OAUTH_BINDING_STORAGE_KEY)).toBeNull(); + expect(session.getState().user).toEqual(user); + }); + + it('clears the binding when the callback fails', async () => { + const { client, session } = buildSession(); + client.startOAuthLogin.mockResolvedValue(started('s1', 'b1')); + client.finishOAuthLogin.mockResolvedValue({ + data: null, + error: new Error('Invalid OAuth state'), + }); + + await session.actions.startOAuthLogin({ providerId: 'mock' }); + await session.actions.finishOAuthLogin({ + providerId: 'mock', + code: 'c', + state: 's1', + }); + + expect(sessionStorage.getItem(OAUTH_BINDING_STORAGE_KEY)).toBeNull(); + expect(client.getCurrentUser).not.toHaveBeenCalled(); + }); + + it('does not send a binding stored for a different state', async () => { + const { client, session } = buildSession(); + client.startOAuthLogin.mockResolvedValue(started('s1', 'b1')); + + await session.actions.startOAuthLogin({ providerId: 'mock' }); + await session.actions.finishOAuthLogin({ + providerId: 'mock', + code: 'c', + state: 's2', + }); + + expect(client.finishOAuthLogin).toHaveBeenCalledWith({ + providerId: 'mock', + code: 'c', + state: 's2', + }); + }); + + it('prefers a binding the caller passes', async () => { + const { client, session } = buildSession(); + client.startOAuthLogin.mockResolvedValue(started('s1', 'b1')); + + await session.actions.startOAuthLogin({ providerId: 'mock' }); + await session.actions.finishOAuthLogin({ + providerId: 'mock', + code: 'c', + state: 's1', + binding: 'explicit', + }); + + expect(client.finishOAuthLogin).toHaveBeenCalledWith( + expect.objectContaining({ binding: 'explicit' }) + ); + }); + + it('drops a stale binding when a server without binding starts sign-in', async () => { + const { client, session } = buildSession(); + client.startOAuthLogin + .mockResolvedValueOnce(started('s1', 'b1')) + .mockResolvedValueOnce(started('s1')); + + await session.actions.startOAuthLogin({ providerId: 'mock' }); + await session.actions.startOAuthLogin({ providerId: 'mock' }); + await session.actions.finishOAuthLogin({ + providerId: 'mock', + code: 'c', + state: 's1', + }); + + expect(sessionStorage.getItem(OAUTH_BINDING_STORAGE_KEY)).toBeNull(); + expect(client.finishOAuthLogin).toHaveBeenCalledWith({ + providerId: 'mock', + code: 'c', + state: 's1', + }); + }); + + it('survives a redirect by reading the binding back from sessionStorage', async () => { + sessionStorage.setItem( + OAUTH_BINDING_STORAGE_KEY, + JSON.stringify({ state: 's1', binding: 'b1' }) + ); + const { client, session } = buildSession(); + + await session.actions.finishOAuthLogin({ + providerId: 'mock', + code: 'c', + state: 's1', + }); + + expect(client.finishOAuthLogin).toHaveBeenCalledWith( + expect.objectContaining({ binding: 'b1' }) + ); + }); + + it('ignores an unreadable stored value', async () => { + sessionStorage.setItem(OAUTH_BINDING_STORAGE_KEY, '{not json'); + const { client, session } = buildSession(); + + await session.actions.finishOAuthLogin({ + providerId: 'mock', + code: 'c', + state: 's1', + }); + + expect(client.finishOAuthLogin).toHaveBeenCalledWith({ + providerId: 'mock', + code: 'c', + state: 's1', + }); + }); + + it('keeps the binding in memory when sessionStorage refuses writes', async () => { + const setItem = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota'); + }); + const removeItem = jest + .spyOn(Storage.prototype, 'removeItem') + .mockImplementation(() => { + throw new Error('denied'); + }); + + try { + const { client, session } = buildSession(); + client.startOAuthLogin.mockResolvedValue(started('s1', 'b1')); + + await session.actions.startOAuthLogin({ providerId: 'mock' }); + await session.actions.finishOAuthLogin({ + providerId: 'mock', + code: 'c', + state: 's1', + }); + + expect(client.finishOAuthLogin).toHaveBeenCalledWith( + expect.objectContaining({ binding: 'b1' }) + ); + } finally { + setItem.mockRestore(); + removeItem.mockRestore(); + } + }); +}); diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 86b1c2a..1a93a63 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -153,6 +153,13 @@ it with `ports.oauthRedirect.open(url, redirectUri)`, which resolves with the callback's `code` and `state`, and finish with `finishOAuthLogin`. `redirectUri` must be registered with the provider and allowed by the auth API. +Newer auth servers also return a `binding` from `startOAuthLogin` and refuse a +callback without it. The session actions keep it in memory with its `state` and +send it from `finishOAuthLogin`, so this needs nothing from you. If you finish +with another client, pass `binding` yourself, and check that the callback's +`state` matches the one `startOAuthLogin` returned. Never put the binding in a +URL or send it to the provider. + ## License Apache-2.0. See [LICENSE](LICENSE). diff --git a/packages/react/README.md b/packages/react/README.md index b1e896c..ad772d6 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -574,6 +574,14 @@ function OAuthCallback() { } ``` +Auth servers that bind the OAuth state to the client that started sign-in (API 0.19.x with +fells-code/seamless-auth-api#463) return a `binding` from `startOAuthLogin` and refuse a callback +without it (`400`, `Invalid OAuth state`). The `useAuth()` actions handle this for you: +`startOAuthLogin` keeps the binding in `sessionStorage` under `seamless:oauth:binding`, keyed to its +`state`, and `finishOAuthLogin` sends it with the matching callback and then clears it. Only pass +`binding` yourself if you start and finish on different `useAuth()` instances or with the headless +client, and keep it out of URLs: it goes to your auth server, never to the provider. + Some callback failures are the user's to fix, so the API returns a stable `code` alongside the error message. `getOAuthErrorCode()` narrows it to the codes this SDK knows about and returns `undefined` for everything else, so unexpected failures keep your generic message: diff --git a/packages/react/tests/OAuthProviderButtons.test.tsx b/packages/react/tests/OAuthProviderButtons.test.tsx index 41548d0..dceb9eb 100644 --- a/packages/react/tests/OAuthProviderButtons.test.tsx +++ b/packages/react/tests/OAuthProviderButtons.test.tsx @@ -123,7 +123,11 @@ describe('OAuthProviderButtons', () => { error: null, }); startOAuthLogin.mockResolvedValue({ - data: { authorizationUrl: 'http://idp.test/authorize' }, + data: { + authorizationUrl: 'http://idp.test/authorize', + state: 's-1', + binding: 'b-1', + }, error: null, }); open.mockResolvedValue({ type: 'callback', code: 'c-1', state: 's-1' }); @@ -139,6 +143,7 @@ describe('OAuthProviderButtons', () => { providerId: 'mock', code: 'c-1', state: 's-1', + binding: 'b-1', }) ); }); From f03c2c5eacb3fdab21fa6d83ef2413bf05faa7d8 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 20 Oct 2026 21:45:23 -0400 Subject: [PATCH 2/3] fix(deps): require @seamless-auth/types 0.29.0 for the OAuth state binding --- AGENTS.md | 4 +- package-lock.json | 42 +++++++++---------- packages/client/package.json | 2 +- .../src/client/createSeamlessAuthClient.ts | 7 +--- packages/react-native/package.json | 2 +- packages/react/package.json | 2 +- 6 files changed, 26 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 390c01c..a903cf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -418,9 +418,7 @@ union exists to prevent. back (fells-code/seamless-auth-api#463). The session actions store it with its state (`packages/client/src/session/oauthBinding.ts`) and attach it in `finishOAuthLogin`, so callers of those actions get it without code changes. It -never goes to the provider or into a URL. The optional `binding` field is declared -locally on `StartOAuthLoginResult` and `FinishOAuthLoginInput` until -`@seamless-auth/types` publishes it. +never goes to the provider or into a URL. Before documenting new flow behavior, verify the route contract in `seamless-auth-server` or `seamless-auth-api`. diff --git a/package-lock.json b/package-lock.json index 79fe5f8..d684d0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4642,12 +4642,12 @@ "link": true }, "node_modules/@seamless-auth/types": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.28.0.tgz", - "integrity": "sha512-VKb83pSd0KyL608I4pqKxs0KYZNTHlc2CaPula9rtURcgj3eZDky39idSTvsRbHQTlxblvPeNLsgOBnyr+P7FQ==", + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.29.0.tgz", + "integrity": "sha512-Moiuw17RNZaORrrb5Wypjvl1iBj5K1PqDQkkK8ACYh9NSHD7khMyHaTsx9AzK6zOqROXgRRrIAurk9S1w2CZhA==", "license": "Apache-2.0", "dependencies": { - "zod": "^4.3.6" + "zod": "^4.5.0" }, "engines": { "node": ">=22" @@ -19001,9 +19001,9 @@ "license": "MIT" }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -19011,10 +19011,10 @@ }, "packages/angular": { "name": "@seamless-auth/angular", - "version": "0.2.0", + "version": "0.3.1", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/client": "^0.5.0", + "@seamless-auth/client": "^0.7.0", "tslib": "^2.7.0" }, "engines": { @@ -19030,10 +19030,10 @@ }, "packages/client": { "name": "@seamless-auth/client", - "version": "0.5.0", + "version": "0.7.0", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/types": "^0.28.0", + "@seamless-auth/types": "^0.29.0", "@simplewebauthn/browser": "^13.1.0", "libphonenumber-js": "^1.12.7" }, @@ -19044,11 +19044,11 @@ }, "packages/react": { "name": "@seamless-auth/react", - "version": "0.16.0", + "version": "0.18.0", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/client": "^0.5.0", - "@seamless-auth/types": "^0.28.0", + "@seamless-auth/client": "^0.7.0", + "@seamless-auth/types": "^0.29.0", "@simplewebauthn/browser": "^13.1.0", "libphonenumber-js": "^1.12.7" }, @@ -19069,11 +19069,11 @@ }, "packages/react-native": { "name": "@seamless-auth/react-native", - "version": "0.3.0", + "version": "0.3.2", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/client": "^0.5.0", - "@seamless-auth/types": "^0.28.0" + "@seamless-auth/client": "^0.7.0", + "@seamless-auth/types": "^0.29.0" }, "engines": { "node": ">=22", @@ -19085,10 +19085,10 @@ }, "packages/svelte": { "name": "@seamless-auth/svelte", - "version": "0.0.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/client": "^0.5.0" + "@seamless-auth/client": "^0.7.0" }, "engines": { "node": ">=22", @@ -19106,10 +19106,10 @@ }, "packages/vue": { "name": "@seamless-auth/vue", - "version": "0.1.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@seamless-auth/client": "^0.5.0" + "@seamless-auth/client": "^0.7.0" }, "engines": { "node": ">=22", diff --git a/packages/client/package.json b/packages/client/package.json index 8b7ff18..1c7c511 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -42,7 +42,7 @@ "provenance": true }, "dependencies": { - "@seamless-auth/types": "^0.28.0", + "@seamless-auth/types": "^0.29.0", "@simplewebauthn/browser": "^13.1.0", "libphonenumber-js": "^1.12.7" }, diff --git a/packages/client/src/client/createSeamlessAuthClient.ts b/packages/client/src/client/createSeamlessAuthClient.ts index 5701751..baf9fcd 100644 --- a/packages/client/src/client/createSeamlessAuthClient.ts +++ b/packages/client/src/client/createSeamlessAuthClient.ts @@ -150,12 +150,7 @@ export interface StartOAuthLoginInput { returnTo?: string; } -/** - * `binding` is returned by auth servers that tie the OAuth state to the client - * that started sign-in, and must come back with the callback. Older servers omit - * it. Declared here until `@seamless-auth/types` publishes it. - */ -export type StartOAuthLoginResult = StartOAuthLoginResponse & { binding?: string }; +export type StartOAuthLoginResult = StartOAuthLoginResponse; export interface FinishOAuthLoginInput { providerId: string; diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 1d051a2..89cc6b7 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -46,7 +46,7 @@ }, "dependencies": { "@seamless-auth/client": "^0.7.0", - "@seamless-auth/types": "^0.28.0" + "@seamless-auth/types": "^0.29.0" }, "sideEffects": false } diff --git a/packages/react/package.json b/packages/react/package.json index 1ff867c..13bfc2d 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -57,7 +57,7 @@ }, "dependencies": { "@seamless-auth/client": "^0.7.0", - "@seamless-auth/types": "^0.28.0", + "@seamless-auth/types": "^0.29.0", "@simplewebauthn/browser": "^13.1.0", "libphonenumber-js": "^1.12.7" }, From 0f43efae24923ff4bc1bd18c9fe4f44d4d1efb41 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 20 Oct 2026 21:56:21 -0400 Subject: [PATCH 3/3] test(oauth): hide the global sessionStorage newer Node versions define --- .../client/tests/oauthBinding.node.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/client/tests/oauthBinding.node.test.ts b/packages/client/tests/oauthBinding.node.test.ts index 92ef573..a1f9c73 100644 --- a/packages/client/tests/oauthBinding.node.test.ts +++ b/packages/client/tests/oauthBinding.node.test.ts @@ -20,8 +20,24 @@ jest.mock('@/fetchWithAuth', () => ({ })); // React Native has no sessionStorage. Its callback returns to the same process, -// so the in-memory copy is enough. +// so the in-memory copy is enough. Newer Node versions define a global +// sessionStorage, so it is hidden here to stand in for React Native. describe('OAuth state binding without sessionStorage', () => { + const original = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage'); + + beforeAll(() => { + Object.defineProperty(globalThis, 'sessionStorage', { + value: undefined, + configurable: true, + writable: true, + }); + }); + + afterAll(() => { + if (original) Object.defineProperty(globalThis, 'sessionStorage', original); + else delete (globalThis as { sessionStorage?: unknown }).sessionStorage; + }); + it('keeps the binding in memory for the callback, then clears it', async () => { expect(typeof sessionStorage).toBe('undefined');