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,