From 1690640c012bdf5d26894f741541c82d11ea6268 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 16 Oct 2026 22:28:05 -0400 Subject: [PATCH] feat(core): forward the pre-auth credential on magic link verify and serve the confirm route Verify now sends the opening browser's pre-auth session to the auth API when it has one, read from the signed pre-auth cookie in cookie transport or the client's Authorization header in bearer transport. A missing or unverifiable one sends nothing and never becomes a 401. Express, Fastify and Next.js all go through verifyMagicLinkHandler, which takes the new optional authorization. The bundled fallback manifest is resynced from the auth API and now lists POST /magic-link/verify/{token}/confirm, which the manifest catch-alls serve. The 409 confirmation_required body, requestedFrom included, passes through unchanged. --- .changeset/magic-link-confirmation-step.md | 25 +++ packages/core/README.md | 5 + .../src/handlers/verifyMagicLinkHandler.ts | 46 ++++++ packages/core/src/manifest/bundledManifest.ts | 7 +- packages/core/tests/adapterManifest.test.js | 17 ++ packages/core/tests/ensureCookies.test.js | 20 +++ packages/core/tests/publicExports.test.js | 2 + packages/core/tests/redaction.test.js | 6 + .../core/tests/verifyMagicLinkHandler.test.js | 117 ++++++++++++++ packages/express/README.md | 11 +- packages/express/src/createServer.ts | 22 +-- .../express/src/handlers/verifyMagicLink.ts | 38 +++++ .../tests/cookieRequirementRoutes.test.js | 14 ++ packages/express/tests/originGuard.test.js | 22 +++ packages/fastify/src/routes/authRoutes.ts | 31 ++-- packages/fastify/tests/parity.test.js | 153 ++++++++++++++++++ packages/nextjs/src/routes/authRoutes.ts | 30 ++-- packages/nextjs/tests/parity.test.js | 153 ++++++++++++++++++ 18 files changed, 678 insertions(+), 41 deletions(-) create mode 100644 .changeset/magic-link-confirmation-step.md create mode 100644 packages/express/src/handlers/verifyMagicLink.ts diff --git a/.changeset/magic-link-confirmation-step.md b/.changeset/magic-link-confirmation-step.md new file mode 100644 index 0000000..26148db --- /dev/null +++ b/.changeset/magic-link-confirmation-step.md @@ -0,0 +1,25 @@ +--- +"@seamless-auth/core": minor +"@seamless-auth/express": minor +"@seamless-auth/fastify": minor +"@seamless-auth/nextjs": minor +--- + +Support the magic link confirmation step. + +`GET /magic-link/verify/:token` now sends the opening browser's pre-auth session to the auth API +when it has one: the token from the signed pre-auth cookie in cookie transport, or the client's +`Authorization` header in bearer transport. The auth API uses it to recognise the device that asked +for the link. A browser without one, or with a cookie that no longer verifies, is still answered and +never gets a 401 from the adapter. + +When the auth API (0.19.0 and later) asks for confirmation, verify answers +`409 { error: "confirmation_required", message, requestedFrom: { deviceClass, requestedAt } }` +unchanged, and `POST /magic-link/verify/:token/confirm` approves the sign-in. The confirm route is +served from the adapter manifest, and the bundled fallback manifest now lists it. It is a state +change, so the cross-site request guard applies to it. + +`verifyMagicLinkHandler` takes an optional `authorization`, and core exports +`magicLinkVerifyAuthorization` to build it. All three adapters now verify through the core handler, +so a verify failure the auth API sends with an empty body is answered as +`{ error: "upstream_error" }` instead of a bare status. diff --git a/packages/core/README.md b/packages/core/README.md index 0a17d77..7416d81 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -155,6 +155,11 @@ of this package. `pollMagicLinkConfirmationHandler`, `switchOrganizationHandler`, `listOAuthProvidersHandler`, `startOAuthLoginHandler`, `finishOAuthLoginHandler`. +`verifyMagicLinkHandler` takes an optional `authorization`, the pre-auth credential of the +browser opening the link. `magicLinkVerifyAuthorization` builds it from the signed pre-auth +cookie (cookie transport) or the client's `Authorization` header (bearer transport), and yields +nothing rather than failing when there is none or it does not verify. + **Admin and operations handlers** User, session, auth-event, metrics, and system-config handlers, for example diff --git a/packages/core/src/handlers/verifyMagicLinkHandler.ts b/packages/core/src/handlers/verifyMagicLinkHandler.ts index 387b892..e6de4a6 100644 --- a/packages/core/src/handlers/verifyMagicLinkHandler.ts +++ b/packages/core/src/handlers/verifyMagicLinkHandler.ts @@ -1,9 +1,18 @@ import { authFetch } from "../authFetch.js"; import { readPassthroughFailure } from "../upstreamError.js"; import type { ResultFailure } from "../result.js"; +import type { AuthTransport } from "../transport.js"; +import { extractBearerToken } from "../verifyAccessToken.js"; +import { verifyCookieJwt } from "../verifyCookieJwt.js"; export interface VerifyMagicLinkInput { token: string; + /** + * The pre-auth credential of the browser opening the link, when it has one. + * The auth API uses it to recognise the device that requested the link. It is + * optional: a link opened anywhere else carries none and is still answered. + */ + authorization?: string; } export interface VerifyMagicLinkOptions { @@ -18,6 +27,42 @@ export interface VerifyMagicLinkResult extends ResultFailure { body?: unknown; } +export interface MagicLinkVerifyAuthorizationInput { + transport?: AuthTransport; + /** The raw `Authorization` header, read in bearer transport. */ + authorizationHeader?: string; + /** The signed pre-auth cookie, read in cookie transport. */ + preAuthCookie?: string; + cookieSecret: string; +} + +/** + * The `Authorization` to send with a magic link verify, or `undefined`. + * + * `ensureCookies` leaves this route ungated, so nothing has decoded the pre-auth + * cookie by the time it runs. A missing, stale or forged cookie yields no + * credential rather than a rejection, because the link itself is what + * authenticates the request. + */ +export function magicLinkVerifyAuthorization( + input: MagicLinkVerifyAuthorizationInput, +): string | undefined { + if (input.transport === "bearer") { + const token = extractBearerToken(input.authorizationHeader); + return token ? `Bearer ${token}` : undefined; + } + + if (!input.preAuthCookie) { + return undefined; + } + + const payload = verifyCookieJwt(input.preAuthCookie, input.cookieSecret); + + return typeof payload?.token === "string" + ? `Bearer ${payload.token}` + : undefined; +} + export async function verifyMagicLinkHandler( input: VerifyMagicLinkInput, opts: VerifyMagicLinkOptions, @@ -26,6 +71,7 @@ export async function verifyMagicLinkHandler( `${opts.authServerUrl}/magic-link/verify/${encodeURIComponent(input.token)}`, { method: "GET", + authorization: input.authorization, serviceAuthorization: opts.serviceAuthorization, forwardedClientIp: opts.forwardedClientIp, forwardedUserAgent: opts.forwardedUserAgent, diff --git a/packages/core/src/manifest/bundledManifest.ts b/packages/core/src/manifest/bundledManifest.ts index 0ce68da..1925976 100644 --- a/packages/core/src/manifest/bundledManifest.ts +++ b/packages/core/src/manifest/bundledManifest.ts @@ -3,7 +3,7 @@ import type { AdapterManifest } from "./adapterManifest.js"; export const BUNDLED_ADAPTER_MANIFEST: AdapterManifest = { "schemaVersion": 1, - "apiVersion": "0.17.0", + "apiVersion": "0.18.0", "session": { "subject": "sub", "token": "token", @@ -257,6 +257,11 @@ export const BUNDLED_ADAPTER_MANIFEST: AdapterManifest = { "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/packages/core/tests/adapterManifest.test.js b/packages/core/tests/adapterManifest.test.js index 0e34289..0cc6332 100644 --- a/packages/core/tests/adapterManifest.test.js +++ b/packages/core/tests/adapterManifest.test.js @@ -19,6 +19,7 @@ const manifest = { { method: "POST", path: "/admin/users/import", credential: "access" }, { method: "POST", path: "/webauthn/login/start", credential: "preAuth" }, { method: "GET", path: "/magic-link/verify/{token}", credential: "none" }, + { method: "POST", path: "/magic-link/verify/{token}/confirm", credential: "none" }, ], }; @@ -99,6 +100,22 @@ describe("matchManifestRoute", () => { }, ); + it("tells the confirm step apart from verify by method and segment count", () => { + expect(matchManifestRoute(manifest, "POST", "/magic-link/verify/tok/confirm")).toEqual({ + route: manifest.routes[4], + params: { token: "tok" }, + }); + expect(matchManifestRoute(manifest, "GET", "/magic-link/verify/tok/confirm")).toBeUndefined(); + expect(matchManifestRoute(manifest, "POST", "/magic-link/verify/tok")).toBeUndefined(); + }); + + it("bundles the confirm step as a route that needs no credential", () => { + expect( + matchManifestRoute(BUNDLED_ADAPTER_MANIFEST, "POST", "/magic-link/verify/tok/confirm") + ?.route, + ).toEqual({ method: "POST", path: "/magic-link/verify/{token}/confirm", credential: "none" }); + }); + it("does not match a malformed percent-encoding", () => { expect(matchManifestRoute(manifest, "GET", "/magic-link/verify/%E0%A4%A")).toBeUndefined(); }); diff --git a/packages/core/tests/ensureCookies.test.js b/packages/core/tests/ensureCookies.test.js index 30d8dc6..79968a0 100644 --- a/packages/core/tests/ensureCookies.test.js +++ b/packages/core/tests/ensureCookies.test.js @@ -426,6 +426,22 @@ describe("ensureCookies", () => { expect(refreshAccessTokenMock).not.toHaveBeenCalled(); }); + it("does not gate the magic-link confirm step without a manifest", async () => { + const { ensureCookies } = await import("../dist/ensureCookies.js"); + + const result = await ensureCookies( + { + path: "/magic-link/verify/token-abc/confirm", + method: "POST", + cookies: {}, + }, + BASE_OPTS, + ); + + expect(result).toEqual({ type: "ok" }); + expect(verifyCookieJwtMock).not.toHaveBeenCalled(); + }); + it("does not gate magic-link verify even with a stale pre-auth cookie (#60)", async () => { const { ensureCookies } = await import("../dist/ensureCookies.js"); @@ -610,6 +626,8 @@ describe("ensureCookies with a manifest", () => { { method: "POST", path: "/registration/phone", credential: "access" }, { method: "GET", path: "/oauth/providers", credential: "none" }, { method: "POST", path: "/refresh", credential: "refresh" }, + { method: "GET", path: "/magic-link/verify/{token}", credential: "none" }, + { method: "POST", path: "/magic-link/verify/{token}/confirm", credential: "none" }, ], }; @@ -676,6 +694,8 @@ describe("ensureCookies with a manifest", () => { it.each([ ["GET", "/oauth/providers"], ["POST", "/refresh"], + ["GET", "/magic-link/verify/token-abc"], + ["POST", "/magic-link/verify/token-abc/confirm"], ])("requires nothing for %s %s", async (method, path) => { const { ensureCookies } = await import("../dist/ensureCookies.js"); diff --git a/packages/core/tests/publicExports.test.js b/packages/core/tests/publicExports.test.js index 41cc286..082be3c 100644 --- a/packages/core/tests/publicExports.test.js +++ b/packages/core/tests/publicExports.test.js @@ -59,6 +59,7 @@ import { verifyCookieJwt, verifyLoginOtpHandler, verifyMagicLinkHandler, + magicLinkVerifyAuthorization, verifyRefreshCookie, verifyRegistrationOtpHandler, verifySignedAuthResponse, @@ -133,6 +134,7 @@ const DOCUMENTED_FUNCTIONS = { verifyCookieJwt, verifyLoginOtpHandler, verifyMagicLinkHandler, + magicLinkVerifyAuthorization, verifyRefreshCookie, verifyRegistrationOtpHandler, verifySignedAuthResponse, diff --git a/packages/core/tests/redaction.test.js b/packages/core/tests/redaction.test.js index 46f3f87..550b16c 100644 --- a/packages/core/tests/redaction.test.js +++ b/packages/core/tests/redaction.test.js @@ -11,4 +11,10 @@ describe("redaction", () => { "/magic-link/verify/[REDACTED]?bootstrapToken=[REDACTED]&code=[REDACTED]&salt=[REDACTED]", ); }); + + it("redacts the token in the magic link confirm path", () => { + expect(redactSensitiveText("POST /magic-link/verify/abc123/confirm")).toBe( + "POST /magic-link/verify/[REDACTED]/confirm", + ); + }); }); diff --git a/packages/core/tests/verifyMagicLinkHandler.test.js b/packages/core/tests/verifyMagicLinkHandler.test.js index 8bc5d60..9ddeaab 100644 --- a/packages/core/tests/verifyMagicLinkHandler.test.js +++ b/packages/core/tests/verifyMagicLinkHandler.test.js @@ -1,4 +1,5 @@ import { jest } from "@jest/globals"; +import jwt from "jsonwebtoken"; function jsonResponse(status, body) { return new Response(JSON.stringify(body), { @@ -67,4 +68,120 @@ describe("verifyMagicLinkHandler", () => { expect.objectContaining({ method: "GET" }), ); }); + + it("forwards the opener's credential with the service identity", async () => { + const { verifyMagicLinkHandler } = await import( + "../dist/handlers/verifyMagicLinkHandler.js" + ); + + await verifyMagicLinkHandler( + { token: "abc123", authorization: "Bearer ephemeral" }, + { + authServerUrl: "https://auth.example.com", + serviceAuthorization: "Bearer service", + forwardedClientIp: "203.0.113.7", + forwardedUserAgent: "Mozilla/5.0", + }, + ); + + const [, init] = global.fetch.mock.calls[0]; + expect(init.headers).toMatchObject({ + Authorization: "Bearer ephemeral", + "x-seamless-service-token": "Bearer service", + "x-seamless-client-ip": "203.0.113.7", + "x-seamless-client-user-agent": "Mozilla/5.0", + }); + }); + + it("sends no Authorization when the opener has no credential", async () => { + const { verifyMagicLinkHandler } = await import( + "../dist/handlers/verifyMagicLinkHandler.js" + ); + + await verifyMagicLinkHandler( + { token: "abc123" }, + { authServerUrl: "https://auth.example.com" }, + ); + + const [, init] = global.fetch.mock.calls[0]; + expect(init.headers.Authorization).toBeUndefined(); + }); + + it("passes a confirmation request through verbatim", async () => { + const body = { + error: "confirmation_required", + message: "Confirm this sign-in.", + requestedFrom: { deviceClass: "ios", requestedAt: "2026-10-16T12:00:00.000Z" }, + }; + global.fetch.mockResolvedValue(jsonResponse(409, body)); + + const { verifyMagicLinkHandler } = await import( + "../dist/handlers/verifyMagicLinkHandler.js" + ); + + await expect( + verifyMagicLinkHandler( + { token: "abc123" }, + { authServerUrl: "https://auth.example.com" }, + ), + ).resolves.toEqual({ status: 409, errorBody: body }); + }); +}); + +describe("magicLinkVerifyAuthorization", () => { + const cookieSecret = "cookie-secret-cookie-secret-cookie-secret"; + + async function load() { + return (await import("../dist/handlers/verifyMagicLinkHandler.js")) + .magicLinkVerifyAuthorization; + } + + it("reads the token out of a valid pre-auth cookie", async () => { + const magicLinkVerifyAuthorization = await load(); + const preAuthCookie = jwt.sign({ sub: "u1", token: "ephemeral" }, cookieSecret, { + algorithm: "HS256", + }); + + expect(magicLinkVerifyAuthorization({ preAuthCookie, cookieSecret })).toBe( + "Bearer ephemeral", + ); + }); + + it.each([ + ["no cookie", undefined], + ["a cookie signed with another secret", jwt.sign({ sub: "u1", token: "x" }, "another-secret-another-secret-another", { algorithm: "HS256" })], + ["an expired cookie", jwt.sign({ sub: "u1", token: "x", exp: 1 }, cookieSecret, { algorithm: "HS256" })], + ["a cookie without a token", jwt.sign({ sub: "u1" }, cookieSecret, { algorithm: "HS256" })], + ["a value that is not a JWT", "garbage"], + ])("yields nothing for %s", async (_label, preAuthCookie) => { + const magicLinkVerifyAuthorization = await load(); + + expect(magicLinkVerifyAuthorization({ preAuthCookie, cookieSecret })).toBeUndefined(); + }); + + it("forwards the client's bearer token in bearer transport", async () => { + const magicLinkVerifyAuthorization = await load(); + + expect( + magicLinkVerifyAuthorization({ + transport: "bearer", + authorizationHeader: "Bearer ephemeral", + cookieSecret, + }), + ).toBe("Bearer ephemeral"); + expect( + magicLinkVerifyAuthorization({ transport: "bearer", cookieSecret }), + ).toBeUndefined(); + }); + + it("ignores a cookie in bearer transport", async () => { + const magicLinkVerifyAuthorization = await load(); + const preAuthCookie = jwt.sign({ sub: "u1", token: "ephemeral" }, cookieSecret, { + algorithm: "HS256", + }); + + expect( + magicLinkVerifyAuthorization({ transport: "bearer", preAuthCookie, cookieSecret }), + ).toBeUndefined(); + }); }); diff --git a/packages/express/README.md b/packages/express/README.md index d717350..6abfa70 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -199,6 +199,13 @@ Routes include: - `DELETE /auth/users/delete` to delete the signed-in user's own account. It answers with the auth API's body and clears the session cookies the way `/logout` does; the SDK's `deleteUser()` calls it - `POST /auth/magic-link` to request a magic-link email +- `GET /auth/magic-link/verify/:token` to open a magic link. It sends the browser's pre-auth + session along when it has one, so the auth API can recognise the device that asked for the link. + A browser without one is still answered: opened on another device, the link is not used up and + the answer is `409 { "error": "confirmation_required", "message", "requestedFrom": { "deviceClass", + "requestedAt" } }`, so the page can ask the person to approve the sign-in +- `POST /auth/magic-link/verify/:token/confirm` to approve that sign-in. The token in the path is + the only credential it needs - `POST /auth/otp/generate-phone-otp`, `-email-otp`, and their `-login-` variants to send an OTP `GET /auth/logout` and `GET /auth/magic-link` were removed, and the four @@ -339,7 +346,9 @@ cross-site state-changing requests with `403 { "error": Same-origin single-page-app calls send `same-origin` or `same-site` and pass with no allowlist. - Safe methods are never gated, so `GET /magic-link/verify/:token` and the CORS - preflight (`OPTIONS`) are unaffected. + preflight (`OPTIONS`) are unaffected. `POST /magic-link/verify/:token/confirm` + is a state change and is guarded like any other, so serve the page that calls + it from your own site. - A caller that sends neither `Origin` nor `Sec-Fetch-Site`, such as a server-to-server request, passes. diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index e14c7b7..bd32573 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -23,6 +23,7 @@ import { deleteAccount } from "./handlers/deleteAccount.js"; import { refresh } from "./handlers/refresh.js"; import { pollMagicLinkConfirmation } from "./handlers/pollMagicLinkConfirmation.js"; import { requestMagicLink } from "./handlers/requestMagicLink.js"; +import { verifyMagicLink } from "./handlers/verifyMagicLink.js"; import { finishOAuthLogin, listOAuthProviders, @@ -30,7 +31,6 @@ import { } from "./handlers/oauth.js"; import * as admin from "./handlers/admin.js"; import { - authFetch, AuthFetchOptions, checkProxyIdentity, createAdapterManifestSource, @@ -509,23 +509,9 @@ export function createSeamlessAuthServer( proxyWithIdentity("users/credentials", "access", "DELETE"), ); r.post("/magic-link", (req, res) => requestMagicLink(req, res, resolvedOpts)); - r.get("/magic-link/verify/:token", async (req, res) => { - const upstream = await authFetch( - `${resolvedOpts.authServerUrl}/magic-link/verify/${encodeURIComponent(routeParam(req, "token"))}`, - { - method: "GET", - serviceAuthorization: buildProxyServiceAuthorization(resolvedOpts), - forwardedClientIp: buildForwardedClientIp( - req, - resolvedOpts.resolveClientIp, - ), - forwardedUserAgent: buildForwardedUserAgent(req), - }, - ); - - const data = await upstream.json(); - res.status(upstream.status).json(data); - }); + r.get("/magic-link/verify/:token", (req, res) => + verifyMagicLink(req, res, routeParam(req, "token"), resolvedOpts), + ); r.get("/magic-link/check", (req, res) => pollMagicLinkConfirmation(req, res, resolvedOpts), ); diff --git a/packages/express/src/handlers/verifyMagicLink.ts b/packages/express/src/handlers/verifyMagicLink.ts new file mode 100644 index 0000000..1f09d50 --- /dev/null +++ b/packages/express/src/handlers/verifyMagicLink.ts @@ -0,0 +1,38 @@ +import { Request, Response } from "express"; +import { + magicLinkVerifyAuthorization, + verifyMagicLinkHandler, +} from "@seamless-auth/core/handlers/verifyMagicLinkHandler"; +import { respond } from "../internal/respond.js"; +import { transportOf } from "../internal/transport.js"; +import { buildProxyServiceAuthorization } from "../internal/buildAuthorization.js"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp.js"; +import { buildForwardedUserAgent } from "../internal/buildForwardedUserAgent.js"; +import { SeamlessAuthServerOptions } from "../createServer.js"; + +export async function verifyMagicLink( + req: Request, + res: Response, + token: string, + opts: SeamlessAuthServerOptions, +) { + const result = await verifyMagicLinkHandler( + { + token, + authorization: magicLinkVerifyAuthorization({ + transport: transportOf(req), + authorizationHeader: req.headers?.authorization, + preAuthCookie: req.cookies?.[opts.preAuthCookieName!], + cookieSecret: opts.cookieSecret, + }), + }, + { + authServerUrl: opts.authServerUrl, + serviceAuthorization: buildProxyServiceAuthorization(opts), + forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), + forwardedUserAgent: buildForwardedUserAgent(req), + }, + ); + + respond(res, result, opts); +} diff --git a/packages/express/tests/cookieRequirementRoutes.test.js b/packages/express/tests/cookieRequirementRoutes.test.js index 5c21241..29c17eb 100644 --- a/packages/express/tests/cookieRequirementRoutes.test.js +++ b/packages/express/tests/cookieRequirementRoutes.test.js @@ -187,6 +187,20 @@ describe("cookie requirement routes", () => { ); }); + it("verifies a magic link with a stale pre-auth cookie and sends no credential", async () => { + global.fetch.mockResolvedValue( + createJsonResponse(200, { message: "Success" }), + ); + + const res = await request(createApp()) + .get("/auth/magic-link/verify/token-abc") + .set("Cookie", "seamless-ephemeral=not-a-signed-cookie"); + + expect(res.status).toBe(200); + const [, init] = global.fetch.mock.calls[0]; + expect(init.headers.Authorization).toBeUndefined(); + }); + it("keeps an injected magic link token in a single path segment (#65)", async () => { global.fetch.mockResolvedValue( createJsonResponse(200, { message: "Verified" }), diff --git a/packages/express/tests/originGuard.test.js b/packages/express/tests/originGuard.test.js index aa6631f..254eb89 100644 --- a/packages/express/tests/originGuard.test.js +++ b/packages/express/tests/originGuard.test.js @@ -105,6 +105,28 @@ describe("origin guard (#104)", () => { expect(res.body).not.toEqual(BLOCKED); }); + it("rejects a cross-site POST to the magic link confirm step", async () => { + const res = await request(createApp()) + .post("/auth/magic-link/verify/some-token/confirm") + .set("Sec-Fetch-Site", "cross-site"); + + expect(res.status).toBe(403); + expect(res.body).toEqual(BLOCKED); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("accepts a same-origin POST to the magic link confirm step", async () => { + const res = await request(createApp()) + .post("/auth/magic-link/verify/some-token/confirm") + .set("Sec-Fetch-Site", "same-origin"); + + expect(res.status).toBe(200); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.example.com/magic-link/verify/some-token/confirm", + expect.objectContaining({ method: "POST" }), + ); + }); + it("lets a CORS preflight through", async () => { const res = await request(createApp()) .options("/auth/magic-link") diff --git a/packages/fastify/src/routes/authRoutes.ts b/packages/fastify/src/routes/authRoutes.ts index eb2236c..e140ffb 100644 --- a/packages/fastify/src/routes/authRoutes.ts +++ b/packages/fastify/src/routes/authRoutes.ts @@ -9,9 +9,9 @@ import { listOAuthProvidersHandler, loginHandler, logoutHandler, + magicLinkVerifyAuthorization, meHandler, pollMagicLinkConfirmationHandler, - proxyRequest, refreshHandler, registerHandler, requestMagicLinkHandler, @@ -19,6 +19,7 @@ import { startOAuthLoginHandler, switchOrganizationHandler, verifyLoginOtpHandler, + verifyMagicLinkHandler, verifyRegistrationOtpHandler, type LogoutScope, } from "@seamless-auth/core"; @@ -30,6 +31,7 @@ import { } from "../internal/buildAuthorization.js"; import { buildForwardedClientIp } from "../internal/buildForwardedClientIp.js"; import { buildForwardedUserAgent } from "../internal/buildForwardedUserAgent.js"; +import { requestCookies } from "../internal/requestCookies.js"; import { respond } from "../internal/respond.js"; import { transportOf } from "../internal/transport.js"; import type { ResolvedOptions } from "../options.js"; @@ -274,17 +276,24 @@ export function registerAuthRoutes( respond(reply, { ...result, body }, opts); }); - // Verified by the link recipient, who holds no session yet, so this forwards - // without an identity gate. + // Verified by the link recipient, who may hold no session at all, so this + // forwards without an identity gate. fastify.get("/magic-link/verify/:token", async (req, reply) => { - const result = await proxyRequest({ - authServerUrl: opts.authServerUrl, - path: `magic-link/verify/${encodeURIComponent(routeParam(req, "token"))}`, - method: "GET", - serviceAuthorization: buildProxyServiceAuthorization(opts), - forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), - forwardedUserAgent: buildForwardedUserAgent(req), - }); + const result = await verifyMagicLinkHandler( + { + token: routeParam(req, "token"), + authorization: magicLinkVerifyAuthorization({ + transport: transportOf(req), + authorizationHeader: req.headers?.authorization, + preAuthCookie: requestCookies(req)[opts.preAuthCookieName], + cookieSecret: opts.cookieSecret, + }), + }, + { + ...common(req), + serviceAuthorization: buildProxyServiceAuthorization(opts), + }, + ); respond(reply, result, opts); }); diff --git a/packages/fastify/tests/parity.test.js b/packages/fastify/tests/parity.test.js index 57a3c48..e42bc42 100644 --- a/packages/fastify/tests/parity.test.js +++ b/packages/fastify/tests/parity.test.js @@ -743,6 +743,159 @@ describe("fastify and express adapters agree", () => { }); }); +// Verify forwards the pre-auth credential of the browser that opens the link so +// the auth API can recognise the device that asked for it. A missing or bad one +// never turns into a 401: the link itself is the credential. +describe("both adapters carry the opener's credential on magic link verify", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + const CONFIRMATION_REQUIRED = { + error: "confirmation_required", + message: "Confirm this sign-in on the device that requested it.", + requestedFrom: { deviceClass: "macos", requestedAt: "2026-10-16T12:00:00.000Z" }, + }; + + async function verifyThrough(run, scenario, response = upstream(200, { message: "Success" })) { + let call; + + global.fetch = jest.fn(async (url, init) => { + call = { url: String(url), init }; + return response; + }); + + const result = await run({ method: "get", path: "/magic-link/verify/tok-1", ...scenario }); + + return { result, call }; + } + + async function both(scenario, response) { + return [ + await verifyThrough(viaFastify, scenario, response), + await verifyThrough(viaExpress, scenario, response), + ]; + } + + it("sends the pre-auth cookie's token as a bearer credential", async () => { + for (const { result, call } of await both({ + cookie: preAuthCookie(), + headers: { "user-agent": "Mozilla/5.0 (Macintosh)" }, + })) { + expect(result.status).toBe(200); + expect(result.body).toEqual({ message: "Success" }); + expect(call.url).toBe("https://auth.example.com/magic-link/verify/tok-1"); + expect(call.init.headers.Authorization).toBe("Bearer pre-auth"); + expect(call.init.headers["x-seamless-service-token"]).toMatch(/^Bearer /); + expect(call.init.headers["x-seamless-client-user-agent"]).toBe( + "Mozilla/5.0 (Macintosh)", + ); + } + }); + + it.each([ + ["no cookie", undefined], + ["a cookie signed with another secret", `seamless-ephemeral=${jwt.sign({ sub: "u", token: "forged" }, "another-secret-another-secret-another", { algorithm: "HS256" })}`], + ["a cookie that is not a JWT", "seamless-ephemeral=garbage"], + ["a cookie with no token", `seamless-ephemeral=${signed({ sub: "user-123" })}`], + ])("sends no credential and still verifies with %s", async (_label, cookie) => { + for (const { result, call } of await both({ cookie })) { + expect(result.status).toBe(200); + expect(call.init.headers.Authorization).toBeUndefined(); + expect(call.init.headers["x-seamless-service-token"]).toMatch(/^Bearer /); + } + }); + + it("forwards the client's bearer token in bearer transport", async () => { + for (const { result, call } of await both({ + headers: { + "x-seamless-auth-transport": "bearer", + authorization: "Bearer ephemeral-xyz", + }, + })) { + expect(result.status).toBe(200); + expect(call.init.headers.Authorization).toBe("Bearer ephemeral-xyz"); + } + }); + + it("sends no credential in bearer transport when the client sent none", async () => { + for (const { result, call } of await both({ + headers: { "x-seamless-auth-transport": "bearer" }, + })) { + expect(result.status).toBe(200); + expect(call.init.headers.Authorization).toBeUndefined(); + } + }); + + it("passes a confirmation request through with where it came from", async () => { + const [other, viaE] = await both( + { cookie: preAuthCookie() }, + upstream(409, CONFIRMATION_REQUIRED), + ); + + expect(other.result.status).toBe(409); + expect(other.result.body).toEqual(CONFIRMATION_REQUIRED); + expect(other.result).toEqual(viaE.result); + }); + + it("serves the confirm step from the manifest without a credential", async () => { + for (const run of [viaFastify, viaExpress]) { + let call; + global.fetch = jest.fn(async (url, init) => { + call = { url: String(url), init }; + return upstream(200, { message: "Success" }); + }); + + const result = await run({ + method: "post", + path: "/magic-link/verify/tok-1/confirm", + cookie: preAuthCookie(), + headers: { "sec-fetch-site": "same-origin" }, + }); + + expect(result.status).toBe(200); + expect(result.body).toEqual({ message: "Success" }); + expect(call.url).toBe("https://auth.example.com/magic-link/verify/tok-1/confirm"); + expect(call.init.method).toBe("POST"); + expect(call.init.headers.Authorization).toBeUndefined(); + } + }); + + it("passes a spent link's confirm failure through", async () => { + for (const run of [viaFastify, viaExpress]) { + global.fetch = jest.fn(async () => + upstream(400, { error: "Invalid or expired link" }), + ); + + const result = await run({ + method: "post", + path: "/magic-link/verify/tok-1/confirm", + }); + + expect(result.status).toBe(400); + expect(result.body).toEqual({ error: "Invalid or expired link" }); + } + }); + + it("refuses a cross-site confirm", async () => { + for (const run of [viaFastify, viaExpress]) { + global.fetch = jest.fn(async () => upstream(200, { message: "Success" })); + + const result = await run({ + method: "post", + path: "/magic-link/verify/tok-1/confirm", + headers: { "sec-fetch-site": "cross-site" }, + }); + + expect(result.status).toBe(403); + expect(result.body).toEqual({ error: "cross_site_request_blocked" }); + expect(global.fetch).not.toHaveBeenCalled(); + } + }); +}); + function consoleUpstream(status, body, headers = {}) { const encoded = Buffer.from(body); return { diff --git a/packages/nextjs/src/routes/authRoutes.ts b/packages/nextjs/src/routes/authRoutes.ts index f0b7d61..e8b88b3 100644 --- a/packages/nextjs/src/routes/authRoutes.ts +++ b/packages/nextjs/src/routes/authRoutes.ts @@ -8,9 +8,9 @@ import { listOAuthProvidersHandler, loginHandler, logoutHandler, + magicLinkVerifyAuthorization, meHandler, pollMagicLinkConfirmationHandler, - proxyRequest, refreshHandler, registerHandler, requestMagicLinkHandler, @@ -18,6 +18,7 @@ import { startOAuthLoginHandler, switchOrganizationHandler, verifyLoginOtpHandler, + verifyMagicLinkHandler, verifyRegistrationOtpHandler, type LogoutScope, } from "@seamless-auth/core"; @@ -292,19 +293,28 @@ export const AUTH_ROUTES: Route[] = [ }; }, }, - // Verified by the link recipient, who holds no session yet, so this forwards - // without an identity gate. + // Verified by the link recipient, who may hold no session at all, so this + // forwards without an identity gate. { method: "GET", path: "/magic-link/verify/:token", run: (ctx, opts) => - proxyRequest({ - authServerUrl: opts.authServerUrl, - path: `magic-link/verify/${encodeURIComponent(param(ctx, "token"))}`, - method: "GET", - serviceAuthorization: buildProxyServiceAuthorization(opts), - ...forwarded(ctx, opts), - }), + verifyMagicLinkHandler( + { + token: param(ctx, "token"), + authorization: magicLinkVerifyAuthorization({ + transport: ctx.transport, + authorizationHeader: + ctx.request.headers.get("authorization") ?? undefined, + preAuthCookie: ctx.cookies[opts.preAuthCookieName], + cookieSecret: opts.cookieSecret, + }), + }, + { + ...common(ctx, opts), + serviceAuthorization: buildProxyServiceAuthorization(opts), + }, + ), }, { method: "GET", diff --git a/packages/nextjs/tests/parity.test.js b/packages/nextjs/tests/parity.test.js index 0701b15..802bd2e 100644 --- a/packages/nextjs/tests/parity.test.js +++ b/packages/nextjs/tests/parity.test.js @@ -754,6 +754,159 @@ describe("both adapters forward a magic link destination", () => { }); }); +// Verify forwards the pre-auth credential of the browser that opens the link so +// the auth API can recognise the device that asked for it. A missing or bad one +// never turns into a 401: the link itself is the credential. +describe("both adapters carry the opener's credential on magic link verify", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + const CONFIRMATION_REQUIRED = { + error: "confirmation_required", + message: "Confirm this sign-in on the device that requested it.", + requestedFrom: { deviceClass: "macos", requestedAt: "2026-10-16T12:00:00.000Z" }, + }; + + async function verifyThrough(run, scenario, response = upstream(200, { message: "Success" })) { + let call; + + global.fetch = jest.fn(async (url, init) => { + call = { url: String(url), init }; + return response; + }); + + const result = await run({ method: "get", path: "/magic-link/verify/tok-1", ...scenario }); + + return { result, call }; + } + + async function both(scenario, response) { + return [ + await verifyThrough(viaNext, scenario, response), + await verifyThrough(viaExpress, scenario, response), + ]; + } + + it("sends the pre-auth cookie's token as a bearer credential", async () => { + for (const { result, call } of await both({ + cookie: preAuthCookie(), + headers: { "user-agent": "Mozilla/5.0 (Macintosh)" }, + })) { + expect(result.status).toBe(200); + expect(result.body).toEqual({ message: "Success" }); + expect(call.url).toBe("https://auth.example.com/magic-link/verify/tok-1"); + expect(call.init.headers.Authorization).toBe("Bearer pre-auth"); + expect(call.init.headers["x-seamless-service-token"]).toMatch(/^Bearer /); + expect(call.init.headers["x-seamless-client-user-agent"]).toBe( + "Mozilla/5.0 (Macintosh)", + ); + } + }); + + it.each([ + ["no cookie", undefined], + ["a cookie signed with another secret", `seamless-ephemeral=${jwt.sign({ sub: "u", token: "forged" }, "another-secret-another-secret-another", { algorithm: "HS256" })}`], + ["a cookie that is not a JWT", "seamless-ephemeral=garbage"], + ["a cookie with no token", `seamless-ephemeral=${signed({ sub: "user-123" })}`], + ])("sends no credential and still verifies with %s", async (_label, cookie) => { + for (const { result, call } of await both({ cookie })) { + expect(result.status).toBe(200); + expect(call.init.headers.Authorization).toBeUndefined(); + expect(call.init.headers["x-seamless-service-token"]).toMatch(/^Bearer /); + } + }); + + it("forwards the client's bearer token in bearer transport", async () => { + for (const { result, call } of await both({ + headers: { + "x-seamless-auth-transport": "bearer", + authorization: "Bearer ephemeral-xyz", + }, + })) { + expect(result.status).toBe(200); + expect(call.init.headers.Authorization).toBe("Bearer ephemeral-xyz"); + } + }); + + it("sends no credential in bearer transport when the client sent none", async () => { + for (const { result, call } of await both({ + headers: { "x-seamless-auth-transport": "bearer" }, + })) { + expect(result.status).toBe(200); + expect(call.init.headers.Authorization).toBeUndefined(); + } + }); + + it("passes a confirmation request through with where it came from", async () => { + const [other, viaE] = await both( + { cookie: preAuthCookie() }, + upstream(409, CONFIRMATION_REQUIRED), + ); + + expect(other.result.status).toBe(409); + expect(other.result.body).toEqual(CONFIRMATION_REQUIRED); + expect(other.result).toEqual(viaE.result); + }); + + it("serves the confirm step from the manifest without a credential", async () => { + for (const run of [viaNext, viaExpress]) { + let call; + global.fetch = jest.fn(async (url, init) => { + call = { url: String(url), init }; + return upstream(200, { message: "Success" }); + }); + + const result = await run({ + method: "post", + path: "/magic-link/verify/tok-1/confirm", + cookie: preAuthCookie(), + headers: { "sec-fetch-site": "same-origin" }, + }); + + expect(result.status).toBe(200); + expect(result.body).toEqual({ message: "Success" }); + expect(call.url).toBe("https://auth.example.com/magic-link/verify/tok-1/confirm"); + expect(call.init.method).toBe("POST"); + expect(call.init.headers.Authorization).toBeUndefined(); + } + }); + + it("passes a spent link's confirm failure through", async () => { + for (const run of [viaNext, viaExpress]) { + global.fetch = jest.fn(async () => + upstream(400, { error: "Invalid or expired link" }), + ); + + const result = await run({ + method: "post", + path: "/magic-link/verify/tok-1/confirm", + }); + + expect(result.status).toBe(400); + expect(result.body).toEqual({ error: "Invalid or expired link" }); + } + }); + + it("refuses a cross-site confirm", async () => { + for (const run of [viaNext, viaExpress]) { + global.fetch = jest.fn(async () => upstream(200, { message: "Success" })); + + const result = await run({ + method: "post", + path: "/magic-link/verify/tok-1/confirm", + headers: { "sec-fetch-site": "cross-site" }, + }); + + expect(result.status).toBe(403); + expect(result.body).toEqual({ error: "cross_site_request_blocked" }); + expect(global.fetch).not.toHaveBeenCalled(); + } + }); +}); + function consoleUpstream(status, body, headers = {}) { return new Response(body, { status, headers }); }