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

Let a caller choose where a magic link lands.

`requestMagicLink` takes an optional `redirectUri`. A deployment serving both a web
app and a mobile app previously had one destination for every magic link, so a link
had to arrive in one or the other.

The value goes in the request body the client already sends. The deployment validates
it against its configured origins and refuses anything else, so this cannot be used to
point a link on the tenant's domain somewhere it should not go, and a refusal comes
back as an ordinary error result.

Omit it and nothing changes: the same empty body is sent, so the destination stays the
deployment's own and no caller has to do anything.

Needs a `@seamless-auth/server` adapter that forwards the field and an auth API that
understands it. Against older versions the value is dropped and the link keeps the
deployment's destination, which is the behaviour today.
21 changes: 15 additions & 6 deletions src/client/createSeamlessAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,13 @@ export interface SeamlessAuthClient {
verifyLoginEmailOtp: (
verificationToken: string
) => Promise<SeamlessAuthResult<MessageResult>>;
requestMagicLink: () => Promise<SeamlessAuthResult<MessageResult>>;
/**
* @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.
*/
requestMagicLink: (redirectUri?: string) => Promise<SeamlessAuthResult<MessageResult>>;
checkMagicLink: () => Promise<SeamlessAuthResult<MessageResult>>;
verifyMagicLink: (token: string) => Promise<SeamlessAuthResult<MessageResult>>;
listOAuthProviders: () => Promise<SeamlessAuthResult<OAuthProvidersResult>>;
Expand Down Expand Up @@ -575,12 +581,15 @@ export const createSeamlessAuthClient = (
'Failed to verify the email code.'
),

// Sends an empty JSON body on purpose. The adapter ignores it, but it makes
// fetchWithAuth declare a JSON content type, which forces a CORS preflight.
// A bodyless POST is still a simple request and stays reachable cross-site.
requestMagicLink: () =>
// 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>(
fetchWithAuth(`/magic-link`, { method: 'POST', body: JSON.stringify({}) }),
fetchWithAuth(`/magic-link`, {
method: 'POST',
body: JSON.stringify(redirectUri ? { redirectUri } : {}),
}),
'Failed to send the magic link.'
),

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

it('sends a requested destination for the magic link', async () => {
mockFetchWithAuth.mockResolvedValue({
ok: true,
json: async () => ({ message: 'Success' }),
});

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

expect(
(await client.requestMagicLink('https://app.example.com/magic')).error
).toBeNull();

expect(mockFetchWithAuth).toHaveBeenCalledWith('/magic-link', {
method: 'POST',
body: JSON.stringify({ redirectUri: 'https://app.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 () => {
mockFetchWithAuth.mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: 'Redirect URI is not allowed' }),
});

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

const { error } = await client.requestMagicLink('https://evil.example/steal');

expect(error).not.toBeNull();
});

it('keeps an untrusted magic-link token inside its own path segment', async () => {
mockFetchWithAuth.mockResolvedValue({
ok: true,
Expand Down
Loading