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
16 changes: 16 additions & 0 deletions .changeset/magic-link-confirm-step.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 14 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,14 +357,27 @@ 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)
- `/users/me`
- `/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;
Expand Down
4 changes: 2 additions & 2 deletions packages/angular/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
134 changes: 111 additions & 23 deletions packages/angular/routes/src/verify-magic-link.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,43 +14,98 @@ 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';

/**
* 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: `
<sa-auth-layout>
<div class="sa-center">
<h1 class="sa-heading">Verifying your login</h1>

@if (!outcome() && !error()) {
<div class="sa-spinner" aria-hidden="true"></div>
<p class="sa-helper-text">
Please wait while we securely verify your sign-in link.
</p>
}
@if (outcome() === 'signed-in') {
<p class="sa-success" role="status">Login verified. Redirecting...</p>
}
@if (outcome() === 'elsewhere') {
<p class="sa-success" role="status">
Login verified. Return to the device where you requested this link to
continue.
</p>
}
@if (error()) {
<p class="sa-error" role="alert">{{ error() }}</p>
@if (prompt()) {
<div class="sa-center">
<h1 class="sa-heading">Confirm this sign-in</h1>

@if (decision() === 'pending' || decision() === 'confirming') {
<p class="sa-description">{{ prompt() }}</p>
<p 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.
</p>
}
@if (decision() === 'confirmed') {
<p class="sa-success" role="status">
Sign-in approved. You can return to your other device to continue.
</p>
}
@if (decision() === 'declined') {
<p class="sa-helper-text" role="status">
No one has been signed in. You can ignore the email and close this page.
</p>
}
</div>

@if (decision() === 'pending' || decision() === 'confirming') {
<div class="sa-actions">
<button
type="button"
class="sa-button"
[disabled]="decision() === 'confirming'"
(click)="confirm()"
>
Yes, sign in that device
</button>
<button
type="button"
class="sa-secondary"
[disabled]="decision() === 'confirming'"
(click)="decision.set('declined')"
>
No, this wasn't me
</button>
</div>
@if (confirmError()) {
<p class="sa-error" role="alert">{{ confirmError() }}</p>
}
}
</div>
} @else {
<div class="sa-center">
<h1 class="sa-heading">Verifying your login</h1>

@if (!outcome() && !error()) {
<div class="sa-spinner" aria-hidden="true"></div>
<p class="sa-helper-text">
Please wait while we securely verify your sign-in link.
</p>
}
@if (outcome() === 'signed-in') {
<p class="sa-success" role="status">Login verified. Redirecting...</p>
}
@if (outcome() === 'elsewhere') {
<p class="sa-success" role="status">
Login verified. Return to the device where you requested this link to
continue.
</p>
}
@if (error()) {
<p class="sa-error" role="alert">{{ error() }}</p>
}
</div>
}
</sa-auth-layout>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
Expand All @@ -62,6 +117,33 @@ export class SaVerifyMagicLink {

readonly outcome = signal<MagicLinkOutcome | null>(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;
Expand Down Expand Up @@ -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;
}
Expand Down
46 changes: 46 additions & 0 deletions packages/angular/tests/screens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { provideSeamlessAuth } from '../src';
import { SaOtpInput, seamlessAuthRoutes } from '../routes/src';
import {
confirmationRequired,
createAdapter,
flush,
passkeyPort,
Expand Down Expand Up @@ -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 },
Expand Down
2 changes: 2 additions & 0 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
19 changes: 19 additions & 0 deletions packages/client/src/client/createSeamlessAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,17 @@ export interface SeamlessAuthClient {
*/
requestMagicLink: (redirectUri?: string) => Promise<SeamlessAuthResult<MessageResult>>;
checkMagicLink: () => Promise<SeamlessAuthResult<MessageResult>>;
/**
* 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<SeamlessAuthResult<MessageResult>>;
/**
* 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<SeamlessAuthResult<MessageResult>>;
listOAuthProviders: () => Promise<SeamlessAuthResult<OAuthProvidersResult>>;
getPublicSystemConfig: () => Promise<SeamlessAuthResult<PublicSystemConfigResult>>;
startOAuthLogin: (
Expand Down Expand Up @@ -659,6 +669,15 @@ export const createSeamlessAuthClient = (
'Failed to verify the magic link.'
),

confirmMagicLink: token =>
requestResult<MessageResult>(
fetchWithAuth(`/magic-link/verify/${encodeURIComponent(token)}/confirm`, {
method: 'POST',
body: JSON.stringify({}),
}),
'Failed to confirm the magic link.'
),

listOAuthProviders: () =>
requestResult<OAuthProvidersResult>(
fetchWithAuth(`/oauth/providers`, { method: 'GET' }),
Expand Down
Loading
Loading