@broberg/sso 0.7.0 → 0.9.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
@@ -338,4 +338,58 @@ ssoRoutes({ logout: "central" });
338
338
 
339
339
  ## email_verified on the session (since 0.7.0)
340
340
 
341
- `getSession(c).email_verified` is `true` or `false` when BID said so, and absent when it did not. **Bind access to an email address only when it is `true`** — e.g. `session.email === "cb@broberg.ai" && session.email_verified === true`. Sessions minted before 0.7.0 have no such field; treat absent as not verified.
341
+ `getSession(c).email_verified` is `true` or `false` when BID said so, and absent when it did not. Sessions minted before 0.7.0 have no such field; treat absent as not verified.
342
+
343
+ > **On BID today, `email` and `email_verified` are NOT signed.** BID's ID token does not carry them — measured by broberg-id on 20 Sep 2026 against the real service: the token holds `acr · at_hash · aud · auth_time · exp · iat · iss · nonce · sid · sub`, and email + email_verified exist only in the `/oauth2/userinfo` response, which is plain HTTP with no signature. `email_verified === true` is still the right thing to require before linking on an address, but it is BID's word over TLS, not a signed claim.
344
+ >
345
+ > **If a decision must rest on a signed identity, bind on `sub`** (`session.sub`) — the ID token signs it, and the package refuses a userinfo answer whose `sub` differs. helpdesk moved its account linking from email to `sub` for exactly this reason.
346
+
347
+ ## Which claims were signed — `unverifiedClaims` (since 0.8.0)
348
+
349
+ `completeLogin()` returns `unverifiedClaims: string[]` beside `claims`: the claims that came from the unsigned userinfo response and not from the signed ID token, sorted. Against BID today it is `["email", "email_verified", "name", …]`. An empty list means everything in `claims` was signed, or userinfo was not reached.
350
+
351
+ When both sources carry a claim, **the signed token's value wins** and the claim is not listed. Before 0.8.0 userinfo was spread over the token, so an issuer whose userinfo disagreed with its own token had the unsigned value win. Against BID this changes nothing today — the two never overlap on email — so for BID it is a hardening, not a fix.
352
+
353
+ ## Calling BID as the user — the token store (since 0.9.0)
354
+
355
+ The login already receives an **access token** and a **refresh token**. Give
356
+ `ssoRoutes` a `tokenStore` and they are kept **server-side**, so your app can
357
+ call BID's app API as the user — the account page (read and change your own
358
+ name and picture) is the first thing that needs it.
359
+
360
+ ```ts
361
+ import { ssoRoutes } from "@broberg/sso/hono";
362
+ import type { TokenStore } from "@broberg/sso";
363
+
364
+ const tokenStore: TokenStore = { // backed by YOUR database (SQLite, Postgres, Redis …)
365
+ get: (sid) => db.tokens.get(sid),
366
+ set: (sid, t) => db.tokens.put(sid, t), // t = { sub, accessToken, refreshToken?, expiresAt }
367
+ delete: (sid) => db.tokens.delete(sid),
368
+ deleteSub: (sub) => db.tokens.deleteWhere({ sub }),
369
+ };
370
+ const sso = ssoRoutes({ tokenStore, backchannel: { store } });
371
+
372
+ app.get("/api/something", sso.require, async (c) => {
373
+ const token = await sso.getAccessToken(c); // renewed for you when it has expired
374
+ …
375
+ });
376
+ ```
377
+
378
+ - **Never in the cookie.** The session cookie is signed, not encrypted, and the
379
+ browser can read it. It carries only a random `sid`; the tokens live in your
380
+ store under that key.
381
+ - **`getAccessToken(c)`** returns the stored token, and when it has expired (or
382
+ is within 30 s of it) renews it with the refresh token, stores the new pair
383
+ and returns the new token. Two requests that need renewal at the same moment
384
+ share one renewal (with rotation, a second one would be refused).
385
+ - **It throws `SsoError`, never returns a stale or empty token** — no live
386
+ session, a session from before you configured a store, no tokens, or a refused
387
+ renewal. A refused renewal also deletes the dead pair. The message names the
388
+ status and BID's error code, never a token.
389
+ - **The tokens die with the session:** «Log ud» deletes the pair, and «Log ud
390
+ overalt» (the back channel) calls `deleteSub(sub)` — inside the same step, so
391
+ a failure is reported to BID as not done.
392
+ - **`memoryTokenStore()`** is the reference implementation for tests and one
393
+ dev process. It warns once: in production it loses every user's tokens on a
394
+ restart and is not shared between machines.
395
+ - **Without `tokenStore`, nothing changes** — login behaves exactly as in 0.8.0.
package/dist/hono.cjs CHANGED
@@ -360,18 +360,19 @@ function createSsoClient(config, options = {}) {
360
360
  }
361
361
  async function withUserInfo(claims, accessToken) {
362
362
  const doc = await discovery();
363
- if (!doc.userinfo_endpoint) return claims;
363
+ if (!doc.userinfo_endpoint) return { claims, unverifiedClaims: [] };
364
364
  const res = await fetchImpl(doc.userinfo_endpoint, {
365
365
  headers: { authorization: `Bearer ${accessToken}` }
366
366
  });
367
- if (!res.ok) return claims;
367
+ if (!res.ok) return { claims, unverifiedClaims: [] };
368
368
  const info = await res.json();
369
369
  if (info.sub !== claims.sub) {
370
370
  throw new SsoError(
371
371
  `userinfo describes ${String(info.sub)} but the ID token is for ${claims.sub} \u2014 refusing to merge another user's profile onto this session.`
372
372
  );
373
373
  }
374
- return { ...claims, ...info, sub: claims.sub };
374
+ const unverifiedClaims = Object.keys(info).filter((k) => k !== "sub" && info[k] !== void 0 && !(k in claims)).sort();
375
+ return { claims: { ...info, ...claims, sub: claims.sub }, unverifiedClaims };
375
376
  }
376
377
  return {
377
378
  discovery,
@@ -443,11 +444,42 @@ function createSsoClient(config, options = {}) {
443
444
  );
444
445
  }
445
446
  const claims = await verifyIdToken(body.id_token, { nonce });
447
+ const merged = body.access_token ? await withUserInfo(claims, body.access_token) : { claims, unverifiedClaims: [] };
446
448
  return {
447
- claims: body.access_token ? await withUserInfo(claims, body.access_token) : claims,
449
+ claims: merged.claims,
450
+ unverifiedClaims: merged.unverifiedClaims,
448
451
  idToken: body.id_token,
449
452
  ...body.access_token ? { accessToken: body.access_token } : {},
450
- ...body.refresh_token ? { refreshToken: body.refresh_token } : {}
453
+ ...body.refresh_token ? { refreshToken: body.refresh_token } : {},
454
+ ...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
455
+ };
456
+ },
457
+ async refreshTokens(refreshToken) {
458
+ const { token_endpoint } = await discovery();
459
+ const res = await fetchImpl(token_endpoint, {
460
+ method: "POST",
461
+ headers: { "content-type": "application/x-www-form-urlencoded" },
462
+ body: new URLSearchParams({
463
+ grant_type: "refresh_token",
464
+ refresh_token: refreshToken,
465
+ client_id: config.clientId,
466
+ ...config.clientSecret ? { client_secret: config.clientSecret } : {}
467
+ })
468
+ });
469
+ const raw = await res.text();
470
+ let body;
471
+ try {
472
+ body = raw.trim() === "" ? {} : JSON.parse(raw);
473
+ } catch {
474
+ throw new SsoError(`token refresh failed (${res.status}): the response body is not JSON`);
475
+ }
476
+ if (!res.ok || typeof body.access_token !== "string" || body.access_token === "") {
477
+ throw new SsoError(`token refresh failed (${res.status}): ${body.error ?? "no access_token in response"}`);
478
+ }
479
+ return {
480
+ accessToken: body.access_token,
481
+ ...typeof body.refresh_token === "string" && body.refresh_token ? { refreshToken: body.refresh_token } : {},
482
+ ...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
451
483
  };
452
484
  },
453
485
  async logoutUrl(opts = {}) {
@@ -508,6 +540,26 @@ function createSsoClient(config, options = {}) {
508
540
  };
509
541
  }
510
542
 
543
+ // src/tokens.ts
544
+ function expiresAtFrom(expiresIn, accessToken, now) {
545
+ if (typeof expiresIn === "number" && expiresIn > 0) return now + Math.floor(expiresIn);
546
+ const parts = accessToken.split(".");
547
+ if (parts.length === 3) {
548
+ try {
549
+ const json = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
550
+ if (typeof json.exp === "number" && json.exp > now) return json.exp;
551
+ } catch {
552
+ }
553
+ }
554
+ return now + 300;
555
+ }
556
+ function newSid() {
557
+ const b = crypto.getRandomValues(new Uint8Array(16));
558
+ let s = "";
559
+ for (const x of b) s += String.fromCharCode(x);
560
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
561
+ }
562
+
511
563
  // src/session.ts
512
564
  var enc = new TextEncoder();
513
565
  var b64url2 = (bytes) => {
@@ -638,6 +690,7 @@ function ssoRoutes(options = {}) {
638
690
  throw new Error("ssoRoutes: backchannel needs a store shared by every instance of the app \u2014 there is no in-memory default.");
639
691
  }
640
692
  const onStoreError = backchannel?.onStoreError ?? "reject";
693
+ const tokenStore = options.tokenStore;
641
694
  async function liveSession(c) {
642
695
  const session = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
643
696
  if (!session || !backchannel) return session;
@@ -670,6 +723,7 @@ function ssoRoutes(options = {}) {
670
723
  }
671
724
  try {
672
725
  await backchannel.store.revokeSubBefore(verified.sub, verified.iat);
726
+ if (tokenStore) await tokenStore.deleteSub(verified.sub);
673
727
  await backchannel.store.useJti(verified.jti, verified.exp);
674
728
  } catch {
675
729
  return c.json({ error: "store_unavailable" }, 503);
@@ -733,11 +787,23 @@ function ssoRoutes(options = {}) {
733
787
  codeVerifier: parsed.tx.codeVerifier,
734
788
  nonce: parsed.tx.nonce
735
789
  });
736
- const exp = Math.floor(Date.now() / 1e3) + config.sessionMaxAge;
790
+ const nowS = Math.floor(Date.now() / 1e3);
791
+ const exp = nowS + config.sessionMaxAge;
792
+ let sid;
793
+ if (tokenStore && result.accessToken) {
794
+ sid = newSid();
795
+ await tokenStore.set(sid, {
796
+ sub: result.claims.sub,
797
+ accessToken: result.accessToken,
798
+ ...result.refreshToken ? { refreshToken: result.refreshToken } : {},
799
+ expiresAt: expiresAtFrom(result.expiresIn, result.accessToken, nowS)
800
+ });
801
+ }
737
802
  const session = {
738
803
  sub: result.claims.sub,
739
804
  exp,
740
- iat: Math.floor(Date.now() / 1e3),
805
+ iat: nowS,
806
+ ...sid ? { sid } : {},
741
807
  ...result.claims.email ? { email: result.claims.email } : {},
742
808
  ...result.claims.name ? { name: result.claims.name } : {},
743
809
  ...result.claims.picture ? { picture: result.claims.picture } : {},
@@ -771,6 +837,10 @@ function ssoRoutes(options = {}) {
771
837
  readCookie(c.req.header("cookie"), idTokenCookie),
772
838
  config.cookieSecret
773
839
  );
840
+ if (tokenStore) {
841
+ const leaving = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
842
+ if (leaving?.sid) await tokenStore.delete(leaving.sid);
843
+ }
774
844
  c.header(
775
845
  "Set-Cookie",
776
846
  cookieHeader(config.cookieName, "", { maxAge: 0, secure: isSecure(c) })
@@ -801,7 +871,54 @@ function ssoRoutes(options = {}) {
801
871
  c.set(SESSION_KEY, session);
802
872
  await next();
803
873
  };
804
- return { app, attach, require: require2, client, config };
874
+ const refreshing = /* @__PURE__ */ new Map();
875
+ async function getAccessToken(c) {
876
+ if (!tokenStore) {
877
+ throw new SsoError("getAccessToken needs ssoRoutes({ tokenStore }) \u2014 without one the login's tokens are not kept.");
878
+ }
879
+ const raw = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
880
+ const live = await liveSession(c);
881
+ if (!live) {
882
+ if (raw?.sid) await tokenStore.delete(raw.sid);
883
+ throw new SsoError("no live session \u2014 log in again.");
884
+ }
885
+ const sid = live.sid;
886
+ if (!sid) throw new SsoError("this session has no stored tokens (it began before tokenStore was configured) \u2014 log in again.");
887
+ const tokens = await tokenStore.get(sid);
888
+ if (!tokens) throw new SsoError("no tokens stored for this session \u2014 log in again.");
889
+ const now = Math.floor(Date.now() / 1e3);
890
+ if (tokens.expiresAt - 30 > now) return tokens.accessToken;
891
+ if (!tokens.refreshToken) {
892
+ await tokenStore.delete(sid);
893
+ throw new SsoError("the access token has expired and there is no refresh token \u2014 log in again.");
894
+ }
895
+ const inFlight = refreshing.get(sid);
896
+ if (inFlight) return inFlight;
897
+ const p = (async () => {
898
+ let next;
899
+ try {
900
+ next = await client.refreshTokens(tokens.refreshToken);
901
+ } catch (e) {
902
+ await tokenStore.delete(sid);
903
+ throw e instanceof SsoError ? e : new SsoError("token refresh failed");
904
+ }
905
+ await tokenStore.set(sid, {
906
+ sub: tokens.sub,
907
+ accessToken: next.accessToken,
908
+ // Rotation: use the new one if BID issued one, else keep the old.
909
+ ...next.refreshToken ?? tokens.refreshToken ? { refreshToken: next.refreshToken ?? tokens.refreshToken } : {},
910
+ expiresAt: expiresAtFrom(next.expiresIn, next.accessToken, Math.floor(Date.now() / 1e3))
911
+ });
912
+ return next.accessToken;
913
+ })();
914
+ refreshing.set(sid, p);
915
+ try {
916
+ return await p;
917
+ } finally {
918
+ refreshing.delete(sid);
919
+ }
920
+ }
921
+ return { app, attach, require: require2, client, config, getAccessToken };
805
922
  }
806
923
  async function parseTransaction(raw, secret) {
807
924
  if (raw === void 0) return { ok: false, reason: "absent" };