From a810619caacd5705ed96e446cb4405cae0be86ba Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 29 Jul 2026 21:56:40 -0400 Subject: [PATCH 1/4] feat(fastify): add the Fastify adapter Adds @seamless-auth/fastify, serving the same routes as the Express adapter and issuing the same cookies. Registered under a prefix, so Fastify's encapsulation keeps the cookie and origin hooks off the rest of the application. requireAuth and requireRole ship as preHandler hooks. Writing a second adapter is what the epic in #72 was for, and it found three things core still left to each adapter. The last guard decisions move into core alongside checkProxyIdentity: checkOrigin, authenticateCookie and authorizeRoles each take the request facts and return a GuardRejection or nothing. Express now calls them too, which is a straight substitution. SetCookieCommand and ClearCookieCommand gain an expires. The command previously specified only a max age, so two adapters could satisfy it and still emit different headers: Express sent Expires and Max-Age, Fastify sent Max-Age alone, which older clients treat as a session cookie. The parity suite caught it. ensureCookies matches a mount-relative path. Express hands a mounted router a req.path with the mount point already stripped, Fastify does not, so the prefix has to come off explicitly or every route silently loses its cookie payload. Also caught by the parity suite. The parity suite runs the same requests through both adapters against the same mocked auth API and asserts status, body and every Set-Cookie header match, so the two cannot drift. Refs #72 --- .../adapter-guards-and-cookie-contract.md | 12 + .changeset/fastify-adapter.md | 11 + packages/core/src/applyResult.ts | 23 + packages/core/src/guards.ts | 189 +++++++++ packages/core/src/index.ts | 1 + packages/core/tests/applyResult.test.js | 25 ++ packages/express/src/internal/respond.ts | 2 + .../express/src/middleware/originGuard.ts | 94 +---- .../express/src/middleware/requireAuth.ts | 45 +- .../express/src/middleware/requireRole.ts | 31 +- packages/fastify/LICENSE | 79 ++++ packages/fastify/LICENSE.md | 26 ++ packages/fastify/README.md | 136 ++++++ packages/fastify/jest.config.ts | 9 + packages/fastify/package.json | 78 ++++ packages/fastify/src/getSeamlessUser.ts | 23 + packages/fastify/src/guards.ts | 88 ++++ packages/fastify/src/hooks/ensureCookies.ts | 81 ++++ packages/fastify/src/hooks/originGuard.ts | 36 ++ packages/fastify/src/index.ts | 24 ++ .../src/internal/buildAuthorization.ts | 75 ++++ .../src/internal/buildForwardedClientIp.ts | 42 ++ packages/fastify/src/internal/respond.ts | 61 +++ packages/fastify/src/options.ts | 43 ++ packages/fastify/src/plugin.ts | 173 ++++++++ packages/fastify/src/routes/adminRoutes.ts | 216 ++++++++++ packages/fastify/src/routes/authRoutes.ts | 323 ++++++++++++++ packages/fastify/src/routes/proxyRoutes.ts | 246 +++++++++++ packages/fastify/tests/parity.test.js | 349 +++++++++++++++ packages/fastify/tsconfig.build.json | 10 + packages/fastify/tsconfig.json | 15 + pnpm-lock.yaml | 396 ++++++++++++++++++ 32 files changed, 2827 insertions(+), 135 deletions(-) create mode 100644 .changeset/adapter-guards-and-cookie-contract.md create mode 100644 .changeset/fastify-adapter.md create mode 100644 packages/core/src/guards.ts create mode 100644 packages/fastify/LICENSE create mode 100644 packages/fastify/LICENSE.md create mode 100644 packages/fastify/README.md create mode 100644 packages/fastify/jest.config.ts create mode 100644 packages/fastify/package.json create mode 100644 packages/fastify/src/getSeamlessUser.ts create mode 100644 packages/fastify/src/guards.ts create mode 100644 packages/fastify/src/hooks/ensureCookies.ts create mode 100644 packages/fastify/src/hooks/originGuard.ts create mode 100644 packages/fastify/src/index.ts create mode 100644 packages/fastify/src/internal/buildAuthorization.ts create mode 100644 packages/fastify/src/internal/buildForwardedClientIp.ts create mode 100644 packages/fastify/src/internal/respond.ts create mode 100644 packages/fastify/src/options.ts create mode 100644 packages/fastify/src/plugin.ts create mode 100644 packages/fastify/src/routes/adminRoutes.ts create mode 100644 packages/fastify/src/routes/authRoutes.ts create mode 100644 packages/fastify/src/routes/proxyRoutes.ts create mode 100644 packages/fastify/tests/parity.test.js create mode 100644 packages/fastify/tsconfig.build.json create mode 100644 packages/fastify/tsconfig.json diff --git a/.changeset/adapter-guards-and-cookie-contract.md b/.changeset/adapter-guards-and-cookie-contract.md new file mode 100644 index 0000000..7816cb4 --- /dev/null +++ b/.changeset/adapter-guards-and-cookie-contract.md @@ -0,0 +1,12 @@ +--- +"@seamless-auth/core": minor +"@seamless-auth/express": patch +--- + +Move the remaining guard decisions into core, and fully specify a cookie's lifetime in the response contract. + +`checkOrigin`, `authenticateCookie`, and `authorizeRoles` join `checkProxyIdentity`: each takes the request facts a guard needs and returns a `GuardRejection` or nothing, so an adapter reads headers and cookies and writes a response but decides nothing. The Express origin guard, `requireAuth`, and `requireRole` now call them, which is a straight substitution with no behavior change. `SeamlessAuthUser` moves to core with them and is re-exported from `@seamless-auth/express`, so nothing changes for adopters. + +`SetCookieCommand` and `ClearCookieCommand` now carry an `expires` alongside `maxAgeSeconds`. Previously the command specified only a max age, and two adapters could satisfy it while emitting different headers: Express sent both `Expires` and `Max-Age`, and a second adapter sent only `Max-Age`, which older clients treat as a session cookie. Specifying both means every adapter emits the same header for the same instruction. Clearing carries the epoch for the same reason. + +No change to what `@seamless-auth/express` sends. The `expires` it now receives explicitly is the value it was already deriving. diff --git a/.changeset/fastify-adapter.md b/.changeset/fastify-adapter.md new file mode 100644 index 0000000..f38f860 --- /dev/null +++ b/.changeset/fastify-adapter.md @@ -0,0 +1,11 @@ +--- +"@seamless-auth/fastify": minor +--- + +Add `@seamless-auth/fastify`, a Fastify adapter serving the same routes as the Express adapter. + +Register it under a prefix and it serves the auth flows, the passthrough proxy routes, and the admin, session, metrics, and system-config routes, managing the session cookies they depend on. `requireAuth` and `requireRole` are exported as `preHandler` hooks for an adopter's own routes, alongside `getSeamlessUser`. + +Both adapters emit identical responses. A parity suite runs the same requests through each against the same mocked auth API and asserts the status, body, and every `Set-Cookie` header match, so the two cannot drift. + +`createSeamlessConsoleProxy` has no Fastify equivalent yet. It proxies the admin console's static assets and is separate from the auth routes. diff --git a/packages/core/src/applyResult.ts b/packages/core/src/applyResult.ts index bd6186d..37bacb8 100644 --- a/packages/core/src/applyResult.ts +++ b/packages/core/src/applyResult.ts @@ -30,6 +30,16 @@ export interface SetCookieCommand { value: string; domain?: string; maxAgeSeconds: number; + /** + * The same lifetime as `maxAgeSeconds`, as an absolute time. + * + * Both are specified so every adapter emits the same header. `Max-Age` wins + * wherever it is understood; `Expires` is the fallback for clients that do + * not, which would otherwise treat the cookie as a session cookie. Leaving + * this to the adapter is how two adapters end up issuing different cookies for + * the same session. + */ + expires: Date; httpOnly: boolean; secure: boolean; sameSite: CookieSameSite; @@ -39,11 +49,20 @@ export interface SetCookieCommand { export interface ClearCookieCommand { name: string; domain?: string; + /** + * The epoch, which is what tells the browser to drop the cookie. Specified + * here for the same reason as on the set path: so every adapter emits the + * same header rather than each picking its own expression of "delete this". + */ + expires: Date; secure: boolean; sameSite: CookieSameSite; path: string; } +/** Any time in the past drops the cookie; the epoch is the conventional one. */ +const COOKIE_EPOCH = new Date(0); + /** * The three things an adapter has to be able to do with its framework's * response. Everything else about turning a handler result into a response, @@ -117,6 +136,7 @@ export function applyCookies( adapter.clearCookie({ name, domain: opts.cookieDomain, + expires: COOKIE_EPOCH, secure, sameSite, path: "/", @@ -127,12 +147,15 @@ export function applyCookies( if (result.setCookies?.length) { const secret = requireSecret(opts); + const now = Date.now(); + for (const cookie of result.setCookies) { adapter.setCookie({ name: cookie.name, value: signSessionCookie(cookie.value, secret, cookie.ttl), domain: cookie.domain, maxAgeSeconds: cookie.ttl, + expires: new Date(now + cookie.ttl * 1000), httpOnly: true, secure, sameSite, diff --git a/packages/core/src/guards.ts b/packages/core/src/guards.ts new file mode 100644 index 0000000..38733fc --- /dev/null +++ b/packages/core/src/guards.ts @@ -0,0 +1,189 @@ +import { resolveCookieSameSite, type CookieSameSite } from "./applyResult.js"; +import { hasScopedRole } from "@seamless-auth/types/role/matching"; +import { assertSecretStrength } from "./validateSecrets.js"; +import { verifyCookieJwt } from "./verifyCookieJwt.js"; + +/** + * The session a verified access cookie describes. Distinct from `SeamlessUser`, + * which is the hydrated profile fetched from the auth API: this one is only + * what the cookie itself carries. + */ +export interface SeamlessAuthUser { + id: string; + roles: string[]; + email?: string; + phone?: string | null; + iat?: number; + exp?: number; + token?: string; +} + +/** + * A guard's decision to refuse a request. Adapters render it as + * `{ error: errorCode, ...detail }` and log `warn` when it is set. + */ +export interface GuardRejection { + status: number; + errorCode: string; + detail?: Record; + warn?: string; +} + +// GET/HEAD are read-only and OPTIONS is the CORS preflight, so none can carry a +// state change worth gating. +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +export interface OriginCheckInput { + method: string; + /** `Sec-Fetch-Site`, already reduced to a single value. */ + secFetchSite?: string; + /** `Origin`, already reduced to a single value. */ + origin?: string; + cookieSecure?: boolean; + cookieSameSite?: CookieSameSite; + allowedOrigins?: string[]; +} + +function normalizeOrigin(value: string): string { + return value.trim().toLowerCase(); +} + +/** + * Decides whether a cross-site state-changing request should be refused. + * + * Only matters when the adapter issues `SameSite=None` cookies, which the + * browser would otherwise attach to a forged cross-site request. A `Lax` or + * `Strict` cookie is not sent on one, so the check is inert there. + * + * `Sec-Fetch-Site` is the primary signal: current browsers send it and page + * JavaScript cannot forge it. When it is absent the `Origin` is matched against + * `allowedOrigins`, but only when the adopter opted in, so nothing regresses for + * callers that predate the guard. + */ +export function checkOrigin( + input: OriginCheckInput, +): GuardRejection | undefined { + const active = resolveCookieSameSite(input) === "none"; + + if (!active || SAFE_METHODS.has(input.method)) { + return undefined; + } + + const rejection: GuardRejection = { + status: 403, + errorCode: "cross_site_request_blocked", + }; + + if (input.secFetchSite !== undefined) { + return input.secFetchSite.toLowerCase() === "cross-site" + ? rejection + : undefined; + } + + // No `Origin` and no `Sec-Fetch-Site` is a same-origin or non-browser + // server-to-server caller. + if (input.origin === undefined) { + return undefined; + } + + // A literal `null` origin is opaque or sandboxed, which is cross-site + // regardless of the allowlist. + if (input.origin === "null") { + return rejection; + } + + // Older browser, but the adopter has not opted into an allowlist. Preserve the + // pre-guard behavior rather than start rejecting these. + if (!input.allowedOrigins) { + return undefined; + } + + const allowed = new Set( + input.allowedOrigins.map(normalizeOrigin).filter(Boolean), + ); + + return allowed.has(normalizeOrigin(input.origin)) ? undefined : rejection; +} + +export interface CookieAuthInput { + /** The raw access cookie, or `undefined` when the request carried none. */ + token?: string; + cookieSecret: string; +} + +export type CookieAuthResult = + | { user: SeamlessAuthUser; rejection?: undefined } + | { user?: undefined; rejection: GuardRejection }; + +/** + * Verifies an access cookie into a session. + * + * Does not refresh: silent refresh belongs to `ensureCookies`, mounted on the + * auth router. A guard on an adopter's own route only reads what is already + * there. + */ +export function authenticateCookie(input: CookieAuthInput): CookieAuthResult { + assertSecretStrength("requireAuth: cookieSecret", input.cookieSecret); + + if (!input.token) { + return { + rejection: { + status: 401, + errorCode: "Failed to find authentication token required", + warn: "Missing expected auth cookie.", + }, + }; + } + + const payload = verifyCookieJwt(input.token, input.cookieSecret); + + if (!payload || !payload.sub) { + return { + rejection: { status: 401, errorCode: "Invalid or expired session" }, + }; + } + + return { + user: { + id: payload.sub, + roles: Array.isArray(payload.roles) ? payload.roles : [], + email: payload.email, + phone: payload.phone, + iat: payload.iat, + exp: payload.exp, + token: payload.token, + }, + }; +} + +/** + * Authorization only, against a session a guard has already authenticated. + * + * Any one of the required roles is enough. Scoped names are understood: a broad + * `admin` grants everything under it, and a `:write` role grants the matching + * `:read`. + */ +export function authorizeRoles( + user: SeamlessAuthUser | undefined, + requiredRoles: string | string[], +): GuardRejection | undefined { + const roles = Array.isArray(requiredRoles) ? requiredRoles : [requiredRoles]; + + if (!user) { + return { status: 401, errorCode: "Authentication required" }; + } + + if (!Array.isArray(user.roles)) { + return { status: 403, errorCode: "User has no roles assigned" }; + } + + if (!hasScopedRole(user.roles, roles)) { + return { + status: 403, + errorCode: "Insufficient role", + detail: { required: roles, actual: user.roles }, + }; + } + + return undefined; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d66a615..7408d31 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,6 +7,7 @@ export * from "./verifyRefreshCookie.js"; export * from "./verifySignedAuthResponse.js"; export * from "./refreshAccessToken.js"; export * from "./getSeamlessUser.js"; +export * from "./guards.js"; export * from "./createServiceToken.js"; export * from "./validateSecrets.js"; // Role matching decides whether a request is authorized, and the auth API runs diff --git a/packages/core/tests/applyResult.test.js b/packages/core/tests/applyResult.test.js index 56efc58..6499d1f 100644 --- a/packages/core/tests/applyResult.test.js +++ b/packages/core/tests/applyResult.test.js @@ -148,6 +148,7 @@ describe("cookies", () => { { name: "seamless-ephemeral", domain: "acme.test", + expires: new Date(0), secure: true, sameSite: "none", path: "/", @@ -155,6 +156,30 @@ describe("cookies", () => { ]); }); + // Both lifetimes are specified so every adapter emits the same header. Left to + // the adapter, one framework sends Max-Age only and another sends both, and + // the two issue different cookies for the same session. + it("gives a set cookie both a max age and a matching absolute expiry", () => { + const adapter = recorder(); + const before = Date.now(); + + applyResult(cookieResult, adapter, { cookieSecret: SECRET }); + + const { maxAgeSeconds, expires } = adapter.calls.set[0]; + expect(maxAgeSeconds).toBe(300); + expect(expires).toBeInstanceOf(Date); + expect(expires.getTime()).toBeGreaterThanOrEqual(before + 300 * 1000); + expect(expires.getTime()).toBeLessThanOrEqual(Date.now() + 300 * 1000); + }); + + it("clears with the epoch", () => { + const adapter = recorder(); + + applyResult(cookieResult, adapter, { cookieSecret: SECRET }); + + expect(adapter.calls.cleared[0].expires.getTime()).toBe(0); + }); + it("clears before setting, since doing both replaces a session", () => { const order = []; const adapter = { diff --git a/packages/express/src/internal/respond.ts b/packages/express/src/internal/respond.ts index 5077d0c..1d568cc 100644 --- a/packages/express/src/internal/respond.ts +++ b/packages/express/src/internal/respond.ts @@ -24,6 +24,7 @@ export function expressResponseAdapter(res: Response): ResponseAdapter { path: command.path, domain: command.domain, maxAge: command.maxAgeSeconds * 1000, + expires: command.expires, }); }, @@ -33,6 +34,7 @@ export function expressResponseAdapter(res: Response): ResponseAdapter { sameSite: command.sameSite, domain: command.domain, path: command.path, + expires: command.expires, }); }, diff --git a/packages/express/src/middleware/originGuard.ts b/packages/express/src/middleware/originGuard.ts index 8ff1c53..dded4bc 100644 --- a/packages/express/src/middleware/originGuard.ts +++ b/packages/express/src/middleware/originGuard.ts @@ -1,9 +1,6 @@ import { NextFunction, Request, Response } from "express"; -import { - resolveCookieSameSite, - type CookieSameSite, -} from "@seamless-auth/core"; +import { checkOrigin, type CookieSameSite } from "@seamless-auth/core"; export interface OriginGuardOptions { cookieSecure?: boolean; @@ -11,94 +8,31 @@ export interface OriginGuardOptions { allowedOrigins?: string[]; } -// GET/HEAD are read-only and OPTIONS is the CORS preflight, so none can carry a -// state change worth gating. -const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); - /** * Rejects cross-site state-changing requests when the adapter issues - * `SameSite=None` cookies, which the browser would otherwise attach to a forged - * cross-site request. `Sec-Fetch-Site` is the primary signal: it ships on current - * browsers and page JavaScript cannot forge it. When it is absent (older - * browsers) the request `Origin` is matched against `allowedOrigins`, but only - * when the adopter opted in, so nothing regresses for callers that predate this. + * `SameSite=None` cookies. The decision is core's; this reads the headers and + * writes the response. */ export function createOriginGuardMiddleware(opts: OriginGuardOptions) { - // A `Lax`/`Strict` cookie is not sent on a cross-site state-changing request, - // so the guard is inert and only `None` needs gating. - const active = resolveCookieSameSite(opts) === "none"; - const allowedOrigins = normalizeAllowedOrigins(opts.allowedOrigins); - return function originGuard(req: Request, res: Response, next: NextFunction) { - if (!active || SAFE_METHODS.has(req.method)) { - next(); - return; - } - - const secFetchSite = firstHeader(req.headers["sec-fetch-site"]); - if (secFetchSite !== undefined) { - if (secFetchSite.toLowerCase() === "cross-site") { - return reject(res); - } - next(); - return; - } - - const origin = firstHeader(req.headers.origin); - if (origin === undefined) { - // No `Origin` and no `Sec-Fetch-Site` is a same-origin or non-browser - // server-to-server caller. Let it through. - next(); - return; - } - - // A literal `null` origin is an opaque or sandboxed origin, which is - // cross-site regardless of the allowlist. - if (origin === "null") { - return reject(res); - } - - if (allowedOrigins === null) { - // Older browser, but the adopter has not opted into an allowlist. Preserve - // the pre-guard behavior rather than start rejecting these. - next(); + const rejection = checkOrigin({ + method: req.method, + secFetchSite: firstHeader(req.headers["sec-fetch-site"]), + origin: firstHeader(req.headers.origin), + cookieSecure: opts.cookieSecure, + cookieSameSite: opts.cookieSameSite, + allowedOrigins: opts.allowedOrigins, + }); + + if (rejection) { + res.status(rejection.status).json({ error: rejection.errorCode }); return; } - if (!allowedOrigins.has(normalizeOrigin(origin))) { - return reject(res); - } - next(); }; } -function reject(res: Response): void { - res.status(403).json({ error: "cross_site_request_blocked" }); -} - function firstHeader(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value; } - -function normalizeOrigin(value: string): string { - return value.trim().toLowerCase(); -} - -function normalizeAllowedOrigins( - origins: string[] | undefined, -): Set | null { - if (!origins) { - return null; - } - - const normalized = new Set(); - for (const origin of origins) { - const value = normalizeOrigin(origin); - if (value) { - normalized.add(value); - } - } - - return normalized; -} diff --git a/packages/express/src/middleware/requireAuth.ts b/packages/express/src/middleware/requireAuth.ts index c446bbb..44085c2 100644 --- a/packages/express/src/middleware/requireAuth.ts +++ b/packages/express/src/middleware/requireAuth.ts @@ -1,6 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import { assertSecretStrength, verifyCookieJwt } from "@seamless-auth/core"; -import { SeamlessAuthUser } from "../createServer"; +import { assertSecretStrength, authenticateCookie } from "@seamless-auth/core"; export interface RequireAuthOptions { cookieName?: string; @@ -34,40 +33,26 @@ export interface RequireAuthOptions { export function requireAuth(opts: RequireAuthOptions) { const { cookieName = "seamless-access", cookieSecret } = opts; + // Eagerly, so a weak secret fails at setup rather than on the first request. assertSecretStrength("requireAuth: cookieSecret", cookieSecret); return function (req: Request, res: Response, next: NextFunction) { - const token = req.cookies?.[cookieName]; - - if (!token) { - console.warn( - "[SEAMLESS-AUTH-EXPRESS] - (requireAuth) - Missing expected auth cookie. Ensure you are using `cookieParser` in your express server", - ); - res.status(401).json({ - error: "Failed to find authentication token required", - }); - return; - } - - const payload = verifyCookieJwt(token, cookieSecret); - - if (!payload || !payload.sub) { - res.status(401).json({ - error: "Invalid or expired session", - }); + const { user, rejection } = authenticateCookie({ + token: req.cookies?.[cookieName], + cookieSecret, + }); + + if (rejection) { + if (rejection.warn) { + console.warn( + `[SEAMLESS-AUTH-EXPRESS] - (requireAuth) - ${rejection.warn} Ensure you are using \`cookieParser\` in your express server`, + ); + } + + res.status(rejection.status).json({ error: rejection.errorCode }); return; } - const user: SeamlessAuthUser = { - id: payload.sub, - roles: Array.isArray(payload.roles) ? payload.roles : [], - email: payload.email, - phone: payload.phone, - iat: payload.iat, - exp: payload.exp, - token: payload.token, - }; - req.user = user; next(); }; diff --git a/packages/express/src/middleware/requireRole.ts b/packages/express/src/middleware/requireRole.ts index 758ba35..09b80ab 100644 --- a/packages/express/src/middleware/requireRole.ts +++ b/packages/express/src/middleware/requireRole.ts @@ -1,4 +1,4 @@ -import { hasScopedRole } from "@seamless-auth/core"; +import { authorizeRoles } from "@seamless-auth/core"; import { Request, Response, NextFunction, RequestHandler } from "express"; /** @@ -41,32 +41,13 @@ import { Request, Response, NextFunction, RequestHandler } from "express"; * @param requiredRoles - A role or list of roles required to access the route */ export function requireRole(requiredRoles: string | string[]): RequestHandler { - const roles = Array.isArray(requiredRoles) ? requiredRoles : [requiredRoles]; - return (req: Request, res: Response, next: NextFunction): void => { - const user = req.user; - - if (!user) { - res.status(401).json({ - error: "Authentication required", - }); - return; - } - - if (!Array.isArray(user.roles)) { - res.status(403).json({ - error: "User has no roles assigned", - }); - return; - } - - const hasRole = hasScopedRole(user.roles, roles); + const rejection = authorizeRoles(req.user, requiredRoles); - if (!hasRole) { - res.status(403).json({ - error: "Insufficient role", - required: roles, - actual: user.roles, + if (rejection) { + res.status(rejection.status).json({ + error: rejection.errorCode, + ...rejection.detail, }); return; } diff --git a/packages/fastify/LICENSE b/packages/fastify/LICENSE new file mode 100644 index 0000000..bf9fc5a --- /dev/null +++ b/packages/fastify/LICENSE @@ -0,0 +1,79 @@ +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + +This license is identical to the GNU General Public License, except that it also +ensures that software running as a network service makes its source code +available to users. + +--- + +TERMS AND CONDITIONS + +0. Definitions. + +“This License” refers to version 3 of the GNU Affero General Public License. + +“Copyright” also means copyright-like laws that apply to other kinds of works, +such as semiconductor masks. + +The “Program” refers to any copyrightable work licensed under this License. +Each licensee is addressed as “you”. + +To “modify” a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact copy. + +To “propagate” a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under applicable +copyright law, except executing it on a computer or modifying a private copy. + +To “convey” a work means any kind of propagation that enables other parties to +make or receive copies. + +An interactive user interface displays “Appropriate Legal Notices” to the extent +that it includes a convenient and prominently visible feature that displays an +appropriate copyright notice, and tells the user that there is no warranty for +the work (except to the extent that warranties are provided), that licensees may +convey the work under this License, and how to view a copy of this License. + +--- + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the Program, +your modified version must prominently offer all users interacting with it +remotely through a computer network an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source from a +network server at no charge, through some standard or customary means of +facilitating copying of software. + +--- + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER +PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER +EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. + +--- + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY +COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS +PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, +INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE +THE PROGRAM. + +--- + +END OF TERMS AND CONDITIONS + +You should have received a copy of the GNU Affero General Public License along +with this program. If not, see . diff --git a/packages/fastify/LICENSE.md b/packages/fastify/LICENSE.md new file mode 100644 index 0000000..4685887 --- /dev/null +++ b/packages/fastify/LICENSE.md @@ -0,0 +1,26 @@ +# License + +Seamless Auth Server - Express ("@seamless-auth/express") is licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0-only)**. + +- SPDX: `AGPL-3.0-only` + +## What this means (high level) + +- You are free to **use**, **modify**, and **self-host** this software. +- If you **modify** this software and **run it as a network service** (for example, hosting it for others to use), you must **make the complete corresponding source code of your modified version available** to users of that service, under the AGPL. + +This summary is not legal advice and does not replace the license text. + +## Full license text + +The full license text is available here: + +- https://www.gnu.org/licenses/agpl-3.0.html + +You should include a copy of the AGPLv3 license in your distribution. If this repository does not contain the full license text yet, add it as `LICENSE` or `LICENSE.txt` (recommended), and keep this `LICENSE.md` as the human-friendly summary. + +## Commercial licensing + +If you would like to embed Seamless Auth API into a proprietary product, redistribute it under different terms, or offer it as a managed service without AGPL obligations, commercial licensing may be available. + +Contact: support@seamlessauth.com diff --git a/packages/fastify/README.md b/packages/fastify/README.md new file mode 100644 index 0000000..7461bf9 --- /dev/null +++ b/packages/fastify/README.md @@ -0,0 +1,136 @@ +# @seamless-auth/fastify + +Fastify adapter for [Seamless Auth](https://seamlessauth.com) passwordless +authentication. + +It serves the Seamless Auth routes from your own backend and manages the session +cookies they depend on, so the browser talks to your origin and never holds a +token itself. The decisions all live in `@seamless-auth/core`; this package binds +them to Fastify. + +## Install + +```sh +npm install @seamless-auth/fastify fastify +``` + +## Use + +```ts +import Fastify from "fastify"; +import seamlessAuth from "@seamless-auth/fastify"; + +const app = Fastify(); + +await app.register(seamlessAuth, { + prefix: "/auth", + authServerUrl: "https://identifier.seamlessauth.com", + audience: "https://identifier.seamlessauth.com", + cookieSecret: process.env.COOKIE_SECRET!, + serviceSecret: process.env.SERVICE_SECRET!, + jwksKid: "2024-09-main", +}); + +await app.listen({ port: 3000 }); +``` + +Register it under a prefix. Fastify's encapsulation keeps the cookie and origin +hooks scoped to those routes, so the rest of your application is untouched. +`@fastify/cookie` is registered for you inside the plugin. + +`cookieSecret` and `serviceSecret` must be at least 32 characters and the +`serviceSecret` must match the auth API's. Both are checked at registration, so a +weak secret fails at startup rather than on the first request. + +## Guarding your own routes + +`requireAuth` and `requireRole` are `preHandler` hooks and work anywhere, without +the plugin: + +```ts +import { requireAuth, requireRole } from "@seamless-auth/fastify"; + +const authenticated = requireAuth({ cookieSecret: process.env.COOKIE_SECRET! }); + +app.get("/api/me", { preHandler: authenticated }, async (req) => ({ + user: req.user, +})); + +app.get( + "/api/admin/reports", + { preHandler: [authenticated, requireRole("admin:read")] }, + listReports, +); +``` + +`requireAuth` verifies the access cookie and puts the session on `request.user`. +It does not refresh: silent refresh belongs to the plugin's own hook on the auth +routes. Role checks understand scoped names, so `admin` grants everything under +it and `admin:write` grants `admin:read`. + +For the hydrated profile rather than the cookie payload, `getSeamlessUser(request, options)` +fetches it from the auth API and returns `SeamlessUser | null`. + +## Adopter-supplied message delivery + +Pass `messaging` to have the adapter deliver OTPs and magic links through your +own transports instead of the auth API sending them: + +```ts +await app.register(seamlessAuth, { + prefix: "/auth", + // ... + messaging: { + email: myEmailTransport, + defaults: { appName: "Acme", emailFrom: "no-reply@acme.test" }, + }, +}); +``` + +Delivery payloads carry one-time codes and links. They are stripped from the +response before it reaches the browser. + +## Options + +| Option | Default | Purpose | +| --- | --- | --- | +| `authServerUrl` | required | Base URL of your Seamless Auth instance | +| `audience` | required | Audience your user tokens are issued for | +| `cookieSecret` | required | Signs the session cookies, 32 characters minimum | +| `serviceSecret` | required | Shared secret for machine-to-machine calls | +| `jwksKid` | `dev-main` | Active JWKS key id; set it explicitly before deploying | +| `cookieDomain` | none | Domain attribute for the auth cookies | +| `cookieSecure` | `true` | Set `false` only for local HTTP development | +| `cookieSameSite` | `none` when secure, else `lax` | SameSite policy | +| `allowedOrigins` | none | Origin allowlist for browsers without `Sec-Fetch-Site` | +| `accessCookieName` | `seamless-access` | Session cookie name | +| `registrationCookieName` | `seamless-ephemeral` | Registration cookie name | +| `refreshCookieName` | `seamless-refresh` | Refresh cookie name | +| `preAuthCookieName` | `seamless-ephemeral` | Login-initiation cookie name | +| `messaging` | none | Adopter-supplied delivery transports and overrides | +| `resolveClientIp` | none | Resolver for the end user's IP | + +### Client IP + +The adapter forwards the end user's IP so the auth API can rate limit and audit +against the real caller. With Fastify's `trustProxy` set to blanket `true`, +`request.ip` comes from the leftmost `X-Forwarded-For` entry, which any client +can set, so the adapter drops it and warns rather than forwarding a value the +caller chose. Set `trustProxy` to an explicit hop count or subnet, or pass +`resolveClientIp`. + +## Relationship to `@seamless-auth/express` + +Both adapters serve the same routes and issue the same cookies. A parity suite +runs the same requests through both against the same mocked auth API and asserts +the status, body, and every `Set-Cookie` header match, so the two cannot drift. + +## Not included + +`createSeamlessConsoleProxy` from the Express adapter, which proxies the admin +console's static assets, has no Fastify equivalent yet. It is a separate concern +from the auth routes. + +## License + +AGPL-3.0-only. Copyright © Fells Code, LLC. diff --git a/packages/fastify/jest.config.ts b/packages/fastify/jest.config.ts new file mode 100644 index 0000000..a12f3b6 --- /dev/null +++ b/packages/fastify/jest.config.ts @@ -0,0 +1,9 @@ +export default { + testEnvironment: "node", + testMatch: ["**/tests/**/*.test.js"], + clearMocks: true, + moduleNameMapper: { + "^@seamless-auth/core$": "/../core/dist/index.js", + "^@seamless-auth/core/(.*)$": "/../core/dist/$1.js", + }, +}; diff --git a/packages/fastify/package.json b/packages/fastify/package.json new file mode 100644 index 0000000..39a1a52 --- /dev/null +++ b/packages/fastify/package.json @@ -0,0 +1,78 @@ +{ + "name": "@seamless-auth/fastify", + "version": "0.10.0", + "description": "Fastify adapter for Seamless Auth passwordless authentication", + "keywords": [ + "authentication", + "passwordless", + "fastify", + "webauthn", + "passkeys", + "session", + "cookies", + "seamless-auth" + ], + "license": "AGPL-3.0-only", + "type": "module", + "main": "dist/index.js", + "types": "./dist/index.d.ts", + "author": "Fells Code, LLC", + "homepage": "https://seamlessauth.com", + "files": [ + "dist", + "LICENSE", + "LICENSE.md", + "README.md" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "engines": { + "node": ">=24 <25" + }, + "scripts": { + "build": "pnpm run build:js && pnpm run build:types", + "build:js": "tsup src/index.ts --format esm --out-dir dist --splitting --external @seamless-auth/core --external @seamless-auth/core/*", + "build:types": "tsc -p tsconfig.build.json", + "clean": "rm -rf dist", + "dev": "tsc --watch", + "prepublishOnly": "pnpm run clean && pnpm run build", + "test": "pnpm --filter @seamless-auth/core build && pnpm run build && NODE_OPTIONS=--experimental-vm-modules jest", + "test:watch": "pnpm --filter @seamless-auth/core build && pnpm run build && NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "repository": { + "type": "git", + "url": "https://github.com/fells-code/seamless-auth-server.git", + "directory": "packages/fastify" + }, + "bugs": { + "url": "https://github.com/fells-code/seamless-auth-server/issues" + }, + "peerDependencies": { + "fastify": ">=5.0.0" + }, + "dependencies": { + "@fastify/cookie": "^11.0.2", + "@seamless-auth/core": "workspace:^", + "fastify-plugin": "^5.0.1" + }, + "devDependencies": { + "@seamless-auth/express": "workspace:^", + "@types/jest": "^29.5.14", + "@types/jsonwebtoken": "^9.0.10", + "express": "^5.2.1", + "fastify": "^5.10.0", + "jest": "^29.7.0", + "jsonwebtoken": "^9.0.3", + "supertest": "^7.2.2", + "ts-node": "^10.9.2", + "tsup": "^8.5.1", + "typescript": "^5.5.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/fastify/src/getSeamlessUser.ts b/packages/fastify/src/getSeamlessUser.ts new file mode 100644 index 0000000..3bfed03 --- /dev/null +++ b/packages/fastify/src/getSeamlessUser.ts @@ -0,0 +1,23 @@ +import type { FastifyRequest } from "fastify"; +import { getSeamlessUser as getSeamlessUserCore } from "@seamless-auth/core"; + +import { + buildProxyServiceAuthorization, + buildServiceAuthorization, +} from "./internal/buildAuthorization"; +import { buildForwardedClientIp } from "./internal/buildForwardedClientIp"; +import type { SeamlessAuthServerOptions } from "./options"; + +export async function getSeamlessUser( + req: FastifyRequest, + opts: SeamlessAuthServerOptions, +) { + return getSeamlessUserCore(req.cookies ?? {}, { + authServerUrl: opts.authServerUrl, + cookieSecret: opts.cookieSecret, + cookieName: opts.accessCookieName ?? "seamless-access", + authorization: buildServiceAuthorization(req), + serviceAuthorization: buildProxyServiceAuthorization(opts), + forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), + }); +} diff --git a/packages/fastify/src/guards.ts b/packages/fastify/src/guards.ts new file mode 100644 index 0000000..ff4afc0 --- /dev/null +++ b/packages/fastify/src/guards.ts @@ -0,0 +1,88 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; +import { + assertSecretStrength, + authenticateCookie, + authorizeRoles, +} from "@seamless-auth/core"; + +export interface RequireAuthOptions { + cookieName?: string; + cookieSecret: string; +} + +/** + * Fastify `preHandler` that enforces authentication using an already-issued + * Seamless Auth access cookie. + * + * Verifies the signed access cookie, attaches the decoded session to + * `request.user`, and replies 401 when the cookie is missing or invalid. + * + * This guard does NOT attempt token refresh. Silent refresh is handled by the + * plugin's own hook on the auth routes. + * + * ### Example + * ```ts + * const guard = requireAuth({ cookieSecret: process.env.COOKIE_SECRET! }); + * + * app.get("/api/me", { preHandler: guard }, async (req) => ({ user: req.user })); + * ``` + */ +export function requireAuth(opts: RequireAuthOptions) { + const { cookieName = "seamless-access", cookieSecret } = opts; + + // Eagerly, so a weak secret fails at setup rather than on the first request. + assertSecretStrength("requireAuth: cookieSecret", cookieSecret); + + return async function requireAuthHook( + req: FastifyRequest, + reply: FastifyReply, + ) { + const { user, rejection } = authenticateCookie({ + token: req.cookies?.[cookieName], + cookieSecret, + }); + + if (rejection) { + if (rejection.warn) { + req.log.warn( + `[SEAMLESS-AUTH-FASTIFY] - (requireAuth) - ${rejection.warn} Ensure @fastify/cookie is registered.`, + ); + } + + return reply + .status(rejection.status) + .send({ error: rejection.errorCode }); + } + + req.user = user; + }; +} + +/** + * Fastify `preHandler` that enforces role-based authorization, against a session + * `requireAuth` has already put on the request. + * + * Any one of the required roles is enough. Scoped names are understood: a broad + * `admin` grants everything under it, and a `:write` role grants `:read`. + * + * ### Example + * ```ts + * app.get("/admin/users", { + * preHandler: [requireAuth({ cookieSecret }), requireRole("admin")], + * }, listUsers); + * ``` + */ +export function requireRole(requiredRoles: string | string[]) { + return async function requireRoleHook( + req: FastifyRequest, + reply: FastifyReply, + ) { + const rejection = authorizeRoles(req.user, requiredRoles); + + if (rejection) { + return reply + .status(rejection.status) + .send({ error: rejection.errorCode, ...rejection.detail }); + } + }; +} diff --git a/packages/fastify/src/hooks/ensureCookies.ts b/packages/fastify/src/hooks/ensureCookies.ts new file mode 100644 index 0000000..0ba943b --- /dev/null +++ b/packages/fastify/src/hooks/ensureCookies.ts @@ -0,0 +1,81 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; +import { + applyCookies, + assertSecrets, + ensureCookies, + SERVICE_TOKEN_AUDIENCE, + SERVICE_TOKEN_ISSUER, +} from "@seamless-auth/core"; + +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; +import { fastifyResponseAdapter } from "../internal/respond"; +import type { ResolvedOptions } from "../options"; + +/** + * Verifies the session cookies on every request into the plugin, refreshing them + * when the access cookie has expired but the refresh cookie is still good. + * + * Runs as `onRequest` so a refused request never reaches a route, and so the + * refreshed payload is on the request before any handler reads it. + */ +/** + * `ensureCookies` matches the request path against its own route table, and + * those entries are mount-relative. Express hands a mounted router a `req.path` + * that already has the mount point stripped; Fastify's `req.url` keeps the + * prefix, so it has to come off here or nothing matches and every route silently + * loses its cookie payload. + */ +function mountRelativePath(url: string, prefix: string): string { + const path = url.split("?")[0]; + + if (!prefix || prefix === "/") { + return path; + } + + return path.startsWith(prefix) ? path.slice(prefix.length) || "/" : path; +} + +export function createEnsureCookiesHook(opts: ResolvedOptions, prefix: string) { + assertSecrets(opts); + + return async function ensureCookiesHook( + req: FastifyRequest, + reply: FastifyReply, + ) { + const result = await ensureCookies( + { + path: mountRelativePath(req.url, prefix), + cookies: req.cookies ?? {}, + }, + { + authServerUrl: opts.authServerUrl, + cookieDomain: opts.cookieDomain, + accessCookieName: opts.accessCookieName, + registrationCookieName: opts.registrationCookieName, + refreshCookieName: opts.refreshCookieName, + preAuthCookieName: opts.preAuthCookieName, + cookieSecret: opts.cookieSecret, + serviceSecret: opts.serviceSecret, + // The silent-refresh path mints an M2M service token, which the auth API + // validates against a fixed issuer and audience rather than the + // adopter-configured one. + issuer: SERVICE_TOKEN_ISSUER, + audience: SERVICE_TOKEN_AUDIENCE, + keyId: opts.jwksKid, + forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), + }, + ); + + applyCookies(result, fastifyResponseAdapter(reply), opts); + + if (result.user) { + req.cookiePayload = result.user; + } + + if (result.type === "error") { + return reply + .status(result.status ?? 401) + .send({ error: result.errorCode }); + } + }; +} diff --git a/packages/fastify/src/hooks/originGuard.ts b/packages/fastify/src/hooks/originGuard.ts new file mode 100644 index 0000000..a5fecd9 --- /dev/null +++ b/packages/fastify/src/hooks/originGuard.ts @@ -0,0 +1,36 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; +import { checkOrigin, type CookieSameSite } from "@seamless-auth/core"; + +export interface OriginGuardOptions { + cookieSecure?: boolean; + cookieSameSite?: CookieSameSite; + allowedOrigins?: string[]; +} + +/** + * Rejects cross-site state-changing requests when the plugin issues + * `SameSite=None` cookies. The decision is core's; this reads the headers and + * writes the response. + */ +export function createOriginGuardHook(opts: OriginGuardOptions) { + return async function originGuard(req: FastifyRequest, reply: FastifyReply) { + const rejection = checkOrigin({ + method: req.method, + secFetchSite: firstHeader(req.headers["sec-fetch-site"]), + origin: firstHeader(req.headers.origin), + cookieSecure: opts.cookieSecure, + cookieSameSite: opts.cookieSameSite, + allowedOrigins: opts.allowedOrigins, + }); + + if (rejection) { + return reply + .status(rejection.status) + .send({ error: rejection.errorCode }); + } + }; +} + +function firstHeader(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} diff --git a/packages/fastify/src/index.ts b/packages/fastify/src/index.ts new file mode 100644 index 0000000..9465f15 --- /dev/null +++ b/packages/fastify/src/index.ts @@ -0,0 +1,24 @@ +import { seamlessAuth } from "./plugin"; + +export { seamlessAuth }; +export { requireAuth, requireRole } from "./guards"; +export type { RequireAuthOptions } from "./guards"; +export { getSeamlessUser } from "./getSeamlessUser"; +export type { SeamlessAuthServerOptions } from "./options"; +export type { ClientIpResolver } from "./internal/buildForwardedClientIp"; +export { fastifyResponseAdapter } from "./internal/respond"; +export type { + AuthMessageOverrides, + AuthMessagingHandlers, + DeliveryResult, + EmailMessage, + EmailTransport, + SeamlessAuthMessagingOptions, + SeamlessAuthUser, + SeamlessUser, + SmsMessage, + SmsTransport, +} from "@seamless-auth/core"; +export { hasScopedRole, roleGrantsAccess } from "@seamless-auth/core"; + +export default seamlessAuth; diff --git a/packages/fastify/src/internal/buildAuthorization.ts b/packages/fastify/src/internal/buildAuthorization.ts new file mode 100644 index 0000000..9312e48 --- /dev/null +++ b/packages/fastify/src/internal/buildAuthorization.ts @@ -0,0 +1,75 @@ +import type { FastifyRequest } from "fastify"; +import { + buildExternalDeliveryAuthorization, + createServiceToken, + DEV_JWKS_KID, + SERVICE_TOKEN_AUDIENCE, + SERVICE_TOKEN_ISSUER, +} from "@seamless-auth/core"; + +import type { SeamlessAuthServerOptions } from "../options"; + +export function buildServiceAuthorization(req: FastifyRequest) { + const token = req.cookiePayload?.token || req.user?.token; + + return typeof token === "string" ? `Bearer ${token}` : undefined; +} + +// createServiceToken mints a 60s token. Reuse it for slightly less than that so a +// proxied request never signs a fresh JWT, and never presents a token that expires +// in flight. +const PROXY_TOKEN_REUSE_MS = 45_000; + +let proxyTokenCache: + | { authorization: string; secret: string; keyId: string; expiresAt: number } + | undefined; + +// Identifies the adapter itself, not the browser user. The auth API only requires a +// truthy `sub`, so this stays a constant service name with nothing user-derived in it. +const PROXY_TOKEN_SUBJECT = "seamless-auth-fastify-adapter"; + +export function buildProxyServiceAuthorization( + opts: Pick, +): string | undefined { + // getSeamlessUser is callable standalone, without the serviceSecret the plugin + // requires. Skip the service token there rather than throwing; the auth API simply + // will not honor the forwarded client IP. + if (!opts.serviceSecret) { + return undefined; + } + + const keyId = opts.jwksKid || DEV_JWKS_KID; + const now = Date.now(); + + if ( + proxyTokenCache && + proxyTokenCache.secret === opts.serviceSecret && + proxyTokenCache.keyId === keyId && + proxyTokenCache.expiresAt > now + ) { + return proxyTokenCache.authorization; + } + + const authorization = `Bearer ${createServiceToken({ + subject: PROXY_TOKEN_SUBJECT, + issuer: SERVICE_TOKEN_ISSUER, + audience: SERVICE_TOKEN_AUDIENCE, + serviceSecret: opts.serviceSecret, + keyId, + })}`; + + proxyTokenCache = { + authorization, + secret: opts.serviceSecret, + keyId, + expiresAt: now + PROXY_TOKEN_REUSE_MS, + }; + + return authorization; +} + +export function buildInternalServiceAuthorization( + opts: Pick, +) { + return buildExternalDeliveryAuthorization(opts); +} diff --git a/packages/fastify/src/internal/buildForwardedClientIp.ts b/packages/fastify/src/internal/buildForwardedClientIp.ts new file mode 100644 index 0000000..6ce3f35 --- /dev/null +++ b/packages/fastify/src/internal/buildForwardedClientIp.ts @@ -0,0 +1,42 @@ +import { isIP } from "node:net"; + +import type { FastifyRequest } from "fastify"; + +export type ClientIpResolver = (req: FastifyRequest) => string | undefined; + +let warnedBlanketTrustProxy = false; + +// With `trustProxy` set to blanket true, Fastify derives request.ip from the +// leftmost X-Forwarded-For entry, which any client can set. Forwarding that +// upstream would let a caller pick its own rate-limit and audit identity, so drop +// it instead. +function derivedFromTrustedHop(req: FastifyRequest): string | undefined { + if ( + (req.server as { initialConfig?: { trustProxy?: unknown } }).initialConfig + ?.trustProxy === true + ) { + if (!warnedBlanketTrustProxy) { + warnedBlanketTrustProxy = true; + console.warn( + "[seamless-auth] Fastify 'trustProxy' is set to true, so request.ip is client-controlled. " + + "The client IP will not be forwarded to the auth API. Set 'trustProxy' to an explicit " + + "hop count or subnet, or pass resolveClientIp to the plugin.", + ); + } + + return undefined; + } + + return req.ip; +} + +export function buildForwardedClientIp( + req: FastifyRequest, + resolveClientIp?: ClientIpResolver, +): string | undefined { + const candidate = resolveClientIp + ? resolveClientIp(req) + : derivedFromTrustedHop(req); + + return candidate && isIP(candidate) !== 0 ? candidate : undefined; +} diff --git a/packages/fastify/src/internal/respond.ts b/packages/fastify/src/internal/respond.ts new file mode 100644 index 0000000..cbbe1c9 --- /dev/null +++ b/packages/fastify/src/internal/respond.ts @@ -0,0 +1,61 @@ +import type { FastifyReply } from "fastify"; +import { + applyResult, + type AppliableResult, + type CookieSecurityOptions, + type ResponseAdapter, +} from "@seamless-auth/core"; + +/** + * Fastify half of core's response contract: emit a cookie, clear a cookie, send + * a body. What to emit is decided in core. + * + * `clearCookie` mirrors the set-path attributes on purpose. A clearing header + * without `Secure; SameSite=None` is dropped by the browser in a cross-site + * response, leaving the session cookie in place. + */ +export function fastifyResponseAdapter(reply: FastifyReply): ResponseAdapter { + return { + setCookie(command) { + reply.setCookie(command.name, command.value, { + httpOnly: command.httpOnly, + secure: command.secure, + sameSite: command.sameSite, + path: command.path, + domain: command.domain || undefined, + maxAge: command.maxAgeSeconds, + expires: command.expires, + }); + }, + + // Written with setCookie rather than clearCookie: @fastify/cookie's clear + // path also emits `Max-Age=0`, and the header has to match what every other + // adapter sends for the same instruction. + clearCookie(command) { + reply.setCookie(command.name, "", { + secure: command.secure, + sameSite: command.sameSite, + domain: command.domain || undefined, + path: command.path, + expires: command.expires, + }); + }, + + send(status, body) { + if (body === undefined) { + reply.status(status).send(); + return; + } + + reply.status(status).send(body); + }, + }; +} + +export function respond( + reply: FastifyReply, + result: AppliableResult, + opts: CookieSecurityOptions, +): void { + applyResult(result, fastifyResponseAdapter(reply), opts); +} diff --git a/packages/fastify/src/options.ts b/packages/fastify/src/options.ts new file mode 100644 index 0000000..f0ad7e3 --- /dev/null +++ b/packages/fastify/src/options.ts @@ -0,0 +1,43 @@ +import type { + CookieSameSite, + SeamlessAuthMessagingOptions, + SeamlessAuthUser, +} from "@seamless-auth/core"; + +import type { ClientIpResolver } from "./internal/buildForwardedClientIp"; + +export type SeamlessAuthServerOptions = { + authServerUrl: string; + cookieSecret: string; + serviceSecret: string; + audience: string; + jwksKid?: string; + cookieDomain?: string; + cookieSecure?: boolean; + cookieSameSite?: CookieSameSite; + allowedOrigins?: string[]; + accessCookieName?: string; + registrationCookieName?: string; + refreshCookieName?: string; + preAuthCookieName?: string; + messaging?: SeamlessAuthMessagingOptions; + resolveClientIp?: ClientIpResolver; +}; + +export type ResolvedOptions = SeamlessAuthServerOptions & { + jwksKid: string; + cookieDomain: string; + accessCookieName: string; + registrationCookieName: string; + refreshCookieName: string; + preAuthCookieName: string; +}; + +declare module "fastify" { + interface FastifyRequest { + /** Set by the plugin's cookie hook once a session cookie has been verified. */ + cookiePayload?: Record; + /** Set by `requireAuth`. */ + user?: SeamlessAuthUser; + } +} diff --git a/packages/fastify/src/plugin.ts b/packages/fastify/src/plugin.ts new file mode 100644 index 0000000..e2c2bee --- /dev/null +++ b/packages/fastify/src/plugin.ts @@ -0,0 +1,173 @@ +import cookie from "@fastify/cookie"; +import type { + FastifyInstance, + FastifyPluginAsync, + FastifyReply, + FastifyRequest, +} from "fastify"; +import { + applyExternalDelivery, + assertSecrets, + checkProxyIdentity, + DEV_JWKS_KID, + proxyRequest, + redactSensitiveText, +} from "@seamless-auth/core"; + +import { createEnsureCookiesHook } from "./hooks/ensureCookies"; +import { createOriginGuardHook } from "./hooks/originGuard"; +import { + buildInternalServiceAuthorization, + buildProxyServiceAuthorization, + buildServiceAuthorization, +} from "./internal/buildAuthorization"; +import { buildForwardedClientIp } from "./internal/buildForwardedClientIp"; +import { respond } from "./internal/respond"; +import type { ResolvedOptions, SeamlessAuthServerOptions } from "./options"; +import { PROXY_ROUTES, resolveUpstreamPath } from "./routes/proxyRoutes"; +import { registerAuthRoutes } from "./routes/authRoutes"; +import { registerAdminRoutes } from "./routes/adminRoutes"; + +function warnOnDevJwksKid(jwksKid: string | undefined): void { + if (!jwksKid || jwksKid === DEV_JWKS_KID) { + console.warn( + `[SEAMLESS-AUTH-FASTIFY] - jwksKid is not set and defaults to "${DEV_JWKS_KID}". Set jwksKid explicitly to the active JWKS key id before deploying.`, + ); + } +} + +function resolveOptions(opts: SeamlessAuthServerOptions): ResolvedOptions { + return { + ...opts, + jwksKid: opts.jwksKid ?? DEV_JWKS_KID, + cookieDomain: opts.cookieDomain ?? "", + accessCookieName: opts.accessCookieName ?? "seamless-access", + registrationCookieName: opts.registrationCookieName ?? "seamless-ephemeral", + refreshCookieName: opts.refreshCookieName ?? "seamless-refresh", + // Shares the registration cookie default on purpose: registration and login + // initiation never hold an ephemeral cookie at the same time. + preAuthCookieName: opts.preAuthCookieName ?? "seamless-ephemeral", + }; +} + +/** + * Fastify plugin that serves the Seamless Auth routes and manages the session + * cookies they depend on. + * + * Register it under a prefix. Fastify's encapsulation keeps the cookie and + * origin hooks scoped to these routes, so nothing else in the application is + * affected. + * + * ### Example + * ```ts + * await app.register(seamlessAuth, { + * prefix: "/auth", + * authServerUrl: "https://identifier.seamlessauth.com", + * cookieSecret: process.env.COOKIE_SECRET!, + * serviceSecret: process.env.SERVICE_SECRET!, + * audience: "https://identifier.seamlessauth.com", + * jwksKid: "2024-09-main", + * }); + * ``` + */ +export const seamlessAuth: FastifyPluginAsync< + SeamlessAuthServerOptions +> = async (fastify, opts) => { + assertSecrets(opts); + warnOnDevJwksKid(opts.jwksKid); + + const resolved = resolveOptions(opts); + + await fastify.register(cookie); + + // Ordering matches the Express adapter: a blocked cross-site request must + // never trigger a token refresh or reach a handler. + fastify.addHook("onRequest", createOriginGuardHook(resolved)); + fastify.addHook( + "onRequest", + createEnsureCookiesHook(resolved, fastify.prefix), + ); + + registerAuthRoutes(fastify, resolved); + registerAdminRoutes(fastify, resolved); + registerProxyRoutes(fastify, resolved); + + fastify.setErrorHandler((error, request, reply) => { + const status = clientErrorStatus(error); + + if (status !== null) { + reply.status(status).send({ error: "bad_request" }); + return; + } + + request.log.error( + redactSensitiveText(String((error as Error)?.stack ?? error)), + ); + reply.status(500).send({ error: "internal_error" }); + }); +}; + +function clientErrorStatus(err: unknown): number | null { + const candidate = err as { status?: unknown; statusCode?: unknown } | null; + const status = + typeof candidate?.status === "number" + ? candidate.status + : typeof candidate?.statusCode === "number" + ? candidate.statusCode + : null; + + return status !== null && status >= 400 && status < 500 ? status : null; +} + +function registerProxyRoutes( + fastify: FastifyInstance, + opts: ResolvedOptions, +): void { + for (const route of PROXY_ROUTES) { + fastify.route({ + method: route.method, + url: route.path, + handler: async (req: FastifyRequest, reply: FastifyReply) => { + const rejection = checkProxyIdentity({ + subject: req.cookiePayload?.sub, + cookies: req.cookies ?? {}, + identity: route.identity, + accessCookieName: opts.accessCookieName, + preAuthCookieName: opts.preAuthCookieName, + registrationCookieName: opts.registrationCookieName, + }); + + if (rejection) { + if (rejection.warn) { + req.log.warn( + `[SEAMLESS-AUTH-FASTIFY] - (proxy) - ${rejection.warn}`, + ); + } + + return reply + .status(rejection.status) + .send({ error: rejection.errorCode }); + } + + const result = await proxyRequest({ + authServerUrl: opts.authServerUrl, + path: resolveUpstreamPath( + route.upstream, + req.params as Record, + ), + method: route.method, + authorization: buildServiceAuthorization(req), + serviceAuthorization: buildProxyServiceAuthorization(opts), + forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), + query: req.query as Record, + body: req.body, + }); + + respond(reply, result, opts); + }, + }); + } +} + +export { applyExternalDelivery, buildInternalServiceAuthorization }; +export default seamlessAuth; diff --git a/packages/fastify/src/routes/adminRoutes.ts b/packages/fastify/src/routes/adminRoutes.ts new file mode 100644 index 0000000..f49b3e7 --- /dev/null +++ b/packages/fastify/src/routes/adminRoutes.ts @@ -0,0 +1,216 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; +import { + createUserHandler, + deleteUserHandler, + getAuthEventSummaryHandler, + getAuthEventTimeseriesHandler, + getAuthEventsHandler, + getAvailableRolesHandler, + getCredentialCountHandler, + getDashboardMetricsHandler, + getGroupedEventSummaryHandler, + getLoginStatsHandler, + getSecurityAnomaliesHandler, + getSystemConfigAdminHandler, + getUserAnomaliesHandler, + getUserDetailHandler, + getUsersHandler, + listAllSessionsHandler, + listSessionsHandler, + listUserSessionsHandler, + recoverUserForDeviceReplacementHandler, + revokeAllSessionsHandler, + revokeAllUserSessionsHandler, + revokeSessionHandler, + revokeUserSessionHandler, + updateSystemConfigHandler, + updateUserHandler, + type AppliableResult, +} from "@seamless-auth/core"; + +import { + buildProxyServiceAuthorization, + buildServiceAuthorization, +} from "../internal/buildAuthorization"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; +import { respond } from "../internal/respond"; +import type { ResolvedOptions } from "../options"; + +/** Everything each of these handlers needs to reach the auth API. */ +interface CallContext { + authServerUrl: string; + authorization?: string; + serviceAuthorization?: string; + forwardedClientIp?: string; +} + +type Call = (ctx: CallContext, req: FastifyRequest) => Promise; + +function param(req: FastifyRequest, name: string): string { + const value = (req.params as Record)[name]; + + if (typeof value !== "string" || value === "") { + throw new Error(`Missing route parameter "${name}"`); + } + + return value; +} + +/** + * The handler-backed routes, as a table. + * + * Every one of them is the same shape: build the call context, invoke a core + * handler, apply the result. Writing that out per route is what makes the + * equivalent Express file 520 lines. + */ +const ROUTES: Array<["GET" | "POST" | "PATCH" | "DELETE", string, Call]> = [ + // Users + ["GET", "/admin/users", (c) => getUsersHandler(c)], + ["POST", "/admin/users", (c, r) => createUserHandler({ ...c, body: r.body })], + [ + "DELETE", + "/admin/users", + (c, r) => deleteUserHandler({ ...c, body: r.body }), + ], + [ + "PATCH", + "/admin/users/:userId", + (c, r) => updateUserHandler(param(r, "userId"), { ...c, body: r.body }), + ], + [ + "GET", + "/admin/users/:userId", + (c, r) => getUserDetailHandler(param(r, "userId"), c), + ], + [ + "GET", + "/admin/users/:userId/anomalies", + (c, r) => getUserAnomaliesHandler(param(r, "userId"), c), + ], + [ + "POST", + "/admin/users/:userId/recovery/device-replacement", + (c, r) => + recoverUserForDeviceReplacementHandler(param(r, "userId"), { + ...c, + body: r.body, + }), + ], + + // Auth events and credentials + [ + "GET", + "/admin/auth-events", + (c, r) => + getAuthEventsHandler({ ...c, query: r.query as Record }), + ], + ["GET", "/admin/credential-count", (c) => getCredentialCountHandler(c)], + + // Admin session management + [ + "GET", + "/admin/sessions", + (c, r) => + listAllSessionsHandler({ + ...c, + query: r.query as Record, + }), + ], + [ + "GET", + "/admin/sessions/:userId", + (c, r) => listUserSessionsHandler(param(r, "userId"), c), + ], + [ + "DELETE", + "/admin/sessions/by-id/:id", + (c, r) => revokeUserSessionHandler(param(r, "id"), c), + ], + [ + "DELETE", + "/admin/sessions/:userId/revoke-all", + (c, r) => revokeAllUserSessionsHandler(param(r, "userId"), c), + ], + + // The caller's own sessions + ["GET", "/sessions", (c) => listSessionsHandler(c)], + [ + "DELETE", + "/sessions/:id", + (c, r) => revokeSessionHandler(param(r, "id"), c), + ], + ["DELETE", "/sessions", (c) => revokeAllSessionsHandler(c)], + + // Internal metrics + [ + "GET", + "/internal/auth-events/summary", + (c, r) => + getAuthEventSummaryHandler({ + ...c, + query: r.query as Record, + }), + ], + [ + "GET", + "/internal/auth-events/timeseries", + (c, r) => + getAuthEventTimeseriesHandler({ + ...c, + query: r.query as Record, + }), + ], + ["GET", "/internal/auth-events/login-stats", (c) => getLoginStatsHandler(c)], + [ + "GET", + "/internal/auth-events/grouped", + (c, r) => + getGroupedEventSummaryHandler({ + ...c, + query: r.query as Record, + }), + ], + [ + "GET", + "/internal/security/anomalies", + (c) => getSecurityAnomaliesHandler(c), + ], + ["GET", "/internal/metrics/dashboard", (c) => getDashboardMetricsHandler(c)], + + // System config + ["GET", "/system-config/roles", (c) => getAvailableRolesHandler(c)], + ["GET", "/system-config/admin", (c) => getSystemConfigAdminHandler(c)], + [ + "PATCH", + "/system-config/admin", + (c, r) => updateSystemConfigHandler({ ...c, payload: r.body }), + ], +]; + +export function registerAdminRoutes( + fastify: FastifyInstance, + opts: ResolvedOptions, +): void { + for (const [method, path, call] of ROUTES) { + fastify.route({ + method, + url: path, + handler: async (req, reply) => { + const result = await call( + { + authServerUrl: opts.authServerUrl, + authorization: buildServiceAuthorization(req), + serviceAuthorization: buildProxyServiceAuthorization(opts), + forwardedClientIp: buildForwardedClientIp( + req, + opts.resolveClientIp, + ), + }, + req, + ); + + respond(reply, result, opts); + }, + }); + } +} diff --git a/packages/fastify/src/routes/authRoutes.ts b/packages/fastify/src/routes/authRoutes.ts new file mode 100644 index 0000000..a363131 --- /dev/null +++ b/packages/fastify/src/routes/authRoutes.ts @@ -0,0 +1,323 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { + applyExternalDelivery, + finishLoginHandler, + finishOAuthLoginHandler, + finishRegisterHandler, + listOAuthProvidersHandler, + loginHandler, + logoutHandler, + meHandler, + pollMagicLinkConfirmationHandler, + proxyRequest, + registerHandler, + requestMagicLinkHandler, + requestOtpHandler, + startOAuthLoginHandler, + switchOrganizationHandler, + verifyLoginOtpHandler, + verifyRegistrationOtpHandler, + type LogoutScope, +} from "@seamless-auth/core"; + +import { + buildInternalServiceAuthorization, + buildProxyServiceAuthorization, + buildServiceAuthorization, +} from "../internal/buildAuthorization"; +import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; +import { respond } from "../internal/respond"; +import type { ResolvedOptions } from "../options"; + +function routeParam(req: FastifyRequest, name: string): string { + const value = (req.params as Record)[name]; + + if (typeof value !== "string" || value === "") { + throw new Error(`Missing route parameter "${name}"`); + } + + return value; +} + +export function registerAuthRoutes( + fastify: FastifyInstance, + opts: ResolvedOptions, +): void { + const common = (req: FastifyRequest) => ({ + authServerUrl: opts.authServerUrl, + forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), + }); + + // A message-carrying flow asks the API for a delivery payload instead of + // having it send the message, which needs the external-delivery identity. + const deliveryServiceAuthorization = () => + opts.messaging + ? buildInternalServiceAuthorization(opts) + : buildProxyServiceAuthorization(opts); + + const sessionCookies = { + audience: opts.audience, + cookieDomain: opts.cookieDomain, + accessCookieName: opts.accessCookieName, + refreshCookieName: opts.refreshCookieName, + }; + + fastify.post("/login", async (req, reply) => { + const result = await loginHandler( + { body: req.body }, + { + ...common(req), + audience: opts.audience, + cookieDomain: opts.cookieDomain, + preAuthCookieName: opts.preAuthCookieName, + serviceAuthorization: buildProxyServiceAuthorization(opts), + }, + ); + + respond(reply, result, opts); + }); + + fastify.post("/webAuthn/login/finish", async (req, reply) => { + const result = await finishLoginHandler( + { + body: req.body, + authorization: buildServiceAuthorization(req), + serviceAuthorization: buildProxyServiceAuthorization(opts), + forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), + }, + { authServerUrl: opts.authServerUrl, ...sessionCookies }, + ); + + respond(reply, result, opts); + }); + + fastify.post("/registration/register", async (req, reply) => { + const result = await registerHandler( + { body: req.body }, + { + ...common(req), + cookieDomain: opts.cookieDomain, + registrationCookieName: opts.registrationCookieName, + externalDelivery: Boolean(opts.messaging), + serviceAuthorization: deliveryServiceAuthorization(), + }, + ); + + if (result.errorBody) { + return respond(reply, result, opts); + } + + const body = await applyExternalDelivery(opts.messaging, result.body); + respond(reply, { ...result, body }, opts); + }); + + fastify.post("/webAuthn/register/finish", async (req, reply) => { + const result = await finishRegisterHandler( + { + body: req.body, + authorization: buildServiceAuthorization(req), + serviceAuthorization: buildProxyServiceAuthorization(opts), + forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), + }, + { authServerUrl: opts.authServerUrl, ...sessionCookies }, + ); + + respond(reply, { ...result, body: { message: "success" } }, opts); + }); + + const otpRoutes: Array< + [string, "email" | "phone", "registration" | "login"] + > = [ + ["/otp/generate-phone-otp", "phone", "registration"], + ["/otp/generate-email-otp", "email", "registration"], + ["/otp/generate-login-phone-otp", "phone", "login"], + ["/otp/generate-login-email-otp", "email", "login"], + ]; + + for (const [path, kind, flow] of otpRoutes) { + fastify.post(path, async (req, reply) => { + const result = await requestOtpHandler( + { kind, flow, authorization: buildServiceAuthorization(req) }, + { + ...common(req), + externalDelivery: Boolean(opts.messaging), + serviceAuthorization: deliveryServiceAuthorization(), + }, + ); + + if (result.errorBody) { + return respond(reply, result, opts); + } + + const body = await applyExternalDelivery(opts.messaging, result.body); + respond(reply, { ...result, body }, opts); + }); + } + + const verifyRoutes: Array<[string, "email" | "phone", "login" | "register"]> = + [ + ["/otp/verify-phone-otp", "phone", "register"], + ["/otp/verify-email-otp", "email", "register"], + ["/otp/verify-login-phone-otp", "phone", "login"], + ["/otp/verify-login-email-otp", "email", "login"], + ]; + + for (const [path, kind, flow] of verifyRoutes) { + fastify.post(path, async (req, reply) => { + const handler = + flow === "register" + ? verifyRegistrationOtpHandler + : verifyLoginOtpHandler; + + const result = await handler( + { + body: req.body, + authorization: buildServiceAuthorization(req), + serviceAuthorization: buildProxyServiceAuthorization(opts), + forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), + kind, + }, + { authServerUrl: opts.authServerUrl, ...sessionCookies }, + ); + + respond(reply, result, opts); + }); + } + + fastify.get("/oauth/providers", async (_req, reply) => { + respond( + reply, + await listOAuthProvidersHandler({ authServerUrl: opts.authServerUrl }), + opts, + ); + }); + + fastify.post("/oauth/:providerId/start", async (req, reply) => { + const result = await startOAuthLoginHandler( + { + providerId: routeParam(req, "providerId"), + body: req.body, + serviceAuthorization: buildProxyServiceAuthorization(opts), + forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), + }, + { authServerUrl: opts.authServerUrl }, + ); + + respond(reply, result, opts); + }); + + fastify.post("/oauth/:providerId/callback", async (req, reply) => { + const result = await finishOAuthLoginHandler( + { + providerId: routeParam(req, "providerId"), + body: req.body, + serviceAuthorization: buildProxyServiceAuthorization(opts), + forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), + }, + { authServerUrl: opts.authServerUrl, ...sessionCookies }, + ); + + respond(reply, result, opts); + }); + + fastify.post("/magic-link", async (req, reply) => { + const result = await requestMagicLinkHandler( + { authorization: buildServiceAuthorization(req) }, + { + ...common(req), + externalDelivery: Boolean(opts.messaging), + serviceAuthorization: deliveryServiceAuthorization(), + }, + ); + + if (result.errorBody) { + return respond(reply, result, opts); + } + + const body = await applyExternalDelivery(opts.messaging, result.body); + respond(reply, { ...result, body }, opts); + }); + + // Verified by the link recipient, who holds no session yet, 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), + }); + + respond(reply, result, opts); + }); + + fastify.get("/magic-link/check", async (req, reply) => { + const result = await pollMagicLinkConfirmationHandler( + { + authorization: buildServiceAuthorization(req), + forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), + }, + { + authServerUrl: opts.authServerUrl, + ...sessionCookies, + serviceAuthorization: deliveryServiceAuthorization(), + }, + ); + + respond(reply, result, opts); + }); + + fastify.post("/organizations/:organizationId/switch", async (req, reply) => { + const result = await switchOrganizationHandler( + { + organizationId: routeParam(req, "organizationId"), + authorization: buildServiceAuthorization(req), + serviceAuthorization: buildProxyServiceAuthorization(opts), + forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), + }, + { + authServerUrl: opts.authServerUrl, + audience: opts.audience, + cookieDomain: opts.cookieDomain, + accessCookieName: opts.accessCookieName, + }, + ); + + respond(reply, result, opts); + }); + + fastify.get("/users/me", async (req, reply) => { + const result = await meHandler({ + authServerUrl: opts.authServerUrl, + preAuthCookieName: opts.preAuthCookieName, + authorization: buildServiceAuthorization(req), + serviceAuthorization: buildProxyServiceAuthorization(opts), + forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), + }); + + respond(reply, result, opts); + }); + + const logoutRoutes: Array<[string, LogoutScope]> = [ + ["/logout", "current_session"], + ["/logout/all", "all_sessions"], + ]; + + for (const [path, scope] of logoutRoutes) { + fastify.delete(path, async (req: FastifyRequest, reply: FastifyReply) => { + const result = await logoutHandler({ + authServerUrl: opts.authServerUrl, + accessCookieName: opts.accessCookieName, + registrationCookieName: opts.registrationCookieName, + refreshCookieName: opts.refreshCookieName, + authorization: buildServiceAuthorization(req), + serviceAuthorization: buildProxyServiceAuthorization(opts), + forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), + scope, + }); + + respond(reply, result, opts); + }); + } +} diff --git a/packages/fastify/src/routes/proxyRoutes.ts b/packages/fastify/src/routes/proxyRoutes.ts new file mode 100644 index 0000000..fd5048b --- /dev/null +++ b/packages/fastify/src/routes/proxyRoutes.ts @@ -0,0 +1,246 @@ +import type { ProxyIdentity } from "@seamless-auth/core"; + +export interface ProxyRouteDefinition { + method: "GET" | "POST" | "PATCH" | "DELETE"; + /** Route path, with the `:params` Fastify binds. */ + path: string; + /** + * Upstream path. `:params` here are filled from the route's params and always + * encoded, so a param cannot escape its own path segment. + */ + upstream: string; + identity: ProxyIdentity; +} + +/** + * The passthrough routes, forwarded to the auth API with no interpretation. + * + * A table rather than one path builder per route, so the encoding happens in a + * single place. Hand-writing these is how two of them ended up interpolating a + * param unencoded in the Express adapter. + */ +export const PROXY_ROUTES: ProxyRouteDefinition[] = [ + { + method: "POST", + path: "/webAuthn/login/start", + upstream: "webAuthn/login/start", + identity: "preAuth", + }, + { + method: "GET", + path: "/webAuthn/register/start", + upstream: "webAuthn/register/start", + identity: "preAuth", + }, + + { + method: "GET", + path: "/organizations", + upstream: "organizations", + identity: "access", + }, + { + method: "POST", + path: "/organizations", + upstream: "organizations", + identity: "access", + }, + { + method: "GET", + path: "/organizations/:organizationId", + upstream: "organizations/:organizationId", + identity: "access", + }, + { + method: "PATCH", + path: "/organizations/:organizationId", + upstream: "organizations/:organizationId", + identity: "access", + }, + { + method: "GET", + path: "/organizations/:organizationId/members", + upstream: "organizations/:organizationId/members", + identity: "access", + }, + { + method: "POST", + path: "/organizations/:organizationId/members", + upstream: "organizations/:organizationId/members", + identity: "access", + }, + { + method: "PATCH", + path: "/organizations/:organizationId/members/:userId", + upstream: "organizations/:organizationId/members/:userId", + identity: "access", + }, + { + method: "DELETE", + path: "/organizations/:organizationId/members/:userId", + upstream: "organizations/:organizationId/members/:userId", + identity: "access", + }, + + { + method: "GET", + path: "/step-up/status", + upstream: "step-up/status", + identity: "access", + }, + { + method: "POST", + path: "/step-up/webauthn/start", + upstream: "step-up/webauthn/start", + identity: "access", + }, + { + method: "POST", + path: "/step-up/webauthn/finish", + upstream: "step-up/webauthn/finish", + identity: "access", + }, + + { + method: "GET", + path: "/totp/status", + upstream: "totp/status", + identity: "access", + }, + { + method: "POST", + path: "/totp/enroll/start", + upstream: "totp/enroll/start", + identity: "access", + }, + { + method: "POST", + path: "/totp/enroll/verify", + upstream: "totp/enroll/verify", + identity: "access", + }, + { + method: "POST", + path: "/totp/disable", + upstream: "totp/disable", + identity: "access", + }, + { + method: "POST", + path: "/totp/verify-mfa", + upstream: "totp/verify-mfa", + identity: "access", + }, + + { + method: "POST", + path: "/users/update", + upstream: "users/update", + identity: "access", + }, + { + method: "POST", + path: "/users/credentials", + upstream: "users/credentials", + identity: "access", + }, + { + method: "DELETE", + path: "/users/credentials", + upstream: "users/credentials", + identity: "access", + }, + + { + method: "GET", + path: "/system-config/oauth-providers", + upstream: "system-config/oauth-providers", + identity: "access", + }, + { + method: "POST", + path: "/system-config/oauth-providers", + upstream: "system-config/oauth-providers", + identity: "access", + }, + { + method: "PATCH", + path: "/system-config/oauth-providers/:id", + upstream: "system-config/oauth-providers/:id", + identity: "access", + }, + { + method: "DELETE", + path: "/system-config/oauth-providers/:id", + upstream: "system-config/oauth-providers/:id", + identity: "access", + }, + + { + method: "GET", + path: "/admin/organizations", + upstream: "admin/organizations", + identity: "access", + }, + { + method: "POST", + path: "/admin/organizations", + upstream: "admin/organizations", + identity: "access", + }, + { + method: "GET", + path: "/admin/organizations/:organizationId", + upstream: "admin/organizations/:organizationId", + identity: "access", + }, + { + method: "PATCH", + path: "/admin/organizations/:organizationId", + upstream: "admin/organizations/:organizationId", + identity: "access", + }, + { + method: "GET", + path: "/admin/organizations/:organizationId/members", + upstream: "admin/organizations/:organizationId/members", + identity: "access", + }, + { + method: "POST", + path: "/admin/organizations/:organizationId/members", + upstream: "admin/organizations/:organizationId/members", + identity: "access", + }, + { + method: "PATCH", + path: "/admin/organizations/:organizationId/members/:userId", + upstream: "admin/organizations/:organizationId/members/:userId", + identity: "access", + }, + { + method: "DELETE", + path: "/admin/organizations/:organizationId/members/:userId", + upstream: "admin/organizations/:organizationId/members/:userId", + identity: "access", + }, +]; + +/** + * Fills the `:params` in an upstream template from the route's params, encoding + * each so it stays inside a single path segment. + */ +export function resolveUpstreamPath( + template: string, + params: Record, +): string { + return template.replace(/:([A-Za-z0-9_]+)/g, (_match, name: string) => { + const value = params[name]; + + if (typeof value !== "string" || value === "") { + throw new Error(`Missing route parameter "${name}"`); + } + + return encodeURIComponent(value); + }); +} diff --git a/packages/fastify/tests/parity.test.js b/packages/fastify/tests/parity.test.js new file mode 100644 index 0000000..5076ab9 --- /dev/null +++ b/packages/fastify/tests/parity.test.js @@ -0,0 +1,349 @@ +// Runs the same requests through the Fastify and Express adapters against the +// same mocked auth API and compares what comes back. Two adapters agreeing is +// the only real check that the shared contract in core is doing the work. +import { jest } from "@jest/globals"; +import express from "express"; +import Fastify from "fastify"; +import jwt from "jsonwebtoken"; +import request from "supertest"; + +const { default: seamlessAuth } = await import("../dist/index.js"); +const { default: createSeamlessAuthServer } = await import( + "../../express/dist/index.js" +); + +const COOKIE_SECRET = "cookie-secret-cookie-secret-cookie-secret"; +const SERVICE_SECRET = "service-secret-service-secret-service-secret"; + +const OPTIONS = { + authServerUrl: "https://auth.example.com", + cookieSecret: COOKIE_SECRET, + serviceSecret: SERVICE_SECRET, + audience: "https://auth.example.com", + jwksKid: "test-main", +}; + +function upstream(status, body) { + return { ok: status >= 200 && status < 300, status, json: async () => body }; +} + +function signed(payload, ttl = "300s") { + return jwt.sign(payload, COOKIE_SECRET, { + algorithm: "HS256", + expiresIn: ttl, + }); +} + +const accessCookie = () => + `seamless-access=${signed({ sub: "user-123", roles: ["admin"], sessionId: "s-1", token: "access-token" })}`; +const preAuthCookie = () => + `seamless-ephemeral=${signed({ sub: "user-123", token: "pre-auth" })}`; + +async function buildFastify(options = {}) { + const app = Fastify(); + await app.register(seamlessAuth, { prefix: "/auth", ...OPTIONS, ...options }); + await app.ready(); + return app; +} + +function buildExpress(options = {}) { + const app = express(); + app.use("/auth", createSeamlessAuthServer({ ...OPTIONS, ...options })); + return app; +} + +// Cookie values are signed JWTs carrying iat/exp, so they differ per run. Keep +// the name and the attributes, which are what policy depends on. +function normalizeCookies(raw) { + return (raw ?? []) + .map((value) => { + const [pair, ...attrs] = value.split("; "); + const name = pair.slice(0, pair.indexOf("=")); + const body = pair.slice(pair.indexOf("=") + 1); + return [ + `${name}=${body === "" ? "" : ""}`, + ...attrs + .map((a) => (a.startsWith("Expires=") ? "Expires=" : a)) + .sort(), + ].join("; "); + }) + .sort(); +} + +function parseBody(text) { + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return text; + } +} + +async function viaFastify({ method, path, cookie, payload, options }) { + const app = await buildFastify(options); + try { + const res = await app.inject({ + method: method.toUpperCase(), + url: `/auth${path}`, + headers: cookie ? { cookie } : {}, + ...(payload === undefined ? {} : { payload }), + }); + + const raw = res.headers["set-cookie"]; + return { + status: res.statusCode, + body: parseBody(res.body), + cookies: normalizeCookies( + raw === undefined ? [] : Array.isArray(raw) ? raw : [raw], + ), + }; + } finally { + await app.close(); + } +} + +async function viaExpress({ method, path, cookie, payload, options }) { + let req = request(buildExpress(options))[method](`/auth${path}`); + if (cookie) req = req.set("Cookie", cookie); + if (payload !== undefined) req = req.send(payload); + + const res = await req; + + return { + status: res.status, + body: parseBody(res.text), + cookies: normalizeCookies(res.headers["set-cookie"]), + }; +} + +async function bothAdapters(scenario, upstreamResponse) { + global.fetch = jest.fn(async () => upstreamResponse); + const fastify = await viaFastify(scenario); + + global.fetch = jest.fn(async () => upstreamResponse); + const expressResult = await viaExpress(scenario); + + return { fastify, express: expressResult }; +} + +describe("fastify and express adapters agree", () => { + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + }); + + const REFRESH_OK = { + sub: "user-123", + token: "new-access", + refreshToken: "new-refresh", + roles: ["admin"], + email: "user@example.com", + phone: null, + ttl: 300, + refreshTtl: 3600, + }; + + it.each([ + [ + "login failure forwards the upstream body", + { method: "post", path: "/login", payload: { identifier: "a@b.c" } }, + upstream(400, { error: "account_locked" }), + ], + [ + "login failure keeps an OAuth sibling code", + { method: "post", path: "/login", payload: { identifier: "a@b.c" } }, + upstream(400, { + error: "oauth_profile_error", + message: "Email not verified", + code: "oauth_email_not_verified", + }), + ], + [ + "login failure with a validation body", + { method: "post", path: "/login", payload: { identifier: "a@b.c" } }, + upstream(400, { name: "ZodError", message: "bad" }), + ], + [ + "admin proxy normalizes a coded failure", + { + method: "patch", + path: "/admin/users/user-1", + cookie: accessCookie(), + payload: { phone: "" }, + }, + upstream(400, { name: "ZodError", message: "bad" }), + ], + [ + "admin list success", + { method: "get", path: "/admin/users", cookie: accessCookie() }, + upstream(200, { users: [] }), + ], + [ + "sessions list success", + { method: "get", path: "/sessions", cookie: accessCookie() }, + upstream(200, { sessions: [] }), + ], + [ + "metrics dashboard success", + { + method: "get", + path: "/internal/metrics/dashboard", + cookie: accessCookie(), + }, + upstream(200, { totals: {} }), + ], + [ + "system config roles success", + { method: "get", path: "/system-config/roles", cookie: accessCookie() }, + upstream(200, { roles: ["admin"] }), + ], + [ + "passthrough proxy success", + { method: "get", path: "/organizations", cookie: accessCookie() }, + upstream(200, { organizations: [] }), + ], + [ + "passthrough proxy forwards a 4xx", + { method: "get", path: "/organizations", cookie: accessCookie() }, + upstream(403, { error: "forbidden" }), + ], + [ + "proxy without the required session", + { method: "get", path: "/organizations" }, + upstream(200, {}), + ], + [ + "proxy with the wrong session kind", + { + method: "post", + path: "/webAuthn/login/start", + cookie: accessCookie(), + payload: {}, + }, + upstream(200, {}), + ], + [ + "me with no user clears the preauth cookie", + { method: "get", path: "/users/me", cookie: accessCookie() }, + upstream(200, {}), + ], + [ + "logout clears every session cookie", + { method: "delete", path: "/logout", cookie: accessCookie() }, + upstream(200, {}), + ], + [ + "logout all clears every session cookie", + { method: "delete", path: "/logout/all", cookie: accessCookie() }, + upstream(200, {}), + ], + [ + "oauth providers list", + { method: "get", path: "/oauth/providers" }, + upstream(200, { providers: [] }), + ], + ])("%s", async (_label, scenario, upstreamResponse) => { + const { fastify, express: expressResult } = await bothAdapters( + scenario, + upstreamResponse, + ); + + expect(fastify.status).toBe(expressResult.status); + expect(fastify.body).toEqual(expressResult.body); + expect(fastify.cookies).toEqual(expressResult.cookies); + }); + + it.each([ + ["default policy", {}], + ["insecure dev", { cookieSecure: false }], + ["custom domain", { cookieDomain: "acme.test" }], + ["strict same-site", { cookieSameSite: "strict" }], + ])("issues identical session cookies (%s)", async (_label, options) => { + const scenario = { + method: "get", + path: "/users/me", + cookie: `seamless-refresh=${signed({ sub: "user-123", refreshToken: "opaque" }, "3600s")}`, + options, + }; + + global.fetch = jest.fn(async (url) => + String(url).endsWith("/refresh") + ? upstream(200, REFRESH_OK) + : upstream(200, { user: { id: "u1" } }), + ); + const fastify = await viaFastify(scenario); + + global.fetch = jest.fn(async (url) => + String(url).endsWith("/refresh") + ? upstream(200, REFRESH_OK) + : upstream(200, { user: { id: "u1" } }), + ); + const expressResult = await viaExpress(scenario); + + expect(fastify.cookies).toEqual(expressResult.cookies); + expect(fastify.cookies.length).toBeGreaterThan(0); + expect(fastify.status).toBe(expressResult.status); + }); + + it("sends the same upstream URL for a repeated query parameter", async () => { + const urls = []; + global.fetch = jest.fn(async (url) => { + urls.push(String(url)); + return upstream(200, { events: [] }); + }); + + const scenario = { + method: "get", + path: "/admin/auth-events?type=login&type=logout&limit=5", + cookie: accessCookie(), + }; + + await viaFastify(scenario); + await viaExpress(scenario); + + expect(urls[0]).toBe(urls[1]); + expect(urls[0]).toContain("type=login&type=logout"); + }); + + it("keeps an injected route param in one upstream path segment", async () => { + const urls = []; + global.fetch = jest.fn(async (url) => { + urls.push(String(url)); + return upstream(200, {}); + }); + + const scenario = { + method: "patch", + path: `/system-config/oauth-providers/${encodeURIComponent("abc?admin=1")}`, + cookie: accessCookie(), + payload: {}, + }; + + await viaFastify(scenario); + + expect(urls[0]).toBe( + "https://auth.example.com/system-config/oauth-providers/abc%3Fadmin%3D1", + ); + }); + + it("blocks a cross-site state change the same way", async () => { + global.fetch = jest.fn(async () => upstream(200, {})); + + const app = await buildFastify(); + try { + const res = await app.inject({ + method: "POST", + url: "/auth/login", + headers: { "sec-fetch-site": "cross-site" }, + payload: { identifier: "a@b.c" }, + }); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body)).toEqual({ + error: "cross_site_request_blocked", + }); + } finally { + await app.close(); + } + }); +}); diff --git a/packages/fastify/tsconfig.build.json b/packages/fastify/tsconfig.build.json new file mode 100644 index 0000000..8d5376a --- /dev/null +++ b/packages/fastify/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true + }, + "include": ["src"] +} diff --git a/packages/fastify/tsconfig.json b/packages/fastify/tsconfig.json new file mode 100644 index 0000000..1e68fb9 --- /dev/null +++ b/packages/fastify/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "dist", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "declaration": true, + "emitDeclarationOnly": false, + "declarationMap": true + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4415836..1a39126 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,52 @@ importers: specifier: ^5.5.0 version: 5.9.3 + packages/fastify: + dependencies: + '@fastify/cookie': + specifier: ^11.0.2 + version: 11.1.2 + '@seamless-auth/core': + specifier: workspace:^ + version: link:../core + fastify-plugin: + specifier: ^5.0.1 + version: 5.1.0 + devDependencies: + '@seamless-auth/express': + specifier: workspace:^ + version: link:../express + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 + express: + specifier: ^5.2.1 + version: 5.2.1 + fastify: + specifier: ^5.10.0 + version: 5.10.0 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)) + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 + supertest: + specifier: ^7.2.2 + version: 7.2.2 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@25.9.1)(typescript@5.9.3) + tsup: + specifier: ^8.5.1 + version: 8.5.1(typescript@5.9.3) + typescript: + specifier: ^5.5.0 + version: 5.9.3 + packages: '@babel/code-frame@7.29.0': @@ -469,6 +515,27 @@ packages: cpu: [x64] os: [win32] + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cookie@11.1.2': + resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.1.0': + resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} + + '@fastify/forwarded@3.0.2': + resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -596,6 +663,9 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@rollup/rollup-android-arm-eabi@4.60.4': resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} cpu: [arm] @@ -842,6 +912,9 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -855,6 +928,17 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -901,6 +985,13 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.3.0: + resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1091,6 +1182,14 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} @@ -1135,6 +1234,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -1249,6 +1352,12 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -1256,9 +1365,30 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-json-stringify@7.0.1: + resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + + fast-uri@4.1.1: + resolution: {integrity: sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify-plugin@6.0.0: + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} + + fastify@5.10.0: + resolution: {integrity: sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1282,6 +1412,10 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-my-way@9.7.0: + resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} + engines: {node: '>=20'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -1426,6 +1560,10 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -1650,6 +1788,12 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -1676,6 +1820,9 @@ packages: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1829,6 +1976,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -1918,6 +2069,16 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -1956,6 +2117,12 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -1977,6 +2144,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -1996,10 +2166,21 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -2017,10 +2198,17 @@ packages: engines: {node: '>= 0.4'} hasBin: true + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rollup@4.60.4: resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2036,9 +2224,20 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2056,6 +2255,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -2097,6 +2299,9 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-support@0.5.13: resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} @@ -2111,6 +2316,10 @@ packages: spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -2190,6 +2399,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -2204,6 +2417,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toad-cache@3.7.4: + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} + engines: {node: '>=20'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -2759,6 +2976,34 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.4 + + '@fastify/cookie@11.1.2': + dependencies: + cookie: 2.0.1 + fastify-plugin: 6.0.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.1.0': + dependencies: + fast-json-stringify: 7.0.1 + + '@fastify/forwarded@3.0.2': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.2 + ipaddr.js: 2.4.0 + '@inquirer/external-editor@1.0.3(@types/node@25.9.1)': dependencies: chardet: 2.1.1 @@ -2996,6 +3241,8 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@pinojs/redact@0.4.0': {} + '@rollup/rollup-android-arm-eabi@4.60.4': optional: true @@ -3197,6 +3444,8 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + abstract-logging@2.0.1: {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -3208,6 +3457,17 @@ snapshots: acorn@8.16.0: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.4 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-escapes@4.3.2: @@ -3243,6 +3503,13 @@ snapshots: asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} + + avvio@9.3.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + babel-jest@29.7.0(@babel/core@7.29.0): dependencies: '@babel/core': 7.29.0 @@ -3438,6 +3705,10 @@ snapshots: cookie@0.7.2: {} + cookie@1.1.1: {} + + cookie@2.0.1: {} + cookiejar@2.1.4: {} create-jest@29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.9.3)): @@ -3475,6 +3746,8 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + detect-indent@6.1.0: {} detect-newline@3.1.0: {} @@ -3632,6 +3905,10 @@ snapshots: extendable-error@0.1.7: {} + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3642,8 +3919,47 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-json-stringify@7.0.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.1.1 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + fast-safe-stringify@2.1.1: {} + fast-uri@3.1.4: {} + + fast-uri@4.1.1: {} + + fastify-plugin@5.1.0: {} + + fastify-plugin@6.0.0: {} + + fastify@5.10.0: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.1.0 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.3.0 + fast-json-stringify: 7.0.1 + find-my-way: 9.7.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.1 + toad-cache: 3.7.4 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -3671,6 +3987,12 @@ snapshots: transitivePeerDependencies: - supports-color + find-my-way@9.7.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -3819,6 +4141,8 @@ snapshots: ipaddr.js@1.9.1: {} + ipaddr.js@2.4.0: {} + is-arrayish@0.2.1: {} is-core-module@2.16.2: @@ -4218,6 +4542,12 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + json5@2.2.3: {} jsonfile@4.0.0: @@ -4252,6 +4582,12 @@ snapshots: leven@3.1.0: {} + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -4368,6 +4704,8 @@ snapshots: object-inspect@1.13.4: {} + on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -4437,6 +4775,26 @@ snapshots: pify@4.0.1: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + pirates@4.0.7: {} pkg-dir@4.2.0: @@ -4461,6 +4819,10 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -4481,6 +4843,8 @@ snapshots: queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + range-parser@1.2.1: {} raw-body@3.0.2: @@ -4501,8 +4865,14 @@ snapshots: readdirp@4.1.2: {} + real-require@0.2.0: {} + + real-require@1.0.0: {} + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -4518,8 +4888,12 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + ret@0.5.0: {} + reusify@1.1.0: {} + rfdc@1.4.1: {} + rollup@4.60.4: dependencies: '@types/estree': 1.0.8 @@ -4567,8 +4941,16 @@ snapshots: safe-buffer@5.2.1: {} + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} + secure-json-parse@4.1.0: {} + semver@6.3.1: {} semver@7.8.1: {} @@ -4598,6 +4980,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@2.7.2: {} + setprototypeof@1.2.0: {} shebang-command@2.0.0: @@ -4642,6 +5026,10 @@ snapshots: slash@3.0.0: {} + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-support@0.5.13: dependencies: buffer-from: 1.1.2 @@ -4656,6 +5044,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + split2@4.2.0: {} + sprintf-js@1.0.3: {} stack-utils@2.0.6: @@ -4745,6 +5135,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + tinyexec@0.3.2: {} tinyglobby@0.2.16: @@ -4758,6 +5152,8 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.4: {} + toidentifier@1.0.1: {} tree-kill@1.2.2: {} From 997425eaa1bced829c803a6ce08afa40fec7097b Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 29 Jul 2026 22:38:16 -0400 Subject: [PATCH 2/4] refactor(core): take SeamlessAuthUser from @seamless-auth/types The types package already defines it, field for field. Declaring it again here is the duplication #118 and #120 were about. Re-exported under the same name from core and the adapters, so nothing changes for adopters. The re-export is type-only, so it is erased at compile time: the emitted core still imports only @seamless-auth/types/role/matching and neither zod nor the schema barrel enters the runtime graph. --- .../adapter-guards-and-cookie-contract.md | 4 +++- packages/core/src/guards.ts | 21 ++++++++----------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.changeset/adapter-guards-and-cookie-contract.md b/.changeset/adapter-guards-and-cookie-contract.md index 7816cb4..8468edf 100644 --- a/.changeset/adapter-guards-and-cookie-contract.md +++ b/.changeset/adapter-guards-and-cookie-contract.md @@ -5,7 +5,9 @@ Move the remaining guard decisions into core, and fully specify a cookie's lifetime in the response contract. -`checkOrigin`, `authenticateCookie`, and `authorizeRoles` join `checkProxyIdentity`: each takes the request facts a guard needs and returns a `GuardRejection` or nothing, so an adapter reads headers and cookies and writes a response but decides nothing. The Express origin guard, `requireAuth`, and `requireRole` now call them, which is a straight substitution with no behavior change. `SeamlessAuthUser` moves to core with them and is re-exported from `@seamless-auth/express`, so nothing changes for adopters. +`checkOrigin`, `authenticateCookie`, and `authorizeRoles` join `checkProxyIdentity`: each takes the request facts a guard needs and returns a `GuardRejection` or nothing, so an adapter reads headers and cookies and writes a response but decides nothing. The Express origin guard, `requireAuth`, and `requireRole` now call them, which is a straight substitution with no behavior change. + +`SeamlessAuthUser` comes from `@seamless-auth/types`, which already defines it, rather than being declared again here. It is re-exported from `@seamless-auth/core` and `@seamless-auth/express` under the same name, so nothing changes for adopters. The re-export is type-only, so it is erased at compile time and neither `zod` nor the schema barrel enters the runtime module graph. `SetCookieCommand` and `ClearCookieCommand` now carry an `expires` alongside `maxAgeSeconds`. Previously the command specified only a max age, and two adapters could satisfy it while emitting different headers: Express sent both `Expires` and `Max-Age`, and a second adapter sent only `Max-Age`, which older clients treat as a session cookie. Specifying both means every adapter emits the same header for the same instruction. Clearing carries the epoch for the same reason. diff --git a/packages/core/src/guards.ts b/packages/core/src/guards.ts index 38733fc..608b304 100644 --- a/packages/core/src/guards.ts +++ b/packages/core/src/guards.ts @@ -1,22 +1,19 @@ +import type { SeamlessAuthUser } from "@seamless-auth/types"; + import { resolveCookieSameSite, type CookieSameSite } from "./applyResult.js"; import { hasScopedRole } from "@seamless-auth/types/role/matching"; import { assertSecretStrength } from "./validateSecrets.js"; import { verifyCookieJwt } from "./verifyCookieJwt.js"; /** - * The session a verified access cookie describes. Distinct from `SeamlessUser`, - * which is the hydrated profile fetched from the auth API: this one is only - * what the cookie itself carries. + * The session a verified access cookie describes: the access token's claims as + * a resource server reads them off a request. Distinct from `MeUser`, which is + * the hydrated profile fetched from the auth API. + * + * A type-only re-export, so it costs nothing at runtime: the import is erased at + * compile time and neither zod nor the schema barrel enters the module graph. */ -export interface SeamlessAuthUser { - id: string; - roles: string[]; - email?: string; - phone?: string | null; - iat?: number; - exp?: number; - token?: string; -} +export type { SeamlessAuthUser } from "@seamless-auth/types"; /** * A guard's decision to refuse a request. Adapters render it as From 75d536df7e5dce8d98f213535008fd17e8cc51fb Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 29 Jul 2026 22:57:14 -0400 Subject: [PATCH 3/4] chore(fastify): start the package at 0.1.0 Copied from the express package.json, the version said 0.10.0, which would have published a first release claiming the history of a package that has been shipping for ten minors. Set to 0.0.0 so the pending minor changeset releases it as 0.1.0. The package is deliberately outside the core/express linked group, so it versions on its own until it has the same track record. --- .changeset/README.md | 12 ++++++++---- packages/fastify/package.json | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.changeset/README.md b/.changeset/README.md index 90c7531..6cf11db 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -10,7 +10,11 @@ Use the summary as adopter-facing release notes. The release workflow turns merged changesets into package changelogs, npm publishes, Git tags, and GitHub Releases. -The JavaScript packages are currently linked so `@seamless-auth/core` and -official adapters move together while the API is pre-1.0. After the v1 contract -is stable, remove packages from the linked group when they can safely version -independently. +`@seamless-auth/core` and `@seamless-auth/express` are linked, so they move +together. After the v1 contract is stable, remove them from the linked group when +they can safely version independently. + +`@seamless-auth/fastify` is deliberately outside that group. It starts at `0.1.0` +and versions on its own, so a newer adapter does not inherit a version number +that claims the maturity of the ones that have been shipping. Add it to the +linked group once it has the same track record. diff --git a/packages/fastify/package.json b/packages/fastify/package.json index 39a1a52..88e800d 100644 --- a/packages/fastify/package.json +++ b/packages/fastify/package.json @@ -1,6 +1,6 @@ { "name": "@seamless-auth/fastify", - "version": "0.10.0", + "version": "0.0.0", "description": "Fastify adapter for Seamless Auth passwordless authentication", "keywords": [ "authentication", From a84e35c3aa1776d9a707f3de9c33b674399c5d22 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 29 Jul 2026 23:03:16 -0400 Subject: [PATCH 4/4] chore: release the result-contract change as a minor, staying pre-1.0 The errorCode/errorBody split was marked major, which changesets takes literally and would have published core and express as 1.0.0. For a 0.x package the breaking bump is the minor, and the changesets README still describes these as pre-1.0. Marked minor, so the release is 0.11.0. The changeset still leads with BREAKING and the migration guidance, and now says why a breaking change is shipping as a minor. --- .changeset/unify-result-failure-contract.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/unify-result-failure-contract.md b/.changeset/unify-result-failure-contract.md index 700843c..ff1d359 100644 --- a/.changeset/unify-result-failure-contract.md +++ b/.changeset/unify-result-failure-contract.md @@ -1,10 +1,12 @@ --- -"@seamless-auth/core": major -"@seamless-auth/express": major +"@seamless-auth/core": minor +"@seamless-auth/express": minor --- Split the handler result `error` field into `errorCode` and `errorBody`. +BREAKING for direct consumers of the handler result types. Released as a minor because these packages are pre-1.0, where a minor is the breaking bump. The details are below. + `error` meant two different things depending on which handler produced it. On 12 sites it held the auth API's whole failure body, forwarded to the caller unchanged. On 8 sites it held a short code that the adapter wrapped as `{ error }`. The declared types could not describe either honestly, and `FinishLoginResult` declared `error?: string` while assigning the whole body. Nothing in the type told an adapter which rendering applied, which is the first thing a second adapter has to get right. Failures are now reported through `ResultFailure`, exported from `@seamless-auth/core`: