@broberg/sso 0.9.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/dist/hono.d.cts CHANGED
@@ -139,6 +139,15 @@ interface Discovery {
139
139
  userinfo_endpoint?: string;
140
140
  end_session_endpoint?: string;
141
141
  }
142
+ /** The signed-in user's own profile, as BID's GET /api/app/profile returns it. */
143
+ interface BidProfile {
144
+ sub: string;
145
+ name: string | null;
146
+ picture: string | null;
147
+ email: string | null;
148
+ /** BID's own account page — mail, password, passkeys, 2FA and sessions live there. */
149
+ account_url: string;
150
+ }
142
151
  /**
143
152
  * What `beginLogin` produces. Every field except `url` must survive the round
144
153
  * trip to BID and back — the adapter stores them in a short-lived cookie.
@@ -244,6 +253,21 @@ interface SsoClient {
244
253
  * Throws SsoError on anything that is not a valid, sub-only logout token.
245
254
  */
246
255
  verifyLogoutToken(token: string): Promise<LogoutToken>;
256
+ /**
257
+ * F095.2 — the signed-in user's own profile (scope `profile`), with the user's
258
+ * access token. Throws SsoAppApiError on any refusal.
259
+ */
260
+ getProfile(accessToken: string): Promise<BidProfile>;
261
+ /** F095.2 — change the user's own name (scope `profile:write`). Returns the new profile. */
262
+ updateProfile(accessToken: string, name: string): Promise<BidProfile>;
263
+ /**
264
+ * F095.2 — replace the user's picture (scope `profile:write`). Refuses more than
265
+ * MAX_AVATAR_BYTES or a type outside AVATAR_TYPES with SsoAvatarRejectedError
266
+ * BEFORE anything is sent.
267
+ */
268
+ uploadAvatar(accessToken: string, bytes: Uint8Array | ArrayBuffer, contentType: string): Promise<BidProfile>;
269
+ /** F095.2 — remove the user's picture (scope `profile:write`). */
270
+ removeAvatar(accessToken: string): Promise<BidProfile>;
247
271
  /** Exposed for tests and for a health check; not needed in normal use. */
248
272
  readonly jwks: JwksCache;
249
273
  }
@@ -428,5 +452,28 @@ declare function ssoRoutes(options?: SsoRoutesOptions): {
428
452
  type TransactionFailure = "absent" | "expired" | "unreadable";
429
453
  /** Read the session a middleware attached. */
430
454
  declare function getSession(c: Context): SessionPayload | null;
455
+ /** What accountRoutes needs from ssoRoutes — pass its return value as is. */
456
+ interface AccountRoutesInput {
457
+ client: Pick<SsoClient, "getProfile" | "updateProfile" | "uploadAvatar" | "removeAvatar">;
458
+ getAccessToken(c: Context): Promise<string>;
459
+ }
460
+ /**
461
+ * The signed-in user's own name and picture, for the app's account page.
462
+ * Mount it on /api/account:
463
+ *
464
+ * const sso = ssoRoutes({ tokenStore });
465
+ * app.route("/api/account", accountRoutes(sso));
466
+ *
467
+ * GET /profile · POST /profile {name} · POST /profile/avatar (raw image body) ·
468
+ * POST /profile/avatar/remove. Every answer is JSON, refusals included — it is
469
+ * an API, so never a login redirect:
470
+ * 401 {error:"unauthenticated"} no live session
471
+ * 401 {error:"reauth"} the session's tokens are spent — log in again
472
+ * 403 {error:"insufficient_scope", scope} log in again with that scope requested
473
+ * 413 / 415 picture too large / not PNG, JPEG or WebP
474
+ * 4xx {error:<BID's code>} BID refused the change (e.g. an invalid name)
475
+ * 502 {error:"bid_unavailable"} BID did not answer usably
476
+ */
477
+ declare function accountRoutes(sso: AccountRoutesInput): Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
431
478
 
432
- export { type BackchannelOptions, type BackchannelStore, type SsoRoutesOptions, type TransactionFailure, getSession, ssoRoutes };
479
+ export { type AccountRoutesInput, type BackchannelOptions, type BackchannelStore, type SsoRoutesOptions, type TransactionFailure, accountRoutes, getSession, ssoRoutes };
package/dist/hono.d.ts CHANGED
@@ -139,6 +139,15 @@ interface Discovery {
139
139
  userinfo_endpoint?: string;
140
140
  end_session_endpoint?: string;
141
141
  }
142
+ /** The signed-in user's own profile, as BID's GET /api/app/profile returns it. */
143
+ interface BidProfile {
144
+ sub: string;
145
+ name: string | null;
146
+ picture: string | null;
147
+ email: string | null;
148
+ /** BID's own account page — mail, password, passkeys, 2FA and sessions live there. */
149
+ account_url: string;
150
+ }
142
151
  /**
143
152
  * What `beginLogin` produces. Every field except `url` must survive the round
144
153
  * trip to BID and back — the adapter stores them in a short-lived cookie.
@@ -244,6 +253,21 @@ interface SsoClient {
244
253
  * Throws SsoError on anything that is not a valid, sub-only logout token.
245
254
  */
246
255
  verifyLogoutToken(token: string): Promise<LogoutToken>;
256
+ /**
257
+ * F095.2 — the signed-in user's own profile (scope `profile`), with the user's
258
+ * access token. Throws SsoAppApiError on any refusal.
259
+ */
260
+ getProfile(accessToken: string): Promise<BidProfile>;
261
+ /** F095.2 — change the user's own name (scope `profile:write`). Returns the new profile. */
262
+ updateProfile(accessToken: string, name: string): Promise<BidProfile>;
263
+ /**
264
+ * F095.2 — replace the user's picture (scope `profile:write`). Refuses more than
265
+ * MAX_AVATAR_BYTES or a type outside AVATAR_TYPES with SsoAvatarRejectedError
266
+ * BEFORE anything is sent.
267
+ */
268
+ uploadAvatar(accessToken: string, bytes: Uint8Array | ArrayBuffer, contentType: string): Promise<BidProfile>;
269
+ /** F095.2 — remove the user's picture (scope `profile:write`). */
270
+ removeAvatar(accessToken: string): Promise<BidProfile>;
247
271
  /** Exposed for tests and for a health check; not needed in normal use. */
248
272
  readonly jwks: JwksCache;
249
273
  }
@@ -428,5 +452,28 @@ declare function ssoRoutes(options?: SsoRoutesOptions): {
428
452
  type TransactionFailure = "absent" | "expired" | "unreadable";
429
453
  /** Read the session a middleware attached. */
430
454
  declare function getSession(c: Context): SessionPayload | null;
455
+ /** What accountRoutes needs from ssoRoutes — pass its return value as is. */
456
+ interface AccountRoutesInput {
457
+ client: Pick<SsoClient, "getProfile" | "updateProfile" | "uploadAvatar" | "removeAvatar">;
458
+ getAccessToken(c: Context): Promise<string>;
459
+ }
460
+ /**
461
+ * The signed-in user's own name and picture, for the app's account page.
462
+ * Mount it on /api/account:
463
+ *
464
+ * const sso = ssoRoutes({ tokenStore });
465
+ * app.route("/api/account", accountRoutes(sso));
466
+ *
467
+ * GET /profile · POST /profile {name} · POST /profile/avatar (raw image body) ·
468
+ * POST /profile/avatar/remove. Every answer is JSON, refusals included — it is
469
+ * an API, so never a login redirect:
470
+ * 401 {error:"unauthenticated"} no live session
471
+ * 401 {error:"reauth"} the session's tokens are spent — log in again
472
+ * 403 {error:"insufficient_scope", scope} log in again with that scope requested
473
+ * 413 / 415 picture too large / not PNG, JPEG or WebP
474
+ * 4xx {error:<BID's code>} BID refused the change (e.g. an invalid name)
475
+ * 502 {error:"bid_unavailable"} BID did not answer usably
476
+ */
477
+ declare function accountRoutes(sso: AccountRoutesInput): Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
431
478
 
432
- export { type BackchannelOptions, type BackchannelStore, type SsoRoutesOptions, type TransactionFailure, getSession, ssoRoutes };
479
+ export { type AccountRoutesInput, type BackchannelOptions, type BackchannelStore, type SsoRoutesOptions, type TransactionFailure, accountRoutes, getSession, ssoRoutes };
package/dist/hono.js CHANGED
@@ -174,6 +174,45 @@ var SsoError = class extends Error {
174
174
  this.name = "SsoError";
175
175
  }
176
176
  };
177
+ var SsoReauthError = class extends SsoError {
178
+ constructor(message, reason) {
179
+ super(message);
180
+ this.reason = reason;
181
+ this.name = "SsoReauthError";
182
+ }
183
+ reason;
184
+ };
185
+ var SsoAppApiError = class extends SsoError {
186
+ constructor(message, status, code) {
187
+ super(message);
188
+ this.status = status;
189
+ this.code = code;
190
+ this.name = "SsoAppApiError";
191
+ }
192
+ status;
193
+ code;
194
+ };
195
+ var SsoInsufficientScopeError = class extends SsoAppApiError {
196
+ constructor(message, scope) {
197
+ super(message, 403, "insufficient_scope");
198
+ this.scope = scope;
199
+ this.name = "SsoInsufficientScopeError";
200
+ }
201
+ scope;
202
+ };
203
+ var SsoAvatarRejectedError = class extends SsoError {
204
+ constructor(message, reason) {
205
+ super(message);
206
+ this.reason = reason;
207
+ this.name = "SsoAvatarRejectedError";
208
+ }
209
+ reason;
210
+ };
211
+ var MAX_AVATAR_BYTES = 2 * 1024 * 1024;
212
+ var AVATAR_TYPES = ["image/png", "image/jpeg", "image/webp"];
213
+ function mediaType(contentType) {
214
+ return (contentType ?? "").split(";")[0].trim().toLowerCase();
215
+ }
177
216
  var b64url = (bytes) => {
178
217
  const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
179
218
  let s = "";
@@ -265,6 +304,68 @@ function createSsoClient(config, options = {}) {
265
304
  clearTimeout(timer);
266
305
  }
267
306
  }
307
+ async function appRequest(path, bearer, init, what) {
308
+ const url = new URL(path, config.issuer).toString();
309
+ const ctrl = new AbortController();
310
+ let timer;
311
+ const timedOut = new Promise((_, reject) => {
312
+ timer = setTimeout(() => {
313
+ reject(new SsoAppApiError(`${url} did not answer within ${timeoutMs} ms \u2014 ${what} is unknown.`, null, void 0));
314
+ ctrl.abort();
315
+ }, timeoutMs);
316
+ });
317
+ const headers = { authorization: `Bearer ${bearer}` };
318
+ let body;
319
+ if (init.json !== void 0) {
320
+ headers["content-type"] = "application/json";
321
+ body = JSON.stringify(init.json);
322
+ } else if (init.bytes !== void 0) {
323
+ headers["content-type"] = init.contentType ?? "application/octet-stream";
324
+ body = init.bytes;
325
+ }
326
+ try {
327
+ const res = await Promise.race([
328
+ fetchImpl(url, { method: init.method, headers, ...body !== void 0 ? { body } : {}, signal: ctrl.signal }),
329
+ timedOut
330
+ ]);
331
+ const raw = await Promise.race([res.text(), timedOut]);
332
+ let parsed;
333
+ try {
334
+ parsed = raw.trim() === "" ? void 0 : JSON.parse(raw);
335
+ } catch {
336
+ parsed = void 0;
337
+ }
338
+ const fields = parsed && typeof parsed === "object" ? parsed : {};
339
+ const code = typeof fields.error === "string" ? fields.error : void 0;
340
+ if (!res.ok) {
341
+ if (res.status === 403 && code === "insufficient_scope") {
342
+ const scope = typeof fields.scope === "string" && fields.scope ? fields.scope : init.method === "GET" ? "profile" : "profile:write";
343
+ throw new SsoInsufficientScopeError(
344
+ `${url} answered 403 insufficient_scope \u2014 the user's token lacks scope "${scope}"; log in again with it requested.`,
345
+ scope
346
+ );
347
+ }
348
+ throw new SsoAppApiError(`${url} answered ${res.status}${code ? ` (${code})` : ""} \u2014 ${what} is unknown.`, res.status, code);
349
+ }
350
+ if (parsed === void 0) {
351
+ throw new SsoAppApiError(`${url} answered ${res.status} with a body that is not JSON \u2014 ${what} is unknown.`, res.status, void 0);
352
+ }
353
+ return parsed;
354
+ } catch (e) {
355
+ if (e instanceof SsoError) throw e;
356
+ throw new SsoAppApiError(`${url} could not be reached (${e instanceof Error ? e.name : "error"}) \u2014 ${what} is unknown.`, null, void 0);
357
+ } finally {
358
+ clearTimeout(timer);
359
+ }
360
+ }
361
+ function asProfile(body, path) {
362
+ const b = body ?? {};
363
+ if (typeof b.sub !== "string" || b.sub === "" || typeof b.account_url !== "string") {
364
+ throw new SsoAppApiError(`${path} returned no "sub" or "account_url" \u2014 the profile is unknown.`, 200, void 0);
365
+ }
366
+ const opt = (v) => typeof v === "string" ? v : null;
367
+ return { sub: b.sub, name: opt(b.name), picture: opt(b.picture), email: opt(b.email), account_url: b.account_url };
368
+ }
268
369
  let discoveryPromise = null;
269
370
  let jwksCache = null;
270
371
  async function discovery() {
@@ -508,6 +609,35 @@ function createSsoClient(config, options = {}) {
508
609
  return status;
509
610
  },
510
611
  verifyLogoutToken,
612
+ async getProfile(accessToken) {
613
+ const path = "/api/app/profile";
614
+ return asProfile(await appRequest(path, accessToken, { method: "GET" }, "the profile"), path);
615
+ },
616
+ async updateProfile(accessToken, name) {
617
+ const path = "/api/app/profile";
618
+ return asProfile(await appRequest(path, accessToken, { method: "POST", json: { name } }, "the new name"), path);
619
+ },
620
+ async uploadAvatar(accessToken, bytes, contentType) {
621
+ const type = mediaType(contentType);
622
+ if (!AVATAR_TYPES.includes(type)) {
623
+ throw new SsoAvatarRejectedError(
624
+ `uploadAvatar: "${type}" is not accepted \u2014 BID takes ${AVATAR_TYPES.join(", ")}.`,
625
+ "unsupported_type"
626
+ );
627
+ }
628
+ if (bytes.byteLength > MAX_AVATAR_BYTES) {
629
+ throw new SsoAvatarRejectedError(
630
+ `uploadAvatar: ${bytes.byteLength} bytes \u2014 BID takes at most ${MAX_AVATAR_BYTES}.`,
631
+ "too_large"
632
+ );
633
+ }
634
+ const path = "/api/app/profile/avatar";
635
+ return asProfile(await appRequest(path, accessToken, { method: "POST", bytes, contentType: type }, "the new picture"), path);
636
+ },
637
+ async removeAvatar(accessToken) {
638
+ const path = "/api/app/profile/avatar/remove";
639
+ return asProfile(await appRequest(path, accessToken, { method: "POST" }, "the removed picture"), path);
640
+ },
511
641
  async inviteUsers(appKey, input) {
512
642
  if (input.users.length > MAX_INVITATIONS) {
513
643
  throw new SsoError(`inviteUsers: ${input.users.length} users, BID accepts at most ${MAX_INVITATIONS} per call.`);
@@ -878,17 +1008,17 @@ function ssoRoutes(options = {}) {
878
1008
  const live = await liveSession(c);
879
1009
  if (!live) {
880
1010
  if (raw?.sid) await tokenStore.delete(raw.sid);
881
- throw new SsoError("no live session \u2014 log in again.");
1011
+ throw new SsoReauthError("no live session \u2014 log in again.", "no_session");
882
1012
  }
883
1013
  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.");
1014
+ if (!sid) throw new SsoReauthError("this session has no stored tokens (it began before tokenStore was configured) \u2014 log in again.", "no_tokens");
885
1015
  const tokens = await tokenStore.get(sid);
886
- if (!tokens) throw new SsoError("no tokens stored for this session \u2014 log in again.");
1016
+ if (!tokens) throw new SsoReauthError("no tokens stored for this session \u2014 log in again.", "no_tokens");
887
1017
  const now = Math.floor(Date.now() / 1e3);
888
1018
  if (tokens.expiresAt - 30 > now) return tokens.accessToken;
889
1019
  if (!tokens.refreshToken) {
890
1020
  await tokenStore.delete(sid);
891
- throw new SsoError("the access token has expired and there is no refresh token \u2014 log in again.");
1021
+ throw new SsoReauthError("the access token has expired and there is no refresh token \u2014 log in again.", "expired");
892
1022
  }
893
1023
  const inFlight = refreshing.get(sid);
894
1024
  if (inFlight) return inFlight;
@@ -898,7 +1028,7 @@ function ssoRoutes(options = {}) {
898
1028
  next = await client.refreshTokens(tokens.refreshToken);
899
1029
  } catch (e) {
900
1030
  await tokenStore.delete(sid);
901
- throw e instanceof SsoError ? e : new SsoError("token refresh failed");
1031
+ throw new SsoReauthError(e instanceof SsoError ? e.message : "token refresh failed", "refresh_failed");
902
1032
  }
903
1033
  await tokenStore.set(sid, {
904
1034
  sub: tokens.sub,
@@ -935,7 +1065,99 @@ async function parseTransaction(raw, secret) {
935
1065
  function getSession(c) {
936
1066
  return c.get(SESSION_KEY) ?? null;
937
1067
  }
1068
+ function errName(e) {
1069
+ return e instanceof Error ? e.name : void 0;
1070
+ }
1071
+ async function readCapped(c, max) {
1072
+ const stream = c.req.raw.body;
1073
+ if (!stream) return new Uint8Array(0);
1074
+ const reader = stream.getReader();
1075
+ const chunks = [];
1076
+ let size = 0;
1077
+ for (; ; ) {
1078
+ const { done, value } = await reader.read();
1079
+ if (done) break;
1080
+ size += value.byteLength;
1081
+ if (size > max) {
1082
+ await reader.cancel().catch(() => {
1083
+ });
1084
+ return null;
1085
+ }
1086
+ chunks.push(value);
1087
+ }
1088
+ const out = new Uint8Array(size);
1089
+ let at = 0;
1090
+ for (const ch of chunks) {
1091
+ out.set(ch, at);
1092
+ at += ch.byteLength;
1093
+ }
1094
+ return out;
1095
+ }
1096
+ function accountRoutes(sso) {
1097
+ const app = new Hono();
1098
+ const { client } = sso;
1099
+ app.use("*", async (c, next) => {
1100
+ c.header("Cache-Control", "no-store");
1101
+ await next();
1102
+ });
1103
+ app.use("*", async (c, next) => {
1104
+ const site = c.req.header("sec-fetch-site");
1105
+ if (c.req.method !== "GET" && site !== void 0 && site !== "same-origin" && site !== "none") {
1106
+ return c.json({ error: "cross_site" }, 403);
1107
+ }
1108
+ await next();
1109
+ });
1110
+ app.get("/profile", async (c) => c.json(await client.getProfile(await sso.getAccessToken(c))));
1111
+ app.post("/profile", async (c) => {
1112
+ const token = await sso.getAccessToken(c);
1113
+ const body = await c.req.json().catch(() => null);
1114
+ if (typeof body?.name !== "string") {
1115
+ return c.json({ error: "invalid_request", error_description: '"name" must be a string' }, 400);
1116
+ }
1117
+ return c.json(await client.updateProfile(token, body.name));
1118
+ });
1119
+ app.post("/profile/avatar", async (c) => {
1120
+ const token = await sso.getAccessToken(c);
1121
+ const type = mediaType(c.req.header("content-type"));
1122
+ if (!AVATAR_TYPES.includes(type)) {
1123
+ return c.json({ error: "unsupported_type", accepted: AVATAR_TYPES }, 415);
1124
+ }
1125
+ const declared = Number(c.req.header("content-length"));
1126
+ if (Number.isFinite(declared) && declared > MAX_AVATAR_BYTES) {
1127
+ return c.json({ error: "too_large", max_bytes: MAX_AVATAR_BYTES }, 413);
1128
+ }
1129
+ const bytes = await readCapped(c, MAX_AVATAR_BYTES);
1130
+ if (!bytes) return c.json({ error: "too_large", max_bytes: MAX_AVATAR_BYTES }, 413);
1131
+ return c.json(await client.uploadAvatar(token, bytes, type));
1132
+ });
1133
+ app.post("/profile/avatar/remove", async (c) => c.json(await client.removeAvatar(await sso.getAccessToken(c))));
1134
+ app.onError((err, c) => {
1135
+ c.header("Cache-Control", "no-store");
1136
+ const e = err;
1137
+ switch (errName(err)) {
1138
+ case "SsoReauthError":
1139
+ return c.json({ error: e.reason === "no_session" ? "unauthenticated" : "reauth" }, 401);
1140
+ case "SsoInsufficientScopeError":
1141
+ return c.json({ error: "insufficient_scope", scope: e.scope }, 403);
1142
+ case "SsoAvatarRejectedError":
1143
+ 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);
1144
+ case "SsoAppApiError": {
1145
+ if (e.status === 401) return c.json({ error: "reauth" }, 401);
1146
+ if (typeof e.status === "number" && e.status >= 400 && e.status < 500) {
1147
+ return c.json({ error: e.code ?? "bid_refused" }, e.status);
1148
+ }
1149
+ return c.json({ error: "bid_unavailable" }, 502);
1150
+ }
1151
+ // A plain SsoError here is a setup mistake (e.g. ssoRoutes without tokenStore).
1152
+ case "SsoError":
1153
+ return c.json({ error: "sso_misconfigured" }, 500);
1154
+ default:
1155
+ return c.json({ error: "server_error" }, 500);
1156
+ }
1157
+ });
1158
+ return app;
1159
+ }
938
1160
 
939
- export { getSession, ssoRoutes };
1161
+ export { accountRoutes, getSession, ssoRoutes };
940
1162
  //# sourceMappingURL=hono.js.map
941
1163
  //# sourceMappingURL=hono.js.map