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
24 changes: 24 additions & 0 deletions .changeset/olive-foxes-gather.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@seamless-auth/react': minor
---

Let the bundled screens choose where a magic link lands.

`requestMagicLink(redirectUri)` arrived in 0.10.0, but only the headless client
path could reach it. An application using `AuthRoutes` had no way to set one, so
the deployment-wide destination was the only option for the audience least likely
to be wiring up its own client.

`AuthProvider` now takes `magicLinkRedirectUri`, and `useAuthClient()` hands it to
the client as the default for every send. `SeamlessAuthClientOptions` carries the
same field, so a directly constructed client can do this too.

The destination lives on the client rather than at each call site on purpose. The
sign-in screen and the resend on the "check your email" screen both send with no
argument, so they cannot disagree about where the link goes. A resend that landed
somewhere other than the link it repeats would be a confusing failure and an easy
one to miss in review.

Nothing changes if you omit it: the same empty body is sent and the deployment's
own destination still applies. An explicit `requestMagicLink(uri)` still wins over
the configured default.
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,25 @@ async function completeLogin() {

To disable this auto-detection entirely, pass `autoDetectPreviousSignin={false}` to `AuthProvider`.

### Magic link destination

By default a magic link lands wherever the deployment is configured to send it. A deployment serving
more than one front end can override that per application with `magicLinkRedirectUri`:

```tsx
<AuthProvider apiHost={apiHost} magicLinkRedirectUri="https://app.example.com/auth/magic">
<AuthRoutes />
</AuthProvider>
```

Every magic link the bundled screens send uses it, including the resend on the "check your email"
screen, so a resent link always lands where the first one did. The deployment validates the value
against its configured origins and refuses anything else, which comes back as an ordinary error
result.

Custom UIs get the same default through `useAuthClient()`, and can still override a single send with
`requestMagicLink(uri)`.

### Scoped roles

`hasRole(role)` remains an exact role check. Use `hasScopedRole(role)` for colon-separated scoped
Expand Down
11 changes: 9 additions & 2 deletions src/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface AuthContextType {
hasRole: (role: string) => boolean | undefined;
hasScopedRole: (role: string | string[]) => boolean | undefined;
apiHost: string;
magicLinkRedirectUri?: string;
markSignedIn: () => void;
hasSignedInBefore: boolean;
credentials: Credential[];
Expand Down Expand Up @@ -90,12 +91,18 @@ interface AuthProviderProps {
children: ReactNode;
apiHost: string;
autoDetectPreviousSignin?: boolean;
/**
* Where a magic link sent by the bundled screens should land. Both the first
* send and a resend read it from here, so the two cannot drift apart.
*/
magicLinkRedirectUri?: string;
}

export const AuthProvider: React.FC<AuthProviderProps> = ({
children,
apiHost,
autoDetectPreviousSignin = true,
magicLinkRedirectUri,
}) => {
const session = useMemo(
() =>
Expand Down Expand Up @@ -131,8 +138,8 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
}, [session]);

const value = useMemo(
() => ({ ...state, ...session.actions, apiHost }),
[state, session, apiHost]
() => ({ ...state, ...session.actions, apiHost, magicLinkRedirectUri }),
[state, session, apiHost, magicLinkRedirectUri]
);

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
Expand Down
21 changes: 15 additions & 6 deletions src/client/createSeamlessAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ import {

export interface SeamlessAuthClientOptions {
apiHost: string;
/**
* Default destination for `requestMagicLink()`. Every send from this client
* uses it unless a call passes its own, which keeps a resend on the same
* destination as the send it repeats. Omit it to keep the deployment's.
*/
magicLinkRedirectUri?: string;
}

export interface LoginInput {
Expand Down Expand Up @@ -246,8 +252,8 @@ export interface SeamlessAuthClient {
/**
* @param redirectUri Where the emailed link should land. The deployment validates
* it against its configured origins and refuses anything else, so a tenant serving
* a web app and a mobile app can send each to its own destination. Omit it to keep
* the deployment's single destination.
* a web app and a mobile app can send each to its own destination. Omit it to fall
* back to the client's `magicLinkRedirectUri`, then to the deployment's own.
*/
requestMagicLink: (redirectUri?: string) => Promise<SeamlessAuthResult<MessageResult>>;
checkMagicLink: () => Promise<SeamlessAuthResult<MessageResult>>;
Expand Down Expand Up @@ -584,14 +590,17 @@ export const createSeamlessAuthClient = (
// The body is sent even when it is empty, so fetchWithAuth declares a JSON
// content type and the request takes a CORS preflight. A bodyless POST is a
// simple request and stays reachable cross-site.
requestMagicLink: redirectUri =>
requestResult<MessageResult>(
requestMagicLink: redirectUri => {
const destination = redirectUri ?? opts.magicLinkRedirectUri;

return requestResult<MessageResult>(
fetchWithAuth(`/magic-link`, {
method: 'POST',
body: JSON.stringify(redirectUri ? { redirectUri } : {}),
body: JSON.stringify(destination ? { redirectUri: destination } : {}),
}),
'Failed to send the magic link.'
),
);
},

checkMagicLink: () =>
requestResult<MessageResult>(
Expand Down
5 changes: 3 additions & 2 deletions src/hooks/useAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ import { useAuth } from '@/AuthProvider';
import { createSeamlessAuthClient } from '@/client/createSeamlessAuthClient';

export const useAuthClient = () => {
const { apiHost } = useAuth();
const { apiHost, magicLinkRedirectUri } = useAuth();

return useMemo(
() =>
createSeamlessAuthClient({
apiHost,
magicLinkRedirectUri,
}),
[apiHost]
[apiHost, magicLinkRedirectUri]
);
};
38 changes: 38 additions & 0 deletions tests/createSeamlessAuthClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,44 @@ describe('createSeamlessAuthClient', () => {
});
});

it('sends the configured destination when a call names none', async () => {
mockFetchWithAuth.mockResolvedValue({
ok: true,
json: async () => ({ message: 'Success' }),
});

const client = createSeamlessAuthClient({
apiHost: 'https://api.example.com',
magicLinkRedirectUri: 'https://app.example.com/magic',
});

expect((await client.requestMagicLink()).error).toBeNull();

expect(mockFetchWithAuth).toHaveBeenCalledWith('/magic-link', {
method: 'POST',
body: JSON.stringify({ redirectUri: 'https://app.example.com/magic' }),
});
});

it('lets a call override the configured destination', async () => {
mockFetchWithAuth.mockResolvedValue({
ok: true,
json: async () => ({ message: 'Success' }),
});

const client = createSeamlessAuthClient({
apiHost: 'https://api.example.com',
magicLinkRedirectUri: 'https://app.example.com/magic',
});

await client.requestMagicLink('https://other.example.com/magic');

expect(mockFetchWithAuth).toHaveBeenCalledWith('/magic-link', {
method: 'POST',
body: JSON.stringify({ redirectUri: 'https://other.example.com/magic' }),
});
});

// The deployment owns the allowlist, so a refusal is reported rather than
// pre-empted here. Guessing at it in the client would mean two allowlists.
it('reports a destination the deployment refuses', async () => {
Expand Down
134 changes: 134 additions & 0 deletions tests/magicLinkDestination.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the GNU Affero General Public License v3.0
* See LICENSE file in the project root for full license information
*/

import { render, screen, fireEvent, act } from '@testing-library/react';

import Login from '@/views/Login';
import MagicLinkSent from '@/components/MagicLinkSent';
import { useAuth } from '@/AuthProvider';
import { createFetchWithAuth } from '@/fetchWithAuth';
import { useNavigate, useLocation } from 'react-router-dom';

// `useAuthClient` and the client itself stay real here: the whole point is that
// the destination survives the trip from provider config into the request body.
jest.mock('@/AuthProvider');
jest.mock('@/fetchWithAuth');
jest.mock('@/utils', () => ({
isValidEmail: jest.fn(() => true),
isValidPhoneNumber: jest.fn(() => false),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
useLocation: jest.fn(),
useHref: jest.fn((to: string) => to),
}));
jest.mock('@/components/AuthFallbackOptions', () => (props: any) => (
<button onClick={props.onMagicLink}>MagicLink</button>
));

const REDIRECT_URI = 'https://app.example.com/auth/magic';

const mockFetchWithAuth = jest.fn();

/** The body of every POST the client made to /magic-link, in order. */
const magicLinkBodies = (): string[] =>
mockFetchWithAuth.mock.calls
.filter(([path]) => path === '/magic-link')
.map(([, init]) => init.body);

describe('magic link destination in the bundled views', () => {
beforeEach(() => {
(createFetchWithAuth as jest.Mock).mockReturnValue(mockFetchWithAuth);
mockFetchWithAuth.mockResolvedValue({
ok: true,
json: async () => ({ message: 'Success' }),
});

(useNavigate as jest.Mock).mockReturnValue(jest.fn());
(useLocation as jest.Mock).mockReturnValue({
state: { identifier: 'test@example.com' },
});

(useAuth as jest.Mock).mockReturnValue({
apiHost: 'https://api.example.com',
magicLinkRedirectUri: REDIRECT_URI,
hasSignedInBefore: true,
refreshSession: jest.fn(),
listOAuthProviders: jest.fn().mockResolvedValue({ providers: [] }),
login: jest.fn().mockResolvedValue({ data: {}, error: null }),
handlePasskeyLogin: jest.fn().mockResolvedValue(false),
});
});

afterEach(() => {
jest.clearAllMocks();
});

const sendFromLogin = async () => {
render(<Login />);

fireEvent.change(screen.getByPlaceholderText(/email or phone number/i), {
target: { value: 'test@example.com' },
});

await act(async () => {
fireEvent.click(await screen.findByRole('button', { name: /^login$/i }));
});

await act(async () => {
fireEvent.click(await screen.findByText('MagicLink'));
});
};

const resendFromMagicLinkSent = async () => {
jest.useFakeTimers();
try {
render(<MagicLinkSent />);

// The resend button is on a 30s cooldown from mount.
act(() => {
jest.advanceTimersByTime(30_000);
});

await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /resend/i }));
});
} finally {
jest.useRealTimers();
}
};

it('sends the configured destination from the sign-in screen', async () => {
await sendFromLogin();

expect(magicLinkBodies()).toEqual([JSON.stringify({ redirectUri: REDIRECT_URI })]);
});

// The trap this option exists to avoid: a resend that lands somewhere other
// than the link it repeats. Both views must reach the same destination.
it('resends to the same destination the first link used', async () => {
await sendFromLogin();
await resendFromMagicLinkSent();

const [first, resent] = magicLinkBodies();

expect(resent).toBe(first);
expect(resent).toBe(JSON.stringify({ redirectUri: REDIRECT_URI }));
});

it('falls back to the deployment destination when none is configured', async () => {
(useAuth as jest.Mock).mockReturnValue({
...(useAuth as jest.Mock)(),
magicLinkRedirectUri: undefined,
});

await sendFromLogin();
await resendFromMagicLinkSent();

expect(magicLinkBodies()).toEqual([JSON.stringify({}), JSON.stringify({})]);
});
});
15 changes: 15 additions & 0 deletions tests/useAuthClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,19 @@ describe('useAuthClient', () => {
});
expect(result.current).toBe(client);
});

it('passes the magic link destination through to the client', () => {
(useAuth as jest.Mock).mockReturnValue({
apiHost: 'https://api.example.com',
magicLinkRedirectUri: 'https://app.example.com/magic',
});
(createSeamlessAuthClient as jest.Mock).mockReturnValue({});

renderHook(() => useAuthClient());

expect(createSeamlessAuthClient).toHaveBeenCalledWith({
apiHost: 'https://api.example.com',
magicLinkRedirectUri: 'https://app.example.com/magic',
});
});
});
Loading