From b2c7f7472cc62055950b1e27ba360d3abec6c2c1 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 20 Jul 2026 10:58:43 -0400 Subject: [PATCH] refactor(provider): return results from every useAuth helper --- .changeset/provider-result-convention.md | 24 ++++ README.md | 63 ++++++----- src/AuthProvider.tsx | 137 +++++++++++------------ src/components/OAuthProviderButtons.tsx | 30 +++-- src/views/Login.tsx | 5 +- src/views/OAuthCallback.tsx | 15 +-- src/views/PassKeyLogin.tsx | 4 +- tests/OAuthCallback.test.tsx | 2 +- tests/OAuthProviderButtons.test.tsx | 18 ++- tests/PassKeyLogin.test.tsx | 7 +- tests/authProvider.test.tsx | 58 ++++++---- 11 files changed, 202 insertions(+), 161 deletions(-) create mode 100644 .changeset/provider-result-convention.md diff --git a/.changeset/provider-result-convention.md b/.changeset/provider-result-convention.md new file mode 100644 index 0000000..706b488 --- /dev/null +++ b/.changeset/provider-result-convention.md @@ -0,0 +1,24 @@ +--- +'@seamless-auth/react': minor +--- + +Unify `useAuth()` on the same result convention as the headless client. Every provider helper now returns `SeamlessAuthResult` and none of them throw, so the whole SDK reports failure one way. + +```tsx +const { error } = await updateCredential({ ...credential, friendlyName: 'Work laptop' }); +if (error) { + setMessage(error.message); +} +``` + +BREAKING: `useAuth()` previously mixed four styles. Seven helpers threw, four returned a result, `handlePasskeyLogin` returned a boolean, and `refreshStepUpStatus` returned `StepUpStatus | null`. + +Migration: + +- `deleteUser`, `updateCredential`, `deleteCredential`, `switchOrganization`, `listOAuthProviders`, `startOAuthLogin`, and `finishOAuthLogin` no longer throw. Replace `try`/`catch` with an `error` check. +- `handlePasskeyLogin` returns a result instead of a boolean. Replace `if (await handlePasskeyLogin())` with `if (!(await handlePasskeyLogin()).error)`. +- `refreshStepUpStatus` returns a result instead of `StepUpStatus | null`. Read `data` instead of the return value directly. +- `updateCredential` returns the credential under `data`. +- `logout`, `logoutAllSessions`, and `refreshSession` now return a result. Existing callers that ignore the return value keep working. + +Helpers that mutate provider state still do so only when the call succeeds. diff --git a/README.md b/README.md index fc887f1..2ec6295 100644 --- a/README.md +++ b/README.md @@ -114,21 +114,21 @@ You are still responsible for your app’s route protection and redirects. markSignedIn(): void; hasRole(role: string): boolean | undefined; hasScopedRole(role: string | string[]): boolean | undefined; - listOAuthProviders(): Promise; - startOAuthLogin(input: StartOAuthLoginInput): Promise; - finishOAuthLogin(input: FinishOAuthLoginInput): Promise; - refreshSession(): Promise; - refreshStepUpStatus(): Promise; + listOAuthProviders(): Promise>; + startOAuthLogin(input: StartOAuthLoginInput): Promise>; + finishOAuthLogin(input: FinishOAuthLoginInput): Promise>; + refreshSession(): Promise>; + refreshStepUpStatus(): Promise>; verifyStepUpWithPasskey(): Promise>; verifyStepUpWithPasskeyPrf(input: PasskeyPrfInput): Promise>; verifyStepUpWithTotp(code: string): Promise>; - logout(): Promise; - logoutAllSessions(): Promise; - deleteUser(): Promise; + logout(): Promise>; + logoutAllSessions(): Promise>; + deleteUser(): Promise>; login(identifier: string, passkeyAvailable: boolean): Promise>; - handlePasskeyLogin(): Promise; - updateCredential(credential: Credential): Promise; - deleteCredential(credentialId: string): Promise; + handlePasskeyLogin(): Promise>; + updateCredential(credential: Credential): Promise>; + deleteCredential(credentialId: string): Promise>; } ``` @@ -201,7 +201,7 @@ function DeleteAccountButton() { const { refreshStepUpStatus, verifyStepUpWithPasskey } = useAuth(); async function handleDeleteAccount() { - const status = await refreshStepUpStatus(); + const { data: status } = await refreshStepUpStatus(); const fresh = status?.fresh ? true : !(await verifyStepUpWithPasskey()).error; if (!fresh) { @@ -511,16 +511,21 @@ function CustomLogin() { } ``` -### Two error styles, on purpose +### One error style everywhere -The headless client and the provider helpers report failure differently: +`useAuth()` helpers and the headless client report failure the same way: both return +`{ data, error }` and neither throws. Whatever surface you reach for, the handling is identical. -- `useAuthClient()` and `createSeamlessAuthClient()` return `{ data, error }` and never throw. -- `useAuth()` helpers such as `updateCredential`, `deleteCredential`, `switchOrganization`, - `finishOAuthLogin`, `listOAuthProviders`, and `startOAuthLogin` **throw** a `SeamlessAuthError`. +```tsx +const { error } = await updateCredential({ ...credential, friendlyName: 'Work laptop' }); + +if (error) { + setMessage(error.message); +} +``` -The provider helpers throw because they also mutate provider state, so there is no partial success to -hand back. Wrap those in `try`/`catch`, and check `error` on client calls. +Helpers that also mutate provider state, such as `switchOrganization` and `deleteCredential`, apply +that state change only when the call succeeds, then hand the result back for you to inspect. ## Custom UI Recipes @@ -679,10 +684,10 @@ path. ### Credential management `useAuth()` exposes the signed-in user's passkeys plus helpers to rename and remove them. These -helpers update provider state and throw on failure. +helpers update provider state on success and report failure through `error`. ```tsx -import { SeamlessAuthError, useAuth } from '@seamless-auth/react'; +import { useAuth } from '@seamless-auth/react'; import type { Credential } from '@seamless-auth/react'; import { useState } from 'react'; @@ -691,18 +696,18 @@ function PasskeyList() { const [message, setMessage] = useState(''); async function rename(credential: Credential, friendlyName: string) { - try { - await updateCredential({ ...credential, friendlyName }); - } catch (error) { - setMessage(error instanceof SeamlessAuthError ? error.message : 'Rename failed.'); + const { error } = await updateCredential({ ...credential, friendlyName }); + + if (error) { + setMessage(error.message); } } async function remove(credentialId: string) { - try { - await deleteCredential(credentialId); - } catch (error) { - setMessage(error instanceof SeamlessAuthError ? error.message : 'Removal failed.'); + const { error } = await deleteCredential(credentialId); + + if (error) { + setMessage(error.message); } } diff --git a/src/AuthProvider.tsx b/src/AuthProvider.tsx index 58a0978..9c3dd00 100644 --- a/src/AuthProvider.tsx +++ b/src/AuthProvider.tsx @@ -6,9 +6,13 @@ import { createSeamlessAuthClient, + CurrentUserResult, FinishOAuthLoginInput, LoginStartResult, + MessageResult, OAuthProvidersResult, + OrganizationSwitchResult, + PasskeyLoginData, StartOAuthLoginInput, StartOAuthLoginResult, StepUpPrfData, @@ -32,10 +36,10 @@ import { hasScopedRole as rolesGrantScopedAccess } from './scopedRoles'; export interface AuthContextType { user: User | null; - logout: () => Promise; - logoutAllSessions: () => Promise; - deleteUser: () => Promise; - refreshSession: () => Promise; + logout: () => Promise>; + logoutAllSessions: () => Promise>; + deleteUser: () => Promise>; + refreshSession: () => Promise>; isAuthenticated: boolean; hasRole: (role: string) => boolean | undefined; hasScopedRole: (role: string | string[]) => boolean | undefined; @@ -45,19 +49,25 @@ export interface AuthContextType { credentials: Credential[]; organizations: Organization[]; activeOrganization: Organization | null; - switchOrganization: (organizationId: string) => Promise; - listOAuthProviders: () => Promise; - startOAuthLogin: (input: StartOAuthLoginInput) => Promise; - finishOAuthLogin: (input: FinishOAuthLoginInput) => Promise; + switchOrganization: ( + organizationId: string + ) => Promise>; + listOAuthProviders: () => Promise>; + startOAuthLogin: ( + input: StartOAuthLoginInput + ) => Promise>; + finishOAuthLogin: ( + input: FinishOAuthLoginInput + ) => Promise>; stepUpStatus: StepUpStatus | null; - updateCredential: (credential: Credential) => Promise; - deleteCredential: (credentialId: string) => Promise; + updateCredential: (credential: Credential) => Promise>; + deleteCredential: (credentialId: string) => Promise>; login: ( identifier: string, passkeyAvailable: boolean ) => Promise>; - handlePasskeyLogin: () => Promise; - refreshStepUpStatus: () => Promise; + handlePasskeyLogin: () => Promise>; + refreshStepUpStatus: () => Promise>; verifyStepUpWithPasskey: () => Promise>; verifyStepUpWithPasskeyPrf: ( input: PasskeyPrfInput @@ -112,15 +122,13 @@ export const AuthProvider: React.FC = ({ authClient.login({ identifier, passkeyAvailable }); const handlePasskeyLogin = async () => { - const { error } = await authClient.loginWithPasskey(); + const result = await authClient.loginWithPasskey(); - if (error) { - console.error('Passkey login failed.'); - return false; + if (!result.error) { + await validateToken(); } - await validateToken(); - return true; + return result; }; const resetAuthState = useCallback(() => { @@ -138,7 +146,7 @@ export const AuthProvider: React.FC = ({ // when the server call fails, otherwise the UI keeps presenting a signed-in // user whose session is already gone. try { - await authClient.logout(); + return await authClient.logout(); } finally { resetAuthState(); } @@ -150,21 +158,20 @@ export const AuthProvider: React.FC = ({ // when the server call fails, otherwise the UI keeps presenting a signed-in // user whose session is already gone. try { - await authClient.logoutAllSessions(); + return await authClient.logoutAllSessions(); } finally { resetAuthState(); } }, [authClient, resetAuthState]); const deleteUser = async () => { - const { error } = await authClient.deleteUser(); + const result = await authClient.deleteUser(); - if (error) { - console.error('Something went wrong deleting user.'); - throw error; + if (!result.error) { + resetAuthState(); } - resetAuthState(); + return result; }; const hasRole = (role: string) => user?.roles?.includes(role); @@ -174,20 +181,23 @@ export const AuthProvider: React.FC = ({ const validateToken = useCallback(async () => { setLoading(true); - const { data, error } = await authClient.getCurrentUser(); + const result = await authClient.getCurrentUser(); - if (error) { + if (result.error) { + // An unusable session is cleared rather than left half-applied. await logout(); setLoading(false); - return; + return result; } - setUser(data.user); - setCredentials(data.credentials ?? []); - setOrganizations(data.organizations ?? []); - setActiveOrganization(data.activeOrganization ?? null); + setUser(result.data.user); + setCredentials(result.data.credentials ?? []); + setOrganizations(result.data.organizations ?? []); + setActiveOrganization(result.data.activeOrganization ?? null); setIsAuthenticated(true); setLoading(false); + + return result; }, [authClient, logout]); const updateCredential = async (credential: Credential) => { @@ -197,7 +207,7 @@ export const AuthProvider: React.FC = ({ }); if (error) { - throw error; + return { data: null, error }; } const updatedCredential = data.credential; @@ -210,71 +220,52 @@ export const AuthProvider: React.FC = ({ ) ); - return updatedCredential; + return { data: updatedCredential, error: null }; }; const deleteCredential = async (credentialId: string) => { - const { error } = await authClient.deleteCredential(credentialId); + const result = await authClient.deleteCredential(credentialId); - if (error) { - throw error; + if (!result.error) { + setCredentials(currentCredentials => + currentCredentials.filter(credential => credential.id !== credentialId) + ); } - setCredentials(currentCredentials => - currentCredentials.filter(credential => credential.id !== credentialId) - ); + return result; }; const switchOrganization = async (organizationId: string) => { - const { error } = await authClient.switchOrganization(organizationId); + const result = await authClient.switchOrganization(organizationId); - if (error) { - throw error; - } - - await validateToken(); - }; - - const listOAuthProviders = async () => { - const { data, error } = await authClient.listOAuthProviders(); - - if (error) { - throw error; + if (!result.error) { + await validateToken(); } - return data; + return result; }; - const startOAuthLogin = async (input: StartOAuthLoginInput) => { - const { data, error } = await authClient.startOAuthLogin(input); + const listOAuthProviders = () => authClient.listOAuthProviders(); - if (error) { - throw error; - } - - return data; - }; + const startOAuthLogin = (input: StartOAuthLoginInput) => + authClient.startOAuthLogin(input); const finishOAuthLogin = async (input: FinishOAuthLoginInput) => { - const { error } = await authClient.finishOAuthLogin(input); + const result = await authClient.finishOAuthLogin(input); - if (error) { - throw error; + if (!result.error) { + await validateToken(); } - await validateToken(); + return result; }; const refreshStepUpStatus = useCallback(async () => { - const { data, error } = await authClient.getStepUpStatus(); + const result = await authClient.getStepUpStatus(); - if (error) { - setStepUpStatus(null); - return null; - } + setStepUpStatus(result.error ? null : result.data); - setStepUpStatus(data); - return data; + return result; }, [authClient]); const verifyStepUpWithPasskey = useCallback(async () => { diff --git a/src/components/OAuthProviderButtons.tsx b/src/components/OAuthProviderButtons.tsx index 5632cfe..8bc838d 100644 --- a/src/components/OAuthProviderButtons.tsx +++ b/src/components/OAuthProviderButtons.tsx @@ -24,13 +24,9 @@ const OAuthProviderButtons: React.FC = () => { useEffect(() => { let active = true; - listOAuthProviders() - .then(result => { - if (active) setProviders(result.providers ?? []); - }) - .catch(() => { - if (active) setProviders([]); - }); + void listOAuthProviders().then(({ data }) => { + if (active) setProviders(data?.providers ?? []); + }); return () => { active = false; @@ -43,19 +39,21 @@ const OAuthProviderButtons: React.FC = () => { const handleSelect = async (providerId: string) => { setError(''); - try { - // The callback route reads this to know which provider to finish with. - sessionStorage.setItem(OAUTH_PROVIDER_STORAGE_KEY, providerId); - const { authorizationUrl } = await startOAuthLogin({ - providerId, - redirectUri: new URL(callbackHref, window.location.origin).toString(), - }); + // The callback route reads this to know which provider to finish with. + sessionStorage.setItem(OAUTH_PROVIDER_STORAGE_KEY, providerId); - window.location.assign(authorizationUrl); - } catch { + const { data, error } = await startOAuthLogin({ + providerId, + redirectUri: new URL(callbackHref, window.location.origin).toString(), + }); + + if (error) { setError('Could not start sign-in with this provider.'); + return; } + + window.location.assign(data.authorizationUrl); }; return ( diff --git a/src/views/Login.tsx b/src/views/Login.tsx index a34d1ba..a7909a4 100644 --- a/src/views/Login.tsx +++ b/src/views/Login.tsx @@ -146,8 +146,9 @@ const Login: React.FC = () => { setLoginMethods(availableMethods); if (passkeySupported && availableMethods.includes('passkey')) { - const passkeyResult = await handlePasskeyLogin(); - if (passkeyResult) { + const { error: passkeyError } = await handlePasskeyLogin(); + + if (!passkeyError) { navigate('/'); return; } diff --git a/src/views/OAuthCallback.tsx b/src/views/OAuthCallback.tsx index cd185e2..058cfd1 100644 --- a/src/views/OAuthCallback.tsx +++ b/src/views/OAuthCallback.tsx @@ -31,14 +31,15 @@ const OAuthCallback: React.FC = () => { return; } - finishOAuthLogin({ providerId, code, state }) - .then(() => { - sessionStorage.removeItem(OAUTH_PROVIDER_STORAGE_KEY); - navigate('/'); - }) - .catch(() => { + void finishOAuthLogin({ providerId, code, state }).then(({ error: finishError }) => { + if (finishError) { setError('We could not complete sign-in. Please try again.'); - }); + return; + } + + sessionStorage.removeItem(OAUTH_PROVIDER_STORAGE_KEY); + navigate('/'); + }); }, [finishOAuthLogin, navigate, searchParams]); return ( diff --git a/src/views/PassKeyLogin.tsx b/src/views/PassKeyLogin.tsx index 6e20fb6..ed4082a 100644 --- a/src/views/PassKeyLogin.tsx +++ b/src/views/PassKeyLogin.tsx @@ -18,9 +18,9 @@ const PassKeyLogin: React.FC = () => { const handlePasskeyLoginClick = async () => { setError(''); - const success = await runPasskeyLogin(); + const { error: passkeyError } = await runPasskeyLogin(); - if (success) { + if (!passkeyError) { navigate('/'); return; } diff --git a/tests/OAuthCallback.test.tsx b/tests/OAuthCallback.test.tsx index 2754af8..41060ae 100644 --- a/tests/OAuthCallback.test.tsx +++ b/tests/OAuthCallback.test.tsx @@ -29,7 +29,7 @@ describe('OAuthCallback', () => { }); test('finishes the login and navigates home', async () => { - finishOAuthLogin.mockResolvedValue(undefined); + finishOAuthLogin.mockResolvedValue({ data: { message: 'Success' }, error: null }); window.sessionStorage.setItem('seamless:oauth:provider', 'mock'); (useSearchParams as jest.Mock).mockReturnValue([ new URLSearchParams('code=abc&state=xyz'), diff --git a/tests/OAuthProviderButtons.test.tsx b/tests/OAuthProviderButtons.test.tsx index b4fff6b..fcf8eab 100644 --- a/tests/OAuthProviderButtons.test.tsx +++ b/tests/OAuthProviderButtons.test.tsx @@ -30,7 +30,7 @@ describe('OAuthProviderButtons', () => { }); test('renders nothing when no providers are configured', async () => { - listOAuthProviders.mockResolvedValue({ providers: [] }); + listOAuthProviders.mockResolvedValue({ data: { providers: [] }, error: null }); const { container } = renderInRouter(); @@ -40,9 +40,13 @@ describe('OAuthProviderButtons', () => { test('starts the flow and stores the provider when one is selected', async () => { listOAuthProviders.mockResolvedValue({ - providers: [{ id: 'mock', name: 'Mock OIDC', scopes: [] }], + data: { providers: [{ id: 'mock', name: 'Mock OIDC', scopes: [] }] }, + error: null, + }); + startOAuthLogin.mockResolvedValue({ + data: { authorizationUrl: 'http://idp.test/authorize' }, + error: null, }); - startOAuthLogin.mockResolvedValue({ authorizationUrl: 'http://idp.test/authorize' }); renderInRouter(); @@ -60,9 +64,13 @@ describe('OAuthProviderButtons', () => { test('includes the router basename in the callback redirect URI', async () => { listOAuthProviders.mockResolvedValue({ - providers: [{ id: 'mock', name: 'Mock OIDC', scopes: [] }], + data: { providers: [{ id: 'mock', name: 'Mock OIDC', scopes: [] }] }, + error: null, + }); + startOAuthLogin.mockResolvedValue({ + data: { authorizationUrl: 'http://idp.test/authorize' }, + error: null, }); - startOAuthLogin.mockResolvedValue({ authorizationUrl: 'http://idp.test/authorize' }); renderInRouter('/app'); diff --git a/tests/PassKeyLogin.test.tsx b/tests/PassKeyLogin.test.tsx index 6c75c16..83c39c2 100644 --- a/tests/PassKeyLogin.test.tsx +++ b/tests/PassKeyLogin.test.tsx @@ -39,7 +39,7 @@ describe('PassKeyLogin', () => { }); it('navigates home when passkey login succeeds', async () => { - mockHandlePasskeyLogin.mockResolvedValueOnce(true); + mockHandlePasskeyLogin.mockResolvedValueOnce({ data: {}, error: null }); render(); fireEvent.click(screen.getByRole('button', { name: /use passkey/i })); @@ -51,7 +51,10 @@ describe('PassKeyLogin', () => { }); it('shows an error when passkey login cannot be completed', async () => { - mockHandlePasskeyLogin.mockResolvedValueOnce(false); + mockHandlePasskeyLogin.mockResolvedValueOnce({ + data: null, + error: new Error('nope'), + }); render(); fireEvent.click(screen.getByRole('button', { name: /use passkey/i })); diff --git a/tests/authProvider.test.tsx b/tests/authProvider.test.tsx index a12c7e7..6a315b4 100644 --- a/tests/authProvider.test.tsx +++ b/tests/authProvider.test.tsx @@ -446,11 +446,12 @@ describe('AuthProvider', () => { friendlyName: 'Renamed passkey', }); - // The API wraps the payload as { message, credential }. Returning the + // The API wraps the payload as { message, credential }. Surfacing the // wrapper would hand callers an object with no credential fields on it. - expect(returned).toEqual(updatedCredential); - expect(returned.friendlyName).toBe('Renamed passkey'); - expect(returned).not.toHaveProperty('message'); + expect(returned.error).toBeNull(); + expect(returned.data).toEqual(updatedCredential); + expect(returned.data?.friendlyName).toBe('Renamed passkey'); + expect(returned.data).not.toHaveProperty('message'); }); describe('failure paths', () => { @@ -473,7 +474,7 @@ describe('AuthProvider', () => { // The start call fails, so no assertion is ever attempted. mockFetchWithAuthImpl.mockResolvedValueOnce(failure(401)); - await expect(auth.handlePasskeyLogin()).resolves.toBe(false); + expect((await auth.handlePasskeyLogin()).error).not.toBeNull(); }); it('clears auth state even when signing out fails', async () => { @@ -497,10 +498,9 @@ describe('AuthProvider', () => { failure(403, { error: 'Deletion is disabled' }) ); - await expect(auth.deleteUser()).rejects.toMatchObject({ - message: 'Deletion is disabled', - status: 403, - }); + const { error } = await auth.deleteUser(); + + expect(error).toMatchObject({ message: 'Deletion is disabled', status: 403 }); }); it('surfaces a failed credential update', async () => { @@ -510,9 +510,12 @@ describe('AuthProvider', () => { failure(409, { error: 'Name already used' }) ); - await expect( - auth.updateCredential({ ...buildCredential(), friendlyName: 'x' }) - ).rejects.toMatchObject({ message: 'Name already used', status: 409 }); + const { error } = await auth.updateCredential({ + ...buildCredential(), + friendlyName: 'x', + }); + + expect(error).toMatchObject({ message: 'Name already used', status: 409 }); }); it('surfaces a failed credential deletion', async () => { @@ -520,9 +523,9 @@ describe('AuthProvider', () => { mockFetchWithAuthImpl.mockResolvedValueOnce(failure(404)); - await expect(auth.deleteCredential('cred-1')).rejects.toMatchObject({ - status: 404, - }); + const { error } = await auth.deleteCredential('cred-1'); + + expect(error).toMatchObject({ status: 404 }); }); it('surfaces a failed organization switch', async () => { @@ -532,20 +535,20 @@ describe('AuthProvider', () => { failure(403, { error: 'Not a member' }) ); - await expect(auth.switchOrganization('org-1')).rejects.toMatchObject({ - message: 'Not a member', - }); + const { error } = await auth.switchOrganization('org-1'); + + expect(error).toMatchObject({ message: 'Not a member' }); }); - it('throws from the OAuth helpers rather than returning a result', async () => { + it('reports OAuth helper failures as results', async () => { const auth = await renderAndCaptureAuth(); mockFetchWithAuthImpl .mockResolvedValueOnce(failure(500)) .mockResolvedValueOnce(failure(400, { error: 'Unknown provider' })); - await expect(auth.listOAuthProviders()).rejects.toMatchObject({ status: 500 }); - await expect(auth.startOAuthLogin({ providerId: 'nope' })).rejects.toMatchObject({ + expect((await auth.listOAuthProviders()).error).toMatchObject({ status: 500 }); + expect((await auth.startOAuthLogin({ providerId: 'nope' })).error).toMatchObject({ message: 'Unknown provider', }); }); @@ -555,7 +558,10 @@ describe('AuthProvider', () => { mockFetchWithAuthImpl.mockResolvedValueOnce(failure(401)); - await expect(auth.refreshStepUpStatus()).resolves.toBeNull(); + const { data, error } = await auth.refreshStepUpStatus(); + + expect(data).toBeNull(); + expect(error).not.toBeNull(); }); it('leaves step-up status untouched when verification fails', async () => { @@ -611,7 +617,9 @@ describe('AuthProvider', () => { json: async () => ({ error: 'OAuth signup is disabled' }), } as any); - await expect(auth.finishOAuthLogin(input)).rejects.toMatchObject({ + const { error } = await auth.finishOAuthLogin(input); + + expect(error).toMatchObject({ name: 'SeamlessAuthError', message: 'OAuth signup is disabled', status: 403, @@ -630,7 +638,9 @@ describe('AuthProvider', () => { }, } as any); - await expect(auth.finishOAuthLogin(input)).rejects.toMatchObject({ + const { error } = await auth.finishOAuthLogin(input); + + expect(error).toMatchObject({ name: 'SeamlessAuthError', message: 'Failed to finish OAuth login.', status: 502,