@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/dist/hono.d.ts
CHANGED
|
@@ -204,10 +204,60 @@ 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>;
|
|
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>;
|
|
207
223
|
/** Exposed for tests and for a health check; not needed in normal use. */
|
|
208
224
|
readonly jwks: JwksCache;
|
|
209
225
|
}
|
|
210
226
|
type AddressOwnership = "verified" | "unverified" | "not_on_account";
|
|
227
|
+
type InvitationOutcome = "invited" | "already_invited" | "existing" | "invalid" | "too_soon" | "too_many_today" | "send_failed";
|
|
228
|
+
interface InviteUsersInput {
|
|
229
|
+
customerName: string;
|
|
230
|
+
appUrl: string;
|
|
231
|
+
appName?: string;
|
|
232
|
+
switchDate?: string;
|
|
233
|
+
users: Array<{
|
|
234
|
+
email: string;
|
|
235
|
+
name: string;
|
|
236
|
+
}>;
|
|
237
|
+
}
|
|
238
|
+
interface InvitationResult {
|
|
239
|
+
email: string;
|
|
240
|
+
outcome: InvitationOutcome;
|
|
241
|
+
mailId?: string;
|
|
242
|
+
problem?: string;
|
|
243
|
+
}
|
|
244
|
+
type MigrationState = "ready" | "invited" | "expired" | "not_invited";
|
|
245
|
+
interface MigrationStatus {
|
|
246
|
+
users: Array<{
|
|
247
|
+
email: string;
|
|
248
|
+
state: MigrationState;
|
|
249
|
+
expiresAt?: string;
|
|
250
|
+
}>;
|
|
251
|
+
counts: Record<string, number>;
|
|
252
|
+
complete: boolean;
|
|
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
|
+
}
|
|
211
261
|
|
|
212
262
|
/**
|
|
213
263
|
* The app's OWN session cookie — signed, not encrypted.
|
|
@@ -228,6 +278,12 @@ interface SessionPayload {
|
|
|
228
278
|
sub: string;
|
|
229
279
|
/** Unix seconds. Checked on every read. */
|
|
230
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;
|
|
231
287
|
/** Optional convenience copies; never authorisation data. */
|
|
232
288
|
email?: string;
|
|
233
289
|
name?: string;
|
|
@@ -237,7 +293,37 @@ interface SessionPayload {
|
|
|
237
293
|
picture?: string;
|
|
238
294
|
}
|
|
239
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
|
+
}
|
|
240
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;
|
|
241
327
|
config?: SsoConfig;
|
|
242
328
|
client?: SsoClient;
|
|
243
329
|
/** Path prefix these routes are mounted under. Used to build the return URL. */
|
|
@@ -257,4 +343,4 @@ type TransactionFailure = "absent" | "expired" | "unreadable";
|
|
|
257
343
|
/** Read the session a middleware attached. */
|
|
258
344
|
declare function getSession(c: Context): SessionPayload | null;
|
|
259
345
|
|
|
260
|
-
export { type SsoRoutesOptions, type TransactionFailure, getSession, ssoRoutes };
|
|
346
|
+
export { type BackchannelOptions, type BackchannelStore, type SsoRoutesOptions, type TransactionFailure, getSession, ssoRoutes };
|
package/dist/hono.js
CHANGED
|
@@ -185,6 +185,18 @@ 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 BACKCHANNEL_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
199
|
+
var MAX_INVITATIONS = 500;
|
|
188
200
|
function excerptForLog(raw, clientSecret) {
|
|
189
201
|
let text = raw.replace(/\s+/g, " ").trim();
|
|
190
202
|
if (clientSecret) text = text.split(clientSecret).join("[redacted client_secret]");
|
|
@@ -219,6 +231,40 @@ var ALLOWED_ALGS = [
|
|
|
219
231
|
];
|
|
220
232
|
function createSsoClient(config, options = {}) {
|
|
221
233
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
234
|
+
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
235
|
+
async function appPost(path, bearer, payload, what) {
|
|
236
|
+
const url = new URL(path, config.issuer).toString();
|
|
237
|
+
const ctrl = new AbortController();
|
|
238
|
+
let timer;
|
|
239
|
+
const timedOut = new Promise((_, reject) => {
|
|
240
|
+
timer = setTimeout(() => {
|
|
241
|
+
ctrl.abort();
|
|
242
|
+
reject(new SsoError(`${url} did not answer within ${timeoutMs} ms \u2014 ${what} is unknown.`));
|
|
243
|
+
}, timeoutMs);
|
|
244
|
+
});
|
|
245
|
+
try {
|
|
246
|
+
const res = await Promise.race([
|
|
247
|
+
fetchImpl(url, {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
250
|
+
body: JSON.stringify(payload),
|
|
251
|
+
signal: ctrl.signal
|
|
252
|
+
}),
|
|
253
|
+
timedOut
|
|
254
|
+
]);
|
|
255
|
+
if (!res.ok) {
|
|
256
|
+
const detail = await res.text().catch(() => "");
|
|
257
|
+
const hint = detail.includes("invalid_app_key") ? " (invalid_app_key: this app's bidk_ key was refused)" : "";
|
|
258
|
+
throw new SsoError(`${url} answered ${res.status}${hint} \u2014 ${what} is unknown.`);
|
|
259
|
+
}
|
|
260
|
+
return await Promise.race([res.json().catch(() => null), timedOut]);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
if (e instanceof SsoError) throw e;
|
|
263
|
+
throw new SsoError(`${url} could not be reached: ${e instanceof Error ? e.message : String(e)} \u2014 ${what} is unknown.`);
|
|
264
|
+
} finally {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
222
268
|
let discoveryPromise = null;
|
|
223
269
|
let jwksCache = null;
|
|
224
270
|
async function discovery() {
|
|
@@ -259,6 +305,38 @@ function createSsoClient(config, options = {}) {
|
|
|
259
305
|
}
|
|
260
306
|
return jwksCache;
|
|
261
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
|
+
}
|
|
262
340
|
async function verifyIdToken(idToken, opts = {}) {
|
|
263
341
|
const cache = await keys();
|
|
264
342
|
const header = decodeProtectedHeader(idToken);
|
|
@@ -385,21 +463,45 @@ function createSsoClient(config, options = {}) {
|
|
|
385
463
|
return `${doc.end_session_endpoint}?${params}`;
|
|
386
464
|
},
|
|
387
465
|
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);
|
|
466
|
+
const body = await appPost(
|
|
467
|
+
"/api/app/address-ownership",
|
|
468
|
+
accessToken,
|
|
469
|
+
{ address },
|
|
470
|
+
'address ownership (not "unverified")'
|
|
471
|
+
);
|
|
398
472
|
const status = body?.status;
|
|
399
473
|
if (typeof status !== "string" || !ADDRESS_OWNERSHIP.has(status)) {
|
|
400
|
-
throw new SsoError(
|
|
474
|
+
throw new SsoError(`address-ownership returned status ${JSON.stringify(status)} \u2014 expected verified, unverified or not_on_account.`);
|
|
401
475
|
}
|
|
402
476
|
return status;
|
|
477
|
+
},
|
|
478
|
+
verifyLogoutToken,
|
|
479
|
+
async inviteUsers(appKey, input) {
|
|
480
|
+
if (input.users.length > MAX_INVITATIONS) {
|
|
481
|
+
throw new SsoError(`inviteUsers: ${input.users.length} users, BID accepts at most ${MAX_INVITATIONS} per call.`);
|
|
482
|
+
}
|
|
483
|
+
const body = await appPost("/api/app/invitations", appKey, input, "the invitation outcome");
|
|
484
|
+
if (!Array.isArray(body?.results)) {
|
|
485
|
+
throw new SsoError(`invitations returned no "results" array \u2014 the invitation outcome is unknown.`);
|
|
486
|
+
}
|
|
487
|
+
for (const r of body.results) {
|
|
488
|
+
if (typeof r?.outcome !== "string" || !INVITATION_OUTCOMES.has(r.outcome)) {
|
|
489
|
+
throw new SsoError(`invitations returned outcome ${JSON.stringify(r?.outcome)} for ${String(r?.email)} \u2014 not one this client knows.`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return body.results;
|
|
493
|
+
},
|
|
494
|
+
async migrationStatus(appKey, emails) {
|
|
495
|
+
const body = await appPost("/api/app/migration-status", appKey, { emails }, "the migration state");
|
|
496
|
+
if (!Array.isArray(body?.users) || typeof body.complete !== "boolean") {
|
|
497
|
+
throw new SsoError(`migration-status returned no "users" array or no "complete" flag \u2014 the migration state is unknown.`);
|
|
498
|
+
}
|
|
499
|
+
for (const u of body.users) {
|
|
500
|
+
if (typeof u?.state !== "string" || !MIGRATION_STATES.has(u.state)) {
|
|
501
|
+
throw new SsoError(`migration-status returned state ${JSON.stringify(u?.state)} for ${String(u?.email)} \u2014 never read as "ready".`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return body;
|
|
403
505
|
}
|
|
404
506
|
};
|
|
405
507
|
}
|
|
@@ -529,7 +631,51 @@ function ssoRoutes(options = {}) {
|
|
|
529
631
|
const defaultReturnTo = options.defaultReturnTo ?? "/";
|
|
530
632
|
const txCookie = `${config.cookieName}_tx`;
|
|
531
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
|
+
}
|
|
532
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
|
+
if (await backchannel.store.useJti(verified.jti, verified.exp)) {
|
|
671
|
+
await backchannel.store.revokeSubBefore(verified.sub, verified.iat);
|
|
672
|
+
}
|
|
673
|
+
} catch {
|
|
674
|
+
return c.json({ error: "store_unavailable" }, 503);
|
|
675
|
+
}
|
|
676
|
+
return c.body(null, 200);
|
|
677
|
+
});
|
|
678
|
+
}
|
|
533
679
|
app.get("/login", async (c) => {
|
|
534
680
|
const prompt = c.req.query("prompt");
|
|
535
681
|
const start = await client.beginLogin(
|
|
@@ -590,6 +736,7 @@ function ssoRoutes(options = {}) {
|
|
|
590
736
|
const session = {
|
|
591
737
|
sub: result.claims.sub,
|
|
592
738
|
exp,
|
|
739
|
+
iat: Math.floor(Date.now() / 1e3),
|
|
593
740
|
...result.claims.email ? { email: result.claims.email } : {},
|
|
594
741
|
...result.claims.name ? { name: result.claims.name } : {},
|
|
595
742
|
...result.claims.picture ? { picture: result.claims.picture } : {}
|
|
@@ -633,13 +780,11 @@ function ssoRoutes(options = {}) {
|
|
|
633
780
|
return c.redirect(await client.logoutUrl(idTokenHint ? { idTokenHint } : {}), 302);
|
|
634
781
|
});
|
|
635
782
|
const attach = async (c, next) => {
|
|
636
|
-
|
|
637
|
-
c.set(SESSION_KEY, await verifySession(cookie, config.cookieSecret));
|
|
783
|
+
c.set(SESSION_KEY, await liveSession(c));
|
|
638
784
|
await next();
|
|
639
785
|
};
|
|
640
786
|
const require2 = async (c, next) => {
|
|
641
|
-
const
|
|
642
|
-
const session = await verifySession(cookie, config.cookieSecret);
|
|
787
|
+
const session = await liveSession(c);
|
|
643
788
|
if (!session) {
|
|
644
789
|
const url = new URL(c.req.url);
|
|
645
790
|
return c.redirect(
|