From a84ad3dcc0f45ff48f3d974322fae88732399533 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 16 Oct 2026 21:53:57 -0400 Subject: [PATCH 1/4] fix(magic-link): issue one session per link, to the attempt that requested it Bind each magic link to the requesting ephemeral token's jti and redeem it once. Later checks for the same attempt are told the sign-in is complete without receiving another session. --- .changeset/magic-link-attempt-redemption.md | 10 ++ openapi.json | 50 ++++---- src/controllers/magicLinks.ts | 31 ++++- src/generated/api.ts | 48 +++----- ...1016120000-bind-magic-links-to-attempt.cjs | 39 ++++++ src/models/magicLinks.ts | 10 ++ src/routes/magicLink.routes.ts | 4 +- src/schemas/magiclink.responses.ts | 15 ++- tests/integration/magicLink/magicLink.spec.ts | 116 +++++++++++++++++- tests/setup/mocks.ts | 1 + 10 files changed, 264 insertions(+), 60 deletions(-) create mode 100644 .changeset/magic-link-attempt-redemption.md create mode 100644 src/migrations/20261016120000-bind-magic-links-to-attempt.cjs diff --git a/.changeset/magic-link-attempt-redemption.md b/.changeset/magic-link-attempt-redemption.md new file mode 100644 index 0000000..d317b13 --- /dev/null +++ b/.changeset/magic-link-attempt-redemption.md @@ -0,0 +1,10 @@ +--- +'seamless-auth-api': patch +--- + +Magic link sign-in issues one session per confirmed link, to the attempt that requested it (GHSA-9jg2-jjqq-3346). + +- Each link records the `jti` of the ephemeral token that requested it, and `GET /magic-link/check` only looks up links for the polling attempt. Another ephemeral token for the same address cannot collect it. +- The first check after the link is confirmed issues the session and marks the link redeemed. Later checks for the same attempt answer `200 { "message": "Success" }` without tokens, so a second tab in the same browser still sees the sign-in complete and reads the session it already has. +- A request or poll with an ephemeral token that has no `jti` is refused with `400`. +- Migration: `magic_link_tokens` gains `attempt_id` and `redeemed_at`. Links requested before the upgrade can no longer be collected; they expire within fifteen minutes. diff --git a/openapi.json b/openapi.json index 44e9f1e..742b468 100644 --- a/openapi.json +++ b/openapi.json @@ -7360,33 +7360,31 @@ "description": "HTTP 200", "content": { "application/json": { - "example": { - "message": "string", - "token": "string", - "refreshToken": "string", - "sub": "string", - "roles": [null], - "email": "string", - "phone": "string", - "ttl": 0, - "refreshTtl": 0, - "nextStep": null - }, + "example": null, "schema": { - "type": "object", - "properties": { - "message": { "type": "string" }, - "token": { "type": "string" }, - "refreshToken": { "type": "string" }, - "sub": { "type": "string" }, - "roles": { "type": "array", "items": { "type": "string" } }, - "email": { "type": "string" }, - "phone": { "type": "string", "nullable": true }, - "ttl": { "type": "number" }, - "refreshTtl": { "type": "number" }, - "nextStep": { "type": "string", "enum": ["enroll_passkey"] } - }, - "required": ["message"] + "anyOf": [ + { + "type": "object", + "properties": { + "message": { "type": "string" }, + "token": { "type": "string" }, + "refreshToken": { "type": "string" }, + "sub": { "type": "string" }, + "roles": { "type": "array", "items": { "type": "string" } }, + "email": { "type": "string" }, + "phone": { "type": "string", "nullable": true }, + "ttl": { "type": "number" }, + "refreshTtl": { "type": "number" }, + "nextStep": { "type": "string", "enum": ["enroll_passkey"] } + }, + "required": ["message"] + }, + { + "type": "object", + "properties": { "message": { "type": "string", "enum": ["Success"] } }, + "required": ["message"] + } + ] } } } diff --git a/src/controllers/magicLinks.ts b/src/controllers/magicLinks.ts index 1330dcb..9ff6084 100644 --- a/src/controllers/magicLinks.ts +++ b/src/controllers/magicLinks.ts @@ -113,6 +113,13 @@ export async function requestMagicLink(req: MagicLinkRequest, res: Response) { logger.error('Could not identify devive metadata to send a magic link'); return res.status(400).json({ error: 'Invalid device data' }); } + + // The poll collects the session by attempt, so a link without one could never be + // collected. Every ephemeral token minted by a current server carries it. + if (!authReq.attemptId) { + await logMagicLinkFailure(req, 'Ephemeral token has no attempt id', user.id); + return res.status(400).json({ error: 'Invalid request' }); + } // Expire all previous links await MagicLinkToken.update( { expires_at: new Date() }, @@ -129,6 +136,7 @@ export async function requestMagicLink(req: MagicLinkRequest, res: Response) { redirect_url, ip_hash, user_agent_hash, + attempt_id: authReq.attemptId, expires_at: new Date(Date.now() + TTL_MINUTES * 60 * 1000), }); @@ -256,8 +264,17 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) { }); } + if (!authReq.attemptId) { + await logMagicLinkFailure(req, 'Ephemeral token has no attempt id', user.id); + return res.status(400).json({ error: 'Invalid request' }); + } + const record = await MagicLinkToken.findOne({ - where: { user_id: user.id, expires_at: { [Op.gt]: new Date() } }, + where: { + user_id: user.id, + attempt_id: authReq.attemptId, + expires_at: { [Op.gt]: new Date() }, + }, }); if (!record) { @@ -279,6 +296,18 @@ export async function pollMagicLinkConfirmation(req: Request, res: Response) { } if (record.used_at && record.expires_at > new Date()) { + // One session per confirmed link. A second check for the same attempt, such as the + // tab the link opened in when the requesting tab already collected the session, is + // told the link is done without being handed another session. + const [redeemed] = await MagicLinkToken.update( + { redeemed_at: new Date() }, + { where: { id: record.id, redeemed_at: null } }, + ); + + if (!redeemed) { + return res.status(200).json({ message: 'Success' }); + } + await AuthEventService.log({ userId: record.user_id, type: 'magic_link_poll_completed_successfully', diff --git a/src/generated/api.ts b/src/generated/api.ts index b03aee8..b9cb611 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -7301,35 +7301,25 @@ export interface paths { [name: string]: unknown; }; content: { - /** - * @example { - * "message": "string", - * "token": "string", - * "refreshToken": "string", - * "sub": "string", - * "roles": [ - * null - * ], - * "email": "string", - * "phone": "string", - * "ttl": 0, - * "refreshTtl": 0, - * "nextStep": null - * } - */ - 'application/json': { - message: string; - token?: string; - refreshToken?: string; - sub?: string; - roles?: string[]; - email?: string; - phone?: string | null; - ttl?: number; - refreshTtl?: number; - /** @enum {string} */ - nextStep?: 'enroll_passkey'; - }; + /** @example null */ + 'application/json': + | { + message: string; + token?: string; + refreshToken?: string; + sub?: string; + roles?: string[]; + email?: string; + phone?: string | null; + ttl?: number; + refreshTtl?: number; + /** @enum {string} */ + nextStep?: 'enroll_passkey'; + } + | { + /** @enum {string} */ + message: 'Success'; + }; }; }; /** @description HTTP 204 */ diff --git a/src/migrations/20261016120000-bind-magic-links-to-attempt.cjs b/src/migrations/20261016120000-bind-magic-links-to-attempt.cjs new file mode 100644 index 0000000..15923ea --- /dev/null +++ b/src/migrations/20261016120000-bind-magic-links-to-attempt.cjs @@ -0,0 +1,39 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +'use strict'; + +/** + * Ties each magic link to the sign-in attempt that requested it, and records when its + * session was issued so a confirmed link is redeemed once. + * + * Links created before this migration have no attempt and can no longer be collected. + * They live for fifteen minutes, so only links in flight during the upgrade are lost. + * + * @type {import('sequelize-cli').Migration} + */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE public.magic_link_tokens + ADD COLUMN IF NOT EXISTS attempt_id TEXT, + ADD COLUMN IF NOT EXISTS redeemed_at TIMESTAMPTZ; + + CREATE INDEX IF NOT EXISTS idx_magic_link_attempt_id + ON public.magic_link_tokens(attempt_id); + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DROP INDEX IF EXISTS public.idx_magic_link_attempt_id; + + ALTER TABLE public.magic_link_tokens + DROP COLUMN IF EXISTS redeemed_at, + DROP COLUMN IF EXISTS attempt_id; + `); + }, +}; diff --git a/src/models/magicLinks.ts b/src/models/magicLinks.ts index 4416a22..cbc6e63 100644 --- a/src/models/magicLinks.ts +++ b/src/models/magicLinks.ts @@ -25,6 +25,8 @@ export class MagicLinkToken extends Model< declare user_agent_hash: string | null; declare expires_at: Date; declare used_at: Date | null; + declare attempt_id: string | null; + declare redeemed_at: CreationOptional; declare created_at: CreationOptional; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -72,6 +74,14 @@ const initializeMagicTokenModel = (sequelize: Sequelize) => { type: DataTypes.DATE, allowNull: true, }, + attempt_id: { + type: DataTypes.TEXT, + allowNull: true, + }, + redeemed_at: { + type: DataTypes.DATE, + allowNull: true, + }, created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW, diff --git a/src/routes/magicLink.routes.ts b/src/routes/magicLink.routes.ts index 587746a..e56fa23 100644 --- a/src/routes/magicLink.routes.ts +++ b/src/routes/magicLink.routes.ts @@ -17,7 +17,7 @@ import { MagicLinkRequestQuerySchema, MagicLinkVerifyParamsSchema, } from '../schemas/magiclink.requests.js'; -import { MagicLinkPollSuccessSchema } from '../schemas/magiclink.responses.js'; +import { MagicLinkPollResponseSchema } from '../schemas/magiclink.responses.js'; const magicLinkRouter = createRouter('/magic-link'); @@ -81,7 +81,7 @@ magicLinkRouter.get( schemas: { response: { - 200: MagicLinkPollSuccessSchema, + 200: MagicLinkPollResponseSchema, 204: MessageSchema, 403: ErrorSchema, 404: ErrorSchema, diff --git a/src/schemas/magiclink.responses.ts b/src/schemas/magiclink.responses.ts index 80342b0..43318ab 100644 --- a/src/schemas/magiclink.responses.ts +++ b/src/schemas/magiclink.responses.ts @@ -4,4 +4,17 @@ * See LICENSE file in the project root for full license information */ -export { MagicLinkPollSuccessSchema } from '@seamless-auth/types'; +import { MagicLinkPollSuccessSchema } from '@seamless-auth/types'; +import z from 'zod'; + +export { MagicLinkPollSuccessSchema }; + +/** A confirmed link whose session this attempt has already collected. */ +export const MagicLinkPollRedeemedSchema = z.object({ + message: z.literal('Success'), +}); + +export const MagicLinkPollResponseSchema = z.union([ + MagicLinkPollSuccessSchema, + MagicLinkPollRedeemedSchema, +]); diff --git a/tests/integration/magicLink/magicLink.spec.ts b/tests/integration/magicLink/magicLink.spec.ts index 93ff78f..2d87fe6 100644 --- a/tests/integration/magicLink/magicLink.spec.ts +++ b/tests/integration/magicLink/magicLink.spec.ts @@ -10,7 +10,11 @@ import { Session } from '../../../src/models/sessions.js'; import { createApp } from '../../../src/app.js'; import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; -import { verifyMagicLink } from '../../../src/controllers/magicLinks.js'; +import { + pollMagicLinkConfirmation, + requestMagicLink, + verifyMagicLink, +} from '../../../src/controllers/magicLinks.js'; import { createRefreshTokenLookup, generateRefreshToken, @@ -472,6 +476,7 @@ it('creates session when magic link completed', async () => { }), ); + (MagicLinkToken.update as any).mockResolvedValue([1]); (Session.create as any).mockResolvedValue({ id: 'session-1' }); (generateRefreshToken as any).mockReturnValue('refresh-token'); @@ -494,6 +499,115 @@ it('creates session when magic link completed', async () => { ); }); +describe('magic link attempt binding and redemption', () => { + const completedUser = () => ({ + id: 'user-1', + email: 'test@example.com', + roles: ['user'], + save: vi.fn(), + update: vi.fn(), + }); + + it('stores the requesting attempt on the link', async () => { + (sendMagicLinkEmail as any).mockResolvedValue(undefined); + (MagicLinkToken.update as any).mockResolvedValue([1]); + (MagicLinkToken.create as any).mockResolvedValue({ id: 'link-1' }); + + const res = await request(app).get('/magic-link'); + + expect(res.status).toBe(200); + expect(MagicLinkToken.create).toHaveBeenCalledWith( + expect.objectContaining({ attempt_id: 'attempt-1' }), + ); + }); + + it('looks the link up by the polling attempt, not only by user', async () => { + (MagicLinkToken.findOne as any).mockResolvedValue(null); + + const res = await request(app).get('/magic-link/check'); + + expect(res.status).toBe(204); + expect(MagicLinkToken.findOne).toHaveBeenCalledWith({ + where: expect.objectContaining({ user_id: 'user-1', attempt_id: 'attempt-1' }), + }); + }); + + it('issues the session once and answers later checks without one', async () => { + (User.findOne as any).mockResolvedValue(completedUser()); + (MagicLinkToken.findOne as any).mockResolvedValue(buildMagicLink({ used_at: new Date() })); + (Session.create as any).mockResolvedValue({ id: 'session-1' }); + (generateRefreshToken as any).mockReturnValue('refresh-token'); + (createRefreshTokenLookup as any).mockReturnValue('refresh-lookup'); + (signAccessToken as any).mockResolvedValue('access-token'); + + (MagicLinkToken.update as any).mockResolvedValueOnce([1]); + const first = await request(app).get('/magic-link/check'); + + (MagicLinkToken.update as any).mockResolvedValueOnce([0]); + const second = await request(app).get('/magic-link/check'); + + expect(first.status).toBe(200); + expect(first.body.token).toBe('access-token'); + expect(second.status).toBe(200); + expect(second.body).toEqual({ message: 'Success' }); + expect(Session.create).toHaveBeenCalledTimes(1); + expect(MagicLinkToken.update).toHaveBeenCalledWith( + { redeemed_at: expect.any(Date) }, + { where: { id: 'link-1', redeemed_at: null } }, + ); + }); +}); + +describe('magic link without an attempt id', () => { + const directReq = () => + ({ + user: { id: 'user-1', email: 'test@example.com' }, + ip: '127.0.0.1', + headers: { 'user-agent': 'test-agent' }, + query: {}, + get: () => undefined, + }) as any; + + const directRes = () => { + const res: any = {}; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + res.end = vi.fn().mockReturnValue(res); + return res; + }; + + it('refuses to create a link that no poll could collect', async () => { + const res = directRes(); + + await requestMagicLink(directReq(), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'magic_link_failed', + metadata: { reason: 'Ephemeral token has no attempt id' }, + }), + ); + expect(MagicLinkToken.create).not.toHaveBeenCalled(); + }); + + it('refuses to poll without an attempt to match', async () => { + const res = directRes(); + + await pollMagicLinkConfirmation(directReq(), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'magic_link_failed', + metadata: { reason: 'Ephemeral token has no attempt id' }, + }), + ); + expect(MagicLinkToken.findOne).not.toHaveBeenCalled(); + expect(Session.create).not.toHaveBeenCalled(); + }); +}); + describe('magic link full sign-in sequence (regression)', () => { it('request -> poll(waiting) -> verify -> poll issues a session', async () => { const user = { diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index b3e023f..cbec88f 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -194,6 +194,7 @@ vi.mock('../../src/middleware/attachAuthMiddleware.js', async (importOriginal) = }; req.sessionId = 'session-1'; + req.attemptId = 'attempt-1'; next(); }, }; From 31ef9ff28713aff567833332e06ba7049a409e40 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 16 Oct 2026 22:08:01 -0400 Subject: [PATCH 2/4] fix(magic-link): ask for confirmation when a link is opened on another device Verify consumes the link only on the requesting device, proven by the attempt's ephemeral token when the adapter forwards it, otherwise by IP and user agent. Elsewhere it answers 409 confirmation_required without consuming the link, and POST /magic-link/verify/:token/confirm consumes it once the person who opened it approves. --- .changeset/magic-link-attempt-redemption.md | 11 +- adapter-manifest.json | 1 + openapi.json | 192 +++++++++++++++++ src/controllers/magicLinks.ts | 109 ++++++++-- src/generated/api.ts | 204 +++++++++++++++++- ...1016120000-bind-magic-links-to-attempt.cjs | 7 +- src/models/magicLinks.ts | 5 + src/routes/magicLink.routes.ts | 36 +++- src/schemas/authEvent.types.ts | 1 + src/schemas/magiclink.responses.ts | 15 ++ tests/integration/magicLink/magicLink.spec.ts | 187 ++++++++++++++-- 11 files changed, 727 insertions(+), 41 deletions(-) diff --git a/.changeset/magic-link-attempt-redemption.md b/.changeset/magic-link-attempt-redemption.md index d317b13..afa9542 100644 --- a/.changeset/magic-link-attempt-redemption.md +++ b/.changeset/magic-link-attempt-redemption.md @@ -1,10 +1,15 @@ --- -'seamless-auth-api': patch +'seamless-auth-api': minor --- -Magic link sign-in issues one session per confirmed link, to the attempt that requested it (GHSA-9jg2-jjqq-3346). +Magic link sign-in issues one session per confirmed link, to the attempt that requested it, and a link opened on another device needs the person who opened it to confirm (GHSA-9jg2-jjqq-3346). - Each link records the `jti` of the ephemeral token that requested it, and `GET /magic-link/check` only looks up links for the polling attempt. Another ephemeral token for the same address cannot collect it. - The first check after the link is confirmed issues the session and marks the link redeemed. Later checks for the same attempt answer `200 { "message": "Success" }` without tokens, so a second tab in the same browser still sees the sign-in complete and reads the session it already has. +- `GET /magic-link/verify/{token}` consumes the link only when it is opened on the requesting device: proven by the requesting attempt's ephemeral bearer token when the adapter forwards it, otherwise by IP address and user agent. Opened anywhere else it answers `409 confirmation_required` with the requesting platform and time, and leaves the link unused, so a mail scanner or a click on a link someone else requested no longer signs that someone in. +- New `POST /magic-link/verify/{token}/confirm` consumes the link after the person who opened it approves. +- New auth event `magic_link_confirmation_required`. - A request or poll with an ephemeral token that has no `jti` is refused with `400`. -- Migration: `magic_link_tokens` gains `attempt_id` and `redeemed_at`. Links requested before the upgrade can no longer be collected; they expire within fifteen minutes. +- Migration: `magic_link_tokens` gains `attempt_id`, `requested_device_class` and `redeemed_at`. Links requested before the upgrade can no longer be used; they expire within fifteen minutes. + +Breaking for clients: a link opened on a different device now needs a confirmation screen. SDK versions without one show the 409 as an error, and the link stays usable for the requesting device's own browser. diff --git a/adapter-manifest.json b/adapter-manifest.json index 29e0240..936bc31 100644 --- a/adapter-manifest.json +++ b/adapter-manifest.json @@ -98,6 +98,7 @@ { "method": "POST", "path": "/magic-link", "credential": "preAuth", "delivery": true }, { "method": "GET", "path": "/magic-link/check", "credential": "preAuth", "issues": "session" }, { "method": "GET", "path": "/magic-link/verify/{token}", "credential": "none" }, + { "method": "POST", "path": "/magic-link/verify/{token}/confirm", "credential": "none" }, { "method": "POST", "path": "/oauth/{providerId}/callback", diff --git a/openapi.json b/openapi.json index 742b468..b710ed0 100644 --- a/openapi.json +++ b/openapi.json @@ -3454,6 +3454,7 @@ "logout_failed", "logout_success", "logout_suspicious", + "magic_link_confirmation_required", "magic_link_poll_completed_successfully", "magic_link_failed", "magic_link_requested", @@ -3532,6 +3533,7 @@ "logout_failed", "logout_success", "logout_suspicious", + "magic_link_confirmation_required", "magic_link_poll_completed_successfully", "magic_link_failed", "magic_link_requested", @@ -7504,6 +7506,196 @@ "/magic-link/verify/{token}": { "get": { "summary": "Verify magic link token", + "description": "Consumes the link when it is opened on the device that requested it, proven by the requesting attempt's ephemeral bearer token when the adapter forwards it, otherwise by IP address and user agent. Opened anywhere else, answers 409 without consuming it; confirm with POST /magic-link/verify/{token}/confirm.", + "tags": ["MagicLinks"], + "parameters": [ + { "schema": { "type": "string" }, "required": true, "name": "token", "in": "path" } + ], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "message": "string", "token": "string", "delivery": null }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "token": { "type": "string" }, + "delivery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_sms"] }, + "to": { "type": "string" }, + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["magic_link_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" }, + "magicLinkUrl": { "type": "string" } + }, + "required": ["kind", "to", "magicLinkUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["enrollment_invite_email"] }, + "to": { "type": "string" }, + "signInUrl": { "type": "string" } + }, + "required": ["kind", "to", "signInUrl"] + } + ] + } + }, + "required": ["message"] + } + } + } + }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { + "error": "string", + "message": "string", + "details": { "issues": [null] } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "message": { "type": "string" }, + "details": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["path", "code", "message"] + } + } + }, + "required": ["issues"] + } + }, + "required": ["error"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "409": { + "description": "HTTP 409", + "content": { + "application/json": { + "example": { + "error": null, + "message": "string", + "requestedFrom": { "deviceClass": null, "requestedAt": "string" } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string", "enum": ["confirmation_required"] }, + "message": { "type": "string" }, + "requestedFrom": { + "type": "object", + "properties": { + "deviceClass": { + "type": "string", + "enum": [ + "ios", + "android", + "macos", + "windows", + "linux", + "chromeos", + "bot", + "unknown" + ] + }, + "requestedAt": { "type": "string" } + }, + "required": ["deviceClass", "requestedAt"] + } + }, + "required": ["error", "message", "requestedFrom"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, + "/magic-link/verify/{token}/confirm": { + "post": { + "summary": "Confirm a magic link opened on another device", + "description": "Consumes the link after the person who opened it has approved signing in the device that requested it.", "tags": ["MagicLinks"], "parameters": [ { "schema": { "type": "string" }, "required": true, "name": "token", "in": "path" } diff --git a/src/controllers/magicLinks.ts b/src/controllers/magicLinks.ts index 9ff6084..5c6963a 100644 --- a/src/controllers/magicLinks.ts +++ b/src/controllers/magicLinks.ts @@ -9,6 +9,7 @@ import { Request, Response } from 'express'; import { Op } from 'sequelize'; import { z } from 'zod'; +import { classifyDeviceClass } from '../lib/deviceClass.js'; import { canReturnExternalDelivery } from '../lib/externalDelivery.js'; import { MagicLinkToken } from '../models/magicLinks.js'; import { User } from '../models/users.js'; @@ -26,6 +27,7 @@ import { } from '../services/magicLinkRedirect.js'; import { sendMagicLinkEmail } from '../services/messagingService.js'; import { issueSessionAndRespond } from '../services/sessionIssuance.js'; +import { verifyJwtWithKid } from '../services/sessionService.js'; import { invalidateChallengesForUser } from '../services/webauthnChallengeService.js'; import { AuthenticatedRequest, RouteRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; @@ -137,6 +139,7 @@ export async function requestMagicLink(req: MagicLinkRequest, res: Response) { ip_hash, user_agent_hash, attempt_id: authReq.attemptId, + requested_device_class: classifyDeviceClass(req.headers['user-agent']), expires_at: new Date(Date.now() + TTL_MINUTES * 60 * 1000), }); @@ -181,42 +184,53 @@ export async function requestMagicLink(req: MagicLinkRequest, res: Response) { }); } -export async function verifyMagicLink(req: RouteRequest, res: Response) { - logger.debug('Verifying magic link'); +async function findUsableLink(req: RouteRequest, res: Response) { const { token } = req.params; if (await rejectDisabledMagicLink(req, res)) { - return; + return null; } if (!token) { - return res.status(400).json({ error: 'Missing verification token' }); + res.status(400).json({ error: 'Missing verification token' }); + return null; } - const tokenHash = hashSha256(token); const record = await MagicLinkToken.findOne({ - where: { token_hash: tokenHash }, + where: { token_hash: hashSha256(token) }, }); if (!record) { logger.warn('No magic link found for supplied token'); await logMagicLinkFailure(req, 'Invalid verification token'); - return res.status(400).json({ error: 'Invalid verification token' }); + res.status(400).json({ error: 'Invalid verification token' }); + return null; } if (record.used_at) { logger.warn('Magic link token is already used'); await logMagicLinkFailure(req, 'Token already used', record.user_id); - return res.status(400).json({ error: 'Invalid verification token' }); + res.status(400).json({ error: 'Invalid verification token' }); + return null; } - if (record.expires_at < new Date()) { + // A link from before attempt binding has no poll that could collect it. + if (record.expires_at < new Date() || !record.attempt_id) { logger.warn('Magic link token expired'); await logMagicLinkFailure(req, 'Token expired', record.user_id); - return res.status(400).json({ error: 'Invalid verification token' }); + res.status(400).json({ error: 'Invalid verification token' }); + return null; } - // Atomic consume + return record; +} + +async function consumeLink( + req: RouteRequest, + res: Response, + record: MagicLinkToken, + confirmation: 'same_device' | 'confirmed', +) { logger.info('Magic link being consumed'); const [updated] = await MagicLinkToken.update( @@ -239,15 +253,80 @@ export async function verifyMagicLink(req: RouteRequest, res: Response) { userId: record.user_id, type: 'magic_link_success', req, - metadata: { reason: 'Magic link token consumed' }, + metadata: { reason: 'Magic link token consumed', confirmation }, }); - // Device binding is enforced at the poll step (pollMagicLinkConfirmation), where the - // session is actually issued. A magic link may legitimately be opened on a different - // device than the one that requested it, so verification must not gate on the device. return res.status(200).json({ message: 'Success' }); } +/** + * Whether the request that opened the link comes from the device that asked for it. + * + * The session goes to the requesting device, so a link opened anywhere else must not + * complete silently: anyone can request a link for someone else's address from their + * own machine. The pre-auth token, when the adapter forwards it, proves the attempt. + * Without it the IP and user agent are the only signal, which people behind one + * address with the same browser can share. + */ +async function openedOnRequestingDevice(req: RouteRequest, record: MagicLinkToken) { + const auth = req.headers.authorization; + + if (auth?.startsWith('Bearer ')) { + const payload = await verifyJwtWithKid(auth.slice(7), 'ephemeral').catch(() => null); + + if (payload) { + return payload.jti === record.attempt_id; + } + } + + const { ip_hash, user_agent_hash } = hashDeviceFingerprint(req.ip, req.headers['user-agent']); + + return ( + Boolean(record.ip_hash && record.user_agent_hash) && + record.ip_hash === ip_hash && + record.user_agent_hash === user_agent_hash + ); +} + +export async function verifyMagicLink(req: RouteRequest, res: Response) { + logger.debug('Verifying magic link'); + + const record = await findUsableLink(req, res); + + if (!record) { + return; + } + + if (!(await openedOnRequestingDevice(req, record))) { + await AuthEventService.log({ + userId: record.user_id, + type: 'magic_link_confirmation_required', + req, + }); + + return res.status(409).json({ + error: 'confirmation_required', + message: 'This link was requested from another device. Confirm to sign that device in.', + requestedFrom: { + deviceClass: record.requested_device_class ?? 'unknown', + requestedAt: new Date(record.created_at).toISOString(), + }, + }); + } + + return consumeLink(req, res, record, 'same_device'); +} + +export async function confirmMagicLink(req: RouteRequest, res: Response) { + const record = await findUsableLink(req, res); + + if (!record) { + return; + } + + return consumeLink(req, res, record, 'confirmed'); +} + export async function pollMagicLinkConfirmation(req: Request, res: Response) { const authReq = req as AuthenticatedRequest; const preAuthUser = authReq.user; diff --git a/src/generated/api.ts b/src/generated/api.ts index b9cb611..909d777 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -3146,6 +3146,7 @@ export interface paths { | 'logout_failed' | 'logout_success' | 'logout_suspicious' + | 'magic_link_confirmation_required' | 'magic_link_poll_completed_successfully' | 'magic_link_failed' | 'magic_link_requested' @@ -3218,6 +3219,7 @@ export interface paths { | 'logout_failed' | 'logout_success' | 'logout_suspicious' + | 'magic_link_confirmation_required' | 'magic_link_poll_completed_successfully' | 'magic_link_failed' | 'magic_link_requested' @@ -7456,7 +7458,10 @@ export interface paths { path?: never; cookie?: never; }; - /** Verify magic link token */ + /** + * Verify magic link token + * @description Consumes the link when it is opened on the device that requested it, proven by the requesting attempt's ephemeral bearer token when the adapter forwards it, otherwise by IP address and user agent. Opened anywhere else, answers 409 without consuming it; confirm with POST /magic-link/verify/{token}/confirm. + */ get: { parameters: { query?: never; @@ -7561,6 +7566,42 @@ export interface paths { }; }; }; + /** @description HTTP 409 */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": null, + * "message": "string", + * "requestedFrom": { + * "deviceClass": null, + * "requestedAt": "string" + * } + * } + */ + 'application/json': { + /** @enum {string} */ + error: 'confirmation_required'; + message: string; + requestedFrom: { + /** @enum {string} */ + deviceClass: + | 'ios' + | 'android' + | 'macos' + | 'windows' + | 'linux' + | 'chromeos' + | 'bot' + | 'unknown'; + requestedAt: string; + }; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -7607,6 +7648,167 @@ export interface paths { patch?: never; trace?: never; }; + '/magic-link/verify/{token}/confirm': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Confirm a magic link opened on another device + * @description Consumes the link after the person who opened it has approved signing in the device that requested it. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + token: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "token": "string", + * "delivery": null + * } + */ + 'application/json': { + message: string; + token?: string; + delivery?: + | { + /** @enum {string} */ + kind: 'otp_email'; + to: string; + token: string; + } + | { + /** @enum {string} */ + kind: 'otp_sms'; + to: string; + token: string | number; + } + | { + /** @enum {string} */ + kind: 'magic_link_email'; + to: string; + token?: string; + magicLinkUrl: string; + } + | { + /** @enum {string} */ + kind: 'enrollment_invite_email'; + to: string; + signInUrl: string; + }; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/oauth/providers': { parameters: { query?: never; diff --git a/src/migrations/20261016120000-bind-magic-links-to-attempt.cjs b/src/migrations/20261016120000-bind-magic-links-to-attempt.cjs index 15923ea..e6e1dcc 100644 --- a/src/migrations/20261016120000-bind-magic-links-to-attempt.cjs +++ b/src/migrations/20261016120000-bind-magic-links-to-attempt.cjs @@ -7,8 +7,9 @@ 'use strict'; /** - * Ties each magic link to the sign-in attempt that requested it, and records when its - * session was issued so a confirmed link is redeemed once. + * Ties each magic link to the sign-in attempt that requested it, records the platform it + * was requested from so a link opened elsewhere can say which device is asking, and + * records when its session was issued so a confirmed link is redeemed once. * * Links created before this migration have no attempt and can no longer be collected. * They live for fifteen minutes, so only links in flight during the upgrade are lost. @@ -20,6 +21,7 @@ module.exports = { await queryInterface.sequelize.query(` ALTER TABLE public.magic_link_tokens ADD COLUMN IF NOT EXISTS attempt_id TEXT, + ADD COLUMN IF NOT EXISTS requested_device_class TEXT, ADD COLUMN IF NOT EXISTS redeemed_at TIMESTAMPTZ; CREATE INDEX IF NOT EXISTS idx_magic_link_attempt_id @@ -33,6 +35,7 @@ module.exports = { ALTER TABLE public.magic_link_tokens DROP COLUMN IF EXISTS redeemed_at, + DROP COLUMN IF EXISTS requested_device_class, DROP COLUMN IF EXISTS attempt_id; `); }, diff --git a/src/models/magicLinks.ts b/src/models/magicLinks.ts index cbc6e63..8df558e 100644 --- a/src/models/magicLinks.ts +++ b/src/models/magicLinks.ts @@ -26,6 +26,7 @@ export class MagicLinkToken extends Model< declare expires_at: Date; declare used_at: Date | null; declare attempt_id: string | null; + declare requested_device_class: string | null; declare redeemed_at: CreationOptional; declare created_at: CreationOptional; @@ -78,6 +79,10 @@ const initializeMagicTokenModel = (sequelize: Sequelize) => { type: DataTypes.TEXT, allowNull: true, }, + requested_device_class: { + type: DataTypes.TEXT, + allowNull: true, + }, redeemed_at: { type: DataTypes.DATE, allowNull: true, diff --git a/src/routes/magicLink.routes.ts b/src/routes/magicLink.routes.ts index e56fa23..e33c649 100644 --- a/src/routes/magicLink.routes.ts +++ b/src/routes/magicLink.routes.ts @@ -6,6 +6,7 @@ import { decoyPollMagicLink, decoyRequestMagicLink } from '../controllers/decoyResponders.js'; import { + confirmMagicLink, pollMagicLinkConfirmation, requestMagicLink, verifyMagicLink, @@ -17,7 +18,10 @@ import { MagicLinkRequestQuerySchema, MagicLinkVerifyParamsSchema, } from '../schemas/magiclink.requests.js'; -import { MagicLinkPollResponseSchema } from '../schemas/magiclink.responses.js'; +import { + MagicLinkConfirmationRequiredSchema, + MagicLinkPollResponseSchema, +} from '../schemas/magiclink.responses.js'; const magicLinkRouter = createRouter('/magic-link'); @@ -97,6 +101,11 @@ magicLinkRouter.get( { adapter: {}, summary: 'Verify magic link token', + description: + 'Consumes the link when it is opened on the device that requested it, proven by the ' + + "requesting attempt's ephemeral bearer token when the adapter forwards it, otherwise by " + + 'IP address and user agent. Opened anywhere else, answers 409 without consuming it; ' + + 'confirm with POST /magic-link/verify/{token}/confirm.', tags: ['MagicLinks'], schemas: { @@ -106,6 +115,7 @@ magicLinkRouter.get( 200: MessageSchema, 403: ErrorSchema, 400: ErrorSchema, + 409: MagicLinkConfirmationRequiredSchema, 500: InternalErrorSchema, }, }, @@ -113,4 +123,28 @@ magicLinkRouter.get( verifyMagicLink, ); +magicLinkRouter.post( + '/verify/:token/confirm', + { + adapter: {}, + summary: 'Confirm a magic link opened on another device', + description: + 'Consumes the link after the person who opened it has approved signing in the device ' + + 'that requested it.', + tags: ['MagicLinks'], + + schemas: { + params: MagicLinkVerifyParamsSchema, + + response: { + 200: MessageSchema, + 403: ErrorSchema, + 400: ErrorSchema, + 500: InternalErrorSchema, + }, + }, + }, + confirmMagicLink, +); + export default magicLinkRouter.router; diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts index a608d89..fcbadb8 100644 --- a/src/schemas/authEvent.types.ts +++ b/src/schemas/authEvent.types.ts @@ -28,6 +28,7 @@ export const AUTH_EVENT_TYPES = [ 'logout_failed', 'logout_success', 'logout_suspicious', + 'magic_link_confirmation_required', 'magic_link_poll_completed_successfully', 'magic_link_failed', 'magic_link_requested', diff --git a/src/schemas/magiclink.responses.ts b/src/schemas/magiclink.responses.ts index 43318ab..f6de223 100644 --- a/src/schemas/magiclink.responses.ts +++ b/src/schemas/magiclink.responses.ts @@ -7,6 +7,8 @@ import { MagicLinkPollSuccessSchema } from '@seamless-auth/types'; import z from 'zod'; +import { DEVICE_CLASSES } from '../lib/deviceClass.js'; + export { MagicLinkPollSuccessSchema }; /** A confirmed link whose session this attempt has already collected. */ @@ -18,3 +20,16 @@ export const MagicLinkPollResponseSchema = z.union([ MagicLinkPollSuccessSchema, MagicLinkPollRedeemedSchema, ]); + +/** + * A link opened on a device other than the one that asked for it. The link is still + * unused; confirming it from this device signs the requesting device in. + */ +export const MagicLinkConfirmationRequiredSchema = z.object({ + error: z.literal('confirmation_required'), + message: z.string(), + requestedFrom: z.object({ + deviceClass: z.enum(DEVICE_CLASSES), + requestedAt: z.string(), + }), +}); diff --git a/tests/integration/magicLink/magicLink.spec.ts b/tests/integration/magicLink/magicLink.spec.ts index 2d87fe6..080271a 100644 --- a/tests/integration/magicLink/magicLink.spec.ts +++ b/tests/integration/magicLink/magicLink.spec.ts @@ -13,6 +13,7 @@ import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; import { pollMagicLinkConfirmation, requestMagicLink, + confirmMagicLink, verifyMagicLink, } from '../../../src/controllers/magicLinks.js'; import { @@ -21,6 +22,7 @@ import { signAccessToken, } from '../../../src/lib/token.js'; import { AuthEventService } from '../../../src/services/authEventService.js'; +import { verifyJwtWithKid } from '../../../src/services/sessionService.js'; import { sendMagicLinkEmail } from '../../../src/services/messagingService.js'; import { hashDeviceFingerprint } from '../../../src/utils/utils.js'; @@ -35,16 +37,40 @@ function buildMagicLink(overrides: any = {}) { expires_at: new Date(Date.now() + 100000), ip_hash: 'ip', user_agent_hash: 'ua', + attempt_id: 'attempt-1', + requested_device_class: 'macos', + created_at: new Date('2026-10-16T12:00:00.000Z'), ...overrides, }; } +function directRes() { + const res: any = {}; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + res.end = vi.fn().mockReturnValue(res); + return res; +} + +function directVerifyReq(opts: { ip: string; userAgent: string; bearer?: string }) { + return { + params: { token: 'token' }, + ip: opts.ip, + headers: { + 'user-agent': opts.userAgent, + ...(opts.bearer ? { authorization: `Bearer ${opts.bearer}` } : {}), + }, + get: () => undefined, + } as any; +} + beforeAll(async () => { app = await createApp(); }); beforeEach(() => { vi.clearAllMocks(); + (verifyJwtWithKid as any).mockReset(); (User.findOne as any).mockResolvedValue({ id: 'user-1', @@ -290,31 +316,105 @@ describe('GET /magic-link/verify/:token', () => { expect(res.status).toBe(400); }); - it('accepts valid token', async () => { + it('accepts a valid token opened by the requesting attempt', async () => { (MagicLinkToken.findOne as any).mockResolvedValue(buildMagicLink()); - (MagicLinkToken.update as any).mockResolvedValue([1]); + (verifyJwtWithKid as any).mockResolvedValue({ jti: 'attempt-1' }); - const res = await request(app).get('/magic-link/verify/token'); + const res = await request(app) + .get('/magic-link/verify/token') + .set('Authorization', 'Bearer pre-auth'); expect(res.status).toBe(200); + expect(verifyJwtWithKid).toHaveBeenCalledWith('pre-auth', 'ephemeral'); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'magic_link_success', + metadata: { reason: 'Magic link token consumed', confirmation: 'same_device' }, + }), + ); }); - it('accepts a valid token even when opened on a different device', async () => { - // The email link may legitimately be opened on a different device than the one - // that requested it. Device binding is enforced at the poll step, not here — so - // verify must remain device-agnostic. Guards against re-adding a device gate here. + it('asks for confirmation when opened on another device, without consuming the link', async () => { + // Anyone can request a link for someone else's address from their own machine, and + // the session goes to the requesting device. So a link opened anywhere else must + // not complete without the person who opened it approving. (MagicLinkToken.findOne as any).mockResolvedValue( buildMagicLink({ ip_hash: 'a-different-device', user_agent_hash: 'another-ua' }), ); - (MagicLinkToken.update as any).mockResolvedValue([1]); const res = await request(app).get('/magic-link/verify/token'); - expect(res.status).toBe(200); + expect(res.status).toBe(409); + expect(res.body).toEqual({ + error: 'confirmation_required', + message: expect.any(String), + requestedFrom: { deviceClass: 'macos', requestedAt: '2026-10-16T12:00:00.000Z' }, + }); + expect(MagicLinkToken.update).not.toHaveBeenCalled(); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', type: 'magic_link_confirmation_required' }), + ); + }); + + it('asks for confirmation when the bearer belongs to another attempt, even on a matching fingerprint', async () => { + // hashDeviceFingerprint is mocked to the hashes buildMagicLink uses, so this link + // matches the request's fingerprint. + (MagicLinkToken.findOne as any).mockResolvedValue(buildMagicLink()); + (verifyJwtWithKid as any).mockResolvedValue({ jti: 'attempt-2' }); + const res = directRes(); + + await verifyMagicLink( + directVerifyReq({ ip: '203.0.113.7', userAgent: 'same-browser', bearer: 'other' }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(409); + expect(MagicLinkToken.update).not.toHaveBeenCalled(); + }); + + it('falls back to the IP and user agent when no valid bearer is forwarded', async () => { + // hashDeviceFingerprint is mocked to the hashes buildMagicLink uses, so this link + // matches the request's fingerprint. + (MagicLinkToken.findOne as any).mockResolvedValue(buildMagicLink()); + (MagicLinkToken.update as any).mockResolvedValue([1]); + (verifyJwtWithKid as any).mockResolvedValue(null); + const res = directRes(); + + await verifyMagicLink( + directVerifyReq({ ip: '203.0.113.7', userAgent: 'same-browser', bearer: 'expired' }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(200); expect(MagicLinkToken.update).toHaveBeenCalled(); }); + it('treats a bearer that fails to verify like no bearer at all', async () => { + (MagicLinkToken.findOne as any).mockResolvedValue( + buildMagicLink({ ip_hash: 'a-different-device', user_agent_hash: 'another-ua' }), + ); + (verifyJwtWithKid as any).mockRejectedValue(new Error('bad signature')); + const res = directRes(); + + await verifyMagicLink( + directVerifyReq({ ip: '203.0.113.7', userAgent: 'other-browser', bearer: 'forged' }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(409); + expect(MagicLinkToken.update).not.toHaveBeenCalled(); + }); + + it('refuses a link created before attempt binding', async () => { + (MagicLinkToken.findOne as any).mockResolvedValue(buildMagicLink({ attempt_id: null })); + + const res = await request(app).get('/magic-link/verify/token'); + + expect(res.status).toBe(400); + expect(MagicLinkToken.update).not.toHaveBeenCalled(); + }); + it('rejects verification when the token param is empty (direct invocation)', async () => { const res: any = {}; res.status = vi.fn().mockReturnValue(res); @@ -342,8 +442,11 @@ describe('GET /magic-link/verify/:token', () => { it('returns 500 when the token cannot be atomically consumed', async () => { (MagicLinkToken.findOne as any).mockResolvedValue(buildMagicLink()); (MagicLinkToken.update as any).mockResolvedValue([0]); + (verifyJwtWithKid as any).mockResolvedValue({ jti: 'attempt-1' }); - const res = await request(app).get('/magic-link/verify/token'); + const res = await request(app) + .get('/magic-link/verify/token') + .set('Authorization', 'Bearer pre-auth'); expect(res.status).toBe(500); expect(res.body.error).toBe('Failed to use token'); @@ -356,6 +459,57 @@ describe('GET /magic-link/verify/:token', () => { }); }); +describe('POST /magic-link/verify/:token/confirm', () => { + it('consumes a link opened on another device once the person approves', async () => { + (MagicLinkToken.findOne as any).mockResolvedValue( + buildMagicLink({ ip_hash: 'a-different-device', user_agent_hash: 'another-ua' }), + ); + (MagicLinkToken.update as any).mockResolvedValue([1]); + + const res = await request(app).post('/magic-link/verify/token/confirm'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ message: 'Success' }); + expect(MagicLinkToken.update).toHaveBeenCalledWith( + { used_at: expect.any(Date) }, + { where: { id: 'link-1', used_at: null } }, + ); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'magic_link_success', + metadata: { reason: 'Magic link token consumed', confirmation: 'confirmed' }, + }), + ); + }); + + it('refuses a link that is already used', async () => { + (MagicLinkToken.findOne as any).mockResolvedValue(buildMagicLink({ used_at: new Date() })); + + const res = await request(app).post('/magic-link/verify/token/confirm'); + + expect(res.status).toBe(400); + expect(MagicLinkToken.update).not.toHaveBeenCalled(); + }); + + it('refuses an expired link', async () => { + (MagicLinkToken.findOne as any).mockResolvedValue( + buildMagicLink({ expires_at: new Date(Date.now() - 1000) }), + ); + + const res = await request(app).post('/magic-link/verify/token/confirm'); + + expect(res.status).toBe(400); + }); + + it('rejects a missing token (direct invocation)', async () => { + const res = directRes(); + + await confirmMagicLink({ params: {} } as any, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); +}); + describe('GET /magic-link/check', () => { it('returns 400 when user not found', async () => { (User.findOne as any).mockResolvedValue(null); @@ -568,14 +722,6 @@ describe('magic link without an attempt id', () => { get: () => undefined, }) as any; - const directRes = () => { - const res: any = {}; - res.status = vi.fn().mockReturnValue(res); - res.json = vi.fn().mockReturnValue(res); - res.end = vi.fn().mockReturnValue(res); - return res; - }; - it('refuses to create a link that no poll could collect', async () => { const res = directRes(); @@ -636,7 +782,10 @@ describe('magic link full sign-in sequence (regression)', () => { // 3) Verify the token (marks it used) (MagicLinkToken.findOne as any).mockResolvedValue(buildMagicLink({ used_at: null })); (MagicLinkToken.update as any).mockResolvedValue([1]); - const verifyRes = await request(app).get('/magic-link/verify/token'); + (verifyJwtWithKid as any).mockResolvedValue({ jti: 'attempt-1' }); + const verifyRes = await request(app) + .get('/magic-link/verify/token') + .set('Authorization', 'Bearer pre-auth'); expect(verifyRes.status).toBe(200); // 4) Poll after verification -> session issued From 630bd11b17e313ae0e549d212cee88b90bc4349b Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 16 Oct 2026 22:14:49 -0400 Subject: [PATCH 3/4] fix(magic-link): count a device fingerprint only when a trusted adapter forwarded it --- .changeset/magic-link-attempt-redemption.md | 2 +- openapi.json | 2 +- src/controllers/magicLinks.ts | 8 ++++++- src/generated/api.ts | 2 +- src/routes/magicLink.routes.ts | 3 ++- tests/integration/magicLink/magicLink.spec.ts | 23 ++++++++++++++++++- 6 files changed, 34 insertions(+), 6 deletions(-) diff --git a/.changeset/magic-link-attempt-redemption.md b/.changeset/magic-link-attempt-redemption.md index afa9542..c72cfc6 100644 --- a/.changeset/magic-link-attempt-redemption.md +++ b/.changeset/magic-link-attempt-redemption.md @@ -6,7 +6,7 @@ Magic link sign-in issues one session per confirmed link, to the attempt that re - Each link records the `jti` of the ephemeral token that requested it, and `GET /magic-link/check` only looks up links for the polling attempt. Another ephemeral token for the same address cannot collect it. - The first check after the link is confirmed issues the session and marks the link redeemed. Later checks for the same attempt answer `200 { "message": "Success" }` without tokens, so a second tab in the same browser still sees the sign-in complete and reads the session it already has. -- `GET /magic-link/verify/{token}` consumes the link only when it is opened on the requesting device: proven by the requesting attempt's ephemeral bearer token when the adapter forwards it, otherwise by IP address and user agent. Opened anywhere else it answers `409 confirmation_required` with the requesting platform and time, and leaves the link unused, so a mail scanner or a click on a link someone else requested no longer signs that someone in. +- `GET /magic-link/verify/{token}` consumes the link only when it is opened on the requesting device: proven by the requesting attempt's ephemeral bearer token when the adapter forwards it, otherwise by the IP address and user agent a trusted adapter forwards with its service token. A fingerprint no adapter vouched for does not count, since behind an adapter that does not forward client context every request carries the adapter's own address. Opened anywhere else it answers `409 confirmation_required` with the requesting platform and time, and leaves the link unused, so a mail scanner or a click on a link someone else requested no longer signs that someone in. - New `POST /magic-link/verify/{token}/confirm` consumes the link after the person who opened it approves. - New auth event `magic_link_confirmation_required`. - A request or poll with an ephemeral token that has no `jti` is refused with `400`. diff --git a/openapi.json b/openapi.json index b710ed0..ae8ebf3 100644 --- a/openapi.json +++ b/openapi.json @@ -7506,7 +7506,7 @@ "/magic-link/verify/{token}": { "get": { "summary": "Verify magic link token", - "description": "Consumes the link when it is opened on the device that requested it, proven by the requesting attempt's ephemeral bearer token when the adapter forwards it, otherwise by IP address and user agent. Opened anywhere else, answers 409 without consuming it; confirm with POST /magic-link/verify/{token}/confirm.", + "description": "Consumes the link when it is opened on the device that requested it, proven by the requesting attempt's ephemeral bearer token when the adapter forwards it, otherwise by the IP address and user agent a trusted adapter forwards. Opened anywhere else, answers 409 without consuming it; confirm with POST /magic-link/verify/{token}/confirm.", "tags": ["MagicLinks"], "parameters": [ { "schema": { "type": "string" }, "required": true, "name": "token", "in": "path" } diff --git a/src/controllers/magicLinks.ts b/src/controllers/magicLinks.ts index 5c6963a..673879d 100644 --- a/src/controllers/magicLinks.ts +++ b/src/controllers/magicLinks.ts @@ -266,7 +266,9 @@ async function consumeLink( * complete silently: anyone can request a link for someone else's address from their * own machine. The pre-auth token, when the adapter forwards it, proves the attempt. * Without it the IP and user agent are the only signal, which people behind one - * address with the same browser can share. + * address with the same browser can share. They count only when a trusted adapter + * vouched for them: otherwise every request carries the adapter's own address, and + * every device would look like the one that asked. */ async function openedOnRequestingDevice(req: RouteRequest, record: MagicLinkToken) { const auth = req.headers.authorization; @@ -279,6 +281,10 @@ async function openedOnRequestingDevice(req: RouteRequest, record: MagicLinkToke } } + if (!(req as RouteRequest & { trustedClientIp?: string }).trustedClientIp) { + return false; + } + const { ip_hash, user_agent_hash } = hashDeviceFingerprint(req.ip, req.headers['user-agent']); return ( diff --git a/src/generated/api.ts b/src/generated/api.ts index 909d777..a524c93 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -7460,7 +7460,7 @@ export interface paths { }; /** * Verify magic link token - * @description Consumes the link when it is opened on the device that requested it, proven by the requesting attempt's ephemeral bearer token when the adapter forwards it, otherwise by IP address and user agent. Opened anywhere else, answers 409 without consuming it; confirm with POST /magic-link/verify/{token}/confirm. + * @description Consumes the link when it is opened on the device that requested it, proven by the requesting attempt's ephemeral bearer token when the adapter forwards it, otherwise by the IP address and user agent a trusted adapter forwards. Opened anywhere else, answers 409 without consuming it; confirm with POST /magic-link/verify/{token}/confirm. */ get: { parameters: { diff --git a/src/routes/magicLink.routes.ts b/src/routes/magicLink.routes.ts index e33c649..27b1f0f 100644 --- a/src/routes/magicLink.routes.ts +++ b/src/routes/magicLink.routes.ts @@ -104,7 +104,8 @@ magicLinkRouter.get( description: 'Consumes the link when it is opened on the device that requested it, proven by the ' + "requesting attempt's ephemeral bearer token when the adapter forwards it, otherwise by " + - 'IP address and user agent. Opened anywhere else, answers 409 without consuming it; ' + + 'the IP address and user agent a trusted adapter forwards. Opened anywhere else, answers ' + + '409 without consuming it; ' + 'confirm with POST /magic-link/verify/{token}/confirm.', tags: ['MagicLinks'], diff --git a/tests/integration/magicLink/magicLink.spec.ts b/tests/integration/magicLink/magicLink.spec.ts index 080271a..15d59bc 100644 --- a/tests/integration/magicLink/magicLink.spec.ts +++ b/tests/integration/magicLink/magicLink.spec.ts @@ -52,10 +52,16 @@ function directRes() { return res; } -function directVerifyReq(opts: { ip: string; userAgent: string; bearer?: string }) { +function directVerifyReq(opts: { + ip: string; + userAgent: string; + bearer?: string; + trusted?: boolean; +}) { return { params: { token: 'token' }, ip: opts.ip, + ...(opts.trusted === false ? {} : { trustedClientIp: opts.ip }), headers: { 'user-agent': opts.userAgent, ...(opts.bearer ? { authorization: `Bearer ${opts.bearer}` } : {}), @@ -406,6 +412,21 @@ describe('GET /magic-link/verify/:token', () => { expect(MagicLinkToken.update).not.toHaveBeenCalled(); }); + // Behind an adapter that does not forward the client context, every request carries + // the adapter's own address and user agent, so a matching fingerprint proves nothing. + it('asks for confirmation on a matching fingerprint no adapter vouched for', async () => { + (MagicLinkToken.findOne as any).mockResolvedValue(buildMagicLink()); + const res = directRes(); + + await verifyMagicLink( + directVerifyReq({ ip: '10.0.0.5', userAgent: 'node', trusted: false }), + res, + ); + + expect(res.status).toHaveBeenCalledWith(409); + expect(MagicLinkToken.update).not.toHaveBeenCalled(); + }); + it('refuses a link created before attempt binding', async () => { (MagicLinkToken.findOne as any).mockResolvedValue(buildMagicLink({ attempt_id: null })); From 502bff12b6ceca58ececf543f77c22d4b6bc6789 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 18 Oct 2026 07:17:44 -0400 Subject: [PATCH 4/4] docs(changeset): say which adapter versions prove a same-browser magic link --- .changeset/magic-link-attempt-redemption.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/magic-link-attempt-redemption.md b/.changeset/magic-link-attempt-redemption.md index c72cfc6..94b896f 100644 --- a/.changeset/magic-link-attempt-redemption.md +++ b/.changeset/magic-link-attempt-redemption.md @@ -12,4 +12,6 @@ Magic link sign-in issues one session per confirmed link, to the attempt that re - A request or poll with an ephemeral token that has no `jti` is refused with `400`. - Migration: `magic_link_tokens` gains `attempt_id`, `requested_device_class` and `redeemed_at`. Links requested before the upgrade can no longer be used; they expire within fifteen minutes. -Breaking for clients: a link opened on a different device now needs a confirmation screen. SDK versions without one show the 409 as an error, and the link stays usable for the requesting device's own browser. +Breaking for clients: a link opened on a different device now needs a confirmation screen. SDK versions without one show the 409 as an error. + +Upgrade the server adapter together with the API. Current adapters (`@seamless-auth/express`, `@seamless-auth/fastify`, `@seamless-auth/nextjs`, and the Go, Python and Rust adapters) forward the browser's pre-auth token on verify, which proves a link opened in the requesting browser. On an older adapter that proof is missing: Express still forwards the browser's address, so same-browser links keep working there, but `@seamless-auth/nextjs` forwards no address unless `resolveClientIp` is configured, so a link opened in the requesting browser is answered with the confirmation step.