diff --git a/.changeset/adapter-manifest-proxy.md b/.changeset/adapter-manifest-proxy.md new file mode 100644 index 0000000..1f543c1 --- /dev/null +++ b/.changeset/adapter-manifest-proxy.md @@ -0,0 +1,19 @@ +--- +"@seamless-auth/core": minor +"@seamless-auth/express": minor +"@seamless-auth/fastify": minor +"@seamless-auth/nextjs": minor +--- + +Serve auth API routes from the adapter manifest (#201). + +The auth API publishes which token each route takes and which tokens its response issues or clears at `/.well-known/seamless-adapter.json`. Every adapter now serves any route listed there that it has no handler of its own for, so a new API route works without a new release of these packages. Routes with their own handlers behave as before. + +- Routes that had no passthrough now work: TOTP sign-in (`POST /totp/verify-login`), `POST /registration/phone` and `/registration/phone/verify`, `POST /admin/users/import`, and anything the API adds later. +- `@seamless-auth/nextjs` returns a `PUT` handler, so the OAuth provider retirement routes are reachable. Export it from the catch-all route: `export const { GET, POST, PUT, PATCH, DELETE } = createSeamlessAuthHandler(...)`. +- Fastify serves manifest routes whatever their path casing, as Express and Next.js already did. +- Routes served from the manifest never return `token` or `refreshToken` to the browser in cookie transport. +- `ensureCookies` takes optional `method` and `manifest`, and then loads the cookie the manifest names for the route. +- Core exports `createAdapterManifestSource`, `matchManifestRoute`, `handleManifestRoute`, `parseAdapterManifest`, `buildManifestPath` and `ADAPTER_MANIFEST_PATH`. + +Each adapter fetches the manifest from the auth API on its first request, waiting up to five seconds, and keeps it for the life of the process. If the API does not serve one (versions before the manifest), it uses the copy bundled with the package and tries again a minute later. Tests that mock `fetch` will see this extra request; pass `fetchManifest: false` to use only the bundled copy. diff --git a/AGENTS.md b/AGENTS.md index 19c7c73..90b66d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,12 @@ packages/ error path. - The adapters bridge to `seamless-auth-api`; when behavior looks off, check the API's route/token/JWKS contract before changing code here. +- A new API route needs no change here. Each adapter serves any route in the + API's adapter manifest (`/.well-known/seamless-adapter.json`) that it has no + handler of its own for, through `handleManifestRoute` in core. Add a dedicated + handler only for behaviour the manifest cannot describe. Refresh the bundled + fallback with `node scripts/sync-adapter-manifest.mjs` after the API's + manifest changes. ## Tooling diff --git a/packages/core/README.md b/packages/core/README.md index 68c529b..0a17d77 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -132,6 +132,21 @@ remain for direct imports. - `signSessionCookie(...)` / `resolveCookieSameSite(...)` – cookie format and policy - `authFetch(...)` – calls the auth API with the adapter's headers and a tolerant `json()` +**The adapter manifest** + +The auth API publishes, at `ADAPTER_MANIFEST_PATH` (`/.well-known/seamless-adapter.json`), which +token each route takes and which tokens its response issues or clears. Adapters serve every route it +lists that they have no handler of their own for, so a new API route works without a new release +of this package. + +- `createAdapterManifestSource({ authServerUrl, fetchManifest })` – fetches the manifest once, + falling back to the copy bundled with this version (`fetchManifest: false` uses only that copy) +- `matchManifestRoute(manifest, method, path)` – finds the route and its path parameters +- `handleManifestRoute(input, opts)` – proxies a matched route: sends the held token it names, stores + the session it issues, clears what it clears, and keeps tokens out of cookie-transport bodies +- `parseAdapterManifest(...)` / `buildManifestPath(...)` – validation and upstream path building +- `ensureCookies` takes optional `method` and `manifest`, and then loads the cookie the manifest names + **Auth flow handlers** `loginHandler`, `finishLoginHandler`, `registerHandler`, `finishRegisterHandler`, diff --git a/packages/core/src/ensureCookies.ts b/packages/core/src/ensureCookies.ts index 8cbae87..4181be9 100644 --- a/packages/core/src/ensureCookies.ts +++ b/packages/core/src/ensureCookies.ts @@ -2,6 +2,10 @@ import { verifyCookieJwt } from "./verifyCookieJwt.js"; import type { ResultFailure } from "./result.js"; import { refreshAccessToken } from "./refreshAccessToken.js"; import { assertSecrets } from "./validateSecrets.js"; +import { + type AdapterManifest, + matchManifestRoute, +} from "./manifest/adapterManifest.js"; import type { AuthServerIssuerOption } from "./authServerIssuer.js"; import { issueSessionCookies, @@ -11,6 +15,9 @@ import { export interface EnsureCookiesInput { path: string; cookies: Record; + /** With `manifest`, the route's credential comes from the manifest. */ + method?: string; + manifest?: AdapterManifest; } export interface CookiePayload { @@ -225,6 +232,46 @@ const COOKIE_REQUIREMENTS: Record< }, }; +const MANIFEST_CREDENTIAL_COOKIES = { + preAuth: "preAuthCookieName", + registration: "registrationCookieName", + access: "accessCookieName", +} as const; + +/** + * Which cookie a request needs, if any. + * + * The manifest wins for any route it lists. The table remains for requests the + * manifest cannot place, such as one made without a method or a manifest. + */ +function cookieRequirement( + input: EnsureCookiesInput, +): { name: keyof EnsureCookiesOptions; required: boolean } | undefined { + if (input.manifest && input.method) { + const match = matchManifestRoute(input.manifest, input.method, input.path); + + if (match) { + const { credential } = match.route; + + return credential === "none" || credential === "refresh" + ? undefined + : { name: MANIFEST_CREDENTIAL_COOKIES[credential], required: true }; + } + } + + // Match case-insensitively: Express route matching is case-insensitive by + // default, so a client may send a path whose casing differs from the mounted + // route (e.g. "/webauthn/..." vs "/webAuthn/..."). A case-sensitive miss here + // would silently skip cookie loading and break the request downstream, so the + // comparison is normalized to lower case on both sides. + const requestPath = input.path.toLowerCase(); + const match = Object.entries(COOKIE_REQUIREMENTS).find(([path]) => + requestPath.startsWith(path.toLowerCase()), + ); + + return match?.[1]; +} + async function refreshRequiredCookie( cookieName: string, refreshCookie: string | undefined, @@ -304,21 +351,13 @@ export async function ensureCookies( ): Promise { assertSecrets(opts); - // Match case-insensitively: Express route matching is case-insensitive by - // default, so a client may send a path whose casing differs from the mounted - // route (e.g. "/webauthn/..." vs "/webAuthn/..."). A case-sensitive miss here - // would silently skip cookie loading and break the request downstream, so the - // comparison is normalized to lower case on both sides. - const requestPath = input.path.toLowerCase(); - const match = Object.entries(COOKIE_REQUIREMENTS).find(([path]) => - requestPath.startsWith(path.toLowerCase()), - ); + const requirement = cookieRequirement(input); - if (!match) { + if (!requirement) { return { type: "ok" }; } - const [, { name, required }] = match; + const { name, required } = requirement; // A not-required entry marks a route that is explicitly ungated: it must pass // through regardless of which cookies are (or are not) present, so a stale or diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7163520..a2206d4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,6 +3,8 @@ export type { AuthServerIssuerOption } from "./authServerIssuer.js"; export * from "./authMessaging.js"; export * from "./deliverAuthMessage.js"; export * from "./ensureCookies.js"; +export * from "./manifest/adapterManifest.js"; +export * from "./manifestProxy.js"; export * from "./verifyCookieJwt.js"; export * from "./verifyRefreshCookie.js"; export * from "./verifySignedAuthResponse.js"; diff --git a/packages/core/src/manifest/adapterManifest.ts b/packages/core/src/manifest/adapterManifest.ts new file mode 100644 index 0000000..c09334f --- /dev/null +++ b/packages/core/src/manifest/adapterManifest.ts @@ -0,0 +1,267 @@ +import { getSeamlessLogger } from "../logger.js"; +import { BUNDLED_ADAPTER_MANIFEST } from "./bundledManifest.js"; + +/** + * The adapter manifest the auth API publishes at + * `/.well-known/seamless-adapter.json`. It says, per route, which held token the + * adapter sends and which tokens the response issues or clears, so a new API route + * reaches adopters without a change to this package. + */ +export type AdapterCredential = + | "none" + | "preAuth" + | "registration" + | "access" + | "refresh"; + +export type AdapterHeld = "preAuth" | "registration" | "access" | "refresh"; + +export type AdapterMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +export interface AdapterManifestRoute { + method: AdapterMethod; + path: string; + credential: AdapterCredential; + /** `session` stores access and refresh; `access` reissues access only. */ + issues?: "preAuth" | "registration" | "session" | "access"; + clears?: AdapterHeld[]; + /** Cookie transport only: the body fields passed to the browser. */ + body?: { pick: string[] }; + delivery?: true; +} + +export interface AdapterManifest { + schemaVersion: 1; + apiVersion: string; + session: Record; + routes: AdapterManifestRoute[]; +} + +export const ADAPTER_MANIFEST_PATH = "/.well-known/seamless-adapter.json"; + +const METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]); +const CREDENTIALS = new Set([ + "none", + "preAuth", + "registration", + "access", + "refresh", +]); +const ISSUES = new Set(["preAuth", "registration", "session", "access"]); +const HELD = new Set(["preAuth", "registration", "access", "refresh"]); + +function isRoute(value: unknown): value is AdapterManifestRoute { + if (!value || typeof value !== "object") return false; + + const route = value as Record; + + return ( + METHODS.has(route.method as string) && + typeof route.path === "string" && + route.path.startsWith("/") && + CREDENTIALS.has(route.credential as string) && + (route.issues === undefined || ISSUES.has(route.issues as string)) && + (route.clears === undefined || + (Array.isArray(route.clears) && + route.clears.every((held) => HELD.has(held)))) && + (route.body === undefined || + (typeof route.body === "object" && + route.body !== null && + Array.isArray((route.body as { pick?: unknown }).pick))) && + (route.delivery === undefined || route.delivery === true) + ); +} + +/** + * Accepts a manifest only if every route is one this package knows how to follow. + * + * A route with a credential or effect this version does not understand could not + * be handled safely, so a manifest carrying one is refused whole and the bundled + * copy is used instead, rather than proxying that route with the wrong token. + */ +export function parseAdapterManifest( + value: unknown, +): AdapterManifest | undefined { + if (!value || typeof value !== "object") return undefined; + + const manifest = value as Record; + + if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.routes)) { + return undefined; + } + + if (!manifest.routes.every(isRoute)) { + return undefined; + } + + return value as AdapterManifest; +} + +export interface ManifestRouteMatch { + route: AdapterManifestRoute; + params: Record; +} + +function segments(path: string) { + return path.split("/").filter(Boolean); +} + +/** + * Finds the manifest route for a request. + * + * Static segments compare case-insensitively, matching Express's default and the + * adapters' existing `/webAuthn` routes against the API's `/webauthn`. A static + * match beats a parameter at the same position, so `/admin/users/import` is never + * read as `/admin/users/{userId}`. + */ +export function matchManifestRoute( + manifest: AdapterManifest, + method: string, + path: string, +): ManifestRouteMatch | undefined { + const requested = segments(path); + const upperMethod = method.toUpperCase(); + let best: { match: ManifestRouteMatch; score: number } | undefined; + + for (const route of manifest.routes) { + if (route.method !== upperMethod) continue; + + const pattern = segments(route.path); + if (pattern.length !== requested.length) continue; + + const params: Record = {}; + let score = 0; + let matched = true; + + for (let i = 0; i < pattern.length; i++) { + const part = pattern[i]; + const param = /^\{(.+)\}$/.exec(part); + + if (param) { + let value: string; + + try { + value = decodeURIComponent(requested[i]); + } catch { + matched = false; + break; + } + + // Re-encoded, a dot segment survives as a literal `..` that fetch then + // resolves, sending the held token to a different upstream path. + if (value === "." || value === "..") { + matched = false; + break; + } + + params[param[1]] = value; + continue; + } + + if (part.toLowerCase() !== requested[i].toLowerCase()) { + matched = false; + break; + } + + score += 1; + } + + if (matched && (!best || score > best.score)) { + best = { match: { route, params }, score }; + } + } + + return best?.match; +} + +/** Fills a manifest path's `{param}` segments, encoding each value. */ +export function buildManifestPath( + route: AdapterManifestRoute, + params: Record, +): string { + return route.path.replace(/\{([^}]+)\}/g, (_, name: string) => + encodeURIComponent(params[name] ?? ""), + ); +} + +export interface AdapterManifestSourceOptions { + authServerUrl: string; + /** + * `false` uses the bundled manifest only, for deployments that pin adapter + * behaviour to the installed package version. + */ + fetchManifest?: boolean; + /** How long to wait before fetching again after a failure. */ + retryAfterMs?: number; + /** How long the fetch may take before the bundled copy is used. */ + timeoutMs?: number; +} + +export interface AdapterManifestSource { + get(): Promise; +} + +const DEFAULT_RETRY_AFTER_MS = 60_000; +const DEFAULT_TIMEOUT_MS = 5_000; + +/** + * Loads the manifest from the auth API once, falling back to the bundled copy. + * + * A failed fetch is retried after `retryAfterMs` rather than on every request, so + * an API that predates the manifest costs one request a minute, not one per call. + * A fetched manifest is kept for the life of the process. + */ +export function createAdapterManifestSource( + opts: AdapterManifestSourceOptions, +): AdapterManifestSource { + if (opts.fetchManifest === false) { + return { get: async () => BUNDLED_ADAPTER_MANIFEST }; + } + + const retryAfterMs = opts.retryAfterMs ?? DEFAULT_RETRY_AFTER_MS; + let loaded: AdapterManifest | undefined; + let pending: Promise | undefined; + let retryAt = 0; + + async function load(): Promise { + try { + const response = await fetch(`${opts.authServerUrl}${ADAPTER_MANIFEST_PATH}`, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS), + }); + + const manifest = response.ok + ? parseAdapterManifest(await response.json()) + : undefined; + + if (manifest) { + loaded = manifest; + return manifest; + } + + getSeamlessLogger().warn( + `[SeamlessAuth] The auth API returned no usable adapter manifest (HTTP ${response.status}). Using the bundled copy.`, + ); + } catch (error) { + getSeamlessLogger().warn( + `[SeamlessAuth] Could not fetch the adapter manifest (${(error as Error).message}). Using the bundled copy.`, + ); + } + + retryAt = Date.now() + retryAfterMs; + return BUNDLED_ADAPTER_MANIFEST; + } + + return { + async get() { + if (loaded) return loaded; + if (Date.now() < retryAt) return BUNDLED_ADAPTER_MANIFEST; + + pending ??= load().finally(() => { + pending = undefined; + }); + + return pending; + }, + }; +} diff --git a/packages/core/src/manifest/bundledManifest.ts b/packages/core/src/manifest/bundledManifest.ts new file mode 100644 index 0000000..f6248bc --- /dev/null +++ b/packages/core/src/manifest/bundledManifest.ts @@ -0,0 +1,543 @@ +// Generated by scripts/sync-adapter-manifest.mjs. Do not edit by hand. +import type { AdapterManifest } from "./adapterManifest.js"; + +export const BUNDLED_ADAPTER_MANIFEST: AdapterManifest = { + "schemaVersion": 1, + "apiVersion": "0.17.0", + "session": { + "subject": "sub", + "token": "token", + "refreshToken": "refreshToken", + "ttl": "ttl", + "refreshTtl": "refreshTtl" + }, + "routes": [ + { + "method": "GET", + "path": "/admin/auth-events", + "credential": "access" + }, + { + "method": "GET", + "path": "/admin/auth-events/export", + "credential": "access" + }, + { + "method": "GET", + "path": "/admin/auth-events/integrity", + "credential": "access" + }, + { + "method": "GET", + "path": "/admin/credential-count", + "credential": "access" + }, + { + "method": "GET", + "path": "/admin/enrollment", + "credential": "access" + }, + { + "method": "POST", + "path": "/admin/enrollment/invites", + "credential": "access" + }, + { + "method": "GET", + "path": "/admin/organizations", + "credential": "access" + }, + { + "method": "POST", + "path": "/admin/organizations", + "credential": "access" + }, + { + "method": "DELETE", + "path": "/admin/organizations/{organizationId}", + "credential": "access" + }, + { + "method": "GET", + "path": "/admin/organizations/{organizationId}", + "credential": "access" + }, + { + "method": "PATCH", + "path": "/admin/organizations/{organizationId}", + "credential": "access" + }, + { + "method": "GET", + "path": "/admin/organizations/{organizationId}/members", + "credential": "access" + }, + { + "method": "POST", + "path": "/admin/organizations/{organizationId}/members", + "credential": "access" + }, + { + "method": "DELETE", + "path": "/admin/organizations/{organizationId}/members/{userId}", + "credential": "access" + }, + { + "method": "PATCH", + "path": "/admin/organizations/{organizationId}/members/{userId}", + "credential": "access" + }, + { + "method": "DELETE", + "path": "/admin/organizations/{organizationId}/oauth-providers/{providerId}/retirement", + "credential": "access" + }, + { + "method": "PUT", + "path": "/admin/organizations/{organizationId}/oauth-providers/{providerId}/retirement", + "credential": "access" + }, + { + "method": "GET", + "path": "/admin/reports/authentication-coverage", + "credential": "access" + }, + { + "method": "GET", + "path": "/admin/review-accounts", + "credential": "access" + }, + { + "method": "GET", + "path": "/admin/sessions", + "credential": "access" + }, + { + "method": "GET", + "path": "/admin/sessions/{userId}", + "credential": "access" + }, + { + "method": "DELETE", + "path": "/admin/sessions/{userId}/revoke-all", + "credential": "access" + }, + { + "method": "DELETE", + "path": "/admin/sessions/by-id/{id}", + "credential": "access" + }, + { + "method": "DELETE", + "path": "/admin/users", + "credential": "access" + }, + { + "method": "GET", + "path": "/admin/users", + "credential": "access" + }, + { + "method": "POST", + "path": "/admin/users", + "credential": "access" + }, + { + "method": "GET", + "path": "/admin/users/{userId}", + "credential": "access" + }, + { + "method": "PATCH", + "path": "/admin/users/{userId}", + "credential": "access" + }, + { + "method": "GET", + "path": "/admin/users/{userId}/anomalies", + "credential": "access" + }, + { + "method": "POST", + "path": "/admin/users/{userId}/recovery/device-replacement", + "credential": "access" + }, + { + "method": "POST", + "path": "/admin/users/import", + "credential": "access" + }, + { + "method": "GET", + "path": "/internal/auth-events/grouped", + "credential": "access" + }, + { + "method": "GET", + "path": "/internal/auth-events/login-stats", + "credential": "access" + }, + { + "method": "GET", + "path": "/internal/auth-events/summary", + "credential": "access" + }, + { + "method": "GET", + "path": "/internal/auth-events/timeseries", + "credential": "access" + }, + { + "method": "GET", + "path": "/internal/metrics/dashboard", + "credential": "access" + }, + { + "method": "GET", + "path": "/internal/metrics/funnel", + "credential": "access" + }, + { + "method": "GET", + "path": "/internal/metrics/sign-ins", + "credential": "access" + }, + { + "method": "GET", + "path": "/internal/security/anomalies", + "credential": "access" + }, + { + "method": "POST", + "path": "/login", + "credential": "none", + "issues": "preAuth", + "body": { + "pick": [ + "message", + "identifierType", + "loginMethods" + ] + } + }, + { + "method": "DELETE", + "path": "/logout", + "credential": "access", + "clears": [ + "access", + "registration", + "refresh" + ] + }, + { + "method": "DELETE", + "path": "/logout/all", + "credential": "access", + "clears": [ + "access", + "registration", + "refresh" + ] + }, + { + "method": "POST", + "path": "/magic-link", + "credential": "preAuth", + "delivery": true + }, + { + "method": "GET", + "path": "/magic-link/check", + "credential": "preAuth", + "issues": "session" + }, + { + "method": "GET", + "path": "/magic-link/verify/{token}", + "credential": "none" + }, + { + "method": "POST", + "path": "/oauth/{providerId}/callback", + "credential": "none", + "issues": "session" + }, + { + "method": "POST", + "path": "/oauth/{providerId}/start", + "credential": "none" + }, + { + "method": "GET", + "path": "/oauth/providers", + "credential": "none" + }, + { + "method": "GET", + "path": "/organizations", + "credential": "access" + }, + { + "method": "POST", + "path": "/organizations", + "credential": "access" + }, + { + "method": "GET", + "path": "/organizations/{organizationId}", + "credential": "access" + }, + { + "method": "PATCH", + "path": "/organizations/{organizationId}", + "credential": "access" + }, + { + "method": "GET", + "path": "/organizations/{organizationId}/members", + "credential": "access" + }, + { + "method": "POST", + "path": "/organizations/{organizationId}/members", + "credential": "access" + }, + { + "method": "DELETE", + "path": "/organizations/{organizationId}/members/{userId}", + "credential": "access" + }, + { + "method": "PATCH", + "path": "/organizations/{organizationId}/members/{userId}", + "credential": "access" + }, + { + "method": "POST", + "path": "/organizations/{organizationId}/switch", + "credential": "access", + "issues": "access" + }, + { + "method": "POST", + "path": "/otp/generate-email-otp", + "credential": "registration", + "delivery": true + }, + { + "method": "POST", + "path": "/otp/generate-login-email-otp", + "credential": "preAuth", + "delivery": true + }, + { + "method": "POST", + "path": "/otp/generate-login-phone-otp", + "credential": "preAuth", + "delivery": true + }, + { + "method": "POST", + "path": "/otp/generate-phone-otp", + "credential": "registration", + "delivery": true + }, + { + "method": "POST", + "path": "/otp/verify-email-otp", + "credential": "registration", + "issues": "session" + }, + { + "method": "POST", + "path": "/otp/verify-login-email-otp", + "credential": "preAuth", + "issues": "session" + }, + { + "method": "POST", + "path": "/otp/verify-login-phone-otp", + "credential": "preAuth", + "issues": "session" + }, + { + "method": "POST", + "path": "/otp/verify-phone-otp", + "credential": "registration", + "issues": "session" + }, + { + "method": "POST", + "path": "/refresh", + "credential": "refresh", + "issues": "session" + }, + { + "method": "POST", + "path": "/registration/phone", + "credential": "access" + }, + { + "method": "POST", + "path": "/registration/phone/verify", + "credential": "access" + }, + { + "method": "POST", + "path": "/registration/register", + "credential": "none", + "issues": "registration" + }, + { + "method": "DELETE", + "path": "/sessions", + "credential": "access" + }, + { + "method": "GET", + "path": "/sessions", + "credential": "access" + }, + { + "method": "DELETE", + "path": "/sessions/{id}", + "credential": "access" + }, + { + "method": "GET", + "path": "/step-up/status", + "credential": "access" + }, + { + "method": "POST", + "path": "/step-up/webauthn/finish", + "credential": "access" + }, + { + "method": "POST", + "path": "/step-up/webauthn/start", + "credential": "access" + }, + { + "method": "GET", + "path": "/system-config/admin", + "credential": "access" + }, + { + "method": "PATCH", + "path": "/system-config/admin", + "credential": "access" + }, + { + "method": "GET", + "path": "/system-config/oauth-providers", + "credential": "access" + }, + { + "method": "POST", + "path": "/system-config/oauth-providers", + "credential": "access" + }, + { + "method": "DELETE", + "path": "/system-config/oauth-providers/{id}", + "credential": "access" + }, + { + "method": "PATCH", + "path": "/system-config/oauth-providers/{id}", + "credential": "access" + }, + { + "method": "GET", + "path": "/system-config/public", + "credential": "none" + }, + { + "method": "GET", + "path": "/system-config/roles", + "credential": "access" + }, + { + "method": "POST", + "path": "/totp/disable", + "credential": "access" + }, + { + "method": "POST", + "path": "/totp/enroll/start", + "credential": "access" + }, + { + "method": "POST", + "path": "/totp/enroll/verify", + "credential": "access" + }, + { + "method": "GET", + "path": "/totp/status", + "credential": "access" + }, + { + "method": "POST", + "path": "/totp/verify-login", + "credential": "preAuth", + "issues": "session" + }, + { + "method": "POST", + "path": "/totp/verify-mfa", + "credential": "access" + }, + { + "method": "DELETE", + "path": "/users/credentials", + "credential": "access" + }, + { + "method": "POST", + "path": "/users/credentials", + "credential": "access" + }, + { + "method": "DELETE", + "path": "/users/delete", + "credential": "access", + "clears": [ + "access", + "registration", + "refresh" + ] + }, + { + "method": "GET", + "path": "/users/me", + "credential": "access", + "clears": [ + "preAuth" + ] + }, + { + "method": "POST", + "path": "/webauthn/login/finish", + "credential": "preAuth", + "issues": "session" + }, + { + "method": "POST", + "path": "/webauthn/login/start", + "credential": "preAuth" + }, + { + "method": "POST", + "path": "/webauthn/register/finish", + "credential": "access" + }, + { + "method": "GET", + "path": "/webauthn/register/start", + "credential": "access" + } + ] +}; diff --git a/packages/core/src/manifestProxy.ts b/packages/core/src/manifestProxy.ts new file mode 100644 index 0000000..f6f4687 --- /dev/null +++ b/packages/core/src/manifestProxy.ts @@ -0,0 +1,252 @@ +import type { AppliableResult, SessionCookie } from "./applyResult.js"; +import { authFetch } from "./authFetch.js"; +import type { AuthServerIssuerOption } from "./authServerIssuer.js"; +import type { SeamlessAuthMessagingOptions } from "./authMessaging.js"; +import { EXTERNAL_DELIVERY_HEADERS } from "./apiContract.js"; +import { applyExternalDelivery } from "./deliverAuthMessage.js"; +import { + type AdapterHeld, + type AdapterManifestRoute, + buildManifestPath, +} from "./manifest/adapterManifest.js"; +import { + buildUpstreamUrl, + checkProxyIdentity, + type QueryInput, +} from "./proxyRequest.js"; +import type { AuthTransport } from "./transport.js"; +import { readPassthroughFailure } from "./upstreamError.js"; +import { + issueSessionCookies, + type UpstreamSessionResponse, + verifyUpstreamSession, +} from "./upstreamSession.js"; +import { extractBearerToken } from "./verifyAccessToken.js"; + +export interface ManifestProxyInput { + route: AdapterManifestRoute; + params: Record; + transport: AuthTransport; + query?: QueryInput; + body?: unknown; + /** The raw `Authorization` header, read in bearer transport. */ + authorization?: string; + /** The verified payload `ensureCookies` loaded for this route's credential. */ + cookiePayload?: { sub?: string; token?: string }; + cookies: Record; + forwardedClientIp?: string; + forwardedUserAgent?: string; +} + +export interface ManifestProxyOptions extends AuthServerIssuerOption { + authServerUrl: string; + audience: string; + cookieDomain?: string; + accessCookieName: string; + registrationCookieName: string; + refreshCookieName: string; + preAuthCookieName: string; + /** The adapter's proxy service token, which lets the API trust forwarded client context. */ + serviceAuthorization?: string; + /** Sent instead of `serviceAuthorization` on a delivery route when `messaging` is set. */ + deliveryAuthorization?: string; + messaging?: SeamlessAuthMessagingOptions; +} + +const IDENTITY = { + access: "access", + preAuth: "preAuth", + registration: "register", +} as const; + +function cookieNameFor(held: AdapterHeld, opts: ManifestProxyOptions) { + return { + access: opts.accessCookieName, + preAuth: opts.preAuthCookieName, + registration: opts.registrationCookieName, + refresh: opts.refreshCookieName, + }[held]; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isUpstreamSession(value: unknown): value is UpstreamSessionResponse { + return ( + isObject(value) && + typeof value.token === "string" && + typeof value.sub === "string" + ); +} + +/** + * The body a cookie-transport caller receives. + * + * The browser never sees a token: the cookies carry them. That holds for every + * route, not only session-issuing ones, because the auth API also returns the + * ephemeral token it re-mints on an OTP send. + */ +function cookieTransportBody(route: AdapterManifestRoute, data: unknown) { + if (!isObject(data)) return data; + + if (route.body) { + return Object.fromEntries( + route.body.pick.filter((key) => key in data).map((key) => [key, data[key]]), + ); + } + + const { token: _token, refreshToken: _refreshToken, ...rest } = data; + return rest; +} + +async function cookiesFor( + route: AdapterManifestRoute, + data: UpstreamSessionResponse, + opts: ManifestProxyOptions, +): Promise { + if (route.issues === "preAuth" || route.issues === "registration") { + await verifyUpstreamSession( + data, + opts.authServerUrl, + opts.audience, + opts.authServerIssuer, + ); + + return [ + { + name: cookieNameFor(route.issues, opts), + value: { sub: data.sub, token: data.token }, + ttl: data.ttl, + domain: opts.cookieDomain, + }, + ]; + } + + return issueSessionCookies(data, { + authServerUrl: opts.authServerUrl, + audience: opts.audience, + authServerIssuer: opts.authServerIssuer, + accessCookieName: opts.accessCookieName, + refreshCookieName: + route.issues === "session" ? opts.refreshCookieName : undefined, + cookieDomain: opts.cookieDomain, + }); +} + +function isJson(contentType: string | null | undefined) { + return !contentType || /\bjson\b/i.test(contentType); +} + +/** + * Proxies a route the adapter has no dedicated handler for, following what the + * manifest says about it. + * + * A response only issues cookies when it actually carries a session: an OTP + * verify on a phone-first signup step, for example, succeeds without one. + */ +export async function handleManifestRoute( + input: ManifestProxyInput, + opts: ManifestProxyOptions, +): Promise { + const { route } = input; + + if (route.credential === "refresh") { + return { status: 404, errorCode: "route_not_supported" }; + } + + if (route.credential !== "none") { + const rejection = checkProxyIdentity({ + subject: input.cookiePayload?.sub, + cookies: input.cookies, + identity: IDENTITY[route.credential], + accessCookieName: opts.accessCookieName, + preAuthCookieName: opts.preAuthCookieName, + registrationCookieName: opts.registrationCookieName, + transport: input.transport, + authorization: input.authorization, + }); + + if (rejection) { + return { status: rejection.status, errorCode: rejection.errorCode }; + } + } + + const authorization = + route.credential === "none" + ? undefined + : input.transport === "bearer" + ? `Bearer ${extractBearerToken(input.authorization)}` + : `Bearer ${input.cookiePayload?.token}`; + + const externalDelivery = Boolean(route.delivery && opts.messaging); + + const upstream = await authFetch( + buildUpstreamUrl( + opts.authServerUrl, + buildManifestPath(route, input.params), + input.query, + ), + { + method: route.method, + authorization, + serviceAuthorization: externalDelivery + ? opts.deliveryAuthorization + : opts.serviceAuthorization, + forwardedClientIp: input.forwardedClientIp, + forwardedUserAgent: input.forwardedUserAgent, + ...(externalDelivery ? { headers: { ...EXTERNAL_DELIVERY_HEADERS } } : {}), + ...(route.method === "GET" ? {} : { body: input.body }), + }, + ); + + if (upstream.ok && !isJson(upstream.headers?.get("content-type"))) { + const headers: Record = {}; + + for (const name of ["content-type", "content-disposition", "cache-control"]) { + const value = upstream.headers.get(name); + if (value !== null) headers[name] = value; + } + + return { status: upstream.status, raw: { headers, body: upstream.body } }; + } + + let data: unknown = await upstream.json(); + + if (!upstream.ok) { + return { status: upstream.status, ...readPassthroughFailure(data) }; + } + + if (externalDelivery) { + data = await applyExternalDelivery(opts.messaging, data); + } + + const clearCookies = route.clears + ? [...new Set(route.clears.map((held) => cookieNameFor(held, opts)))] + : undefined; + + if (input.transport === "bearer") { + if (route.issues && isUpstreamSession(data)) { + await verifyUpstreamSession( + data, + opts.authServerUrl, + opts.audience, + opts.authServerIssuer, + ); + } + + return { status: upstream.status, body: data }; + } + + const setCookies = + route.issues && isUpstreamSession(data) + ? await cookiesFor(route, data, opts) + : undefined; + + return { + status: upstream.status, + body: cookieTransportBody(route, data), + ...(setCookies ? { setCookies } : {}), + ...(clearCookies ? { clearCookies } : {}), + }; +} diff --git a/packages/core/tests/adapterManifest.test.js b/packages/core/tests/adapterManifest.test.js new file mode 100644 index 0000000..0e34289 --- /dev/null +++ b/packages/core/tests/adapterManifest.test.js @@ -0,0 +1,204 @@ +import { jest } from "@jest/globals"; + +const { + BUNDLED_ADAPTER_MANIFEST, +} = await import("../dist/manifest/bundledManifest.js"); +const { + buildManifestPath, + createAdapterManifestSource, + matchManifestRoute, + parseAdapterManifest, +} = await import("../dist/manifest/adapterManifest.js"); + +const manifest = { + schemaVersion: 1, + apiVersion: "1.0.0", + session: {}, + routes: [ + { method: "GET", path: "/admin/users/{userId}", credential: "access" }, + { method: "POST", path: "/admin/users/import", credential: "access" }, + { method: "POST", path: "/webauthn/login/start", credential: "preAuth" }, + { method: "GET", path: "/magic-link/verify/{token}", credential: "none" }, + ], +}; + +describe("parseAdapterManifest", () => { + it("accepts a schemaVersion 1 manifest", () => { + expect(parseAdapterManifest(manifest)).toBe(manifest); + }); + + it("accepts the bundled manifest", () => { + expect(parseAdapterManifest(BUNDLED_ADAPTER_MANIFEST)).toBeDefined(); + }); + + it("refuses an unknown schema version", () => { + expect(parseAdapterManifest({ ...manifest, schemaVersion: 2 })).toBeUndefined(); + }); + + // A route with a credential this version cannot follow would be proxied with + // the wrong token, so the whole manifest is refused instead. + it("refuses a manifest with a credential it does not understand", () => { + expect( + parseAdapterManifest({ + ...manifest, + routes: [{ method: "GET", path: "/x", credential: "device" }], + }), + ).toBeUndefined(); + }); + + it("refuses a manifest with an unknown effect", () => { + expect( + parseAdapterManifest({ + ...manifest, + routes: [ + { method: "GET", path: "/x", credential: "none", issues: "everything" }, + ], + }), + ).toBeUndefined(); + }); + + it("refuses non-objects", () => { + expect(parseAdapterManifest(null)).toBeUndefined(); + expect(parseAdapterManifest("manifest")).toBeUndefined(); + }); +}); + +describe("matchManifestRoute", () => { + it("matches path parameters and decodes them", () => { + expect(matchManifestRoute(manifest, "get", "/admin/users/a%2Fb")).toEqual({ + route: manifest.routes[0], + params: { userId: "a/b" }, + }); + }); + + it("prefers a static segment over a parameter", () => { + expect( + matchManifestRoute(manifest, "POST", "/admin/users/import")?.route.path, + ).toBe("/admin/users/import"); + }); + + it("compares static segments case-insensitively", () => { + expect( + matchManifestRoute(manifest, "POST", "/webAuthn/login/start")?.route.path, + ).toBe("/webauthn/login/start"); + }); + + it("requires the method to match", () => { + expect(matchManifestRoute(manifest, "DELETE", "/admin/users/1")).toBeUndefined(); + }); + + it("requires the segment count to match", () => { + expect(matchManifestRoute(manifest, "GET", "/admin/users")).toBeUndefined(); + expect(matchManifestRoute(manifest, "GET", "/admin/users/1/x")).toBeUndefined(); + }); + + it.each(["..", ".", "%2E%2E", "%2e"])( + "does not match a dot segment as a parameter (%s)", + (segment) => { + expect(matchManifestRoute(manifest, "GET", `/admin/users/${segment}`)).toBeUndefined(); + }, + ); + + it("does not match a malformed percent-encoding", () => { + expect(matchManifestRoute(manifest, "GET", "/magic-link/verify/%E0%A4%A")).toBeUndefined(); + }); +}); + +describe("buildManifestPath", () => { + it("encodes each parameter", () => { + expect(buildManifestPath(manifest.routes[0], { userId: "a/b?c" })).toBe( + "/admin/users/a%2Fb%3Fc", + ); + }); +}); + +describe("createAdapterManifestSource", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("fetches the manifest once and keeps it", async () => { + global.fetch = jest.fn(async () => Response.json(manifest)); + const source = createAdapterManifestSource({ authServerUrl: "https://auth.test" }); + + await expect(source.get()).resolves.toEqual(manifest); + await expect(source.get()).resolves.toEqual(manifest); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith( + "https://auth.test/.well-known/seamless-adapter.json", + expect.anything(), + ); + }); + + it("shares one fetch between concurrent callers", async () => { + global.fetch = jest.fn(async () => Response.json(manifest)); + const source = createAdapterManifestSource({ authServerUrl: "https://auth.test" }); + + await Promise.all([source.get(), source.get(), source.get()]); + + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("falls back to the bundled copy and waits before trying again", async () => { + global.fetch = jest.fn(async () => new Response("not found", { status: 404 })); + const source = createAdapterManifestSource({ + authServerUrl: "https://auth.test", + retryAfterMs: 60_000, + }); + + await expect(source.get()).resolves.toBe(BUNDLED_ADAPTER_MANIFEST); + await expect(source.get()).resolves.toBe(BUNDLED_ADAPTER_MANIFEST); + + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("retries after the wait", async () => { + global.fetch = jest + .fn() + .mockRejectedValueOnce(new Error("ECONNREFUSED")) + .mockResolvedValueOnce(Response.json(manifest)); + const source = createAdapterManifestSource({ + authServerUrl: "https://auth.test", + retryAfterMs: 0, + }); + + await expect(source.get()).resolves.toBe(BUNDLED_ADAPTER_MANIFEST); + await expect(source.get()).resolves.toEqual(manifest); + }); + + it("falls back when the fetched manifest is not one it can follow", async () => { + global.fetch = jest.fn(async () => Response.json({ ...manifest, schemaVersion: 2 })); + const source = createAdapterManifestSource({ authServerUrl: "https://auth.test" }); + + await expect(source.get()).resolves.toBe(BUNDLED_ADAPTER_MANIFEST); + }); + + it("gives up on a fetch that does not answer in time", async () => { + global.fetch = jest.fn( + (_url, init) => + new Promise((_resolve, reject) => { + init.signal.addEventListener("abort", () => reject(init.signal.reason)); + }), + ); + const source = createAdapterManifestSource({ + authServerUrl: "https://auth.test", + timeoutMs: 10, + }); + + await expect(source.get()).resolves.toBe(BUNDLED_ADAPTER_MANIFEST); + }); + + it("never fetches when fetching is turned off", async () => { + global.fetch = jest.fn(); + const source = createAdapterManifestSource({ + authServerUrl: "https://auth.test", + fetchManifest: false, + }); + + await expect(source.get()).resolves.toBe(BUNDLED_ADAPTER_MANIFEST); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/tests/ensureCookies.test.js b/packages/core/tests/ensureCookies.test.js index c1d519c..30d8dc6 100644 --- a/packages/core/tests/ensureCookies.test.js +++ b/packages/core/tests/ensureCookies.test.js @@ -599,3 +599,102 @@ describe("ensureCookies", () => { }); }); }); + +describe("ensureCookies with a manifest", () => { + const manifest = { + schemaVersion: 1, + apiVersion: "1.0.0", + session: {}, + routes: [ + { method: "POST", path: "/totp/verify-login", credential: "preAuth" }, + { method: "POST", path: "/registration/phone", credential: "access" }, + { method: "GET", path: "/oauth/providers", credential: "none" }, + { method: "POST", path: "/refresh", credential: "refresh" }, + ], + }; + + beforeEach(() => { + verifyCookieJwtMock.mockReset(); + refreshAccessTokenMock.mockReset(); + verifySignedAuthResponseMock.mockReset(); + }); + + it("loads the cookie the manifest names for a route the table does not list", async () => { + const { ensureCookies } = await import("../dist/ensureCookies.js"); + verifyCookieJwtMock.mockReturnValue({ sub: "user-1", token: "pre-auth" }); + + const result = await ensureCookies( + { + path: "/totp/verify-login", + method: "POST", + manifest, + cookies: { preauth: "signed" }, + }, + BASE_OPTS, + ); + + expect(verifyCookieJwtMock).toHaveBeenCalledWith("signed", BASE_OPTS.cookieSecret); + expect(result).toMatchObject({ type: "ok", user: { sub: "user-1", token: "pre-auth" } }); + }); + + it("answers 401 when the manifest's cookie is missing", async () => { + const { ensureCookies } = await import("../dist/ensureCookies.js"); + + const result = await ensureCookies( + { path: "/totp/verify-login", method: "POST", manifest, cookies: {} }, + BASE_OPTS, + ); + + expect(result).toMatchObject({ type: "error", status: 401 }); + }); + + it("refreshes a missing access cookie for an access route", async () => { + const { ensureCookies } = await import("../dist/ensureCookies.js"); + verifySignedAuthResponseMock.mockResolvedValue({ sub: "user-1", sid: "s-1" }); + refreshAccessTokenMock.mockResolvedValue({ + sub: "user-1", + token: "new-access", + refreshToken: "new-refresh", + ttl: 300, + refreshTtl: 3600, + }); + + const result = await ensureCookies( + { + path: "/registration/phone", + method: "POST", + manifest, + cookies: { refresh: "refresh-cookie" }, + }, + BASE_OPTS, + ); + + expect(refreshAccessTokenMock).toHaveBeenCalled(); + expect(result).toMatchObject({ type: "ok", user: { token: "new-access" } }); + }); + + it.each([ + ["GET", "/oauth/providers"], + ["POST", "/refresh"], + ])("requires nothing for %s %s", async (method, path) => { + const { ensureCookies } = await import("../dist/ensureCookies.js"); + + const result = await ensureCookies( + { path, method, manifest, cookies: {} }, + BASE_OPTS, + ); + + expect(result).toEqual({ type: "ok" }); + }); + + it("falls back to the table for a route the manifest does not list", async () => { + const { ensureCookies } = await import("../dist/ensureCookies.js"); + + const result = await ensureCookies( + { path: "/users/me", method: "GET", manifest, cookies: {} }, + BASE_OPTS, + ); + + expect(result).toMatchObject({ type: "error", status: 401 }); + }); +}); diff --git a/packages/core/tests/manifestProxy.test.js b/packages/core/tests/manifestProxy.test.js new file mode 100644 index 0000000..4605aef --- /dev/null +++ b/packages/core/tests/manifestProxy.test.js @@ -0,0 +1,383 @@ +import { jest } from "@jest/globals"; + +const authFetchMock = jest.fn(); +const verifySignedAuthResponseMock = jest.fn(); +const applyExternalDeliveryMock = jest.fn(); + +jest.unstable_mockModule("../dist/authFetch.js", () => ({ + authFetch: authFetchMock, +})); +jest.unstable_mockModule("../dist/verifySignedAuthResponse.js", () => ({ + verifySignedAuthResponse: verifySignedAuthResponseMock, +})); +jest.unstable_mockModule("../dist/deliverAuthMessage.js", () => ({ + applyExternalDelivery: applyExternalDeliveryMock, +})); + +const { handleManifestRoute } = await import("../dist/manifestProxy.js"); + +const OPTIONS = { + authServerUrl: "https://auth.test", + audience: "https://auth.test", + cookieDomain: "acme.test", + accessCookieName: "seamless-access", + registrationCookieName: "seamless-ephemeral", + preAuthCookieName: "seamless-ephemeral", + refreshCookieName: "seamless-refresh", + serviceAuthorization: "Bearer proxy-token", + deliveryAuthorization: "Bearer delivery-token", +}; + +const SESSION = { + message: "Success", + sub: "user-1", + token: "access-token", + refreshToken: "refresh-token", + ttl: 300, + refreshTtl: 3600, +}; + +function upstream(status, body, contentType = "application/json") { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(contentType ? { "content-type": contentType } : {}), + body: null, + json: async () => body, + }; +} + +function input(route, overrides = {}) { + return { + route, + params: {}, + transport: "cookie", + cookies: {}, + ...overrides, + }; +} + +const ACCESS_COOKIES = { + cookiePayload: { sub: "user-1", token: "access-token" }, + cookies: { "seamless-access": "signed" }, +}; + +beforeEach(() => { + authFetchMock.mockReset(); + verifySignedAuthResponseMock.mockReset(); + verifySignedAuthResponseMock.mockResolvedValue({ sub: "user-1", sid: "s-1" }); + applyExternalDeliveryMock.mockReset(); +}); + +describe("handleManifestRoute", () => { + it("forwards an access route with the held token, path parameters and query", async () => { + authFetchMock.mockResolvedValue(upstream(200, { ok: true })); + + const result = await handleManifestRoute( + input( + { method: "GET", path: "/admin/users/{userId}", credential: "access" }, + { ...ACCESS_COOKIES, params: { userId: "a/b" }, query: { limit: 5 } }, + ), + OPTIONS, + ); + + expect(authFetchMock).toHaveBeenCalledWith( + "https://auth.test/admin/users/a%2Fb?limit=5", + expect.objectContaining({ + method: "GET", + authorization: "Bearer access-token", + serviceAuthorization: "Bearer proxy-token", + }), + ); + expect(authFetchMock.mock.calls[0][1]).not.toHaveProperty("body"); + expect(result).toEqual({ status: 200, body: { ok: true } }); + }); + + it("refuses a cookie-transport request without the route's cookie", async () => { + const result = await handleManifestRoute( + input( + { method: "POST", path: "/totp/verify-login", credential: "preAuth" }, + { cookiePayload: { sub: "user-1", token: "t" }, cookies: {} }, + ), + OPTIONS, + ); + + expect(result).toEqual({ status: 401, errorCode: "pre-auth session required" }); + expect(authFetchMock).not.toHaveBeenCalled(); + }); + + it("sends no user credential on a public route", async () => { + authFetchMock.mockResolvedValue(upstream(200, { providers: [] })); + + await handleManifestRoute( + input({ method: "GET", path: "/oauth/providers", credential: "none" }), + OPTIONS, + ); + + expect(authFetchMock.mock.calls[0][1].authorization).toBeUndefined(); + }); + + it("does not proxy a refresh-credential route", async () => { + const result = await handleManifestRoute( + input({ method: "POST", path: "/refresh", credential: "refresh", issues: "session" }), + OPTIONS, + ); + + expect(result.status).toBe(404); + expect(authFetchMock).not.toHaveBeenCalled(); + }); + + it("passes an upstream failure through", async () => { + authFetchMock.mockResolvedValue(upstream(403, { error: "forbidden" })); + + const result = await handleManifestRoute( + input({ method: "GET", path: "/admin/users", credential: "access" }, ACCESS_COOKIES), + OPTIONS, + ); + + expect(result).toEqual({ status: 403, errorBody: { error: "forbidden" } }); + }); + + it("streams a response that is not JSON", async () => { + authFetchMock.mockResolvedValue(upstream(200, undefined, "application/x-ndjson")); + + const result = await handleManifestRoute( + input( + { method: "GET", path: "/admin/auth-events/export", credential: "access" }, + ACCESS_COOKIES, + ), + OPTIONS, + ); + + expect(result).toEqual({ + status: 200, + raw: { headers: { "content-type": "application/x-ndjson" }, body: null }, + }); + }); + + describe("issuing a session", () => { + const route = { + method: "POST", + path: "/totp/verify-login", + credential: "preAuth", + issues: "session", + }; + const preAuth = { + cookiePayload: { sub: "user-1", token: "pre-auth-token" }, + cookies: { "seamless-ephemeral": "signed" }, + }; + + it("sets access and refresh cookies and keeps tokens out of the body", async () => { + authFetchMock.mockResolvedValue(upstream(200, SESSION)); + + const result = await handleManifestRoute(input(route, preAuth), OPTIONS); + + expect(authFetchMock.mock.calls[0][1].authorization).toBe("Bearer pre-auth-token"); + expect(result.body).toEqual({ + message: "Success", + sub: "user-1", + ttl: 300, + refreshTtl: 3600, + }); + expect(result.setCookies.map((cookie) => cookie.name)).toEqual([ + "seamless-access", + "seamless-refresh", + ]); + expect(result.setCookies[0].value).toMatchObject({ + sub: "user-1", + sessionId: "s-1", + token: "access-token", + }); + }); + + it("refuses a response whose token does not verify", async () => { + authFetchMock.mockResolvedValue(upstream(200, SESSION)); + verifySignedAuthResponseMock.mockResolvedValue(null); + + await expect(handleManifestRoute(input(route, preAuth), OPTIONS)).rejects.toThrow( + /Invalid signed response/, + ); + }); + + it("issues nothing when the response carries no session", async () => { + authFetchMock.mockResolvedValue(upstream(200, { message: "Phone pending" })); + + const result = await handleManifestRoute(input(route, preAuth), OPTIONS); + + expect(result).toEqual({ status: 200, body: { message: "Phone pending" } }); + }); + + it("returns the whole body and sets no cookies in bearer transport", async () => { + authFetchMock.mockResolvedValue(upstream(200, SESSION)); + + const result = await handleManifestRoute( + input(route, { transport: "bearer", authorization: "Bearer client-token" }), + OPTIONS, + ); + + expect(authFetchMock.mock.calls[0][1].authorization).toBe("Bearer client-token"); + expect(verifySignedAuthResponseMock).toHaveBeenCalled(); + expect(result).toEqual({ status: 200, body: SESSION }); + }); + + it("reissues only the access cookie for an access reissue", async () => { + authFetchMock.mockResolvedValue(upstream(200, SESSION)); + + const result = await handleManifestRoute( + input( + { + method: "POST", + path: "/organizations/{organizationId}/switch", + credential: "access", + issues: "access", + }, + { ...ACCESS_COOKIES, params: { organizationId: "org-1" } }, + ), + OPTIONS, + ); + + expect(result.setCookies.map((cookie) => cookie.name)).toEqual(["seamless-access"]); + }); + + it("stores an ephemeral token under the route's cookie", async () => { + authFetchMock.mockResolvedValue( + upstream(200, { message: "ok", sub: "user-1", token: "reg-token", ttl: 300 }), + ); + + const result = await handleManifestRoute( + input({ + method: "POST", + path: "/registration/register", + credential: "none", + issues: "registration", + }), + OPTIONS, + ); + + expect(result.setCookies).toEqual([ + { + name: "seamless-ephemeral", + value: { sub: "user-1", token: "reg-token" }, + ttl: 300, + domain: "acme.test", + }, + ]); + expect(result.body).toEqual({ message: "ok", sub: "user-1", ttl: 300 }); + }); + }); + + it("passes only picked fields to the browser", async () => { + authFetchMock.mockResolvedValue( + upstream(200, { + message: "Success", + identifierType: "email", + sub: "user-1", + token: "pre-auth", + ttl: 300, + }), + ); + + const result = await handleManifestRoute( + input({ + method: "POST", + path: "/login", + credential: "none", + issues: "preAuth", + body: { pick: ["message", "identifierType", "loginMethods"] }, + }), + OPTIONS, + ); + + expect(result.body).toEqual({ message: "Success", identifierType: "email" }); + }); + + // The auth API re-mints the ephemeral token on an OTP send. The cookie already + // holds one, and a page script must not be able to read it. + it("strips a token from a route that issues nothing", async () => { + authFetchMock.mockResolvedValue(upstream(200, { message: "success", token: "re-minted" })); + + const result = await handleManifestRoute( + input( + { method: "POST", path: "/otp/generate-login-email-otp", credential: "preAuth" }, + { + cookiePayload: { sub: "user-1", token: "pre-auth" }, + cookies: { "seamless-ephemeral": "signed" }, + }, + ), + OPTIONS, + ); + + expect(result.body).toEqual({ message: "success" }); + }); + + it("clears the declared cookies once, even when two share a name", async () => { + authFetchMock.mockResolvedValue(upstream(200, { message: "deleted" })); + + const result = await handleManifestRoute( + input( + { + method: "DELETE", + path: "/users/delete", + credential: "access", + clears: ["access", "registration", "preAuth", "refresh"], + }, + ACCESS_COOKIES, + ), + OPTIONS, + ); + + expect(result.clearCookies).toEqual([ + "seamless-access", + "seamless-ephemeral", + "seamless-refresh", + ]); + }); + + describe("delivery routes", () => { + const route = { + method: "POST", + path: "/magic-link", + credential: "preAuth", + delivery: true, + }; + const preAuth = { + cookiePayload: { sub: "user-1", token: "pre-auth" }, + cookies: { "seamless-ephemeral": "signed" }, + }; + + it("asks for external delivery and delivers when messaging is configured", async () => { + const messaging = { email: { send: jest.fn() } }; + authFetchMock.mockResolvedValue( + upstream(200, { message: "sent", delivery: { kind: "magic_link_email" } }), + ); + applyExternalDeliveryMock.mockResolvedValue({ message: "sent" }); + + const result = await handleManifestRoute(input(route, preAuth), { + ...OPTIONS, + messaging, + }); + + expect(authFetchMock.mock.calls[0][1]).toMatchObject({ + serviceAuthorization: "Bearer delivery-token", + headers: { "x-seamless-auth-delivery-mode": "external" }, + }); + expect(applyExternalDeliveryMock).toHaveBeenCalledWith(messaging, { + message: "sent", + delivery: { kind: "magic_link_email" }, + }); + expect(result.body).toEqual({ message: "sent" }); + }); + + it("lets the auth API deliver when no messaging is configured", async () => { + authFetchMock.mockResolvedValue(upstream(200, { message: "sent" })); + + await handleManifestRoute(input(route, preAuth), OPTIONS); + + expect(authFetchMock.mock.calls[0][1]).toMatchObject({ + serviceAuthorization: "Bearer proxy-token", + }); + expect(authFetchMock.mock.calls[0][1]).not.toHaveProperty("headers"); + expect(applyExternalDeliveryMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/tests/publicExports.test.js b/packages/core/tests/publicExports.test.js index 75427a3..41cc286 100644 --- a/packages/core/tests/publicExports.test.js +++ b/packages/core/tests/publicExports.test.js @@ -12,9 +12,11 @@ import { authenticateRequest, authorizeRoles, buildExternalDeliveryAuthorization, + buildManifestPath, buildQueryString, buildUpstreamUrl, checkProxyIdentity, + createAdapterManifestSource, createServiceToken, deliverAuthMessage, ensureCookies, @@ -27,12 +29,15 @@ import { getDashboardMetricsHandler, getSeamlessUser, getUsersHandler, + handleManifestRoute, hasScopedRole, listOAuthProvidersHandler, listSessionsHandler, loginHandler, logoutHandler, + matchManifestRoute, meHandler, + parseAdapterManifest, pollMagicLinkConfirmationHandler, proxyRequest, redactSensitiveText, @@ -58,6 +63,7 @@ import { verifyRegistrationOtpHandler, verifySignedAuthResponse, verifyAccessToken, + ADAPTER_MANIFEST_PATH, AUTH_DELIVERY_MODE_HEADER, AUTH_TRANSPORT_HEADER, BEARER_TRANSPORT, @@ -80,9 +86,11 @@ const DOCUMENTED_FUNCTIONS = { authenticateRequest, authorizeRoles, buildExternalDeliveryAuthorization, + buildManifestPath, buildQueryString, buildUpstreamUrl, checkProxyIdentity, + createAdapterManifestSource, createServiceToken, deliverAuthMessage, ensureCookies, @@ -95,12 +103,15 @@ const DOCUMENTED_FUNCTIONS = { getDashboardMetricsHandler, getSeamlessUser, getUsersHandler, + handleManifestRoute, hasScopedRole, listOAuthProvidersHandler, listSessionsHandler, loginHandler, logoutHandler, + matchManifestRoute, meHandler, + parseAdapterManifest, pollMagicLinkConfirmationHandler, proxyRequest, redactSensitiveText, @@ -129,6 +140,7 @@ const DOCUMENTED_FUNCTIONS = { }; const DOCUMENTED_CONSTANTS = { + ADAPTER_MANIFEST_PATH, AUTH_DELIVERY_MODE_HEADER, AUTH_TRANSPORT_HEADER, BEARER_TRANSPORT, diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index 8f0cb2c..621f96a 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -33,12 +33,16 @@ import { authFetch, AuthFetchOptions, checkProxyIdentity, + createAdapterManifestSource, + handleManifestRoute, + matchManifestRoute, proxyRequest, redactSensitiveText, SERVICE_TOKEN_AUDIENCE, SERVICE_TOKEN_ISSUER, } from "@seamless-auth/core"; import { + buildInternalServiceAuthorization, buildProxyServiceAuthorization, buildServiceAuthorization, } from "./internal/buildAuthorization"; @@ -125,6 +129,13 @@ export type SeamlessAuthServerOptions = { preAuthCookieName?: string; messaging?: SeamlessAuthMessagingOptions; resolveClientIp?: ClientIpResolver; + /** + * Fetch the adapter manifest from the auth API (the default). Routes with no + * handler of their own here are proxied as it describes, so a new API route + * works without upgrading this package. `false` uses the manifest bundled with + * this package version only. + */ + fetchManifest?: boolean; }; export interface SeamlessAuthUser { @@ -224,6 +235,10 @@ export function createSeamlessAuthServer( warnOnDevJwksKid(opts.jwksKid); const r = express.Router(); + const manifestSource = createAdapterManifestSource({ + authServerUrl: opts.authServerUrl, + fetchManifest: opts.fetchManifest, + }); r.use(express.json()); r.use(cookieParser()); @@ -329,6 +344,7 @@ export function createSeamlessAuthServer( authServerIssuer: resolvedOpts.authServerIssuer, keyId: resolvedOpts.jwksKid, resolveClientIp: resolvedOpts.resolveClientIp, + manifestSource, }), ); @@ -762,6 +778,46 @@ export function createSeamlessAuthServer( revokeAllSessions(req, res, resolvedOpts), ); + // Last, so a route with a handler of its own always wins. + r.use(async (req: Request & { cookiePayload?: any }, res, next) => { + const match = matchManifestRoute( + await manifestSource.get(), + req.method, + req.path, + ); + + if (!match) { + next(); + return; + } + + const result = await handleManifestRoute( + { + ...match, + transport: transportOf(req), + query: req.query, + body: req.body, + authorization: req.headers.authorization, + cookiePayload: req.cookiePayload, + cookies: req.cookies ?? {}, + forwardedClientIp: buildForwardedClientIp( + req, + resolvedOpts.resolveClientIp, + ), + forwardedUserAgent: buildForwardedUserAgent(req), + }, + { + ...resolvedOpts, + serviceAuthorization: buildProxyServiceAuthorization(resolvedOpts), + deliveryAuthorization: resolvedOpts.messaging + ? buildInternalServiceAuthorization(resolvedOpts) + : undefined, + }, + ); + + respond(res, result, resolvedOpts); + }); + // Express 5 forwards rejected handler promises here. Without this, the // built-in handler answers with an HTML stack trace (including absolute // server paths) whenever NODE_ENV is not "production". diff --git a/packages/express/src/middleware/ensureCookies.ts b/packages/express/src/middleware/ensureCookies.ts index 6be61d9..7aedfd1 100644 --- a/packages/express/src/middleware/ensureCookies.ts +++ b/packages/express/src/middleware/ensureCookies.ts @@ -1,5 +1,9 @@ import { Request, Response, NextFunction } from "express"; -import { ensureCookies, EnsureCookiesResult } from "@seamless-auth/core"; +import { + type AdapterManifestSource, + ensureCookies, + EnsureCookiesResult, +} from "@seamless-auth/core"; import { buildForwardedClientIp, @@ -38,6 +42,8 @@ export interface EnsureCookiesMiddlewareOptions { authServerIssuer?: string; keyId: string; resolveClientIp?: ClientIpResolver; + /** Supplies each route's credential. Without it only the built-in table applies. */ + manifestSource?: AdapterManifestSource; } export function createEnsureCookiesMiddleware( @@ -61,6 +67,8 @@ export function createEnsureCookiesMiddleware( { path: req.path, cookies: req.cookies ?? {}, + method: req.method, + manifest: await opts.manifestSource?.get(), }, { authServerUrl: opts.authServerUrl, diff --git a/packages/express/tests/adminRoutes.test.js b/packages/express/tests/adminRoutes.test.js index c49ec8c..543235c 100644 --- a/packages/express/tests/adminRoutes.test.js +++ b/packages/express/tests/adminRoutes.test.js @@ -37,6 +37,7 @@ function createApp() { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret-cookie-secret-cookie-secret", serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/cookieRequirementRoutes.test.js b/packages/express/tests/cookieRequirementRoutes.test.js index c73bb44..5c21241 100644 --- a/packages/express/tests/cookieRequirementRoutes.test.js +++ b/packages/express/tests/cookieRequirementRoutes.test.js @@ -37,6 +37,7 @@ function createApp() { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret-cookie-secret-cookie-secret", serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/cookieSecurity.test.js b/packages/express/tests/cookieSecurity.test.js index 90dab63..95f99a7 100644 --- a/packages/express/tests/cookieSecurity.test.js +++ b/packages/express/tests/cookieSecurity.test.js @@ -31,6 +31,7 @@ function createApp(overrides = {}) { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret-cookie-secret-cookie-secret", serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/failureWireFormat.test.js b/packages/express/tests/failureWireFormat.test.js index 53be58d..abcc53d 100644 --- a/packages/express/tests/failureWireFormat.test.js +++ b/packages/express/tests/failureWireFormat.test.js @@ -42,6 +42,7 @@ function createApp() { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: COOKIE_SECRET, serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/internalMetricsRoutes.test.js b/packages/express/tests/internalMetricsRoutes.test.js index 8dfed0d..0e8b25c 100644 --- a/packages/express/tests/internalMetricsRoutes.test.js +++ b/packages/express/tests/internalMetricsRoutes.test.js @@ -37,6 +37,7 @@ function createApp() { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret-cookie-secret-cookie-secret", serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/loginOtpRoutes.test.js b/packages/express/tests/loginOtpRoutes.test.js index 007ab7b..9cf87e0 100644 --- a/packages/express/tests/loginOtpRoutes.test.js +++ b/packages/express/tests/loginOtpRoutes.test.js @@ -32,6 +32,7 @@ function createApp() { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret-cookie-secret-cookie-secret", serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/logoutRoutes.test.js b/packages/express/tests/logoutRoutes.test.js index 5d40224..9ef8ca4 100644 --- a/packages/express/tests/logoutRoutes.test.js +++ b/packages/express/tests/logoutRoutes.test.js @@ -46,6 +46,7 @@ function createApp() { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret-cookie-secret-cookie-secret", serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/magicLinkRedirect.test.js b/packages/express/tests/magicLinkRedirect.test.js index d13a53e..2f9c9a1 100644 --- a/packages/express/tests/magicLinkRedirect.test.js +++ b/packages/express/tests/magicLinkRedirect.test.js @@ -30,6 +30,7 @@ function createApp() { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: COOKIE_SECRET, serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/manifestRoutes.test.js b/packages/express/tests/manifestRoutes.test.js new file mode 100644 index 0000000..a968047 --- /dev/null +++ b/packages/express/tests/manifestRoutes.test.js @@ -0,0 +1,208 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import jwt from "jsonwebtoken"; +import request from "supertest"; + +const { default: createSeamlessAuthServer } = await import("../dist/index.js"); + +const AUTH = "https://auth.example.com"; +const COOKIE_SECRET = "cookie-secret-cookie-secret-cookie-secret"; + +let privateKey; +let jwk; + +beforeAll(async () => { + const pair = await generateKeyPair("RS256"); + privateKey = pair.privateKey; + jwk = { ...(await exportJWK(pair.publicKey)), alg: "RS256", kid: "k1", use: "sig" }; +}); + +function signAccessToken(sub = "user-1") { + return new SignJWT({ sub, sid: "session-1" }) + .setProtectedHeader({ alg: "RS256", kid: "k1" }) + .setIssuer(AUTH) + .setAudience(AUTH) + .setSubject(sub) + .setExpirationTime("5m") + .sign(privateKey); +} + +function json(status, body) { + return new Response(body === undefined ? null : JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function cookie(name, payload) { + return `${name}=${jwt.sign(payload, COOKIE_SECRET, { algorithm: "HS256", expiresIn: "300s" })}`; +} + +function createApp(overrides = {}) { + const app = express(); + app.use( + "/auth", + createSeamlessAuthServer({ + authServerUrl: AUTH, + cookieSecret: COOKIE_SECRET, + serviceSecret: "service-secret-service-secret-service-secret", + audience: AUTH, + jwksKid: "test-main", + ...overrides, + }), + ); + return app; +} + +function routeFetch(handlers) { + global.fetch = jest.fn(async (url, init) => { + const path = new URL(url).pathname; + if (path === "/.well-known/jwks.json") return json(200, { keys: [jwk] }); + const handler = handlers[`${init?.method ?? "GET"} ${path}`]; + return handler ? handler(url, init) : json(404, { error: "not_found" }); + }); +} + +function upstreamCalls(path) { + return global.fetch.mock.calls.filter(([url]) => new URL(url).pathname === path); +} + +describe("routes proxied from the adapter manifest", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("serves TOTP sign-in, which has no handler of its own", async () => { + const token = await signAccessToken(); + routeFetch({ + "POST /totp/verify-login": async () => + json(200, { + message: "Success", + sub: "user-1", + token, + refreshToken: "refresh-1", + ttl: 300, + refreshTtl: 3600, + }), + }); + + const res = await request(createApp({ fetchManifest: false })) + .post("/auth/totp/verify-login") + .set("Cookie", cookie("seamless-ephemeral", { sub: "user-1", token: "pre-auth-token" })) + .send({ code: "123456" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + message: "Success", + sub: "user-1", + ttl: 300, + refreshTtl: 3600, + }); + + const [[, init]] = upstreamCalls("/totp/verify-login"); + expect(init.headers.Authorization).toBe("Bearer pre-auth-token"); + expect(init.body).toBe(JSON.stringify({ code: "123456" })); + + const setCookies = res.headers["set-cookie"].join(";"); + expect(setCookies).toMatch(/seamless-access=/); + expect(setCookies).toMatch(/seamless-refresh=/); + }); + + it("refuses a manifest route without the cookie it needs", async () => { + routeFetch({}); + + const res = await request(createApp({ fetchManifest: false })) + .post("/auth/totp/verify-login") + .send({ code: "123456" }); + + expect(res.status).toBe(401); + expect(upstreamCalls("/totp/verify-login")).toHaveLength(0); + }); + + it("answers 404 for a path the manifest does not list", async () => { + routeFetch({}); + + const res = await request(createApp({ fetchManifest: false })).get( + "/auth/not-a-route", + ); + + expect(res.status).toBe(404); + }); + + it("does not expose a deprecated GET the manifest leaves out", async () => { + routeFetch({}); + + const res = await request(createApp({ fetchManifest: false })) + .get("/auth/otp/generate-login-email-otp") + .set("Cookie", cookie("seamless-ephemeral", { sub: "user-1", token: "t" })); + + expect(res.status).toBe(404); + expect(upstreamCalls("/otp/generate-login-email-otp")).toHaveLength(0); + }); + + it("follows a route the live manifest adds, fetching the manifest once", async () => { + routeFetch({ + "GET /.well-known/seamless-adapter.json": async () => + json(200, { + schemaVersion: 1, + apiVersion: "9.9.9", + session: {}, + routes: [{ method: "GET", path: "/brand-new/{id}", credential: "access" }], + }), + "GET /brand-new/a%20b": async () => json(200, { fresh: true }), + }); + + const app = createApp(); + const accessCookie = cookie("seamless-access", { sub: "user-1", token: "access-token" }); + + for (let i = 0; i < 2; i++) { + const res = await request(app) + .get("/auth/brand-new/a%20b") + .set("Cookie", accessCookie); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ fresh: true }); + } + + expect(upstreamCalls("/.well-known/seamless-adapter.json")).toHaveLength(1); + expect(upstreamCalls("/brand-new/a%20b")[0][1].headers.Authorization).toBe( + "Bearer access-token", + ); + }); + + it("keeps a route's own handler ahead of the manifest", async () => { + routeFetch({ + "GET /users/me": async () => + json(200, { user: { id: "user-1" }, credentials: [], extra: "dropped" }), + }); + + const res = await request(createApp({ fetchManifest: false })) + .get("/auth/users/me") + .set("Cookie", cookie("seamless-access", { sub: "user-1", token: "access-token" })); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ user: { id: "user-1" }, credentials: [] }); + }); + + it("returns the session body whole in bearer transport", async () => { + const token = await signAccessToken(); + const session = { message: "Success", sub: "user-1", token, ttl: 300 }; + routeFetch({ "POST /totp/verify-login": async () => json(200, session) }); + + const res = await request(createApp({ fetchManifest: false })) + .post("/auth/totp/verify-login") + .set("x-seamless-auth-transport", "bearer") + .set("Authorization", "Bearer client-pre-auth") + .send({ code: "123456" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual(session); + expect(res.headers["set-cookie"]).toBeUndefined(); + expect(upstreamCalls("/totp/verify-login")[0][1].headers.Authorization).toBe( + "Bearer client-pre-auth", + ); + }); +}); diff --git a/packages/express/tests/messagingDelivery.test.js b/packages/express/tests/messagingDelivery.test.js index b452803..4f56ba0 100644 --- a/packages/express/tests/messagingDelivery.test.js +++ b/packages/express/tests/messagingDelivery.test.js @@ -32,6 +32,7 @@ function createApp(emailTransport) { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret-cookie-secret-cookie-secret", serviceSecret: "service-secret-service-secret-service-secret", @@ -191,6 +192,7 @@ describe("messaging delivery routes", () => { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret-cookie-secret-cookie-secret", serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/minorCorrectness.test.js b/packages/express/tests/minorCorrectness.test.js index d57e741..dc66522 100644 --- a/packages/express/tests/minorCorrectness.test.js +++ b/packages/express/tests/minorCorrectness.test.js @@ -55,6 +55,7 @@ function createApp() { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret-cookie-secret-cookie-secret", serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/oauthProvidersProxy.test.js b/packages/express/tests/oauthProvidersProxy.test.js index 7b50946..39abb83 100644 --- a/packages/express/tests/oauthProvidersProxy.test.js +++ b/packages/express/tests/oauthProvidersProxy.test.js @@ -32,6 +32,7 @@ function createApp() { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret-cookie-secret-cookie-secret", serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/oauthRoutes.test.js b/packages/express/tests/oauthRoutes.test.js index bf8f445..efdc925 100644 --- a/packages/express/tests/oauthRoutes.test.js +++ b/packages/express/tests/oauthRoutes.test.js @@ -18,6 +18,7 @@ function createApp() { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret-cookie-secret-cookie-secret", serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/organizationRoutes.test.js b/packages/express/tests/organizationRoutes.test.js index 8dd1329..f3b9bfe 100644 --- a/packages/express/tests/organizationRoutes.test.js +++ b/packages/express/tests/organizationRoutes.test.js @@ -37,6 +37,7 @@ function createApp() { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret-cookie-secret-cookie-secret", serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/originGuard.test.js b/packages/express/tests/originGuard.test.js index a04c4c3..aa6631f 100644 --- a/packages/express/tests/originGuard.test.js +++ b/packages/express/tests/originGuard.test.js @@ -20,6 +20,7 @@ function createApp(overrides = {}) { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret-cookie-secret-cookie-secret", serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/proxyQueryForwarding.test.js b/packages/express/tests/proxyQueryForwarding.test.js index 7896624..6c49572 100644 --- a/packages/express/tests/proxyQueryForwarding.test.js +++ b/packages/express/tests/proxyQueryForwarding.test.js @@ -36,6 +36,7 @@ function createApp() { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: COOKIE_SECRET, serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/proxyServiceToken.test.js b/packages/express/tests/proxyServiceToken.test.js index c23e8f1..f752faf 100644 --- a/packages/express/tests/proxyServiceToken.test.js +++ b/packages/express/tests/proxyServiceToken.test.js @@ -42,6 +42,7 @@ function createApp(configure) { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: COOKIE_SECRET, serviceSecret: SERVICE_SECRET, @@ -186,6 +187,7 @@ describe("proxied service token", () => { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: COOKIE_SECRET, serviceSecret: SERVICE_SECRET, diff --git a/packages/express/tests/queryForwarding.test.js b/packages/express/tests/queryForwarding.test.js index 03ef6c8..15c0228 100644 --- a/packages/express/tests/queryForwarding.test.js +++ b/packages/express/tests/queryForwarding.test.js @@ -28,6 +28,7 @@ function createApp() { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: COOKIE_SECRET, serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/refreshRoute.test.js b/packages/express/tests/refreshRoute.test.js index 2a35c0a..925917d 100644 --- a/packages/express/tests/refreshRoute.test.js +++ b/packages/express/tests/refreshRoute.test.js @@ -63,6 +63,7 @@ function app() { a.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: AUTH, cookieSecret: COOKIE_SECRET, serviceSecret: SERVICE_SECRET, diff --git a/packages/express/tests/refreshServiceToken.test.js b/packages/express/tests/refreshServiceToken.test.js index 197b88d..06cd7de 100644 --- a/packages/express/tests/refreshServiceToken.test.js +++ b/packages/express/tests/refreshServiceToken.test.js @@ -29,6 +29,7 @@ function createApp() { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret-cookie-secret-cookie-secret", serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/routeErrorHandling.test.js b/packages/express/tests/routeErrorHandling.test.js index f7b1208..d62cde7 100644 --- a/packages/express/tests/routeErrorHandling.test.js +++ b/packages/express/tests/routeErrorHandling.test.js @@ -24,6 +24,7 @@ function createApp() { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret-cookie-secret-cookie-secret", serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/stepUpProxy.test.js b/packages/express/tests/stepUpProxy.test.js index 2c6a5ac..bb430fd 100644 --- a/packages/express/tests/stepUpProxy.test.js +++ b/packages/express/tests/stepUpProxy.test.js @@ -45,6 +45,7 @@ function createApp() { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret-cookie-secret-cookie-secret", serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/totpProxy.test.js b/packages/express/tests/totpProxy.test.js index 541f075..f227bb4 100644 --- a/packages/express/tests/totpProxy.test.js +++ b/packages/express/tests/totpProxy.test.js @@ -32,6 +32,7 @@ function createApp() { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret-cookie-secret-cookie-secret", serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/express/tests/usersRoutes.test.js b/packages/express/tests/usersRoutes.test.js index 60910c4..8c84c50 100644 --- a/packages/express/tests/usersRoutes.test.js +++ b/packages/express/tests/usersRoutes.test.js @@ -37,6 +37,7 @@ function createApp() { app.use( "/auth", createSeamlessAuthServer({ + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: "cookie-secret-cookie-secret-cookie-secret", serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/fastify/src/hooks/ensureCookies.ts b/packages/fastify/src/hooks/ensureCookies.ts index ab6e56d..1584cc1 100644 --- a/packages/fastify/src/hooks/ensureCookies.ts +++ b/packages/fastify/src/hooks/ensureCookies.ts @@ -1,5 +1,6 @@ import type { FastifyReply, FastifyRequest } from "fastify"; import { + type AdapterManifestSource, applyCookies, assertSecrets, ensureCookies, @@ -27,7 +28,7 @@ import type { ResolvedOptions } from "../options"; * 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 { +export function mountRelativePath(url: string, prefix: string): string { const path = url.split("?")[0]; if (!prefix || prefix === "/") { @@ -37,7 +38,11 @@ function mountRelativePath(url: string, prefix: string): string { return path.startsWith(prefix) ? path.slice(prefix.length) || "/" : path; } -export function createEnsureCookiesHook(opts: ResolvedOptions, prefix: string) { +export function createEnsureCookiesHook( + opts: ResolvedOptions, + prefix: string, + manifestSource?: AdapterManifestSource, +) { assertSecrets(opts); return async function ensureCookiesHook( @@ -54,6 +59,8 @@ export function createEnsureCookiesHook(opts: ResolvedOptions, prefix: string) { { path: mountRelativePath(req.url, prefix), cookies: req.cookies ?? {}, + method: req.method, + manifest: await manifestSource?.get(), }, { authServerUrl: opts.authServerUrl, diff --git a/packages/fastify/src/options.ts b/packages/fastify/src/options.ts index a53af9e..9c110d9 100644 --- a/packages/fastify/src/options.ts +++ b/packages/fastify/src/options.ts @@ -37,6 +37,13 @@ export type SeamlessAuthServerOptions = { preAuthCookieName?: string; messaging?: SeamlessAuthMessagingOptions; resolveClientIp?: ClientIpResolver; + /** + * Fetch the adapter manifest from the auth API (the default). Routes with no + * handler of their own here are proxied as it describes, so a new API route + * works without upgrading this package. `false` uses the manifest bundled with + * this package version only. + */ + fetchManifest?: boolean; }; export type ResolvedOptions = SeamlessAuthServerOptions & { diff --git a/packages/fastify/src/plugin.ts b/packages/fastify/src/plugin.ts index 7c785b5..80e38b6 100644 --- a/packages/fastify/src/plugin.ts +++ b/packages/fastify/src/plugin.ts @@ -6,15 +6,19 @@ import type { FastifyRequest, } from "fastify"; import { + type AdapterManifestSource, applyExternalDelivery, assertSecrets, checkProxyIdentity, + createAdapterManifestSource, DEV_JWKS_KID, + handleManifestRoute, + matchManifestRoute, proxyRequest, redactSensitiveText, } from "@seamless-auth/core"; -import { createEnsureCookiesHook } from "./hooks/ensureCookies"; +import { createEnsureCookiesHook, mountRelativePath } from "./hooks/ensureCookies"; import { createOriginGuardHook } from "./hooks/originGuard"; import { buildInternalServiceAuthorization, @@ -82,6 +86,10 @@ export const seamlessAuth: FastifyPluginAsync< warnOnDevJwksKid(opts.jwksKid); const resolved = resolveOptions(opts); + const manifestSource = createAdapterManifestSource({ + authServerUrl: resolved.authServerUrl, + fetchManifest: resolved.fetchManifest, + }); await fastify.register(cookie); @@ -90,12 +98,13 @@ export const seamlessAuth: FastifyPluginAsync< fastify.addHook("onRequest", createOriginGuardHook(resolved)); fastify.addHook( "onRequest", - createEnsureCookiesHook(resolved, fastify.prefix), + createEnsureCookiesHook(resolved, fastify.prefix, manifestSource), ); registerAuthRoutes(fastify, resolved); registerAdminRoutes(fastify, resolved); registerProxyRoutes(fastify, resolved); + registerManifestRoutes(fastify, resolved, manifestSource); fastify.setErrorHandler((error, request, reply) => { const status = clientErrorStatus(error); @@ -181,5 +190,57 @@ function registerProxyRoutes( } } +/** + * Serves every manifest route the routes above do not. Fastify prefers a static + * route over a wildcard, so a route with a handler of its own always wins. Only + * the methods the manifest uses are claimed, leaving `OPTIONS` to whatever CORS + * handling the application registers. + */ +function registerManifestRoutes( + fastify: FastifyInstance, + opts: ResolvedOptions, + manifestSource: AdapterManifestSource, +): void { + fastify.route({ + method: ["GET", "POST", "PUT", "PATCH", "DELETE"], + url: "/*", + handler: async (req: FastifyRequest, reply: FastifyReply) => { + const match = matchManifestRoute( + await manifestSource.get(), + req.method, + mountRelativePath(req.url, fastify.prefix), + ); + + if (!match) { + return reply.status(404).send({ error: "not_found" }); + } + + const result = await handleManifestRoute( + { + ...match, + transport: transportOf(req), + query: req.query as Record, + body: req.body, + authorization: req.headers.authorization, + cookiePayload: req.cookiePayload, + cookies: req.cookies ?? {}, + forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), + forwardedUserAgent: buildForwardedUserAgent(req), + }, + { + ...opts, + serviceAuthorization: buildProxyServiceAuthorization(opts), + deliveryAuthorization: opts.messaging + ? buildInternalServiceAuthorization(opts) + : undefined, + }, + ); + + respond(reply, result, opts); + return reply; + }, + }); +} + export { applyExternalDelivery, buildInternalServiceAuthorization }; export default seamlessAuth; diff --git a/packages/fastify/tests/bearerTransport.parity.test.js b/packages/fastify/tests/bearerTransport.parity.test.js index 120545e..132c544 100644 --- a/packages/fastify/tests/bearerTransport.parity.test.js +++ b/packages/fastify/tests/bearerTransport.parity.test.js @@ -20,6 +20,7 @@ const SERVICE_SECRET = "service-secret-service-secret-service-secret"; const BEARER = { "x-seamless-auth-transport": "bearer" }; const OPTIONS = { + fetchManifest: false, authServerUrl: AUTH, cookieSecret: COOKIE_SECRET, serviceSecret: SERVICE_SECRET, diff --git a/packages/fastify/tests/issuer.parity.test.js b/packages/fastify/tests/issuer.parity.test.js index 89268e8..3b0e925 100644 --- a/packages/fastify/tests/issuer.parity.test.js +++ b/packages/fastify/tests/issuer.parity.test.js @@ -23,6 +23,7 @@ const COOKIE_SECRET = "cookie-secret-cookie-secret-cookie-secret"; const SERVICE_SECRET = "service-secret-service-secret-service-secret"; const OPTIONS = { + fetchManifest: false, authServerUrl: AUTH, cookieSecret: COOKIE_SECRET, serviceSecret: SERVICE_SECRET, diff --git a/packages/fastify/tests/parity.test.js b/packages/fastify/tests/parity.test.js index a3b9b1e..cc9d6c2 100644 --- a/packages/fastify/tests/parity.test.js +++ b/packages/fastify/tests/parity.test.js @@ -17,6 +17,7 @@ const COOKIE_SECRET = "cookie-secret-cookie-secret-cookie-secret"; const SERVICE_SECRET = "service-secret-service-secret-service-secret"; const OPTIONS = { + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: COOKIE_SECRET, serviceSecret: SERVICE_SECRET, @@ -1078,3 +1079,124 @@ describe("fastify and express forward downloads unparsed", () => { ); }); }); + +describe("fastify and express agree on routes served from the manifest", () => { + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + }); + + function routedUpstream(routes) { + return jest.fn(async (url, init) => { + const { pathname } = new URL(String(url)); + if (pathname === "/.well-known/jwks.json") return upstream(200, { keys: [jwk] }); + const respond = routes[`${init?.method ?? "GET"} ${pathname}`]; + return respond ? respond() : upstream(404, { error: "not_found" }); + }); + } + + async function both(scenario, routes) { + global.fetch = routedUpstream(routes); + const fastify = await viaFastify(scenario); + const fastifyCalls = global.fetch.mock.calls; + + global.fetch = routedUpstream(routes); + const expressResult = await viaExpress(scenario); + + return { fastify, express: expressResult, fastifyCalls, expressCalls: global.fetch.mock.calls }; + } + + it("signs in with TOTP and sets the same session cookies", async () => { + const token = await accessToken({ sub: "user-123", typ: "access", sid: "s-9" }); + const { fastify, express: expressResult } = await both( + { + method: "post", + path: "/totp/verify-login", + cookie: preAuthCookie(), + payload: { code: "123456" }, + }, + { + "POST /totp/verify-login": () => + upstream(200, { + message: "Success", + sub: "user-123", + token, + refreshToken: "refresh-9", + ttl: 300, + refreshTtl: 3600, + }), + }, + ); + + expect(fastify.status).toBe(200); + expect(fastify.body).toEqual({ + message: "Success", + sub: "user-123", + ttl: 300, + refreshTtl: 3600, + }); + expect(fastify).toEqual(expressResult); + expect(fastify.cookies).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^seamless-access=/), + expect.stringMatching(/^seamless-refresh=/), + ]), + ); + }); + + it("proxies an access route neither adapter declares", async () => { + const result = await both( + { + method: "post", + path: "/registration/phone", + cookie: accessCookie(), + payload: { phone: "+14155552671" }, + }, + { "POST /registration/phone": () => upstream(200, { message: "sent" }) }, + ); + + expect(result.fastify).toEqual({ status: 200, body: { message: "sent" }, cookies: [] }); + expect(result.fastify).toEqual(result.express); + + for (const calls of [result.fastifyCalls, result.expressCalls]) { + const [, init] = calls.find(([url]) => String(url).endsWith("/registration/phone")); + expect(init.headers.Authorization).toBe("Bearer access-token"); + } + }); + + it("answers 404 for a path the manifest does not list", async () => { + const { fastify, express: expressResult } = await both( + { method: "get", path: "/no-such-route" }, + {}, + ); + + expect(fastify.status).toBe(404); + expect(expressResult.status).toBe(404); + }); + + it("follows a route only the live manifest knows", async () => { + const routes = { + "GET /.well-known/seamless-adapter.json": () => + upstream(200, { + schemaVersion: 1, + apiVersion: "9.9.9", + session: {}, + routes: [{ method: "GET", path: "/brand-new", credential: "access" }], + }), + "GET /brand-new": () => upstream(200, { fresh: true }), + }; + + const { fastify, express: expressResult } = await both( + { + method: "get", + path: "/brand-new", + cookie: accessCookie(), + options: { fetchManifest: true }, + }, + routes, + ); + + expect(fastify).toEqual({ status: 200, body: { fresh: true }, cookies: [] }); + expect(fastify).toEqual(expressResult); + }); +}); diff --git a/packages/fastify/tests/queryForwarding.test.js b/packages/fastify/tests/queryForwarding.test.js index c768fcc..b6472fe 100644 --- a/packages/fastify/tests/queryForwarding.test.js +++ b/packages/fastify/tests/queryForwarding.test.js @@ -26,6 +26,7 @@ async function buildApp() { await app.register(seamlessAuth, { prefix: "/auth", + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: COOKIE_SECRET, serviceSecret: "service-secret-service-secret-service-secret", diff --git a/packages/nextjs/README.md b/packages/nextjs/README.md index fa09fdd..4e1c32b 100644 --- a/packages/nextjs/README.md +++ b/packages/nextjs/README.md @@ -27,7 +27,7 @@ Mount a catch-all route at `app/auth/[...seamless]/route.ts`: ```ts import { createSeamlessAuthHandler } from "@seamless-auth/nextjs"; -export const { GET, POST, PATCH, DELETE } = createSeamlessAuthHandler({ +export const { GET, POST, PUT, PATCH, DELETE } = createSeamlessAuthHandler({ authServerUrl: process.env.AUTH_SERVER_URL!, audience: process.env.AUTH_SERVER_URL!, cookieSecret: process.env.COOKIE_SECRET!, diff --git a/packages/nextjs/src/handler.ts b/packages/nextjs/src/handler.ts index 37b14ae..6b6e80a 100644 --- a/packages/nextjs/src/handler.ts +++ b/packages/nextjs/src/handler.ts @@ -1,10 +1,16 @@ import { + type AdapterManifest, + type AdapterManifestSource, + type AppliableResult, applyCookies, assertSecrets, checkOrigin, checkProxyIdentity, + createAdapterManifestSource, DEV_JWKS_KID, ensureCookies, + handleManifestRoute, + matchManifestRoute, proxyRequest, redactSensitiveText, SERVICE_TOKEN_AUDIENCE, @@ -12,6 +18,7 @@ import { } from "@seamless-auth/core"; import { + buildInternalServiceAuthorization, buildProxyServiceAuthorization, buildServiceAuthorization, } from "./internal/buildAuthorization"; @@ -38,6 +45,7 @@ export type RouteHandler = (request: Request) => Promise; export interface SeamlessAuthRouteHandlers { GET: RouteHandler; POST: RouteHandler; + PUT: RouteHandler; PATCH: RouteHandler; DELETE: RouteHandler; } @@ -125,7 +133,7 @@ function warnOnDevJwksKid(jwksKid: string | undefined): void { * // app/auth/[...seamless]/route.ts * import { createSeamlessAuthHandler } from "@seamless-auth/nextjs"; * - * export const { GET, POST, PATCH, DELETE } = createSeamlessAuthHandler({ + * export const { GET, POST, PUT, PATCH, DELETE } = createSeamlessAuthHandler({ * authServerUrl: process.env.AUTH_SERVER_URL!, * cookieSecret: process.env.COOKIE_SECRET!, * serviceSecret: process.env.SERVICE_SECRET!, @@ -141,12 +149,16 @@ export function createSeamlessAuthHandler( warnOnDevJwksKid(options.jwksKid); const opts = resolveOptions(options); + const manifestSource = createAdapterManifestSource({ + authServerUrl: opts.authServerUrl, + fetchManifest: opts.fetchManifest, + }); async function handle(request: Request): Promise { const collector = new ResponseCollector(); try { - return await dispatch(request, opts, collector); + return await dispatch(request, opts, collector, manifestSource); } catch (error) { if (error instanceof BodyError) { return collector.json(error.status, { error: error.message }); @@ -167,13 +179,20 @@ export function createSeamlessAuthHandler( } } - return { GET: handle, POST: handle, PATCH: handle, DELETE: handle }; + return { + GET: handle, + POST: handle, + PUT: handle, + PATCH: handle, + DELETE: handle, + }; } async function dispatch( request: Request, opts: ResolvedOptions, collector: ResponseCollector, + manifestSource: AdapterManifestSource, ): Promise { // Ordering matches the other adapters: a blocked cross-site request must // never trigger a token refresh or reach a handler. @@ -194,9 +213,10 @@ async function dispatch( const url = new URL(request.url); const path = mountRelativePath(url.pathname, opts.basePath); - const match = matchRoute(ROUTES, request.method, path); + const manifest = await manifestSource.get(); + const target = resolveTarget(manifest, request, path, opts); - if (!match) { + if (!target) { return collector.json(404, { error: "not_found" }); } @@ -204,7 +224,7 @@ async function dispatch( request, method: request.method, path, - params: match.params, + params: target.params, query: readQuery(url), body: await readBody(request), cookies: readCookies(request), @@ -215,7 +235,7 @@ async function dispatch( // there is no cookie here to load or rotate. if (ctx.transport !== "bearer") { const result = await ensureCookies( - { path, cookies: ctx.cookies }, + { path, cookies: ctx.cookies, method: request.method, manifest }, { authServerUrl: opts.authServerUrl, cookieDomain: opts.cookieDomain, @@ -249,5 +269,55 @@ async function dispatch( } } - return respond(collector, ctx, await match.route.run(ctx, opts), opts); + return respond(collector, ctx, await target.run(ctx), opts); +} + +interface DispatchTarget { + params: Record; + run: (ctx: AuthContext) => Promise; +} + +/** A route with a handler of its own always wins over the manifest. */ +function resolveTarget( + manifest: AdapterManifest, + request: Request, + path: string, + opts: ResolvedOptions, +): DispatchTarget | undefined { + const match = matchRoute(ROUTES, request.method, path); + + if (match) { + return { params: match.params, run: (ctx) => match.route.run(ctx, opts) }; + } + + const manifestMatch = matchManifestRoute(manifest, request.method, path); + + if (!manifestMatch) { + return undefined; + } + + return { + params: manifestMatch.params, + run: (ctx) => + handleManifestRoute( + { + ...manifestMatch, + transport: ctx.transport, + query: ctx.query, + body: ctx.body, + authorization: request.headers.get("authorization") ?? undefined, + cookiePayload: ctx.cookiePayload, + cookies: ctx.cookies, + forwardedClientIp: forwardedClientIp(request, opts.resolveClientIp), + forwardedUserAgent: forwardedUserAgent(request), + }, + { + ...opts, + serviceAuthorization: buildProxyServiceAuthorization(opts), + deliveryAuthorization: opts.messaging + ? buildInternalServiceAuthorization(opts) + : undefined, + }, + ), + }; } diff --git a/packages/nextjs/src/options.ts b/packages/nextjs/src/options.ts index 5cf957b..7825dd7 100644 --- a/packages/nextjs/src/options.ts +++ b/packages/nextjs/src/options.ts @@ -46,6 +46,13 @@ export type SeamlessAuthHandlerOptions = { * `x-real-ip` on Vercel). */ resolveClientIp?: ClientIpResolver; + /** + * Fetch the adapter manifest from the auth API (the default). Routes with no + * handler of their own here are proxied as it describes, so a new API route + * works without upgrading this package. `false` uses the manifest bundled with + * this package version only. + */ + fetchManifest?: boolean; /** * Where the catch-all route is mounted. The auth route table is relative to * it. Defaults to `/auth`, which is what the client SDKs call. diff --git a/packages/nextjs/tests/bearerTransport.parity.test.js b/packages/nextjs/tests/bearerTransport.parity.test.js index 74789a1..24d622f 100644 --- a/packages/nextjs/tests/bearerTransport.parity.test.js +++ b/packages/nextjs/tests/bearerTransport.parity.test.js @@ -19,6 +19,7 @@ const SERVICE_SECRET = "service-secret-service-secret-service-secret"; const BEARER = { "x-seamless-auth-transport": "bearer" }; const OPTIONS = { + fetchManifest: false, authServerUrl: AUTH, cookieSecret: COOKIE_SECRET, serviceSecret: SERVICE_SECRET, diff --git a/packages/nextjs/tests/handler.test.js b/packages/nextjs/tests/handler.test.js index a37453e..8333dfb 100644 --- a/packages/nextjs/tests/handler.test.js +++ b/packages/nextjs/tests/handler.test.js @@ -11,6 +11,7 @@ const COOKIE_SECRET = "cookie-secret-cookie-secret-cookie-secret"; const SERVICE_SECRET = "service-secret-service-secret-service-secret"; const OPTIONS = { + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: COOKIE_SECRET, serviceSecret: SERVICE_SECRET, diff --git a/packages/nextjs/tests/issuer.parity.test.js b/packages/nextjs/tests/issuer.parity.test.js index aa22353..87c44ea 100644 --- a/packages/nextjs/tests/issuer.parity.test.js +++ b/packages/nextjs/tests/issuer.parity.test.js @@ -23,6 +23,7 @@ const COOKIE_SECRET = "cookie-secret-cookie-secret-cookie-secret"; const SERVICE_SECRET = "service-secret-service-secret-service-secret"; const OPTIONS = { + fetchManifest: false, authServerUrl: AUTH, cookieSecret: COOKIE_SECRET, serviceSecret: SERVICE_SECRET, diff --git a/packages/nextjs/tests/parity.test.js b/packages/nextjs/tests/parity.test.js index d0bb8e6..84ca1e5 100644 --- a/packages/nextjs/tests/parity.test.js +++ b/packages/nextjs/tests/parity.test.js @@ -19,6 +19,7 @@ const COOKIE_SECRET = "cookie-secret-cookie-secret-cookie-secret"; const SERVICE_SECRET = "service-secret-service-secret-service-secret"; const OPTIONS = { + fetchManifest: false, authServerUrl: "https://auth.example.com", cookieSecret: COOKIE_SECRET, serviceSecret: SERVICE_SECRET, @@ -1043,3 +1044,120 @@ describe("next.js forwards the range on ranged internal metrics", () => { } }); }); + +describe("next and express agree on routes served from the manifest", () => { + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + }); + + function routedUpstream(routes) { + return jest.fn(async (url, init) => { + const { pathname } = new URL(String(url)); + if (pathname === "/.well-known/jwks.json") return upstream(200, { keys: [jwk] }); + const respond = routes[`${init?.method ?? "GET"} ${pathname}`]; + return respond ? respond() : upstream(404, { error: "not_found" }); + }); + } + + async function both(scenario, routes) { + global.fetch = routedUpstream(routes); + const next = await viaNext(scenario); + + global.fetch = routedUpstream(routes); + const expressResult = await viaExpress(scenario); + + return { next, express: expressResult }; + } + + it("signs in with TOTP and sets the same session cookies", async () => { + const token = await accessToken({ sub: "user-123", typ: "access", sid: "s-9" }); + const { next, express: expressResult } = await both( + { + method: "post", + path: "/totp/verify-login", + cookie: preAuthCookie(), + payload: { code: "123456" }, + }, + { + "POST /totp/verify-login": () => + upstream(200, { + message: "Success", + sub: "user-123", + token, + refreshToken: "refresh-9", + ttl: 300, + refreshTtl: 3600, + }), + }, + ); + + expect(next.status).toBe(200); + expect(next.body).toEqual({ + message: "Success", + sub: "user-123", + ttl: 300, + refreshTtl: 3600, + }); + expect(next).toEqual(expressResult); + expect(next.cookies).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^seamless-access=/), + expect.stringMatching(/^seamless-refresh=/), + ]), + ); + }); + + // The route handler exported no PUT before the manifest, so this route was + // unreachable through Next.js. + it("serves a PUT route", async () => { + const path = + "/admin/organizations/org-1/oauth-providers/google/retirement"; + const { next, express: expressResult } = await both( + { + method: "put", + path, + cookie: accessCookie(), + payload: { retireAt: "2026-12-01T00:00:00Z" }, + }, + { [`PUT ${path}`]: () => upstream(200, { retired: true }) }, + ); + + expect(next).toEqual({ status: 200, body: { retired: true }, cookies: [] }); + expect(next).toEqual(expressResult); + }); + + it("answers 404 for a path the manifest does not list", async () => { + const { next, express: expressResult } = await both( + { method: "get", path: "/no-such-route" }, + {}, + ); + + expect(next.status).toBe(404); + expect(expressResult.status).toBe(404); + }); + + it("follows a route only the live manifest knows", async () => { + const { next, express: expressResult } = await both( + { + method: "get", + path: "/brand-new", + cookie: accessCookie(), + options: { fetchManifest: true }, + }, + { + "GET /.well-known/seamless-adapter.json": () => + upstream(200, { + schemaVersion: 1, + apiVersion: "9.9.9", + session: {}, + routes: [{ method: "GET", path: "/brand-new", credential: "access" }], + }), + "GET /brand-new": () => upstream(200, { fresh: true }), + }, + ); + + expect(next).toEqual({ status: 200, body: { fresh: true }, cookies: [] }); + expect(next).toEqual(expressResult); + }); +}); diff --git a/scripts/sync-adapter-manifest.mjs b/scripts/sync-adapter-manifest.mjs new file mode 100644 index 0000000..3c0a394 --- /dev/null +++ b/scripts/sync-adapter-manifest.mjs @@ -0,0 +1,48 @@ +// Writes the adapter manifest the auth API publishes into core as its bundled +// fallback. Adapters fetch the live manifest at startup and only use this copy +// when that fails, so it only has to be as new as the oldest API it supports. +// +// node scripts/sync-adapter-manifest.mjs [path-or-url] +// +// Defaults to the manifest on the auth API's main branch. +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +const DEFAULT_SOURCE = + "https://raw.githubusercontent.com/fells-code/seamless-auth-api/main/adapter-manifest.json"; +const TARGET = join( + import.meta.dirname, + "../packages/core/src/manifest/bundledManifest.ts", +); + +async function read(source) { + if (!/^https?:\/\//.test(source)) { + return readFile(source, "utf8"); + } + + const response = await fetch(source); + if (!response.ok) { + throw new Error(`Could not read ${source}: HTTP ${response.status}`); + } + return response.text(); +} + +const source = process.argv[2] ?? DEFAULT_SOURCE; +const manifest = JSON.parse(await read(source)); + +if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.routes)) { + throw new Error(`${source} is not a schemaVersion 1 adapter manifest.`); +} + +await writeFile( + TARGET, + `// Generated by scripts/sync-adapter-manifest.mjs. Do not edit by hand. +import type { AdapterManifest } from "./adapterManifest.js"; + +export const BUNDLED_ADAPTER_MANIFEST: AdapterManifest = ${JSON.stringify(manifest, null, 2)}; +`, +); + +console.log( + `Wrote ${manifest.routes.length} routes (API ${manifest.apiVersion}) to ${TARGET}.`, +);