@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/README.md
CHANGED
|
@@ -349,3 +349,47 @@ ssoRoutes({ logout: "central" });
|
|
|
349
349
|
`completeLogin()` returns `unverifiedClaims: string[]` beside `claims`: the claims that came from the unsigned userinfo response and not from the signed ID token, sorted. Against BID today it is `["email", "email_verified", "name", …]`. An empty list means everything in `claims` was signed, or userinfo was not reached.
|
|
350
350
|
|
|
351
351
|
When both sources carry a claim, **the signed token's value wins** and the claim is not listed. Before 0.8.0 userinfo was spread over the token, so an issuer whose userinfo disagreed with its own token had the unsigned value win. Against BID this changes nothing today — the two never overlap on email — so for BID it is a hardening, not a fix.
|
|
352
|
+
|
|
353
|
+
## Calling BID as the user — the token store (since 0.9.0)
|
|
354
|
+
|
|
355
|
+
The login already receives an **access token** and a **refresh token**. Give
|
|
356
|
+
`ssoRoutes` a `tokenStore` and they are kept **server-side**, so your app can
|
|
357
|
+
call BID's app API as the user — the account page (read and change your own
|
|
358
|
+
name and picture) is the first thing that needs it.
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
import { ssoRoutes } from "@broberg/sso/hono";
|
|
362
|
+
import type { TokenStore } from "@broberg/sso";
|
|
363
|
+
|
|
364
|
+
const tokenStore: TokenStore = { // backed by YOUR database (SQLite, Postgres, Redis …)
|
|
365
|
+
get: (sid) => db.tokens.get(sid),
|
|
366
|
+
set: (sid, t) => db.tokens.put(sid, t), // t = { sub, accessToken, refreshToken?, expiresAt }
|
|
367
|
+
delete: (sid) => db.tokens.delete(sid),
|
|
368
|
+
deleteSub: (sub) => db.tokens.deleteWhere({ sub }),
|
|
369
|
+
};
|
|
370
|
+
const sso = ssoRoutes({ tokenStore, backchannel: { store } });
|
|
371
|
+
|
|
372
|
+
app.get("/api/something", sso.require, async (c) => {
|
|
373
|
+
const token = await sso.getAccessToken(c); // renewed for you when it has expired
|
|
374
|
+
…
|
|
375
|
+
});
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
- **Never in the cookie.** The session cookie is signed, not encrypted, and the
|
|
379
|
+
browser can read it. It carries only a random `sid`; the tokens live in your
|
|
380
|
+
store under that key.
|
|
381
|
+
- **`getAccessToken(c)`** returns the stored token, and when it has expired (or
|
|
382
|
+
is within 30 s of it) renews it with the refresh token, stores the new pair
|
|
383
|
+
and returns the new token. Two requests that need renewal at the same moment
|
|
384
|
+
share one renewal (with rotation, a second one would be refused).
|
|
385
|
+
- **It throws `SsoError`, never returns a stale or empty token** — no live
|
|
386
|
+
session, a session from before you configured a store, no tokens, or a refused
|
|
387
|
+
renewal. A refused renewal also deletes the dead pair. The message names the
|
|
388
|
+
status and BID's error code, never a token.
|
|
389
|
+
- **The tokens die with the session:** «Log ud» deletes the pair, and «Log ud
|
|
390
|
+
overalt» (the back channel) calls `deleteSub(sub)` — inside the same step, so
|
|
391
|
+
a failure is reported to BID as not done.
|
|
392
|
+
- **`memoryTokenStore()`** is the reference implementation for tests and one
|
|
393
|
+
dev process. It warns once: in production it loses every user's tokens on a
|
|
394
|
+
restart and is not shared between machines.
|
|
395
|
+
- **Without `tokenStore`, nothing changes** — login behaves exactly as in 0.8.0.
|
package/dist/hono.cjs
CHANGED
|
@@ -450,7 +450,36 @@ function createSsoClient(config, options = {}) {
|
|
|
450
450
|
unverifiedClaims: merged.unverifiedClaims,
|
|
451
451
|
idToken: body.id_token,
|
|
452
452
|
...body.access_token ? { accessToken: body.access_token } : {},
|
|
453
|
-
...body.refresh_token ? { refreshToken: body.refresh_token } : {}
|
|
453
|
+
...body.refresh_token ? { refreshToken: body.refresh_token } : {},
|
|
454
|
+
...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
|
|
455
|
+
};
|
|
456
|
+
},
|
|
457
|
+
async refreshTokens(refreshToken) {
|
|
458
|
+
const { token_endpoint } = await discovery();
|
|
459
|
+
const res = await fetchImpl(token_endpoint, {
|
|
460
|
+
method: "POST",
|
|
461
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
462
|
+
body: new URLSearchParams({
|
|
463
|
+
grant_type: "refresh_token",
|
|
464
|
+
refresh_token: refreshToken,
|
|
465
|
+
client_id: config.clientId,
|
|
466
|
+
...config.clientSecret ? { client_secret: config.clientSecret } : {}
|
|
467
|
+
})
|
|
468
|
+
});
|
|
469
|
+
const raw = await res.text();
|
|
470
|
+
let body;
|
|
471
|
+
try {
|
|
472
|
+
body = raw.trim() === "" ? {} : JSON.parse(raw);
|
|
473
|
+
} catch {
|
|
474
|
+
throw new SsoError(`token refresh failed (${res.status}): the response body is not JSON`);
|
|
475
|
+
}
|
|
476
|
+
if (!res.ok || typeof body.access_token !== "string" || body.access_token === "") {
|
|
477
|
+
throw new SsoError(`token refresh failed (${res.status}): ${body.error ?? "no access_token in response"}`);
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
accessToken: body.access_token,
|
|
481
|
+
...typeof body.refresh_token === "string" && body.refresh_token ? { refreshToken: body.refresh_token } : {},
|
|
482
|
+
...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
|
|
454
483
|
};
|
|
455
484
|
},
|
|
456
485
|
async logoutUrl(opts = {}) {
|
|
@@ -511,6 +540,26 @@ function createSsoClient(config, options = {}) {
|
|
|
511
540
|
};
|
|
512
541
|
}
|
|
513
542
|
|
|
543
|
+
// src/tokens.ts
|
|
544
|
+
function expiresAtFrom(expiresIn, accessToken, now) {
|
|
545
|
+
if (typeof expiresIn === "number" && expiresIn > 0) return now + Math.floor(expiresIn);
|
|
546
|
+
const parts = accessToken.split(".");
|
|
547
|
+
if (parts.length === 3) {
|
|
548
|
+
try {
|
|
549
|
+
const json = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
|
|
550
|
+
if (typeof json.exp === "number" && json.exp > now) return json.exp;
|
|
551
|
+
} catch {
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return now + 300;
|
|
555
|
+
}
|
|
556
|
+
function newSid() {
|
|
557
|
+
const b = crypto.getRandomValues(new Uint8Array(16));
|
|
558
|
+
let s = "";
|
|
559
|
+
for (const x of b) s += String.fromCharCode(x);
|
|
560
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
561
|
+
}
|
|
562
|
+
|
|
514
563
|
// src/session.ts
|
|
515
564
|
var enc = new TextEncoder();
|
|
516
565
|
var b64url2 = (bytes) => {
|
|
@@ -641,6 +690,7 @@ function ssoRoutes(options = {}) {
|
|
|
641
690
|
throw new Error("ssoRoutes: backchannel needs a store shared by every instance of the app \u2014 there is no in-memory default.");
|
|
642
691
|
}
|
|
643
692
|
const onStoreError = backchannel?.onStoreError ?? "reject";
|
|
693
|
+
const tokenStore = options.tokenStore;
|
|
644
694
|
async function liveSession(c) {
|
|
645
695
|
const session = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
646
696
|
if (!session || !backchannel) return session;
|
|
@@ -673,6 +723,7 @@ function ssoRoutes(options = {}) {
|
|
|
673
723
|
}
|
|
674
724
|
try {
|
|
675
725
|
await backchannel.store.revokeSubBefore(verified.sub, verified.iat);
|
|
726
|
+
if (tokenStore) await tokenStore.deleteSub(verified.sub);
|
|
676
727
|
await backchannel.store.useJti(verified.jti, verified.exp);
|
|
677
728
|
} catch {
|
|
678
729
|
return c.json({ error: "store_unavailable" }, 503);
|
|
@@ -736,11 +787,23 @@ function ssoRoutes(options = {}) {
|
|
|
736
787
|
codeVerifier: parsed.tx.codeVerifier,
|
|
737
788
|
nonce: parsed.tx.nonce
|
|
738
789
|
});
|
|
739
|
-
const
|
|
790
|
+
const nowS = Math.floor(Date.now() / 1e3);
|
|
791
|
+
const exp = nowS + config.sessionMaxAge;
|
|
792
|
+
let sid;
|
|
793
|
+
if (tokenStore && result.accessToken) {
|
|
794
|
+
sid = newSid();
|
|
795
|
+
await tokenStore.set(sid, {
|
|
796
|
+
sub: result.claims.sub,
|
|
797
|
+
accessToken: result.accessToken,
|
|
798
|
+
...result.refreshToken ? { refreshToken: result.refreshToken } : {},
|
|
799
|
+
expiresAt: expiresAtFrom(result.expiresIn, result.accessToken, nowS)
|
|
800
|
+
});
|
|
801
|
+
}
|
|
740
802
|
const session = {
|
|
741
803
|
sub: result.claims.sub,
|
|
742
804
|
exp,
|
|
743
|
-
iat:
|
|
805
|
+
iat: nowS,
|
|
806
|
+
...sid ? { sid } : {},
|
|
744
807
|
...result.claims.email ? { email: result.claims.email } : {},
|
|
745
808
|
...result.claims.name ? { name: result.claims.name } : {},
|
|
746
809
|
...result.claims.picture ? { picture: result.claims.picture } : {},
|
|
@@ -774,6 +837,10 @@ function ssoRoutes(options = {}) {
|
|
|
774
837
|
readCookie(c.req.header("cookie"), idTokenCookie),
|
|
775
838
|
config.cookieSecret
|
|
776
839
|
);
|
|
840
|
+
if (tokenStore) {
|
|
841
|
+
const leaving = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
842
|
+
if (leaving?.sid) await tokenStore.delete(leaving.sid);
|
|
843
|
+
}
|
|
777
844
|
c.header(
|
|
778
845
|
"Set-Cookie",
|
|
779
846
|
cookieHeader(config.cookieName, "", { maxAge: 0, secure: isSecure(c) })
|
|
@@ -804,7 +871,54 @@ function ssoRoutes(options = {}) {
|
|
|
804
871
|
c.set(SESSION_KEY, session);
|
|
805
872
|
await next();
|
|
806
873
|
};
|
|
807
|
-
|
|
874
|
+
const refreshing = /* @__PURE__ */ new Map();
|
|
875
|
+
async function getAccessToken(c) {
|
|
876
|
+
if (!tokenStore) {
|
|
877
|
+
throw new SsoError("getAccessToken needs ssoRoutes({ tokenStore }) \u2014 without one the login's tokens are not kept.");
|
|
878
|
+
}
|
|
879
|
+
const raw = await verifySession(readCookie(c.req.header("cookie"), config.cookieName), config.cookieSecret);
|
|
880
|
+
const live = await liveSession(c);
|
|
881
|
+
if (!live) {
|
|
882
|
+
if (raw?.sid) await tokenStore.delete(raw.sid);
|
|
883
|
+
throw new SsoError("no live session \u2014 log in again.");
|
|
884
|
+
}
|
|
885
|
+
const sid = live.sid;
|
|
886
|
+
if (!sid) throw new SsoError("this session has no stored tokens (it began before tokenStore was configured) \u2014 log in again.");
|
|
887
|
+
const tokens = await tokenStore.get(sid);
|
|
888
|
+
if (!tokens) throw new SsoError("no tokens stored for this session \u2014 log in again.");
|
|
889
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
890
|
+
if (tokens.expiresAt - 30 > now) return tokens.accessToken;
|
|
891
|
+
if (!tokens.refreshToken) {
|
|
892
|
+
await tokenStore.delete(sid);
|
|
893
|
+
throw new SsoError("the access token has expired and there is no refresh token \u2014 log in again.");
|
|
894
|
+
}
|
|
895
|
+
const inFlight = refreshing.get(sid);
|
|
896
|
+
if (inFlight) return inFlight;
|
|
897
|
+
const p = (async () => {
|
|
898
|
+
let next;
|
|
899
|
+
try {
|
|
900
|
+
next = await client.refreshTokens(tokens.refreshToken);
|
|
901
|
+
} catch (e) {
|
|
902
|
+
await tokenStore.delete(sid);
|
|
903
|
+
throw e instanceof SsoError ? e : new SsoError("token refresh failed");
|
|
904
|
+
}
|
|
905
|
+
await tokenStore.set(sid, {
|
|
906
|
+
sub: tokens.sub,
|
|
907
|
+
accessToken: next.accessToken,
|
|
908
|
+
// Rotation: use the new one if BID issued one, else keep the old.
|
|
909
|
+
...next.refreshToken ?? tokens.refreshToken ? { refreshToken: next.refreshToken ?? tokens.refreshToken } : {},
|
|
910
|
+
expiresAt: expiresAtFrom(next.expiresIn, next.accessToken, Math.floor(Date.now() / 1e3))
|
|
911
|
+
});
|
|
912
|
+
return next.accessToken;
|
|
913
|
+
})();
|
|
914
|
+
refreshing.set(sid, p);
|
|
915
|
+
try {
|
|
916
|
+
return await p;
|
|
917
|
+
} finally {
|
|
918
|
+
refreshing.delete(sid);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
return { app, attach, require: require2, client, config, getAccessToken };
|
|
808
922
|
}
|
|
809
923
|
async function parseTransaction(raw, secret) {
|
|
810
924
|
if (raw === void 0) return { ok: false, reason: "absent" };
|