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/.changeset/adapter-guards-and-cookie-contract.md b/.changeset/adapter-guards-and-cookie-contract.md new file mode 100644 index 0000000..8468edf --- /dev/null +++ b/.changeset/adapter-guards-and-cookie-contract.md @@ -0,0 +1,14 @@ +--- +"@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` 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. + +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/.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`: 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..608b304 --- /dev/null +++ b/packages/core/src/guards.ts @@ -0,0 +1,186 @@ +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: 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 type { SeamlessAuthUser } from "@seamless-auth/types"; + +/** + * 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..88e800d --- /dev/null +++ b/packages/fastify/package.json @@ -0,0 +1,78 @@ +{ + "name": "@seamless-auth/fastify", + "version": "0.0.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: {}