@broberg/sso 0.6.1 → 0.8.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 +26 -0
- package/dist/hono.cjs +14 -6
- package/dist/hono.cjs.map +1 -1
- package/dist/hono.d.cts +26 -0
- package/dist/hono.d.ts +26 -0
- package/dist/hono.js +14 -6
- package/dist/hono.js.map +1 -1
- package/dist/index.cjs +7 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +17 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -323,3 +323,29 @@ What happens: a verified token → `revokeSubBefore(sub, iat)` → from then on
|
|
|
323
323
|
**Nothing arrives until BID has registered your URL** — mounting it early is safe (ship dark). Tell broberg-id the exact URL once it is deployed.
|
|
324
324
|
|
|
325
325
|
**The cost:** one store lookup per request that goes through `attach`/`require`.
|
|
326
|
+
|
|
327
|
+
## «Log ud» in your app (changed in 0.7.0)
|
|
328
|
+
|
|
329
|
+
**`GET /logout` now logs the user out of YOUR app only, and shows BID's login dialog.** It clears the app's session and sends the browser to `<mount>/login?prompt=login`; BID then shows its dialog even though the user is still signed in to BID. The user can pick another account or just sign in again. Their BID session, and every other app, are untouched: logging out of BID itself is BID's own «Log ud» (owner decision D-376ffa), and «Log ud overalt» is the button in BID for ending everything.
|
|
330
|
+
|
|
331
|
+
Up to 0.6.x, `/logout` sent the user to BID's end-session endpoint, which also ended the BID session. Keep that behaviour with:
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
ssoRoutes({ logout: "central" });
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
`/login?prompt=login` and `/login?prompt=none` are passed on to BID; any other prompt is dropped.
|
|
338
|
+
|
|
339
|
+
## email_verified on the session (since 0.7.0)
|
|
340
|
+
|
|
341
|
+
`getSession(c).email_verified` is `true` or `false` when BID said so, and absent when it did not. Sessions minted before 0.7.0 have no such field; treat absent as not verified.
|
|
342
|
+
|
|
343
|
+
> **On BID today, `email` and `email_verified` are NOT signed.** BID's ID token does not carry them — measured by broberg-id on 20 Sep 2026 against the real service: the token holds `acr · at_hash · aud · auth_time · exp · iat · iss · nonce · sid · sub`, and email + email_verified exist only in the `/oauth2/userinfo` response, which is plain HTTP with no signature. `email_verified === true` is still the right thing to require before linking on an address, but it is BID's word over TLS, not a signed claim.
|
|
344
|
+
>
|
|
345
|
+
> **If a decision must rest on a signed identity, bind on `sub`** (`session.sub`) — the ID token signs it, and the package refuses a userinfo answer whose `sub` differs. helpdesk moved its account linking from email to `sub` for exactly this reason.
|
|
346
|
+
|
|
347
|
+
## Which claims were signed — `unverifiedClaims` (since 0.8.0)
|
|
348
|
+
|
|
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
|
+
|
|
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.
|
package/dist/hono.cjs
CHANGED
|
@@ -360,18 +360,19 @@ function createSsoClient(config, options = {}) {
|
|
|
360
360
|
}
|
|
361
361
|
async function withUserInfo(claims, accessToken) {
|
|
362
362
|
const doc = await discovery();
|
|
363
|
-
if (!doc.userinfo_endpoint) return claims;
|
|
363
|
+
if (!doc.userinfo_endpoint) return { claims, unverifiedClaims: [] };
|
|
364
364
|
const res = await fetchImpl(doc.userinfo_endpoint, {
|
|
365
365
|
headers: { authorization: `Bearer ${accessToken}` }
|
|
366
366
|
});
|
|
367
|
-
if (!res.ok) return claims;
|
|
367
|
+
if (!res.ok) return { claims, unverifiedClaims: [] };
|
|
368
368
|
const info = await res.json();
|
|
369
369
|
if (info.sub !== claims.sub) {
|
|
370
370
|
throw new SsoError(
|
|
371
371
|
`userinfo describes ${String(info.sub)} but the ID token is for ${claims.sub} \u2014 refusing to merge another user's profile onto this session.`
|
|
372
372
|
);
|
|
373
373
|
}
|
|
374
|
-
|
|
374
|
+
const unverifiedClaims = Object.keys(info).filter((k) => k !== "sub" && info[k] !== void 0 && !(k in claims)).sort();
|
|
375
|
+
return { claims: { ...info, ...claims, sub: claims.sub }, unverifiedClaims };
|
|
375
376
|
}
|
|
376
377
|
return {
|
|
377
378
|
discovery,
|
|
@@ -443,8 +444,10 @@ function createSsoClient(config, options = {}) {
|
|
|
443
444
|
);
|
|
444
445
|
}
|
|
445
446
|
const claims = await verifyIdToken(body.id_token, { nonce });
|
|
447
|
+
const merged = body.access_token ? await withUserInfo(claims, body.access_token) : { claims, unverifiedClaims: [] };
|
|
446
448
|
return {
|
|
447
|
-
claims:
|
|
449
|
+
claims: merged.claims,
|
|
450
|
+
unverifiedClaims: merged.unverifiedClaims,
|
|
448
451
|
idToken: body.id_token,
|
|
449
452
|
...body.access_token ? { accessToken: body.access_token } : {},
|
|
450
453
|
...body.refresh_token ? { refreshToken: body.refresh_token } : {}
|
|
@@ -680,7 +683,7 @@ function ssoRoutes(options = {}) {
|
|
|
680
683
|
app.get("/login", async (c) => {
|
|
681
684
|
const prompt = c.req.query("prompt");
|
|
682
685
|
const start = await client.beginLogin(
|
|
683
|
-
prompt === "none" ? { prompt
|
|
686
|
+
prompt === "none" || prompt === "login" ? { prompt } : {}
|
|
684
687
|
);
|
|
685
688
|
const tx = JSON.stringify({
|
|
686
689
|
state: start.state,
|
|
@@ -740,7 +743,9 @@ function ssoRoutes(options = {}) {
|
|
|
740
743
|
iat: Math.floor(Date.now() / 1e3),
|
|
741
744
|
...result.claims.email ? { email: result.claims.email } : {},
|
|
742
745
|
...result.claims.name ? { name: result.claims.name } : {},
|
|
743
|
-
...result.claims.picture ? { picture: result.claims.picture } : {}
|
|
746
|
+
...result.claims.picture ? { picture: result.claims.picture } : {},
|
|
747
|
+
// Only when BID actually said so — absent means "not stated", never false.
|
|
748
|
+
...typeof result.claims.email_verified === "boolean" ? { email_verified: result.claims.email_verified } : {}
|
|
744
749
|
};
|
|
745
750
|
c.header(
|
|
746
751
|
"Set-Cookie",
|
|
@@ -778,6 +783,9 @@ function ssoRoutes(options = {}) {
|
|
|
778
783
|
cookieHeader(idTokenCookie, "", { maxAge: 0, secure: isSecure(c) }),
|
|
779
784
|
{ append: true }
|
|
780
785
|
);
|
|
786
|
+
if ((options.logout ?? "app") === "app") {
|
|
787
|
+
return c.redirect(`${loginPath}/login?prompt=login`, 302);
|
|
788
|
+
}
|
|
781
789
|
return c.redirect(await client.logoutUrl(idTokenHint ? { idTokenHint } : {}), 302);
|
|
782
790
|
});
|
|
783
791
|
const attach = async (c, next) => {
|