@broberg/sso 0.8.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 +44 -0
- package/dist/hono.cjs +118 -4
- package/dist/hono.cjs.map +1 -1
- package/dist/hono.d.cts +60 -0
- package/dist/hono.d.ts +60 -0
- package/dist/hono.js +118 -4
- package/dist/hono.js.map +1 -1
- package/dist/index.cjs +60 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +65 -1
- package/dist/index.d.ts +65 -1
- package/dist/index.js +60 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/hono.d.cts
CHANGED
|
@@ -177,6 +177,8 @@ interface LoginResult {
|
|
|
177
177
|
idToken: string;
|
|
178
178
|
accessToken?: string;
|
|
179
179
|
refreshToken?: string;
|
|
180
|
+
/** Seconds the access token lives, when BID said so (`expires_in`). */
|
|
181
|
+
expiresIn?: number;
|
|
180
182
|
}
|
|
181
183
|
/**
|
|
182
184
|
* `prompt=none` asks BID to answer WITHOUT showing anything (components-F084.6).
|
|
@@ -216,6 +218,16 @@ interface SsoClient {
|
|
|
216
218
|
* answers — see the implementation for why that must never become "unverified".
|
|
217
219
|
*/
|
|
218
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
|
+
}>;
|
|
219
231
|
/**
|
|
220
232
|
* Invite users to BID on behalf of this app (the app's OWN key, `bidk_…`).
|
|
221
233
|
* One result per email. Throws SsoError on anything BID did not clearly say.
|
|
@@ -271,6 +283,41 @@ interface LogoutToken {
|
|
|
271
283
|
exp: number;
|
|
272
284
|
}
|
|
273
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
|
+
|
|
274
321
|
/**
|
|
275
322
|
* The app's OWN session cookie — signed, not encrypted.
|
|
276
323
|
*
|
|
@@ -296,6 +343,12 @@ interface SessionPayload {
|
|
|
296
343
|
* older than any logout.
|
|
297
344
|
*/
|
|
298
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;
|
|
299
352
|
/** Optional convenience copies; never authorisation data. */
|
|
300
353
|
email?: string;
|
|
301
354
|
name?: string;
|
|
@@ -356,6 +409,12 @@ interface SsoRoutesOptions {
|
|
|
356
409
|
loginPath?: string;
|
|
357
410
|
/** Where to send a user after a successful login when no returnTo is given. */
|
|
358
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;
|
|
359
418
|
}
|
|
360
419
|
declare function ssoRoutes(options?: SsoRoutesOptions): {
|
|
361
420
|
app: Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
|
|
@@ -363,6 +422,7 @@ declare function ssoRoutes(options?: SsoRoutesOptions): {
|
|
|
363
422
|
require: MiddlewareHandler;
|
|
364
423
|
client: SsoClient;
|
|
365
424
|
config: SsoConfig;
|
|
425
|
+
getAccessToken: (c: Context) => Promise<string>;
|
|
366
426
|
};
|
|
367
427
|
/** Why a login transaction could not be read back. Three states, three answers. */
|
|
368
428
|
type TransactionFailure = "absent" | "expired" | "unreadable";
|
package/dist/hono.d.ts
CHANGED
|
@@ -177,6 +177,8 @@ interface LoginResult {
|
|
|
177
177
|
idToken: string;
|
|
178
178
|
accessToken?: string;
|
|
179
179
|
refreshToken?: string;
|
|
180
|
+
/** Seconds the access token lives, when BID said so (`expires_in`). */
|
|
181
|
+
expiresIn?: number;
|
|
180
182
|
}
|
|
181
183
|
/**
|
|
182
184
|
* `prompt=none` asks BID to answer WITHOUT showing anything (components-F084.6).
|
|
@@ -216,6 +218,16 @@ interface SsoClient {
|
|
|
216
218
|
* answers — see the implementation for why that must never become "unverified".
|
|
217
219
|
*/
|
|
218
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
|
+
}>;
|
|
219
231
|
/**
|
|
220
232
|
* Invite users to BID on behalf of this app (the app's OWN key, `bidk_…`).
|
|
221
233
|
* One result per email. Throws SsoError on anything BID did not clearly say.
|
|
@@ -271,6 +283,41 @@ interface LogoutToken {
|
|
|
271
283
|
exp: number;
|
|
272
284
|
}
|
|
273
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
|
+
|
|
274
321
|
/**
|
|
275
322
|
* The app's OWN session cookie — signed, not encrypted.
|
|
276
323
|
*
|
|
@@ -296,6 +343,12 @@ interface SessionPayload {
|
|
|
296
343
|
* older than any logout.
|
|
297
344
|
*/
|
|
298
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;
|
|
299
352
|
/** Optional convenience copies; never authorisation data. */
|
|
300
353
|
email?: string;
|
|
301
354
|
name?: string;
|
|
@@ -356,6 +409,12 @@ interface SsoRoutesOptions {
|
|
|
356
409
|
loginPath?: string;
|
|
357
410
|
/** Where to send a user after a successful login when no returnTo is given. */
|
|
358
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;
|
|
359
418
|
}
|
|
360
419
|
declare function ssoRoutes(options?: SsoRoutesOptions): {
|
|
361
420
|
app: Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
|
|
@@ -363,6 +422,7 @@ declare function ssoRoutes(options?: SsoRoutesOptions): {
|
|
|
363
422
|
require: MiddlewareHandler;
|
|
364
423
|
client: SsoClient;
|
|
365
424
|
config: SsoConfig;
|
|
425
|
+
getAccessToken: (c: Context) => Promise<string>;
|
|
366
426
|
};
|
|
367
427
|
/** Why a login transaction could not be read back. Three states, three answers. */
|
|
368
428
|
type TransactionFailure = "absent" | "expired" | "unreadable";
|
package/dist/hono.js
CHANGED
|
@@ -448,7 +448,36 @@ function createSsoClient(config, options = {}) {
|
|
|
448
448
|
unverifiedClaims: merged.unverifiedClaims,
|
|
449
449
|
idToken: body.id_token,
|
|
450
450
|
...body.access_token ? { accessToken: body.access_token } : {},
|
|
451
|
-
...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 } : {}
|
|
452
481
|
};
|
|
453
482
|
},
|
|
454
483
|
async logoutUrl(opts = {}) {
|
|
@@ -509,6 +538,26 @@ function createSsoClient(config, options = {}) {
|
|
|
509
538
|
};
|
|
510
539
|
}
|
|
511
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
|
+
|
|
512
561
|
// src/session.ts
|
|
513
562
|
var enc = new TextEncoder();
|
|
514
563
|
var b64url2 = (bytes) => {
|
|
@@ -639,6 +688,7 @@ function ssoRoutes(options = {}) {
|
|
|
639
688
|
throw new Error("ssoRoutes: backchannel needs a store shared by every instance of the app \u2014 there is no in-memory default.");
|
|
640
689
|
}
|
|
641
690
|
const onStoreError = backchannel?.onStoreError ?? "reject";
|
|
691
|
+
const tokenStore = options.tokenStore;
|
|
642
692
|
async function liveSession(c) {
|
|
643
693
|
const session = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
644
694
|
if (!session || !backchannel) return session;
|
|
@@ -671,6 +721,7 @@ function ssoRoutes(options = {}) {
|
|
|
671
721
|
}
|
|
672
722
|
try {
|
|
673
723
|
await backchannel.store.revokeSubBefore(verified.sub, verified.iat);
|
|
724
|
+
if (tokenStore) await tokenStore.deleteSub(verified.sub);
|
|
674
725
|
await backchannel.store.useJti(verified.jti, verified.exp);
|
|
675
726
|
} catch {
|
|
676
727
|
return c.json({ error: "store_unavailable" }, 503);
|
|
@@ -734,11 +785,23 @@ function ssoRoutes(options = {}) {
|
|
|
734
785
|
codeVerifier: parsed.tx.codeVerifier,
|
|
735
786
|
nonce: parsed.tx.nonce
|
|
736
787
|
});
|
|
737
|
-
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
|
+
}
|
|
738
800
|
const session = {
|
|
739
801
|
sub: result.claims.sub,
|
|
740
802
|
exp,
|
|
741
|
-
iat:
|
|
803
|
+
iat: nowS,
|
|
804
|
+
...sid ? { sid } : {},
|
|
742
805
|
...result.claims.email ? { email: result.claims.email } : {},
|
|
743
806
|
...result.claims.name ? { name: result.claims.name } : {},
|
|
744
807
|
...result.claims.picture ? { picture: result.claims.picture } : {},
|
|
@@ -772,6 +835,10 @@ function ssoRoutes(options = {}) {
|
|
|
772
835
|
readCookie(c.req.header("cookie"), idTokenCookie),
|
|
773
836
|
config.cookieSecret
|
|
774
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
|
+
}
|
|
775
842
|
c.header(
|
|
776
843
|
"Set-Cookie",
|
|
777
844
|
cookieHeader(config.cookieName, "", { maxAge: 0, secure: isSecure(c) })
|
|
@@ -802,7 +869,54 @@ function ssoRoutes(options = {}) {
|
|
|
802
869
|
c.set(SESSION_KEY, session);
|
|
803
870
|
await next();
|
|
804
871
|
};
|
|
805
|
-
|
|
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 };
|
|
806
920
|
}
|
|
807
921
|
async function parseTransaction(raw, secret) {
|
|
808
922
|
if (raw === void 0) return { ok: false, reason: "absent" };
|