@broberg/sso 0.4.0 → 0.6.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 +64 -0
- package/dist/hono.cjs +160 -15
- package/dist/hono.cjs.map +1 -1
- package/dist/hono.d.cts +87 -1
- package/dist/hono.d.ts +87 -1
- package/dist/hono.js +160 -15
- package/dist/hono.js.map +1 -1
- package/dist/index.cjs +114 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +64 -1
- package/dist/index.d.ts +64 -1
- package/dist/index.js +114 -12
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -259,3 +259,67 @@ const answer = await client.addressOwnership(accessToken, "a@example.dk");
|
|
|
259
259
|
```
|
|
260
260
|
|
|
261
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 })`.
|
|
285
|
+
|
|
286
|
+
## «Log ud overalt» — receiving it from BID (since 0.6.0)
|
|
287
|
+
|
|
288
|
+
When a user presses **«Log ud overalt»** in Broberg ID, BID tells every app it has registered for that: an OIDC Back-Channel Logout token to `POST <mount>/backchannel-logout`. The ordinary «Log ud» in BID does **not** reach your app (owner decision D-376ffa), and the package refuses a token carrying `sid` to keep it that way.
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
const sso = ssoRoutes({
|
|
292
|
+
backchannel: {
|
|
293
|
+
store, // REQUIRED — shared by every instance of the app
|
|
294
|
+
onStoreError: "reject", // default: if the store is down, treat the user as logged out
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
app.route("/auth", sso.app); // → POST /auth/backchannel-logout; give BID that exact URL
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**The store is yours, and it must be shared.** Your app runs on more than one machine; a list kept in memory would log the user out on one and not the other. There is no in-memory default — `ssoRoutes` throws at startup without a store. A table in the database you already have is enough:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
// CREATE TABLE sso_logout (sub TEXT PRIMARY KEY, before INTEGER NOT NULL);
|
|
304
|
+
// CREATE TABLE sso_logout_jti (jti TEXT PRIMARY KEY, until INTEGER NOT NULL);
|
|
305
|
+
const store: BackchannelStore = {
|
|
306
|
+
async revokeSubBefore(sub, iat) {
|
|
307
|
+
await db.execute({ sql: `INSERT INTO sso_logout (sub, before) VALUES (?, ?)
|
|
308
|
+
ON CONFLICT(sub) DO UPDATE SET before = MAX(before, excluded.before)`, args: [sub, iat] });
|
|
309
|
+
},
|
|
310
|
+
async revokedBefore(sub) {
|
|
311
|
+
const r = await db.execute({ sql: "SELECT before FROM sso_logout WHERE sub = ?", args: [sub] });
|
|
312
|
+
return r.rows[0] ? Number(r.rows[0].before) : null;
|
|
313
|
+
},
|
|
314
|
+
async useJti(jti, until) {
|
|
315
|
+
const r = await db.execute({ sql: "INSERT OR IGNORE INTO sso_logout_jti (jti, until) VALUES (?, ?)", args: [jti, until] });
|
|
316
|
+
return r.rowsAffected === 1; // false = a replay: answered 200, no new effect
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
What happens: a verified token → `revokeSubBefore(sub, iat)` → from then on `attach`/`require` look up the user and refuse every session minted at or before that moment (sessions from before 0.6.0 carry no timestamp and count as older). A session started after the logout is untouched. Invalid tokens → 400; store down while receiving → 503, so BID can tell the user this app did not log out.
|
|
322
|
+
|
|
323
|
+
**Nothing arrives until BID has registered your URL** — mounting it early is safe (ship dark). Tell broberg-id the exact URL once it is deployed.
|
|
324
|
+
|
|
325
|
+
**The cost:** one store lookup per request that goes through `attach`/`require`.
|
package/dist/hono.cjs
CHANGED
|
@@ -187,6 +187,18 @@ async function challengeFor(verifier) {
|
|
|
187
187
|
return b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
|
|
188
188
|
}
|
|
189
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 BACKCHANNEL_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
201
|
+
var MAX_INVITATIONS = 500;
|
|
190
202
|
function excerptForLog(raw, clientSecret) {
|
|
191
203
|
let text = raw.replace(/\s+/g, " ").trim();
|
|
192
204
|
if (clientSecret) text = text.split(clientSecret).join("[redacted client_secret]");
|
|
@@ -221,6 +233,40 @@ var ALLOWED_ALGS = [
|
|
|
221
233
|
];
|
|
222
234
|
function createSsoClient(config, options = {}) {
|
|
223
235
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
236
|
+
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
237
|
+
async function appPost(path, bearer, payload, what) {
|
|
238
|
+
const url = new URL(path, config.issuer).toString();
|
|
239
|
+
const ctrl = new AbortController();
|
|
240
|
+
let timer;
|
|
241
|
+
const timedOut = new Promise((_, reject) => {
|
|
242
|
+
timer = setTimeout(() => {
|
|
243
|
+
ctrl.abort();
|
|
244
|
+
reject(new SsoError(`${url} did not answer within ${timeoutMs} ms \u2014 ${what} is unknown.`));
|
|
245
|
+
}, timeoutMs);
|
|
246
|
+
});
|
|
247
|
+
try {
|
|
248
|
+
const res = await Promise.race([
|
|
249
|
+
fetchImpl(url, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
252
|
+
body: JSON.stringify(payload),
|
|
253
|
+
signal: ctrl.signal
|
|
254
|
+
}),
|
|
255
|
+
timedOut
|
|
256
|
+
]);
|
|
257
|
+
if (!res.ok) {
|
|
258
|
+
const detail = await res.text().catch(() => "");
|
|
259
|
+
const hint = detail.includes("invalid_app_key") ? " (invalid_app_key: this app's bidk_ key was refused)" : "";
|
|
260
|
+
throw new SsoError(`${url} answered ${res.status}${hint} \u2014 ${what} is unknown.`);
|
|
261
|
+
}
|
|
262
|
+
return await Promise.race([res.json().catch(() => null), timedOut]);
|
|
263
|
+
} catch (e) {
|
|
264
|
+
if (e instanceof SsoError) throw e;
|
|
265
|
+
throw new SsoError(`${url} could not be reached: ${e instanceof Error ? e.message : String(e)} \u2014 ${what} is unknown.`);
|
|
266
|
+
} finally {
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
224
270
|
let discoveryPromise = null;
|
|
225
271
|
let jwksCache = null;
|
|
226
272
|
async function discovery() {
|
|
@@ -261,6 +307,38 @@ function createSsoClient(config, options = {}) {
|
|
|
261
307
|
}
|
|
262
308
|
return jwksCache;
|
|
263
309
|
}
|
|
310
|
+
async function verifyLogoutToken(token) {
|
|
311
|
+
const cache = await keys();
|
|
312
|
+
let header;
|
|
313
|
+
try {
|
|
314
|
+
header = jose.decodeProtectedHeader(token);
|
|
315
|
+
} catch {
|
|
316
|
+
throw new SsoError("logout_token is not a JWS");
|
|
317
|
+
}
|
|
318
|
+
if (!header.kid) throw new SsoError("logout_token has no kid \u2014 cannot pick a signing key");
|
|
319
|
+
let payload;
|
|
320
|
+
try {
|
|
321
|
+
({ payload } = await jose.jwtVerify(token, async () => cache.getKey(header.kid, header.alg ?? "RS256"), {
|
|
322
|
+
issuer: config.issuer,
|
|
323
|
+
audience: config.clientId,
|
|
324
|
+
algorithms: ALLOWED_ALGS,
|
|
325
|
+
typ: "logout+jwt",
|
|
326
|
+
requiredClaims: ["iat", "exp", "jti"]
|
|
327
|
+
}));
|
|
328
|
+
} catch (e) {
|
|
329
|
+
throw new SsoError(`logout_token rejected: ${e instanceof Error ? e.message : String(e)}`);
|
|
330
|
+
}
|
|
331
|
+
const events = payload.events;
|
|
332
|
+
if (!events || typeof events !== "object" || !(BACKCHANNEL_EVENT in events)) {
|
|
333
|
+
throw new SsoError("logout_token has no back-channel logout event");
|
|
334
|
+
}
|
|
335
|
+
if ("nonce" in payload) throw new SsoError("logout_token carries a nonce \u2014 that is an ID token, not a logout token");
|
|
336
|
+
if ("sid" in payload) {
|
|
337
|
+
throw new SsoError("logout_token carries sid \u2014 only sub-wide \xABLog ud overalt\xBB is accepted (D-376ffa)");
|
|
338
|
+
}
|
|
339
|
+
if (typeof payload.sub !== "string" || payload.sub === "") throw new SsoError("logout_token has no sub");
|
|
340
|
+
return { sub: payload.sub, iat: payload.iat, jti: String(payload.jti), exp: payload.exp };
|
|
341
|
+
}
|
|
264
342
|
async function verifyIdToken(idToken, opts = {}) {
|
|
265
343
|
const cache = await keys();
|
|
266
344
|
const header = jose.decodeProtectedHeader(idToken);
|
|
@@ -387,21 +465,45 @@ function createSsoClient(config, options = {}) {
|
|
|
387
465
|
return `${doc.end_session_endpoint}?${params}`;
|
|
388
466
|
},
|
|
389
467
|
async addressOwnership(accessToken, address) {
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
if (!res.ok) {
|
|
397
|
-
throw new SsoError(`${url} answered ${res.status} \u2014 address ownership is unknown, not "unverified".`);
|
|
398
|
-
}
|
|
399
|
-
const body = await res.json().catch(() => null);
|
|
468
|
+
const body = await appPost(
|
|
469
|
+
"/api/app/address-ownership",
|
|
470
|
+
accessToken,
|
|
471
|
+
{ address },
|
|
472
|
+
'address ownership (not "unverified")'
|
|
473
|
+
);
|
|
400
474
|
const status = body?.status;
|
|
401
475
|
if (typeof status !== "string" || !ADDRESS_OWNERSHIP.has(status)) {
|
|
402
|
-
throw new SsoError(
|
|
476
|
+
throw new SsoError(`address-ownership returned status ${JSON.stringify(status)} \u2014 expected verified, unverified or not_on_account.`);
|
|
403
477
|
}
|
|
404
478
|
return status;
|
|
479
|
+
},
|
|
480
|
+
verifyLogoutToken,
|
|
481
|
+
async inviteUsers(appKey, input) {
|
|
482
|
+
if (input.users.length > MAX_INVITATIONS) {
|
|
483
|
+
throw new SsoError(`inviteUsers: ${input.users.length} users, BID accepts at most ${MAX_INVITATIONS} per call.`);
|
|
484
|
+
}
|
|
485
|
+
const body = await appPost("/api/app/invitations", appKey, input, "the invitation outcome");
|
|
486
|
+
if (!Array.isArray(body?.results)) {
|
|
487
|
+
throw new SsoError(`invitations returned no "results" array \u2014 the invitation outcome is unknown.`);
|
|
488
|
+
}
|
|
489
|
+
for (const r of body.results) {
|
|
490
|
+
if (typeof r?.outcome !== "string" || !INVITATION_OUTCOMES.has(r.outcome)) {
|
|
491
|
+
throw new SsoError(`invitations returned outcome ${JSON.stringify(r?.outcome)} for ${String(r?.email)} \u2014 not one this client knows.`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return body.results;
|
|
495
|
+
},
|
|
496
|
+
async migrationStatus(appKey, emails) {
|
|
497
|
+
const body = await appPost("/api/app/migration-status", appKey, { emails }, "the migration state");
|
|
498
|
+
if (!Array.isArray(body?.users) || typeof body.complete !== "boolean") {
|
|
499
|
+
throw new SsoError(`migration-status returned no "users" array or no "complete" flag \u2014 the migration state is unknown.`);
|
|
500
|
+
}
|
|
501
|
+
for (const u of body.users) {
|
|
502
|
+
if (typeof u?.state !== "string" || !MIGRATION_STATES.has(u.state)) {
|
|
503
|
+
throw new SsoError(`migration-status returned state ${JSON.stringify(u?.state)} for ${String(u?.email)} \u2014 never read as "ready".`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return body;
|
|
405
507
|
}
|
|
406
508
|
};
|
|
407
509
|
}
|
|
@@ -531,7 +633,51 @@ function ssoRoutes(options = {}) {
|
|
|
531
633
|
const defaultReturnTo = options.defaultReturnTo ?? "/";
|
|
532
634
|
const txCookie = `${config.cookieName}_tx`;
|
|
533
635
|
const idTokenCookie = `${config.cookieName}_idt`;
|
|
636
|
+
const backchannel = options.backchannel;
|
|
637
|
+
if (backchannel && !backchannel.store) {
|
|
638
|
+
throw new Error("ssoRoutes: backchannel needs a store shared by every instance of the app \u2014 there is no in-memory default.");
|
|
639
|
+
}
|
|
640
|
+
const onStoreError = backchannel?.onStoreError ?? "reject";
|
|
641
|
+
async function liveSession(c) {
|
|
642
|
+
const session = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
643
|
+
if (!session || !backchannel) return session;
|
|
644
|
+
try {
|
|
645
|
+
const before = await backchannel.store.revokedBefore(session.sub);
|
|
646
|
+
if (before !== null && (session.iat ?? 0) <= before) return null;
|
|
647
|
+
return session;
|
|
648
|
+
} catch {
|
|
649
|
+
return onStoreError === "allow" ? session : null;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
534
652
|
const app = new hono.Hono();
|
|
653
|
+
if (backchannel) {
|
|
654
|
+
app.post("/backchannel-logout", async (c) => {
|
|
655
|
+
c.header("Cache-Control", "no-store");
|
|
656
|
+
let token;
|
|
657
|
+
try {
|
|
658
|
+
const form = await c.req.parseBody();
|
|
659
|
+
token = typeof form.logout_token === "string" ? form.logout_token : void 0;
|
|
660
|
+
} catch {
|
|
661
|
+
token = void 0;
|
|
662
|
+
}
|
|
663
|
+
if (!token) return c.json({ error: "invalid_request", error_description: "logout_token is missing" }, 400);
|
|
664
|
+
let verified;
|
|
665
|
+
try {
|
|
666
|
+
verified = await client.verifyLogoutToken(token);
|
|
667
|
+
} catch (e) {
|
|
668
|
+
const msg = e instanceof SsoError ? e.message : "logout_token could not be verified";
|
|
669
|
+
return c.json({ error: "invalid_request", error_description: msg }, 400);
|
|
670
|
+
}
|
|
671
|
+
try {
|
|
672
|
+
if (await backchannel.store.useJti(verified.jti, verified.exp)) {
|
|
673
|
+
await backchannel.store.revokeSubBefore(verified.sub, verified.iat);
|
|
674
|
+
}
|
|
675
|
+
} catch {
|
|
676
|
+
return c.json({ error: "store_unavailable" }, 503);
|
|
677
|
+
}
|
|
678
|
+
return c.body(null, 200);
|
|
679
|
+
});
|
|
680
|
+
}
|
|
535
681
|
app.get("/login", async (c) => {
|
|
536
682
|
const prompt = c.req.query("prompt");
|
|
537
683
|
const start = await client.beginLogin(
|
|
@@ -592,6 +738,7 @@ function ssoRoutes(options = {}) {
|
|
|
592
738
|
const session = {
|
|
593
739
|
sub: result.claims.sub,
|
|
594
740
|
exp,
|
|
741
|
+
iat: Math.floor(Date.now() / 1e3),
|
|
595
742
|
...result.claims.email ? { email: result.claims.email } : {},
|
|
596
743
|
...result.claims.name ? { name: result.claims.name } : {},
|
|
597
744
|
...result.claims.picture ? { picture: result.claims.picture } : {}
|
|
@@ -635,13 +782,11 @@ function ssoRoutes(options = {}) {
|
|
|
635
782
|
return c.redirect(await client.logoutUrl(idTokenHint ? { idTokenHint } : {}), 302);
|
|
636
783
|
});
|
|
637
784
|
const attach = async (c, next) => {
|
|
638
|
-
|
|
639
|
-
c.set(SESSION_KEY, await verifySession(cookie, config.cookieSecret));
|
|
785
|
+
c.set(SESSION_KEY, await liveSession(c));
|
|
640
786
|
await next();
|
|
641
787
|
};
|
|
642
788
|
const require2 = async (c, next) => {
|
|
643
|
-
const
|
|
644
|
-
const session = await verifySession(cookie, config.cookieSecret);
|
|
789
|
+
const session = await liveSession(c);
|
|
645
790
|
if (!session) {
|
|
646
791
|
const url = new URL(c.req.url);
|
|
647
792
|
return c.redirect(
|