@broberg/sso 0.3.2 → 0.5.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
@@ -248,3 +248,37 @@ match", which sends you looking at the OAuth flow instead of at a cookie flag.
248
248
  ## Licence
249
249
 
250
250
  MIT
251
+
252
+ ## Does this address belong to the user? (since 0.4.0)
253
+
254
+ ```ts
255
+ const answer = await client.addressOwnership(accessToken, "a@example.dk");
256
+ // "verified" on the user's BID account and verified there
257
+ // "unverified" on the account, not verified
258
+ // "not_on_account" not on this user's account
259
+ ```
260
+
261
+ Calls BID's `POST /api/app/address-ownership` with the user's access token. **It throws `SsoError` on anything else** — a non-2xx, a body that is not JSON, a missing or unknown status. Do not catch that into `"unverified"`: an error means *we do not know*, and treating it as "not verified" quietly downgrades a real user.
262
+
263
+ ## Inviting users and tracking the move to BID (since 0.5.0)
264
+
265
+ Two calls that authenticate with **your app's own key** (`bidk_…`, issued by Broberg ID) — not the client secret, not a user's token.
266
+
267
+ ```ts
268
+ const results = await client.inviteUsers(process.env.BID_APP_KEY!, {
269
+ customerName: "Sanne Andersen",
270
+ appUrl: "https://sanneandersen.dk",
271
+ appName: "CMS", // optional
272
+ switchDate: "2026-10-15", // optional
273
+ users: [{ email: "sanne@example.dk", name: "Sanne" }], // max 500 per call
274
+ });
275
+ // [{ email, outcome: "invited" | "already_invited" | "existing" | "invalid"
276
+ // | "too_soon" | "too_many_today" | "send_failed", mailId?, problem? }]
277
+
278
+ const status = await client.migrationStatus(process.env.BID_APP_KEY!, ["sanne@example.dk"]);
279
+ // { users: [{ email, state: "ready" | "invited" | "expired" | "not_invited", expiresAt? }], counts, complete }
280
+ ```
281
+
282
+ **Both throw `SsoError`** on a non-2xx (a refused key says `invalid_app_key`), a body that is not JSON, a missing array, or an outcome/state this client does not know. **Never treat an error as `"ready"`**: switching off a user's old login because BID's answer was unclear is how someone gets locked out.
283
+
284
+ **Timeout.** `addressOwnership`, `inviteUsers` and `migrationStatus` give up after `timeoutMs` (default 10 000) with `SsoError`, so a hanging BID cannot hang your app: `createSsoClient(config, { timeoutMs: 5_000 })`.
package/dist/hono.cjs CHANGED
@@ -122,8 +122,13 @@ function createJwksCache(options) {
122
122
  `${jwksUri} answered ${res.status} \u2014 cannot verify any token right now`
123
123
  );
124
124
  }
125
- const body = await res.json();
126
- if (!Array.isArray(body.keys)) {
125
+ let body;
126
+ try {
127
+ body = await res.json();
128
+ } catch {
129
+ throw new JwksUnavailableError(`${jwksUri} answered 200 with a body that is not JSON`);
130
+ }
131
+ if (!Array.isArray(body?.keys)) {
127
132
  throw new JwksUnavailableError(`${jwksUri} returned no "keys" array`);
128
133
  }
129
134
  keys = body.keys;
@@ -181,6 +186,18 @@ var randomToken = (bytes = 32) => b64url(crypto.getRandomValues(new Uint8Array(b
181
186
  async function challengeFor(verifier) {
182
187
  return b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
183
188
  }
189
+ var ADDRESS_OWNERSHIP = /* @__PURE__ */ new Set(["verified", "unverified", "not_on_account"]);
190
+ var INVITATION_OUTCOMES = /* @__PURE__ */ new Set([
191
+ "invited",
192
+ "already_invited",
193
+ "existing",
194
+ "invalid",
195
+ "too_soon",
196
+ "too_many_today",
197
+ "send_failed"
198
+ ]);
199
+ var MIGRATION_STATES = /* @__PURE__ */ new Set(["ready", "invited", "expired", "not_invited"]);
200
+ var MAX_INVITATIONS = 500;
184
201
  function excerptForLog(raw, clientSecret) {
185
202
  let text = raw.replace(/\s+/g, " ").trim();
186
203
  if (clientSecret) text = text.split(clientSecret).join("[redacted client_secret]");
@@ -215,6 +232,40 @@ var ALLOWED_ALGS = [
215
232
  ];
216
233
  function createSsoClient(config, options = {}) {
217
234
  const fetchImpl = options.fetchImpl ?? fetch;
235
+ const timeoutMs = options.timeoutMs ?? 1e4;
236
+ async function appPost(path, bearer, payload, what) {
237
+ const url = new URL(path, config.issuer).toString();
238
+ const ctrl = new AbortController();
239
+ let timer;
240
+ const timedOut = new Promise((_, reject) => {
241
+ timer = setTimeout(() => {
242
+ ctrl.abort();
243
+ reject(new SsoError(`${url} did not answer within ${timeoutMs} ms \u2014 ${what} is unknown.`));
244
+ }, timeoutMs);
245
+ });
246
+ try {
247
+ const res = await Promise.race([
248
+ fetchImpl(url, {
249
+ method: "POST",
250
+ headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
251
+ body: JSON.stringify(payload),
252
+ signal: ctrl.signal
253
+ }),
254
+ timedOut
255
+ ]);
256
+ if (!res.ok) {
257
+ const detail = await res.text().catch(() => "");
258
+ const hint = detail.includes("invalid_app_key") ? " (invalid_app_key: this app's bidk_ key was refused)" : "";
259
+ throw new SsoError(`${url} answered ${res.status}${hint} \u2014 ${what} is unknown.`);
260
+ }
261
+ return await Promise.race([res.json().catch(() => null), timedOut]);
262
+ } catch (e) {
263
+ if (e instanceof SsoError) throw e;
264
+ throw new SsoError(`${url} could not be reached: ${e instanceof Error ? e.message : String(e)} \u2014 ${what} is unknown.`);
265
+ } finally {
266
+ clearTimeout(timer);
267
+ }
268
+ }
218
269
  let discoveryPromise = null;
219
270
  let jwksCache = null;
220
271
  async function discovery() {
@@ -379,6 +430,46 @@ function createSsoClient(config, options = {}) {
379
430
  if (post) params.set("post_logout_redirect_uri", post);
380
431
  params.set("client_id", config.clientId);
381
432
  return `${doc.end_session_endpoint}?${params}`;
433
+ },
434
+ async addressOwnership(accessToken, address) {
435
+ const body = await appPost(
436
+ "/api/app/address-ownership",
437
+ accessToken,
438
+ { address },
439
+ 'address ownership (not "unverified")'
440
+ );
441
+ const status = body?.status;
442
+ if (typeof status !== "string" || !ADDRESS_OWNERSHIP.has(status)) {
443
+ throw new SsoError(`address-ownership returned status ${JSON.stringify(status)} \u2014 expected verified, unverified or not_on_account.`);
444
+ }
445
+ return status;
446
+ },
447
+ async inviteUsers(appKey, input) {
448
+ if (input.users.length > MAX_INVITATIONS) {
449
+ throw new SsoError(`inviteUsers: ${input.users.length} users, BID accepts at most ${MAX_INVITATIONS} per call.`);
450
+ }
451
+ const body = await appPost("/api/app/invitations", appKey, input, "the invitation outcome");
452
+ if (!Array.isArray(body?.results)) {
453
+ throw new SsoError(`invitations returned no "results" array \u2014 the invitation outcome is unknown.`);
454
+ }
455
+ for (const r of body.results) {
456
+ if (typeof r?.outcome !== "string" || !INVITATION_OUTCOMES.has(r.outcome)) {
457
+ throw new SsoError(`invitations returned outcome ${JSON.stringify(r?.outcome)} for ${String(r?.email)} \u2014 not one this client knows.`);
458
+ }
459
+ }
460
+ return body.results;
461
+ },
462
+ async migrationStatus(appKey, emails) {
463
+ const body = await appPost("/api/app/migration-status", appKey, { emails }, "the migration state");
464
+ if (!Array.isArray(body?.users) || typeof body.complete !== "boolean") {
465
+ throw new SsoError(`migration-status returned no "users" array or no "complete" flag \u2014 the migration state is unknown.`);
466
+ }
467
+ for (const u of body.users) {
468
+ if (typeof u?.state !== "string" || !MIGRATION_STATES.has(u.state)) {
469
+ throw new SsoError(`migration-status returned state ${JSON.stringify(u?.state)} for ${String(u?.email)} \u2014 never read as "ready".`);
470
+ }
471
+ }
472
+ return body;
382
473
  }
383
474
  };
384
475
  }