@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/index.d.ts CHANGED
@@ -201,6 +201,55 @@ interface Discovery {
201
201
  declare class SsoError extends Error {
202
202
  constructor(message: string);
203
203
  }
204
+ /**
205
+ * F095.2 — the user must log in again: there is no live session, no stored
206
+ * tokens, or the renewal was refused. `reason` says which; "no_session" means
207
+ * there never was a login to recover, the others mean there was one and it is
208
+ * spent. Thrown by getAccessToken.
209
+ */
210
+ declare class SsoReauthError extends SsoError {
211
+ readonly reason: "no_session" | "no_tokens" | "expired" | "refresh_failed";
212
+ constructor(message: string, reason: "no_session" | "no_tokens" | "expired" | "refresh_failed");
213
+ }
214
+ /**
215
+ * F095.2 — BID's app API answered with an error, or did not answer at all.
216
+ * `status` is BID's HTTP status (null when nothing came back: timeout, network);
217
+ * `code` is BID's own `error` field when it sent one. The message carries the
218
+ * URL, the status and the code — never the body, never a token.
219
+ */
220
+ declare class SsoAppApiError extends SsoError {
221
+ readonly status: number | null;
222
+ readonly code: string | undefined;
223
+ constructor(message: string, status: number | null, code: string | undefined);
224
+ }
225
+ /**
226
+ * F095.2 — BID refused because the user's token lacks a scope (403
227
+ * insufficient_scope). For the profile writes that is `profile:write`: the app
228
+ * must request it at login, and a user who logged in before it did must log in
229
+ * again. That is the one actionable fact, so it gets its own class.
230
+ */
231
+ declare class SsoInsufficientScopeError extends SsoAppApiError {
232
+ readonly scope: string;
233
+ constructor(message: string, scope: string);
234
+ }
235
+ /** F095.2 — an avatar refused BEFORE it was sent: too big, or not PNG/JPEG/WebP. */
236
+ declare class SsoAvatarRejectedError extends SsoError {
237
+ readonly reason: "too_large" | "unsupported_type";
238
+ constructor(message: string, reason: "too_large" | "unsupported_type");
239
+ }
240
+ /** BID's limit for a profile picture (llms.txt step 7a: "max 2 MB", read as 2 MiB). */
241
+ declare const MAX_AVATAR_BYTES: number;
242
+ /** The picture types BID accepts. */
243
+ declare const AVATAR_TYPES: readonly string[];
244
+ /** The signed-in user's own profile, as BID's GET /api/app/profile returns it. */
245
+ interface BidProfile {
246
+ sub: string;
247
+ name: string | null;
248
+ picture: string | null;
249
+ email: string | null;
250
+ /** BID's own account page — mail, password, passkeys, 2FA and sessions live there. */
251
+ account_url: string;
252
+ }
204
253
  /**
205
254
  * What `beginLogin` produces. Every field except `url` must survive the round
206
255
  * trip to BID and back — the adapter stores them in a short-lived cookie.
@@ -306,6 +355,21 @@ interface SsoClient {
306
355
  * Throws SsoError on anything that is not a valid, sub-only logout token.
307
356
  */
308
357
  verifyLogoutToken(token: string): Promise<LogoutToken>;
358
+ /**
359
+ * F095.2 — the signed-in user's own profile (scope `profile`), with the user's
360
+ * access token. Throws SsoAppApiError on any refusal.
361
+ */
362
+ getProfile(accessToken: string): Promise<BidProfile>;
363
+ /** F095.2 — change the user's own name (scope `profile:write`). Returns the new profile. */
364
+ updateProfile(accessToken: string, name: string): Promise<BidProfile>;
365
+ /**
366
+ * F095.2 — replace the user's picture (scope `profile:write`). Refuses more than
367
+ * MAX_AVATAR_BYTES or a type outside AVATAR_TYPES with SsoAvatarRejectedError
368
+ * BEFORE anything is sent.
369
+ */
370
+ uploadAvatar(accessToken: string, bytes: Uint8Array | ArrayBuffer, contentType: string): Promise<BidProfile>;
371
+ /** F095.2 — remove the user's picture (scope `profile:write`). */
372
+ removeAvatar(accessToken: string): Promise<BidProfile>;
309
373
  /** Exposed for tests and for a health check; not needed in normal use. */
310
374
  readonly jwks: JwksCache;
311
375
  }
@@ -513,4 +577,4 @@ declare function memoryTokenStore(options?: {
513
577
  readonly size: number;
514
578
  };
515
579
 
516
- export { type AddressOwnership, type BeginLoginOptions, type CreateSsoClientOptions, DEFAULT_SESSION_MAX_AGE, type Discovery, type InvitationOutcome, type InvitationResult, type InviteUsersInput, type JwksCache, type JwksCacheOptions, JwksError, JwksUnavailableError, JwksUnknownKeyError, type LoginResult, type LoginStart, type LogoutToken, MAX_INVITATIONS, type MigrationState, type MigrationStatus, type SessionPayload, type SsoClaims, type SsoClient, type SsoConfig, SsoConfigError, SsoError, type TokenSet, type TokenStore, cookieHeader, createJwksCache, createSsoClient, loadSsoConfig, memoryTokenStore, readCookie, signSession, signValue, verifySession, verifyValue };
580
+ export { AVATAR_TYPES, type AddressOwnership, type BeginLoginOptions, type BidProfile, type CreateSsoClientOptions, DEFAULT_SESSION_MAX_AGE, type Discovery, type InvitationOutcome, type InvitationResult, type InviteUsersInput, type JwksCache, type JwksCacheOptions, JwksError, JwksUnavailableError, JwksUnknownKeyError, type LoginResult, type LoginStart, type LogoutToken, MAX_AVATAR_BYTES, MAX_INVITATIONS, type MigrationState, type MigrationStatus, type SessionPayload, SsoAppApiError, SsoAvatarRejectedError, type SsoClaims, type SsoClient, type SsoConfig, SsoConfigError, SsoError, SsoInsufficientScopeError, SsoReauthError, type TokenSet, type TokenStore, cookieHeader, createJwksCache, createSsoClient, loadSsoConfig, memoryTokenStore, readCookie, signSession, signValue, verifySession, verifyValue };
package/dist/index.js CHANGED
@@ -171,6 +171,45 @@ var SsoError = class extends Error {
171
171
  this.name = "SsoError";
172
172
  }
173
173
  };
174
+ var SsoReauthError = class extends SsoError {
175
+ constructor(message, reason) {
176
+ super(message);
177
+ this.reason = reason;
178
+ this.name = "SsoReauthError";
179
+ }
180
+ reason;
181
+ };
182
+ var SsoAppApiError = class extends SsoError {
183
+ constructor(message, status, code) {
184
+ super(message);
185
+ this.status = status;
186
+ this.code = code;
187
+ this.name = "SsoAppApiError";
188
+ }
189
+ status;
190
+ code;
191
+ };
192
+ var SsoInsufficientScopeError = class extends SsoAppApiError {
193
+ constructor(message, scope) {
194
+ super(message, 403, "insufficient_scope");
195
+ this.scope = scope;
196
+ this.name = "SsoInsufficientScopeError";
197
+ }
198
+ scope;
199
+ };
200
+ var SsoAvatarRejectedError = class extends SsoError {
201
+ constructor(message, reason) {
202
+ super(message);
203
+ this.reason = reason;
204
+ this.name = "SsoAvatarRejectedError";
205
+ }
206
+ reason;
207
+ };
208
+ var MAX_AVATAR_BYTES = 2 * 1024 * 1024;
209
+ var AVATAR_TYPES = ["image/png", "image/jpeg", "image/webp"];
210
+ function mediaType(contentType) {
211
+ return (contentType ?? "").split(";")[0].trim().toLowerCase();
212
+ }
174
213
  var b64url = (bytes) => {
175
214
  const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
176
215
  let s = "";
@@ -262,6 +301,68 @@ function createSsoClient(config, options = {}) {
262
301
  clearTimeout(timer);
263
302
  }
264
303
  }
304
+ async function appRequest(path, bearer, init, what) {
305
+ const url = new URL(path, config.issuer).toString();
306
+ const ctrl = new AbortController();
307
+ let timer;
308
+ const timedOut = new Promise((_, reject) => {
309
+ timer = setTimeout(() => {
310
+ reject(new SsoAppApiError(`${url} did not answer within ${timeoutMs} ms \u2014 ${what} is unknown.`, null, void 0));
311
+ ctrl.abort();
312
+ }, timeoutMs);
313
+ });
314
+ const headers = { authorization: `Bearer ${bearer}` };
315
+ let body;
316
+ if (init.json !== void 0) {
317
+ headers["content-type"] = "application/json";
318
+ body = JSON.stringify(init.json);
319
+ } else if (init.bytes !== void 0) {
320
+ headers["content-type"] = init.contentType ?? "application/octet-stream";
321
+ body = init.bytes;
322
+ }
323
+ try {
324
+ const res = await Promise.race([
325
+ fetchImpl(url, { method: init.method, headers, ...body !== void 0 ? { body } : {}, signal: ctrl.signal }),
326
+ timedOut
327
+ ]);
328
+ const raw = await Promise.race([res.text(), timedOut]);
329
+ let parsed;
330
+ try {
331
+ parsed = raw.trim() === "" ? void 0 : JSON.parse(raw);
332
+ } catch {
333
+ parsed = void 0;
334
+ }
335
+ const fields = parsed && typeof parsed === "object" ? parsed : {};
336
+ const code = typeof fields.error === "string" ? fields.error : void 0;
337
+ if (!res.ok) {
338
+ if (res.status === 403 && code === "insufficient_scope") {
339
+ const scope = typeof fields.scope === "string" && fields.scope ? fields.scope : init.method === "GET" ? "profile" : "profile:write";
340
+ throw new SsoInsufficientScopeError(
341
+ `${url} answered 403 insufficient_scope \u2014 the user's token lacks scope "${scope}"; log in again with it requested.`,
342
+ scope
343
+ );
344
+ }
345
+ throw new SsoAppApiError(`${url} answered ${res.status}${code ? ` (${code})` : ""} \u2014 ${what} is unknown.`, res.status, code);
346
+ }
347
+ if (parsed === void 0) {
348
+ throw new SsoAppApiError(`${url} answered ${res.status} with a body that is not JSON \u2014 ${what} is unknown.`, res.status, void 0);
349
+ }
350
+ return parsed;
351
+ } catch (e) {
352
+ if (e instanceof SsoError) throw e;
353
+ throw new SsoAppApiError(`${url} could not be reached (${e instanceof Error ? e.name : "error"}) \u2014 ${what} is unknown.`, null, void 0);
354
+ } finally {
355
+ clearTimeout(timer);
356
+ }
357
+ }
358
+ function asProfile(body, path) {
359
+ const b = body ?? {};
360
+ if (typeof b.sub !== "string" || b.sub === "" || typeof b.account_url !== "string") {
361
+ throw new SsoAppApiError(`${path} returned no "sub" or "account_url" \u2014 the profile is unknown.`, 200, void 0);
362
+ }
363
+ const opt = (v) => typeof v === "string" ? v : null;
364
+ return { sub: b.sub, name: opt(b.name), picture: opt(b.picture), email: opt(b.email), account_url: b.account_url };
365
+ }
265
366
  let discoveryPromise = null;
266
367
  let jwksCache = null;
267
368
  async function discovery() {
@@ -505,6 +606,35 @@ function createSsoClient(config, options = {}) {
505
606
  return status;
506
607
  },
507
608
  verifyLogoutToken,
609
+ async getProfile(accessToken) {
610
+ const path = "/api/app/profile";
611
+ return asProfile(await appRequest(path, accessToken, { method: "GET" }, "the profile"), path);
612
+ },
613
+ async updateProfile(accessToken, name) {
614
+ const path = "/api/app/profile";
615
+ return asProfile(await appRequest(path, accessToken, { method: "POST", json: { name } }, "the new name"), path);
616
+ },
617
+ async uploadAvatar(accessToken, bytes, contentType) {
618
+ const type = mediaType(contentType);
619
+ if (!AVATAR_TYPES.includes(type)) {
620
+ throw new SsoAvatarRejectedError(
621
+ `uploadAvatar: "${type}" is not accepted \u2014 BID takes ${AVATAR_TYPES.join(", ")}.`,
622
+ "unsupported_type"
623
+ );
624
+ }
625
+ if (bytes.byteLength > MAX_AVATAR_BYTES) {
626
+ throw new SsoAvatarRejectedError(
627
+ `uploadAvatar: ${bytes.byteLength} bytes \u2014 BID takes at most ${MAX_AVATAR_BYTES}.`,
628
+ "too_large"
629
+ );
630
+ }
631
+ const path = "/api/app/profile/avatar";
632
+ return asProfile(await appRequest(path, accessToken, { method: "POST", bytes, contentType: type }, "the new picture"), path);
633
+ },
634
+ async removeAvatar(accessToken) {
635
+ const path = "/api/app/profile/avatar/remove";
636
+ return asProfile(await appRequest(path, accessToken, { method: "POST" }, "the removed picture"), path);
637
+ },
508
638
  async inviteUsers(appKey, input) {
509
639
  if (input.users.length > MAX_INVITATIONS) {
510
640
  throw new SsoError(`inviteUsers: ${input.users.length} users, BID accepts at most ${MAX_INVITATIONS} per call.`);
@@ -654,6 +784,6 @@ function memoryTokenStore(options = {}) {
654
784
  };
655
785
  }
656
786
 
657
- export { DEFAULT_SESSION_MAX_AGE, JwksError, JwksUnavailableError, JwksUnknownKeyError, MAX_INVITATIONS, SsoConfigError, SsoError, cookieHeader, createJwksCache, createSsoClient, loadSsoConfig, memoryTokenStore, readCookie, signSession, signValue, verifySession, verifyValue };
787
+ export { AVATAR_TYPES, DEFAULT_SESSION_MAX_AGE, JwksError, JwksUnavailableError, JwksUnknownKeyError, MAX_AVATAR_BYTES, MAX_INVITATIONS, SsoAppApiError, SsoAvatarRejectedError, SsoConfigError, SsoError, SsoInsufficientScopeError, SsoReauthError, cookieHeader, createJwksCache, createSsoClient, loadSsoConfig, memoryTokenStore, readCookie, signSession, signValue, verifySession, verifyValue };
658
788
  //# sourceMappingURL=index.js.map
659
789
  //# sourceMappingURL=index.js.map