diff --git a/.changeset/magic-link-confirm-step.md b/.changeset/magic-link-confirm-step.md new file mode 100644 index 0000000..de8aa59 --- /dev/null +++ b/.changeset/magic-link-confirm-step.md @@ -0,0 +1,16 @@ +--- +'@seamless-auth/client': minor +'@seamless-auth/react': minor +'@seamless-auth/angular': minor +'@seamless-auth/vue': minor +'@seamless-auth/svelte': minor +'@seamless-auth/react-native': patch +--- + +Add a confirmation step for magic links opened on another device. + +A magic link opened on a device other than the one that asked for it now waits for the reader to approve it (auth API 0.19.0 and later). The bundled verify screens show which kind of device asked and when, and offer "Yes, sign in that device" or "No, this wasn't me". Approving signs in the requesting device and tells the reader to return to it; declining changes nothing. + +`@seamless-auth/client` adds `client.confirmMagicLink(token)`, the `getMagicLinkConfirmation`, `isMagicLinkConfirmationRequired`, `describeMagicLinkDevice`, `magicLinkConfirmationPrompt` and `confirmMagicLinkSignIn` helpers, and the `MagicLinkConfirmation` and `MagicLinkDeviceClass` types. In bearer mode the verify call now sends the sign-in's ephemeral token when one is held. A custom verify screen that treats every failure as an invalid link should check `getMagicLinkConfirmation(error)` first. + +The React Native README describes the new step for apps that handle magic links. diff --git a/AGENTS.md b/AGENTS.md index 5d10b2b..1bdc783 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -357,7 +357,7 @@ The built-in flows and exported client assume these route families exist: - `/otp/verify-phone-otp`, `/otp/verify-email-otp`, and their `-login-` variants - `POST /magic-link` - `/magic-link/check` -- `/magic-link/verify/:token` +- `/magic-link/verify/:token` and `POST /magic-link/verify/:token/confirm` - `/oauth/providers`, `/oauth/:providerId/start`, `/oauth/:providerId/callback` - `/step-up/status`, `/step-up/webauthn/start`, `/step-up/webauthn/finish` - `/organizations` and `/organizations/:organizationId` (plus `/switch` and `/members` subroutes) @@ -365,6 +365,19 @@ The built-in flows and exported client assume these route families exist: - `/users/credentials` - `/users/delete` +`/magic-link/verify/:token` uses the link only on the device that asked for it, +which the API recognises from the ephemeral token (bearer mode sends it on that +route when it holds one) or from the address and user agent the adapter +forwards. Opened anywhere else it answers `409` with `error: +'confirmation_required'` and a `requestedFrom` (`deviceClass`, `requestedAt`), +and leaves the link unused. `getMagicLinkConfirmation()` reads that, and +`confirmMagicLinkSignIn()` posts to `/confirm` once the reader approves, which +signs in the requesting device through its poll rather than this one. Every +bundled verify screen asks first; never confirm without the reader's say-so. +`confirmationRequired` in `test-support/fakeAdapter.ts` is the 409 the binding +tests share. The shape is local to `packages/client/src/flows/magicLink.ts` +until `@seamless-auth/types` publishes it. + The `@seamless-auth/express` adapter mounts the WebAuthn routes as `/webAuthn` (camelCase), and its cookie middleware matches request paths case-sensitively. Keep the client paths byte-for-byte aligned with the adapter's mounted paths; diff --git a/packages/angular/README.md b/packages/angular/README.md index 4896e7e..666b987 100644 --- a/packages/angular/README.md +++ b/packages/angular/README.md @@ -185,8 +185,8 @@ renders, and the guard would send them on before they could add a passkey. ## Headless use Every screen is a thin view over a flow in `@seamless-auth/client` (`beginSignIn`, -`registerWithEmail`, `verifyOtp`, `watchMagicLink`, `finishMagicLinkSignIn`, `startOAuthSignIn`, -`completeOAuthCallback`, `enrollPasskey`, and helpers). A custom screen calls the same flows with the +`registerWithEmail`, `verifyOtp`, `watchMagicLink`, `finishMagicLinkSignIn`, +`confirmMagicLinkSignIn`, `startOAuthSignIn`, `completeOAuthCallback`, `enrollPasskey`, and helpers). A custom screen calls the same flows with the service as its actions: ```ts diff --git a/packages/angular/routes/src/verify-magic-link.component.ts b/packages/angular/routes/src/verify-magic-link.component.ts index a0b27d4..19d5f11 100644 --- a/packages/angular/routes/src/verify-magic-link.component.ts +++ b/packages/angular/routes/src/verify-magic-link.component.ts @@ -14,7 +14,13 @@ import { } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { SeamlessAuth } from '@seamless-auth/angular'; -import { finishMagicLinkSignIn, type MagicLinkOutcome } from '@seamless-auth/client'; +import { + confirmMagicLinkSignIn, + finishMagicLinkSignIn, + getMagicLinkConfirmation, + magicLinkConfirmationPrompt, + type MagicLinkOutcome, +} from '@seamless-auth/client'; import { SaAuthLayout } from './auth-layout.component'; import { injectAuthNavigation } from './navigation'; @@ -22,35 +28,84 @@ import { injectAuthNavigation } from './navigation'; /** * Where an emailed magic link lands. Verifies the link once, tells the tab that * asked for it, and signs this tab in too when the link was opened in the same - * browser. + * browser. Opened on another device, the link is held and the screen asks + * whether to sign the requesting device in. */ @Component({ selector: 'sa-verify-magic-link', imports: [SaAuthLayout], template: ` -
-

Verifying your login

- - @if (!outcome() && !error()) { - -

- Please wait while we securely verify your sign-in link. -

- } - @if (outcome() === 'signed-in') { -

Login verified. Redirecting...

- } - @if (outcome() === 'elsewhere') { -

- Login verified. Return to the device where you requested this link to - continue. -

- } - @if (error()) { - + @if (prompt()) { +
+

Confirm this sign-in

+ + @if (decision() === 'pending' || decision() === 'confirming') { +

{{ prompt() }}

+

+ This link was opened on a different device from the one that asked for it. + Only continue if you made that request. +

+ } + @if (decision() === 'confirmed') { +

+ Sign-in approved. You can return to your other device to continue. +

+ } + @if (decision() === 'declined') { +

+ No one has been signed in. You can ignore the email and close this page. +

+ } +
+ + @if (decision() === 'pending' || decision() === 'confirming') { +
+ + +
+ @if (confirmError()) { + + } } -
+ } @else { +
+

Verifying your login

+ + @if (!outcome() && !error()) { + +

+ Please wait while we securely verify your sign-in link. +

+ } + @if (outcome() === 'signed-in') { +

Login verified. Redirecting...

+ } + @if (outcome() === 'elsewhere') { +

+ Login verified. Return to the device where you requested this link to + continue. +

+ } + @if (error()) { + + } +
+ }
`, changeDetection: ChangeDetectionStrategy.OnPush, @@ -62,6 +117,33 @@ export class SaVerifyMagicLink { readonly outcome = signal(null); readonly error = signal(''); + readonly prompt = signal(''); + readonly decision = signal<'pending' | 'confirming' | 'confirmed' | 'declined'>( + 'pending' + ); + readonly confirmError = signal(''); + + async confirm() { + if (!this.token || this.decision() === 'confirming') return; + + this.decision.set('confirming'); + this.confirmError.set(''); + + const { error } = await confirmMagicLinkSignIn({ + client: this.auth.client, + token: this.token, + }); + + if (error) { + this.decision.set('pending'); + this.confirmError.set( + 'We could not confirm this sign-in. The link may have expired or already been used.' + ); + return; + } + + this.decision.set('confirmed'); + } constructor() { let active = true; @@ -91,6 +173,12 @@ export class SaVerifyMagicLink { if (!active) return; if (error) { + const held = getMagicLinkConfirmation(error); + if (held) { + this.prompt.set(magicLinkConfirmationPrompt(held)); + return; + } + this.error.set('Failed to verify token'); return; } diff --git a/packages/angular/tests/screens.test.ts b/packages/angular/tests/screens.test.ts index 7f175ff..17dd304 100644 --- a/packages/angular/tests/screens.test.ts +++ b/packages/angular/tests/screens.test.ts @@ -18,6 +18,7 @@ import { import { provideSeamlessAuth } from '../src'; import { SaOtpInput, seamlessAuthRoutes } from '../routes/src'; import { + confirmationRequired, createAdapter, flush, passkeyPort, @@ -328,6 +329,51 @@ describe('magic link screens', () => { expect(url()).toBe('/verify-magiclink'); }); + it('asks before signing in the device a link opened elsewhere came from', async () => { + const { adapter } = await open('/verify-magiclink?token=t1', { + 'GET /magic-link/verify/t1': confirmationRequired, + 'POST /magic-link/verify/t1/confirm': ok, + }); + + expect(heading()).toBe('Confirm this sign-in'); + expect(text()).toContain('A sign-in was requested from an Android device on '); + expect(text()).not.toContain('Failed to verify token'); + expect(adapter.called('GET', '/magic-link/check')).toHaveLength(0); + + await click('Yes, sign in that device'); + + expect(adapter.called('POST', '/magic-link/verify/t1/confirm')).toHaveLength(1); + expect(root().querySelector('[role="status"]')?.textContent).toContain( + 'You can return to your other device' + ); + expect(url()).toBe('/verify-magiclink'); + }); + + it('reports a confirmation the server refuses', async () => { + await open('/verify-magiclink?token=t1', { + 'GET /magic-link/verify/t1': confirmationRequired, + 'POST /magic-link/verify/t1/confirm': { status: 400, body: { error: 'Invalid' } }, + }); + + await click('Yes, sign in that device'); + + expect(root().querySelector('[role="alert"]')?.textContent).toContain( + 'We could not confirm this sign-in' + ); + expect(button('Yes, sign in that device').disabled).toBe(false); + }); + + it('leaves a link alone when the reader declines', async () => { + const { adapter } = await open('/verify-magiclink?token=t1', { + 'GET /magic-link/verify/t1': confirmationRequired, + }); + + await click("No, this wasn't me"); + + expect(text()).toContain('You can ignore the email'); + expect(adapter.called('POST', '/magic-link/verify/t1/confirm')).toHaveLength(0); + }); + it('reports a link that cannot be verified, or no link at all', async () => { await open('/verify-magiclink?token=t1', { 'GET /magic-link/verify/t1': { status: 400 }, diff --git a/packages/client/README.md b/packages/client/README.md index d7bc95a..746116a 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -53,6 +53,8 @@ any framework can call them directly. | `requestOtp` / `verifyOtp` | Sends and checks a code for a channel (`email`, `phone`) and flow (`login`, `register`), and says where to go next | | `watchMagicLink({ client, refreshSession, onSignedIn })` | In the tab that asked for a link, waits for it to be used (cross-tab message or poll). Returns a stop function | | `finishMagicLinkSignIn({ client, refreshSession })` | On the page the link lands on, after `client.verifyMagicLink(token)` succeeds | +| `getMagicLinkConfirmation(error)`, `magicLinkConfirmationPrompt(held)` | When verifying fails because the link was opened on another device: who asked for it, and when | +| `confirmMagicLinkSignIn({ client, token })` | Approves that link once the reader agrees. Signs in the device that asked for it, not this one | | `startOAuthSignIn` / `completeOAuthCallback` | Starts a provider sign-in and finishes it on the redirect URI | | `enrollPasskey({ client, refreshSession }, attachment?)` | Enrols a passkey for the signed-in user, labelled with this device | | `loadLoginMethods`, `fallbackSignInOptions`, `hasNonPasskeyLoginMethod` | What the instance allows, and what to offer a user | diff --git a/packages/client/src/client/createSeamlessAuthClient.ts b/packages/client/src/client/createSeamlessAuthClient.ts index c99eaf1..fee8e42 100644 --- a/packages/client/src/client/createSeamlessAuthClient.ts +++ b/packages/client/src/client/createSeamlessAuthClient.ts @@ -288,7 +288,17 @@ export interface SeamlessAuthClient { */ requestMagicLink: (redirectUri?: string) => Promise>; checkMagicLink: () => Promise>; + /** + * Opened on a device other than the one that asked for it, the link is not + * used: the error is a `409` that `getMagicLinkConfirmation()` reads, and the + * link waits for `confirmMagicLink()`. + */ verifyMagicLink: (token: string) => Promise>; + /** + * Approves a link `verifyMagicLink()` held for confirmation. This signs in + * the device that asked for the link, through its poll, not this one. + */ + confirmMagicLink: (token: string) => Promise>; listOAuthProviders: () => Promise>; getPublicSystemConfig: () => Promise>; startOAuthLogin: ( @@ -659,6 +669,15 @@ export const createSeamlessAuthClient = ( 'Failed to verify the magic link.' ), + confirmMagicLink: token => + requestResult( + fetchWithAuth(`/magic-link/verify/${encodeURIComponent(token)}/confirm`, { + method: 'POST', + body: JSON.stringify({}), + }), + 'Failed to confirm the magic link.' + ), + listOAuthProviders: () => requestResult( fetchWithAuth(`/oauth/providers`, { method: 'GET' }), diff --git a/packages/client/src/flows/magicLink.ts b/packages/client/src/flows/magicLink.ts index 5ee449c..87e27da 100644 --- a/packages/client/src/flows/magicLink.ts +++ b/packages/client/src/flows/magicLink.ts @@ -4,7 +4,12 @@ * See LICENSE file in the project root for full license information */ -import type { SeamlessAuthClient } from '../client/createSeamlessAuthClient'; +import type { + MessageResult, + SeamlessAuthClient, +} from '../client/createSeamlessAuthClient'; +import { SeamlessAuthError } from '../client/errors'; +import type { SeamlessAuthResult } from '../client/result'; import type { AuthSessionActions } from '../session/createAuthSession'; /** The channel a verified link uses to tell the tab that asked for it. */ @@ -84,6 +89,130 @@ export function watchMagicLink( export type MagicLinkOutcome = 'signed-in' | 'elsewhere'; +/** The platform family of the device that asked for a link. */ +export type MagicLinkDeviceClass = + | 'ios' + | 'android' + | 'macos' + | 'windows' + | 'linux' + | 'chromeos' + | 'bot' + | 'unknown'; + +/** Who asked for a link that was opened on another device. */ +export interface MagicLinkConfirmation { + deviceClass: MagicLinkDeviceClass; + /** ISO 8601 timestamp of the request. */ + requestedAt: string; +} + +const DEVICE_LABELS: Record = { + ios: 'an iPhone or iPad', + android: 'an Android device', + macos: 'a Mac', + windows: 'a Windows PC', + linux: 'a Linux computer', + chromeos: 'a Chromebook', + bot: 'an automated client', + unknown: 'an unknown device', +}; + +function readConfirmation(body: unknown): MagicLinkConfirmation | undefined { + if (typeof body !== 'object' || body === null) return undefined; + + const { error, requestedFrom } = body as { error?: unknown; requestedFrom?: unknown }; + if (error !== 'confirmation_required') return undefined; + + const from = ( + typeof requestedFrom === 'object' && requestedFrom !== null ? requestedFrom : {} + ) as { deviceClass?: unknown; requestedAt?: unknown }; + + return { + deviceClass: + typeof from.deviceClass === 'string' && + Object.prototype.hasOwnProperty.call(DEVICE_LABELS, from.deviceClass) + ? (from.deviceClass as MagicLinkDeviceClass) + : 'unknown', + requestedAt: typeof from.requestedAt === 'string' ? from.requestedAt : '', + }; +} + +/** + * Read a `verifyMagicLink()` failure that means the link was opened on a + * device other than the one that asked for it. The link is still unused and + * waits for `confirmMagicLink()`. Returns `undefined` for every other failure. + * + * As with the OAuth codes, a body a proxy nested under `details` is accepted. + */ +export function getMagicLinkConfirmation( + error: unknown +): MagicLinkConfirmation | undefined { + if (!(error instanceof SeamlessAuthError) || error.status !== 409) { + return undefined; + } + + return ( + readConfirmation(error.body) ?? + readConfirmation((error.body as { details?: unknown } | null)?.details) + ); +} + +export function isMagicLinkConfirmationRequired(error: unknown): boolean { + return getMagicLinkConfirmation(error) !== undefined; +} + +/** A device class as a phrase, for example `a Mac`. */ +export function describeMagicLinkDevice(deviceClass: MagicLinkDeviceClass): string { + return DEVICE_LABELS[deviceClass] ?? DEVICE_LABELS.unknown; +} + +/** + * The sentence the bundled screens show above the confirmation buttons, with + * the request time in the reader's locale. The time is left out when the API + * sent one that does not parse. + */ +export function magicLinkConfirmationPrompt( + confirmation: MagicLinkConfirmation, + locales?: Intl.LocalesArgument +): string { + const device = describeMagicLinkDevice(confirmation.deviceClass); + const requestedAt = new Date(confirmation.requestedAt); + + if (!confirmation.requestedAt || Number.isNaN(requestedAt.getTime())) { + return `A sign-in was requested from ${device}.`; + } + + const when = requestedAt.toLocaleString(locales, { + dateStyle: 'medium', + timeStyle: 'short', + }); + + return `A sign-in was requested from ${device} on ${when}.`; +} + +/** + * Approve a link that `verifyMagicLink()` held for confirmation. The device + * that asked for it is signed in through its own poll; this one is not, so on + * success a screen tells the reader to go back to it. + */ +export async function confirmMagicLinkSignIn(deps: { + client: Pick; + token: string; +}): Promise> { + const result = await deps.client.confirmMagicLink(deps.token); + + // The requesting tab may be in this browser after all (a changed network + // address is enough to miss the device match), and is then told at once. + if (!result.error && typeof BroadcastChannel !== 'undefined') { + const channel = new BroadcastChannel(MAGIC_LINK_CHANNEL); + channel.postMessage({ type: MAGIC_LINK_SUCCESS_MESSAGE }); + channel.close(); + } + + return result; +} + /** * What the screen an emailed link lands on does once the link is verified. * @@ -94,6 +223,11 @@ export type MagicLinkOutcome = 'signed-in' | 'elsewhere'; * pre-auth cookie. Opened on another device there is nothing to collect, and * the outcome is `elsewhere`. * + * Call it only after `verifyMagicLink()` succeeds. When the link was opened on + * a device other than the one that asked for it, verification fails with an + * error `getMagicLinkConfirmation()` recognises instead, and the flow continues + * with `confirmMagicLinkSignIn()`. + * * A link can be used once, so verification itself stays with the caller, which * has to make sure a remounted screen reuses the first request rather than * sending another. diff --git a/packages/client/src/transport.ts b/packages/client/src/transport.ts index 4417007..5344990 100644 --- a/packages/client/src/transport.ts +++ b/packages/client/src/transport.ts @@ -170,7 +170,10 @@ const ROUTE_RULES: readonly RouteRule[] = [ { match: /^\/oauth\/[^/]+\/start$/, identity: 'none' }, { match: /^\/oauth\/[^/]+\/callback$/, identity: 'none', effect: 'issue' }, { match: /^\/system-config\/public$/, identity: 'none' }, - { match: /^\/magic-link\/verify\/[^/]+$/, identity: 'none' }, + // The ephemeral token, when this process holds one, is how the API tells the + // device that asked for the link from one that merely opened it. + { match: /^\/magic-link\/verify\/[^/]+$/, identity: 'preAuth' }, + { match: /^\/magic-link\/verify\/[^/]+\/confirm$/, identity: 'none' }, { match: /^\/magic-link$/, identity: 'preAuth' }, { match: /^\/magic-link\/check$/, identity: 'preAuth', effect: 'issue' }, { match: /^\/otp\/generate-/, identity: 'preAuth' }, diff --git a/packages/client/tests/createSeamlessAuthClient.test.ts b/packages/client/tests/createSeamlessAuthClient.test.ts index 4ed875b..b65c3e2 100644 --- a/packages/client/tests/createSeamlessAuthClient.test.ts +++ b/packages/client/tests/createSeamlessAuthClient.test.ts @@ -339,6 +339,25 @@ describe('createSeamlessAuthClient', () => { ); }); + it('confirms a held magic link with a preflighted POST to its own path', async () => { + mockFetchWithAuth.mockResolvedValue({ + ok: true, + json: async () => ({ message: 'Success' }), + }); + + const client = createSeamlessAuthClient({ + apiHost: 'https://api.example.com', + }); + + const { data } = await client.confirmMagicLink('../../users/delete'); + + const [path, init] = mockFetchWithAuth.mock.calls[0]; + + expect(data).toEqual({ message: 'Success' }); + expect(path).toBe('/magic-link/verify/..%2F..%2Fusers%2Fdelete/confirm'); + expect(init).toEqual({ method: 'POST', body: '{}' }); + }); + it('uses organization endpoints', async () => { const response = { ok: true }; mockFetchWithAuth.mockResolvedValue(response); diff --git a/packages/client/tests/flows.test.ts b/packages/client/tests/flows.test.ts index 132d744..05f1358 100644 --- a/packages/client/tests/flows.test.ts +++ b/packages/client/tests/flows.test.ts @@ -9,17 +9,23 @@ import { SeamlessAuthError } from '../src/client/errors'; import { beginSignIn, completeOAuthCallback, + confirmMagicLinkSignIn, + describeMagicLinkDevice, detectPasskeySupport, enrollPasskey, fallbackSignInOptions, finishMagicLinkSignIn, formatCountdown, + getMagicLinkConfirmation, hasFallbackSignInOption, hasNonPasskeyLoginMethod, inAppPath, + isMagicLinkConfirmationRequired, isOtpCharacter, loadLoginMethods, MAGIC_LINK_SUCCESS_MESSAGE, + type MagicLinkConfirmation, + magicLinkConfirmationPrompt, OAUTH_PROVIDER_STORAGE_KEY, oauthErrorMessage, otpCharacters, @@ -363,6 +369,98 @@ describe('magic link', () => { }); }); +describe('magic link confirmation', () => { + const held = (body: unknown, status = 409) => + new SeamlessAuthError('Confirm this sign-in.', status, body); + const requestedFrom: MagicLinkConfirmation = { + deviceClass: 'macos', + requestedAt: '2026-10-16T14:05:00.000Z', + }; + const body = { + error: 'confirmation_required', + message: 'Confirm this sign-in.', + requestedFrom, + }; + + it('reads who asked for a link opened on another device', () => { + expect(getMagicLinkConfirmation(held(body))).toEqual({ + deviceClass: 'macos', + requestedAt: '2026-10-16T14:05:00.000Z', + }); + expect(isMagicLinkConfirmationRequired(held(body))).toBe(true); + }); + + it('accepts a body a proxy nested under details', () => { + expect(getMagicLinkConfirmation(held({ error: 'Conflict', details: body }))).toEqual( + body.requestedFrom + ); + }); + + it('ignores every other failure', () => { + expect(getMagicLinkConfirmation(held(body, 400))).toBeUndefined(); + expect(getMagicLinkConfirmation(held({ error: 'Invalid token' }))).toBeUndefined(); + expect(getMagicLinkConfirmation(new Error('nope'))).toBeUndefined(); + expect(isMagicLinkConfirmationRequired(null)).toBe(false); + }); + + it('treats a device class it does not know as unknown', () => { + expect( + getMagicLinkConfirmation( + held({ ...body, requestedFrom: { deviceClass: 'toaster', requestedAt: 1 } }) + ) + ).toEqual({ deviceClass: 'unknown', requestedAt: '' }); + }); + + it.each([ + ['ios', 'an iPhone or iPad'], + ['android', 'an Android device'], + ['macos', 'a Mac'], + ['windows', 'a Windows PC'], + ['linux', 'a Linux computer'], + ['chromeos', 'a Chromebook'], + ['bot', 'an automated client'], + ['unknown', 'an unknown device'], + ] as const)('describes %s as %s', (deviceClass, label) => { + expect(describeMagicLinkDevice(deviceClass)).toBe(label); + }); + + it('says where and when the link was asked for', () => { + const prompt = magicLinkConfirmationPrompt(requestedFrom, 'en-US'); + const when = new Date(requestedFrom.requestedAt).toLocaleString('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }); + + expect(prompt).toBe(`A sign-in was requested from a Mac on ${when}.`); + expect( + magicLinkConfirmationPrompt({ deviceClass: 'ios', requestedAt: 'not a date' }) + ).toBe('A sign-in was requested from an iPhone or iPad.'); + }); + + it('confirms the link and tells a requesting tab in this browser', async () => { + const confirmMagicLink = jest.fn().mockResolvedValue(ok({ message: 'Success' })); + + const result = await confirmMagicLinkSignIn({ + client: { confirmMagicLink }, + token: 'tok', + }); + + expect(result.error).toBeNull(); + expect(confirmMagicLink).toHaveBeenCalledWith('tok'); + expect(FakeChannel.posted).toEqual([{ type: MAGIC_LINK_SUCCESS_MESSAGE }]); + }); + + it('reports a refused confirmation without announcing a sign-in', async () => { + const result = await confirmMagicLinkSignIn({ + client: { confirmMagicLink: jest.fn().mockResolvedValue(failed(400)) }, + token: 'tok', + }); + + expect(result.error).toBeInstanceOf(SeamlessAuthError); + expect(FakeChannel.posted).toEqual([]); + }); +}); + describe('OAuth', () => { const origin = 'https://app.example.com'; const params = (values: Record) => new URLSearchParams(values); diff --git a/packages/client/tests/transport.node.test.ts b/packages/client/tests/transport.node.test.ts index cce7b17..d81cd4e 100644 --- a/packages/client/tests/transport.node.test.ts +++ b/packages/client/tests/transport.node.test.ts @@ -55,7 +55,8 @@ describe('resolveRouteRule', () => { ['/oauth/google/start', 'none', undefined], ['/oauth/google/callback', 'none', 'issue'], ['/system-config/public', 'none', undefined], - ['/magic-link/verify/abc', 'none', undefined], + ['/magic-link/verify/abc', 'preAuth', undefined], + ['/magic-link/verify/abc/confirm', 'none', undefined], ['/magic-link', 'preAuth', undefined], ['/magic-link/check', 'preAuth', 'issue'], ['/otp/generate-login-email-otp', 'preAuth', undefined], @@ -340,6 +341,40 @@ describe('bearer transport', () => { expect(headersOf(calls[4]).Authorization).toBeUndefined(); }); + it('verifies a magic link with the ephemeral token only when one is held', async () => { + const { calls, fetchImpl } = scriptedFetch(call => + call.url.endsWith('/login') + ? jsonResponse(200, { message: 'Login continued', token: 'ephemeral-1' }) + : jsonResponse(200, { message: 'Success' }) + ); + const cold = bearer(fetchImpl, createMemoryTokenStorage()); + + const opened = await cold.fetch('/magic-link/verify/tok', { method: 'GET' }); + expect(opened.status).toBe(200); + expect(headersOf(calls[0])).toEqual({ [AUTH_TRANSPORT_HEADER]: 'bearer' }); + + const requester = bearer(fetchImpl, createMemoryTokenStorage()); + await requester.fetch('/login', { method: 'POST', body: '{}' }); + await requester.fetch('/magic-link/verify/tok', { method: 'GET' }); + expect(headersOf(calls[2]).Authorization).toBe('Bearer ephemeral-1'); + }); + + it('confirms a magic link without sending any session', async () => { + const storage = createMemoryTokenStorage(); + await storage.set({ accessToken: 'access-1', refreshToken: 'refresh-1' }); + const { calls, fetchImpl } = scriptedFetch(call => + call.url.endsWith('/login') + ? jsonResponse(200, { token: 'ephemeral-1' }) + : jsonResponse(200, { message: 'Success' }) + ); + const transport = bearer(fetchImpl, storage); + + await transport.fetch('/login', { method: 'POST', body: '{}' }); + await transport.fetch('/magic-link/verify/tok/confirm', { method: 'POST' }); + + expect(headersOf(calls[1]).Authorization).toBeUndefined(); + }); + it('holds nothing when a pre-auth step returns no session yet', async () => { const storage = createMemoryTokenStorage(); const { fetchImpl } = scriptedFetch(() => diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 5f3a367..fdaff69 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -132,9 +132,18 @@ rather than a user agent. Request one with `client.requestMagicLink()`, then poll `client.checkMagicLink()` every few seconds; the poll returns the session once -the link has been opened anywhere. If the app also receives the link as a -universal link, call `client.verifyMagicLink(token)` and then -`checkMagicLink()`. +the link has been used. If the app also receives the link as a universal link, +call `client.verifyMagicLink(token)` and then `checkMagicLink()`. Bearer mode +sends the sign-in's ephemeral token with the verify call, which is how the API +knows this is the device that asked for the link. + +Opened on any other device, the link is not used until someone there approves +it: `verifyMagicLink` fails with an error `getMagicLinkConfirmation(error)` +recognises, carrying the requesting device's platform and request time +(`magicLinkConfirmationPrompt(held)` phrases both). If the app handles links it +did not ask for, show that and call `confirmMagicLinkSignIn({ client, token })` +only when the person agrees. The requesting device is then signed in by its +poll, and this one is not. ### OAuth diff --git a/packages/react/README.md b/packages/react/README.md index cd385fc..c0afaf9 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -1047,8 +1047,11 @@ The landing route verifies the token from the query string, then refreshes its o ```tsx function CustomMagicLinkLanding() { - const { refreshSession } = useAuth(); // plus: import { useEffect } from 'react' + // plus: import { useEffect, useState } from 'react', and + // getMagicLinkConfirmation, magicLinkConfirmationPrompt from '@seamless-auth/client' + const { refreshSession } = useAuth(); const authClient = useAuthClient(); + const [prompt, setPrompt] = useState(''); useEffect(() => { const token = new URLSearchParams(window.location.search).get('token'); @@ -1059,14 +1062,27 @@ function CustomMagicLinkLanding() { // Refresh here too. This tab set the cookie, but its provider state // was loaded before the cookie existed. await refreshSession(); + return; } + + const held = getMagicLinkConfirmation(error); + if (held) setPrompt(magicLinkConfirmationPrompt(held)); }); }, [authClient, refreshSession]); - return

Finishing sign-in...

; + // Opened on another device: ask before calling confirmMagicLinkSignIn. + return

{prompt || 'Finishing sign-in...'}

; } ``` +A link opened on a device other than the one that asked for it is not used straight away. The +verify call fails with a `409` that `getMagicLinkConfirmation(error)` (from `@seamless-auth/client`) +reads, giving the requesting device's platform and when it asked. Show that to the reader, and only +if they approve call `confirmMagicLinkSignIn({ client: authClient, token })`. That signs in the +device that asked for the link, through its poll, not this one, so tell the reader to go back to +it. The bundled `/verify-magiclink` screen does all of this, with a "No, this wasn't me" option that +changes nothing. + The auth API emails a link pointing at `/verify-magiclink?token=...`, so a custom app must serve that path. @@ -1258,6 +1274,7 @@ The built-in flows assume compatible endpoints for: - `POST /magic-link` - `/magic-link/check` - `/magic-link/verify/:token` +- `POST /magic-link/verify/:token/confirm` - `/oauth/providers` - `/oauth/:providerId/start` - `/oauth/:providerId/callback` diff --git a/packages/react/src/styles/verifyMagiclink.module.css b/packages/react/src/styles/verifyMagiclink.module.css index 0d700e6..045f702 100644 --- a/packages/react/src/styles/verifyMagiclink.module.css +++ b/packages/react/src/styles/verifyMagiclink.module.css @@ -57,6 +57,68 @@ animation: checkPop 0.25s ease-out; } +.prompt { + text-align: center; + font-weight: 600; + line-height: 1.4; +} + +.helperText { + color: var(--seamless-text-muted, #9ca3af); + font-size: 0.875rem; + text-align: center; + line-height: 1.4; +} + +.actions { + display: flex; + flex-direction: column; + gap: 0.6rem; + width: 100%; +} + +.primaryButton { + width: 100%; + background-color: var(--seamless-accent, #2563eb); + color: var(--seamless-accent-contrast, white); + padding: 0.65rem; + border-radius: 0.375rem; + border: none; + cursor: pointer; + font: inherit; + font-weight: 500; +} + +.primaryButton:hover:not(:disabled) { + background-color: var(--seamless-accent-hover, #1d4ed8); +} + +.secondaryButton { + width: 100%; + padding: 0.65rem; + border-radius: 0.375rem; + border: 1px solid var(--seamless-border, #374151); + background: transparent; + color: var(--seamless-text, white); + cursor: pointer; + font: inherit; +} + +.secondaryButton:hover:not(:disabled) { + background: var(--seamless-surface-hover, #1f2937); +} + +.primaryButton:disabled, +.secondaryButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.error { + color: var(--seamless-danger, #f87171); + text-align: center; +} + .successText { color: var(--seamless-success, #34d399); font-size: 0.9rem; diff --git a/packages/react/src/views/VerifyMagicLink.tsx b/packages/react/src/views/VerifyMagicLink.tsx index f8b4a76..1def055 100644 --- a/packages/react/src/views/VerifyMagicLink.tsx +++ b/packages/react/src/views/VerifyMagicLink.tsx @@ -9,7 +9,13 @@ import { useAuth } from '@/AuthProvider'; import { useAuthClient } from '@/hooks/useAuthClient'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { finishMagicLinkSignIn } from '@seamless-auth/client'; +import { + confirmMagicLinkSignIn, + finishMagicLinkSignIn, + getMagicLinkConfirmation, + type MagicLinkConfirmation, + magicLinkConfirmationPrompt, +} from '@seamless-auth/client'; import styles from '@/styles/verifyMagiclink.module.css'; @@ -22,8 +28,10 @@ type Verification = ReturnType['verifyMagicLink * browser that asked for the link, which collects it from `/magic-link/check` * with its pre-auth cookie. So once the link is verified, this screen tells * that tab, then signs in here too when it can: when the link was opened in - * the same browser, which holds the same pre-auth cookie. Opened on another - * device there is nothing to collect, and the screen says to go back. + * the same browser, which holds the same pre-auth cookie. + * + * Opened on a device other than the one that asked for it, the link is held + * rather than used, and the screen asks whether to sign that device in. */ const VerifyMagicLink: React.FC = () => { const [searchParams] = useSearchParams(); @@ -32,6 +40,11 @@ const VerifyMagicLink: React.FC = () => { const [error, setError] = useState(''); const [outcome, setOutcome] = useState<'signed-in' | 'elsewhere' | null>(null); + const [confirmation, setConfirmation] = useState(null); + const [decision, setDecision] = useState< + 'pending' | 'confirming' | 'confirmed' | 'declined' + >('pending'); + const [confirmError, setConfirmError] = useState(''); const authClient = useAuthClient(); const { refreshSession } = useAuth(); @@ -65,6 +78,12 @@ const VerifyMagicLink: React.FC = () => { } if (error) { + const held = getMagicLinkConfirmation(error); + if (held) { + setConfirmation(held); + return; + } + console.error('Failed to verify token'); setError('Failed to verify token'); return; @@ -104,6 +123,86 @@ const VerifyMagicLink: React.FC = () => { }; }, [token, authClient, navigate, refreshSession]); + const confirm = async () => { + if (!token || decision === 'confirming') return; + + setDecision('confirming'); + setConfirmError(''); + + const { error } = await confirmMagicLinkSignIn({ client: authClient, token }); + + if (error) { + setDecision('pending'); + setConfirmError( + 'We could not confirm this sign-in. The link may have expired or already been used.' + ); + return; + } + + setDecision('confirmed'); + }; + + if (confirmation) { + return ( +
+
+

Confirm this sign-in

+ +
+ {(decision === 'pending' || decision === 'confirming') && ( + <> +

+ {magicLinkConfirmationPrompt(confirmation)} +

+

+ This link was opened on a different device from the one that asked for + it. Only continue if you made that request. +

+ +
+ + +
+ + {confirmError && ( +

+ {confirmError} +

+ )} + + )} + + {decision === 'confirmed' && ( +

+ Sign-in approved. You can return to your other device to continue. +

+ )} + + {decision === 'declined' && ( +

+ No one has been signed in. You can ignore the email and close this page. +

+ )} +
+
+
+ ); + } + return (
diff --git a/packages/react/tests/VerifyMagicLink.test.tsx b/packages/react/tests/VerifyMagicLink.test.tsx index e6aeac7..c1a7984 100644 --- a/packages/react/tests/VerifyMagicLink.test.tsx +++ b/packages/react/tests/VerifyMagicLink.test.tsx @@ -4,7 +4,8 @@ * See LICENSE file in the project root for full license information */ -import { render, screen, act } from '@testing-library/react'; +import { render, screen, act, fireEvent } from '@testing-library/react'; +import { SeamlessAuthError } from '@seamless-auth/client'; import { StrictMode } from 'react'; import VerifyMagicLink from '@/views/VerifyMagicLink'; @@ -30,6 +31,15 @@ describe('VerifyMagicLink', () => { const mockAuthClient = { verifyMagicLink: jest.fn(), checkMagicLink: jest.fn(), + confirmMagicLink: jest.fn(), + }; + const heldElsewhere = { + data: null, + error: new SeamlessAuthError('Confirm this sign-in.', 409, { + error: 'confirmation_required', + message: 'Confirm this sign-in.', + requestedFrom: { deviceClass: 'macos', requestedAt: '2026-10-16T14:05:00.000Z' }, + }), }; const postMessage = jest.fn(); const close = jest.fn(); @@ -304,6 +314,82 @@ describe('VerifyMagicLink', () => { expect(refreshSession).toHaveBeenCalledWith({ background: true }); }); + describe('a link opened on another device', () => { + beforeEach(() => { + (useSearchParams as jest.Mock).mockReturnValue([ + new URLSearchParams('?token=abc123'), + ]); + mockAuthClient.verifyMagicLink.mockResolvedValue(heldElsewhere); + }); + + test('asks before signing in the device that requested it', async () => { + render(); + + expect( + await screen.findByRole('heading', { name: /confirm this sign-in/i }) + ).toBeInTheDocument(); + expect(screen.getByText(/requested from a Mac on /)).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /yes, sign in that device/i }) + ).toBeInTheDocument(); + expect(screen.queryByText(/failed to verify token/i)).not.toBeInTheDocument(); + expect(refreshSession).not.toHaveBeenCalled(); + expect(mockAuthClient.checkMagicLink).not.toHaveBeenCalled(); + expect(postMessage).not.toHaveBeenCalled(); + }); + + test('confirms and sends the reader back to the other device', async () => { + mockAuthClient.confirmMagicLink.mockResolvedValue({ + data: { message: 'Success' }, + error: null, + }); + render(); + + fireEvent.click( + await screen.findByRole('button', { name: /yes, sign in that device/i }) + ); + + expect(await screen.findByRole('status')).toHaveTextContent( + /return to your other device/i + ); + expect(mockAuthClient.confirmMagicLink).toHaveBeenCalledWith('abc123'); + expect(refreshSession).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(900); + }); + expect(navigate).not.toHaveBeenCalled(); + }); + + test('reports a confirmation the server refuses', async () => { + mockAuthClient.confirmMagicLink.mockResolvedValue({ + data: null, + error: new SeamlessAuthError('Invalid token', 400), + }); + render(); + + fireEvent.click( + await screen.findByRole('button', { name: /yes, sign in that device/i }) + ); + + expect(await screen.findByRole('alert')).toHaveTextContent( + /could not confirm this sign-in/i + ); + expect( + screen.getByRole('button', { name: /yes, sign in that device/i }) + ).toBeEnabled(); + }); + + test('declining changes nothing', async () => { + render(); + + fireEvent.click(await screen.findByRole('button', { name: /this wasn't me/i })); + + expect(screen.getByRole('status')).toHaveTextContent(/ignore the email/i); + expect(mockAuthClient.confirmMagicLink).not.toHaveBeenCalled(); + }); + }); + test('shows spinner during verification', () => { (useSearchParams as jest.Mock).mockReturnValue([ new URLSearchParams('?token=abc123'), diff --git a/packages/svelte/README.md b/packages/svelte/README.md index 1025ae0..14aab4e 100644 --- a/packages/svelte/README.md +++ b/packages/svelte/README.md @@ -203,8 +203,8 @@ The screens never import a router. They navigate through an `AuthNavigator` set ## Headless use Every screen is a thin view over a flow in `@seamless-auth/client` (`beginSignIn`, -`registerWithEmail`, `verifyOtp`, `watchMagicLink`, `finishMagicLinkSignIn`, `startOAuthSignIn`, -`completeOAuthCallback`, `enrollPasskey`, and helpers). A custom screen calls the same flows with the +`registerWithEmail`, `verifyOtp`, `watchMagicLink`, `finishMagicLinkSignIn`, +`confirmMagicLinkSignIn`, `startOAuthSignIn`, `completeOAuthCallback`, `enrollPasskey`, and helpers). A custom screen calls the same flows with the session as its actions: ```ts diff --git a/packages/svelte/src/screens/VerifyMagicLink.svelte b/packages/svelte/src/screens/VerifyMagicLink.svelte index 4fd20aa..fbdb36c 100644 --- a/packages/svelte/src/screens/VerifyMagicLink.svelte +++ b/packages/svelte/src/screens/VerifyMagicLink.svelte @@ -6,7 +6,8 @@ -
-

Verifying your login

- - {#if !outcome && !error} - -

Please wait while we securely verify your sign-in link.

- {/if} - {#if outcome === 'signed-in'} -

Login verified. Redirecting...

- {/if} - {#if outcome === 'elsewhere'} -

- Login verified. Return to the device where you requested this link to continue. -

- {/if} - {#if error} - + {#if prompt} + {@const asking = decision === 'pending' || decision === 'confirming'} +
+

Confirm this sign-in

+ + {#if asking} +

{prompt}

+

+ This link was opened on a different device from the one that asked for it. Only + continue if you made that request. +

+ {/if} + {#if decision === 'confirmed'} +

+ Sign-in approved. You can return to your other device to continue. +

+ {/if} + {#if decision === 'declined'} +

+ No one has been signed in. You can ignore the email and close this page. +

+ {/if} +
+ + {#if asking} +
+ + +
+ {#if confirmError} + + {/if} {/if} -
+ {:else} +
+

Verifying your login

+ + {#if !outcome && !error} + +

Please wait while we securely verify your sign-in link.

+ {/if} + {#if outcome === 'signed-in'} +

Login verified. Redirecting...

+ {/if} + {#if outcome === 'elsewhere'} +

+ Login verified. Return to the device where you requested this link to continue. +

+ {/if} + {#if error} + + {/if} +
+ {/if}
diff --git a/packages/svelte/tests/screens.test.ts b/packages/svelte/tests/screens.test.ts index 39302ed..fa9e5d4 100644 --- a/packages/svelte/tests/screens.test.ts +++ b/packages/svelte/tests/screens.test.ts @@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createSeamlessAuth } from '../src'; import type { AuthScreen } from '../src/navigation'; import { + confirmationRequired, createAdapter, passkeyPort, signedIn, @@ -305,6 +306,62 @@ describe('magic link screens', () => { expect(text()).toContain('This sign-in link has already been used.'); }); + it('asks before signing in the device a link opened elsewhere came from', async () => { + const { adapter } = await open( + 'verifyMagicLink', + { + 'GET /magic-link/verify/t-held': confirmationRequired, + 'POST /magic-link/verify/t-held/confirm': ok, + }, + { query: 'token=t-held' } + ); + await waitSettled(); + + expect(heading()).toBe('Confirm this sign-in'); + expect(text()).toContain('A sign-in was requested from an Android device on '); + expect(text()).not.toContain('Failed to verify token'); + expect(adapter.called('GET', '/magic-link/check')).toHaveLength(0); + + await click('Yes, sign in that device'); + await waitSettled(); + + expect(adapter.called('POST', '/magic-link/verify/t-held/confirm')).toHaveLength(1); + expect(screen.getByRole('status').textContent).toContain( + 'You can return to your other device' + ); + expect(nav.current).toBe('verifyMagicLink'); + }); + + it('reports a confirmation the server refuses, and changes nothing on decline', async () => { + const { adapter } = await open( + 'verifyMagicLink', + { + 'GET /magic-link/verify/t-refused': confirmationRequired, + 'POST /magic-link/verify/t-refused/confirm': { + status: 400, + body: { error: 'Invalid' }, + }, + }, + { query: 'token=t-refused' } + ); + await waitSettled(); + + await click('Yes, sign in that device'); + await waitSettled(); + + expect(screen.getByRole('alert').textContent).toContain( + 'We could not confirm this sign-in' + ); + expect(button('Yes, sign in that device')).not.toBeDisabled(); + + await click("No, this wasn't me"); + + expect(text()).toContain('You can ignore the email'); + expect(adapter.called('POST', '/magic-link/verify/t-refused/confirm')).toHaveLength( + 1 + ); + }); + it('says to go back when the link was opened elsewhere, and reports failures', async () => { await open( 'verifyMagicLink', diff --git a/packages/vue/README.md b/packages/vue/README.md index c86044f..ce27072 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -164,8 +164,8 @@ it, so a single-use secret does not stay in history or travel in a `Referer`. ## Headless use Every screen is a thin view over a flow in `@seamless-auth/client` (`beginSignIn`, -`registerWithEmail`, `verifyOtp`, `watchMagicLink`, `finishMagicLinkSignIn`, `startOAuthSignIn`, -`completeOAuthCallback`, `enrollPasskey`, and helpers). A custom screen calls the same flows with the +`registerWithEmail`, `verifyOtp`, `watchMagicLink`, `finishMagicLinkSignIn`, +`confirmMagicLinkSignIn`, `startOAuthSignIn`, `completeOAuthCallback`, `enrollPasskey`, and helpers). A custom screen calls the same flows with the session as its actions: ```ts diff --git a/packages/vue/src/router/components/VerifyMagicLink.ts b/packages/vue/src/router/components/VerifyMagicLink.ts index 496adc3..e0325c9 100644 --- a/packages/vue/src/router/components/VerifyMagicLink.ts +++ b/packages/vue/src/router/components/VerifyMagicLink.ts @@ -4,7 +4,13 @@ * See LICENSE file in the project root for full license information */ -import { finishMagicLinkSignIn, type MagicLinkOutcome } from '@seamless-auth/client'; +import { + confirmMagicLinkSignIn, + finishMagicLinkSignIn, + getMagicLinkConfirmation, + magicLinkConfirmationPrompt, + type MagicLinkOutcome, +} from '@seamless-auth/client'; import { defineComponent, h, onBeforeUnmount, onMounted, ref } from 'vue'; import { useRoute } from 'vue-router'; @@ -15,7 +21,8 @@ import { SaAuthLayout } from './AuthLayout'; /** * Where an emailed magic link lands. Verifies the link once, tells the tab that * asked for it, and signs this tab in too when the link was opened in the same - * browser. + * browser. Opened on another device, the link is held and the screen asks + * whether to sign the requesting device in. */ export const SaVerifyMagicLink = defineComponent({ name: 'SaVerifyMagicLink', @@ -27,6 +34,9 @@ export const SaVerifyMagicLink = defineComponent({ const outcome = ref(null); const error = ref(''); + const prompt = ref(''); + const decision = ref<'pending' | 'confirming' | 'confirmed' | 'declined'>('pending'); + const confirmError = ref(''); let active = true; let redirect: ReturnType | undefined; @@ -52,6 +62,12 @@ export const SaVerifyMagicLink = defineComponent({ if (!active) return; if (result.error) { + const held = getMagicLinkConfirmation(result.error); + if (held) { + prompt.value = magicLinkConfirmationPrompt(held); + return; + } + error.value = 'Failed to verify token'; return; } @@ -70,33 +86,113 @@ export const SaVerifyMagicLink = defineComponent({ } }); - return () => - h(SaAuthLayout, null, () => + const confirm = async () => { + if (!token || decision.value === 'confirming') return; + + decision.value = 'confirming'; + confirmError.value = ''; + + const result = await confirmMagicLinkSignIn({ client: auth.client, token }); + + if (!active) return; + + if (result.error) { + decision.value = 'pending'; + confirmError.value = + 'We could not confirm this sign-in. The link may have expired or already been used.'; + return; + } + + decision.value = 'confirmed'; + }; + + const renderConfirmation = () => { + const asking = decision.value === 'pending' || decision.value === 'confirming'; + + return [ h('div', { class: 'sa-center' }, [ - h('h1', { class: 'sa-heading' }, 'Verifying your login'), - !outcome.value && - !error.value && [ - h('div', { class: 'sa-spinner', 'aria-hidden': 'true' }), - h( - 'p', - { class: 'sa-helper-text' }, - 'Please wait while we securely verify your sign-in link.' - ), - ], - outcome.value === 'signed-in' && + h('h1', { class: 'sa-heading' }, 'Confirm this sign-in'), + asking && [ + h('p', { class: 'sa-description' }, prompt.value), h( 'p', - { class: 'sa-success', role: 'status' }, - 'Login verified. Redirecting...' + { class: 'sa-helper-text' }, + 'This link was opened on a different device from the one that asked for it. Only continue if you made that request.' ), - outcome.value === 'elsewhere' && + ], + decision.value === 'confirmed' && h( 'p', { class: 'sa-success', role: 'status' }, - 'Login verified. Return to the device where you requested this link to continue.' + 'Sign-in approved. You can return to your other device to continue.' + ), + decision.value === 'declined' && + h( + 'p', + { class: 'sa-helper-text', role: 'status' }, + 'No one has been signed in. You can ignore the email and close this page.' + ), + ]), + asking && + h('div', { class: 'sa-actions' }, [ + h( + 'button', + { + type: 'button', + class: 'sa-button', + disabled: decision.value === 'confirming', + onClick: () => void confirm(), + }, + 'Yes, sign in that device' + ), + h( + 'button', + { + type: 'button', + class: 'sa-secondary', + disabled: decision.value === 'confirming', + onClick: () => { + decision.value = 'declined'; + }, + }, + "No, this wasn't me" ), - error.value && h('p', { class: 'sa-error', role: 'alert' }, error.value), - ]) + ]), + asking && + confirmError.value && + h('p', { class: 'sa-error', role: 'alert' }, confirmError.value), + ]; + }; + + return () => + h(SaAuthLayout, null, () => + prompt.value + ? renderConfirmation() + : h('div', { class: 'sa-center' }, [ + h('h1', { class: 'sa-heading' }, 'Verifying your login'), + !outcome.value && + !error.value && [ + h('div', { class: 'sa-spinner', 'aria-hidden': 'true' }), + h( + 'p', + { class: 'sa-helper-text' }, + 'Please wait while we securely verify your sign-in link.' + ), + ], + outcome.value === 'signed-in' && + h( + 'p', + { class: 'sa-success', role: 'status' }, + 'Login verified. Redirecting...' + ), + outcome.value === 'elsewhere' && + h( + 'p', + { class: 'sa-success', role: 'status' }, + 'Login verified. Return to the device where you requested this link to continue.' + ), + error.value && h('p', { class: 'sa-error', role: 'alert' }, error.value), + ]) ); }, }); diff --git a/packages/vue/tests/screens.test.ts b/packages/vue/tests/screens.test.ts index 2d86acd..acc6437 100644 --- a/packages/vue/tests/screens.test.ts +++ b/packages/vue/tests/screens.test.ts @@ -16,6 +16,7 @@ import { createMemoryHistory, createRouter, RouterView, type Router } from 'vue- import { createSeamlessAuth } from '../src'; import { createSeamlessAuthRoutes, SaOtpInput } from '../src/router'; import { + confirmationRequired, createAdapter, passkeyPort, signedIn, @@ -316,6 +317,51 @@ describe('magic link screens', () => { expect(text()).toContain('Return to the device where you requested this link'); }); + it('asks before signing in the device a link opened elsewhere came from', async () => { + const { adapter } = await open('/verify-magiclink?token=t1', { + 'GET /magic-link/verify/t1': confirmationRequired, + 'POST /magic-link/verify/t1/confirm': ok, + }); + + expect(heading()).toBe('Confirm this sign-in'); + expect(text()).toContain('A sign-in was requested from an Android device on '); + expect(text()).not.toContain('Failed to verify token'); + expect(adapter.called('GET', '/magic-link/check')).toHaveLength(0); + + await click('Yes, sign in that device'); + + expect(adapter.called('POST', '/magic-link/verify/t1/confirm')).toHaveLength(1); + expect(wrapper.find('[role="status"]').text()).toContain( + 'You can return to your other device' + ); + expect(url()).toBe('/verify-magiclink'); + }); + + it('reports a confirmation the server refuses', async () => { + await open('/verify-magiclink?token=t1', { + 'GET /magic-link/verify/t1': confirmationRequired, + 'POST /magic-link/verify/t1/confirm': { status: 400, body: { error: 'Invalid' } }, + }); + + await click('Yes, sign in that device'); + + expect(wrapper.find('[role="alert"]').text()).toContain( + 'We could not confirm this sign-in' + ); + expect(button('Yes, sign in that device').attributes('disabled')).toBeUndefined(); + }); + + it('leaves a link alone when the reader declines', async () => { + const { adapter } = await open('/verify-magiclink?token=t1', { + 'GET /magic-link/verify/t1': confirmationRequired, + }); + + await click("No, this wasn't me"); + + expect(text()).toContain('You can ignore the email'); + expect(adapter.called('POST', '/magic-link/verify/t1/confirm')).toHaveLength(0); + }); + it('reports a link that cannot be verified, or no link at all', async () => { await open('/verify-magiclink?token=t1', { 'GET /magic-link/verify/t1': { status: 400 }, diff --git a/test-support/fakeAdapter.ts b/test-support/fakeAdapter.ts index acbedcb..fa69934 100644 --- a/test-support/fakeAdapter.ts +++ b/test-support/fakeAdapter.ts @@ -85,6 +85,16 @@ export const signedOut: AdapterReply = { body: { error: 'unauthenticated' }, }; +/** A magic link opened on a device other than the one that asked for it. */ +export const confirmationRequired: AdapterReply = { + status: 409, + body: { + error: 'confirmation_required', + message: 'Confirm this sign-in.', + requestedFrom: { deviceClass: 'android', requestedAt: '2026-10-16T14:05:00.000Z' }, + }, +}; + export const flush = () => new Promise(resolve => setTimeout(resolve, 0)); export const passkeyPort = (supported: boolean) => ({