@broberg/sso 0.7.0 → 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 CHANGED
@@ -338,4 +338,14 @@ ssoRoutes({ logout: "central" });
338
338
 
339
339
  ## email_verified on the session (since 0.7.0)
340
340
 
341
- `getSession(c).email_verified` is `true` or `false` when BID said so, and absent when it did not. **Bind access to an email address only when it is `true`** — e.g. `session.email === "cb@broberg.ai" && session.email_verified === true`. Sessions minted before 0.7.0 have no such field; treat absent as not verified.
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
- return { ...claims, ...info, sub: claims.sub };
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: body.access_token ? await withUserInfo(claims, body.access_token) : 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 } : {}