From 866a540cfef1dd794dc6c5d5e3de4d8a46089dbc Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 5 Oct 2026 20:41:20 -0400 Subject: [PATCH 1/2] feat(oauth): passkey enrollment after a legacy provider sign-in Route nextStep: 'enroll_passkey' from the OAuth callback into passkey enrollment, carrying returnTo through it, and recognise the oauth_provider_retired and oauth_invalid_id_token codes. Part of fells-code/seamless-auth-api#337. --- .changeset/oauth-cutover.md | 12 ++++++ packages/client/src/client/errors.ts | 2 + packages/react/src/views/OAuthCallback.tsx | 14 +++++- .../react/src/views/PassKeyRegistration.tsx | 24 +++++++++-- packages/react/tests/OAuthCallback.test.tsx | 26 +++++++++++ packages/react/tests/RegisterPassKey.test.tsx | 43 +++++++++++++++++++ 6 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 .changeset/oauth-cutover.md diff --git a/.changeset/oauth-cutover.md b/.changeset/oauth-cutover.md new file mode 100644 index 0000000..bcd9541 --- /dev/null +++ b/.changeset/oauth-cutover.md @@ -0,0 +1,12 @@ +--- +'@seamless-auth/client': minor +'@seamless-auth/react': minor +--- + +Support signing in through a legacy identity provider during a migration cutover (fells-code/seamless-auth-api#337). + +- `getOAuthErrorCode` recognises `oauth_provider_retired` (the user's organization no longer signs in with that provider) and `oauth_invalid_id_token`. +- The bundled OAuth callback sends the user to passkey enrollment when the sign-in response carries `nextStep: 'enroll_passkey'`, and the passkey screen then continues to the `returnTo` the flow asked for instead of always going home. +- The callback shows a specific message for both new codes. + +Requires `@seamless-auth/types` 0.25.0. diff --git a/packages/client/src/client/errors.ts b/packages/client/src/client/errors.ts index 1a48058..2bb5567 100644 --- a/packages/client/src/client/errors.ts +++ b/packages/client/src/client/errors.ts @@ -47,6 +47,8 @@ const OAUTH_ERROR_CODES: Record = { oauth_missing_email: true, oauth_email_not_verified: true, oauth_missing_subject: true, + oauth_invalid_id_token: true, + oauth_provider_retired: true, }; function readCode(body: unknown): unknown { diff --git a/packages/react/src/views/OAuthCallback.tsx b/packages/react/src/views/OAuthCallback.tsx index 25d81f1..964def7 100644 --- a/packages/react/src/views/OAuthCallback.tsx +++ b/packages/react/src/views/OAuthCallback.tsx @@ -7,6 +7,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '@/AuthProvider'; +import { authRoutePaths } from '@/authRoutePaths'; import { getOAuthErrorCode, OAuthErrorCode } from '@seamless-auth/client'; import { OAUTH_PROVIDER_STORAGE_KEY } from '@/components/OAuthProviderButtons'; @@ -45,6 +46,10 @@ const CODE_ERRORS: Record = { 'The email address on your provider account is not verified. Verify it with your provider, then try again.', oauth_missing_subject: 'Your provider did not return a usable account identifier. Try again, or sign in with a different method.', + oauth_invalid_id_token: + 'Your provider sent a sign-in response that could not be verified. Try again, or sign in with a different method.', + oauth_provider_retired: + 'Your organization no longer signs in with this provider. Sign in with your passkey or another method instead.', }; const OAuthCallback: React.FC = () => { @@ -76,7 +81,14 @@ const OAuthCallback: React.FC = () => { } sessionStorage.removeItem(OAUTH_PROVIDER_STORAGE_KEY); - navigate(inAppPath(data?.returnTo) ?? '/'); + const destination = inAppPath(data?.returnTo) ?? '/'; + + if (data?.nextStep === 'enroll_passkey') { + navigate(authRoutePaths.registerPasskey, { state: { returnTo: destination } }); + return; + } + + navigate(destination); } ); }, [finishOAuthLogin, navigate, searchParams]); diff --git a/packages/react/src/views/PassKeyRegistration.tsx b/packages/react/src/views/PassKeyRegistration.tsx index 66d0c0e..1a09fe4 100644 --- a/packages/react/src/views/PassKeyRegistration.tsx +++ b/packages/react/src/views/PassKeyRegistration.tsx @@ -15,7 +15,7 @@ import React, { useState } from 'react'; import { useAuthClient } from '@/hooks/useAuthClient'; import { hasNonPasskeyLoginMethod, useLoginMethods } from '@/hooks/useLoginMethods'; import { usePasskeySupport } from '@/hooks/usePasskeySupport'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import styles from '@/styles/registerPasskey.module.css'; import { parseUserAgent } from '@/utils'; @@ -36,12 +36,30 @@ function policyRefusalMessage(error: unknown): string | undefined { return code ? POLICY_REFUSAL_MESSAGES[code] : undefined; } +/** + * Where to go once the passkey step is done. The OAuth callback passes the + * caller's destination through router state when the API asks for enrollment + * first. Only an in-app path is honoured, so state cannot become an off-site + * redirect. + */ +function destinationFrom(state: unknown): string { + const returnTo = (state as { returnTo?: unknown } | null)?.returnTo; + + return typeof returnTo === 'string' && + returnTo.startsWith('/') && + !returnTo.startsWith('//') && + !returnTo.startsWith('/\\') + ? returnTo + : '/'; +} + const PasskeyRegistration: React.FC = () => { const { refreshSession } = useAuth(); const authClient = useAuthClient(); const { passkeySupported, loading: passkeySupportLoading } = usePasskeySupport(); const { loginMethods, loading: loginMethodsLoading } = useLoginMethods(); const navigate = useNavigate(); + const destination = destinationFrom(useLocation().state); const [status, setStatus] = useState<'idle' | 'success' | 'error' | 'loading'>('idle'); const [message, setMessage] = useState(''); @@ -57,7 +75,7 @@ const PasskeyRegistration: React.FC = () => { const finishWithoutPasskey = async () => { await refreshSession(); - navigate('/'); + navigate(destination); }; const registerPasskey = async (attachment?: PasskeyAttachment) => { @@ -85,7 +103,7 @@ const PasskeyRegistration: React.FC = () => { await refreshSession(); setStatus('success'); setMessage('Passkey registered successfully.'); - navigate('/'); + navigate(destination); } catch (error) { console.error('Passkey registration failed.'); setStatus('error'); diff --git a/packages/react/tests/OAuthCallback.test.tsx b/packages/react/tests/OAuthCallback.test.tsx index 5e894a2..3fc1bed 100644 --- a/packages/react/tests/OAuthCallback.test.tsx +++ b/packages/react/tests/OAuthCallback.test.tsx @@ -71,6 +71,30 @@ describe('OAuthCallback', () => { ); }); + test('sends the user into passkey enrollment when the API asks, keeping the destination', async () => { + finishOAuthLogin.mockResolvedValue({ + data: { + message: 'Success', + nextStep: 'enroll_passkey', + returnTo: `${window.location.origin}/dashboard`, + }, + error: null, + }); + window.sessionStorage.setItem('seamless:oauth:provider', 'mock'); + (useSearchParams as jest.Mock).mockReturnValue([ + new URLSearchParams('code=abc&state=xyz'), + ]); + + render(); + + await waitFor(() => + expect(navigate).toHaveBeenCalledWith('/register-passkey', { + state: { returnTo: '/dashboard' }, + }) + ); + expect(navigate).toHaveBeenCalledTimes(1); + }); + // These views route with react-router, which cannot leave the application. An adopter // that wants to is expected to read returnTo off the client result and navigate itself. test('falls back home when the returnTo is on another origin', async () => { @@ -133,6 +157,8 @@ describe('OAuthCallback', () => { ['oauth_missing_email', /did not share an email address/], ['oauth_email_not_verified', /is not verified/], ['oauth_missing_subject', /usable account identifier/], + ['oauth_invalid_id_token', /could not be verified/], + ['oauth_provider_retired', /no longer signs in with this provider/], ])('maps %s to curated messaging', async (code, expected) => { renderWithError(new SeamlessAuthError('Sign-in failed', 400, { code })); diff --git a/packages/react/tests/RegisterPassKey.test.tsx b/packages/react/tests/RegisterPassKey.test.tsx index f4f04a0..b92e429 100644 --- a/packages/react/tests/RegisterPassKey.test.tsx +++ b/packages/react/tests/RegisterPassKey.test.tsx @@ -12,12 +12,14 @@ import { useLoginMethods } from '@/hooks/useLoginMethods'; import { usePasskeySupport } from '@/hooks/usePasskeySupport'; const mockNavigate = jest.fn(); +let mockLocationState: unknown = null; const mockRefreshSession = jest.fn(); const mockRegisterPasskey = jest.fn(); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useNavigate: () => mockNavigate, + useLocation: () => ({ state: mockLocationState }), })); jest.mock('@/AuthProvider', () => ({ @@ -46,6 +48,7 @@ jest.mock('@/utils', () => ({ beforeEach(() => { jest.clearAllMocks(); + mockLocationState = null; (useAuthClient as jest.Mock).mockReturnValue({ registerPasskey: mockRegisterPasskey, }); @@ -329,3 +332,43 @@ describe('RegisterPasskey skip control', () => { ).toBeInTheDocument(); }); }); + +// The OAuth callback sends a user here first when the API asks for enrollment, +// and hands over where they were going. +describe('RegisterPasskey destination', () => { + it('continues to the destination it was given after registering', async () => { + mockLocationState = { returnTo: '/dashboard?tab=billing' }; + mockRegisterPasskey.mockResolvedValueOnce({ + data: { credentialId: 'cred', prfCapable: false }, + error: null, + }); + + render(); + fireEvent.click(await screen.findByText(/Register Passkey/i)); + + await waitFor(() => + expect(mockNavigate).toHaveBeenCalledWith('/dashboard?tab=billing') + ); + }); + + it('continues to the destination it was given after a skip', async () => { + mockLocationState = { returnTo: '/dashboard' }; + + render(); + fireEvent.click(await screen.findByText(/Skip for now/i)); + + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard')); + }); + + it.each(['//evil.example/path', '/\\evil.example', 'https://evil.example/', 42])( + 'goes home instead of following %p', + async returnTo => { + mockLocationState = { returnTo }; + + render(); + fireEvent.click(await screen.findByText(/Skip for now/i)); + + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/')); + } + ); +}); From 8bb6371ac169efaccef341e590ff9e3432e9558d Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 5 Oct 2026 21:11:20 -0400 Subject: [PATCH 2/2] chore(deps): require @seamless-auth/types 0.25.0 --- package-lock.json | 12 ++++++------ packages/client/package.json | 2 +- packages/react-native/package.json | 2 +- packages/react/package.json | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index bd5810e..7a9c5ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3069,9 +3069,9 @@ "link": true }, "node_modules/@seamless-auth/types": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.20.0.tgz", - "integrity": "sha512-e21oZDg1Ssf6zbAt1qswOSEKFC4kDX4JTLOKJKdkBgsy+f64Ii4xdKJcdLmvz1fErcN5I3Lzz6g0d8HnMmfoyg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.25.0.tgz", + "integrity": "sha512-/E1AtmTRiEKv1qN/XGxwu6PpDTI4vOv/uTKkkFc4tmsxOVTAYJLJhd23GbofunZmTth8pSfhrW05aUSAXwUMmA==", "license": "AGPL-3.0-only", "dependencies": { "zod": "^4.3.6" @@ -13869,7 +13869,7 @@ "version": "0.1.0", "license": "AGPL-3.0-only", "dependencies": { - "@seamless-auth/types": "^0.20.0", + "@seamless-auth/types": "^0.25.0", "@simplewebauthn/browser": "^13.1.0" }, "engines": { @@ -13883,7 +13883,7 @@ "license": "AGPL-3.0-only", "dependencies": { "@seamless-auth/client": "^0.1.0", - "@seamless-auth/types": "^0.20.0", + "@seamless-auth/types": "^0.25.0", "@simplewebauthn/browser": "^13.1.0", "libphonenumber-js": "^1.12.7" }, @@ -13908,7 +13908,7 @@ "license": "AGPL-3.0-only", "dependencies": { "@seamless-auth/client": "^0.1.0", - "@seamless-auth/types": "^0.20.0" + "@seamless-auth/types": "^0.25.0" }, "engines": { "node": ">=24.0.0 <25.0.0", diff --git a/packages/client/package.json b/packages/client/package.json index 0f8861f..bf18c56 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -42,7 +42,7 @@ "provenance": true }, "dependencies": { - "@seamless-auth/types": "^0.20.0", + "@seamless-auth/types": "^0.25.0", "@simplewebauthn/browser": "^13.1.0" }, "sideEffects": false diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 4a04219..7e85a5c 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -46,7 +46,7 @@ }, "dependencies": { "@seamless-auth/client": "^0.1.0", - "@seamless-auth/types": "^0.20.0" + "@seamless-auth/types": "^0.25.0" }, "sideEffects": false } diff --git a/packages/react/package.json b/packages/react/package.json index 68f1563..867ee02 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -57,7 +57,7 @@ }, "dependencies": { "@seamless-auth/client": "^0.1.0", - "@seamless-auth/types": "^0.20.0", + "@seamless-auth/types": "^0.25.0", "@simplewebauthn/browser": "^13.1.0", "libphonenumber-js": "^1.12.7" },