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..a903cf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -414,6 +414,12 @@ 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. + Before documenting new flow behavior, verify the route contract in `seamless-auth-server` or `seamless-auth-api`. ## Migration Status 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/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/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 7be2f4d..baf9fcd 100644 --- a/packages/client/src/client/createSeamlessAuthClient.ts +++ b/packages/client/src/client/createSeamlessAuthClient.ts @@ -156,6 +156,11 @@ 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 +717,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..a1f9c73 --- /dev/null +++ b/packages/client/tests/oauthBinding.node.test.ts @@ -0,0 +1,88 @@ +/* + * 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. 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'); + + 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-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/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/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" }, 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', }) ); });