@broberg/sso 0.5.0 → 0.6.1

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
@@ -282,3 +282,44 @@ const status = await client.migrationStatus(process.env.BID_APP_KEY!, ["sanne@ex
282
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
283
 
284
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
@@ -197,6 +197,7 @@ var INVITATION_OUTCOMES = /* @__PURE__ */ new Set([
197
197
  "send_failed"
198
198
  ]);
199
199
  var MIGRATION_STATES = /* @__PURE__ */ new Set(["ready", "invited", "expired", "not_invited"]);
200
+ var BACKCHANNEL_EVENT = "http://schemas.openid.net/event/backchannel-logout";
200
201
  var MAX_INVITATIONS = 500;
201
202
  function excerptForLog(raw, clientSecret) {
202
203
  let text = raw.replace(/\s+/g, " ").trim();
@@ -306,6 +307,38 @@ function createSsoClient(config, options = {}) {
306
307
  }
307
308
  return jwksCache;
308
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
+ }
309
342
  async function verifyIdToken(idToken, opts = {}) {
310
343
  const cache = await keys();
311
344
  const header = jose.decodeProtectedHeader(idToken);
@@ -444,6 +477,7 @@ function createSsoClient(config, options = {}) {
444
477
  }
445
478
  return status;
446
479
  },
480
+ verifyLogoutToken,
447
481
  async inviteUsers(appKey, input) {
448
482
  if (input.users.length > MAX_INVITATIONS) {
449
483
  throw new SsoError(`inviteUsers: ${input.users.length} users, BID accepts at most ${MAX_INVITATIONS} per call.`);
@@ -599,7 +633,50 @@ function ssoRoutes(options = {}) {
599
633
  const defaultReturnTo = options.defaultReturnTo ?? "/";
600
634
  const txCookie = `${config.cookieName}_tx`;
601
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
+ }
602
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
+ await backchannel.store.revokeSubBefore(verified.sub, verified.iat);
673
+ await backchannel.store.useJti(verified.jti, verified.exp);
674
+ } catch {
675
+ return c.json({ error: "store_unavailable" }, 503);
676
+ }
677
+ return c.body(null, 200);
678
+ });
679
+ }
603
680
  app.get("/login", async (c) => {
604
681
  const prompt = c.req.query("prompt");
605
682
  const start = await client.beginLogin(
@@ -660,6 +737,7 @@ function ssoRoutes(options = {}) {
660
737
  const session = {
661
738
  sub: result.claims.sub,
662
739
  exp,
740
+ iat: Math.floor(Date.now() / 1e3),
663
741
  ...result.claims.email ? { email: result.claims.email } : {},
664
742
  ...result.claims.name ? { name: result.claims.name } : {},
665
743
  ...result.claims.picture ? { picture: result.claims.picture } : {}
@@ -703,13 +781,11 @@ function ssoRoutes(options = {}) {
703
781
  return c.redirect(await client.logoutUrl(idTokenHint ? { idTokenHint } : {}), 302);
704
782
  });
705
783
  const attach = async (c, next) => {
706
- const cookie = readCookie(c.req.header("cookie"), config.cookieName);
707
- c.set(SESSION_KEY, await verifySession(cookie, config.cookieSecret));
784
+ c.set(SESSION_KEY, await liveSession(c));
708
785
  await next();
709
786
  };
710
787
  const require2 = async (c, next) => {
711
- const cookie = readCookie(c.req.header("cookie"), config.cookieName);
712
- const session = await verifySession(cookie, config.cookieSecret);
788
+ const session = await liveSession(c);
713
789
  if (!session) {
714
790
  const url = new URL(c.req.url);
715
791
  return c.redirect(