@broberg/sso 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -259,3 +259,26 @@ const answer = await client.addressOwnership(accessToken, "a@example.dk");
259
259
  ```
260
260
 
261
261
  Calls BID's `POST /api/app/address-ownership` with the user's access token. **It throws `SsoError` on anything else** — a non-2xx, a body that is not JSON, a missing or unknown status. Do not catch that into `"unverified"`: an error means *we do not know*, and treating it as "not verified" quietly downgrades a real user.
262
+
263
+ ## Inviting users and tracking the move to BID (since 0.5.0)
264
+
265
+ Two calls that authenticate with **your app's own key** (`bidk_…`, issued by Broberg ID) — not the client secret, not a user's token.
266
+
267
+ ```ts
268
+ const results = await client.inviteUsers(process.env.BID_APP_KEY!, {
269
+ customerName: "Sanne Andersen",
270
+ appUrl: "https://sanneandersen.dk",
271
+ appName: "CMS", // optional
272
+ switchDate: "2026-10-15", // optional
273
+ users: [{ email: "sanne@example.dk", name: "Sanne" }], // max 500 per call
274
+ });
275
+ // [{ email, outcome: "invited" | "already_invited" | "existing" | "invalid"
276
+ // | "too_soon" | "too_many_today" | "send_failed", mailId?, problem? }]
277
+
278
+ const status = await client.migrationStatus(process.env.BID_APP_KEY!, ["sanne@example.dk"]);
279
+ // { users: [{ email, state: "ready" | "invited" | "expired" | "not_invited", expiresAt? }], counts, complete }
280
+ ```
281
+
282
+ **Both throw `SsoError`** on a non-2xx (a refused key says `invalid_app_key`), a body that is not JSON, a missing array, or an outcome/state this client does not know. **Never treat an error as `"ready"`**: switching off a user's old login because BID's answer was unclear is how someone gets locked out.
283
+
284
+ **Timeout.** `addressOwnership`, `inviteUsers` and `migrationStatus` give up after `timeoutMs` (default 10 000) with `SsoError`, so a hanging BID cannot hang your app: `createSsoClient(config, { timeoutMs: 5_000 })`.
package/dist/hono.cjs CHANGED
@@ -187,6 +187,17 @@ async function challengeFor(verifier) {
187
187
  return b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
188
188
  }
189
189
  var ADDRESS_OWNERSHIP = /* @__PURE__ */ new Set(["verified", "unverified", "not_on_account"]);
190
+ var INVITATION_OUTCOMES = /* @__PURE__ */ new Set([
191
+ "invited",
192
+ "already_invited",
193
+ "existing",
194
+ "invalid",
195
+ "too_soon",
196
+ "too_many_today",
197
+ "send_failed"
198
+ ]);
199
+ var MIGRATION_STATES = /* @__PURE__ */ new Set(["ready", "invited", "expired", "not_invited"]);
200
+ var MAX_INVITATIONS = 500;
190
201
  function excerptForLog(raw, clientSecret) {
191
202
  let text = raw.replace(/\s+/g, " ").trim();
192
203
  if (clientSecret) text = text.split(clientSecret).join("[redacted client_secret]");
@@ -221,6 +232,40 @@ var ALLOWED_ALGS = [
221
232
  ];
222
233
  function createSsoClient(config, options = {}) {
223
234
  const fetchImpl = options.fetchImpl ?? fetch;
235
+ const timeoutMs = options.timeoutMs ?? 1e4;
236
+ async function appPost(path, bearer, payload, what) {
237
+ const url = new URL(path, config.issuer).toString();
238
+ const ctrl = new AbortController();
239
+ let timer;
240
+ const timedOut = new Promise((_, reject) => {
241
+ timer = setTimeout(() => {
242
+ ctrl.abort();
243
+ reject(new SsoError(`${url} did not answer within ${timeoutMs} ms \u2014 ${what} is unknown.`));
244
+ }, timeoutMs);
245
+ });
246
+ try {
247
+ const res = await Promise.race([
248
+ fetchImpl(url, {
249
+ method: "POST",
250
+ headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
251
+ body: JSON.stringify(payload),
252
+ signal: ctrl.signal
253
+ }),
254
+ timedOut
255
+ ]);
256
+ if (!res.ok) {
257
+ const detail = await res.text().catch(() => "");
258
+ const hint = detail.includes("invalid_app_key") ? " (invalid_app_key: this app's bidk_ key was refused)" : "";
259
+ throw new SsoError(`${url} answered ${res.status}${hint} \u2014 ${what} is unknown.`);
260
+ }
261
+ return await Promise.race([res.json().catch(() => null), timedOut]);
262
+ } catch (e) {
263
+ if (e instanceof SsoError) throw e;
264
+ throw new SsoError(`${url} could not be reached: ${e instanceof Error ? e.message : String(e)} \u2014 ${what} is unknown.`);
265
+ } finally {
266
+ clearTimeout(timer);
267
+ }
268
+ }
224
269
  let discoveryPromise = null;
225
270
  let jwksCache = null;
226
271
  async function discovery() {
@@ -387,21 +432,44 @@ function createSsoClient(config, options = {}) {
387
432
  return `${doc.end_session_endpoint}?${params}`;
388
433
  },
389
434
  async addressOwnership(accessToken, address) {
390
- const url = new URL("/api/app/address-ownership", config.issuer).toString();
391
- const res = await fetchImpl(url, {
392
- method: "POST",
393
- headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
394
- body: JSON.stringify({ address })
395
- });
396
- if (!res.ok) {
397
- throw new SsoError(`${url} answered ${res.status} \u2014 address ownership is unknown, not "unverified".`);
398
- }
399
- const body = await res.json().catch(() => null);
435
+ const body = await appPost(
436
+ "/api/app/address-ownership",
437
+ accessToken,
438
+ { address },
439
+ 'address ownership (not "unverified")'
440
+ );
400
441
  const status = body?.status;
401
442
  if (typeof status !== "string" || !ADDRESS_OWNERSHIP.has(status)) {
402
- throw new SsoError(`${url} returned status ${JSON.stringify(status)} \u2014 expected verified, unverified or not_on_account.`);
443
+ throw new SsoError(`address-ownership returned status ${JSON.stringify(status)} \u2014 expected verified, unverified or not_on_account.`);
403
444
  }
404
445
  return status;
446
+ },
447
+ async inviteUsers(appKey, input) {
448
+ if (input.users.length > MAX_INVITATIONS) {
449
+ throw new SsoError(`inviteUsers: ${input.users.length} users, BID accepts at most ${MAX_INVITATIONS} per call.`);
450
+ }
451
+ const body = await appPost("/api/app/invitations", appKey, input, "the invitation outcome");
452
+ if (!Array.isArray(body?.results)) {
453
+ throw new SsoError(`invitations returned no "results" array \u2014 the invitation outcome is unknown.`);
454
+ }
455
+ for (const r of body.results) {
456
+ if (typeof r?.outcome !== "string" || !INVITATION_OUTCOMES.has(r.outcome)) {
457
+ throw new SsoError(`invitations returned outcome ${JSON.stringify(r?.outcome)} for ${String(r?.email)} \u2014 not one this client knows.`);
458
+ }
459
+ }
460
+ return body.results;
461
+ },
462
+ async migrationStatus(appKey, emails) {
463
+ const body = await appPost("/api/app/migration-status", appKey, { emails }, "the migration state");
464
+ if (!Array.isArray(body?.users) || typeof body.complete !== "boolean") {
465
+ throw new SsoError(`migration-status returned no "users" array or no "complete" flag \u2014 the migration state is unknown.`);
466
+ }
467
+ for (const u of body.users) {
468
+ if (typeof u?.state !== "string" || !MIGRATION_STATES.has(u.state)) {
469
+ throw new SsoError(`migration-status returned state ${JSON.stringify(u?.state)} for ${String(u?.email)} \u2014 never read as "ready".`);
470
+ }
471
+ }
472
+ return body;
405
473
  }
406
474
  };
407
475
  }
package/dist/hono.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/config.ts","../src/jwks.ts","../src/client.ts","../src/session.ts","../src/hono.ts"],"names":["importJWK","decodeProtectedHeader","jwtVerify","b64url","Hono","require"],"mappings":"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","file":"hono.cjs","sourcesContent":["/**\n * Configuration, read from the environment and nowhere else (F084.4 AC#5).\n *\n * An app mounts this package and sets env vars. It does not pass options in\n * code, because the moment configuration lives in code, two deployments of the\n * same app can disagree about who their identity provider is — and the symptom\n * is a token rejection nobody can trace back to a config line.\n */\n\nexport class SsoConfigError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"SsoConfigError\";\n }\n}\n\nexport interface SsoConfig {\n /** Broberg ID's origin, e.g. https://id.broberg.ai — the BARE origin. */\n issuer: string;\n /** The client id this app was registered under (administratively, in BID). */\n clientId: string;\n /** Exactly the redirect registered in BID. Exact match — one character decides. */\n redirectUri: string;\n /** Requested scopes. */\n scopes: string[];\n /** Signs the local session cookie. 32+ bytes of randomness. */\n cookieSecret: string;\n /** Local session cookie name. */\n cookieName: string;\n /**\n * How long this app trusts its OWN session, in seconds.\n *\n * F084.7 decided the fleet's numbers: 7 days for a normal app, and 12 hours\n * + 30 minutes of inactivity for anything holding personal or health data.\n * The default here is the 7 days. An app handling patient data MUST set\n * SSO_SESSION_MAX_AGE=43200 — the default is a normal-app default, not a\n * safe-for-everything one.\n */\n sessionMaxAge: number;\n /** Where to send the browser after a logout completes. */\n postLogoutRedirectUri?: string;\n /**\n * Set ONLY by an app that can actually keep a secret — one whose callback\n * runs on a server. Leave it unset for anything shipped to a browser; a\n * secret in a static bundle is not a secret, and PKCE alone is the correct,\n * fully standard design there.\n *\n * Setting it makes this a CONFIDENTIAL client: the secret is sent as\n * `client_secret_post` (in the body), which is the method BID registers\n * confidential clients with.\n *\n * IT DOES NOT RELAX PKCE. A secret proves which APP is asking; the PKCE\n * verifier proves the request belongs to the browser that started the login.\n * They defend against different attacks, so one is never an excuse to drop\n * the other — and there is a test that fails if anyone makes it one.\n */\n clientSecret?: string;\n}\n\n/**\n * NOT `!value`. A blank string is falsy in JavaScript, so the obvious guard\n * lets `SSO_COOKIE_SECRET=\" \"` boot an app whose sessions are signed with\n * whitespace. This fleet has measured that exact defect before (components\n * F004.7), which is why it is a trim-and-compare rather than a truthiness test.\n */\nfunction required(env: NodeJS.ProcessEnv, name: string, hint: string): string {\n const raw = env[name];\n if (typeof raw !== \"string\" || raw.trim() === \"\") {\n throw new SsoConfigError(`${name} is not set (or is blank). ${hint}`);\n }\n return raw.trim();\n}\n\n/** Seconds in a week — the fleet default from F084.7. */\nexport const DEFAULT_SESSION_MAX_AGE = 60 * 60 * 24 * 7;\n\nexport function loadSsoConfig(env: NodeJS.ProcessEnv = process.env): SsoConfig {\n const rawIssuer = required(\n env,\n \"BID_ISSUER\",\n \"It is Broberg ID's bare origin, e.g. https://id.broberg.ai\",\n );\n\n let issuer: string;\n try {\n const url = new URL(rawIssuer);\n if (url.protocol !== \"https:\" && url.hostname !== \"localhost\") {\n throw new SsoConfigError(\n `BID_ISSUER must be https (got ${url.protocol}//). Only localhost may be http.`,\n );\n }\n // Normalised to the origin: a trailing slash makes it a DIFFERENT issuer to\n // a strict OIDC client, and the mismatch surfaces as an opaque token\n // rejection rather than as a configuration error anyone can read.\n issuer = url.origin;\n } catch (err) {\n if (err instanceof SsoConfigError) throw err;\n throw new SsoConfigError(`BID_ISSUER is not a valid URL: ${rawIssuer}`);\n }\n\n const cookieSecret = required(\n env,\n \"SSO_COOKIE_SECRET\",\n \"Generate one with `openssl rand -hex 32`. It signs this app's session cookie.\",\n );\n if (cookieSecret.length < 32) {\n throw new SsoConfigError(\n `SSO_COOKIE_SECRET is ${cookieSecret.length} characters; it must be at least 32. ` +\n \"A short secret is a forgeable session, and a forged session is any user you like.\",\n );\n }\n\n const rawMaxAge = env.SSO_SESSION_MAX_AGE?.trim();\n let sessionMaxAge = DEFAULT_SESSION_MAX_AGE;\n if (rawMaxAge) {\n const n = Number(rawMaxAge);\n // A non-number here would silently become NaN and then a cookie that\n // expires immediately — an app that cannot keep anyone logged in, with no\n // error anywhere. Refuse instead.\n if (!Number.isFinite(n) || n <= 0) {\n throw new SsoConfigError(\n `SSO_SESSION_MAX_AGE must be a positive number of seconds (got ${JSON.stringify(rawMaxAge)}).`,\n );\n }\n sessionMaxAge = Math.floor(n);\n }\n\n return {\n issuer,\n clientId: required(env, \"SSO_CLIENT_ID\", \"The id this app is registered under in Broberg ID.\"),\n redirectUri: required(\n env,\n \"SSO_REDIRECT_URI\",\n \"Must match the registered redirect EXACTLY — one trailing slash is a different address.\",\n ),\n scopes: (env.SSO_SCOPES?.trim() || \"openid profile email\").split(/\\s+/),\n cookieSecret,\n cookieName: env.SSO_COOKIE_NAME?.trim() || \"bid_session\",\n sessionMaxAge,\n postLogoutRedirectUri: env.SSO_POST_LOGOUT_REDIRECT_URI?.trim() || undefined,\n clientSecret: env.SSO_CLIENT_SECRET?.trim() || undefined,\n };\n}\n","/**\n * The signing-key cache.\n *\n * ── THE ONE BEHAVIOUR THIS FILE EXISTS FOR ────────────────────────────────\n *\n * It refetches on an UNKNOWN KEY ID, not on a timer (F084.4's constraint, and\n * it is the right one). An interval is a guess about when somebody else will\n * rotate their key; an unknown kid is the event itself. With an interval, the\n * window between \"BID rotated\" and \"the interval elapsed\" is a window where\n * every login in every app fails, and the length of that window is a number\n * nobody chose on purpose.\n *\n * ── AND THE PART AN OBVIOUS IMPLEMENTATION GETS WRONG ─────────────────────\n *\n * \"Unknown kid ⇒ refetch\" turns anyone who can send this app a token into\n * someone who can make it hammer BID: a stream of tokens with random kids is a\n * stream of fetches against the one service the whole fleet logs in through.\n * So a refetch is rate-limited by time. The cost of the floor is real and worth\n * stating: a rotation landing inside the cooldown makes logins fail for up to\n * that many milliseconds. Seconds of failure for one app beats a way to aim\n * traffic at BID from outside.\n *\n * ── TWO FAILURES, NOT ONE (components-F084.50) ───────────────────────────────────────\n *\n * Reported by helpdesk, measured in the published 0.1.0 dist: at process start\n * the key set is EMPTY, so the first verification fetches. If BID is down in\n * that second, NOTHING verifies for that app — not just new logins. A process\n * that has been running and has seen a kid survives the same outage untouched.\n *\n * The recovery was never the defect: `lastFetchAt` is set only on SUCCESS, so a\n * failed fetch does not block the next attempt and the cache self-heals the\n * moment BID answers again. The defect was that `getKey` threw the SAME error\n * for two states whose correct answers are opposites:\n *\n * cannot reach the issuer transient. Retry, or answer 503. The token may\n * well be perfectly good — we simply did not look.\n * kid is not published permanent. This token was not signed by us.\n * Reject it, 401, and do not retry.\n *\n * One error name for both is how a caller ends up rejecting a legitimate user\n * because a foreign service had a bad minute — or, worse, retrying a forgery.\n * Hence two subclasses. They BOTH extend JwksError, so a 0.1.0 consumer whose\n * catch tests `instanceof JwksError` keeps working across the upgrade.\n *\n * ── WHAT WE DELIBERATELY DID NOT DO ───────────────────────────────────────\n *\n * We do NOT persist the key set. A stored key set that survives a REVOCATION is\n * a worse failure than a login that is down for two minutes — helpdesk's own\n * point, and the reason `keys = body.keys` replaces rather than merges. Writing\n * the set to disk would reintroduce exactly what that line exists to prevent.\n */\n// jose 6 dropped the `KeyLike` alias; importJWK now answers\n// `CryptoKey | Uint8Array` directly. Taking the type FROM the function\n// rather than naming it means a future rename cannot silently widen it.\nimport { importJWK, type JWK } from \"jose\";\n\ntype SigningKey = Awaited<ReturnType<typeof importJWK>>;\n\nexport interface JwksCacheOptions {\n /** Absolute URL of the key set, taken from BID's discovery document. */\n jwksUri: string;\n /**\n * The floor between two refetches. Below it, an unknown kid is rejected\n * without asking BID again.\n */\n minRefetchIntervalMs?: number;\n /**\n * Fetch the key set once at construction, so the cold window sits at BOOT\n * rather than in front of the first user who tries to log in.\n *\n * It is fire-and-forget ON PURPOSE: a failed warm-up must never stop the app\n * from starting. A package that refuses to boot because someone else's\n * service is down has made the outage worse, not better — the first getKey\n * will simply fetch, exactly as it does today.\n */\n warmUp?: boolean;\n /** Injectable for tests. */\n fetchImpl?: typeof fetch;\n /** Injectable for tests, so the cooldown can be exercised without waiting. */\n now?: () => number;\n}\n\nexport interface JwksCache {\n /** Resolve a key for this kid, refetching once if it is unknown. */\n getKey(kid: string, alg: string): Promise<SigningKey>;\n /** How many times the remote key set has actually been fetched. */\n readonly fetchCount: number;\n}\n\nexport class JwksError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"JwksError\";\n }\n}\n\n/**\n * The issuer could not be consulted — unreachable, a non-2xx, or a body that is\n * not a key set. Also thrown when an unknown kid arrives inside the refetch\n * cooldown, because there too the honest statement is \"we did not look\".\n *\n * TRANSIENT. The right response is to retry, or to answer 503 — never to\n * conclude anything about the token, which may be perfectly valid.\n */\nexport class JwksUnavailableError extends JwksError {\n constructor(message: string) {\n super(message);\n this.name = \"JwksUnavailableError\";\n }\n}\n\n/**\n * The issuer WAS consulted and does not publish this key id.\n *\n * PERMANENT. The token was not signed by this issuer. Reject it; retrying only\n * asks the same question again and BID will keep giving the same answer.\n */\nexport class JwksUnknownKeyError extends JwksError {\n constructor(message: string) {\n super(message);\n this.name = \"JwksUnknownKeyError\";\n }\n}\n\nexport function createJwksCache(options: JwksCacheOptions): JwksCache {\n const {\n jwksUri,\n minRefetchIntervalMs = 10_000,\n warmUp = false,\n fetchImpl = fetch,\n now = () => Date.now(),\n } = options;\n\n let keys: JWK[] = [];\n let lastFetchAt = -Infinity;\n let fetchCount = 0;\n /** Collapses concurrent refetches into one request. */\n let inFlight: Promise<void> | null = null;\n\n async function refresh(): Promise<void> {\n if (inFlight) return inFlight;\n inFlight = (async () => {\n let res: Response;\n try {\n res = await fetchImpl(jwksUri);\n } catch (cause) {\n // A thrown fetch is DNS, TLS, a refused connection — the issuer was not\n // reached at all. Rethrown as our own type so a caller never has to\n // pattern-match on a runtime's network error to tell this from a\n // rejected token.\n throw new JwksUnavailableError(\n `${jwksUri} could not be reached: ${cause instanceof Error ? cause.message : String(cause)}`,\n );\n }\n if (!res.ok) {\n throw new JwksUnavailableError(\n `${jwksUri} answered ${res.status} — cannot verify any token right now`,\n );\n }\n let body: { keys?: JWK[] } | null;\n try {\n body = (await res.json()) as { keys?: JWK[] } | null;\n } catch {\n // A 200 that is not JSON (a proxy's HTML error page, a captive portal)\n // is still \"we could not look\" — not a raw SyntaxError that slips past\n // every consumer's `instanceof JwksError`.\n throw new JwksUnavailableError(`${jwksUri} answered 200 with a body that is not JSON`);\n }\n if (!Array.isArray(body?.keys)) {\n // Reachable but not serving a key set: still \"we could not look\".\n throw new JwksUnavailableError(`${jwksUri} returned no \"keys\" array`);\n }\n // Replace rather than merge. Merging would keep a REVOKED key usable\n // forever, which is the one thing rotating a key is meant to stop.\n keys = body!.keys!;\n lastFetchAt = now();\n fetchCount++;\n })().finally(() => {\n inFlight = null;\n });\n return inFlight;\n }\n\n if (warmUp) {\n // Swallowed deliberately — see `warmUp` above. The failure is not lost:\n // the next getKey reports it to the caller who can actually act on it.\n void refresh().catch(() => {});\n }\n\n return {\n get fetchCount() {\n return fetchCount;\n },\n\n async getKey(kid: string, alg: string) {\n let jwk = keys.find((k) => k.kid === kid);\n\n if (!jwk) {\n const sinceLast = now() - lastFetchAt;\n if (sinceLast < minRefetchIntervalMs) {\n // The wording matters as much as the class, and this sentence is\n // helpdesk's, not mine. The old one said \"Refusing to refetch — retry\n // shortly\": transient ADVICE on a state that does not improve by\n // waiting, which literally instructs a consumer to do the thing the\n // split exists to prevent. It described what the CACHE did; a caller\n // needs to know what the STATE is.\n //\n // It carries the AGE because that is the number that decides. Their\n // measurement against the live issuer showed a key set 2ms old — we\n // had just looked, and the kid was not there. That is evidence of a\n // forgery, not of ignorance. Only a rotation inside the floor can\n // make this a false negative, and the age is what lets a caller see\n // which case it is standing in.\n throw new JwksUnavailableError(\n `kid ${kid} is not in the key set we hold, fetched ${sinceLast}ms ago ` +\n `(we do not ask the issuer again within ${minRefetchIntervalMs}ms). ` +\n `A key set this fresh that lacks the kid means the token was not signed by ` +\n `this issuer — reject it. Only a key rotation within the last ` +\n `${minRefetchIntervalMs}ms could make that wrong.`,\n );\n }\n await refresh();\n jwk = keys.find((k) => k.kid === kid);\n }\n\n if (!jwk) {\n throw new JwksUnknownKeyError(\n `Broberg ID does not publish a signing key with kid ${kid}. ` +\n `The token was not signed by this issuer.`,\n );\n }\n return importJWK(jwk, alg);\n },\n };\n}\n","/**\n * The core: framework-free, public-client OAuth 2.1 against Broberg ID.\n *\n * ── THIS PACKAGE IS A PUBLIC CLIENT. THERE IS NO CLIENT SECRET. ───────────\n *\n * Deliberate, and F084.4 AC#4 greps the published tarball to keep it that way.\n * PKCE alone carries the exchange. That is safe here for a reason worth stating\n * rather than assuming: BID matches redirect addresses EXACTLY (measured — one\n * trailing slash is refused), so the authorization code is delivered to this\n * app's own server and nowhere else. An attacker who knows the client id can\n * start a flow; they cannot receive its result.\n *\n * What it buys is the thing the card actually asks for: a secret that does not\n * exist cannot be committed, leaked in a log, copied into a second app, or left\n * behind in a repository someone later makes public.\n *\n * ── AND WHAT THIS PACKAGE MUST NEVER LEARN TO DO ──────────────────────────\n *\n * No passwords. No passkey registration. No social-provider keys. No email\n * verification. All of it lives in BID. A client that CAN do any of it is a\n * client somebody eventually uses to do it — and then the identity rules exist\n * in two places and drift.\n */\nimport { decodeProtectedHeader, jwtVerify, type JWTPayload } from \"jose\";\nimport type { SsoConfig } from \"./config.js\";\nimport { createJwksCache, type JwksCache } from \"./jwks.js\";\n\n/* ── discovery ───────────────────────────────────────────────────────────── */\n\nexport interface Discovery {\n /** What the issuer says it signs with. Absent on some providers. */\n id_token_signing_alg_values_supported?: string[];\n issuer: string;\n authorization_endpoint: string;\n token_endpoint: string;\n jwks_uri: string;\n userinfo_endpoint?: string;\n end_session_endpoint?: string;\n}\n\nexport class SsoError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"SsoError\";\n }\n}\n\n/* ── PKCE ────────────────────────────────────────────────────────────────── */\n\nconst b64url = (bytes: ArrayBuffer | Uint8Array) => {\n const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);\n let s = \"\";\n for (const b of view) s += String.fromCharCode(b);\n return btoa(s).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n};\n\nconst randomToken = (bytes = 32) => b64url(crypto.getRandomValues(new Uint8Array(bytes)));\n\nasync function challengeFor(verifier: string): Promise<string> {\n return b64url(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(verifier)));\n}\n\n/* ── the shapes a caller handles ─────────────────────────────────────────── */\n\n/**\n * What `beginLogin` produces. Every field except `url` must survive the round\n * trip to BID and back — the adapter stores them in a short-lived cookie.\n *\n * They are NOT optional extras. `state` is what makes the callback provably the\n * answer to THIS request, and `nonce` is what stops a valid token minted for\n * some other login from being replayed into this one.\n */\nexport interface LoginStart {\n url: string;\n state: string;\n codeVerifier: string;\n nonce: string;\n}\n\nexport interface SsoClaims extends JWTPayload {\n sub: string;\n email?: string;\n name?: string;\n picture?: string;\n email_verified?: boolean;\n}\n\nexport interface LoginResult {\n claims: SsoClaims;\n idToken: string;\n accessToken?: string;\n refreshToken?: string;\n}\n\n/**\n * `prompt=none` asks BID to answer WITHOUT showing anything (components-F084.6).\n *\n * The delivery method decides whether it works, and this package only ever\n * produces a URL for a FULL TOP-LEVEL REDIRECT. It never returns anything an\n * app could put in a hidden iframe, because an iframe against the identity\n * provider is third-party context: Safari has blocked it for years and Chrome\n * is retiring it. That path works in testing on a Mac and fails for every user\n * on an iPhone — a failure that looks like \"you are not logged in\".\n */\nexport interface BeginLoginOptions {\n prompt?: \"none\" | \"login\" | \"consent\" | \"select_account\";\n /** Extra scopes for this one request, on top of the configured set. */\n scopes?: string[];\n}\n\nexport interface SsoClient {\n discovery(): Promise<Discovery>;\n beginLogin(options?: BeginLoginOptions): Promise<LoginStart>;\n completeLogin(input: {\n params: URLSearchParams;\n state: string;\n codeVerifier: string;\n nonce: string;\n }): Promise<LoginResult>;\n verifyIdToken(idToken: string, options?: { nonce?: string }): Promise<SsoClaims>;\n logoutUrl(options?: { idTokenHint?: string; postLogoutRedirectUri?: string }): Promise<string>;\n /**\n * Does this address belong to the signed-in user's BID account, and is it\n * verified there? Asks BID's POST /api/app/address-ownership with the user's\n * access token. Throws SsoError on anything that is not one of the three\n * answers — see the implementation for why that must never become \"unverified\".\n */\n addressOwnership(accessToken: string, address: string): Promise<AddressOwnership>;\n /** Exposed for tests and for a health check; not needed in normal use. */\n readonly jwks: JwksCache;\n}\n\nexport type AddressOwnership = \"verified\" | \"unverified\" | \"not_on_account\";\nconst ADDRESS_OWNERSHIP = new Set<string>([\"verified\", \"unverified\", \"not_on_account\"]);\n\nexport interface CreateSsoClientOptions {\n fetchImpl?: typeof fetch;\n /** Passed through to the key cache; see jwks.ts for why there is a floor. */\n minRefetchIntervalMs?: number;\n}\n\n/**\n * `invalid_client` is returned for two OPPOSITE mistakes, and the issuer cannot\n * tell you which — from outside they are the same three words.\n *\n * we sent a secret, it was refused → wrong secret, or we are registered\n * as PUBLIC and should send none\n * we sent none and it was demanded → we are registered as CONFIDENTIAL\n * and SSO_CLIENT_SECRET is unset\n *\n * The expensive version of this is the plugin storing the secret HASHED\n * (SHA-256 → base64url) and comparing hashes: write the plaintext through the\n * adapter and the row is written, reads back clean, and every token call is\n * refused forever. broberg-id paid for that one. We cannot see their storage\n * from here — but we CAN say what WE sent, which halves the search instead of\n * leaving a caller to guess which end to open.\n *\n * It states our SIDE, never the value. A secret must not reach a log line.\n */\n/**\n * A fragment of a foreign response body, safe to put in a log line (components-F084.52).\n *\n * Three things it has to survive, and each one has bitten somebody:\n *\n * · LENGTH — an HTML error page is kilobytes. A log line is not.\n * · NEWLINES — a multi-line excerpt breaks every log aggregator that treats a\n * line as a record, and the useful half is the part that scrolled away.\n * · OUR OWN SECRET — the body comes from a server we do not control, and some\n * servers echo the request back on an error. If our client_secret is in\n * there, we must not be the ones who write it to disk.\n */\nfunction excerptForLog(raw: string, clientSecret: string | undefined): string {\n let text = raw.replace(/\\s+/g, \" \").trim();\n if (clientSecret) text = text.split(clientSecret).join(\"[redacted client_secret]\");\n if (text === \"\") return \"(empty body)\";\n return text.length > 200 ? `${text.slice(0, 200)}…` : text;\n}\n\nfunction invalidClientHint(error: string | undefined, weSentASecret: boolean): string {\n if (error !== \"invalid_client\") return \"\";\n return weSentASecret\n ? \" — we DID send a client_secret, so either it is wrong or this client is registered as PUBLIC (in which case unset SSO_CLIENT_SECRET).\"\n : \" — we sent NO client_secret, so this client is likely registered as CONFIDENTIAL (set SSO_CLIENT_SECRET to the value it was registered with).\";\n}\n\n/**\n * The algorithms an ID token may be signed with. Asymmetric only, and stated\n * HERE rather than inferred from the token.\n *\n * ── WHY THIS LIST EXISTS WHEN NOTHING WAS EXPLOITABLE WITHOUT IT ──────────\n *\n * `alg` is read from the token's own protected header — that is, from whoever\n * sent it. Before this list, it was handed to importJWK and jwtVerify was\n * called with no restriction at all. Both classic forgeries were built and run\n * against this client in the security review of 2026-09-20:\n *\n * HS256, signed with the PUBLIC key as the HMAC secret → rejected\n * alg: none → rejected\n * control: a genuine RS256 token → accepted\n *\n * So the package was NOT exploitable. But read the rejection: both failed with\n * `JOSENotSupported: Invalid or unsupported JWK \"alg\"`, thrown inside jose's\n * key import because an RSA JWK cannot be imported as an HMAC key. The defence\n * was a dependency's internals, not ours — it would move on a minor upgrade, or\n * the day an issuer publishes a symmetric key, and NOTHING here would go red.\n *\n * A guard you cannot see is a guard you cannot keep. Now jwtVerify refuses\n * first, and there is a test that fails if this list is widened.\n */\nconst ALLOWED_ALGS = [\n // FIRST, because it is what Broberg ID actually signs with. Measured on the\n // live issuer 2026-09-20:\n // /jwks kid=Iodh… kty=OKP alg=EdDSA crv=Ed25519\n // discovery id_token_signing_alg_values_supported: [\"EdDSA\"]\n //\n // 0.2.1 SHIPPED THIS LIST WITHOUT IT and rejected every real token for an\n // hour. The list was written from what an OIDC client usually allows rather\n // than from what our issuer uses — and the whole suite missed it because the\n // fake issuer in the tests signs RS256. A fake agrees with whoever wrote it.\n // There is now a test that mints EdDSA, the algorithm production uses.\n \"EdDSA\",\n \"RS256\", \"RS384\", \"RS512\",\n \"PS256\", \"PS384\", \"PS512\",\n \"ES256\", \"ES384\", \"ES512\",\n];\n\nexport function createSsoClient(\n config: SsoConfig,\n options: CreateSsoClientOptions = {},\n): SsoClient {\n const fetchImpl = options.fetchImpl ?? fetch;\n let discoveryPromise: Promise<Discovery> | null = null;\n let jwksCache: JwksCache | null = null;\n\n async function discovery(): Promise<Discovery> {\n discoveryPromise ??= (async () => {\n const url = `${config.issuer}/.well-known/openid-configuration`;\n const res = await fetchImpl(url);\n if (!res.ok) throw new SsoError(`${url} answered ${res.status}`);\n const doc = (await res.json()) as Discovery;\n\n // STRICT equality, and it is not pedantry. Better Auth's default basePath\n // advertised `<origin>/api/auth` as the issuer — measured on a running\n // BID during F084.1. If the document's issuer and our configured issuer\n // disagree, every token this app later verifies will be rejected for a\n // reason that reads as a signature problem. Refuse at boot instead.\n // THE CHECK THAT WOULD HAVE TURNED 0.2.1'S OUTAGE INTO A SENTENCE.\n //\n // An allow-list that omits the algorithm the issuer actually uses does\n // not fail loudly: every token is refused, one at a time, with a message\n // about that token. The app looks broken, the issuer looks broken, and\n // nothing names the real cause. That is what 0.2.1 did for an hour.\n //\n // The issuer already publishes the answer. If it advertises its signing\n // algorithms and NONE of them are ones we accept, that is knowable here —\n // once, at first use — instead of being rediscovered on every login.\n //\n // Deliberately narrow: it fires only on an EMPTY intersection. An issuer\n // that supports one algorithm we allow and three we do not is perfectly\n // workable, and refusing to start there would be a second outage dressed\n // as caution.\n const advertised = doc.id_token_signing_alg_values_supported;\n if (Array.isArray(advertised) && advertised.length > 0) {\n const usable = advertised.filter((a) => ALLOWED_ALGS.includes(a));\n if (usable.length === 0) {\n throw new SsoError(\n `${config.issuer} signs ID tokens with ${advertised.join(\", \")}, and this client ` +\n `accepts none of those (it allows ${ALLOWED_ALGS.join(\", \")}). Every token would be ` +\n `rejected. This is a mismatch between the package and the issuer — report it rather ` +\n `than working around it.`,\n );\n }\n }\n\n if (doc.issuer !== config.issuer) {\n throw new SsoError(\n `BID_ISSUER is ${config.issuer} but ${url} says its issuer is ${doc.issuer}. ` +\n `These must match exactly — tokens are validated against the issuer string.`,\n );\n }\n return doc;\n })().catch((err) => {\n discoveryPromise = null; // let a later call retry rather than cache a failure\n throw err;\n });\n return discoveryPromise;\n }\n\n async function keys(): Promise<JwksCache> {\n if (!jwksCache) {\n const { jwks_uri } = await discovery();\n jwksCache = createJwksCache({\n jwksUri: jwks_uri,\n fetchImpl,\n ...(options.minRefetchIntervalMs !== undefined\n ? { minRefetchIntervalMs: options.minRefetchIntervalMs }\n : {}),\n });\n }\n return jwksCache;\n }\n\n async function verifyIdToken(idToken: string, opts: { nonce?: string } = {}) {\n const cache = await keys();\n const header = decodeProtectedHeader(idToken);\n if (!header.kid) throw new SsoError(\"ID token has no kid — cannot pick a signing key\");\n\n const { payload } = await jwtVerify(\n idToken,\n async () => cache.getKey(header.kid!, header.alg ?? \"RS256\"),\n { issuer: config.issuer, audience: config.clientId, algorithms: ALLOWED_ALGS },\n );\n\n if (opts.nonce !== undefined && payload.nonce !== opts.nonce) {\n throw new SsoError(\n \"ID token nonce does not match this login request — refusing a token minted for another sign-in.\",\n );\n }\n if (typeof payload.sub !== \"string\" || payload.sub === \"\") {\n throw new SsoError(\"ID token has no sub — there is no user to be\");\n }\n return payload as SsoClaims;\n }\n\n /**\n * Fetch the profile claims and fold them in.\n *\n * NOT an optimisation — it is the only way to learn a user's name. MEASURED\n * against the live Broberg ID on 16 Sep 2026:\n *\n * ID token iss · sub · aud · iat · exp · auth_time · acr · at_hash\n * userinfo sub · name · given_name · family_name · email · email_verified\n *\n * That is correct OIDC for an authorization-code flow: profile claims belong\n * to the userinfo endpoint, and an ID token proving WHO you are does not have\n * to say what you are called. An app that only reads the ID token gets a\n * signed identity and a blank name — which is exactly what the first run of\n * the example app showed on screen.\n *\n * ── THE CHECK A NAIVE VERSION SKIPS ───────────────────────────────────────\n *\n * The `sub` from userinfo MUST equal the `sub` in the ID token (OIDC Core\n * 5.3.2 says MUST). Without it, a userinfo response for a DIFFERENT user\n * would be merged over a correctly verified identity — the app would show,\n * and act as, somebody else, with a valid signature underneath. A mismatch is\n * refused outright rather than reconciled.\n */\n async function withUserInfo(claims: SsoClaims, accessToken: string): Promise<SsoClaims> {\n const doc = await discovery();\n if (!doc.userinfo_endpoint) return claims;\n\n const res = await fetchImpl(doc.userinfo_endpoint, {\n headers: { authorization: `Bearer ${accessToken}` },\n });\n // A failure here must NOT lose the sign-in: the identity is already proven\n // by the ID token. The user ends up with a session and no display name,\n // which is worse than having one and far better than being logged out.\n if (!res.ok) return claims;\n\n const info = (await res.json()) as Record<string, unknown>;\n if (info.sub !== claims.sub) {\n throw new SsoError(\n `userinfo describes ${String(info.sub)} but the ID token is for ${claims.sub} — ` +\n `refusing to merge another user's profile onto this session.`,\n );\n }\n return { ...claims, ...info, sub: claims.sub };\n }\n\n return {\n discovery,\n get jwks() {\n if (!jwksCache) throw new SsoError(\"the key cache is created on first verification\");\n return jwksCache;\n },\n verifyIdToken,\n\n async beginLogin(opts: BeginLoginOptions = {}): Promise<LoginStart> {\n const { authorization_endpoint } = await discovery();\n const codeVerifier = randomToken();\n const state = randomToken(16);\n const nonce = randomToken(16);\n\n const params = new URLSearchParams({\n response_type: \"code\",\n client_id: config.clientId,\n redirect_uri: config.redirectUri,\n scope: [...new Set([...config.scopes, ...(opts.scopes ?? [])])].join(\" \"),\n state,\n nonce,\n code_challenge: await challengeFor(codeVerifier),\n code_challenge_method: \"S256\",\n });\n if (opts.prompt) params.set(\"prompt\", opts.prompt);\n\n return { url: `${authorization_endpoint}?${params}`, state, codeVerifier, nonce };\n },\n\n async completeLogin({ params, state, codeVerifier, nonce }) {\n // An ERROR comes back on the same redirect as a success — BID answers a\n // refused `prompt=none` by redirecting here with ?error=login_required.\n // Reading only for `code` would make a refusal look like a malformed\n // response instead of the expected answer it is.\n const error = params.get(\"error\");\n if (error) {\n throw new SsoError(\n `Broberg ID refused this login: ${error}` +\n (params.get(\"error_description\") ? ` — ${params.get(\"error_description\")}` : \"\"),\n );\n }\n\n const returned = params.get(\"state\");\n if (!returned || returned !== state) {\n throw new SsoError(\n \"state does not match the login this browser started — refusing the callback.\",\n );\n }\n\n const code = params.get(\"code\");\n if (!code) throw new SsoError(\"callback carried neither an error nor a code\");\n\n const { token_endpoint } = await discovery();\n const res = await fetchImpl(token_endpoint, {\n method: \"POST\",\n headers: { \"content-type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n grant_type: \"authorization_code\",\n code,\n redirect_uri: config.redirectUri,\n client_id: config.clientId,\n // ALWAYS sent, secret or not. See SsoConfig.clientSecret: the two\n // prove different things, so a confidential client keeps PKCE.\n code_verifier: codeVerifier,\n ...(config.clientSecret ? { client_secret: config.clientSecret } : {}),\n }),\n });\n\n // STATUS FIRST, PARSE SECOND — and the order IS the fix (components-F084.52).\n //\n // This used to be `await res.json()`, which runs BEFORE the `if (!res.ok)`\n // below. So when the issuer answered with something that is not JSON — an\n // empty 500 body, an HTML error page from a proxy, a gateway timeout — the\n // parse threw `SyntaxError: Unexpected end of JSON input` and the careful\n // message underneath was never reached. In the one case it exists for.\n //\n // The cost is not cosmetic. BID's own public guide (id.broberg.ai/docs §4b)\n // tells every consumer to wrap completeLogin in try/catch and redirect\n // instead of returning 500, so this string is ALL a consumer has: the user\n // sees a redirect, and the diagnosis lives only in what they logged. A line\n // reading \"Unexpected end of JSON input\" sends that reader into their own\n // code to look for a fault that is in the server.\n //\n // Reported by broberg-id, who measured production first: a real\n // `POST /oauth2/token` with a bad code answers 400 + JSON, so the good path\n // was never affected. This only fires against a broken or outdated issuer —\n // which is exactly where somebody is already debugging.\n //\n // NOT a try/catch around res.json(): that catches the throw and still loses\n // res.status, which is the one fact that says WHICH end is at fault.\n const raw = await res.text();\n let body: {\n id_token?: string;\n access_token?: string;\n refresh_token?: string;\n error?: string;\n error_description?: string;\n };\n try {\n body = raw.trim() === \"\" ? {} : (JSON.parse(raw) as typeof body);\n } catch {\n throw new SsoError(\n `token exchange failed (${res.status}): the response body is not JSON` +\n `${res.headers.get(\"content-type\") ? ` (content-type: ${res.headers.get(\"content-type\")})` : \"\"}` +\n ` — ${excerptForLog(raw, config.clientSecret)}`,\n );\n }\n // An EMPTY body parses to {} and falls through to the check below, which\n // reports the status and \"no id_token in response\" — right for a 500 with\n // no body, and right for a 200 that returned nothing.\n if (!res.ok || !body.id_token) {\n throw new SsoError(\n `token exchange failed (${res.status}): ${body.error ?? \"no id_token in response\"}` +\n (body.error_description ? ` — ${body.error_description}` : \"\") +\n invalidClientHint(body.error, config.clientSecret !== undefined),\n );\n }\n\n const claims = await verifyIdToken(body.id_token, { nonce });\n\n return {\n claims: body.access_token ? await withUserInfo(claims, body.access_token) : claims,\n idToken: body.id_token,\n ...(body.access_token ? { accessToken: body.access_token } : {}),\n ...(body.refresh_token ? { refreshToken: body.refresh_token } : {}),\n };\n },\n\n async logoutUrl(opts = {}) {\n const doc = await discovery();\n if (!doc.end_session_endpoint) {\n throw new SsoError(\n `${config.issuer} does not advertise end_session_endpoint — central logout is unavailable.`,\n );\n }\n const params = new URLSearchParams();\n if (opts.idTokenHint) params.set(\"id_token_hint\", opts.idTokenHint);\n const post = opts.postLogoutRedirectUri ?? config.postLogoutRedirectUri;\n if (post) params.set(\"post_logout_redirect_uri\", post);\n params.set(\"client_id\", config.clientId);\n return `${doc.end_session_endpoint}?${params}`;\n },\n\n async addressOwnership(accessToken, address) {\n // An app-API route on BID's origin, not an OIDC endpoint, so it is not in\n // discovery. Origin-relative: the issuer may carry a path.\n const url = new URL(\"/api/app/address-ownership\", config.issuer).toString();\n const res = await fetchImpl(url, {\n method: \"POST\",\n headers: { authorization: `Bearer ${accessToken}`, \"content-type\": \"application/json\" },\n body: JSON.stringify({ address }),\n });\n if (!res.ok) {\n throw new SsoError(`${url} answered ${res.status} — address ownership is unknown, not \"unverified\".`);\n }\n // Anything but the three answers THROWS. Mapping a malformed or new status\n // to \"unverified\" would quietly downgrade a real user's verified address,\n // and nothing about that looks broken from the outside.\n const body = (await res.json().catch(() => null)) as { status?: unknown } | null;\n const status = body?.status;\n if (typeof status !== \"string\" || !ADDRESS_OWNERSHIP.has(status)) {\n throw new SsoError(`${url} returned status ${JSON.stringify(status)} — expected verified, unverified or not_on_account.`);\n }\n return status as AddressOwnership;\n },\n };\n}\n","/**\n * The app's OWN session cookie — signed, not encrypted.\n *\n * Signed is the right choice and the distinction matters: the contents are not\n * secret (a user may read their own id and name), but they must not be\n * FORGEABLE. Encryption would hide a subject id the user already knows while\n * doing nothing extra about forgery, which is the actual risk.\n *\n * What goes in is deliberately small: who you are and when this stops being\n * true. Claims that can change — a name, a role, a picture — belong in the\n * app's own store keyed by `sub`, because a cookie is a cache nobody can\n * invalidate, and a stale role in a cookie is a permission that outlives its\n * revocation.\n */\n\nexport interface SessionPayload {\n /** The subject from Broberg ID. The one stable identifier. */\n sub: string;\n /** Unix seconds. Checked on every read. */\n exp: number;\n /** Optional convenience copies; never authorisation data. */\n email?: string;\n name?: string;\n /** A URL, never bytes. The picture lives wherever BID put it; this is a\n * reference, so a changed picture propagates without every app storing a\n * copy that then goes stale. */\n picture?: string;\n}\n\nconst enc = new TextEncoder();\n\nconst b64url = (bytes: Uint8Array) => {\n let s = \"\";\n for (const b of bytes) s += String.fromCharCode(b);\n return btoa(s).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n};\n\nconst fromB64url = (s: string) => {\n const pad = s.replace(/-/g, \"+\").replace(/_/g, \"/\");\n const bin = atob(pad + \"=\".repeat((4 - (pad.length % 4)) % 4));\n return Uint8Array.from(bin, (c) => c.charCodeAt(0));\n};\n\nasync function hmacKey(secret: string) {\n return crypto.subtle.importKey(\"raw\", enc.encode(secret), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\n \"sign\",\n \"verify\",\n ]);\n}\n\n/**\n * Sign an arbitrary string. The primitive underneath BOTH cookies this package\n * sets — the session and the short-lived login transaction.\n *\n * They are separate functions on purpose. The first version of this file had\n * the transaction ride inside the session envelope with `exp: 0`, and the\n * expiry check (`exp * 1000 <= now()`) then rejected it every single time: the\n * transaction cookie could never be read back, so every login would have failed\n * with \"state does not match\" — a message pointing at the wrong thing entirely.\n * Two different lifetimes wanted two different envelopes, not one envelope with\n * a sentinel in it.\n */\nexport async function signValue(\n value: string,\n secret: string,\n // `now` is injectable for the same reason verifySession's is: an age check\n // tested by waiting is a slow test that fails on a loaded machine.\n options: { maxAgeSeconds?: number; now?: () => number } = {},\n): Promise<string> {\n const payload = b64url(enc.encode(value));\n // The timestamp goes INSIDE the signed body, never beside it. An expiry the\n // holder can edit is not a limit. `~` is outside the base64url alphabet, so\n // `t<digits>~` cannot be confused with a payload that merely starts with \"t\".\n const body =\n options.maxAgeSeconds === undefined\n ? payload\n : `t${Math.floor((options.now ?? Date.now)() / 1000)}~${payload}`;\n const sig = new Uint8Array(await crypto.subtle.sign(\"HMAC\", await hmacKey(secret), enc.encode(body)));\n return `${body}.${b64url(sig)}`;\n}\n\nconst STAMPED = /^t(\\d+)~(.*)$/s;\n\n/**\n * Verify the signature, and — only if you ask — the age (components-F084.53).\n *\n * THE PREFIX ON THAT NUMBER IS NOT DECORATION. F-numbers are per-project, and\n * this comment ships in the .d.ts — so a consumer reading it in their editor\n * resolves the number against whatever board THEY know. broberg-id found this\n * by reading their own F084.53, which is \"no limit on password guesses\": a\n * different feature, in a different repo, behind the same string.\n *\n * WITHOUT `maxAgeSeconds` this is signature-only, exactly as before. That is\n * not laziness: the login-transaction cookie has lived on its browser `Max-Age`\n * since the package shipped, and making the check mandatory would invalidate\n * every cookie already in a user's browser — an instant logout for everyone.\n *\n * WITH `maxAgeSeconds`, a value the SERVER can date is refused once it is too\n * old. Reported by broberg-id: `Max-Age` is the client's claim about when a\n * cookie stopped being valid, and a client may simply not make that claim, so\n * the server used to accept a correctly-signed transaction value forever.\n *\n * THE MIXED CASE IS THE ONE THAT DECIDES WHETHER THIS IS WORTH ANYTHING: a\n * value signed by the OLD code (no timestamp) verified by the NEW code (with a\n * limit). That happens during every rollout, and it FAILS CLOSED — otherwise an\n * undated value would be the way around the very limit being added, and the\n * limit would only apply to those not trying to avoid it.\n */\nexport async function verifyValue(\n token: string | undefined | null,\n secret: string,\n options: { maxAgeSeconds?: number; now?: () => number } = {},\n): Promise<string | null> {\n if (!token) return null;\n const dot = token.lastIndexOf(\".\");\n if (dot <= 0) return null;\n const body = token.slice(0, dot);\n try {\n const ok = await crypto.subtle.verify(\n \"HMAC\",\n await hmacKey(secret),\n fromB64url(token.slice(dot + 1)),\n enc.encode(body),\n );\n if (!ok) return null;\n\n const stamped = STAMPED.exec(body);\n if (options.maxAgeSeconds !== undefined) {\n if (!stamped) return null; // undated + a limit asked for → fail CLOSED\n const issuedAt = Number(stamped[1]);\n if ((options.now ?? Date.now)() / 1000 - issuedAt > options.maxAgeSeconds) return null;\n }\n return new TextDecoder().decode(fromB64url(stamped?.[2] ?? body));\n } catch {\n return null;\n }\n}\n\nexport async function signSession(payload: SessionPayload, secret: string): Promise<string> {\n return signValue(JSON.stringify(payload), secret);\n}\n\n/**\n * Returns null for ANY reason the cookie cannot be trusted — tampered,\n * truncated, wrong secret, expired, or simply not one of ours.\n *\n * Deliberately one return value rather than distinguishing them to the caller:\n * an app that can tell \"bad signature\" from \"expired\" will eventually branch on\n * it, and there is no branch where a forged cookie should do anything other\n * than what an absent one does.\n */\nexport async function verifySession(\n token: string | undefined | null,\n secret: string,\n now: () => number = Date.now,\n): Promise<SessionPayload | null> {\n // crypto.subtle.verify is constant-time; a hand-rolled string compare would\n // leak the signature one byte at a time.\n const body = await verifyValue(token, secret);\n if (body === null) return null;\n\n try {\n const payload = JSON.parse(body) as SessionPayload;\n if (typeof payload.sub !== \"string\" || payload.sub === \"\") return null;\n if (typeof payload.exp !== \"number\" || payload.exp * 1000 <= now()) return null;\n return payload;\n } catch {\n return null;\n }\n}\n\n/** Serialise a Set-Cookie value. `secure` is off only for http://localhost. */\nexport function cookieHeader(\n name: string,\n value: string,\n opts: { maxAge: number; secure: boolean; sameSite?: \"Lax\" | \"Strict\"; path?: string },\n): string {\n const parts = [\n `${name}=${value}`,\n `Path=${opts.path ?? \"/\"}`,\n `Max-Age=${opts.maxAge}`,\n \"HttpOnly\",\n // Lax, NOT Strict, and this is load-bearing: the callback from Broberg ID\n // is a top-level GET navigation from another site. Strict withholds the\n // cookie on exactly that navigation, so the login transaction cookie would\n // be missing when it is needed and every sign-in would fail with \"state\n // does not match\" — a message that sends you looking in the wrong place.\n `SameSite=${opts.sameSite ?? \"Lax\"}`,\n ];\n if (opts.secure) parts.push(\"Secure\");\n return parts.join(\"; \");\n}\n\nexport function readCookie(header: string | null | undefined, name: string): string | undefined {\n if (!header) return undefined;\n for (const part of header.split(\";\")) {\n const eq = part.indexOf(\"=\");\n if (eq === -1) continue;\n if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();\n }\n return undefined;\n}\n","/**\n * The Hono adapter — mounting, and nothing else.\n *\n * Every rule about what a valid session IS lives in the core (client.ts,\n * session.ts). This file only turns those into routes. F084.5 adds a Next.js\n * adapter on the SAME core, because a rule implemented twice is a rule that\n * eventually says two things.\n */\nimport { Hono, type Context, type MiddlewareHandler } from \"hono\";\nimport { loadSsoConfig, type SsoConfig } from \"./config.js\";\nimport { createSsoClient, type SsoClient } from \"./client.js\";\nimport {\n cookieHeader,\n readCookie,\n signSession,\n signValue,\n verifySession,\n verifyValue,\n type SessionPayload,\n} from \"./session.js\";\n\n/** Where the adapter parks the session on the request, for `getSession`. */\nconst SESSION_KEY = \"bidSession\";\n\n/**\n * How long the SERVER will accept an in-flight login. Five minutes is plenty\n * for a person to type a password; longer just widens the window. This is the\n * real limit: it is enforced in `parseTransaction`, on a timestamp inside the\n * signature, so no client can talk its way past it.\n */\nconst TRANSACTION_MAX_AGE = 300;\n\n/**\n * How long the BROWSER keeps the cookie — deliberately LONGER than the window\n * above, and the two numbers must never be collapsed back into one.\n *\n * A cookie the browser has already dropped NEVER ARRIVES. With one number for\n * both, the moment the window passes there is simply nothing in the request,\n * and the server cannot tell \"she took too long\" from \"she never started a\n * login here\". Those are different things that deserve different answers, and\n * collapsing them cost a diagnosis: this adapter used to answer every failed\n * callback with one message containing the word \"or\".\n *\n * Reported by helpdesk (2026-09-22, components-F084.54), who run 3x in\n * production for exactly this reason. The extra time is INERT — `parseTransaction`\n * refuses anything past TRANSACTION_MAX_AGE and /callback clears the cookie, so\n * a verifier that cannot be exchanged is not a key. It buys diagnosis, not\n * lifetime.\n */\nconst TRANSACTION_COOKIE_MAX_AGE = TRANSACTION_MAX_AGE * 3;\n\nexport interface SsoRoutesOptions {\n config?: SsoConfig;\n client?: SsoClient;\n /** Path prefix these routes are mounted under. Used to build the return URL. */\n loginPath?: string;\n /** Where to send a user after a successful login when no returnTo is given. */\n defaultReturnTo?: string;\n}\n\nfunction isSecure(c: Context): boolean {\n // localhost over http is the one case where a Secure cookie would simply\n // never be stored, making local development impossible for a reason no error\n // would explain.\n return new URL(c.req.url).protocol === \"https:\";\n}\n\n/**\n * Only same-origin, path-only return targets are honoured.\n *\n * Without this, `/auth/login?returnTo=https://evil.example` turns this app's\n * own login into an open redirect that arrives wearing our domain — the\n * classic phishing lever, and it is one line to close.\n */\n/**\n * An origin nothing can resolve to. Only used as a yardstick: if a candidate\n * still sits on THIS origin after parsing, it is same-origin wherever the app\n * actually lives.\n */\nconst YARDSTICK = \"https://sso.invalid\";\n\n/**\n * Only same-origin, path-only return targets are honoured.\n *\n * ── WHY THIS ASKS A PARSER INSTEAD OF INSPECTING CHARACTERS ───────────────\n *\n * The first version of this function rejected a leading `//` and required a\n * leading `/`. That is the obvious rule, it reads as correct, and it was\n * EXPLOITABLE — found in the first full security review of this package\n * (2026-09-20), not by any diff review, because this code never appeared in a\n * diff:\n *\n * returnTo=/\\evil.dk → passed the guard\n * → resolves to https://evil.dk/ in a browser\n *\n * A backslash is not a path separator to RFC 3986 and IS one to the WHATWG URL\n * spec, which is what browsers implement. So the string looked like a path to\n * us and was an authority to the thing that acts on it.\n *\n * AND THE OBVIOUS FIX WAS ALSO LEAKY. Parsing once and comparing origins closes\n * the backslash and still lets `/..//evil.dk` through: `..` NORMALISES the path\n * to `//evil.dk`, which is protocol-relative when it is later used as a\n * Location. So the result is re-checked — a fixpoint — rather than trusted\n * because the input parsed cleanly.\n *\n * The rule is therefore: let the SAME parser the browser uses decide, twice.\n * A list of dangerous characters is always one trick behind; the first version\n * knew `//` and not `\\`, and the second knew `\\` and not `..`.\n */\nfunction safeReturnTo(raw: string | undefined, fallback: string): string {\n if (!raw || !raw.startsWith(\"/\")) return fallback;\n\n let once: URL;\n try {\n once = new URL(raw, YARDSTICK);\n } catch {\n return fallback;\n }\n if (once.origin !== YARDSTICK) return fallback;\n\n const normalised = once.pathname + once.search + once.hash;\n let twice: URL;\n try {\n twice = new URL(normalised, YARDSTICK);\n } catch {\n return fallback;\n }\n if (twice.origin !== YARDSTICK) return fallback;\n // Belt and braces: a normalised path must still be a path.\n if (!normalised.startsWith(\"/\") || normalised.startsWith(\"//\")) return fallback;\n\n return normalised;\n}\n\nexport function ssoRoutes(options: SsoRoutesOptions = {}) {\n const config = options.config ?? loadSsoConfig();\n const client = options.client ?? createSsoClient(config);\n const loginPath = options.loginPath ?? \"/auth\";\n const defaultReturnTo = options.defaultReturnTo ?? \"/\";\n const txCookie = `${config.cookieName}_tx`;\n /**\n * The ID token, kept ONLY so logout can prove who is leaving.\n *\n * Its own cookie rather than a field on SessionPayload, for three reasons:\n * that type is public and deliberately small (session.ts says why), a JWT is\n * ~1 kB that would otherwise ride along on every single request to the app,\n * and the two are cleared at different moments.\n */\n const idTokenCookie = `${config.cookieName}_idt`;\n\n const app = new Hono();\n\n app.get(\"/login\", async (c) => {\n const prompt = c.req.query(\"prompt\");\n const start = await client.beginLogin(\n prompt === \"none\" ? { prompt: \"none\" } : {},\n );\n const tx = JSON.stringify({\n state: start.state,\n codeVerifier: start.codeVerifier,\n nonce: start.nonce,\n returnTo: safeReturnTo(c.req.query(\"returnTo\"), defaultReturnTo),\n });\n c.header(\n \"Set-Cookie\",\n // The signed stamp carries the SERVER's window; the cookie carries the\n // longer browser one, so an expired transaction still reaches us and can\n // be named instead of vanishing.\n cookieHeader(txCookie, await signValue(tx, config.cookieSecret, { maxAgeSeconds: TRANSACTION_MAX_AGE }), {\n maxAge: TRANSACTION_COOKIE_MAX_AGE,\n secure: isSecure(c),\n }),\n );\n // A FULL top-level redirect. Never a hidden iframe — see client.ts.\n return c.redirect(start.url, 302);\n });\n\n app.get(\"/callback\", async (c) => {\n const parsed = await parseTransaction(\n readCookie(c.req.header(\"cookie\"), txCookie),\n config.cookieSecret,\n );\n if (!parsed.ok) {\n /**\n * THREE CAUSES, THREE ANSWERS (components-F084.54).\n *\n * THE ORDER IN `parseTransaction` IS LOAD-BEARING AND NOT OBVIOUS:\n * `expired` is reached ONLY AFTER the signature has verified. So\n * `login_expired` is a POSITIVE CONFIRMATION that the HMAC held against\n * the CURRENT secret, and `bad_login_cookie` is the denial of that.\n *\n * WHICH MAKES THE PAIR A ONE-BIT ORACLE, and the justification that stood\n * here was wrong. It said an attacker can produce all three himself, so\n * naming them tells him nothing. That holds only for an attacker who\n * CONSTRUCTED the input and therefore knows what he is holding. The\n * interesting one has a value he FOUND — in a log, on a shared machine, in\n * a referrer leak, in a backup — and he cannot mint a\n * validly-signed-but-expired value himself. For him the answer separates\n * two things he could not otherwise learn: whether a found value is still\n * live, when the secret rotates (the same value changes code the second it\n * does), and whether two environments share a secret.\n *\n * Reported by helpdesk, 2026-09-22, measured against the published 0.3.0\n * dist rather than argued. It is NOT a hole: it does not help him forge\n * anything — one bit per attempt against an HMAC-SHA256 key he never\n * reaches. It is a property the server gives away for free.\n *\n * KEPT AS IS FOR NOW, deliberately and not by omission: the three answers\n * solve a real problem for a consumer who owns only the browser end and\n * must show \"your login expired, try again\" rather than \"something went\n * wrong\". Splitting the two audiences — all three to the app and the log,\n * one to the browser by default — is components-F084.55 and is the owner's\n * call, not a thing to change twice in an hour under a peer's argument.\n *\n * The operator half stands unchanged and is the reason the codes exist at\n * all: `bad_login_cookie` on a REAL user's browser is what a rotated\n * SSO_COOKIE_SECRET looks like from the outside.\n */\n const failures = {\n absent: {\n status: 400 as const,\n error: \"no_login_in_progress\",\n message: \"This browser did not start a login here. Begin again from the login page.\",\n },\n expired: {\n status: 400 as const,\n error: \"login_expired\",\n message: `This login took longer than ${TRANSACTION_MAX_AGE} seconds. Begin again from the login page.`,\n },\n unreadable: {\n status: 400 as const,\n error: \"bad_login_cookie\",\n message: \"This browser's login cookie could not be read. Begin again from the login page.\",\n },\n };\n const { status, ...body } = failures[parsed.reason];\n // Clear it either way: a transaction we refuse must not sit in the browser\n // waiting to be refused again on every retry.\n c.header(\"Set-Cookie\", cookieHeader(txCookie, \"\", { maxAge: 0, secure: isSecure(c) }));\n return c.json(body, status);\n }\n\n const result = await client.completeLogin({\n params: new URL(c.req.url).searchParams,\n state: parsed.tx.state,\n codeVerifier: parsed.tx.codeVerifier,\n nonce: parsed.tx.nonce,\n });\n\n const exp = Math.floor(Date.now() / 1000) + config.sessionMaxAge;\n const session: SessionPayload = {\n sub: result.claims.sub,\n exp,\n ...(result.claims.email ? { email: result.claims.email } : {}),\n ...(result.claims.name ? { name: result.claims.name } : {}),\n ...(result.claims.picture ? { picture: result.claims.picture } : {}),\n };\n\n c.header(\n \"Set-Cookie\",\n cookieHeader(config.cookieName, await signSession(session, config.cookieSecret), {\n maxAge: config.sessionMaxAge,\n secure: isSecure(c),\n }),\n );\n // The logout hint. Without it the issuer MUST ask the user to confirm —\n // that is RP-initiated logout per spec, and the plugin says so itself\n // (\"User confirmation is required to complete logout\"). The result was an\n // unstyled English confirmation page with a bare browser button in the\n // middle of our own product.\n c.header(\n \"Set-Cookie\",\n cookieHeader(idTokenCookie, await signValue(result.idToken, config.cookieSecret), {\n maxAge: config.sessionMaxAge,\n secure: isSecure(c),\n }),\n { append: true },\n );\n // Clear the transaction cookie: it has done its job, and a replayed one is\n // only ever useful to someone who should not have it.\n c.header(\n \"Set-Cookie\",\n cookieHeader(txCookie, \"\", { maxAge: 0, secure: isSecure(c) }),\n { append: true },\n );\n return c.redirect(parsed.tx.returnTo, 302);\n });\n\n app.get(\"/logout\", async (c) => {\n // Read the hint BEFORE clearing, obviously — but note what happens when it\n // is absent: a session minted by 0.1.0 has no such cookie, and that session\n // is still live in every app that upgrades. So a missing hint falls back to\n // the old behaviour (the issuer asks) rather than throwing. The upgrade path\n // is the one that fails in production; it gets its own test.\n const idTokenHint = await verifyValue(\n readCookie(c.req.header(\"cookie\"), idTokenCookie),\n config.cookieSecret,\n );\n\n c.header(\n \"Set-Cookie\",\n cookieHeader(config.cookieName, \"\", { maxAge: 0, secure: isSecure(c) }),\n );\n c.header(\n \"Set-Cookie\",\n cookieHeader(idTokenCookie, \"\", { maxAge: 0, secure: isSecure(c) }),\n { append: true },\n );\n // Local cookies cleared FIRST, then central logout. If the redirect to BID\n // fails or the user closes the tab, the worst case is \"signed out here but\n // not everywhere\" — never the reverse, which would leave this app trusting\n // a session the user believes is gone.\n return c.redirect(await client.logoutUrl(idTokenHint ? { idTokenHint } : {}), 302);\n });\n\n /** Reads the session and puts it on the context. Never blocks. */\n const attach: MiddlewareHandler = async (c, next) => {\n const cookie = readCookie(c.req.header(\"cookie\"), config.cookieName);\n c.set(SESSION_KEY, await verifySession(cookie, config.cookieSecret));\n await next();\n };\n\n /** Blocks and redirects to login, preserving where the user was going. */\n const require: MiddlewareHandler = async (c, next) => {\n const cookie = readCookie(c.req.header(\"cookie\"), config.cookieName);\n const session = await verifySession(cookie, config.cookieSecret);\n if (!session) {\n const url = new URL(c.req.url);\n return c.redirect(\n `${loginPath}/login?returnTo=${encodeURIComponent(url.pathname + url.search)}`,\n 302,\n );\n }\n c.set(SESSION_KEY, session);\n await next();\n };\n\n return { app, attach, require, client, config };\n}\n\n/** Why a login transaction could not be read back. Three states, three answers. */\nexport type TransactionFailure = \"absent\" | \"expired\" | \"unreadable\";\n\ntype TransactionResult =\n | { ok: true; tx: { state: string; codeVerifier: string; nonce: string; returnTo: string } }\n | { ok: false; reason: TransactionFailure };\n\n/**\n * Reads the login transaction back out of its signed cookie.\n *\n * RETURNS A REASON, NOT `null` (components-F084.54). It used to collapse three\n * causes into one empty answer, and /callback then told the user \"you did not\n * start a login here, OR it expired\" — a sentence that admits in its own wording\n * that it does not know. The word \"or\" was the bug.\n *\n * Age-checked against TRANSACTION_MAX_AGE, not only against the cookie's own\n * Max-Age (components-F084.53) — Max-Age is the browser's promise, and a client\n * can decline to make it. It still does NOT go through verifySession: that\n * envelope carries an `exp`, and the first version of this file used `exp: 0`,\n * which the expiry check rejected every time.\n *\n * NO FUTURE-STAMP GUARD, and that is a deliberate divergence from helpdesk's\n * implementation, which rejects a negative age so a runaway clock cannot widen\n * the window. Ours cannot be widened that way: the stamp is inside the HMAC, so\n * only OUR OWN clock could produce a future one — and refusing it would mean a\n * second app instance whose clock is a few seconds behind starts rejecting\n * perfectly good logins. That trades a theoretical gain for a real outage.\n */\nasync function parseTransaction(raw: string | undefined, secret: string): Promise<TransactionResult> {\n if (raw === undefined) return { ok: false, reason: \"absent\" };\n\n // Signature FIRST, without the age limit, so the two questions stay separate.\n // Asking them together is what produced one answer for three causes: a single\n // `verifyValue(..., { maxAgeSeconds })` returns null for a forged cookie and\n // for an honest late one alike.\n const signed = await verifyValue(raw, secret);\n if (signed === null) return { ok: false, reason: \"unreadable\" };\n\n const fresh = await verifyValue(raw, secret, { maxAgeSeconds: TRANSACTION_MAX_AGE });\n if (fresh === null) return { ok: false, reason: \"expired\" };\n\n try {\n const tx = JSON.parse(fresh) as {\n state: string;\n codeVerifier: string;\n nonce: string;\n returnTo: string;\n };\n // A correctly signed cookie whose contents are not a transaction is not an\n // expiry and not an absence — it is ours and it is wrong.\n if (!tx.state || !tx.codeVerifier || !tx.nonce) return { ok: false, reason: \"unreadable\" };\n return { ok: true, tx };\n } catch {\n return { ok: false, reason: \"unreadable\" };\n }\n}\n\n/** Read the session a middleware attached. */\nexport function getSession(c: Context): SessionPayload | null {\n return (c.get(SESSION_KEY) as SessionPayload | null) ?? null;\n}\n"]}
1
+ {"version":3,"sources":["../src/config.ts","../src/jwks.ts","../src/client.ts","../src/session.ts","../src/hono.ts"],"names":["importJWK","decodeProtectedHeader","jwtVerify","b64url","Hono","require"],"mappings":"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","file":"hono.cjs","sourcesContent":["/**\n * Configuration, read from the environment and nowhere else (F084.4 AC#5).\n *\n * An app mounts this package and sets env vars. It does not pass options in\n * code, because the moment configuration lives in code, two deployments of the\n * same app can disagree about who their identity provider is — and the symptom\n * is a token rejection nobody can trace back to a config line.\n */\n\nexport class SsoConfigError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"SsoConfigError\";\n }\n}\n\nexport interface SsoConfig {\n /** Broberg ID's origin, e.g. https://id.broberg.ai — the BARE origin. */\n issuer: string;\n /** The client id this app was registered under (administratively, in BID). */\n clientId: string;\n /** Exactly the redirect registered in BID. Exact match — one character decides. */\n redirectUri: string;\n /** Requested scopes. */\n scopes: string[];\n /** Signs the local session cookie. 32+ bytes of randomness. */\n cookieSecret: string;\n /** Local session cookie name. */\n cookieName: string;\n /**\n * How long this app trusts its OWN session, in seconds.\n *\n * F084.7 decided the fleet's numbers: 7 days for a normal app, and 12 hours\n * + 30 minutes of inactivity for anything holding personal or health data.\n * The default here is the 7 days. An app handling patient data MUST set\n * SSO_SESSION_MAX_AGE=43200 — the default is a normal-app default, not a\n * safe-for-everything one.\n */\n sessionMaxAge: number;\n /** Where to send the browser after a logout completes. */\n postLogoutRedirectUri?: string;\n /**\n * Set ONLY by an app that can actually keep a secret — one whose callback\n * runs on a server. Leave it unset for anything shipped to a browser; a\n * secret in a static bundle is not a secret, and PKCE alone is the correct,\n * fully standard design there.\n *\n * Setting it makes this a CONFIDENTIAL client: the secret is sent as\n * `client_secret_post` (in the body), which is the method BID registers\n * confidential clients with.\n *\n * IT DOES NOT RELAX PKCE. A secret proves which APP is asking; the PKCE\n * verifier proves the request belongs to the browser that started the login.\n * They defend against different attacks, so one is never an excuse to drop\n * the other — and there is a test that fails if anyone makes it one.\n */\n clientSecret?: string;\n}\n\n/**\n * NOT `!value`. A blank string is falsy in JavaScript, so the obvious guard\n * lets `SSO_COOKIE_SECRET=\" \"` boot an app whose sessions are signed with\n * whitespace. This fleet has measured that exact defect before (components\n * F004.7), which is why it is a trim-and-compare rather than a truthiness test.\n */\nfunction required(env: NodeJS.ProcessEnv, name: string, hint: string): string {\n const raw = env[name];\n if (typeof raw !== \"string\" || raw.trim() === \"\") {\n throw new SsoConfigError(`${name} is not set (or is blank). ${hint}`);\n }\n return raw.trim();\n}\n\n/** Seconds in a week — the fleet default from F084.7. */\nexport const DEFAULT_SESSION_MAX_AGE = 60 * 60 * 24 * 7;\n\nexport function loadSsoConfig(env: NodeJS.ProcessEnv = process.env): SsoConfig {\n const rawIssuer = required(\n env,\n \"BID_ISSUER\",\n \"It is Broberg ID's bare origin, e.g. https://id.broberg.ai\",\n );\n\n let issuer: string;\n try {\n const url = new URL(rawIssuer);\n if (url.protocol !== \"https:\" && url.hostname !== \"localhost\") {\n throw new SsoConfigError(\n `BID_ISSUER must be https (got ${url.protocol}//). Only localhost may be http.`,\n );\n }\n // Normalised to the origin: a trailing slash makes it a DIFFERENT issuer to\n // a strict OIDC client, and the mismatch surfaces as an opaque token\n // rejection rather than as a configuration error anyone can read.\n issuer = url.origin;\n } catch (err) {\n if (err instanceof SsoConfigError) throw err;\n throw new SsoConfigError(`BID_ISSUER is not a valid URL: ${rawIssuer}`);\n }\n\n const cookieSecret = required(\n env,\n \"SSO_COOKIE_SECRET\",\n \"Generate one with `openssl rand -hex 32`. It signs this app's session cookie.\",\n );\n if (cookieSecret.length < 32) {\n throw new SsoConfigError(\n `SSO_COOKIE_SECRET is ${cookieSecret.length} characters; it must be at least 32. ` +\n \"A short secret is a forgeable session, and a forged session is any user you like.\",\n );\n }\n\n const rawMaxAge = env.SSO_SESSION_MAX_AGE?.trim();\n let sessionMaxAge = DEFAULT_SESSION_MAX_AGE;\n if (rawMaxAge) {\n const n = Number(rawMaxAge);\n // A non-number here would silently become NaN and then a cookie that\n // expires immediately — an app that cannot keep anyone logged in, with no\n // error anywhere. Refuse instead.\n if (!Number.isFinite(n) || n <= 0) {\n throw new SsoConfigError(\n `SSO_SESSION_MAX_AGE must be a positive number of seconds (got ${JSON.stringify(rawMaxAge)}).`,\n );\n }\n sessionMaxAge = Math.floor(n);\n }\n\n return {\n issuer,\n clientId: required(env, \"SSO_CLIENT_ID\", \"The id this app is registered under in Broberg ID.\"),\n redirectUri: required(\n env,\n \"SSO_REDIRECT_URI\",\n \"Must match the registered redirect EXACTLY — one trailing slash is a different address.\",\n ),\n scopes: (env.SSO_SCOPES?.trim() || \"openid profile email\").split(/\\s+/),\n cookieSecret,\n cookieName: env.SSO_COOKIE_NAME?.trim() || \"bid_session\",\n sessionMaxAge,\n postLogoutRedirectUri: env.SSO_POST_LOGOUT_REDIRECT_URI?.trim() || undefined,\n clientSecret: env.SSO_CLIENT_SECRET?.trim() || undefined,\n };\n}\n","/**\n * The signing-key cache.\n *\n * ── THE ONE BEHAVIOUR THIS FILE EXISTS FOR ────────────────────────────────\n *\n * It refetches on an UNKNOWN KEY ID, not on a timer (F084.4's constraint, and\n * it is the right one). An interval is a guess about when somebody else will\n * rotate their key; an unknown kid is the event itself. With an interval, the\n * window between \"BID rotated\" and \"the interval elapsed\" is a window where\n * every login in every app fails, and the length of that window is a number\n * nobody chose on purpose.\n *\n * ── AND THE PART AN OBVIOUS IMPLEMENTATION GETS WRONG ─────────────────────\n *\n * \"Unknown kid ⇒ refetch\" turns anyone who can send this app a token into\n * someone who can make it hammer BID: a stream of tokens with random kids is a\n * stream of fetches against the one service the whole fleet logs in through.\n * So a refetch is rate-limited by time. The cost of the floor is real and worth\n * stating: a rotation landing inside the cooldown makes logins fail for up to\n * that many milliseconds. Seconds of failure for one app beats a way to aim\n * traffic at BID from outside.\n *\n * ── TWO FAILURES, NOT ONE (components-F084.50) ───────────────────────────────────────\n *\n * Reported by helpdesk, measured in the published 0.1.0 dist: at process start\n * the key set is EMPTY, so the first verification fetches. If BID is down in\n * that second, NOTHING verifies for that app — not just new logins. A process\n * that has been running and has seen a kid survives the same outage untouched.\n *\n * The recovery was never the defect: `lastFetchAt` is set only on SUCCESS, so a\n * failed fetch does not block the next attempt and the cache self-heals the\n * moment BID answers again. The defect was that `getKey` threw the SAME error\n * for two states whose correct answers are opposites:\n *\n * cannot reach the issuer transient. Retry, or answer 503. The token may\n * well be perfectly good — we simply did not look.\n * kid is not published permanent. This token was not signed by us.\n * Reject it, 401, and do not retry.\n *\n * One error name for both is how a caller ends up rejecting a legitimate user\n * because a foreign service had a bad minute — or, worse, retrying a forgery.\n * Hence two subclasses. They BOTH extend JwksError, so a 0.1.0 consumer whose\n * catch tests `instanceof JwksError` keeps working across the upgrade.\n *\n * ── WHAT WE DELIBERATELY DID NOT DO ───────────────────────────────────────\n *\n * We do NOT persist the key set. A stored key set that survives a REVOCATION is\n * a worse failure than a login that is down for two minutes — helpdesk's own\n * point, and the reason `keys = body.keys` replaces rather than merges. Writing\n * the set to disk would reintroduce exactly what that line exists to prevent.\n */\n// jose 6 dropped the `KeyLike` alias; importJWK now answers\n// `CryptoKey | Uint8Array` directly. Taking the type FROM the function\n// rather than naming it means a future rename cannot silently widen it.\nimport { importJWK, type JWK } from \"jose\";\n\ntype SigningKey = Awaited<ReturnType<typeof importJWK>>;\n\nexport interface JwksCacheOptions {\n /** Absolute URL of the key set, taken from BID's discovery document. */\n jwksUri: string;\n /**\n * The floor between two refetches. Below it, an unknown kid is rejected\n * without asking BID again.\n */\n minRefetchIntervalMs?: number;\n /**\n * Fetch the key set once at construction, so the cold window sits at BOOT\n * rather than in front of the first user who tries to log in.\n *\n * It is fire-and-forget ON PURPOSE: a failed warm-up must never stop the app\n * from starting. A package that refuses to boot because someone else's\n * service is down has made the outage worse, not better — the first getKey\n * will simply fetch, exactly as it does today.\n */\n warmUp?: boolean;\n /** Injectable for tests. */\n fetchImpl?: typeof fetch;\n /** Injectable for tests, so the cooldown can be exercised without waiting. */\n now?: () => number;\n}\n\nexport interface JwksCache {\n /** Resolve a key for this kid, refetching once if it is unknown. */\n getKey(kid: string, alg: string): Promise<SigningKey>;\n /** How many times the remote key set has actually been fetched. */\n readonly fetchCount: number;\n}\n\nexport class JwksError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"JwksError\";\n }\n}\n\n/**\n * The issuer could not be consulted — unreachable, a non-2xx, or a body that is\n * not a key set. Also thrown when an unknown kid arrives inside the refetch\n * cooldown, because there too the honest statement is \"we did not look\".\n *\n * TRANSIENT. The right response is to retry, or to answer 503 — never to\n * conclude anything about the token, which may be perfectly valid.\n */\nexport class JwksUnavailableError extends JwksError {\n constructor(message: string) {\n super(message);\n this.name = \"JwksUnavailableError\";\n }\n}\n\n/**\n * The issuer WAS consulted and does not publish this key id.\n *\n * PERMANENT. The token was not signed by this issuer. Reject it; retrying only\n * asks the same question again and BID will keep giving the same answer.\n */\nexport class JwksUnknownKeyError extends JwksError {\n constructor(message: string) {\n super(message);\n this.name = \"JwksUnknownKeyError\";\n }\n}\n\nexport function createJwksCache(options: JwksCacheOptions): JwksCache {\n const {\n jwksUri,\n minRefetchIntervalMs = 10_000,\n warmUp = false,\n fetchImpl = fetch,\n now = () => Date.now(),\n } = options;\n\n let keys: JWK[] = [];\n let lastFetchAt = -Infinity;\n let fetchCount = 0;\n /** Collapses concurrent refetches into one request. */\n let inFlight: Promise<void> | null = null;\n\n async function refresh(): Promise<void> {\n if (inFlight) return inFlight;\n inFlight = (async () => {\n let res: Response;\n try {\n res = await fetchImpl(jwksUri);\n } catch (cause) {\n // A thrown fetch is DNS, TLS, a refused connection — the issuer was not\n // reached at all. Rethrown as our own type so a caller never has to\n // pattern-match on a runtime's network error to tell this from a\n // rejected token.\n throw new JwksUnavailableError(\n `${jwksUri} could not be reached: ${cause instanceof Error ? cause.message : String(cause)}`,\n );\n }\n if (!res.ok) {\n throw new JwksUnavailableError(\n `${jwksUri} answered ${res.status} — cannot verify any token right now`,\n );\n }\n let body: { keys?: JWK[] } | null;\n try {\n body = (await res.json()) as { keys?: JWK[] } | null;\n } catch {\n // A 200 that is not JSON (a proxy's HTML error page, a captive portal)\n // is still \"we could not look\" — not a raw SyntaxError that slips past\n // every consumer's `instanceof JwksError`.\n throw new JwksUnavailableError(`${jwksUri} answered 200 with a body that is not JSON`);\n }\n if (!Array.isArray(body?.keys)) {\n // Reachable but not serving a key set: still \"we could not look\".\n throw new JwksUnavailableError(`${jwksUri} returned no \"keys\" array`);\n }\n // Replace rather than merge. Merging would keep a REVOKED key usable\n // forever, which is the one thing rotating a key is meant to stop.\n keys = body!.keys!;\n lastFetchAt = now();\n fetchCount++;\n })().finally(() => {\n inFlight = null;\n });\n return inFlight;\n }\n\n if (warmUp) {\n // Swallowed deliberately — see `warmUp` above. The failure is not lost:\n // the next getKey reports it to the caller who can actually act on it.\n void refresh().catch(() => {});\n }\n\n return {\n get fetchCount() {\n return fetchCount;\n },\n\n async getKey(kid: string, alg: string) {\n let jwk = keys.find((k) => k.kid === kid);\n\n if (!jwk) {\n const sinceLast = now() - lastFetchAt;\n if (sinceLast < minRefetchIntervalMs) {\n // The wording matters as much as the class, and this sentence is\n // helpdesk's, not mine. The old one said \"Refusing to refetch — retry\n // shortly\": transient ADVICE on a state that does not improve by\n // waiting, which literally instructs a consumer to do the thing the\n // split exists to prevent. It described what the CACHE did; a caller\n // needs to know what the STATE is.\n //\n // It carries the AGE because that is the number that decides. Their\n // measurement against the live issuer showed a key set 2ms old — we\n // had just looked, and the kid was not there. That is evidence of a\n // forgery, not of ignorance. Only a rotation inside the floor can\n // make this a false negative, and the age is what lets a caller see\n // which case it is standing in.\n throw new JwksUnavailableError(\n `kid ${kid} is not in the key set we hold, fetched ${sinceLast}ms ago ` +\n `(we do not ask the issuer again within ${minRefetchIntervalMs}ms). ` +\n `A key set this fresh that lacks the kid means the token was not signed by ` +\n `this issuer — reject it. Only a key rotation within the last ` +\n `${minRefetchIntervalMs}ms could make that wrong.`,\n );\n }\n await refresh();\n jwk = keys.find((k) => k.kid === kid);\n }\n\n if (!jwk) {\n throw new JwksUnknownKeyError(\n `Broberg ID does not publish a signing key with kid ${kid}. ` +\n `The token was not signed by this issuer.`,\n );\n }\n return importJWK(jwk, alg);\n },\n };\n}\n","/**\n * The core: framework-free, public-client OAuth 2.1 against Broberg ID.\n *\n * ── THIS PACKAGE IS A PUBLIC CLIENT. THERE IS NO CLIENT SECRET. ───────────\n *\n * Deliberate, and F084.4 AC#4 greps the published tarball to keep it that way.\n * PKCE alone carries the exchange. That is safe here for a reason worth stating\n * rather than assuming: BID matches redirect addresses EXACTLY (measured — one\n * trailing slash is refused), so the authorization code is delivered to this\n * app's own server and nowhere else. An attacker who knows the client id can\n * start a flow; they cannot receive its result.\n *\n * What it buys is the thing the card actually asks for: a secret that does not\n * exist cannot be committed, leaked in a log, copied into a second app, or left\n * behind in a repository someone later makes public.\n *\n * ── AND WHAT THIS PACKAGE MUST NEVER LEARN TO DO ──────────────────────────\n *\n * No passwords. No passkey registration. No social-provider keys. No email\n * verification. All of it lives in BID. A client that CAN do any of it is a\n * client somebody eventually uses to do it — and then the identity rules exist\n * in two places and drift.\n */\nimport { decodeProtectedHeader, jwtVerify, type JWTPayload } from \"jose\";\nimport type { SsoConfig } from \"./config.js\";\nimport { createJwksCache, type JwksCache } from \"./jwks.js\";\n\n/* ── discovery ───────────────────────────────────────────────────────────── */\n\nexport interface Discovery {\n /** What the issuer says it signs with. Absent on some providers. */\n id_token_signing_alg_values_supported?: string[];\n issuer: string;\n authorization_endpoint: string;\n token_endpoint: string;\n jwks_uri: string;\n userinfo_endpoint?: string;\n end_session_endpoint?: string;\n}\n\nexport class SsoError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"SsoError\";\n }\n}\n\n/* ── PKCE ────────────────────────────────────────────────────────────────── */\n\nconst b64url = (bytes: ArrayBuffer | Uint8Array) => {\n const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);\n let s = \"\";\n for (const b of view) s += String.fromCharCode(b);\n return btoa(s).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n};\n\nconst randomToken = (bytes = 32) => b64url(crypto.getRandomValues(new Uint8Array(bytes)));\n\nasync function challengeFor(verifier: string): Promise<string> {\n return b64url(await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(verifier)));\n}\n\n/* ── the shapes a caller handles ─────────────────────────────────────────── */\n\n/**\n * What `beginLogin` produces. Every field except `url` must survive the round\n * trip to BID and back — the adapter stores them in a short-lived cookie.\n *\n * They are NOT optional extras. `state` is what makes the callback provably the\n * answer to THIS request, and `nonce` is what stops a valid token minted for\n * some other login from being replayed into this one.\n */\nexport interface LoginStart {\n url: string;\n state: string;\n codeVerifier: string;\n nonce: string;\n}\n\nexport interface SsoClaims extends JWTPayload {\n sub: string;\n email?: string;\n name?: string;\n picture?: string;\n email_verified?: boolean;\n}\n\nexport interface LoginResult {\n claims: SsoClaims;\n idToken: string;\n accessToken?: string;\n refreshToken?: string;\n}\n\n/**\n * `prompt=none` asks BID to answer WITHOUT showing anything (components-F084.6).\n *\n * The delivery method decides whether it works, and this package only ever\n * produces a URL for a FULL TOP-LEVEL REDIRECT. It never returns anything an\n * app could put in a hidden iframe, because an iframe against the identity\n * provider is third-party context: Safari has blocked it for years and Chrome\n * is retiring it. That path works in testing on a Mac and fails for every user\n * on an iPhone — a failure that looks like \"you are not logged in\".\n */\nexport interface BeginLoginOptions {\n prompt?: \"none\" | \"login\" | \"consent\" | \"select_account\";\n /** Extra scopes for this one request, on top of the configured set. */\n scopes?: string[];\n}\n\nexport interface SsoClient {\n discovery(): Promise<Discovery>;\n beginLogin(options?: BeginLoginOptions): Promise<LoginStart>;\n completeLogin(input: {\n params: URLSearchParams;\n state: string;\n codeVerifier: string;\n nonce: string;\n }): Promise<LoginResult>;\n verifyIdToken(idToken: string, options?: { nonce?: string }): Promise<SsoClaims>;\n logoutUrl(options?: { idTokenHint?: string; postLogoutRedirectUri?: string }): Promise<string>;\n /**\n * Does this address belong to the signed-in user's BID account, and is it\n * verified there? Asks BID's POST /api/app/address-ownership with the user's\n * access token. Throws SsoError on anything that is not one of the three\n * answers — see the implementation for why that must never become \"unverified\".\n */\n addressOwnership(accessToken: string, address: string): Promise<AddressOwnership>;\n /**\n * Invite users to BID on behalf of this app (the app's OWN key, `bidk_…`).\n * One result per email. Throws SsoError on anything BID did not clearly say.\n */\n inviteUsers(appKey: string, input: InviteUsersInput): Promise<InvitationResult[]>;\n /**\n * Where each user is in the move to BID. Throws SsoError on any state it does\n * not know — never a silent \"ready\", which would switch off a user's old login\n * while they cannot get in.\n */\n migrationStatus(appKey: string, emails: string[]): Promise<MigrationStatus>;\n /** Exposed for tests and for a health check; not needed in normal use. */\n readonly jwks: JwksCache;\n}\n\nexport type AddressOwnership = \"verified\" | \"unverified\" | \"not_on_account\";\nconst ADDRESS_OWNERSHIP = new Set<string>([\"verified\", \"unverified\", \"not_on_account\"]);\n\nexport type InvitationOutcome =\n | \"invited\"\n | \"already_invited\"\n | \"existing\"\n | \"invalid\"\n | \"too_soon\"\n | \"too_many_today\"\n | \"send_failed\";\nconst INVITATION_OUTCOMES = new Set<string>([\n \"invited\", \"already_invited\", \"existing\", \"invalid\", \"too_soon\", \"too_many_today\", \"send_failed\",\n]);\nexport interface InviteUsersInput {\n customerName: string;\n appUrl: string;\n appName?: string;\n switchDate?: string;\n users: Array<{ email: string; name: string }>;\n}\nexport interface InvitationResult {\n email: string;\n outcome: InvitationOutcome;\n mailId?: string;\n problem?: string;\n}\n\nexport type MigrationState = \"ready\" | \"invited\" | \"expired\" | \"not_invited\";\nconst MIGRATION_STATES = new Set<string>([\"ready\", \"invited\", \"expired\", \"not_invited\"]);\nexport interface MigrationStatus {\n users: Array<{ email: string; state: MigrationState; expiresAt?: string }>;\n counts: Record<string, number>;\n complete: boolean;\n}\n\n/** BID's own limit per invitation call. */\nexport const MAX_INVITATIONS = 500;\n\nexport interface CreateSsoClientOptions {\n fetchImpl?: typeof fetch;\n /**\n * Ceiling for the app-API calls (addressOwnership, inviteUsers,\n * migrationStatus). A hanging BID must not hang the app. Default 10 000 ms.\n */\n timeoutMs?: number;\n /** Passed through to the key cache; see jwks.ts for why there is a floor. */\n minRefetchIntervalMs?: number;\n}\n\n/**\n * `invalid_client` is returned for two OPPOSITE mistakes, and the issuer cannot\n * tell you which — from outside they are the same three words.\n *\n * we sent a secret, it was refused → wrong secret, or we are registered\n * as PUBLIC and should send none\n * we sent none and it was demanded → we are registered as CONFIDENTIAL\n * and SSO_CLIENT_SECRET is unset\n *\n * The expensive version of this is the plugin storing the secret HASHED\n * (SHA-256 → base64url) and comparing hashes: write the plaintext through the\n * adapter and the row is written, reads back clean, and every token call is\n * refused forever. broberg-id paid for that one. We cannot see their storage\n * from here — but we CAN say what WE sent, which halves the search instead of\n * leaving a caller to guess which end to open.\n *\n * It states our SIDE, never the value. A secret must not reach a log line.\n */\n/**\n * A fragment of a foreign response body, safe to put in a log line (components-F084.52).\n *\n * Three things it has to survive, and each one has bitten somebody:\n *\n * · LENGTH — an HTML error page is kilobytes. A log line is not.\n * · NEWLINES — a multi-line excerpt breaks every log aggregator that treats a\n * line as a record, and the useful half is the part that scrolled away.\n * · OUR OWN SECRET — the body comes from a server we do not control, and some\n * servers echo the request back on an error. If our client_secret is in\n * there, we must not be the ones who write it to disk.\n */\nfunction excerptForLog(raw: string, clientSecret: string | undefined): string {\n let text = raw.replace(/\\s+/g, \" \").trim();\n if (clientSecret) text = text.split(clientSecret).join(\"[redacted client_secret]\");\n if (text === \"\") return \"(empty body)\";\n return text.length > 200 ? `${text.slice(0, 200)}…` : text;\n}\n\nfunction invalidClientHint(error: string | undefined, weSentASecret: boolean): string {\n if (error !== \"invalid_client\") return \"\";\n return weSentASecret\n ? \" — we DID send a client_secret, so either it is wrong or this client is registered as PUBLIC (in which case unset SSO_CLIENT_SECRET).\"\n : \" — we sent NO client_secret, so this client is likely registered as CONFIDENTIAL (set SSO_CLIENT_SECRET to the value it was registered with).\";\n}\n\n/**\n * The algorithms an ID token may be signed with. Asymmetric only, and stated\n * HERE rather than inferred from the token.\n *\n * ── WHY THIS LIST EXISTS WHEN NOTHING WAS EXPLOITABLE WITHOUT IT ──────────\n *\n * `alg` is read from the token's own protected header — that is, from whoever\n * sent it. Before this list, it was handed to importJWK and jwtVerify was\n * called with no restriction at all. Both classic forgeries were built and run\n * against this client in the security review of 2026-09-20:\n *\n * HS256, signed with the PUBLIC key as the HMAC secret → rejected\n * alg: none → rejected\n * control: a genuine RS256 token → accepted\n *\n * So the package was NOT exploitable. But read the rejection: both failed with\n * `JOSENotSupported: Invalid or unsupported JWK \"alg\"`, thrown inside jose's\n * key import because an RSA JWK cannot be imported as an HMAC key. The defence\n * was a dependency's internals, not ours — it would move on a minor upgrade, or\n * the day an issuer publishes a symmetric key, and NOTHING here would go red.\n *\n * A guard you cannot see is a guard you cannot keep. Now jwtVerify refuses\n * first, and there is a test that fails if this list is widened.\n */\nconst ALLOWED_ALGS = [\n // FIRST, because it is what Broberg ID actually signs with. Measured on the\n // live issuer 2026-09-20:\n // /jwks kid=Iodh… kty=OKP alg=EdDSA crv=Ed25519\n // discovery id_token_signing_alg_values_supported: [\"EdDSA\"]\n //\n // 0.2.1 SHIPPED THIS LIST WITHOUT IT and rejected every real token for an\n // hour. The list was written from what an OIDC client usually allows rather\n // than from what our issuer uses — and the whole suite missed it because the\n // fake issuer in the tests signs RS256. A fake agrees with whoever wrote it.\n // There is now a test that mints EdDSA, the algorithm production uses.\n \"EdDSA\",\n \"RS256\", \"RS384\", \"RS512\",\n \"PS256\", \"PS384\", \"PS512\",\n \"ES256\", \"ES384\", \"ES512\",\n];\n\nexport function createSsoClient(\n config: SsoConfig,\n options: CreateSsoClientOptions = {},\n): SsoClient {\n const fetchImpl = options.fetchImpl ?? fetch;\n const timeoutMs = options.timeoutMs ?? 10_000;\n\n /** POST to one of BID's app-API routes: bounded in time, JSON in and out, loud on failure. */\n async function appPost(path: string, bearer: string, payload: unknown, what: string): Promise<unknown> {\n const url = new URL(path, config.issuer).toString();\n const ctrl = new AbortController();\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timedOut = new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n ctrl.abort();\n reject(new SsoError(`${url} did not answer within ${timeoutMs} ms — ${what} is unknown.`));\n }, timeoutMs);\n });\n try {\n const res = await Promise.race([\n fetchImpl(url, {\n method: \"POST\",\n headers: { authorization: `Bearer ${bearer}`, \"content-type\": \"application/json\" },\n body: JSON.stringify(payload),\n signal: ctrl.signal,\n }),\n timedOut,\n ]);\n if (!res.ok) {\n const detail = await res.text().catch(() => \"\");\n const hint = detail.includes(\"invalid_app_key\") ? \" (invalid_app_key: this app's bidk_ key was refused)\" : \"\";\n throw new SsoError(`${url} answered ${res.status}${hint} — ${what} is unknown.`);\n }\n return await Promise.race([res.json().catch(() => null), timedOut]);\n } catch (e) {\n if (e instanceof SsoError) throw e;\n throw new SsoError(`${url} could not be reached: ${e instanceof Error ? e.message : String(e)} — ${what} is unknown.`);\n } finally {\n clearTimeout(timer);\n }\n }\n let discoveryPromise: Promise<Discovery> | null = null;\n let jwksCache: JwksCache | null = null;\n\n async function discovery(): Promise<Discovery> {\n discoveryPromise ??= (async () => {\n const url = `${config.issuer}/.well-known/openid-configuration`;\n const res = await fetchImpl(url);\n if (!res.ok) throw new SsoError(`${url} answered ${res.status}`);\n const doc = (await res.json()) as Discovery;\n\n // STRICT equality, and it is not pedantry. Better Auth's default basePath\n // advertised `<origin>/api/auth` as the issuer — measured on a running\n // BID during F084.1. If the document's issuer and our configured issuer\n // disagree, every token this app later verifies will be rejected for a\n // reason that reads as a signature problem. Refuse at boot instead.\n // THE CHECK THAT WOULD HAVE TURNED 0.2.1'S OUTAGE INTO A SENTENCE.\n //\n // An allow-list that omits the algorithm the issuer actually uses does\n // not fail loudly: every token is refused, one at a time, with a message\n // about that token. The app looks broken, the issuer looks broken, and\n // nothing names the real cause. That is what 0.2.1 did for an hour.\n //\n // The issuer already publishes the answer. If it advertises its signing\n // algorithms and NONE of them are ones we accept, that is knowable here —\n // once, at first use — instead of being rediscovered on every login.\n //\n // Deliberately narrow: it fires only on an EMPTY intersection. An issuer\n // that supports one algorithm we allow and three we do not is perfectly\n // workable, and refusing to start there would be a second outage dressed\n // as caution.\n const advertised = doc.id_token_signing_alg_values_supported;\n if (Array.isArray(advertised) && advertised.length > 0) {\n const usable = advertised.filter((a) => ALLOWED_ALGS.includes(a));\n if (usable.length === 0) {\n throw new SsoError(\n `${config.issuer} signs ID tokens with ${advertised.join(\", \")}, and this client ` +\n `accepts none of those (it allows ${ALLOWED_ALGS.join(\", \")}). Every token would be ` +\n `rejected. This is a mismatch between the package and the issuer — report it rather ` +\n `than working around it.`,\n );\n }\n }\n\n if (doc.issuer !== config.issuer) {\n throw new SsoError(\n `BID_ISSUER is ${config.issuer} but ${url} says its issuer is ${doc.issuer}. ` +\n `These must match exactly — tokens are validated against the issuer string.`,\n );\n }\n return doc;\n })().catch((err) => {\n discoveryPromise = null; // let a later call retry rather than cache a failure\n throw err;\n });\n return discoveryPromise;\n }\n\n async function keys(): Promise<JwksCache> {\n if (!jwksCache) {\n const { jwks_uri } = await discovery();\n jwksCache = createJwksCache({\n jwksUri: jwks_uri,\n fetchImpl,\n ...(options.minRefetchIntervalMs !== undefined\n ? { minRefetchIntervalMs: options.minRefetchIntervalMs }\n : {}),\n });\n }\n return jwksCache;\n }\n\n async function verifyIdToken(idToken: string, opts: { nonce?: string } = {}) {\n const cache = await keys();\n const header = decodeProtectedHeader(idToken);\n if (!header.kid) throw new SsoError(\"ID token has no kid — cannot pick a signing key\");\n\n const { payload } = await jwtVerify(\n idToken,\n async () => cache.getKey(header.kid!, header.alg ?? \"RS256\"),\n { issuer: config.issuer, audience: config.clientId, algorithms: ALLOWED_ALGS },\n );\n\n if (opts.nonce !== undefined && payload.nonce !== opts.nonce) {\n throw new SsoError(\n \"ID token nonce does not match this login request — refusing a token minted for another sign-in.\",\n );\n }\n if (typeof payload.sub !== \"string\" || payload.sub === \"\") {\n throw new SsoError(\"ID token has no sub — there is no user to be\");\n }\n return payload as SsoClaims;\n }\n\n /**\n * Fetch the profile claims and fold them in.\n *\n * NOT an optimisation — it is the only way to learn a user's name. MEASURED\n * against the live Broberg ID on 16 Sep 2026:\n *\n * ID token iss · sub · aud · iat · exp · auth_time · acr · at_hash\n * userinfo sub · name · given_name · family_name · email · email_verified\n *\n * That is correct OIDC for an authorization-code flow: profile claims belong\n * to the userinfo endpoint, and an ID token proving WHO you are does not have\n * to say what you are called. An app that only reads the ID token gets a\n * signed identity and a blank name — which is exactly what the first run of\n * the example app showed on screen.\n *\n * ── THE CHECK A NAIVE VERSION SKIPS ───────────────────────────────────────\n *\n * The `sub` from userinfo MUST equal the `sub` in the ID token (OIDC Core\n * 5.3.2 says MUST). Without it, a userinfo response for a DIFFERENT user\n * would be merged over a correctly verified identity — the app would show,\n * and act as, somebody else, with a valid signature underneath. A mismatch is\n * refused outright rather than reconciled.\n */\n async function withUserInfo(claims: SsoClaims, accessToken: string): Promise<SsoClaims> {\n const doc = await discovery();\n if (!doc.userinfo_endpoint) return claims;\n\n const res = await fetchImpl(doc.userinfo_endpoint, {\n headers: { authorization: `Bearer ${accessToken}` },\n });\n // A failure here must NOT lose the sign-in: the identity is already proven\n // by the ID token. The user ends up with a session and no display name,\n // which is worse than having one and far better than being logged out.\n if (!res.ok) return claims;\n\n const info = (await res.json()) as Record<string, unknown>;\n if (info.sub !== claims.sub) {\n throw new SsoError(\n `userinfo describes ${String(info.sub)} but the ID token is for ${claims.sub} — ` +\n `refusing to merge another user's profile onto this session.`,\n );\n }\n return { ...claims, ...info, sub: claims.sub };\n }\n\n return {\n discovery,\n get jwks() {\n if (!jwksCache) throw new SsoError(\"the key cache is created on first verification\");\n return jwksCache;\n },\n verifyIdToken,\n\n async beginLogin(opts: BeginLoginOptions = {}): Promise<LoginStart> {\n const { authorization_endpoint } = await discovery();\n const codeVerifier = randomToken();\n const state = randomToken(16);\n const nonce = randomToken(16);\n\n const params = new URLSearchParams({\n response_type: \"code\",\n client_id: config.clientId,\n redirect_uri: config.redirectUri,\n scope: [...new Set([...config.scopes, ...(opts.scopes ?? [])])].join(\" \"),\n state,\n nonce,\n code_challenge: await challengeFor(codeVerifier),\n code_challenge_method: \"S256\",\n });\n if (opts.prompt) params.set(\"prompt\", opts.prompt);\n\n return { url: `${authorization_endpoint}?${params}`, state, codeVerifier, nonce };\n },\n\n async completeLogin({ params, state, codeVerifier, nonce }) {\n // An ERROR comes back on the same redirect as a success — BID answers a\n // refused `prompt=none` by redirecting here with ?error=login_required.\n // Reading only for `code` would make a refusal look like a malformed\n // response instead of the expected answer it is.\n const error = params.get(\"error\");\n if (error) {\n throw new SsoError(\n `Broberg ID refused this login: ${error}` +\n (params.get(\"error_description\") ? ` — ${params.get(\"error_description\")}` : \"\"),\n );\n }\n\n const returned = params.get(\"state\");\n if (!returned || returned !== state) {\n throw new SsoError(\n \"state does not match the login this browser started — refusing the callback.\",\n );\n }\n\n const code = params.get(\"code\");\n if (!code) throw new SsoError(\"callback carried neither an error nor a code\");\n\n const { token_endpoint } = await discovery();\n const res = await fetchImpl(token_endpoint, {\n method: \"POST\",\n headers: { \"content-type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n grant_type: \"authorization_code\",\n code,\n redirect_uri: config.redirectUri,\n client_id: config.clientId,\n // ALWAYS sent, secret or not. See SsoConfig.clientSecret: the two\n // prove different things, so a confidential client keeps PKCE.\n code_verifier: codeVerifier,\n ...(config.clientSecret ? { client_secret: config.clientSecret } : {}),\n }),\n });\n\n // STATUS FIRST, PARSE SECOND — and the order IS the fix (components-F084.52).\n //\n // This used to be `await res.json()`, which runs BEFORE the `if (!res.ok)`\n // below. So when the issuer answered with something that is not JSON — an\n // empty 500 body, an HTML error page from a proxy, a gateway timeout — the\n // parse threw `SyntaxError: Unexpected end of JSON input` and the careful\n // message underneath was never reached. In the one case it exists for.\n //\n // The cost is not cosmetic. BID's own public guide (id.broberg.ai/docs §4b)\n // tells every consumer to wrap completeLogin in try/catch and redirect\n // instead of returning 500, so this string is ALL a consumer has: the user\n // sees a redirect, and the diagnosis lives only in what they logged. A line\n // reading \"Unexpected end of JSON input\" sends that reader into their own\n // code to look for a fault that is in the server.\n //\n // Reported by broberg-id, who measured production first: a real\n // `POST /oauth2/token` with a bad code answers 400 + JSON, so the good path\n // was never affected. This only fires against a broken or outdated issuer —\n // which is exactly where somebody is already debugging.\n //\n // NOT a try/catch around res.json(): that catches the throw and still loses\n // res.status, which is the one fact that says WHICH end is at fault.\n const raw = await res.text();\n let body: {\n id_token?: string;\n access_token?: string;\n refresh_token?: string;\n error?: string;\n error_description?: string;\n };\n try {\n body = raw.trim() === \"\" ? {} : (JSON.parse(raw) as typeof body);\n } catch {\n throw new SsoError(\n `token exchange failed (${res.status}): the response body is not JSON` +\n `${res.headers.get(\"content-type\") ? ` (content-type: ${res.headers.get(\"content-type\")})` : \"\"}` +\n ` — ${excerptForLog(raw, config.clientSecret)}`,\n );\n }\n // An EMPTY body parses to {} and falls through to the check below, which\n // reports the status and \"no id_token in response\" — right for a 500 with\n // no body, and right for a 200 that returned nothing.\n if (!res.ok || !body.id_token) {\n throw new SsoError(\n `token exchange failed (${res.status}): ${body.error ?? \"no id_token in response\"}` +\n (body.error_description ? ` — ${body.error_description}` : \"\") +\n invalidClientHint(body.error, config.clientSecret !== undefined),\n );\n }\n\n const claims = await verifyIdToken(body.id_token, { nonce });\n\n return {\n claims: body.access_token ? await withUserInfo(claims, body.access_token) : claims,\n idToken: body.id_token,\n ...(body.access_token ? { accessToken: body.access_token } : {}),\n ...(body.refresh_token ? { refreshToken: body.refresh_token } : {}),\n };\n },\n\n async logoutUrl(opts = {}) {\n const doc = await discovery();\n if (!doc.end_session_endpoint) {\n throw new SsoError(\n `${config.issuer} does not advertise end_session_endpoint — central logout is unavailable.`,\n );\n }\n const params = new URLSearchParams();\n if (opts.idTokenHint) params.set(\"id_token_hint\", opts.idTokenHint);\n const post = opts.postLogoutRedirectUri ?? config.postLogoutRedirectUri;\n if (post) params.set(\"post_logout_redirect_uri\", post);\n params.set(\"client_id\", config.clientId);\n return `${doc.end_session_endpoint}?${params}`;\n },\n\n async addressOwnership(accessToken, address) {\n // An app-API route on BID's origin, not an OIDC endpoint, so it is not in\n // discovery. Origin-relative: the issuer may carry a path.\n const body = (await appPost(\n \"/api/app/address-ownership\",\n accessToken,\n { address },\n 'address ownership (not \"unverified\")',\n )) as { status?: unknown } | null;\n // Anything but the three answers THROWS. Mapping a malformed or new status\n // to \"unverified\" would quietly downgrade a real user's verified address,\n // and nothing about that looks broken from the outside.\n const status = body?.status;\n if (typeof status !== \"string\" || !ADDRESS_OWNERSHIP.has(status)) {\n throw new SsoError(`address-ownership returned status ${JSON.stringify(status)} — expected verified, unverified or not_on_account.`);\n }\n return status as AddressOwnership;\n },\n\n async inviteUsers(appKey, input) {\n if (input.users.length > MAX_INVITATIONS) {\n throw new SsoError(`inviteUsers: ${input.users.length} users, BID accepts at most ${MAX_INVITATIONS} per call.`);\n }\n const body = (await appPost(\"/api/app/invitations\", appKey, input, \"the invitation outcome\")) as {\n results?: unknown;\n } | null;\n if (!Array.isArray(body?.results)) {\n throw new SsoError(`invitations returned no \"results\" array — the invitation outcome is unknown.`);\n }\n for (const r of body.results as Array<{ email?: unknown; outcome?: unknown }>) {\n if (typeof r?.outcome !== \"string\" || !INVITATION_OUTCOMES.has(r.outcome)) {\n throw new SsoError(`invitations returned outcome ${JSON.stringify(r?.outcome)} for ${String(r?.email)} — not one this client knows.`);\n }\n }\n return body.results as InvitationResult[];\n },\n\n async migrationStatus(appKey, emails) {\n const body = (await appPost(\"/api/app/migration-status\", appKey, { emails }, \"the migration state\")) as {\n users?: unknown;\n counts?: unknown;\n complete?: unknown;\n } | null;\n if (!Array.isArray(body?.users) || typeof body.complete !== \"boolean\") {\n throw new SsoError(`migration-status returned no \"users\" array or no \"complete\" flag — the migration state is unknown.`);\n }\n for (const u of body.users as Array<{ email?: unknown; state?: unknown }>) {\n if (typeof u?.state !== \"string\" || !MIGRATION_STATES.has(u.state)) {\n throw new SsoError(`migration-status returned state ${JSON.stringify(u?.state)} for ${String(u?.email)} — never read as \"ready\".`);\n }\n }\n return body as MigrationStatus;\n },\n };\n}\n","/**\n * The app's OWN session cookie — signed, not encrypted.\n *\n * Signed is the right choice and the distinction matters: the contents are not\n * secret (a user may read their own id and name), but they must not be\n * FORGEABLE. Encryption would hide a subject id the user already knows while\n * doing nothing extra about forgery, which is the actual risk.\n *\n * What goes in is deliberately small: who you are and when this stops being\n * true. Claims that can change — a name, a role, a picture — belong in the\n * app's own store keyed by `sub`, because a cookie is a cache nobody can\n * invalidate, and a stale role in a cookie is a permission that outlives its\n * revocation.\n */\n\nexport interface SessionPayload {\n /** The subject from Broberg ID. The one stable identifier. */\n sub: string;\n /** Unix seconds. Checked on every read. */\n exp: number;\n /** Optional convenience copies; never authorisation data. */\n email?: string;\n name?: string;\n /** A URL, never bytes. The picture lives wherever BID put it; this is a\n * reference, so a changed picture propagates without every app storing a\n * copy that then goes stale. */\n picture?: string;\n}\n\nconst enc = new TextEncoder();\n\nconst b64url = (bytes: Uint8Array) => {\n let s = \"\";\n for (const b of bytes) s += String.fromCharCode(b);\n return btoa(s).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n};\n\nconst fromB64url = (s: string) => {\n const pad = s.replace(/-/g, \"+\").replace(/_/g, \"/\");\n const bin = atob(pad + \"=\".repeat((4 - (pad.length % 4)) % 4));\n return Uint8Array.from(bin, (c) => c.charCodeAt(0));\n};\n\nasync function hmacKey(secret: string) {\n return crypto.subtle.importKey(\"raw\", enc.encode(secret), { name: \"HMAC\", hash: \"SHA-256\" }, false, [\n \"sign\",\n \"verify\",\n ]);\n}\n\n/**\n * Sign an arbitrary string. The primitive underneath BOTH cookies this package\n * sets — the session and the short-lived login transaction.\n *\n * They are separate functions on purpose. The first version of this file had\n * the transaction ride inside the session envelope with `exp: 0`, and the\n * expiry check (`exp * 1000 <= now()`) then rejected it every single time: the\n * transaction cookie could never be read back, so every login would have failed\n * with \"state does not match\" — a message pointing at the wrong thing entirely.\n * Two different lifetimes wanted two different envelopes, not one envelope with\n * a sentinel in it.\n */\nexport async function signValue(\n value: string,\n secret: string,\n // `now` is injectable for the same reason verifySession's is: an age check\n // tested by waiting is a slow test that fails on a loaded machine.\n options: { maxAgeSeconds?: number; now?: () => number } = {},\n): Promise<string> {\n const payload = b64url(enc.encode(value));\n // The timestamp goes INSIDE the signed body, never beside it. An expiry the\n // holder can edit is not a limit. `~` is outside the base64url alphabet, so\n // `t<digits>~` cannot be confused with a payload that merely starts with \"t\".\n const body =\n options.maxAgeSeconds === undefined\n ? payload\n : `t${Math.floor((options.now ?? Date.now)() / 1000)}~${payload}`;\n const sig = new Uint8Array(await crypto.subtle.sign(\"HMAC\", await hmacKey(secret), enc.encode(body)));\n return `${body}.${b64url(sig)}`;\n}\n\nconst STAMPED = /^t(\\d+)~(.*)$/s;\n\n/**\n * Verify the signature, and — only if you ask — the age (components-F084.53).\n *\n * THE PREFIX ON THAT NUMBER IS NOT DECORATION. F-numbers are per-project, and\n * this comment ships in the .d.ts — so a consumer reading it in their editor\n * resolves the number against whatever board THEY know. broberg-id found this\n * by reading their own F084.53, which is \"no limit on password guesses\": a\n * different feature, in a different repo, behind the same string.\n *\n * WITHOUT `maxAgeSeconds` this is signature-only, exactly as before. That is\n * not laziness: the login-transaction cookie has lived on its browser `Max-Age`\n * since the package shipped, and making the check mandatory would invalidate\n * every cookie already in a user's browser — an instant logout for everyone.\n *\n * WITH `maxAgeSeconds`, a value the SERVER can date is refused once it is too\n * old. Reported by broberg-id: `Max-Age` is the client's claim about when a\n * cookie stopped being valid, and a client may simply not make that claim, so\n * the server used to accept a correctly-signed transaction value forever.\n *\n * THE MIXED CASE IS THE ONE THAT DECIDES WHETHER THIS IS WORTH ANYTHING: a\n * value signed by the OLD code (no timestamp) verified by the NEW code (with a\n * limit). That happens during every rollout, and it FAILS CLOSED — otherwise an\n * undated value would be the way around the very limit being added, and the\n * limit would only apply to those not trying to avoid it.\n */\nexport async function verifyValue(\n token: string | undefined | null,\n secret: string,\n options: { maxAgeSeconds?: number; now?: () => number } = {},\n): Promise<string | null> {\n if (!token) return null;\n const dot = token.lastIndexOf(\".\");\n if (dot <= 0) return null;\n const body = token.slice(0, dot);\n try {\n const ok = await crypto.subtle.verify(\n \"HMAC\",\n await hmacKey(secret),\n fromB64url(token.slice(dot + 1)),\n enc.encode(body),\n );\n if (!ok) return null;\n\n const stamped = STAMPED.exec(body);\n if (options.maxAgeSeconds !== undefined) {\n if (!stamped) return null; // undated + a limit asked for → fail CLOSED\n const issuedAt = Number(stamped[1]);\n if ((options.now ?? Date.now)() / 1000 - issuedAt > options.maxAgeSeconds) return null;\n }\n return new TextDecoder().decode(fromB64url(stamped?.[2] ?? body));\n } catch {\n return null;\n }\n}\n\nexport async function signSession(payload: SessionPayload, secret: string): Promise<string> {\n return signValue(JSON.stringify(payload), secret);\n}\n\n/**\n * Returns null for ANY reason the cookie cannot be trusted — tampered,\n * truncated, wrong secret, expired, or simply not one of ours.\n *\n * Deliberately one return value rather than distinguishing them to the caller:\n * an app that can tell \"bad signature\" from \"expired\" will eventually branch on\n * it, and there is no branch where a forged cookie should do anything other\n * than what an absent one does.\n */\nexport async function verifySession(\n token: string | undefined | null,\n secret: string,\n now: () => number = Date.now,\n): Promise<SessionPayload | null> {\n // crypto.subtle.verify is constant-time; a hand-rolled string compare would\n // leak the signature one byte at a time.\n const body = await verifyValue(token, secret);\n if (body === null) return null;\n\n try {\n const payload = JSON.parse(body) as SessionPayload;\n if (typeof payload.sub !== \"string\" || payload.sub === \"\") return null;\n if (typeof payload.exp !== \"number\" || payload.exp * 1000 <= now()) return null;\n return payload;\n } catch {\n return null;\n }\n}\n\n/** Serialise a Set-Cookie value. `secure` is off only for http://localhost. */\nexport function cookieHeader(\n name: string,\n value: string,\n opts: { maxAge: number; secure: boolean; sameSite?: \"Lax\" | \"Strict\"; path?: string },\n): string {\n const parts = [\n `${name}=${value}`,\n `Path=${opts.path ?? \"/\"}`,\n `Max-Age=${opts.maxAge}`,\n \"HttpOnly\",\n // Lax, NOT Strict, and this is load-bearing: the callback from Broberg ID\n // is a top-level GET navigation from another site. Strict withholds the\n // cookie on exactly that navigation, so the login transaction cookie would\n // be missing when it is needed and every sign-in would fail with \"state\n // does not match\" — a message that sends you looking in the wrong place.\n `SameSite=${opts.sameSite ?? \"Lax\"}`,\n ];\n if (opts.secure) parts.push(\"Secure\");\n return parts.join(\"; \");\n}\n\nexport function readCookie(header: string | null | undefined, name: string): string | undefined {\n if (!header) return undefined;\n for (const part of header.split(\";\")) {\n const eq = part.indexOf(\"=\");\n if (eq === -1) continue;\n if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();\n }\n return undefined;\n}\n","/**\n * The Hono adapter — mounting, and nothing else.\n *\n * Every rule about what a valid session IS lives in the core (client.ts,\n * session.ts). This file only turns those into routes. F084.5 adds a Next.js\n * adapter on the SAME core, because a rule implemented twice is a rule that\n * eventually says two things.\n */\nimport { Hono, type Context, type MiddlewareHandler } from \"hono\";\nimport { loadSsoConfig, type SsoConfig } from \"./config.js\";\nimport { createSsoClient, type SsoClient } from \"./client.js\";\nimport {\n cookieHeader,\n readCookie,\n signSession,\n signValue,\n verifySession,\n verifyValue,\n type SessionPayload,\n} from \"./session.js\";\n\n/** Where the adapter parks the session on the request, for `getSession`. */\nconst SESSION_KEY = \"bidSession\";\n\n/**\n * How long the SERVER will accept an in-flight login. Five minutes is plenty\n * for a person to type a password; longer just widens the window. This is the\n * real limit: it is enforced in `parseTransaction`, on a timestamp inside the\n * signature, so no client can talk its way past it.\n */\nconst TRANSACTION_MAX_AGE = 300;\n\n/**\n * How long the BROWSER keeps the cookie — deliberately LONGER than the window\n * above, and the two numbers must never be collapsed back into one.\n *\n * A cookie the browser has already dropped NEVER ARRIVES. With one number for\n * both, the moment the window passes there is simply nothing in the request,\n * and the server cannot tell \"she took too long\" from \"she never started a\n * login here\". Those are different things that deserve different answers, and\n * collapsing them cost a diagnosis: this adapter used to answer every failed\n * callback with one message containing the word \"or\".\n *\n * Reported by helpdesk (2026-09-22, components-F084.54), who run 3x in\n * production for exactly this reason. The extra time is INERT — `parseTransaction`\n * refuses anything past TRANSACTION_MAX_AGE and /callback clears the cookie, so\n * a verifier that cannot be exchanged is not a key. It buys diagnosis, not\n * lifetime.\n */\nconst TRANSACTION_COOKIE_MAX_AGE = TRANSACTION_MAX_AGE * 3;\n\nexport interface SsoRoutesOptions {\n config?: SsoConfig;\n client?: SsoClient;\n /** Path prefix these routes are mounted under. Used to build the return URL. */\n loginPath?: string;\n /** Where to send a user after a successful login when no returnTo is given. */\n defaultReturnTo?: string;\n}\n\nfunction isSecure(c: Context): boolean {\n // localhost over http is the one case where a Secure cookie would simply\n // never be stored, making local development impossible for a reason no error\n // would explain.\n return new URL(c.req.url).protocol === \"https:\";\n}\n\n/**\n * Only same-origin, path-only return targets are honoured.\n *\n * Without this, `/auth/login?returnTo=https://evil.example` turns this app's\n * own login into an open redirect that arrives wearing our domain — the\n * classic phishing lever, and it is one line to close.\n */\n/**\n * An origin nothing can resolve to. Only used as a yardstick: if a candidate\n * still sits on THIS origin after parsing, it is same-origin wherever the app\n * actually lives.\n */\nconst YARDSTICK = \"https://sso.invalid\";\n\n/**\n * Only same-origin, path-only return targets are honoured.\n *\n * ── WHY THIS ASKS A PARSER INSTEAD OF INSPECTING CHARACTERS ───────────────\n *\n * The first version of this function rejected a leading `//` and required a\n * leading `/`. That is the obvious rule, it reads as correct, and it was\n * EXPLOITABLE — found in the first full security review of this package\n * (2026-09-20), not by any diff review, because this code never appeared in a\n * diff:\n *\n * returnTo=/\\evil.dk → passed the guard\n * → resolves to https://evil.dk/ in a browser\n *\n * A backslash is not a path separator to RFC 3986 and IS one to the WHATWG URL\n * spec, which is what browsers implement. So the string looked like a path to\n * us and was an authority to the thing that acts on it.\n *\n * AND THE OBVIOUS FIX WAS ALSO LEAKY. Parsing once and comparing origins closes\n * the backslash and still lets `/..//evil.dk` through: `..` NORMALISES the path\n * to `//evil.dk`, which is protocol-relative when it is later used as a\n * Location. So the result is re-checked — a fixpoint — rather than trusted\n * because the input parsed cleanly.\n *\n * The rule is therefore: let the SAME parser the browser uses decide, twice.\n * A list of dangerous characters is always one trick behind; the first version\n * knew `//` and not `\\`, and the second knew `\\` and not `..`.\n */\nfunction safeReturnTo(raw: string | undefined, fallback: string): string {\n if (!raw || !raw.startsWith(\"/\")) return fallback;\n\n let once: URL;\n try {\n once = new URL(raw, YARDSTICK);\n } catch {\n return fallback;\n }\n if (once.origin !== YARDSTICK) return fallback;\n\n const normalised = once.pathname + once.search + once.hash;\n let twice: URL;\n try {\n twice = new URL(normalised, YARDSTICK);\n } catch {\n return fallback;\n }\n if (twice.origin !== YARDSTICK) return fallback;\n // Belt and braces: a normalised path must still be a path.\n if (!normalised.startsWith(\"/\") || normalised.startsWith(\"//\")) return fallback;\n\n return normalised;\n}\n\nexport function ssoRoutes(options: SsoRoutesOptions = {}) {\n const config = options.config ?? loadSsoConfig();\n const client = options.client ?? createSsoClient(config);\n const loginPath = options.loginPath ?? \"/auth\";\n const defaultReturnTo = options.defaultReturnTo ?? \"/\";\n const txCookie = `${config.cookieName}_tx`;\n /**\n * The ID token, kept ONLY so logout can prove who is leaving.\n *\n * Its own cookie rather than a field on SessionPayload, for three reasons:\n * that type is public and deliberately small (session.ts says why), a JWT is\n * ~1 kB that would otherwise ride along on every single request to the app,\n * and the two are cleared at different moments.\n */\n const idTokenCookie = `${config.cookieName}_idt`;\n\n const app = new Hono();\n\n app.get(\"/login\", async (c) => {\n const prompt = c.req.query(\"prompt\");\n const start = await client.beginLogin(\n prompt === \"none\" ? { prompt: \"none\" } : {},\n );\n const tx = JSON.stringify({\n state: start.state,\n codeVerifier: start.codeVerifier,\n nonce: start.nonce,\n returnTo: safeReturnTo(c.req.query(\"returnTo\"), defaultReturnTo),\n });\n c.header(\n \"Set-Cookie\",\n // The signed stamp carries the SERVER's window; the cookie carries the\n // longer browser one, so an expired transaction still reaches us and can\n // be named instead of vanishing.\n cookieHeader(txCookie, await signValue(tx, config.cookieSecret, { maxAgeSeconds: TRANSACTION_MAX_AGE }), {\n maxAge: TRANSACTION_COOKIE_MAX_AGE,\n secure: isSecure(c),\n }),\n );\n // A FULL top-level redirect. Never a hidden iframe — see client.ts.\n return c.redirect(start.url, 302);\n });\n\n app.get(\"/callback\", async (c) => {\n const parsed = await parseTransaction(\n readCookie(c.req.header(\"cookie\"), txCookie),\n config.cookieSecret,\n );\n if (!parsed.ok) {\n /**\n * THREE CAUSES, THREE ANSWERS (components-F084.54).\n *\n * THE ORDER IN `parseTransaction` IS LOAD-BEARING AND NOT OBVIOUS:\n * `expired` is reached ONLY AFTER the signature has verified. So\n * `login_expired` is a POSITIVE CONFIRMATION that the HMAC held against\n * the CURRENT secret, and `bad_login_cookie` is the denial of that.\n *\n * WHICH MAKES THE PAIR A ONE-BIT ORACLE, and the justification that stood\n * here was wrong. It said an attacker can produce all three himself, so\n * naming them tells him nothing. That holds only for an attacker who\n * CONSTRUCTED the input and therefore knows what he is holding. The\n * interesting one has a value he FOUND — in a log, on a shared machine, in\n * a referrer leak, in a backup — and he cannot mint a\n * validly-signed-but-expired value himself. For him the answer separates\n * two things he could not otherwise learn: whether a found value is still\n * live, when the secret rotates (the same value changes code the second it\n * does), and whether two environments share a secret.\n *\n * Reported by helpdesk, 2026-09-22, measured against the published 0.3.0\n * dist rather than argued. It is NOT a hole: it does not help him forge\n * anything — one bit per attempt against an HMAC-SHA256 key he never\n * reaches. It is a property the server gives away for free.\n *\n * KEPT AS IS FOR NOW, deliberately and not by omission: the three answers\n * solve a real problem for a consumer who owns only the browser end and\n * must show \"your login expired, try again\" rather than \"something went\n * wrong\". Splitting the two audiences — all three to the app and the log,\n * one to the browser by default — is components-F084.55 and is the owner's\n * call, not a thing to change twice in an hour under a peer's argument.\n *\n * The operator half stands unchanged and is the reason the codes exist at\n * all: `bad_login_cookie` on a REAL user's browser is what a rotated\n * SSO_COOKIE_SECRET looks like from the outside.\n */\n const failures = {\n absent: {\n status: 400 as const,\n error: \"no_login_in_progress\",\n message: \"This browser did not start a login here. Begin again from the login page.\",\n },\n expired: {\n status: 400 as const,\n error: \"login_expired\",\n message: `This login took longer than ${TRANSACTION_MAX_AGE} seconds. Begin again from the login page.`,\n },\n unreadable: {\n status: 400 as const,\n error: \"bad_login_cookie\",\n message: \"This browser's login cookie could not be read. Begin again from the login page.\",\n },\n };\n const { status, ...body } = failures[parsed.reason];\n // Clear it either way: a transaction we refuse must not sit in the browser\n // waiting to be refused again on every retry.\n c.header(\"Set-Cookie\", cookieHeader(txCookie, \"\", { maxAge: 0, secure: isSecure(c) }));\n return c.json(body, status);\n }\n\n const result = await client.completeLogin({\n params: new URL(c.req.url).searchParams,\n state: parsed.tx.state,\n codeVerifier: parsed.tx.codeVerifier,\n nonce: parsed.tx.nonce,\n });\n\n const exp = Math.floor(Date.now() / 1000) + config.sessionMaxAge;\n const session: SessionPayload = {\n sub: result.claims.sub,\n exp,\n ...(result.claims.email ? { email: result.claims.email } : {}),\n ...(result.claims.name ? { name: result.claims.name } : {}),\n ...(result.claims.picture ? { picture: result.claims.picture } : {}),\n };\n\n c.header(\n \"Set-Cookie\",\n cookieHeader(config.cookieName, await signSession(session, config.cookieSecret), {\n maxAge: config.sessionMaxAge,\n secure: isSecure(c),\n }),\n );\n // The logout hint. Without it the issuer MUST ask the user to confirm —\n // that is RP-initiated logout per spec, and the plugin says so itself\n // (\"User confirmation is required to complete logout\"). The result was an\n // unstyled English confirmation page with a bare browser button in the\n // middle of our own product.\n c.header(\n \"Set-Cookie\",\n cookieHeader(idTokenCookie, await signValue(result.idToken, config.cookieSecret), {\n maxAge: config.sessionMaxAge,\n secure: isSecure(c),\n }),\n { append: true },\n );\n // Clear the transaction cookie: it has done its job, and a replayed one is\n // only ever useful to someone who should not have it.\n c.header(\n \"Set-Cookie\",\n cookieHeader(txCookie, \"\", { maxAge: 0, secure: isSecure(c) }),\n { append: true },\n );\n return c.redirect(parsed.tx.returnTo, 302);\n });\n\n app.get(\"/logout\", async (c) => {\n // Read the hint BEFORE clearing, obviously — but note what happens when it\n // is absent: a session minted by 0.1.0 has no such cookie, and that session\n // is still live in every app that upgrades. So a missing hint falls back to\n // the old behaviour (the issuer asks) rather than throwing. The upgrade path\n // is the one that fails in production; it gets its own test.\n const idTokenHint = await verifyValue(\n readCookie(c.req.header(\"cookie\"), idTokenCookie),\n config.cookieSecret,\n );\n\n c.header(\n \"Set-Cookie\",\n cookieHeader(config.cookieName, \"\", { maxAge: 0, secure: isSecure(c) }),\n );\n c.header(\n \"Set-Cookie\",\n cookieHeader(idTokenCookie, \"\", { maxAge: 0, secure: isSecure(c) }),\n { append: true },\n );\n // Local cookies cleared FIRST, then central logout. If the redirect to BID\n // fails or the user closes the tab, the worst case is \"signed out here but\n // not everywhere\" — never the reverse, which would leave this app trusting\n // a session the user believes is gone.\n return c.redirect(await client.logoutUrl(idTokenHint ? { idTokenHint } : {}), 302);\n });\n\n /** Reads the session and puts it on the context. Never blocks. */\n const attach: MiddlewareHandler = async (c, next) => {\n const cookie = readCookie(c.req.header(\"cookie\"), config.cookieName);\n c.set(SESSION_KEY, await verifySession(cookie, config.cookieSecret));\n await next();\n };\n\n /** Blocks and redirects to login, preserving where the user was going. */\n const require: MiddlewareHandler = async (c, next) => {\n const cookie = readCookie(c.req.header(\"cookie\"), config.cookieName);\n const session = await verifySession(cookie, config.cookieSecret);\n if (!session) {\n const url = new URL(c.req.url);\n return c.redirect(\n `${loginPath}/login?returnTo=${encodeURIComponent(url.pathname + url.search)}`,\n 302,\n );\n }\n c.set(SESSION_KEY, session);\n await next();\n };\n\n return { app, attach, require, client, config };\n}\n\n/** Why a login transaction could not be read back. Three states, three answers. */\nexport type TransactionFailure = \"absent\" | \"expired\" | \"unreadable\";\n\ntype TransactionResult =\n | { ok: true; tx: { state: string; codeVerifier: string; nonce: string; returnTo: string } }\n | { ok: false; reason: TransactionFailure };\n\n/**\n * Reads the login transaction back out of its signed cookie.\n *\n * RETURNS A REASON, NOT `null` (components-F084.54). It used to collapse three\n * causes into one empty answer, and /callback then told the user \"you did not\n * start a login here, OR it expired\" — a sentence that admits in its own wording\n * that it does not know. The word \"or\" was the bug.\n *\n * Age-checked against TRANSACTION_MAX_AGE, not only against the cookie's own\n * Max-Age (components-F084.53) — Max-Age is the browser's promise, and a client\n * can decline to make it. It still does NOT go through verifySession: that\n * envelope carries an `exp`, and the first version of this file used `exp: 0`,\n * which the expiry check rejected every time.\n *\n * NO FUTURE-STAMP GUARD, and that is a deliberate divergence from helpdesk's\n * implementation, which rejects a negative age so a runaway clock cannot widen\n * the window. Ours cannot be widened that way: the stamp is inside the HMAC, so\n * only OUR OWN clock could produce a future one — and refusing it would mean a\n * second app instance whose clock is a few seconds behind starts rejecting\n * perfectly good logins. That trades a theoretical gain for a real outage.\n */\nasync function parseTransaction(raw: string | undefined, secret: string): Promise<TransactionResult> {\n if (raw === undefined) return { ok: false, reason: \"absent\" };\n\n // Signature FIRST, without the age limit, so the two questions stay separate.\n // Asking them together is what produced one answer for three causes: a single\n // `verifyValue(..., { maxAgeSeconds })` returns null for a forged cookie and\n // for an honest late one alike.\n const signed = await verifyValue(raw, secret);\n if (signed === null) return { ok: false, reason: \"unreadable\" };\n\n const fresh = await verifyValue(raw, secret, { maxAgeSeconds: TRANSACTION_MAX_AGE });\n if (fresh === null) return { ok: false, reason: \"expired\" };\n\n try {\n const tx = JSON.parse(fresh) as {\n state: string;\n codeVerifier: string;\n nonce: string;\n returnTo: string;\n };\n // A correctly signed cookie whose contents are not a transaction is not an\n // expiry and not an absence — it is ours and it is wrong.\n if (!tx.state || !tx.codeVerifier || !tx.nonce) return { ok: false, reason: \"unreadable\" };\n return { ok: true, tx };\n } catch {\n return { ok: false, reason: \"unreadable\" };\n }\n}\n\n/** Read the session a middleware attached. */\nexport function getSession(c: Context): SessionPayload | null {\n return (c.get(SESSION_KEY) as SessionPayload | null) ?? null;\n}\n"]}