@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/dist/hono.d.cts CHANGED
@@ -162,9 +162,23 @@ interface SsoClaims extends JWTPayload {
162
162
  }
163
163
  interface LoginResult {
164
164
  claims: SsoClaims;
165
+ /**
166
+ * The claims in `claims` that came from the UNSIGNED userinfo response and
167
+ * not from the signed ID token (components-F084.51, since 0.8.0). Sorted; an
168
+ * empty list means everything in `claims` was signed (or userinfo was not
169
+ * reached). When both sources carry a claim, the signed token's value wins
170
+ * and the claim is not listed.
171
+ *
172
+ * On BID today this always contains "email" and "email_verified": the ID
173
+ * token does not carry them (measured by broberg-id, 20 Sep 2026). A decision
174
+ * that must rest on a signed identity binds on `claims.sub`, never on email.
175
+ */
176
+ unverifiedClaims: string[];
165
177
  idToken: string;
166
178
  accessToken?: string;
167
179
  refreshToken?: string;
180
+ /** Seconds the access token lives, when BID said so (`expires_in`). */
181
+ expiresIn?: number;
168
182
  }
169
183
  /**
170
184
  * `prompt=none` asks BID to answer WITHOUT showing anything (components-F084.6).
@@ -204,6 +218,16 @@ interface SsoClient {
204
218
  * answers — see the implementation for why that must never become "unverified".
205
219
  */
206
220
  addressOwnership(accessToken: string, address: string): Promise<AddressOwnership>;
221
+ /**
222
+ * F095.1 — trade a refresh token for a new access token (`grant_type=refresh_token`).
223
+ * Throws SsoError on refusal; the message carries the status and BID's error
224
+ * code, never a token.
225
+ */
226
+ refreshTokens(refreshToken: string): Promise<{
227
+ accessToken: string;
228
+ refreshToken?: string;
229
+ expiresIn?: number;
230
+ }>;
207
231
  /**
208
232
  * Invite users to BID on behalf of this app (the app's OWN key, `bidk_…`).
209
233
  * One result per email. Throws SsoError on anything BID did not clearly say.
@@ -259,6 +283,41 @@ interface LogoutToken {
259
283
  exp: number;
260
284
  }
261
285
 
286
+ /**
287
+ * Server-side tokens for calling Broberg ID on the user's behalf (F095.1).
288
+ *
289
+ * The login already RECEIVES an access token and a refresh token; before 0.9.0
290
+ * ssoRoutes dropped both, so an app could not call BID's app API (the account
291
+ * page: read and change your own name and picture) as the user.
292
+ *
293
+ * ── WHY NOT IN THE COOKIE ────────────────────────────────────────────────
294
+ * The session cookie is SIGNED, not encrypted, and the browser can read it. A
295
+ * refresh token is the longest-lived, highest-value credential in the whole
296
+ * flow: whoever holds it can mint access tokens until it is revoked. So the
297
+ * tokens live server-side, keyed by a random `sid` that is the only thing the
298
+ * cookie carries. A stolen cookie still needs the server to be useful.
299
+ */
300
+ interface TokenSet {
301
+ /** The BID subject the tokens belong to — lets «Log ud overalt» find them. */
302
+ sub: string;
303
+ accessToken: string;
304
+ refreshToken?: string;
305
+ /** Unix seconds. */
306
+ expiresAt: number;
307
+ }
308
+ /**
309
+ * Where the tokens live. The APP provides it in production, backed by
310
+ * something every instance shares (SQLite on one machine, a database or Redis
311
+ * on several) — the same reason BackchannelStore has no in-memory default.
312
+ */
313
+ interface TokenStore {
314
+ get(sid: string): Promise<TokenSet | undefined>;
315
+ set(sid: string, tokens: TokenSet): Promise<void>;
316
+ delete(sid: string): Promise<void>;
317
+ /** Drop every token set for `sub` — what «Log ud overalt» calls. */
318
+ deleteSub(sub: string): Promise<void>;
319
+ }
320
+
262
321
  /**
263
322
  * The app's OWN session cookie — signed, not encrypted.
264
323
  *
@@ -284,6 +343,12 @@ interface SessionPayload {
284
343
  * older than any logout.
285
344
  */
286
345
  iat?: number;
346
+ /**
347
+ * F095.1 — the key to this session's server-side tokens (ssoRoutes'
348
+ * `tokenStore`). Random, 128 bits. The tokens themselves are NEVER in the
349
+ * cookie: it is signed, not encrypted, and the browser can read it.
350
+ */
351
+ sid?: string;
287
352
  /** Optional convenience copies; never authorisation data. */
288
353
  email?: string;
289
354
  name?: string;
@@ -344,6 +409,12 @@ interface SsoRoutesOptions {
344
409
  loginPath?: string;
345
410
  /** Where to send a user after a successful login when no returnTo is given. */
346
411
  defaultReturnTo?: string;
412
+ /**
413
+ * F095.1 — keep the user's access and refresh token SERVER-SIDE, so the app
414
+ * can call BID's app API (the account page) as the user via getAccessToken.
415
+ * Without it, login behaves exactly as in 0.8.0 and the tokens are dropped.
416
+ */
417
+ tokenStore?: TokenStore;
347
418
  }
348
419
  declare function ssoRoutes(options?: SsoRoutesOptions): {
349
420
  app: Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
@@ -351,6 +422,7 @@ declare function ssoRoutes(options?: SsoRoutesOptions): {
351
422
  require: MiddlewareHandler;
352
423
  client: SsoClient;
353
424
  config: SsoConfig;
425
+ getAccessToken: (c: Context) => Promise<string>;
354
426
  };
355
427
  /** Why a login transaction could not be read back. Three states, three answers. */
356
428
  type TransactionFailure = "absent" | "expired" | "unreadable";
package/dist/hono.d.ts CHANGED
@@ -162,9 +162,23 @@ interface SsoClaims extends JWTPayload {
162
162
  }
163
163
  interface LoginResult {
164
164
  claims: SsoClaims;
165
+ /**
166
+ * The claims in `claims` that came from the UNSIGNED userinfo response and
167
+ * not from the signed ID token (components-F084.51, since 0.8.0). Sorted; an
168
+ * empty list means everything in `claims` was signed (or userinfo was not
169
+ * reached). When both sources carry a claim, the signed token's value wins
170
+ * and the claim is not listed.
171
+ *
172
+ * On BID today this always contains "email" and "email_verified": the ID
173
+ * token does not carry them (measured by broberg-id, 20 Sep 2026). A decision
174
+ * that must rest on a signed identity binds on `claims.sub`, never on email.
175
+ */
176
+ unverifiedClaims: string[];
165
177
  idToken: string;
166
178
  accessToken?: string;
167
179
  refreshToken?: string;
180
+ /** Seconds the access token lives, when BID said so (`expires_in`). */
181
+ expiresIn?: number;
168
182
  }
169
183
  /**
170
184
  * `prompt=none` asks BID to answer WITHOUT showing anything (components-F084.6).
@@ -204,6 +218,16 @@ interface SsoClient {
204
218
  * answers — see the implementation for why that must never become "unverified".
205
219
  */
206
220
  addressOwnership(accessToken: string, address: string): Promise<AddressOwnership>;
221
+ /**
222
+ * F095.1 — trade a refresh token for a new access token (`grant_type=refresh_token`).
223
+ * Throws SsoError on refusal; the message carries the status and BID's error
224
+ * code, never a token.
225
+ */
226
+ refreshTokens(refreshToken: string): Promise<{
227
+ accessToken: string;
228
+ refreshToken?: string;
229
+ expiresIn?: number;
230
+ }>;
207
231
  /**
208
232
  * Invite users to BID on behalf of this app (the app's OWN key, `bidk_…`).
209
233
  * One result per email. Throws SsoError on anything BID did not clearly say.
@@ -259,6 +283,41 @@ interface LogoutToken {
259
283
  exp: number;
260
284
  }
261
285
 
286
+ /**
287
+ * Server-side tokens for calling Broberg ID on the user's behalf (F095.1).
288
+ *
289
+ * The login already RECEIVES an access token and a refresh token; before 0.9.0
290
+ * ssoRoutes dropped both, so an app could not call BID's app API (the account
291
+ * page: read and change your own name and picture) as the user.
292
+ *
293
+ * ── WHY NOT IN THE COOKIE ────────────────────────────────────────────────
294
+ * The session cookie is SIGNED, not encrypted, and the browser can read it. A
295
+ * refresh token is the longest-lived, highest-value credential in the whole
296
+ * flow: whoever holds it can mint access tokens until it is revoked. So the
297
+ * tokens live server-side, keyed by a random `sid` that is the only thing the
298
+ * cookie carries. A stolen cookie still needs the server to be useful.
299
+ */
300
+ interface TokenSet {
301
+ /** The BID subject the tokens belong to — lets «Log ud overalt» find them. */
302
+ sub: string;
303
+ accessToken: string;
304
+ refreshToken?: string;
305
+ /** Unix seconds. */
306
+ expiresAt: number;
307
+ }
308
+ /**
309
+ * Where the tokens live. The APP provides it in production, backed by
310
+ * something every instance shares (SQLite on one machine, a database or Redis
311
+ * on several) — the same reason BackchannelStore has no in-memory default.
312
+ */
313
+ interface TokenStore {
314
+ get(sid: string): Promise<TokenSet | undefined>;
315
+ set(sid: string, tokens: TokenSet): Promise<void>;
316
+ delete(sid: string): Promise<void>;
317
+ /** Drop every token set for `sub` — what «Log ud overalt» calls. */
318
+ deleteSub(sub: string): Promise<void>;
319
+ }
320
+
262
321
  /**
263
322
  * The app's OWN session cookie — signed, not encrypted.
264
323
  *
@@ -284,6 +343,12 @@ interface SessionPayload {
284
343
  * older than any logout.
285
344
  */
286
345
  iat?: number;
346
+ /**
347
+ * F095.1 — the key to this session's server-side tokens (ssoRoutes'
348
+ * `tokenStore`). Random, 128 bits. The tokens themselves are NEVER in the
349
+ * cookie: it is signed, not encrypted, and the browser can read it.
350
+ */
351
+ sid?: string;
287
352
  /** Optional convenience copies; never authorisation data. */
288
353
  email?: string;
289
354
  name?: string;
@@ -344,6 +409,12 @@ interface SsoRoutesOptions {
344
409
  loginPath?: string;
345
410
  /** Where to send a user after a successful login when no returnTo is given. */
346
411
  defaultReturnTo?: string;
412
+ /**
413
+ * F095.1 — keep the user's access and refresh token SERVER-SIDE, so the app
414
+ * can call BID's app API (the account page) as the user via getAccessToken.
415
+ * Without it, login behaves exactly as in 0.8.0 and the tokens are dropped.
416
+ */
417
+ tokenStore?: TokenStore;
347
418
  }
348
419
  declare function ssoRoutes(options?: SsoRoutesOptions): {
349
420
  app: Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
@@ -351,6 +422,7 @@ declare function ssoRoutes(options?: SsoRoutesOptions): {
351
422
  require: MiddlewareHandler;
352
423
  client: SsoClient;
353
424
  config: SsoConfig;
425
+ getAccessToken: (c: Context) => Promise<string>;
354
426
  };
355
427
  /** Why a login transaction could not be read back. Three states, three answers. */
356
428
  type TransactionFailure = "absent" | "expired" | "unreadable";
package/dist/hono.js CHANGED
@@ -358,18 +358,19 @@ function createSsoClient(config, options = {}) {
358
358
  }
359
359
  async function withUserInfo(claims, accessToken) {
360
360
  const doc = await discovery();
361
- if (!doc.userinfo_endpoint) return claims;
361
+ if (!doc.userinfo_endpoint) return { claims, unverifiedClaims: [] };
362
362
  const res = await fetchImpl(doc.userinfo_endpoint, {
363
363
  headers: { authorization: `Bearer ${accessToken}` }
364
364
  });
365
- if (!res.ok) return claims;
365
+ if (!res.ok) return { claims, unverifiedClaims: [] };
366
366
  const info = await res.json();
367
367
  if (info.sub !== claims.sub) {
368
368
  throw new SsoError(
369
369
  `userinfo describes ${String(info.sub)} but the ID token is for ${claims.sub} \u2014 refusing to merge another user's profile onto this session.`
370
370
  );
371
371
  }
372
- return { ...claims, ...info, sub: claims.sub };
372
+ const unverifiedClaims = Object.keys(info).filter((k) => k !== "sub" && info[k] !== void 0 && !(k in claims)).sort();
373
+ return { claims: { ...info, ...claims, sub: claims.sub }, unverifiedClaims };
373
374
  }
374
375
  return {
375
376
  discovery,
@@ -441,11 +442,42 @@ function createSsoClient(config, options = {}) {
441
442
  );
442
443
  }
443
444
  const claims = await verifyIdToken(body.id_token, { nonce });
445
+ const merged = body.access_token ? await withUserInfo(claims, body.access_token) : { claims, unverifiedClaims: [] };
444
446
  return {
445
- claims: body.access_token ? await withUserInfo(claims, body.access_token) : claims,
447
+ claims: merged.claims,
448
+ unverifiedClaims: merged.unverifiedClaims,
446
449
  idToken: body.id_token,
447
450
  ...body.access_token ? { accessToken: body.access_token } : {},
448
- ...body.refresh_token ? { refreshToken: body.refresh_token } : {}
451
+ ...body.refresh_token ? { refreshToken: body.refresh_token } : {},
452
+ ...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
453
+ };
454
+ },
455
+ async refreshTokens(refreshToken) {
456
+ const { token_endpoint } = await discovery();
457
+ const res = await fetchImpl(token_endpoint, {
458
+ method: "POST",
459
+ headers: { "content-type": "application/x-www-form-urlencoded" },
460
+ body: new URLSearchParams({
461
+ grant_type: "refresh_token",
462
+ refresh_token: refreshToken,
463
+ client_id: config.clientId,
464
+ ...config.clientSecret ? { client_secret: config.clientSecret } : {}
465
+ })
466
+ });
467
+ const raw = await res.text();
468
+ let body;
469
+ try {
470
+ body = raw.trim() === "" ? {} : JSON.parse(raw);
471
+ } catch {
472
+ throw new SsoError(`token refresh failed (${res.status}): the response body is not JSON`);
473
+ }
474
+ if (!res.ok || typeof body.access_token !== "string" || body.access_token === "") {
475
+ throw new SsoError(`token refresh failed (${res.status}): ${body.error ?? "no access_token in response"}`);
476
+ }
477
+ return {
478
+ accessToken: body.access_token,
479
+ ...typeof body.refresh_token === "string" && body.refresh_token ? { refreshToken: body.refresh_token } : {},
480
+ ...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
449
481
  };
450
482
  },
451
483
  async logoutUrl(opts = {}) {
@@ -506,6 +538,26 @@ function createSsoClient(config, options = {}) {
506
538
  };
507
539
  }
508
540
 
541
+ // src/tokens.ts
542
+ function expiresAtFrom(expiresIn, accessToken, now) {
543
+ if (typeof expiresIn === "number" && expiresIn > 0) return now + Math.floor(expiresIn);
544
+ const parts = accessToken.split(".");
545
+ if (parts.length === 3) {
546
+ try {
547
+ const json = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
548
+ if (typeof json.exp === "number" && json.exp > now) return json.exp;
549
+ } catch {
550
+ }
551
+ }
552
+ return now + 300;
553
+ }
554
+ function newSid() {
555
+ const b = crypto.getRandomValues(new Uint8Array(16));
556
+ let s = "";
557
+ for (const x of b) s += String.fromCharCode(x);
558
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
559
+ }
560
+
509
561
  // src/session.ts
510
562
  var enc = new TextEncoder();
511
563
  var b64url2 = (bytes) => {
@@ -636,6 +688,7 @@ function ssoRoutes(options = {}) {
636
688
  throw new Error("ssoRoutes: backchannel needs a store shared by every instance of the app \u2014 there is no in-memory default.");
637
689
  }
638
690
  const onStoreError = backchannel?.onStoreError ?? "reject";
691
+ const tokenStore = options.tokenStore;
639
692
  async function liveSession(c) {
640
693
  const session = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
641
694
  if (!session || !backchannel) return session;
@@ -668,6 +721,7 @@ function ssoRoutes(options = {}) {
668
721
  }
669
722
  try {
670
723
  await backchannel.store.revokeSubBefore(verified.sub, verified.iat);
724
+ if (tokenStore) await tokenStore.deleteSub(verified.sub);
671
725
  await backchannel.store.useJti(verified.jti, verified.exp);
672
726
  } catch {
673
727
  return c.json({ error: "store_unavailable" }, 503);
@@ -731,11 +785,23 @@ function ssoRoutes(options = {}) {
731
785
  codeVerifier: parsed.tx.codeVerifier,
732
786
  nonce: parsed.tx.nonce
733
787
  });
734
- const exp = Math.floor(Date.now() / 1e3) + config.sessionMaxAge;
788
+ const nowS = Math.floor(Date.now() / 1e3);
789
+ const exp = nowS + config.sessionMaxAge;
790
+ let sid;
791
+ if (tokenStore && result.accessToken) {
792
+ sid = newSid();
793
+ await tokenStore.set(sid, {
794
+ sub: result.claims.sub,
795
+ accessToken: result.accessToken,
796
+ ...result.refreshToken ? { refreshToken: result.refreshToken } : {},
797
+ expiresAt: expiresAtFrom(result.expiresIn, result.accessToken, nowS)
798
+ });
799
+ }
735
800
  const session = {
736
801
  sub: result.claims.sub,
737
802
  exp,
738
- iat: Math.floor(Date.now() / 1e3),
803
+ iat: nowS,
804
+ ...sid ? { sid } : {},
739
805
  ...result.claims.email ? { email: result.claims.email } : {},
740
806
  ...result.claims.name ? { name: result.claims.name } : {},
741
807
  ...result.claims.picture ? { picture: result.claims.picture } : {},
@@ -769,6 +835,10 @@ function ssoRoutes(options = {}) {
769
835
  readCookie(c.req.header("cookie"), idTokenCookie),
770
836
  config.cookieSecret
771
837
  );
838
+ if (tokenStore) {
839
+ const leaving = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
840
+ if (leaving?.sid) await tokenStore.delete(leaving.sid);
841
+ }
772
842
  c.header(
773
843
  "Set-Cookie",
774
844
  cookieHeader(config.cookieName, "", { maxAge: 0, secure: isSecure(c) })
@@ -799,7 +869,54 @@ function ssoRoutes(options = {}) {
799
869
  c.set(SESSION_KEY, session);
800
870
  await next();
801
871
  };
802
- return { app, attach, require: require2, client, config };
872
+ const refreshing = /* @__PURE__ */ new Map();
873
+ async function getAccessToken(c) {
874
+ if (!tokenStore) {
875
+ throw new SsoError("getAccessToken needs ssoRoutes({ tokenStore }) \u2014 without one the login's tokens are not kept.");
876
+ }
877
+ const raw = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
878
+ const live = await liveSession(c);
879
+ if (!live) {
880
+ if (raw?.sid) await tokenStore.delete(raw.sid);
881
+ throw new SsoError("no live session \u2014 log in again.");
882
+ }
883
+ const sid = live.sid;
884
+ if (!sid) throw new SsoError("this session has no stored tokens (it began before tokenStore was configured) \u2014 log in again.");
885
+ const tokens = await tokenStore.get(sid);
886
+ if (!tokens) throw new SsoError("no tokens stored for this session \u2014 log in again.");
887
+ const now = Math.floor(Date.now() / 1e3);
888
+ if (tokens.expiresAt - 30 > now) return tokens.accessToken;
889
+ if (!tokens.refreshToken) {
890
+ await tokenStore.delete(sid);
891
+ throw new SsoError("the access token has expired and there is no refresh token \u2014 log in again.");
892
+ }
893
+ const inFlight = refreshing.get(sid);
894
+ if (inFlight) return inFlight;
895
+ const p = (async () => {
896
+ let next;
897
+ try {
898
+ next = await client.refreshTokens(tokens.refreshToken);
899
+ } catch (e) {
900
+ await tokenStore.delete(sid);
901
+ throw e instanceof SsoError ? e : new SsoError("token refresh failed");
902
+ }
903
+ await tokenStore.set(sid, {
904
+ sub: tokens.sub,
905
+ accessToken: next.accessToken,
906
+ // Rotation: use the new one if BID issued one, else keep the old.
907
+ ...next.refreshToken ?? tokens.refreshToken ? { refreshToken: next.refreshToken ?? tokens.refreshToken } : {},
908
+ expiresAt: expiresAtFrom(next.expiresIn, next.accessToken, Math.floor(Date.now() / 1e3))
909
+ });
910
+ return next.accessToken;
911
+ })();
912
+ refreshing.set(sid, p);
913
+ try {
914
+ return await p;
915
+ } finally {
916
+ refreshing.delete(sid);
917
+ }
918
+ }
919
+ return { app, attach, require: require2, client, config, getAccessToken };
803
920
  }
804
921
  async function parseTransaction(raw, secret) {
805
922
  if (raw === void 0) return { ok: false, reason: "absent" };