@broberg/sso 0.7.0 → 0.9.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 +55 -1
- package/dist/hono.cjs +125 -8
- package/dist/hono.cjs.map +1 -1
- package/dist/hono.d.cts +72 -0
- package/dist/hono.d.ts +72 -0
- package/dist/hono.js +125 -8
- package/dist/hono.js.map +1 -1
- package/dist/index.cjs +67 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +77 -1
- package/dist/index.d.ts +77 -1
- package/dist/index.js +67 -6
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/hono.d.cts
CHANGED
|
@@ -162,9 +162,23 @@ interface SsoClaims extends JWTPayload {
|
|
|
162
162
|
}
|
|
163
163
|
interface LoginResult {
|
|
164
164
|
claims: SsoClaims;
|
|
165
|
+
/**
|
|
166
|
+
* The claims in `claims` that came from the UNSIGNED userinfo response and
|
|
167
|
+
* not from the signed ID token (components-F084.51, since 0.8.0). Sorted; an
|
|
168
|
+
* empty list means everything in `claims` was signed (or userinfo was not
|
|
169
|
+
* reached). When both sources carry a claim, the signed token's value wins
|
|
170
|
+
* and the claim is not listed.
|
|
171
|
+
*
|
|
172
|
+
* On BID today this always contains "email" and "email_verified": the ID
|
|
173
|
+
* token does not carry them (measured by broberg-id, 20 Sep 2026). A decision
|
|
174
|
+
* that must rest on a signed identity binds on `claims.sub`, never on email.
|
|
175
|
+
*/
|
|
176
|
+
unverifiedClaims: string[];
|
|
165
177
|
idToken: string;
|
|
166
178
|
accessToken?: string;
|
|
167
179
|
refreshToken?: string;
|
|
180
|
+
/** Seconds the access token lives, when BID said so (`expires_in`). */
|
|
181
|
+
expiresIn?: number;
|
|
168
182
|
}
|
|
169
183
|
/**
|
|
170
184
|
* `prompt=none` asks BID to answer WITHOUT showing anything (components-F084.6).
|
|
@@ -204,6 +218,16 @@ interface SsoClient {
|
|
|
204
218
|
* answers — see the implementation for why that must never become "unverified".
|
|
205
219
|
*/
|
|
206
220
|
addressOwnership(accessToken: string, address: string): Promise<AddressOwnership>;
|
|
221
|
+
/**
|
|
222
|
+
* F095.1 — trade a refresh token for a new access token (`grant_type=refresh_token`).
|
|
223
|
+
* Throws SsoError on refusal; the message carries the status and BID's error
|
|
224
|
+
* code, never a token.
|
|
225
|
+
*/
|
|
226
|
+
refreshTokens(refreshToken: string): Promise<{
|
|
227
|
+
accessToken: string;
|
|
228
|
+
refreshToken?: string;
|
|
229
|
+
expiresIn?: number;
|
|
230
|
+
}>;
|
|
207
231
|
/**
|
|
208
232
|
* Invite users to BID on behalf of this app (the app's OWN key, `bidk_…`).
|
|
209
233
|
* One result per email. Throws SsoError on anything BID did not clearly say.
|
|
@@ -259,6 +283,41 @@ interface LogoutToken {
|
|
|
259
283
|
exp: number;
|
|
260
284
|
}
|
|
261
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Server-side tokens for calling Broberg ID on the user's behalf (F095.1).
|
|
288
|
+
*
|
|
289
|
+
* The login already RECEIVES an access token and a refresh token; before 0.9.0
|
|
290
|
+
* ssoRoutes dropped both, so an app could not call BID's app API (the account
|
|
291
|
+
* page: read and change your own name and picture) as the user.
|
|
292
|
+
*
|
|
293
|
+
* ── WHY NOT IN THE COOKIE ────────────────────────────────────────────────
|
|
294
|
+
* The session cookie is SIGNED, not encrypted, and the browser can read it. A
|
|
295
|
+
* refresh token is the longest-lived, highest-value credential in the whole
|
|
296
|
+
* flow: whoever holds it can mint access tokens until it is revoked. So the
|
|
297
|
+
* tokens live server-side, keyed by a random `sid` that is the only thing the
|
|
298
|
+
* cookie carries. A stolen cookie still needs the server to be useful.
|
|
299
|
+
*/
|
|
300
|
+
interface TokenSet {
|
|
301
|
+
/** The BID subject the tokens belong to — lets «Log ud overalt» find them. */
|
|
302
|
+
sub: string;
|
|
303
|
+
accessToken: string;
|
|
304
|
+
refreshToken?: string;
|
|
305
|
+
/** Unix seconds. */
|
|
306
|
+
expiresAt: number;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Where the tokens live. The APP provides it in production, backed by
|
|
310
|
+
* something every instance shares (SQLite on one machine, a database or Redis
|
|
311
|
+
* on several) — the same reason BackchannelStore has no in-memory default.
|
|
312
|
+
*/
|
|
313
|
+
interface TokenStore {
|
|
314
|
+
get(sid: string): Promise<TokenSet | undefined>;
|
|
315
|
+
set(sid: string, tokens: TokenSet): Promise<void>;
|
|
316
|
+
delete(sid: string): Promise<void>;
|
|
317
|
+
/** Drop every token set for `sub` — what «Log ud overalt» calls. */
|
|
318
|
+
deleteSub(sub: string): Promise<void>;
|
|
319
|
+
}
|
|
320
|
+
|
|
262
321
|
/**
|
|
263
322
|
* The app's OWN session cookie — signed, not encrypted.
|
|
264
323
|
*
|
|
@@ -284,6 +343,12 @@ interface SessionPayload {
|
|
|
284
343
|
* older than any logout.
|
|
285
344
|
*/
|
|
286
345
|
iat?: number;
|
|
346
|
+
/**
|
|
347
|
+
* F095.1 — the key to this session's server-side tokens (ssoRoutes'
|
|
348
|
+
* `tokenStore`). Random, 128 bits. The tokens themselves are NEVER in the
|
|
349
|
+
* cookie: it is signed, not encrypted, and the browser can read it.
|
|
350
|
+
*/
|
|
351
|
+
sid?: string;
|
|
287
352
|
/** Optional convenience copies; never authorisation data. */
|
|
288
353
|
email?: string;
|
|
289
354
|
name?: string;
|
|
@@ -344,6 +409,12 @@ interface SsoRoutesOptions {
|
|
|
344
409
|
loginPath?: string;
|
|
345
410
|
/** Where to send a user after a successful login when no returnTo is given. */
|
|
346
411
|
defaultReturnTo?: string;
|
|
412
|
+
/**
|
|
413
|
+
* F095.1 — keep the user's access and refresh token SERVER-SIDE, so the app
|
|
414
|
+
* can call BID's app API (the account page) as the user via getAccessToken.
|
|
415
|
+
* Without it, login behaves exactly as in 0.8.0 and the tokens are dropped.
|
|
416
|
+
*/
|
|
417
|
+
tokenStore?: TokenStore;
|
|
347
418
|
}
|
|
348
419
|
declare function ssoRoutes(options?: SsoRoutesOptions): {
|
|
349
420
|
app: Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
|
|
@@ -351,6 +422,7 @@ declare function ssoRoutes(options?: SsoRoutesOptions): {
|
|
|
351
422
|
require: MiddlewareHandler;
|
|
352
423
|
client: SsoClient;
|
|
353
424
|
config: SsoConfig;
|
|
425
|
+
getAccessToken: (c: Context) => Promise<string>;
|
|
354
426
|
};
|
|
355
427
|
/** Why a login transaction could not be read back. Three states, three answers. */
|
|
356
428
|
type TransactionFailure = "absent" | "expired" | "unreadable";
|
package/dist/hono.d.ts
CHANGED
|
@@ -162,9 +162,23 @@ interface SsoClaims extends JWTPayload {
|
|
|
162
162
|
}
|
|
163
163
|
interface LoginResult {
|
|
164
164
|
claims: SsoClaims;
|
|
165
|
+
/**
|
|
166
|
+
* The claims in `claims` that came from the UNSIGNED userinfo response and
|
|
167
|
+
* not from the signed ID token (components-F084.51, since 0.8.0). Sorted; an
|
|
168
|
+
* empty list means everything in `claims` was signed (or userinfo was not
|
|
169
|
+
* reached). When both sources carry a claim, the signed token's value wins
|
|
170
|
+
* and the claim is not listed.
|
|
171
|
+
*
|
|
172
|
+
* On BID today this always contains "email" and "email_verified": the ID
|
|
173
|
+
* token does not carry them (measured by broberg-id, 20 Sep 2026). A decision
|
|
174
|
+
* that must rest on a signed identity binds on `claims.sub`, never on email.
|
|
175
|
+
*/
|
|
176
|
+
unverifiedClaims: string[];
|
|
165
177
|
idToken: string;
|
|
166
178
|
accessToken?: string;
|
|
167
179
|
refreshToken?: string;
|
|
180
|
+
/** Seconds the access token lives, when BID said so (`expires_in`). */
|
|
181
|
+
expiresIn?: number;
|
|
168
182
|
}
|
|
169
183
|
/**
|
|
170
184
|
* `prompt=none` asks BID to answer WITHOUT showing anything (components-F084.6).
|
|
@@ -204,6 +218,16 @@ interface SsoClient {
|
|
|
204
218
|
* answers — see the implementation for why that must never become "unverified".
|
|
205
219
|
*/
|
|
206
220
|
addressOwnership(accessToken: string, address: string): Promise<AddressOwnership>;
|
|
221
|
+
/**
|
|
222
|
+
* F095.1 — trade a refresh token for a new access token (`grant_type=refresh_token`).
|
|
223
|
+
* Throws SsoError on refusal; the message carries the status and BID's error
|
|
224
|
+
* code, never a token.
|
|
225
|
+
*/
|
|
226
|
+
refreshTokens(refreshToken: string): Promise<{
|
|
227
|
+
accessToken: string;
|
|
228
|
+
refreshToken?: string;
|
|
229
|
+
expiresIn?: number;
|
|
230
|
+
}>;
|
|
207
231
|
/**
|
|
208
232
|
* Invite users to BID on behalf of this app (the app's OWN key, `bidk_…`).
|
|
209
233
|
* One result per email. Throws SsoError on anything BID did not clearly say.
|
|
@@ -259,6 +283,41 @@ interface LogoutToken {
|
|
|
259
283
|
exp: number;
|
|
260
284
|
}
|
|
261
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Server-side tokens for calling Broberg ID on the user's behalf (F095.1).
|
|
288
|
+
*
|
|
289
|
+
* The login already RECEIVES an access token and a refresh token; before 0.9.0
|
|
290
|
+
* ssoRoutes dropped both, so an app could not call BID's app API (the account
|
|
291
|
+
* page: read and change your own name and picture) as the user.
|
|
292
|
+
*
|
|
293
|
+
* ── WHY NOT IN THE COOKIE ────────────────────────────────────────────────
|
|
294
|
+
* The session cookie is SIGNED, not encrypted, and the browser can read it. A
|
|
295
|
+
* refresh token is the longest-lived, highest-value credential in the whole
|
|
296
|
+
* flow: whoever holds it can mint access tokens until it is revoked. So the
|
|
297
|
+
* tokens live server-side, keyed by a random `sid` that is the only thing the
|
|
298
|
+
* cookie carries. A stolen cookie still needs the server to be useful.
|
|
299
|
+
*/
|
|
300
|
+
interface TokenSet {
|
|
301
|
+
/** The BID subject the tokens belong to — lets «Log ud overalt» find them. */
|
|
302
|
+
sub: string;
|
|
303
|
+
accessToken: string;
|
|
304
|
+
refreshToken?: string;
|
|
305
|
+
/** Unix seconds. */
|
|
306
|
+
expiresAt: number;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Where the tokens live. The APP provides it in production, backed by
|
|
310
|
+
* something every instance shares (SQLite on one machine, a database or Redis
|
|
311
|
+
* on several) — the same reason BackchannelStore has no in-memory default.
|
|
312
|
+
*/
|
|
313
|
+
interface TokenStore {
|
|
314
|
+
get(sid: string): Promise<TokenSet | undefined>;
|
|
315
|
+
set(sid: string, tokens: TokenSet): Promise<void>;
|
|
316
|
+
delete(sid: string): Promise<void>;
|
|
317
|
+
/** Drop every token set for `sub` — what «Log ud overalt» calls. */
|
|
318
|
+
deleteSub(sub: string): Promise<void>;
|
|
319
|
+
}
|
|
320
|
+
|
|
262
321
|
/**
|
|
263
322
|
* The app's OWN session cookie — signed, not encrypted.
|
|
264
323
|
*
|
|
@@ -284,6 +343,12 @@ interface SessionPayload {
|
|
|
284
343
|
* older than any logout.
|
|
285
344
|
*/
|
|
286
345
|
iat?: number;
|
|
346
|
+
/**
|
|
347
|
+
* F095.1 — the key to this session's server-side tokens (ssoRoutes'
|
|
348
|
+
* `tokenStore`). Random, 128 bits. The tokens themselves are NEVER in the
|
|
349
|
+
* cookie: it is signed, not encrypted, and the browser can read it.
|
|
350
|
+
*/
|
|
351
|
+
sid?: string;
|
|
287
352
|
/** Optional convenience copies; never authorisation data. */
|
|
288
353
|
email?: string;
|
|
289
354
|
name?: string;
|
|
@@ -344,6 +409,12 @@ interface SsoRoutesOptions {
|
|
|
344
409
|
loginPath?: string;
|
|
345
410
|
/** Where to send a user after a successful login when no returnTo is given. */
|
|
346
411
|
defaultReturnTo?: string;
|
|
412
|
+
/**
|
|
413
|
+
* F095.1 — keep the user's access and refresh token SERVER-SIDE, so the app
|
|
414
|
+
* can call BID's app API (the account page) as the user via getAccessToken.
|
|
415
|
+
* Without it, login behaves exactly as in 0.8.0 and the tokens are dropped.
|
|
416
|
+
*/
|
|
417
|
+
tokenStore?: TokenStore;
|
|
347
418
|
}
|
|
348
419
|
declare function ssoRoutes(options?: SsoRoutesOptions): {
|
|
349
420
|
app: Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
|
|
@@ -351,6 +422,7 @@ declare function ssoRoutes(options?: SsoRoutesOptions): {
|
|
|
351
422
|
require: MiddlewareHandler;
|
|
352
423
|
client: SsoClient;
|
|
353
424
|
config: SsoConfig;
|
|
425
|
+
getAccessToken: (c: Context) => Promise<string>;
|
|
354
426
|
};
|
|
355
427
|
/** Why a login transaction could not be read back. Three states, three answers. */
|
|
356
428
|
type TransactionFailure = "absent" | "expired" | "unreadable";
|
package/dist/hono.js
CHANGED
|
@@ -358,18 +358,19 @@ function createSsoClient(config, options = {}) {
|
|
|
358
358
|
}
|
|
359
359
|
async function withUserInfo(claims, accessToken) {
|
|
360
360
|
const doc = await discovery();
|
|
361
|
-
if (!doc.userinfo_endpoint) return claims;
|
|
361
|
+
if (!doc.userinfo_endpoint) return { claims, unverifiedClaims: [] };
|
|
362
362
|
const res = await fetchImpl(doc.userinfo_endpoint, {
|
|
363
363
|
headers: { authorization: `Bearer ${accessToken}` }
|
|
364
364
|
});
|
|
365
|
-
if (!res.ok) return claims;
|
|
365
|
+
if (!res.ok) return { claims, unverifiedClaims: [] };
|
|
366
366
|
const info = await res.json();
|
|
367
367
|
if (info.sub !== claims.sub) {
|
|
368
368
|
throw new SsoError(
|
|
369
369
|
`userinfo describes ${String(info.sub)} but the ID token is for ${claims.sub} \u2014 refusing to merge another user's profile onto this session.`
|
|
370
370
|
);
|
|
371
371
|
}
|
|
372
|
-
|
|
372
|
+
const unverifiedClaims = Object.keys(info).filter((k) => k !== "sub" && info[k] !== void 0 && !(k in claims)).sort();
|
|
373
|
+
return { claims: { ...info, ...claims, sub: claims.sub }, unverifiedClaims };
|
|
373
374
|
}
|
|
374
375
|
return {
|
|
375
376
|
discovery,
|
|
@@ -441,11 +442,42 @@ function createSsoClient(config, options = {}) {
|
|
|
441
442
|
);
|
|
442
443
|
}
|
|
443
444
|
const claims = await verifyIdToken(body.id_token, { nonce });
|
|
445
|
+
const merged = body.access_token ? await withUserInfo(claims, body.access_token) : { claims, unverifiedClaims: [] };
|
|
444
446
|
return {
|
|
445
|
-
claims:
|
|
447
|
+
claims: merged.claims,
|
|
448
|
+
unverifiedClaims: merged.unverifiedClaims,
|
|
446
449
|
idToken: body.id_token,
|
|
447
450
|
...body.access_token ? { accessToken: body.access_token } : {},
|
|
448
|
-
...body.refresh_token ? { refreshToken: body.refresh_token } : {}
|
|
451
|
+
...body.refresh_token ? { refreshToken: body.refresh_token } : {},
|
|
452
|
+
...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
|
|
453
|
+
};
|
|
454
|
+
},
|
|
455
|
+
async refreshTokens(refreshToken) {
|
|
456
|
+
const { token_endpoint } = await discovery();
|
|
457
|
+
const res = await fetchImpl(token_endpoint, {
|
|
458
|
+
method: "POST",
|
|
459
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
460
|
+
body: new URLSearchParams({
|
|
461
|
+
grant_type: "refresh_token",
|
|
462
|
+
refresh_token: refreshToken,
|
|
463
|
+
client_id: config.clientId,
|
|
464
|
+
...config.clientSecret ? { client_secret: config.clientSecret } : {}
|
|
465
|
+
})
|
|
466
|
+
});
|
|
467
|
+
const raw = await res.text();
|
|
468
|
+
let body;
|
|
469
|
+
try {
|
|
470
|
+
body = raw.trim() === "" ? {} : JSON.parse(raw);
|
|
471
|
+
} catch {
|
|
472
|
+
throw new SsoError(`token refresh failed (${res.status}): the response body is not JSON`);
|
|
473
|
+
}
|
|
474
|
+
if (!res.ok || typeof body.access_token !== "string" || body.access_token === "") {
|
|
475
|
+
throw new SsoError(`token refresh failed (${res.status}): ${body.error ?? "no access_token in response"}`);
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
accessToken: body.access_token,
|
|
479
|
+
...typeof body.refresh_token === "string" && body.refresh_token ? { refreshToken: body.refresh_token } : {},
|
|
480
|
+
...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
|
|
449
481
|
};
|
|
450
482
|
},
|
|
451
483
|
async logoutUrl(opts = {}) {
|
|
@@ -506,6 +538,26 @@ function createSsoClient(config, options = {}) {
|
|
|
506
538
|
};
|
|
507
539
|
}
|
|
508
540
|
|
|
541
|
+
// src/tokens.ts
|
|
542
|
+
function expiresAtFrom(expiresIn, accessToken, now) {
|
|
543
|
+
if (typeof expiresIn === "number" && expiresIn > 0) return now + Math.floor(expiresIn);
|
|
544
|
+
const parts = accessToken.split(".");
|
|
545
|
+
if (parts.length === 3) {
|
|
546
|
+
try {
|
|
547
|
+
const json = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
|
|
548
|
+
if (typeof json.exp === "number" && json.exp > now) return json.exp;
|
|
549
|
+
} catch {
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return now + 300;
|
|
553
|
+
}
|
|
554
|
+
function newSid() {
|
|
555
|
+
const b = crypto.getRandomValues(new Uint8Array(16));
|
|
556
|
+
let s = "";
|
|
557
|
+
for (const x of b) s += String.fromCharCode(x);
|
|
558
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
559
|
+
}
|
|
560
|
+
|
|
509
561
|
// src/session.ts
|
|
510
562
|
var enc = new TextEncoder();
|
|
511
563
|
var b64url2 = (bytes) => {
|
|
@@ -636,6 +688,7 @@ function ssoRoutes(options = {}) {
|
|
|
636
688
|
throw new Error("ssoRoutes: backchannel needs a store shared by every instance of the app \u2014 there is no in-memory default.");
|
|
637
689
|
}
|
|
638
690
|
const onStoreError = backchannel?.onStoreError ?? "reject";
|
|
691
|
+
const tokenStore = options.tokenStore;
|
|
639
692
|
async function liveSession(c) {
|
|
640
693
|
const session = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
641
694
|
if (!session || !backchannel) return session;
|
|
@@ -668,6 +721,7 @@ function ssoRoutes(options = {}) {
|
|
|
668
721
|
}
|
|
669
722
|
try {
|
|
670
723
|
await backchannel.store.revokeSubBefore(verified.sub, verified.iat);
|
|
724
|
+
if (tokenStore) await tokenStore.deleteSub(verified.sub);
|
|
671
725
|
await backchannel.store.useJti(verified.jti, verified.exp);
|
|
672
726
|
} catch {
|
|
673
727
|
return c.json({ error: "store_unavailable" }, 503);
|
|
@@ -731,11 +785,23 @@ function ssoRoutes(options = {}) {
|
|
|
731
785
|
codeVerifier: parsed.tx.codeVerifier,
|
|
732
786
|
nonce: parsed.tx.nonce
|
|
733
787
|
});
|
|
734
|
-
const
|
|
788
|
+
const nowS = Math.floor(Date.now() / 1e3);
|
|
789
|
+
const exp = nowS + config.sessionMaxAge;
|
|
790
|
+
let sid;
|
|
791
|
+
if (tokenStore && result.accessToken) {
|
|
792
|
+
sid = newSid();
|
|
793
|
+
await tokenStore.set(sid, {
|
|
794
|
+
sub: result.claims.sub,
|
|
795
|
+
accessToken: result.accessToken,
|
|
796
|
+
...result.refreshToken ? { refreshToken: result.refreshToken } : {},
|
|
797
|
+
expiresAt: expiresAtFrom(result.expiresIn, result.accessToken, nowS)
|
|
798
|
+
});
|
|
799
|
+
}
|
|
735
800
|
const session = {
|
|
736
801
|
sub: result.claims.sub,
|
|
737
802
|
exp,
|
|
738
|
-
iat:
|
|
803
|
+
iat: nowS,
|
|
804
|
+
...sid ? { sid } : {},
|
|
739
805
|
...result.claims.email ? { email: result.claims.email } : {},
|
|
740
806
|
...result.claims.name ? { name: result.claims.name } : {},
|
|
741
807
|
...result.claims.picture ? { picture: result.claims.picture } : {},
|
|
@@ -769,6 +835,10 @@ function ssoRoutes(options = {}) {
|
|
|
769
835
|
readCookie(c.req.header("cookie"), idTokenCookie),
|
|
770
836
|
config.cookieSecret
|
|
771
837
|
);
|
|
838
|
+
if (tokenStore) {
|
|
839
|
+
const leaving = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
840
|
+
if (leaving?.sid) await tokenStore.delete(leaving.sid);
|
|
841
|
+
}
|
|
772
842
|
c.header(
|
|
773
843
|
"Set-Cookie",
|
|
774
844
|
cookieHeader(config.cookieName, "", { maxAge: 0, secure: isSecure(c) })
|
|
@@ -799,7 +869,54 @@ function ssoRoutes(options = {}) {
|
|
|
799
869
|
c.set(SESSION_KEY, session);
|
|
800
870
|
await next();
|
|
801
871
|
};
|
|
802
|
-
|
|
872
|
+
const refreshing = /* @__PURE__ */ new Map();
|
|
873
|
+
async function getAccessToken(c) {
|
|
874
|
+
if (!tokenStore) {
|
|
875
|
+
throw new SsoError("getAccessToken needs ssoRoutes({ tokenStore }) \u2014 without one the login's tokens are not kept.");
|
|
876
|
+
}
|
|
877
|
+
const raw = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
878
|
+
const live = await liveSession(c);
|
|
879
|
+
if (!live) {
|
|
880
|
+
if (raw?.sid) await tokenStore.delete(raw.sid);
|
|
881
|
+
throw new SsoError("no live session \u2014 log in again.");
|
|
882
|
+
}
|
|
883
|
+
const sid = live.sid;
|
|
884
|
+
if (!sid) throw new SsoError("this session has no stored tokens (it began before tokenStore was configured) \u2014 log in again.");
|
|
885
|
+
const tokens = await tokenStore.get(sid);
|
|
886
|
+
if (!tokens) throw new SsoError("no tokens stored for this session \u2014 log in again.");
|
|
887
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
888
|
+
if (tokens.expiresAt - 30 > now) return tokens.accessToken;
|
|
889
|
+
if (!tokens.refreshToken) {
|
|
890
|
+
await tokenStore.delete(sid);
|
|
891
|
+
throw new SsoError("the access token has expired and there is no refresh token \u2014 log in again.");
|
|
892
|
+
}
|
|
893
|
+
const inFlight = refreshing.get(sid);
|
|
894
|
+
if (inFlight) return inFlight;
|
|
895
|
+
const p = (async () => {
|
|
896
|
+
let next;
|
|
897
|
+
try {
|
|
898
|
+
next = await client.refreshTokens(tokens.refreshToken);
|
|
899
|
+
} catch (e) {
|
|
900
|
+
await tokenStore.delete(sid);
|
|
901
|
+
throw e instanceof SsoError ? e : new SsoError("token refresh failed");
|
|
902
|
+
}
|
|
903
|
+
await tokenStore.set(sid, {
|
|
904
|
+
sub: tokens.sub,
|
|
905
|
+
accessToken: next.accessToken,
|
|
906
|
+
// Rotation: use the new one if BID issued one, else keep the old.
|
|
907
|
+
...next.refreshToken ?? tokens.refreshToken ? { refreshToken: next.refreshToken ?? tokens.refreshToken } : {},
|
|
908
|
+
expiresAt: expiresAtFrom(next.expiresIn, next.accessToken, Math.floor(Date.now() / 1e3))
|
|
909
|
+
});
|
|
910
|
+
return next.accessToken;
|
|
911
|
+
})();
|
|
912
|
+
refreshing.set(sid, p);
|
|
913
|
+
try {
|
|
914
|
+
return await p;
|
|
915
|
+
} finally {
|
|
916
|
+
refreshing.delete(sid);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return { app, attach, require: require2, client, config, getAccessToken };
|
|
803
920
|
}
|
|
804
921
|
async function parseTransaction(raw, secret) {
|
|
805
922
|
if (raw === void 0) return { ok: false, reason: "absent" };
|