Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/oauth-state-binding.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 21 additions & 21 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 17 additions & 3 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
6 changes: 6 additions & 0 deletions packages/client/src/client/createSeamlessAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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.'
Expand Down
10 changes: 9 additions & 1 deletion packages/client/src/flows/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand Down
29 changes: 27 additions & 2 deletions packages/client/src/session/createAuthSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
98 changes: 98 additions & 0 deletions packages/client/src/session/oauthBinding.ts
Original file line number Diff line number Diff line change
@@ -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.
}
},
};
}
18 changes: 18 additions & 0 deletions packages/client/tests/createSeamlessAuthClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading