@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/dist/hono.d.ts
CHANGED
|
@@ -215,6 +215,11 @@ interface SsoClient {
|
|
|
215
215
|
* while they cannot get in.
|
|
216
216
|
*/
|
|
217
217
|
migrationStatus(appKey: string, emails: string[]): Promise<MigrationStatus>;
|
|
218
|
+
/**
|
|
219
|
+
* Verify an OIDC Back-Channel Logout token from BID («Log ud overalt»).
|
|
220
|
+
* Throws SsoError on anything that is not a valid, sub-only logout token.
|
|
221
|
+
*/
|
|
222
|
+
verifyLogoutToken(token: string): Promise<LogoutToken>;
|
|
218
223
|
/** Exposed for tests and for a health check; not needed in normal use. */
|
|
219
224
|
readonly jwks: JwksCache;
|
|
220
225
|
}
|
|
@@ -246,6 +251,13 @@ interface MigrationStatus {
|
|
|
246
251
|
counts: Record<string, number>;
|
|
247
252
|
complete: boolean;
|
|
248
253
|
}
|
|
254
|
+
/** A verified back-channel logout: every session for `sub` issued at or before `iat` is over. */
|
|
255
|
+
interface LogoutToken {
|
|
256
|
+
sub: string;
|
|
257
|
+
iat: number;
|
|
258
|
+
jti: string;
|
|
259
|
+
exp: number;
|
|
260
|
+
}
|
|
249
261
|
|
|
250
262
|
/**
|
|
251
263
|
* The app's OWN session cookie — signed, not encrypted.
|
|
@@ -266,6 +278,12 @@ interface SessionPayload {
|
|
|
266
278
|
sub: string;
|
|
267
279
|
/** Unix seconds. Checked on every read. */
|
|
268
280
|
exp: number;
|
|
281
|
+
/**
|
|
282
|
+
* Unix seconds, when this session was minted. What «Log ud overalt» compares
|
|
283
|
+
* against (F084.151). Absent on sessions minted before 0.6.0 — those count as
|
|
284
|
+
* older than any logout.
|
|
285
|
+
*/
|
|
286
|
+
iat?: number;
|
|
269
287
|
/** Optional convenience copies; never authorisation data. */
|
|
270
288
|
email?: string;
|
|
271
289
|
name?: string;
|
|
@@ -275,7 +293,37 @@ interface SessionPayload {
|
|
|
275
293
|
picture?: string;
|
|
276
294
|
}
|
|
277
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Where «Log ud overalt» is remembered (F084.151). The APP provides it, backed by
|
|
298
|
+
* something every instance shares — apps run on two machines, so a per-process
|
|
299
|
+
* list would log the user out on one machine and not the other. There is
|
|
300
|
+
* deliberately no in-memory default.
|
|
301
|
+
*/
|
|
302
|
+
interface BackchannelStore {
|
|
303
|
+
/** Every session for `sub` minted at or before `iat` is over. Keep the LATEST iat. */
|
|
304
|
+
revokeSubBefore(sub: string, iat: number): Promise<void>;
|
|
305
|
+
/** The iat recorded for `sub`, or null. */
|
|
306
|
+
revokedBefore(sub: string): Promise<number | null>;
|
|
307
|
+
/** Record a logout token id; true the FIRST time, false for a replay. Keep it until `until` (unix s). */
|
|
308
|
+
useJti(jti: string, until: number): Promise<boolean>;
|
|
309
|
+
}
|
|
310
|
+
interface BackchannelOptions {
|
|
311
|
+
store: BackchannelStore;
|
|
312
|
+
/**
|
|
313
|
+
* When the store cannot answer: "reject" (default) treats the user as logged
|
|
314
|
+
* out; "allow" lets the session through unchecked. «reject» is the secure
|
|
315
|
+
* choice — a user who pressed «Log ud overalt» must not stay in because a
|
|
316
|
+
* database hiccupped — at the price of everyone being out while it lasts.
|
|
317
|
+
*/
|
|
318
|
+
onStoreError?: "reject" | "allow";
|
|
319
|
+
}
|
|
278
320
|
interface SsoRoutesOptions {
|
|
321
|
+
/**
|
|
322
|
+
* Receive «Log ud overalt» from BID at POST <mount>/backchannel-logout.
|
|
323
|
+
* Nothing arrives until BID has registered that URL for this app (ship dark),
|
|
324
|
+
* so mounting it early is safe.
|
|
325
|
+
*/
|
|
326
|
+
backchannel?: BackchannelOptions;
|
|
279
327
|
config?: SsoConfig;
|
|
280
328
|
client?: SsoClient;
|
|
281
329
|
/** Path prefix these routes are mounted under. Used to build the return URL. */
|
|
@@ -295,4 +343,4 @@ type TransactionFailure = "absent" | "expired" | "unreadable";
|
|
|
295
343
|
/** Read the session a middleware attached. */
|
|
296
344
|
declare function getSession(c: Context): SessionPayload | null;
|
|
297
345
|
|
|
298
|
-
export { type SsoRoutesOptions, type TransactionFailure, getSession, ssoRoutes };
|
|
346
|
+
export { type BackchannelOptions, type BackchannelStore, type SsoRoutesOptions, type TransactionFailure, getSession, ssoRoutes };
|
package/dist/hono.js
CHANGED
|
@@ -195,6 +195,7 @@ var INVITATION_OUTCOMES = /* @__PURE__ */ new Set([
|
|
|
195
195
|
"send_failed"
|
|
196
196
|
]);
|
|
197
197
|
var MIGRATION_STATES = /* @__PURE__ */ new Set(["ready", "invited", "expired", "not_invited"]);
|
|
198
|
+
var BACKCHANNEL_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
198
199
|
var MAX_INVITATIONS = 500;
|
|
199
200
|
function excerptForLog(raw, clientSecret) {
|
|
200
201
|
let text = raw.replace(/\s+/g, " ").trim();
|
|
@@ -304,6 +305,38 @@ function createSsoClient(config, options = {}) {
|
|
|
304
305
|
}
|
|
305
306
|
return jwksCache;
|
|
306
307
|
}
|
|
308
|
+
async function verifyLogoutToken(token) {
|
|
309
|
+
const cache = await keys();
|
|
310
|
+
let header;
|
|
311
|
+
try {
|
|
312
|
+
header = decodeProtectedHeader(token);
|
|
313
|
+
} catch {
|
|
314
|
+
throw new SsoError("logout_token is not a JWS");
|
|
315
|
+
}
|
|
316
|
+
if (!header.kid) throw new SsoError("logout_token has no kid \u2014 cannot pick a signing key");
|
|
317
|
+
let payload;
|
|
318
|
+
try {
|
|
319
|
+
({ payload } = await jwtVerify(token, async () => cache.getKey(header.kid, header.alg ?? "RS256"), {
|
|
320
|
+
issuer: config.issuer,
|
|
321
|
+
audience: config.clientId,
|
|
322
|
+
algorithms: ALLOWED_ALGS,
|
|
323
|
+
typ: "logout+jwt",
|
|
324
|
+
requiredClaims: ["iat", "exp", "jti"]
|
|
325
|
+
}));
|
|
326
|
+
} catch (e) {
|
|
327
|
+
throw new SsoError(`logout_token rejected: ${e instanceof Error ? e.message : String(e)}`);
|
|
328
|
+
}
|
|
329
|
+
const events = payload.events;
|
|
330
|
+
if (!events || typeof events !== "object" || !(BACKCHANNEL_EVENT in events)) {
|
|
331
|
+
throw new SsoError("logout_token has no back-channel logout event");
|
|
332
|
+
}
|
|
333
|
+
if ("nonce" in payload) throw new SsoError("logout_token carries a nonce \u2014 that is an ID token, not a logout token");
|
|
334
|
+
if ("sid" in payload) {
|
|
335
|
+
throw new SsoError("logout_token carries sid \u2014 only sub-wide \xABLog ud overalt\xBB is accepted (D-376ffa)");
|
|
336
|
+
}
|
|
337
|
+
if (typeof payload.sub !== "string" || payload.sub === "") throw new SsoError("logout_token has no sub");
|
|
338
|
+
return { sub: payload.sub, iat: payload.iat, jti: String(payload.jti), exp: payload.exp };
|
|
339
|
+
}
|
|
307
340
|
async function verifyIdToken(idToken, opts = {}) {
|
|
308
341
|
const cache = await keys();
|
|
309
342
|
const header = decodeProtectedHeader(idToken);
|
|
@@ -442,6 +475,7 @@ function createSsoClient(config, options = {}) {
|
|
|
442
475
|
}
|
|
443
476
|
return status;
|
|
444
477
|
},
|
|
478
|
+
verifyLogoutToken,
|
|
445
479
|
async inviteUsers(appKey, input) {
|
|
446
480
|
if (input.users.length > MAX_INVITATIONS) {
|
|
447
481
|
throw new SsoError(`inviteUsers: ${input.users.length} users, BID accepts at most ${MAX_INVITATIONS} per call.`);
|
|
@@ -597,7 +631,50 @@ function ssoRoutes(options = {}) {
|
|
|
597
631
|
const defaultReturnTo = options.defaultReturnTo ?? "/";
|
|
598
632
|
const txCookie = `${config.cookieName}_tx`;
|
|
599
633
|
const idTokenCookie = `${config.cookieName}_idt`;
|
|
634
|
+
const backchannel = options.backchannel;
|
|
635
|
+
if (backchannel && !backchannel.store) {
|
|
636
|
+
throw new Error("ssoRoutes: backchannel needs a store shared by every instance of the app \u2014 there is no in-memory default.");
|
|
637
|
+
}
|
|
638
|
+
const onStoreError = backchannel?.onStoreError ?? "reject";
|
|
639
|
+
async function liveSession(c) {
|
|
640
|
+
const session = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
641
|
+
if (!session || !backchannel) return session;
|
|
642
|
+
try {
|
|
643
|
+
const before = await backchannel.store.revokedBefore(session.sub);
|
|
644
|
+
if (before !== null && (session.iat ?? 0) <= before) return null;
|
|
645
|
+
return session;
|
|
646
|
+
} catch {
|
|
647
|
+
return onStoreError === "allow" ? session : null;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
600
650
|
const app = new Hono();
|
|
651
|
+
if (backchannel) {
|
|
652
|
+
app.post("/backchannel-logout", async (c) => {
|
|
653
|
+
c.header("Cache-Control", "no-store");
|
|
654
|
+
let token;
|
|
655
|
+
try {
|
|
656
|
+
const form = await c.req.parseBody();
|
|
657
|
+
token = typeof form.logout_token === "string" ? form.logout_token : void 0;
|
|
658
|
+
} catch {
|
|
659
|
+
token = void 0;
|
|
660
|
+
}
|
|
661
|
+
if (!token) return c.json({ error: "invalid_request", error_description: "logout_token is missing" }, 400);
|
|
662
|
+
let verified;
|
|
663
|
+
try {
|
|
664
|
+
verified = await client.verifyLogoutToken(token);
|
|
665
|
+
} catch (e) {
|
|
666
|
+
const msg = e instanceof SsoError ? e.message : "logout_token could not be verified";
|
|
667
|
+
return c.json({ error: "invalid_request", error_description: msg }, 400);
|
|
668
|
+
}
|
|
669
|
+
try {
|
|
670
|
+
await backchannel.store.revokeSubBefore(verified.sub, verified.iat);
|
|
671
|
+
await backchannel.store.useJti(verified.jti, verified.exp);
|
|
672
|
+
} catch {
|
|
673
|
+
return c.json({ error: "store_unavailable" }, 503);
|
|
674
|
+
}
|
|
675
|
+
return c.body(null, 200);
|
|
676
|
+
});
|
|
677
|
+
}
|
|
601
678
|
app.get("/login", async (c) => {
|
|
602
679
|
const prompt = c.req.query("prompt");
|
|
603
680
|
const start = await client.beginLogin(
|
|
@@ -658,6 +735,7 @@ function ssoRoutes(options = {}) {
|
|
|
658
735
|
const session = {
|
|
659
736
|
sub: result.claims.sub,
|
|
660
737
|
exp,
|
|
738
|
+
iat: Math.floor(Date.now() / 1e3),
|
|
661
739
|
...result.claims.email ? { email: result.claims.email } : {},
|
|
662
740
|
...result.claims.name ? { name: result.claims.name } : {},
|
|
663
741
|
...result.claims.picture ? { picture: result.claims.picture } : {}
|
|
@@ -701,13 +779,11 @@ function ssoRoutes(options = {}) {
|
|
|
701
779
|
return c.redirect(await client.logoutUrl(idTokenHint ? { idTokenHint } : {}), 302);
|
|
702
780
|
});
|
|
703
781
|
const attach = async (c, next) => {
|
|
704
|
-
|
|
705
|
-
c.set(SESSION_KEY, await verifySession(cookie, config.cookieSecret));
|
|
782
|
+
c.set(SESSION_KEY, await liveSession(c));
|
|
706
783
|
await next();
|
|
707
784
|
};
|
|
708
785
|
const require2 = async (c, next) => {
|
|
709
|
-
const
|
|
710
|
-
const session = await verifySession(cookie, config.cookieSecret);
|
|
786
|
+
const session = await liveSession(c);
|
|
711
787
|
if (!session) {
|
|
712
788
|
const url = new URL(c.req.url);
|
|
713
789
|
return c.redirect(
|