@broberg/sso 0.8.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
@@ -349,3 +349,47 @@ ssoRoutes({ logout: "central" });
349
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
350
 
351
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
@@ -450,7 +450,36 @@ function createSsoClient(config, options = {}) {
450
450
  unverifiedClaims: merged.unverifiedClaims,
451
451
  idToken: body.id_token,
452
452
  ...body.access_token ? { accessToken: body.access_token } : {},
453
- ...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 } : {}
454
483
  };
455
484
  },
456
485
  async logoutUrl(opts = {}) {
@@ -511,6 +540,26 @@ function createSsoClient(config, options = {}) {
511
540
  };
512
541
  }
513
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
+
514
563
  // src/session.ts
515
564
  var enc = new TextEncoder();
516
565
  var b64url2 = (bytes) => {
@@ -641,6 +690,7 @@ function ssoRoutes(options = {}) {
641
690
  throw new Error("ssoRoutes: backchannel needs a store shared by every instance of the app \u2014 there is no in-memory default.");
642
691
  }
643
692
  const onStoreError = backchannel?.onStoreError ?? "reject";
693
+ const tokenStore = options.tokenStore;
644
694
  async function liveSession(c) {
645
695
  const session = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
646
696
  if (!session || !backchannel) return session;
@@ -673,6 +723,7 @@ function ssoRoutes(options = {}) {
673
723
  }
674
724
  try {
675
725
  await backchannel.store.revokeSubBefore(verified.sub, verified.iat);
726
+ if (tokenStore) await tokenStore.deleteSub(verified.sub);
676
727
  await backchannel.store.useJti(verified.jti, verified.exp);
677
728
  } catch {
678
729
  return c.json({ error: "store_unavailable" }, 503);
@@ -736,11 +787,23 @@ function ssoRoutes(options = {}) {
736
787
  codeVerifier: parsed.tx.codeVerifier,
737
788
  nonce: parsed.tx.nonce
738
789
  });
739
- 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
+ }
740
802
  const session = {
741
803
  sub: result.claims.sub,
742
804
  exp,
743
- iat: Math.floor(Date.now() / 1e3),
805
+ iat: nowS,
806
+ ...sid ? { sid } : {},
744
807
  ...result.claims.email ? { email: result.claims.email } : {},
745
808
  ...result.claims.name ? { name: result.claims.name } : {},
746
809
  ...result.claims.picture ? { picture: result.claims.picture } : {},
@@ -774,6 +837,10 @@ function ssoRoutes(options = {}) {
774
837
  readCookie(c.req.header("cookie"), idTokenCookie),
775
838
  config.cookieSecret
776
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
+ }
777
844
  c.header(
778
845
  "Set-Cookie",
779
846
  cookieHeader(config.cookieName, "", { maxAge: 0, secure: isSecure(c) })
@@ -804,7 +871,54 @@ function ssoRoutes(options = {}) {
804
871
  c.set(SESSION_KEY, session);
805
872
  await next();
806
873
  };
807
- 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 };
808
922
  }
809
923
  async function parseTransaction(raw, secret) {
810
924
  if (raw === void 0) return { ok: false, reason: "absent" };