@broberg/sso 0.8.0 → 0.10.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,116 @@ 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
+ - **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 the row would hold a **refresh token**
399
+ if BID ever issues one, and a forgotten refresh token is a live credential.
400
+ - **Since 0.10.0 the failures are `SsoReauthError`** (a subclass of `SsoError`,
401
+ so existing `catch` blocks still match) with a `reason`: `no_session`,
402
+ `no_tokens`, `expired` or `refresh_failed`. Every one of them means "log in
403
+ again".
404
+ - **`memoryTokenStore()`** is the reference implementation for tests and one
405
+ dev process. It warns once: in production it loses every user's tokens on a
406
+ restart and is not shared between machines.
407
+ - **Without `tokenStore`, nothing changes** — login behaves exactly as in 0.8.0.
408
+
409
+ ## Your own profile — accountRoutes (since 0.10.0)
410
+
411
+ The account page in your app: the signed-in user reads and changes her **own**
412
+ name and picture without being sent to BID. Needs the token store above.
413
+
414
+ ```ts
415
+ import { ssoRoutes, accountRoutes } from "@broberg/sso/hono";
416
+
417
+ const sso = ssoRoutes({ tokenStore });
418
+ app.route("/auth", sso.app);
419
+ app.route("/api/account", accountRoutes(sso)); // pass what ssoRoutes returned
420
+ ```
421
+
422
+ | route | does |
423
+ |---|---|
424
+ | `GET /api/account/profile` | `{sub, name, picture, email, account_url}` |
425
+ | `POST /api/account/profile` `{"name":"…"}` | changes the name, answers the new profile |
426
+ | `POST /api/account/profile/avatar` | raw image bytes as the body, `Content-Type: image/png`, `image/jpeg` or `image/webp`, max 2 MB |
427
+ | `POST /api/account/profile/avatar/remove` | removes the picture |
428
+
429
+ **Scopes.** Reading needs `profile` (in the default `SSO_SCOPES`). Changing needs
430
+ `profile:write` — it must be in your app's registration at BID **and** in
431
+ `SSO_SCOPES`, e.g. `SSO_SCOPES="openid profile email profile:write"`. A user who
432
+ logged in before you added it gets `403 insufficient_scope` until she logs in again.
433
+
434
+ **Every answer is JSON — it is an API, so never a login redirect:**
435
+
436
+ | status | body | means — what the page should do |
437
+ |---|---|---|
438
+ | 403 | `{"error":"cross_site"}` | a write from another site (`Sec-Fetch-Site`), sibling subdomains included — call it from the app's own page |
439
+ | 401 | `{"error":"unauthenticated"}` | no live session — send her to log in |
440
+ | 401 | `{"error":"reauth"}` | the session's BID tokens are spent — «Log ind igen» |
441
+ | 403 | `{"error":"insufficient_scope","scope":"profile:write"}` | «Log ind igen for at rette» |
442
+ | 413 | `{"error":"too_large","max_bytes":2097152}` | picture over 2 MB (refused before a byte goes to BID) |
443
+ | 415 | `{"error":"unsupported_type","accepted":[…]}` | not PNG, JPEG or WebP |
444
+ | 4xx | `{"error":"<BID's code>"}` | BID refused the value (e.g. an invalid name) |
445
+ | 502 | `{"error":"bid_unavailable"}` | BID did not answer usably |
446
+
447
+ **`reauth` is the NORMAL case after an hour, not an edge case.** BID issues apps
448
+ a one-hour access token and — measured in production, 2026-10-05 — **no refresh
449
+ token**. So `getAccessToken` cannot renew, and an hour after login every
450
+ account-page call answers `401 {"error":"reauth"}`. Build the page for that: a
451
+ calm «Log ind igen» that goes to `/auth/login?returnTo=<the account page>`, not
452
+ an error. The same answer comes back when BID itself refuses the token (a 401
453
+ from BID — revoked, or the session was ended in BID). It is never a 500.
454
+
455
+ **The core methods**, if you are not on Hono: `client.getProfile(token)`,
456
+ `updateProfile(token, name)`, `uploadAvatar(token, bytes, contentType)`,
457
+ `removeAvatar(token)` — each returns the profile. They throw named errors:
458
+ `SsoInsufficientScopeError` (`.scope`), `SsoAvatarRejectedError` (`.reason`:
459
+ `too_large` | `unsupported_type`, thrown **before** anything is sent) and
460
+ `SsoAppApiError` (`.status`, BID's `.code`; `status` is null when BID did not
461
+ answer). All extend `SsoError`. No message ever contains a token.
462
+
463
+ Mail, password, passkeys, two-factor and sessions stay in BID: link to the
464
+ 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() {
@@ -450,7 +551,36 @@ function createSsoClient(config, options = {}) {
450
551
  unverifiedClaims: merged.unverifiedClaims,
451
552
  idToken: body.id_token,
452
553
  ...body.access_token ? { accessToken: body.access_token } : {},
453
- ...body.refresh_token ? { refreshToken: body.refresh_token } : {}
554
+ ...body.refresh_token ? { refreshToken: body.refresh_token } : {},
555
+ ...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
556
+ };
557
+ },
558
+ async refreshTokens(refreshToken) {
559
+ const { token_endpoint } = await discovery();
560
+ const res = await fetchImpl(token_endpoint, {
561
+ method: "POST",
562
+ headers: { "content-type": "application/x-www-form-urlencoded" },
563
+ body: new URLSearchParams({
564
+ grant_type: "refresh_token",
565
+ refresh_token: refreshToken,
566
+ client_id: config.clientId,
567
+ ...config.clientSecret ? { client_secret: config.clientSecret } : {}
568
+ })
569
+ });
570
+ const raw = await res.text();
571
+ let body;
572
+ try {
573
+ body = raw.trim() === "" ? {} : JSON.parse(raw);
574
+ } catch {
575
+ throw new SsoError(`token refresh failed (${res.status}): the response body is not JSON`);
576
+ }
577
+ if (!res.ok || typeof body.access_token !== "string" || body.access_token === "") {
578
+ throw new SsoError(`token refresh failed (${res.status}): ${body.error ?? "no access_token in response"}`);
579
+ }
580
+ return {
581
+ accessToken: body.access_token,
582
+ ...typeof body.refresh_token === "string" && body.refresh_token ? { refreshToken: body.refresh_token } : {},
583
+ ...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
454
584
  };
455
585
  },
456
586
  async logoutUrl(opts = {}) {
@@ -481,6 +611,35 @@ function createSsoClient(config, options = {}) {
481
611
  return status;
482
612
  },
483
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
+ },
484
643
  async inviteUsers(appKey, input) {
485
644
  if (input.users.length > MAX_INVITATIONS) {
486
645
  throw new SsoError(`inviteUsers: ${input.users.length} users, BID accepts at most ${MAX_INVITATIONS} per call.`);
@@ -511,6 +670,26 @@ function createSsoClient(config, options = {}) {
511
670
  };
512
671
  }
513
672
 
673
+ // src/tokens.ts
674
+ function expiresAtFrom(expiresIn, accessToken, now) {
675
+ if (typeof expiresIn === "number" && expiresIn > 0) return now + Math.floor(expiresIn);
676
+ const parts = accessToken.split(".");
677
+ if (parts.length === 3) {
678
+ try {
679
+ const json = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
680
+ if (typeof json.exp === "number" && json.exp > now) return json.exp;
681
+ } catch {
682
+ }
683
+ }
684
+ return now + 300;
685
+ }
686
+ function newSid() {
687
+ const b = crypto.getRandomValues(new Uint8Array(16));
688
+ let s = "";
689
+ for (const x of b) s += String.fromCharCode(x);
690
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
691
+ }
692
+
514
693
  // src/session.ts
515
694
  var enc = new TextEncoder();
516
695
  var b64url2 = (bytes) => {
@@ -641,6 +820,7 @@ function ssoRoutes(options = {}) {
641
820
  throw new Error("ssoRoutes: backchannel needs a store shared by every instance of the app \u2014 there is no in-memory default.");
642
821
  }
643
822
  const onStoreError = backchannel?.onStoreError ?? "reject";
823
+ const tokenStore = options.tokenStore;
644
824
  async function liveSession(c) {
645
825
  const session = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
646
826
  if (!session || !backchannel) return session;
@@ -673,6 +853,7 @@ function ssoRoutes(options = {}) {
673
853
  }
674
854
  try {
675
855
  await backchannel.store.revokeSubBefore(verified.sub, verified.iat);
856
+ if (tokenStore) await tokenStore.deleteSub(verified.sub);
676
857
  await backchannel.store.useJti(verified.jti, verified.exp);
677
858
  } catch {
678
859
  return c.json({ error: "store_unavailable" }, 503);
@@ -736,11 +917,23 @@ function ssoRoutes(options = {}) {
736
917
  codeVerifier: parsed.tx.codeVerifier,
737
918
  nonce: parsed.tx.nonce
738
919
  });
739
- const exp = Math.floor(Date.now() / 1e3) + config.sessionMaxAge;
920
+ const nowS = Math.floor(Date.now() / 1e3);
921
+ const exp = nowS + config.sessionMaxAge;
922
+ let sid;
923
+ if (tokenStore && result.accessToken) {
924
+ sid = newSid();
925
+ await tokenStore.set(sid, {
926
+ sub: result.claims.sub,
927
+ accessToken: result.accessToken,
928
+ ...result.refreshToken ? { refreshToken: result.refreshToken } : {},
929
+ expiresAt: expiresAtFrom(result.expiresIn, result.accessToken, nowS)
930
+ });
931
+ }
740
932
  const session = {
741
933
  sub: result.claims.sub,
742
934
  exp,
743
- iat: Math.floor(Date.now() / 1e3),
935
+ iat: nowS,
936
+ ...sid ? { sid } : {},
744
937
  ...result.claims.email ? { email: result.claims.email } : {},
745
938
  ...result.claims.name ? { name: result.claims.name } : {},
746
939
  ...result.claims.picture ? { picture: result.claims.picture } : {},
@@ -774,6 +967,10 @@ function ssoRoutes(options = {}) {
774
967
  readCookie(c.req.header("cookie"), idTokenCookie),
775
968
  config.cookieSecret
776
969
  );
970
+ if (tokenStore) {
971
+ const leaving = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
972
+ if (leaving?.sid) await tokenStore.delete(leaving.sid);
973
+ }
777
974
  c.header(
778
975
  "Set-Cookie",
779
976
  cookieHeader(config.cookieName, "", { maxAge: 0, secure: isSecure(c) })
@@ -804,7 +1001,54 @@ function ssoRoutes(options = {}) {
804
1001
  c.set(SESSION_KEY, session);
805
1002
  await next();
806
1003
  };
807
- return { app, attach, require: require2, client, config };
1004
+ const refreshing = /* @__PURE__ */ new Map();
1005
+ async function getAccessToken(c) {
1006
+ if (!tokenStore) {
1007
+ throw new SsoError("getAccessToken needs ssoRoutes({ tokenStore }) \u2014 without one the login's tokens are not kept.");
1008
+ }
1009
+ const raw = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
1010
+ const live = await liveSession(c);
1011
+ if (!live) {
1012
+ if (raw?.sid) await tokenStore.delete(raw.sid);
1013
+ throw new SsoReauthError("no live session \u2014 log in again.", "no_session");
1014
+ }
1015
+ const sid = live.sid;
1016
+ if (!sid) throw new SsoReauthError("this session has no stored tokens (it began before tokenStore was configured) \u2014 log in again.", "no_tokens");
1017
+ const tokens = await tokenStore.get(sid);
1018
+ if (!tokens) throw new SsoReauthError("no tokens stored for this session \u2014 log in again.", "no_tokens");
1019
+ const now = Math.floor(Date.now() / 1e3);
1020
+ if (tokens.expiresAt - 30 > now) return tokens.accessToken;
1021
+ if (!tokens.refreshToken) {
1022
+ await tokenStore.delete(sid);
1023
+ throw new SsoReauthError("the access token has expired and there is no refresh token \u2014 log in again.", "expired");
1024
+ }
1025
+ const inFlight = refreshing.get(sid);
1026
+ if (inFlight) return inFlight;
1027
+ const p = (async () => {
1028
+ let next;
1029
+ try {
1030
+ next = await client.refreshTokens(tokens.refreshToken);
1031
+ } catch (e) {
1032
+ await tokenStore.delete(sid);
1033
+ throw new SsoReauthError(e instanceof SsoError ? e.message : "token refresh failed", "refresh_failed");
1034
+ }
1035
+ await tokenStore.set(sid, {
1036
+ sub: tokens.sub,
1037
+ accessToken: next.accessToken,
1038
+ // Rotation: use the new one if BID issued one, else keep the old.
1039
+ ...next.refreshToken ?? tokens.refreshToken ? { refreshToken: next.refreshToken ?? tokens.refreshToken } : {},
1040
+ expiresAt: expiresAtFrom(next.expiresIn, next.accessToken, Math.floor(Date.now() / 1e3))
1041
+ });
1042
+ return next.accessToken;
1043
+ })();
1044
+ refreshing.set(sid, p);
1045
+ try {
1046
+ return await p;
1047
+ } finally {
1048
+ refreshing.delete(sid);
1049
+ }
1050
+ }
1051
+ return { app, attach, require: require2, client, config, getAccessToken };
808
1052
  }
809
1053
  async function parseTransaction(raw, secret) {
810
1054
  if (raw === void 0) return { ok: false, reason: "absent" };
@@ -823,7 +1067,100 @@ async function parseTransaction(raw, secret) {
823
1067
  function getSession(c) {
824
1068
  return c.get(SESSION_KEY) ?? null;
825
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
+ }
826
1162
 
1163
+ exports.accountRoutes = accountRoutes;
827
1164
  exports.getSession = getSession;
828
1165
  exports.ssoRoutes = ssoRoutes;
829
1166
  //# sourceMappingURL=hono.cjs.map