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
12 changes: 12 additions & 0 deletions .changeset/oauth-cutover.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 6 additions & 6 deletions package-lock.json

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

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.20.0",
"@seamless-auth/types": "^0.25.0",
"@simplewebauthn/browser": "^13.1.0"
},
"sideEffects": false
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/client/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ const OAUTH_ERROR_CODES: Record<OAuthErrorCode, true> = {
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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/react-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
14 changes: 13 additions & 1 deletion packages/react/src/views/OAuthCallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -45,6 +46,10 @@ const CODE_ERRORS: Record<OAuthErrorCode, string> = {
'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 = () => {
Expand Down Expand Up @@ -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]);
Expand Down
24 changes: 21 additions & 3 deletions packages/react/src/views/PassKeyRegistration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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('');
Expand All @@ -57,7 +75,7 @@ const PasskeyRegistration: React.FC = () => {

const finishWithoutPasskey = async () => {
await refreshSession();
navigate('/');
navigate(destination);
};

const registerPasskey = async (attachment?: PasskeyAttachment) => {
Expand Down Expand Up @@ -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');
Expand Down
26 changes: 26 additions & 0 deletions packages/react/tests/OAuthCallback.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<OAuthCallback />);

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 () => {
Expand Down Expand Up @@ -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 }));

Expand Down
43 changes: 43 additions & 0 deletions packages/react/tests/RegisterPassKey.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -46,6 +48,7 @@ jest.mock('@/utils', () => ({

beforeEach(() => {
jest.clearAllMocks();
mockLocationState = null;
(useAuthClient as jest.Mock).mockReturnValue({
registerPasskey: mockRegisterPasskey,
});
Expand Down Expand Up @@ -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(<RegisterPasskey />);
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(<RegisterPasskey />);
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(<RegisterPasskey />);
fireEvent.click(await screen.findByText(/Skip for now/i));

await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/'));
}
);
});
Loading