From ea7a67439dd932773a77e2c2f96a70c8374be830 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 31 Aug 2026 10:34:59 -0400 Subject: [PATCH] feat(magic-link): let a caller choose where the link lands seamless-auth-api takes a redirectUri on the magic link request and the server adapter forwards it, but nothing in this SDK could supply one, so a deployment serving a web app and a mobile app still had a single destination for every link. requestMagicLink takes an optional redirectUri, carried in the body the client already sends. Omitting it sends the same empty body as before, so the default path is byte for byte unchanged. Not validated here. The deployment checks the value against its configured origins and refuses anything else, and an allowlist kept in two places is one that eventually disagrees with itself, so a refusal is reported as an ordinary error result rather than pre-empted. The comment on the empty body said the adapter ignores it. That stopped being true when the adapter started reading redirectUri, so it now explains only the part that still holds, which is that sending a body forces the CORS preflight. --- .changeset/great-owls-travel.md | 21 ++++++++++++++ src/client/createSeamlessAuthClient.ts | 21 ++++++++++---- tests/createSeamlessAuthClient.test.ts | 38 ++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 .changeset/great-owls-travel.md diff --git a/.changeset/great-owls-travel.md b/.changeset/great-owls-travel.md new file mode 100644 index 0000000..e1d2569 --- /dev/null +++ b/.changeset/great-owls-travel.md @@ -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. diff --git a/src/client/createSeamlessAuthClient.ts b/src/client/createSeamlessAuthClient.ts index e5c6622..cded2d4 100644 --- a/src/client/createSeamlessAuthClient.ts +++ b/src/client/createSeamlessAuthClient.ts @@ -243,7 +243,13 @@ export interface SeamlessAuthClient { verifyLoginEmailOtp: ( verificationToken: string ) => Promise>; - requestMagicLink: () => Promise>; + /** + * @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>; checkMagicLink: () => Promise>; verifyMagicLink: (token: string) => Promise>; listOAuthProviders: () => Promise>; @@ -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( - fetchWithAuth(`/magic-link`, { method: 'POST', body: JSON.stringify({}) }), + fetchWithAuth(`/magic-link`, { + method: 'POST', + body: JSON.stringify(redirectUri ? { redirectUri } : {}), + }), 'Failed to send the magic link.' ), diff --git a/tests/createSeamlessAuthClient.test.ts b/tests/createSeamlessAuthClient.test.ts index 1786fd3..d53f0ab 100644 --- a/tests/createSeamlessAuthClient.test.ts +++ b/tests/createSeamlessAuthClient.test.ts @@ -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,