@broberg/sso 0.7.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/dist/index.d.cts CHANGED
@@ -224,9 +224,23 @@ interface SsoClaims extends JWTPayload {
224
224
  }
225
225
  interface LoginResult {
226
226
  claims: SsoClaims;
227
+ /**
228
+ * The claims in `claims` that came from the UNSIGNED userinfo response and
229
+ * not from the signed ID token (components-F084.51, since 0.8.0). Sorted; an
230
+ * empty list means everything in `claims` was signed (or userinfo was not
231
+ * reached). When both sources carry a claim, the signed token's value wins
232
+ * and the claim is not listed.
233
+ *
234
+ * On BID today this always contains "email" and "email_verified": the ID
235
+ * token does not carry them (measured by broberg-id, 20 Sep 2026). A decision
236
+ * that must rest on a signed identity binds on `claims.sub`, never on email.
237
+ */
238
+ unverifiedClaims: string[];
227
239
  idToken: string;
228
240
  accessToken?: string;
229
241
  refreshToken?: string;
242
+ /** Seconds the access token lives, when BID said so (`expires_in`). */
243
+ expiresIn?: number;
230
244
  }
231
245
  /**
232
246
  * `prompt=none` asks BID to answer WITHOUT showing anything (components-F084.6).
@@ -266,6 +280,16 @@ interface SsoClient {
266
280
  * answers — see the implementation for why that must never become "unverified".
267
281
  */
268
282
  addressOwnership(accessToken: string, address: string): Promise<AddressOwnership>;
283
+ /**
284
+ * F095.1 — trade a refresh token for a new access token (`grant_type=refresh_token`).
285
+ * Throws SsoError on refusal; the message carries the status and BID's error
286
+ * code, never a token.
287
+ */
288
+ refreshTokens(refreshToken: string): Promise<{
289
+ accessToken: string;
290
+ refreshToken?: string;
291
+ expiresIn?: number;
292
+ }>;
269
293
  /**
270
294
  * Invite users to BID on behalf of this app (the app's OWN key, `bidk_…`).
271
295
  * One result per email. Throws SsoError on anything BID did not clearly say.
@@ -359,6 +383,12 @@ interface SessionPayload {
359
383
  * older than any logout.
360
384
  */
361
385
  iat?: number;
386
+ /**
387
+ * F095.1 — the key to this session's server-side tokens (ssoRoutes'
388
+ * `tokenStore`). Random, 128 bits. The tokens themselves are NEVER in the
389
+ * cookie: it is signed, not encrypted, and the browser can read it.
390
+ */
391
+ sid?: string;
362
392
  /** Optional convenience copies; never authorisation data. */
363
393
  email?: string;
364
394
  name?: string;
@@ -437,4 +467,50 @@ declare function cookieHeader(name: string, value: string, opts: {
437
467
  }): string;
438
468
  declare function readCookie(header: string | null | undefined, name: string): string | undefined;
439
469
 
440
- export { type AddressOwnership, type BeginLoginOptions, type CreateSsoClientOptions, DEFAULT_SESSION_MAX_AGE, type Discovery, type InvitationOutcome, type InvitationResult, type InviteUsersInput, type JwksCache, type JwksCacheOptions, JwksError, JwksUnavailableError, JwksUnknownKeyError, type LoginResult, type LoginStart, type LogoutToken, MAX_INVITATIONS, type MigrationState, type MigrationStatus, type SessionPayload, type SsoClaims, type SsoClient, type SsoConfig, SsoConfigError, SsoError, cookieHeader, createJwksCache, createSsoClient, loadSsoConfig, readCookie, signSession, signValue, verifySession, verifyValue };
470
+ /**
471
+ * Server-side tokens for calling Broberg ID on the user's behalf (F095.1).
472
+ *
473
+ * The login already RECEIVES an access token and a refresh token; before 0.9.0
474
+ * ssoRoutes dropped both, so an app could not call BID's app API (the account
475
+ * page: read and change your own name and picture) as the user.
476
+ *
477
+ * ── WHY NOT IN THE COOKIE ────────────────────────────────────────────────
478
+ * The session cookie is SIGNED, not encrypted, and the browser can read it. A
479
+ * refresh token is the longest-lived, highest-value credential in the whole
480
+ * flow: whoever holds it can mint access tokens until it is revoked. So the
481
+ * tokens live server-side, keyed by a random `sid` that is the only thing the
482
+ * cookie carries. A stolen cookie still needs the server to be useful.
483
+ */
484
+ interface TokenSet {
485
+ /** The BID subject the tokens belong to — lets «Log ud overalt» find them. */
486
+ sub: string;
487
+ accessToken: string;
488
+ refreshToken?: string;
489
+ /** Unix seconds. */
490
+ expiresAt: number;
491
+ }
492
+ /**
493
+ * Where the tokens live. The APP provides it in production, backed by
494
+ * something every instance shares (SQLite on one machine, a database or Redis
495
+ * on several) — the same reason BackchannelStore has no in-memory default.
496
+ */
497
+ interface TokenStore {
498
+ get(sid: string): Promise<TokenSet | undefined>;
499
+ set(sid: string, tokens: TokenSet): Promise<void>;
500
+ delete(sid: string): Promise<void>;
501
+ /** Drop every token set for `sub` — what «Log ud overalt» calls. */
502
+ deleteSub(sub: string): Promise<void>;
503
+ }
504
+ /**
505
+ * An in-process TokenStore. Fine for tests and a single dev process. In
506
+ * production it LOSES every user's tokens on a restart (they must log in again
507
+ * before the account page works) and is not shared between machines — so it
508
+ * says so, once, when created.
509
+ */
510
+ declare function memoryTokenStore(options?: {
511
+ quiet?: boolean;
512
+ }): TokenStore & {
513
+ readonly size: number;
514
+ };
515
+
516
+ export { type AddressOwnership, type BeginLoginOptions, type CreateSsoClientOptions, DEFAULT_SESSION_MAX_AGE, type Discovery, type InvitationOutcome, type InvitationResult, type InviteUsersInput, type JwksCache, type JwksCacheOptions, JwksError, JwksUnavailableError, JwksUnknownKeyError, type LoginResult, type LoginStart, type LogoutToken, MAX_INVITATIONS, type MigrationState, type MigrationStatus, type SessionPayload, type SsoClaims, type SsoClient, type SsoConfig, SsoConfigError, SsoError, type TokenSet, type TokenStore, cookieHeader, createJwksCache, createSsoClient, loadSsoConfig, memoryTokenStore, readCookie, signSession, signValue, verifySession, verifyValue };
package/dist/index.d.ts CHANGED
@@ -224,9 +224,23 @@ interface SsoClaims extends JWTPayload {
224
224
  }
225
225
  interface LoginResult {
226
226
  claims: SsoClaims;
227
+ /**
228
+ * The claims in `claims` that came from the UNSIGNED userinfo response and
229
+ * not from the signed ID token (components-F084.51, since 0.8.0). Sorted; an
230
+ * empty list means everything in `claims` was signed (or userinfo was not
231
+ * reached). When both sources carry a claim, the signed token's value wins
232
+ * and the claim is not listed.
233
+ *
234
+ * On BID today this always contains "email" and "email_verified": the ID
235
+ * token does not carry them (measured by broberg-id, 20 Sep 2026). A decision
236
+ * that must rest on a signed identity binds on `claims.sub`, never on email.
237
+ */
238
+ unverifiedClaims: string[];
227
239
  idToken: string;
228
240
  accessToken?: string;
229
241
  refreshToken?: string;
242
+ /** Seconds the access token lives, when BID said so (`expires_in`). */
243
+ expiresIn?: number;
230
244
  }
231
245
  /**
232
246
  * `prompt=none` asks BID to answer WITHOUT showing anything (components-F084.6).
@@ -266,6 +280,16 @@ interface SsoClient {
266
280
  * answers — see the implementation for why that must never become "unverified".
267
281
  */
268
282
  addressOwnership(accessToken: string, address: string): Promise<AddressOwnership>;
283
+ /**
284
+ * F095.1 — trade a refresh token for a new access token (`grant_type=refresh_token`).
285
+ * Throws SsoError on refusal; the message carries the status and BID's error
286
+ * code, never a token.
287
+ */
288
+ refreshTokens(refreshToken: string): Promise<{
289
+ accessToken: string;
290
+ refreshToken?: string;
291
+ expiresIn?: number;
292
+ }>;
269
293
  /**
270
294
  * Invite users to BID on behalf of this app (the app's OWN key, `bidk_…`).
271
295
  * One result per email. Throws SsoError on anything BID did not clearly say.
@@ -359,6 +383,12 @@ interface SessionPayload {
359
383
  * older than any logout.
360
384
  */
361
385
  iat?: number;
386
+ /**
387
+ * F095.1 — the key to this session's server-side tokens (ssoRoutes'
388
+ * `tokenStore`). Random, 128 bits. The tokens themselves are NEVER in the
389
+ * cookie: it is signed, not encrypted, and the browser can read it.
390
+ */
391
+ sid?: string;
362
392
  /** Optional convenience copies; never authorisation data. */
363
393
  email?: string;
364
394
  name?: string;
@@ -437,4 +467,50 @@ declare function cookieHeader(name: string, value: string, opts: {
437
467
  }): string;
438
468
  declare function readCookie(header: string | null | undefined, name: string): string | undefined;
439
469
 
440
- export { type AddressOwnership, type BeginLoginOptions, type CreateSsoClientOptions, DEFAULT_SESSION_MAX_AGE, type Discovery, type InvitationOutcome, type InvitationResult, type InviteUsersInput, type JwksCache, type JwksCacheOptions, JwksError, JwksUnavailableError, JwksUnknownKeyError, type LoginResult, type LoginStart, type LogoutToken, MAX_INVITATIONS, type MigrationState, type MigrationStatus, type SessionPayload, type SsoClaims, type SsoClient, type SsoConfig, SsoConfigError, SsoError, cookieHeader, createJwksCache, createSsoClient, loadSsoConfig, readCookie, signSession, signValue, verifySession, verifyValue };
470
+ /**
471
+ * Server-side tokens for calling Broberg ID on the user's behalf (F095.1).
472
+ *
473
+ * The login already RECEIVES an access token and a refresh token; before 0.9.0
474
+ * ssoRoutes dropped both, so an app could not call BID's app API (the account
475
+ * page: read and change your own name and picture) as the user.
476
+ *
477
+ * ── WHY NOT IN THE COOKIE ────────────────────────────────────────────────
478
+ * The session cookie is SIGNED, not encrypted, and the browser can read it. A
479
+ * refresh token is the longest-lived, highest-value credential in the whole
480
+ * flow: whoever holds it can mint access tokens until it is revoked. So the
481
+ * tokens live server-side, keyed by a random `sid` that is the only thing the
482
+ * cookie carries. A stolen cookie still needs the server to be useful.
483
+ */
484
+ interface TokenSet {
485
+ /** The BID subject the tokens belong to — lets «Log ud overalt» find them. */
486
+ sub: string;
487
+ accessToken: string;
488
+ refreshToken?: string;
489
+ /** Unix seconds. */
490
+ expiresAt: number;
491
+ }
492
+ /**
493
+ * Where the tokens live. The APP provides it in production, backed by
494
+ * something every instance shares (SQLite on one machine, a database or Redis
495
+ * on several) — the same reason BackchannelStore has no in-memory default.
496
+ */
497
+ interface TokenStore {
498
+ get(sid: string): Promise<TokenSet | undefined>;
499
+ set(sid: string, tokens: TokenSet): Promise<void>;
500
+ delete(sid: string): Promise<void>;
501
+ /** Drop every token set for `sub` — what «Log ud overalt» calls. */
502
+ deleteSub(sub: string): Promise<void>;
503
+ }
504
+ /**
505
+ * An in-process TokenStore. Fine for tests and a single dev process. In
506
+ * production it LOSES every user's tokens on a restart (they must log in again
507
+ * before the account page works) and is not shared between machines — so it
508
+ * says so, once, when created.
509
+ */
510
+ declare function memoryTokenStore(options?: {
511
+ quiet?: boolean;
512
+ }): TokenStore & {
513
+ readonly size: number;
514
+ };
515
+
516
+ export { type AddressOwnership, type BeginLoginOptions, type CreateSsoClientOptions, DEFAULT_SESSION_MAX_AGE, type Discovery, type InvitationOutcome, type InvitationResult, type InviteUsersInput, type JwksCache, type JwksCacheOptions, JwksError, JwksUnavailableError, JwksUnknownKeyError, type LoginResult, type LoginStart, type LogoutToken, MAX_INVITATIONS, type MigrationState, type MigrationStatus, type SessionPayload, type SsoClaims, type SsoClient, type SsoConfig, SsoConfigError, SsoError, type TokenSet, type TokenStore, cookieHeader, createJwksCache, createSsoClient, loadSsoConfig, memoryTokenStore, readCookie, signSession, signValue, verifySession, verifyValue };
package/dist/index.js CHANGED
@@ -355,18 +355,19 @@ function createSsoClient(config, options = {}) {
355
355
  }
356
356
  async function withUserInfo(claims, accessToken) {
357
357
  const doc = await discovery();
358
- if (!doc.userinfo_endpoint) return claims;
358
+ if (!doc.userinfo_endpoint) return { claims, unverifiedClaims: [] };
359
359
  const res = await fetchImpl(doc.userinfo_endpoint, {
360
360
  headers: { authorization: `Bearer ${accessToken}` }
361
361
  });
362
- if (!res.ok) return claims;
362
+ if (!res.ok) return { claims, unverifiedClaims: [] };
363
363
  const info = await res.json();
364
364
  if (info.sub !== claims.sub) {
365
365
  throw new SsoError(
366
366
  `userinfo describes ${String(info.sub)} but the ID token is for ${claims.sub} \u2014 refusing to merge another user's profile onto this session.`
367
367
  );
368
368
  }
369
- return { ...claims, ...info, sub: claims.sub };
369
+ const unverifiedClaims = Object.keys(info).filter((k) => k !== "sub" && info[k] !== void 0 && !(k in claims)).sort();
370
+ return { claims: { ...info, ...claims, sub: claims.sub }, unverifiedClaims };
370
371
  }
371
372
  return {
372
373
  discovery,
@@ -438,11 +439,42 @@ function createSsoClient(config, options = {}) {
438
439
  );
439
440
  }
440
441
  const claims = await verifyIdToken(body.id_token, { nonce });
442
+ const merged = body.access_token ? await withUserInfo(claims, body.access_token) : { claims, unverifiedClaims: [] };
441
443
  return {
442
- claims: body.access_token ? await withUserInfo(claims, body.access_token) : claims,
444
+ claims: merged.claims,
445
+ unverifiedClaims: merged.unverifiedClaims,
443
446
  idToken: body.id_token,
444
447
  ...body.access_token ? { accessToken: body.access_token } : {},
445
- ...body.refresh_token ? { refreshToken: body.refresh_token } : {}
448
+ ...body.refresh_token ? { refreshToken: body.refresh_token } : {},
449
+ ...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
450
+ };
451
+ },
452
+ async refreshTokens(refreshToken) {
453
+ const { token_endpoint } = await discovery();
454
+ const res = await fetchImpl(token_endpoint, {
455
+ method: "POST",
456
+ headers: { "content-type": "application/x-www-form-urlencoded" },
457
+ body: new URLSearchParams({
458
+ grant_type: "refresh_token",
459
+ refresh_token: refreshToken,
460
+ client_id: config.clientId,
461
+ ...config.clientSecret ? { client_secret: config.clientSecret } : {}
462
+ })
463
+ });
464
+ const raw = await res.text();
465
+ let body;
466
+ try {
467
+ body = raw.trim() === "" ? {} : JSON.parse(raw);
468
+ } catch {
469
+ throw new SsoError(`token refresh failed (${res.status}): the response body is not JSON`);
470
+ }
471
+ if (!res.ok || typeof body.access_token !== "string" || body.access_token === "") {
472
+ throw new SsoError(`token refresh failed (${res.status}): ${body.error ?? "no access_token in response"}`);
473
+ }
474
+ return {
475
+ accessToken: body.access_token,
476
+ ...typeof body.refresh_token === "string" && body.refresh_token ? { refreshToken: body.refresh_token } : {},
477
+ ...typeof body.expires_in === "number" ? { expiresIn: body.expires_in } : {}
446
478
  };
447
479
  },
448
480
  async logoutUrl(opts = {}) {
@@ -593,6 +625,35 @@ function readCookie(header, name) {
593
625
  return void 0;
594
626
  }
595
627
 
596
- export { DEFAULT_SESSION_MAX_AGE, JwksError, JwksUnavailableError, JwksUnknownKeyError, MAX_INVITATIONS, SsoConfigError, SsoError, cookieHeader, createJwksCache, createSsoClient, loadSsoConfig, readCookie, signSession, signValue, verifySession, verifyValue };
628
+ // src/tokens.ts
629
+ var warned = false;
630
+ function memoryTokenStore(options = {}) {
631
+ if (!options.quiet && !warned) {
632
+ warned = true;
633
+ console.warn(
634
+ "@broberg/sso: memoryTokenStore() keeps tokens in this process only \u2014 they are lost on restart and not shared between instances. Give ssoRoutes a TokenStore backed by your database in production."
635
+ );
636
+ }
637
+ const rows = /* @__PURE__ */ new Map();
638
+ return {
639
+ get size() {
640
+ return rows.size;
641
+ },
642
+ async get(sid) {
643
+ return rows.get(sid);
644
+ },
645
+ async set(sid, tokens) {
646
+ rows.set(sid, { ...tokens });
647
+ },
648
+ async delete(sid) {
649
+ rows.delete(sid);
650
+ },
651
+ async deleteSub(sub) {
652
+ for (const [sid, t] of rows) if (t.sub === sub) rows.delete(sid);
653
+ }
654
+ };
655
+ }
656
+
657
+ export { DEFAULT_SESSION_MAX_AGE, JwksError, JwksUnavailableError, JwksUnknownKeyError, MAX_INVITATIONS, SsoConfigError, SsoError, cookieHeader, createJwksCache, createSsoClient, loadSsoConfig, memoryTokenStore, readCookie, signSession, signValue, verifySession, verifyValue };
597
658
  //# sourceMappingURL=index.js.map
598
659
  //# sourceMappingURL=index.js.map