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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ This package assumes a Seamless Auth-compatible backend with cookie-based auth f

- In `web` mode, requests target `${apiHost}/...`
- In `server` mode, requests target `${apiHost}/auth/...`
- `apiHost` may be provided with or without a trailing slash
- Requests are sent with `credentials: 'include'`
- `AuthProvider` validates the current session by calling `/users/me` on load

Expand Down
21 changes: 18 additions & 3 deletions src/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,18 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
});

if (response.ok) {
return response.json();
const responseBody = await response.json();
const updatedCredential = responseBody.credential ?? responseBody;

setCredentials(currentCredentials =>
currentCredentials.map(currentCredential =>
currentCredential.id === updatedCredential.id
? { ...currentCredential, ...updatedCredential }
: currentCredential
)
);

return responseBody;
}

throw new Error('Failed to update credential');
Expand All @@ -192,10 +203,14 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
const response = await authClient.deleteCredential(credentialId);

if (response.ok) {
return response.json();
const responseBody = await response.json();
setCredentials(currentCredentials =>
currentCredentials.filter(credential => credential.id !== credentialId)
);
return responseBody;
}

throw new Error('Failed to update credential');
throw new Error('Failed to delete credential');
};

const refreshStepUpStatus = useCallback(async () => {
Expand Down
6 changes: 4 additions & 2 deletions src/fetchWithAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ export const createFetchWithAuth = (opts: FetchWithAuthOptions) => {
input: string,
init?: RequestInit
): Promise<Response> {
const base = authMode === 'server' ? `auth` : '';
const host = authHost?.replace(/\/+$/, '') ?? '';
const base = authMode === 'server' ? '/auth' : '';
const path = input.startsWith('/') ? input : `/${input}`;

const url = `${authHost}${base}${input.startsWith('/') ? input : `/${input}`}`;
const url = `${host}${base}${path}`;

const requestInit: RequestInit = {
...init,
Expand Down
33 changes: 29 additions & 4 deletions src/views/VerifyMagicLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,15 @@ const VerifyMagicLink: React.FC = () => {
const authClient = useAuthClient();

useEffect(() => {
let mounted = true;
let channel: BroadcastChannel | null = null;
let redirectTimeout: ReturnType<typeof setTimeout> | null = null;

const verify = async () => {
if (!token) {
setError('Missing token for verification.');
if (mounted) {
setError('Missing token for verification.');
}
console.error('No token found', token);
return;
}
Expand All @@ -33,14 +39,20 @@ const VerifyMagicLink: React.FC = () => {

if (!response.ok) {
console.error('Failed to verify token');
setError('Failed to verify token');
if (mounted) {
setError('Failed to verify token');
}
return;
}
} catch (error) {
console.error(error);
}

const channel = new BroadcastChannel('seamless-auth');
if (!mounted) {
return;
}

channel = new BroadcastChannel('seamless-auth');

channel.postMessage({
type: 'MAGIC_LINK_AUTH_SUCCESS',
Expand All @@ -50,11 +62,24 @@ const VerifyMagicLink: React.FC = () => {
'You have been verified on the device and browser that initiated this request'
);

setTimeout(() => {
redirectTimeout = setTimeout(() => {
if (!mounted) {
return;
}

navigate('/');
}, 900);
};
verify();

return () => {
mounted = false;
channel?.close();

if (redirectTimeout) {
clearTimeout(redirectTimeout);
}
};
}, [token, authClient, navigate]);

return (
Expand Down
28 changes: 27 additions & 1 deletion tests/VerifyMagicLink.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('VerifyMagicLink', () => {
verifyMagicLink: jest.fn(),
};
const postMessage = jest.fn();
const close = jest.fn();

beforeEach(() => {
jest.useFakeTimers();
Expand All @@ -34,7 +35,7 @@ describe('VerifyMagicLink', () => {

global.BroadcastChannel = jest.fn(() => ({
postMessage,
close: jest.fn(),
close,
})) as any;

jest.clearAllMocks();
Expand Down Expand Up @@ -120,6 +121,31 @@ describe('VerifyMagicLink', () => {
expect(navigate).toHaveBeenCalledWith('/');
});

test('cleans up broadcast channel and redirect timeout on unmount', async () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams('?token=abc123'),
]);

mockAuthClient.verifyMagicLink.mockResolvedValue({
ok: true,
});

const { unmount } = render(<VerifyMagicLink />);

await screen.findByText(/login verified/i);

unmount();

expect(close).toHaveBeenCalledTimes(1);
expect(jest.getTimerCount()).toBe(0);

act(() => {
jest.advanceTimersByTime(900);
});

expect(navigate).not.toHaveBeenCalled();
});

test('shows spinner during verification', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams('?token=abc123'),
Expand Down
137 changes: 137 additions & 0 deletions tests/authProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,52 @@ const mockFetchWithAuthImpl = jest.fn();

const Consumer = () => {
const auth = useAuth();
const firstCredential = auth.credentials[0];

return (
<div>
<span data-testid="user">{auth.user ? auth.user.email : 'none'}</span>
<span data-testid="isAuthenticated">{String(auth.isAuthenticated)}</span>
<span data-testid="hasRoleAdmin">{String(auth.hasRole('admin'))}</span>
<span data-testid="stepUpFresh">{String(auth.stepUpStatus?.fresh ?? false)}</span>
<span data-testid="credentials">
{auth.credentials.map(credential => credential.friendlyName).join(',')}
</span>
<button onClick={() => void auth.refreshStepUpStatus()}>Refresh step-up</button>
<button
onClick={() => {
if (firstCredential) {
void auth.updateCredential({
...firstCredential,
friendlyName: 'Renamed passkey',
});
}
}}
>
Update credential
</button>
<button onClick={() => void auth.deleteCredential('cred-1')}>
Delete credential
</button>
</div>
);
};

const buildCredential = (overrides = {}) =>
({
id: 'cred-1',
counter: 0,
transports: [],
deviceType: 'singleDevice',
backedup: false,
friendlyName: 'Old passkey',
lastUsedAt: null,
platform: 'mac',
browser: 'chrome',
deviceInfo: 'mac chrome',
...overrides,
}) as any;

describe('AuthProvider', () => {
const apiHost = 'https://api.example.com/';

Expand Down Expand Up @@ -134,4 +169,106 @@ describe('AuthProvider', () => {
expect(screen.getByTestId('stepUpFresh')).toHaveTextContent('true');
});
});

it('updates credential state after a successful credential update', async () => {
const credential = buildCredential();
const updatedCredential = buildCredential({ friendlyName: 'Renamed passkey' });

mockFetchWithAuthImpl
.mockResolvedValueOnce({
ok: true,
json: async () => ({
user: {
id: '1',
email: 'test@example.com',
phone: '555-1234',
roles: ['admin'],
},
credentials: [credential],
}),
} as any)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
message: 'Credential updated',
credential: updatedCredential,
}),
} as any);

await act(async () => {
render(
<AuthProvider apiHost={apiHost}>
<Consumer />
</AuthProvider>
);
});

await waitFor(() => {
expect(screen.getByTestId('credentials')).toHaveTextContent('Old passkey');
});

fireEvent.click(screen.getByRole('button', { name: /update credential/i }));

await waitFor(() => {
expect(screen.getByTestId('credentials')).toHaveTextContent('Renamed passkey');
});

expect(screen.getByTestId('credentials')).not.toHaveTextContent('Old passkey');
expect(mockFetchWithAuthImpl).toHaveBeenCalledTimes(2);
expect(
mockFetchWithAuthImpl.mock.calls.filter(([path]) => path === 'users/me')
).toHaveLength(1);
});

it('removes deleted credentials from provider state after a successful delete', async () => {
const credential = buildCredential();
const secondCredential = buildCredential({
id: 'cred-2',
friendlyName: 'Backup passkey',
});

mockFetchWithAuthImpl
.mockResolvedValueOnce({
ok: true,
json: async () => ({
user: {
id: '1',
email: 'test@example.com',
phone: '555-1234',
roles: ['admin'],
},
credentials: [credential, secondCredential],
}),
} as any)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
message: 'Credential deleted',
}),
} as any);

await act(async () => {
render(
<AuthProvider apiHost={apiHost}>
<Consumer />
</AuthProvider>
);
});

await waitFor(() => {
expect(screen.getByTestId('credentials')).toHaveTextContent('Old passkey');
});

fireEvent.click(screen.getByRole('button', { name: /delete credential/i }));

await waitFor(() => {
expect(screen.getByTestId('credentials')).not.toHaveTextContent('Old passkey');
});

expect(screen.getByTestId('credentials')).toHaveTextContent('Backup passkey');
expect(mockFetchWithAuthImpl).toHaveBeenCalledTimes(2);
expect(
mockFetchWithAuthImpl.mock.calls.filter(([path]) => path === 'users/me')
).toHaveLength(1);
});
});
20 changes: 17 additions & 3 deletions tests/fetchWithAuth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,32 @@ describe('createFetchWithAuth', () => {
expect(options.credentials).toBe('include');
});

it('builds correct URL in server mode', async () => {
it('builds correct URL in server mode when auth host has a trailing slash', async () => {
const fetchWithAuth = createFetchWithAuth({
authMode: 'server',
authHost: 'https://api.example.com/',
});

mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });

await fetchWithAuth('/auth/me');
await fetchWithAuth('/users/me');

const [url] = mockFetch.mock.calls[0];
expect(url).toBe('https://api.example.com/auth/auth/me');
expect(url).toBe('https://api.example.com/auth/users/me');
});

it('builds correct URL in server mode when auth host has no trailing slash', async () => {
const fetchWithAuth = createFetchWithAuth({
authMode: 'server',
authHost: 'https://api.example.com',
});

mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });

await fetchWithAuth('users/me');

const [url] = mockFetch.mock.calls[0];
expect(url).toBe('https://api.example.com/auth/users/me');
});

it('returns the raw response when fetch response is not ok', async () => {
Expand Down
Loading