@broberg/sso 0.5.0 → 0.6.1
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 +41 -0
- package/dist/hono.cjs +80 -4
- package/dist/hono.cjs.map +1 -1
- package/dist/hono.d.cts +49 -1
- package/dist/hono.d.ts +49 -1
- package/dist/hono.js +80 -4
- package/dist/hono.js.map +1 -1
- package/dist/index.cjs +34 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +19 -1
- package/dist/index.d.ts +19 -1
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -282,3 +282,44 @@ const status = await client.migrationStatus(process.env.BID_APP_KEY!, ["sanne@ex
|
|
|
282
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
283
|
|
|
284
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
|
@@ -197,6 +197,7 @@ var INVITATION_OUTCOMES = /* @__PURE__ */ new Set([
|
|
|
197
197
|
"send_failed"
|
|
198
198
|
]);
|
|
199
199
|
var MIGRATION_STATES = /* @__PURE__ */ new Set(["ready", "invited", "expired", "not_invited"]);
|
|
200
|
+
var BACKCHANNEL_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
200
201
|
var MAX_INVITATIONS = 500;
|
|
201
202
|
function excerptForLog(raw, clientSecret) {
|
|
202
203
|
let text = raw.replace(/\s+/g, " ").trim();
|
|
@@ -306,6 +307,38 @@ function createSsoClient(config, options = {}) {
|
|
|
306
307
|
}
|
|
307
308
|
return jwksCache;
|
|
308
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
|
+
}
|
|
309
342
|
async function verifyIdToken(idToken, opts = {}) {
|
|
310
343
|
const cache = await keys();
|
|
311
344
|
const header = jose.decodeProtectedHeader(idToken);
|
|
@@ -444,6 +477,7 @@ function createSsoClient(config, options = {}) {
|
|
|
444
477
|
}
|
|
445
478
|
return status;
|
|
446
479
|
},
|
|
480
|
+
verifyLogoutToken,
|
|
447
481
|
async inviteUsers(appKey, input) {
|
|
448
482
|
if (input.users.length > MAX_INVITATIONS) {
|
|
449
483
|
throw new SsoError(`inviteUsers: ${input.users.length} users, BID accepts at most ${MAX_INVITATIONS} per call.`);
|
|
@@ -599,7 +633,50 @@ function ssoRoutes(options = {}) {
|
|
|
599
633
|
const defaultReturnTo = options.defaultReturnTo ?? "/";
|
|
600
634
|
const txCookie = `${config.cookieName}_tx`;
|
|
601
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
|
+
}
|
|
602
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
|
+
await backchannel.store.revokeSubBefore(verified.sub, verified.iat);
|
|
673
|
+
await backchannel.store.useJti(verified.jti, verified.exp);
|
|
674
|
+
} catch {
|
|
675
|
+
return c.json({ error: "store_unavailable" }, 503);
|
|
676
|
+
}
|
|
677
|
+
return c.body(null, 200);
|
|
678
|
+
});
|
|
679
|
+
}
|
|
603
680
|
app.get("/login", async (c) => {
|
|
604
681
|
const prompt = c.req.query("prompt");
|
|
605
682
|
const start = await client.beginLogin(
|
|
@@ -660,6 +737,7 @@ function ssoRoutes(options = {}) {
|
|
|
660
737
|
const session = {
|
|
661
738
|
sub: result.claims.sub,
|
|
662
739
|
exp,
|
|
740
|
+
iat: Math.floor(Date.now() / 1e3),
|
|
663
741
|
...result.claims.email ? { email: result.claims.email } : {},
|
|
664
742
|
...result.claims.name ? { name: result.claims.name } : {},
|
|
665
743
|
...result.claims.picture ? { picture: result.claims.picture } : {}
|
|
@@ -703,13 +781,11 @@ function ssoRoutes(options = {}) {
|
|
|
703
781
|
return c.redirect(await client.logoutUrl(idTokenHint ? { idTokenHint } : {}), 302);
|
|
704
782
|
});
|
|
705
783
|
const attach = async (c, next) => {
|
|
706
|
-
|
|
707
|
-
c.set(SESSION_KEY, await verifySession(cookie, config.cookieSecret));
|
|
784
|
+
c.set(SESSION_KEY, await liveSession(c));
|
|
708
785
|
await next();
|
|
709
786
|
};
|
|
710
787
|
const require2 = async (c, next) => {
|
|
711
|
-
const
|
|
712
|
-
const session = await verifySession(cookie, config.cookieSecret);
|
|
788
|
+
const session = await liveSession(c);
|
|
713
789
|
if (!session) {
|
|
714
790
|
const url = new URL(c.req.url);
|
|
715
791
|
return c.redirect(
|