@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/dist/hono.d.ts CHANGED
@@ -162,6 +162,18 @@ 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;
@@ -291,6 +303,11 @@ interface SessionPayload {
291
303
  * reference, so a changed picture propagates without every app storing a
292
304
  * copy that then goes stale. */
293
305
  picture?: string;
306
+ /**
307
+ * Whether BID has verified `email` (F084.152). Absent when BID did not say.
308
+ * Bind access to an address only when this is `true`.
309
+ */
310
+ email_verified?: boolean;
294
311
  }
295
312
 
296
313
  /**
@@ -318,6 +335,15 @@ interface BackchannelOptions {
318
335
  onStoreError?: "reject" | "allow";
319
336
  }
320
337
  interface SsoRoutesOptions {
338
+ /**
339
+ * What «Log ud» in the app does (F084.152).
340
+ * "app" (default): end THIS app's session and send the user to BID's login
341
+ * dialog (prompt=login). They stay signed in to BID — its own «Log ud» is
342
+ * a separate action (decision D-376ffa) — and BID shows the dialog anyway.
343
+ * "central": the pre-0.7.0 behaviour — RP-initiated logout at BID's
344
+ * end-session endpoint, which also ends the BID session.
345
+ */
346
+ logout?: "app" | "central";
321
347
  /**
322
348
  * Receive «Log ud overalt» from BID at POST <mount>/backchannel-logout.
323
349
  * Nothing arrives until BID has registered that URL for this app (ship dark),
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
- return { ...claims, ...info, sub: claims.sub };
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,8 +442,10 @@ 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: body.access_token ? await withUserInfo(claims, body.access_token) : 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
451
  ...body.refresh_token ? { refreshToken: body.refresh_token } : {}
@@ -678,7 +681,7 @@ function ssoRoutes(options = {}) {
678
681
  app.get("/login", async (c) => {
679
682
  const prompt = c.req.query("prompt");
680
683
  const start = await client.beginLogin(
681
- prompt === "none" ? { prompt: "none" } : {}
684
+ prompt === "none" || prompt === "login" ? { prompt } : {}
682
685
  );
683
686
  const tx = JSON.stringify({
684
687
  state: start.state,
@@ -738,7 +741,9 @@ function ssoRoutes(options = {}) {
738
741
  iat: Math.floor(Date.now() / 1e3),
739
742
  ...result.claims.email ? { email: result.claims.email } : {},
740
743
  ...result.claims.name ? { name: result.claims.name } : {},
741
- ...result.claims.picture ? { picture: result.claims.picture } : {}
744
+ ...result.claims.picture ? { picture: result.claims.picture } : {},
745
+ // Only when BID actually said so — absent means "not stated", never false.
746
+ ...typeof result.claims.email_verified === "boolean" ? { email_verified: result.claims.email_verified } : {}
742
747
  };
743
748
  c.header(
744
749
  "Set-Cookie",
@@ -776,6 +781,9 @@ function ssoRoutes(options = {}) {
776
781
  cookieHeader(idTokenCookie, "", { maxAge: 0, secure: isSecure(c) }),
777
782
  { append: true }
778
783
  );
784
+ if ((options.logout ?? "app") === "app") {
785
+ return c.redirect(`${loginPath}/login?prompt=login`, 302);
786
+ }
779
787
  return c.redirect(await client.logoutUrl(idTokenHint ? { idTokenHint } : {}), 302);
780
788
  });
781
789
  const attach = async (c, next) => {