@broberg/sso 0.9.0 → 0.10.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
@@ -389,7 +389,90 @@ app.get("/api/something", sso.require, async (c) => {
389
389
  - **The tokens die with the session:** «Log ud» deletes the pair, and «Log ud
390
390
  overalt» (the back channel) calls `deleteSub(sub)` — inside the same step, so
391
391
  a failure is reported to BID as not done.
392
+ - **Purge old rows yourself — the package cannot.** A session that simply
393
+ expires (no «Log ud»), or a new login that replaces one without a logout,
394
+ leaves its row in your store indefinitely; nothing ever calls `delete` for it.
395
+ Delete rows older than `sessionMaxAge` (`SSO_SESSION_MAX_AGE`, default 7 days)
396
+ with a periodic job or a TTL — e.g. store a `createdAt` beside the row and
397
+ `DELETE FROM tokens WHERE created_at < now - sessionMaxAge`, or a Redis
398
+ `EX sessionMaxAge`. It matters because with `offline_access` the row holds a
399
+ **refresh token**, and a forgotten refresh token is a live credential until
400
+ the user's BID sign-in ends.
401
+ - **Since 0.10.0 the failures are `SsoReauthError`** (a subclass of `SsoError`,
402
+ so existing `catch` blocks still match) with a `reason`: `no_session`,
403
+ `no_tokens`, `expired` or `refresh_failed`. Every one of them means "log in
404
+ again".
392
405
  - **`memoryTokenStore()`** is the reference implementation for tests and one
393
406
  dev process. It warns once: in production it loses every user's tokens on a
394
407
  restart and is not shared between machines.
395
408
  - **Without `tokenStore`, nothing changes** — login behaves exactly as in 0.8.0.
409
+
410
+ ## Your own profile — accountRoutes (since 0.10.0)
411
+
412
+ The account page in your app: the signed-in user reads and changes her **own**
413
+ name and picture without being sent to BID. Needs the token store above.
414
+
415
+ ```ts
416
+ import { ssoRoutes, accountRoutes } from "@broberg/sso/hono";
417
+
418
+ const sso = ssoRoutes({ tokenStore });
419
+ app.route("/auth", sso.app);
420
+ app.route("/api/account", accountRoutes(sso)); // pass what ssoRoutes returned
421
+ ```
422
+
423
+ | route | does |
424
+ |---|---|
425
+ | `GET /api/account/profile` | `{sub, name, picture, email, account_url}` |
426
+ | `POST /api/account/profile` `{"name":"…"}` | changes the name, answers the new profile |
427
+ | `POST /api/account/profile/avatar` | raw image bytes as the body, `Content-Type: image/png`, `image/jpeg` or `image/webp`, max 2 MB |
428
+ | `POST /api/account/profile/avatar/remove` | removes the picture |
429
+
430
+ **Scopes.** Reading needs `profile` (in the default `SSO_SCOPES`). Changing needs
431
+ `profile:write` — it must be in your app's registration at BID **and** in
432
+ `SSO_SCOPES`, e.g. `SSO_SCOPES="openid profile email profile:write"`. A user who
433
+ logged in before you added it gets `403 insufficient_scope` until she logs in again.
434
+
435
+ **Every answer is JSON — it is an API, so never a login redirect:**
436
+
437
+ | status | body | means — what the page should do |
438
+ |---|---|---|
439
+ | 403 | `{"error":"cross_site"}` | a write from another site (`Sec-Fetch-Site`), sibling subdomains included — call it from the app's own page |
440
+ | 401 | `{"error":"unauthenticated"}` | no live session — send her to log in |
441
+ | 401 | `{"error":"reauth"}` | the session's BID tokens are spent — «Log ind igen» |
442
+ | 403 | `{"error":"insufficient_scope","scope":"profile:write"}` | «Log ind igen for at rette» |
443
+ | 413 | `{"error":"too_large","max_bytes":2097152}` | picture over 2 MB (refused before a byte goes to BID) |
444
+ | 415 | `{"error":"unsupported_type","accepted":[…]}` | not PNG, JPEG or WebP |
445
+ | 4xx | `{"error":"<BID's code>"}` | BID refused the value (e.g. an invalid name) |
446
+ | 502 | `{"error":"bid_unavailable"}` | BID did not answer usably |
447
+
448
+ **Renewal needs `offline_access`, in two places.** BID's access token lives one
449
+ hour. Since 2026-10-06 (broberg-id F084.156) BID issues a **refresh token**, but
450
+ only when your app's BID registration has `offline_access` (ask broberg-id, per
451
+ app) **and** you request it at login:
452
+ `SSO_SCOPES="openid profile email profile:write offline_access"`. Then
453
+ `getAccessToken` renews by itself and the account page keeps working.
454
+ - **Without `offline_access`** there is no refresh token. An hour after login,
455
+ every account-page call answers `401 {"error":"reauth"}`.
456
+ - **With it, renewal still ends with the user's BID sign-in.** Log ud, «Log ud
457
+ overalt», a new password or expiry make BID answer the renewal with
458
+ `400 invalid_grant`, and that also becomes `401 {"error":"reauth"}`.
459
+ - **Rotation:** every renewal issues a new refresh token, and presenting the old
460
+ one again revokes the whole chain. `getAccessToken` renews one session at a
461
+ time **per process**. On several machines, a race costs one «Log ind igen»,
462
+ never a stale token.
463
+
464
+ So `reauth` is a normal state, not an edge case. Build the page for it: a calm
465
+ «Log ind igen» that goes to `/auth/login?returnTo=<the account page>` (no
466
+ `prompt=login`), not an error. The same answer comes back when BID refuses the
467
+ access token itself (a 401 from BID). It is never a 500.
468
+
469
+ **The core methods**, if you are not on Hono: `client.getProfile(token)`,
470
+ `updateProfile(token, name)`, `uploadAvatar(token, bytes, contentType)`,
471
+ `removeAvatar(token)` — each returns the profile. They throw named errors:
472
+ `SsoInsufficientScopeError` (`.scope`), `SsoAvatarRejectedError` (`.reason`:
473
+ `too_large` | `unsupported_type`, thrown **before** anything is sent) and
474
+ `SsoAppApiError` (`.status`, BID's `.code`; `status` is null when BID did not
475
+ answer). All extend `SsoError`. No message ever contains a token.
476
+
477
+ Mail, password, passkeys, two-factor and sessions stay in BID: link to the
478
+ profile's `account_url` in a new tab.
package/dist/hono.cjs CHANGED
@@ -176,6 +176,45 @@ var SsoError = class extends Error {
176
176
  this.name = "SsoError";
177
177
  }
178
178
  };
179
+ var SsoReauthError = class extends SsoError {
180
+ constructor(message, reason) {
181
+ super(message);
182
+ this.reason = reason;
183
+ this.name = "SsoReauthError";
184
+ }
185
+ reason;
186
+ };
187
+ var SsoAppApiError = class extends SsoError {
188
+ constructor(message, status, code) {
189
+ super(message);
190
+ this.status = status;
191
+ this.code = code;
192
+ this.name = "SsoAppApiError";
193
+ }
194
+ status;
195
+ code;
196
+ };
197
+ var SsoInsufficientScopeError = class extends SsoAppApiError {
198
+ constructor(message, scope) {
199
+ super(message, 403, "insufficient_scope");
200
+ this.scope = scope;
201
+ this.name = "SsoInsufficientScopeError";
202
+ }
203
+ scope;
204
+ };
205
+ var SsoAvatarRejectedError = class extends SsoError {
206
+ constructor(message, reason) {
207
+ super(message);
208
+ this.reason = reason;
209
+ this.name = "SsoAvatarRejectedError";
210
+ }
211
+ reason;
212
+ };
213
+ var MAX_AVATAR_BYTES = 2 * 1024 * 1024;
214
+ var AVATAR_TYPES = ["image/png", "image/jpeg", "image/webp"];
215
+ function mediaType(contentType) {
216
+ return (contentType ?? "").split(";")[0].trim().toLowerCase();
217
+ }
179
218
  var b64url = (bytes) => {
180
219
  const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
181
220
  let s = "";
@@ -267,6 +306,68 @@ function createSsoClient(config, options = {}) {
267
306
  clearTimeout(timer);
268
307
  }
269
308
  }
309
+ async function appRequest(path, bearer, init, what) {
310
+ const url = new URL(path, config.issuer).toString();
311
+ const ctrl = new AbortController();
312
+ let timer;
313
+ const timedOut = new Promise((_, reject) => {
314
+ timer = setTimeout(() => {
315
+ reject(new SsoAppApiError(`${url} did not answer within ${timeoutMs} ms \u2014 ${what} is unknown.`, null, void 0));
316
+ ctrl.abort();
317
+ }, timeoutMs);
318
+ });
319
+ const headers = { authorization: `Bearer ${bearer}` };
320
+ let body;
321
+ if (init.json !== void 0) {
322
+ headers["content-type"] = "application/json";
323
+ body = JSON.stringify(init.json);
324
+ } else if (init.bytes !== void 0) {
325
+ headers["content-type"] = init.contentType ?? "application/octet-stream";
326
+ body = init.bytes;
327
+ }
328
+ try {
329
+ const res = await Promise.race([
330
+ fetchImpl(url, { method: init.method, headers, ...body !== void 0 ? { body } : {}, signal: ctrl.signal }),
331
+ timedOut
332
+ ]);
333
+ const raw = await Promise.race([res.text(), timedOut]);
334
+ let parsed;
335
+ try {
336
+ parsed = raw.trim() === "" ? void 0 : JSON.parse(raw);
337
+ } catch {
338
+ parsed = void 0;
339
+ }
340
+ const fields = parsed && typeof parsed === "object" ? parsed : {};
341
+ const code = typeof fields.error === "string" ? fields.error : void 0;
342
+ if (!res.ok) {
343
+ if (res.status === 403 && code === "insufficient_scope") {
344
+ const scope = typeof fields.scope === "string" && fields.scope ? fields.scope : init.method === "GET" ? "profile" : "profile:write";
345
+ throw new SsoInsufficientScopeError(
346
+ `${url} answered 403 insufficient_scope \u2014 the user's token lacks scope "${scope}"; log in again with it requested.`,
347
+ scope
348
+ );
349
+ }
350
+ throw new SsoAppApiError(`${url} answered ${res.status}${code ? ` (${code})` : ""} \u2014 ${what} is unknown.`, res.status, code);
351
+ }
352
+ if (parsed === void 0) {
353
+ throw new SsoAppApiError(`${url} answered ${res.status} with a body that is not JSON \u2014 ${what} is unknown.`, res.status, void 0);
354
+ }
355
+ return parsed;
356
+ } catch (e) {
357
+ if (e instanceof SsoError) throw e;
358
+ throw new SsoAppApiError(`${url} could not be reached (${e instanceof Error ? e.name : "error"}) \u2014 ${what} is unknown.`, null, void 0);
359
+ } finally {
360
+ clearTimeout(timer);
361
+ }
362
+ }
363
+ function asProfile(body, path) {
364
+ const b = body ?? {};
365
+ if (typeof b.sub !== "string" || b.sub === "" || typeof b.account_url !== "string") {
366
+ throw new SsoAppApiError(`${path} returned no "sub" or "account_url" \u2014 the profile is unknown.`, 200, void 0);
367
+ }
368
+ const opt = (v) => typeof v === "string" ? v : null;
369
+ return { sub: b.sub, name: opt(b.name), picture: opt(b.picture), email: opt(b.email), account_url: b.account_url };
370
+ }
270
371
  let discoveryPromise = null;
271
372
  let jwksCache = null;
272
373
  async function discovery() {
@@ -510,6 +611,35 @@ function createSsoClient(config, options = {}) {
510
611
  return status;
511
612
  },
512
613
  verifyLogoutToken,
614
+ async getProfile(accessToken) {
615
+ const path = "/api/app/profile";
616
+ return asProfile(await appRequest(path, accessToken, { method: "GET" }, "the profile"), path);
617
+ },
618
+ async updateProfile(accessToken, name) {
619
+ const path = "/api/app/profile";
620
+ return asProfile(await appRequest(path, accessToken, { method: "POST", json: { name } }, "the new name"), path);
621
+ },
622
+ async uploadAvatar(accessToken, bytes, contentType) {
623
+ const type = mediaType(contentType);
624
+ if (!AVATAR_TYPES.includes(type)) {
625
+ throw new SsoAvatarRejectedError(
626
+ `uploadAvatar: "${type}" is not accepted \u2014 BID takes ${AVATAR_TYPES.join(", ")}.`,
627
+ "unsupported_type"
628
+ );
629
+ }
630
+ if (bytes.byteLength > MAX_AVATAR_BYTES) {
631
+ throw new SsoAvatarRejectedError(
632
+ `uploadAvatar: ${bytes.byteLength} bytes \u2014 BID takes at most ${MAX_AVATAR_BYTES}.`,
633
+ "too_large"
634
+ );
635
+ }
636
+ const path = "/api/app/profile/avatar";
637
+ return asProfile(await appRequest(path, accessToken, { method: "POST", bytes, contentType: type }, "the new picture"), path);
638
+ },
639
+ async removeAvatar(accessToken) {
640
+ const path = "/api/app/profile/avatar/remove";
641
+ return asProfile(await appRequest(path, accessToken, { method: "POST" }, "the removed picture"), path);
642
+ },
513
643
  async inviteUsers(appKey, input) {
514
644
  if (input.users.length > MAX_INVITATIONS) {
515
645
  throw new SsoError(`inviteUsers: ${input.users.length} users, BID accepts at most ${MAX_INVITATIONS} per call.`);
@@ -880,17 +1010,17 @@ function ssoRoutes(options = {}) {
880
1010
  const live = await liveSession(c);
881
1011
  if (!live) {
882
1012
  if (raw?.sid) await tokenStore.delete(raw.sid);
883
- throw new SsoError("no live session \u2014 log in again.");
1013
+ throw new SsoReauthError("no live session \u2014 log in again.", "no_session");
884
1014
  }
885
1015
  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.");
1016
+ if (!sid) throw new SsoReauthError("this session has no stored tokens (it began before tokenStore was configured) \u2014 log in again.", "no_tokens");
887
1017
  const tokens = await tokenStore.get(sid);
888
- if (!tokens) throw new SsoError("no tokens stored for this session \u2014 log in again.");
1018
+ if (!tokens) throw new SsoReauthError("no tokens stored for this session \u2014 log in again.", "no_tokens");
889
1019
  const now = Math.floor(Date.now() / 1e3);
890
1020
  if (tokens.expiresAt - 30 > now) return tokens.accessToken;
891
1021
  if (!tokens.refreshToken) {
892
1022
  await tokenStore.delete(sid);
893
- throw new SsoError("the access token has expired and there is no refresh token \u2014 log in again.");
1023
+ throw new SsoReauthError("the access token has expired and there is no refresh token \u2014 log in again.", "expired");
894
1024
  }
895
1025
  const inFlight = refreshing.get(sid);
896
1026
  if (inFlight) return inFlight;
@@ -900,7 +1030,7 @@ function ssoRoutes(options = {}) {
900
1030
  next = await client.refreshTokens(tokens.refreshToken);
901
1031
  } catch (e) {
902
1032
  await tokenStore.delete(sid);
903
- throw e instanceof SsoError ? e : new SsoError("token refresh failed");
1033
+ throw new SsoReauthError(e instanceof SsoError ? e.message : "token refresh failed", "refresh_failed");
904
1034
  }
905
1035
  await tokenStore.set(sid, {
906
1036
  sub: tokens.sub,
@@ -937,7 +1067,100 @@ async function parseTransaction(raw, secret) {
937
1067
  function getSession(c) {
938
1068
  return c.get(SESSION_KEY) ?? null;
939
1069
  }
1070
+ function errName(e) {
1071
+ return e instanceof Error ? e.name : void 0;
1072
+ }
1073
+ async function readCapped(c, max) {
1074
+ const stream = c.req.raw.body;
1075
+ if (!stream) return new Uint8Array(0);
1076
+ const reader = stream.getReader();
1077
+ const chunks = [];
1078
+ let size = 0;
1079
+ for (; ; ) {
1080
+ const { done, value } = await reader.read();
1081
+ if (done) break;
1082
+ size += value.byteLength;
1083
+ if (size > max) {
1084
+ await reader.cancel().catch(() => {
1085
+ });
1086
+ return null;
1087
+ }
1088
+ chunks.push(value);
1089
+ }
1090
+ const out = new Uint8Array(size);
1091
+ let at = 0;
1092
+ for (const ch of chunks) {
1093
+ out.set(ch, at);
1094
+ at += ch.byteLength;
1095
+ }
1096
+ return out;
1097
+ }
1098
+ function accountRoutes(sso) {
1099
+ const app = new hono.Hono();
1100
+ const { client } = sso;
1101
+ app.use("*", async (c, next) => {
1102
+ c.header("Cache-Control", "no-store");
1103
+ await next();
1104
+ });
1105
+ app.use("*", async (c, next) => {
1106
+ const site = c.req.header("sec-fetch-site");
1107
+ if (c.req.method !== "GET" && site !== void 0 && site !== "same-origin" && site !== "none") {
1108
+ return c.json({ error: "cross_site" }, 403);
1109
+ }
1110
+ await next();
1111
+ });
1112
+ app.get("/profile", async (c) => c.json(await client.getProfile(await sso.getAccessToken(c))));
1113
+ app.post("/profile", async (c) => {
1114
+ const token = await sso.getAccessToken(c);
1115
+ const body = await c.req.json().catch(() => null);
1116
+ if (typeof body?.name !== "string") {
1117
+ return c.json({ error: "invalid_request", error_description: '"name" must be a string' }, 400);
1118
+ }
1119
+ return c.json(await client.updateProfile(token, body.name));
1120
+ });
1121
+ app.post("/profile/avatar", async (c) => {
1122
+ const token = await sso.getAccessToken(c);
1123
+ const type = mediaType(c.req.header("content-type"));
1124
+ if (!AVATAR_TYPES.includes(type)) {
1125
+ return c.json({ error: "unsupported_type", accepted: AVATAR_TYPES }, 415);
1126
+ }
1127
+ const declared = Number(c.req.header("content-length"));
1128
+ if (Number.isFinite(declared) && declared > MAX_AVATAR_BYTES) {
1129
+ return c.json({ error: "too_large", max_bytes: MAX_AVATAR_BYTES }, 413);
1130
+ }
1131
+ const bytes = await readCapped(c, MAX_AVATAR_BYTES);
1132
+ if (!bytes) return c.json({ error: "too_large", max_bytes: MAX_AVATAR_BYTES }, 413);
1133
+ return c.json(await client.uploadAvatar(token, bytes, type));
1134
+ });
1135
+ app.post("/profile/avatar/remove", async (c) => c.json(await client.removeAvatar(await sso.getAccessToken(c))));
1136
+ app.onError((err, c) => {
1137
+ c.header("Cache-Control", "no-store");
1138
+ const e = err;
1139
+ switch (errName(err)) {
1140
+ case "SsoReauthError":
1141
+ return c.json({ error: e.reason === "no_session" ? "unauthenticated" : "reauth" }, 401);
1142
+ case "SsoInsufficientScopeError":
1143
+ return c.json({ error: "insufficient_scope", scope: e.scope }, 403);
1144
+ case "SsoAvatarRejectedError":
1145
+ return e.reason === "too_large" ? c.json({ error: "too_large", max_bytes: MAX_AVATAR_BYTES }, 413) : c.json({ error: "unsupported_type", accepted: AVATAR_TYPES }, 415);
1146
+ case "SsoAppApiError": {
1147
+ if (e.status === 401) return c.json({ error: "reauth" }, 401);
1148
+ if (typeof e.status === "number" && e.status >= 400 && e.status < 500) {
1149
+ return c.json({ error: e.code ?? "bid_refused" }, e.status);
1150
+ }
1151
+ return c.json({ error: "bid_unavailable" }, 502);
1152
+ }
1153
+ // A plain SsoError here is a setup mistake (e.g. ssoRoutes without tokenStore).
1154
+ case "SsoError":
1155
+ return c.json({ error: "sso_misconfigured" }, 500);
1156
+ default:
1157
+ return c.json({ error: "server_error" }, 500);
1158
+ }
1159
+ });
1160
+ return app;
1161
+ }
940
1162
 
1163
+ exports.accountRoutes = accountRoutes;
941
1164
  exports.getSession = getSession;
942
1165
  exports.ssoRoutes = ssoRoutes;
943
1166
  //# sourceMappingURL=hono.cjs.map