@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/dist/hono.d.cts CHANGED
@@ -177,6 +177,8 @@ interface LoginResult {
177
177
  idToken: string;
178
178
  accessToken?: string;
179
179
  refreshToken?: string;
180
+ /** Seconds the access token lives, when BID said so (`expires_in`). */
181
+ expiresIn?: number;
180
182
  }
181
183
  /**
182
184
  * `prompt=none` asks BID to answer WITHOUT showing anything (components-F084.6).
@@ -216,6 +218,16 @@ interface SsoClient {
216
218
  * answers — see the implementation for why that must never become "unverified".
217
219
  */
218
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
+ }>;
219
231
  /**
220
232
  * Invite users to BID on behalf of this app (the app's OWN key, `bidk_…`).
221
233
  * One result per email. Throws SsoError on anything BID did not clearly say.
@@ -271,6 +283,41 @@ interface LogoutToken {
271
283
  exp: number;
272
284
  }
273
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
+
274
321
  /**
275
322
  * The app's OWN session cookie — signed, not encrypted.
276
323
  *
@@ -296,6 +343,12 @@ interface SessionPayload {
296
343
  * older than any logout.
297
344
  */
298
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;
299
352
  /** Optional convenience copies; never authorisation data. */
300
353
  email?: string;
301
354
  name?: string;
@@ -356,6 +409,12 @@ interface SsoRoutesOptions {
356
409
  loginPath?: string;
357
410
  /** Where to send a user after a successful login when no returnTo is given. */
358
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;
359
418
  }
360
419
  declare function ssoRoutes(options?: SsoRoutesOptions): {
361
420
  app: Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
@@ -363,6 +422,7 @@ declare function ssoRoutes(options?: SsoRoutesOptions): {
363
422
  require: MiddlewareHandler;
364
423
  client: SsoClient;
365
424
  config: SsoConfig;
425
+ getAccessToken: (c: Context) => Promise<string>;
366
426
  };
367
427
  /** Why a login transaction could not be read back. Three states, three answers. */
368
428
  type TransactionFailure = "absent" | "expired" | "unreadable";
package/dist/hono.d.ts CHANGED
@@ -177,6 +177,8 @@ interface LoginResult {
177
177
  idToken: string;
178
178
  accessToken?: string;
179
179
  refreshToken?: string;
180
+ /** Seconds the access token lives, when BID said so (`expires_in`). */
181
+ expiresIn?: number;
180
182
  }
181
183
  /**
182
184
  * `prompt=none` asks BID to answer WITHOUT showing anything (components-F084.6).
@@ -216,6 +218,16 @@ interface SsoClient {
216
218
  * answers — see the implementation for why that must never become "unverified".
217
219
  */
218
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
+ }>;
219
231
  /**
220
232
  * Invite users to BID on behalf of this app (the app's OWN key, `bidk_…`).
221
233
  * One result per email. Throws SsoError on anything BID did not clearly say.
@@ -271,6 +283,41 @@ interface LogoutToken {
271
283
  exp: number;
272
284
  }
273
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
+
274
321
  /**
275
322
  * The app's OWN session cookie — signed, not encrypted.
276
323
  *
@@ -296,6 +343,12 @@ interface SessionPayload {
296
343
  * older than any logout.
297
344
  */
298
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;
299
352
  /** Optional convenience copies; never authorisation data. */
300
353
  email?: string;
301
354
  name?: string;
@@ -356,6 +409,12 @@ interface SsoRoutesOptions {
356
409
  loginPath?: string;
357
410
  /** Where to send a user after a successful login when no returnTo is given. */
358
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;
359
418
  }
360
419
  declare function ssoRoutes(options?: SsoRoutesOptions): {
361
420
  app: Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
@@ -363,6 +422,7 @@ declare function ssoRoutes(options?: SsoRoutesOptions): {
363
422
  require: MiddlewareHandler;
364
423
  client: SsoClient;
365
424
  config: SsoConfig;
425
+ getAccessToken: (c: Context) => Promise<string>;
366
426
  };
367
427
  /** Why a login transaction could not be read back. Three states, three answers. */
368
428
  type TransactionFailure = "absent" | "expired" | "unreadable";
package/dist/hono.js CHANGED
@@ -448,7 +448,36 @@ function createSsoClient(config, options = {}) {
448
448
  unverifiedClaims: merged.unverifiedClaims,
449
449
  idToken: body.id_token,
450
450
  ...body.access_token ? { accessToken: body.access_token } : {},
451
- ...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 } : {}
452
481
  };
453
482
  },
454
483
  async logoutUrl(opts = {}) {
@@ -509,6 +538,26 @@ function createSsoClient(config, options = {}) {
509
538
  };
510
539
  }
511
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
+
512
561
  // src/session.ts
513
562
  var enc = new TextEncoder();
514
563
  var b64url2 = (bytes) => {
@@ -639,6 +688,7 @@ function ssoRoutes(options = {}) {
639
688
  throw new Error("ssoRoutes: backchannel needs a store shared by every instance of the app \u2014 there is no in-memory default.");
640
689
  }
641
690
  const onStoreError = backchannel?.onStoreError ?? "reject";
691
+ const tokenStore = options.tokenStore;
642
692
  async function liveSession(c) {
643
693
  const session = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
644
694
  if (!session || !backchannel) return session;
@@ -671,6 +721,7 @@ function ssoRoutes(options = {}) {
671
721
  }
672
722
  try {
673
723
  await backchannel.store.revokeSubBefore(verified.sub, verified.iat);
724
+ if (tokenStore) await tokenStore.deleteSub(verified.sub);
674
725
  await backchannel.store.useJti(verified.jti, verified.exp);
675
726
  } catch {
676
727
  return c.json({ error: "store_unavailable" }, 503);
@@ -734,11 +785,23 @@ function ssoRoutes(options = {}) {
734
785
  codeVerifier: parsed.tx.codeVerifier,
735
786
  nonce: parsed.tx.nonce
736
787
  });
737
- 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
+ }
738
800
  const session = {
739
801
  sub: result.claims.sub,
740
802
  exp,
741
- iat: Math.floor(Date.now() / 1e3),
803
+ iat: nowS,
804
+ ...sid ? { sid } : {},
742
805
  ...result.claims.email ? { email: result.claims.email } : {},
743
806
  ...result.claims.name ? { name: result.claims.name } : {},
744
807
  ...result.claims.picture ? { picture: result.claims.picture } : {},
@@ -772,6 +835,10 @@ function ssoRoutes(options = {}) {
772
835
  readCookie(c.req.header("cookie"), idTokenCookie),
773
836
  config.cookieSecret
774
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
+ }
775
842
  c.header(
776
843
  "Set-Cookie",
777
844
  cookieHeader(config.cookieName, "", { maxAge: 0, secure: isSecure(c) })
@@ -802,7 +869,54 @@ function ssoRoutes(options = {}) {
802
869
  c.set(SESSION_KEY, session);
803
870
  await next();
804
871
  };
805
- 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 };
806
920
  }
807
921
  async function parseTransaction(raw, secret) {
808
922
  if (raw === void 0) return { ok: false, reason: "absent" };