@broberg/sso 0.4.0 → 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 +23 -0
- package/dist/hono.cjs +79 -11
- package/dist/hono.cjs.map +1 -1
- package/dist/hono.d.cts +38 -0
- package/dist/hono.d.ts +38 -0
- package/dist/hono.js +79 -11
- package/dist/hono.js.map +1 -1
- package/dist/index.cjs +80 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +46 -1
- package/dist/index.d.ts +46 -1
- package/dist/index.js +80 -12
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/hono.d.cts
CHANGED
|
@@ -204,10 +204,48 @@ interface SsoClient {
|
|
|
204
204
|
* answers — see the implementation for why that must never become "unverified".
|
|
205
205
|
*/
|
|
206
206
|
addressOwnership(accessToken: string, address: string): Promise<AddressOwnership>;
|
|
207
|
+
/**
|
|
208
|
+
* Invite users to BID on behalf of this app (the app's OWN key, `bidk_…`).
|
|
209
|
+
* One result per email. Throws SsoError on anything BID did not clearly say.
|
|
210
|
+
*/
|
|
211
|
+
inviteUsers(appKey: string, input: InviteUsersInput): Promise<InvitationResult[]>;
|
|
212
|
+
/**
|
|
213
|
+
* Where each user is in the move to BID. Throws SsoError on any state it does
|
|
214
|
+
* not know — never a silent "ready", which would switch off a user's old login
|
|
215
|
+
* while they cannot get in.
|
|
216
|
+
*/
|
|
217
|
+
migrationStatus(appKey: string, emails: string[]): Promise<MigrationStatus>;
|
|
207
218
|
/** Exposed for tests and for a health check; not needed in normal use. */
|
|
208
219
|
readonly jwks: JwksCache;
|
|
209
220
|
}
|
|
210
221
|
type AddressOwnership = "verified" | "unverified" | "not_on_account";
|
|
222
|
+
type InvitationOutcome = "invited" | "already_invited" | "existing" | "invalid" | "too_soon" | "too_many_today" | "send_failed";
|
|
223
|
+
interface InviteUsersInput {
|
|
224
|
+
customerName: string;
|
|
225
|
+
appUrl: string;
|
|
226
|
+
appName?: string;
|
|
227
|
+
switchDate?: string;
|
|
228
|
+
users: Array<{
|
|
229
|
+
email: string;
|
|
230
|
+
name: string;
|
|
231
|
+
}>;
|
|
232
|
+
}
|
|
233
|
+
interface InvitationResult {
|
|
234
|
+
email: string;
|
|
235
|
+
outcome: InvitationOutcome;
|
|
236
|
+
mailId?: string;
|
|
237
|
+
problem?: string;
|
|
238
|
+
}
|
|
239
|
+
type MigrationState = "ready" | "invited" | "expired" | "not_invited";
|
|
240
|
+
interface MigrationStatus {
|
|
241
|
+
users: Array<{
|
|
242
|
+
email: string;
|
|
243
|
+
state: MigrationState;
|
|
244
|
+
expiresAt?: string;
|
|
245
|
+
}>;
|
|
246
|
+
counts: Record<string, number>;
|
|
247
|
+
complete: boolean;
|
|
248
|
+
}
|
|
211
249
|
|
|
212
250
|
/**
|
|
213
251
|
* The app's OWN session cookie — signed, not encrypted.
|
package/dist/hono.d.ts
CHANGED
|
@@ -204,10 +204,48 @@ interface SsoClient {
|
|
|
204
204
|
* answers — see the implementation for why that must never become "unverified".
|
|
205
205
|
*/
|
|
206
206
|
addressOwnership(accessToken: string, address: string): Promise<AddressOwnership>;
|
|
207
|
+
/**
|
|
208
|
+
* Invite users to BID on behalf of this app (the app's OWN key, `bidk_…`).
|
|
209
|
+
* One result per email. Throws SsoError on anything BID did not clearly say.
|
|
210
|
+
*/
|
|
211
|
+
inviteUsers(appKey: string, input: InviteUsersInput): Promise<InvitationResult[]>;
|
|
212
|
+
/**
|
|
213
|
+
* Where each user is in the move to BID. Throws SsoError on any state it does
|
|
214
|
+
* not know — never a silent "ready", which would switch off a user's old login
|
|
215
|
+
* while they cannot get in.
|
|
216
|
+
*/
|
|
217
|
+
migrationStatus(appKey: string, emails: string[]): Promise<MigrationStatus>;
|
|
207
218
|
/** Exposed for tests and for a health check; not needed in normal use. */
|
|
208
219
|
readonly jwks: JwksCache;
|
|
209
220
|
}
|
|
210
221
|
type AddressOwnership = "verified" | "unverified" | "not_on_account";
|
|
222
|
+
type InvitationOutcome = "invited" | "already_invited" | "existing" | "invalid" | "too_soon" | "too_many_today" | "send_failed";
|
|
223
|
+
interface InviteUsersInput {
|
|
224
|
+
customerName: string;
|
|
225
|
+
appUrl: string;
|
|
226
|
+
appName?: string;
|
|
227
|
+
switchDate?: string;
|
|
228
|
+
users: Array<{
|
|
229
|
+
email: string;
|
|
230
|
+
name: string;
|
|
231
|
+
}>;
|
|
232
|
+
}
|
|
233
|
+
interface InvitationResult {
|
|
234
|
+
email: string;
|
|
235
|
+
outcome: InvitationOutcome;
|
|
236
|
+
mailId?: string;
|
|
237
|
+
problem?: string;
|
|
238
|
+
}
|
|
239
|
+
type MigrationState = "ready" | "invited" | "expired" | "not_invited";
|
|
240
|
+
interface MigrationStatus {
|
|
241
|
+
users: Array<{
|
|
242
|
+
email: string;
|
|
243
|
+
state: MigrationState;
|
|
244
|
+
expiresAt?: string;
|
|
245
|
+
}>;
|
|
246
|
+
counts: Record<string, number>;
|
|
247
|
+
complete: boolean;
|
|
248
|
+
}
|
|
211
249
|
|
|
212
250
|
/**
|
|
213
251
|
* The app's OWN session cookie — signed, not encrypted.
|
package/dist/hono.js
CHANGED
|
@@ -185,6 +185,17 @@ async function challengeFor(verifier) {
|
|
|
185
185
|
return b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
|
|
186
186
|
}
|
|
187
187
|
var ADDRESS_OWNERSHIP = /* @__PURE__ */ new Set(["verified", "unverified", "not_on_account"]);
|
|
188
|
+
var INVITATION_OUTCOMES = /* @__PURE__ */ new Set([
|
|
189
|
+
"invited",
|
|
190
|
+
"already_invited",
|
|
191
|
+
"existing",
|
|
192
|
+
"invalid",
|
|
193
|
+
"too_soon",
|
|
194
|
+
"too_many_today",
|
|
195
|
+
"send_failed"
|
|
196
|
+
]);
|
|
197
|
+
var MIGRATION_STATES = /* @__PURE__ */ new Set(["ready", "invited", "expired", "not_invited"]);
|
|
198
|
+
var MAX_INVITATIONS = 500;
|
|
188
199
|
function excerptForLog(raw, clientSecret) {
|
|
189
200
|
let text = raw.replace(/\s+/g, " ").trim();
|
|
190
201
|
if (clientSecret) text = text.split(clientSecret).join("[redacted client_secret]");
|
|
@@ -219,6 +230,40 @@ var ALLOWED_ALGS = [
|
|
|
219
230
|
];
|
|
220
231
|
function createSsoClient(config, options = {}) {
|
|
221
232
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
233
|
+
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
234
|
+
async function appPost(path, bearer, payload, what) {
|
|
235
|
+
const url = new URL(path, config.issuer).toString();
|
|
236
|
+
const ctrl = new AbortController();
|
|
237
|
+
let timer;
|
|
238
|
+
const timedOut = new Promise((_, reject) => {
|
|
239
|
+
timer = setTimeout(() => {
|
|
240
|
+
ctrl.abort();
|
|
241
|
+
reject(new SsoError(`${url} did not answer within ${timeoutMs} ms \u2014 ${what} is unknown.`));
|
|
242
|
+
}, timeoutMs);
|
|
243
|
+
});
|
|
244
|
+
try {
|
|
245
|
+
const res = await Promise.race([
|
|
246
|
+
fetchImpl(url, {
|
|
247
|
+
method: "POST",
|
|
248
|
+
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
249
|
+
body: JSON.stringify(payload),
|
|
250
|
+
signal: ctrl.signal
|
|
251
|
+
}),
|
|
252
|
+
timedOut
|
|
253
|
+
]);
|
|
254
|
+
if (!res.ok) {
|
|
255
|
+
const detail = await res.text().catch(() => "");
|
|
256
|
+
const hint = detail.includes("invalid_app_key") ? " (invalid_app_key: this app's bidk_ key was refused)" : "";
|
|
257
|
+
throw new SsoError(`${url} answered ${res.status}${hint} \u2014 ${what} is unknown.`);
|
|
258
|
+
}
|
|
259
|
+
return await Promise.race([res.json().catch(() => null), timedOut]);
|
|
260
|
+
} catch (e) {
|
|
261
|
+
if (e instanceof SsoError) throw e;
|
|
262
|
+
throw new SsoError(`${url} could not be reached: ${e instanceof Error ? e.message : String(e)} \u2014 ${what} is unknown.`);
|
|
263
|
+
} finally {
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
222
267
|
let discoveryPromise = null;
|
|
223
268
|
let jwksCache = null;
|
|
224
269
|
async function discovery() {
|
|
@@ -385,21 +430,44 @@ function createSsoClient(config, options = {}) {
|
|
|
385
430
|
return `${doc.end_session_endpoint}?${params}`;
|
|
386
431
|
},
|
|
387
432
|
async addressOwnership(accessToken, address) {
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (!res.ok) {
|
|
395
|
-
throw new SsoError(`${url} answered ${res.status} \u2014 address ownership is unknown, not "unverified".`);
|
|
396
|
-
}
|
|
397
|
-
const body = await res.json().catch(() => null);
|
|
433
|
+
const body = await appPost(
|
|
434
|
+
"/api/app/address-ownership",
|
|
435
|
+
accessToken,
|
|
436
|
+
{ address },
|
|
437
|
+
'address ownership (not "unverified")'
|
|
438
|
+
);
|
|
398
439
|
const status = body?.status;
|
|
399
440
|
if (typeof status !== "string" || !ADDRESS_OWNERSHIP.has(status)) {
|
|
400
|
-
throw new SsoError(
|
|
441
|
+
throw new SsoError(`address-ownership returned status ${JSON.stringify(status)} \u2014 expected verified, unverified or not_on_account.`);
|
|
401
442
|
}
|
|
402
443
|
return status;
|
|
444
|
+
},
|
|
445
|
+
async inviteUsers(appKey, input) {
|
|
446
|
+
if (input.users.length > MAX_INVITATIONS) {
|
|
447
|
+
throw new SsoError(`inviteUsers: ${input.users.length} users, BID accepts at most ${MAX_INVITATIONS} per call.`);
|
|
448
|
+
}
|
|
449
|
+
const body = await appPost("/api/app/invitations", appKey, input, "the invitation outcome");
|
|
450
|
+
if (!Array.isArray(body?.results)) {
|
|
451
|
+
throw new SsoError(`invitations returned no "results" array \u2014 the invitation outcome is unknown.`);
|
|
452
|
+
}
|
|
453
|
+
for (const r of body.results) {
|
|
454
|
+
if (typeof r?.outcome !== "string" || !INVITATION_OUTCOMES.has(r.outcome)) {
|
|
455
|
+
throw new SsoError(`invitations returned outcome ${JSON.stringify(r?.outcome)} for ${String(r?.email)} \u2014 not one this client knows.`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return body.results;
|
|
459
|
+
},
|
|
460
|
+
async migrationStatus(appKey, emails) {
|
|
461
|
+
const body = await appPost("/api/app/migration-status", appKey, { emails }, "the migration state");
|
|
462
|
+
if (!Array.isArray(body?.users) || typeof body.complete !== "boolean") {
|
|
463
|
+
throw new SsoError(`migration-status returned no "users" array or no "complete" flag \u2014 the migration state is unknown.`);
|
|
464
|
+
}
|
|
465
|
+
for (const u of body.users) {
|
|
466
|
+
if (typeof u?.state !== "string" || !MIGRATION_STATES.has(u.state)) {
|
|
467
|
+
throw new SsoError(`migration-status returned state ${JSON.stringify(u?.state)} for ${String(u?.email)} \u2014 never read as "ready".`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return body;
|
|
403
471
|
}
|
|
404
472
|
};
|
|
405
473
|
}
|