diff --git a/.changeset/magic-link-attempt-redemption.md b/.changeset/magic-link-attempt-redemption.md new file mode 100644 index 0000000..94b896f --- /dev/null +++ b/.changeset/magic-link-attempt-redemption.md @@ -0,0 +1,17 @@ +--- +'seamless-auth-api': minor +--- + +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 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`. +- 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. + +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. 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 44e9f1e..ae8ebf3 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", @@ -7360,33 +7362,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"] + } + ] } } } @@ -7506,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 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" } + ], + "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 1330dcb..673879d 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'; @@ -113,6 +115,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 +138,8 @@ export async function requestMagicLink(req: MagicLinkRequest, res: Response) { redirect_url, 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), }); @@ -173,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( @@ -231,15 +253,86 @@ 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. 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; + + if (auth?.startsWith('Bearer ')) { + const payload = await verifyJwtWithKid(auth.slice(7), 'ephemeral').catch(() => null); + + if (payload) { + return payload.jti === record.attempt_id; + } + } + + if (!(req as RouteRequest & { trustedClientIp?: string }).trustedClientIp) { + return false; + } + + 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; @@ -256,8 +349,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 +381,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..a524c93 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' @@ -7301,35 +7303,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 */ @@ -7466,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 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: { query?: never; @@ -7571,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: { @@ -7617,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 new file mode 100644 index 0000000..e6e1dcc --- /dev/null +++ b/src/migrations/20261016120000-bind-magic-links-to-attempt.cjs @@ -0,0 +1,42 @@ +/* + * 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, 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. + * + * @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 requested_device_class 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 requested_device_class, + DROP COLUMN IF EXISTS attempt_id; + `); + }, +}; diff --git a/src/models/magicLinks.ts b/src/models/magicLinks.ts index 4416a22..8df558e 100644 --- a/src/models/magicLinks.ts +++ b/src/models/magicLinks.ts @@ -25,6 +25,9 @@ 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 requested_device_class: string | null; + declare redeemed_at: CreationOptional; declare created_at: CreationOptional; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -72,6 +75,18 @@ const initializeMagicTokenModel = (sequelize: Sequelize) => { type: DataTypes.DATE, allowNull: true, }, + attempt_id: { + type: DataTypes.TEXT, + allowNull: true, + }, + requested_device_class: { + 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..27b1f0f 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 { MagicLinkPollSuccessSchema } from '../schemas/magiclink.responses.js'; +import { + MagicLinkConfirmationRequiredSchema, + MagicLinkPollResponseSchema, +} from '../schemas/magiclink.responses.js'; const magicLinkRouter = createRouter('/magic-link'); @@ -81,7 +85,7 @@ magicLinkRouter.get( schemas: { response: { - 200: MagicLinkPollSuccessSchema, + 200: MagicLinkPollResponseSchema, 204: MessageSchema, 403: ErrorSchema, 404: ErrorSchema, @@ -97,6 +101,12 @@ 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 " + + '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'], schemas: { @@ -106,6 +116,7 @@ magicLinkRouter.get( 200: MessageSchema, 403: ErrorSchema, 400: ErrorSchema, + 409: MagicLinkConfirmationRequiredSchema, 500: InternalErrorSchema, }, }, @@ -113,4 +124,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 80342b0..f6de223 100644 --- a/src/schemas/magiclink.responses.ts +++ b/src/schemas/magiclink.responses.ts @@ -4,4 +4,32 @@ * 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'; + +import { DEVICE_CLASSES } from '../lib/deviceClass.js'; + +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, +]); + +/** + * 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 93ff78f..15d59bc 100644 --- a/tests/integration/magicLink/magicLink.spec.ts +++ b/tests/integration/magicLink/magicLink.spec.ts @@ -10,13 +10,19 @@ 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, + confirmMagicLink, + verifyMagicLink, +} from '../../../src/controllers/magicLinks.js'; import { createRefreshTokenLookup, generateRefreshToken, 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'; @@ -31,16 +37,46 @@ 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; + 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}` } : {}), + }, + get: () => undefined, + } as any; +} + beforeAll(async () => { app = await createApp(); }); beforeEach(() => { vi.clearAllMocks(); + (verifyJwtWithKid as any).mockReset(); (User.findOne as any).mockResolvedValue({ id: 'user-1', @@ -286,31 +322,120 @@ 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(); + }); + + // 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 })); + + 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); @@ -338,8 +463,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'); @@ -352,6 +480,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); @@ -472,6 +651,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 +674,107 @@ 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; + + 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 = { @@ -522,7 +803,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 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(); }, };