@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 +34 -0
- package/dist/hono.cjs +93 -2
- package/dist/hono.cjs.map +1 -1
- package/dist/hono.d.cts +46 -0
- package/dist/hono.d.ts +46 -0
- package/dist/hono.js +93 -2
- package/dist/hono.js.map +1 -1
- package/dist/index.cjs +94 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +54 -1
- package/dist/index.d.ts +54 -1
- package/dist/index.js +94 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
126
|
-
|
|
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
|
}
|