@broberg/sso 0.4.0 → 0.6.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,67 @@ 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 })`.
285
+
286
+ ## «Log ud overalt» — receiving it from BID (since 0.6.0)
287
+
288
+ When a user presses **«Log ud overalt»** in Broberg ID, BID tells every app it has registered for that: an OIDC Back-Channel Logout token to `POST <mount>/backchannel-logout`. The ordinary «Log ud» in BID does **not** reach your app (owner decision D-376ffa), and the package refuses a token carrying `sid` to keep it that way.
289
+
290
+ ```ts
291
+ const sso = ssoRoutes({
292
+ backchannel: {
293
+ store, // REQUIRED — shared by every instance of the app
294
+ onStoreError: "reject", // default: if the store is down, treat the user as logged out
295
+ },
296
+ });
297
+ app.route("/auth", sso.app); // → POST /auth/backchannel-logout; give BID that exact URL
298
+ ```
299
+
300
+ **The store is yours, and it must be shared.** Your app runs on more than one machine; a list kept in memory would log the user out on one and not the other. There is no in-memory default — `ssoRoutes` throws at startup without a store. A table in the database you already have is enough:
301
+
302
+ ```ts
303
+ // CREATE TABLE sso_logout (sub TEXT PRIMARY KEY, before INTEGER NOT NULL);
304
+ // CREATE TABLE sso_logout_jti (jti TEXT PRIMARY KEY, until INTEGER NOT NULL);
305
+ const store: BackchannelStore = {
306
+ async revokeSubBefore(sub, iat) {
307
+ await db.execute({ sql: `INSERT INTO sso_logout (sub, before) VALUES (?, ?)
308
+ ON CONFLICT(sub) DO UPDATE SET before = MAX(before, excluded.before)`, args: [sub, iat] });
309
+ },
310
+ async revokedBefore(sub) {
311
+ const r = await db.execute({ sql: "SELECT before FROM sso_logout WHERE sub = ?", args: [sub] });
312
+ return r.rows[0] ? Number(r.rows[0].before) : null;
313
+ },
314
+ async useJti(jti, until) {
315
+ const r = await db.execute({ sql: "INSERT OR IGNORE INTO sso_logout_jti (jti, until) VALUES (?, ?)", args: [jti, until] });
316
+ return r.rowsAffected === 1; // false = a replay: answered 200, no new effect
317
+ },
318
+ };
319
+ ```
320
+
321
+ What happens: a verified token → `revokeSubBefore(sub, iat)` → from then on `attach`/`require` look up the user and refuse every session minted at or before that moment (sessions from before 0.6.0 carry no timestamp and count as older). A session started after the logout is untouched. Invalid tokens → 400; store down while receiving → 503, so BID can tell the user this app did not log out.
322
+
323
+ **Nothing arrives until BID has registered your URL** — mounting it early is safe (ship dark). Tell broberg-id the exact URL once it is deployed.
324
+
325
+ **The cost:** one store lookup per request that goes through `attach`/`require`.
package/dist/hono.cjs CHANGED
@@ -187,6 +187,18 @@ 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 BACKCHANNEL_EVENT = "http://schemas.openid.net/event/backchannel-logout";
201
+ var MAX_INVITATIONS = 500;
190
202
  function excerptForLog(raw, clientSecret) {
191
203
  let text = raw.replace(/\s+/g, " ").trim();
192
204
  if (clientSecret) text = text.split(clientSecret).join("[redacted client_secret]");
@@ -221,6 +233,40 @@ var ALLOWED_ALGS = [
221
233
  ];
222
234
  function createSsoClient(config, options = {}) {
223
235
  const fetchImpl = options.fetchImpl ?? fetch;
236
+ const timeoutMs = options.timeoutMs ?? 1e4;
237
+ async function appPost(path, bearer, payload, what) {
238
+ const url = new URL(path, config.issuer).toString();
239
+ const ctrl = new AbortController();
240
+ let timer;
241
+ const timedOut = new Promise((_, reject) => {
242
+ timer = setTimeout(() => {
243
+ ctrl.abort();
244
+ reject(new SsoError(`${url} did not answer within ${timeoutMs} ms \u2014 ${what} is unknown.`));
245
+ }, timeoutMs);
246
+ });
247
+ try {
248
+ const res = await Promise.race([
249
+ fetchImpl(url, {
250
+ method: "POST",
251
+ headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
252
+ body: JSON.stringify(payload),
253
+ signal: ctrl.signal
254
+ }),
255
+ timedOut
256
+ ]);
257
+ if (!res.ok) {
258
+ const detail = await res.text().catch(() => "");
259
+ const hint = detail.includes("invalid_app_key") ? " (invalid_app_key: this app's bidk_ key was refused)" : "";
260
+ throw new SsoError(`${url} answered ${res.status}${hint} \u2014 ${what} is unknown.`);
261
+ }
262
+ return await Promise.race([res.json().catch(() => null), timedOut]);
263
+ } catch (e) {
264
+ if (e instanceof SsoError) throw e;
265
+ throw new SsoError(`${url} could not be reached: ${e instanceof Error ? e.message : String(e)} \u2014 ${what} is unknown.`);
266
+ } finally {
267
+ clearTimeout(timer);
268
+ }
269
+ }
224
270
  let discoveryPromise = null;
225
271
  let jwksCache = null;
226
272
  async function discovery() {
@@ -261,6 +307,38 @@ function createSsoClient(config, options = {}) {
261
307
  }
262
308
  return jwksCache;
263
309
  }
310
+ async function verifyLogoutToken(token) {
311
+ const cache = await keys();
312
+ let header;
313
+ try {
314
+ header = jose.decodeProtectedHeader(token);
315
+ } catch {
316
+ throw new SsoError("logout_token is not a JWS");
317
+ }
318
+ if (!header.kid) throw new SsoError("logout_token has no kid \u2014 cannot pick a signing key");
319
+ let payload;
320
+ try {
321
+ ({ payload } = await jose.jwtVerify(token, async () => cache.getKey(header.kid, header.alg ?? "RS256"), {
322
+ issuer: config.issuer,
323
+ audience: config.clientId,
324
+ algorithms: ALLOWED_ALGS,
325
+ typ: "logout+jwt",
326
+ requiredClaims: ["iat", "exp", "jti"]
327
+ }));
328
+ } catch (e) {
329
+ throw new SsoError(`logout_token rejected: ${e instanceof Error ? e.message : String(e)}`);
330
+ }
331
+ const events = payload.events;
332
+ if (!events || typeof events !== "object" || !(BACKCHANNEL_EVENT in events)) {
333
+ throw new SsoError("logout_token has no back-channel logout event");
334
+ }
335
+ if ("nonce" in payload) throw new SsoError("logout_token carries a nonce \u2014 that is an ID token, not a logout token");
336
+ if ("sid" in payload) {
337
+ throw new SsoError("logout_token carries sid \u2014 only sub-wide \xABLog ud overalt\xBB is accepted (D-376ffa)");
338
+ }
339
+ if (typeof payload.sub !== "string" || payload.sub === "") throw new SsoError("logout_token has no sub");
340
+ return { sub: payload.sub, iat: payload.iat, jti: String(payload.jti), exp: payload.exp };
341
+ }
264
342
  async function verifyIdToken(idToken, opts = {}) {
265
343
  const cache = await keys();
266
344
  const header = jose.decodeProtectedHeader(idToken);
@@ -387,21 +465,45 @@ function createSsoClient(config, options = {}) {
387
465
  return `${doc.end_session_endpoint}?${params}`;
388
466
  },
389
467
  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);
468
+ const body = await appPost(
469
+ "/api/app/address-ownership",
470
+ accessToken,
471
+ { address },
472
+ 'address ownership (not "unverified")'
473
+ );
400
474
  const status = body?.status;
401
475
  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.`);
476
+ throw new SsoError(`address-ownership returned status ${JSON.stringify(status)} \u2014 expected verified, unverified or not_on_account.`);
403
477
  }
404
478
  return status;
479
+ },
480
+ verifyLogoutToken,
481
+ async inviteUsers(appKey, input) {
482
+ if (input.users.length > MAX_INVITATIONS) {
483
+ throw new SsoError(`inviteUsers: ${input.users.length} users, BID accepts at most ${MAX_INVITATIONS} per call.`);
484
+ }
485
+ const body = await appPost("/api/app/invitations", appKey, input, "the invitation outcome");
486
+ if (!Array.isArray(body?.results)) {
487
+ throw new SsoError(`invitations returned no "results" array \u2014 the invitation outcome is unknown.`);
488
+ }
489
+ for (const r of body.results) {
490
+ if (typeof r?.outcome !== "string" || !INVITATION_OUTCOMES.has(r.outcome)) {
491
+ throw new SsoError(`invitations returned outcome ${JSON.stringify(r?.outcome)} for ${String(r?.email)} \u2014 not one this client knows.`);
492
+ }
493
+ }
494
+ return body.results;
495
+ },
496
+ async migrationStatus(appKey, emails) {
497
+ const body = await appPost("/api/app/migration-status", appKey, { emails }, "the migration state");
498
+ if (!Array.isArray(body?.users) || typeof body.complete !== "boolean") {
499
+ throw new SsoError(`migration-status returned no "users" array or no "complete" flag \u2014 the migration state is unknown.`);
500
+ }
501
+ for (const u of body.users) {
502
+ if (typeof u?.state !== "string" || !MIGRATION_STATES.has(u.state)) {
503
+ throw new SsoError(`migration-status returned state ${JSON.stringify(u?.state)} for ${String(u?.email)} \u2014 never read as "ready".`);
504
+ }
505
+ }
506
+ return body;
405
507
  }
406
508
  };
407
509
  }
@@ -531,7 +633,51 @@ function ssoRoutes(options = {}) {
531
633
  const defaultReturnTo = options.defaultReturnTo ?? "/";
532
634
  const txCookie = `${config.cookieName}_tx`;
533
635
  const idTokenCookie = `${config.cookieName}_idt`;
636
+ const backchannel = options.backchannel;
637
+ if (backchannel && !backchannel.store) {
638
+ throw new Error("ssoRoutes: backchannel needs a store shared by every instance of the app \u2014 there is no in-memory default.");
639
+ }
640
+ const onStoreError = backchannel?.onStoreError ?? "reject";
641
+ async function liveSession(c) {
642
+ const session = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
643
+ if (!session || !backchannel) return session;
644
+ try {
645
+ const before = await backchannel.store.revokedBefore(session.sub);
646
+ if (before !== null && (session.iat ?? 0) <= before) return null;
647
+ return session;
648
+ } catch {
649
+ return onStoreError === "allow" ? session : null;
650
+ }
651
+ }
534
652
  const app = new hono.Hono();
653
+ if (backchannel) {
654
+ app.post("/backchannel-logout", async (c) => {
655
+ c.header("Cache-Control", "no-store");
656
+ let token;
657
+ try {
658
+ const form = await c.req.parseBody();
659
+ token = typeof form.logout_token === "string" ? form.logout_token : void 0;
660
+ } catch {
661
+ token = void 0;
662
+ }
663
+ if (!token) return c.json({ error: "invalid_request", error_description: "logout_token is missing" }, 400);
664
+ let verified;
665
+ try {
666
+ verified = await client.verifyLogoutToken(token);
667
+ } catch (e) {
668
+ const msg = e instanceof SsoError ? e.message : "logout_token could not be verified";
669
+ return c.json({ error: "invalid_request", error_description: msg }, 400);
670
+ }
671
+ try {
672
+ if (await backchannel.store.useJti(verified.jti, verified.exp)) {
673
+ await backchannel.store.revokeSubBefore(verified.sub, verified.iat);
674
+ }
675
+ } catch {
676
+ return c.json({ error: "store_unavailable" }, 503);
677
+ }
678
+ return c.body(null, 200);
679
+ });
680
+ }
535
681
  app.get("/login", async (c) => {
536
682
  const prompt = c.req.query("prompt");
537
683
  const start = await client.beginLogin(
@@ -592,6 +738,7 @@ function ssoRoutes(options = {}) {
592
738
  const session = {
593
739
  sub: result.claims.sub,
594
740
  exp,
741
+ iat: Math.floor(Date.now() / 1e3),
595
742
  ...result.claims.email ? { email: result.claims.email } : {},
596
743
  ...result.claims.name ? { name: result.claims.name } : {},
597
744
  ...result.claims.picture ? { picture: result.claims.picture } : {}
@@ -635,13 +782,11 @@ function ssoRoutes(options = {}) {
635
782
  return c.redirect(await client.logoutUrl(idTokenHint ? { idTokenHint } : {}), 302);
636
783
  });
637
784
  const attach = async (c, next) => {
638
- const cookie = readCookie(c.req.header("cookie"), config.cookieName);
639
- c.set(SESSION_KEY, await verifySession(cookie, config.cookieSecret));
785
+ c.set(SESSION_KEY, await liveSession(c));
640
786
  await next();
641
787
  };
642
788
  const require2 = async (c, next) => {
643
- const cookie = readCookie(c.req.header("cookie"), config.cookieName);
644
- const session = await verifySession(cookie, config.cookieSecret);
789
+ const session = await liveSession(c);
645
790
  if (!session) {
646
791
  const url = new URL(c.req.url);
647
792
  return c.redirect(