Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/magic-link-confirmation-step.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions packages/core/src/handlers/verifyMagicLinkHandler.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
Expand All @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/manifest/bundledManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions packages/core/tests/adapterManifest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
};

Expand Down Expand Up @@ -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();
});
Expand Down
20 changes: 20 additions & 0 deletions packages/core/tests/ensureCookies.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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" },
],
};

Expand Down Expand Up @@ -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");

Expand Down
2 changes: 2 additions & 0 deletions packages/core/tests/publicExports.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
verifyCookieJwt,
verifyLoginOtpHandler,
verifyMagicLinkHandler,
magicLinkVerifyAuthorization,
verifyRefreshCookie,
verifyRegistrationOtpHandler,
verifySignedAuthResponse,
Expand Down Expand Up @@ -133,6 +134,7 @@ const DOCUMENTED_FUNCTIONS = {
verifyCookieJwt,
verifyLoginOtpHandler,
verifyMagicLinkHandler,
magicLinkVerifyAuthorization,
verifyRefreshCookie,
verifyRegistrationOtpHandler,
verifySignedAuthResponse,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/tests/redaction.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
});
117 changes: 117 additions & 0 deletions packages/core/tests/verifyMagicLinkHandler.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { jest } from "@jest/globals";
import jwt from "jsonwebtoken";

function jsonResponse(status, body) {
return new Response(JSON.stringify(body), {
Expand Down Expand Up @@ -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();
});
});
11 changes: 10 additions & 1 deletion packages/express/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading