@broberg/sso 0.13.0 → 0.14.2

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.
@@ -0,0 +1,580 @@
1
+ import { importJWK, JWTPayload } from 'jose';
2
+
3
+ /**
4
+ * Configuration, read from the environment and nowhere else (F084.4 AC#5).
5
+ *
6
+ * An app mounts this package and sets env vars. It does not pass options in
7
+ * code, because the moment configuration lives in code, two deployments of the
8
+ * same app can disagree about who their identity provider is — and the symptom
9
+ * is a token rejection nobody can trace back to a config line.
10
+ */
11
+ declare class SsoConfigError extends Error {
12
+ constructor(message: string);
13
+ }
14
+ interface SsoConfig {
15
+ /** Broberg ID's origin, e.g. https://id.broberg.ai — the BARE origin. */
16
+ issuer: string;
17
+ /** The client id this app was registered under (administratively, in BID). */
18
+ clientId: string;
19
+ /** Exactly the redirect registered in BID. Exact match — one character decides. */
20
+ redirectUri: string;
21
+ /** Requested scopes. */
22
+ scopes: string[];
23
+ /** Signs the local session cookie. 32+ bytes of randomness. */
24
+ cookieSecret: string;
25
+ /** Local session cookie name. */
26
+ cookieName: string;
27
+ /**
28
+ * How long this app trusts its OWN session, in seconds.
29
+ *
30
+ * F084.7 decided the fleet's numbers: 7 days for a normal app, and 12 hours
31
+ * + 30 minutes of inactivity for anything holding personal or health data.
32
+ * The default here is the 7 days. An app handling patient data MUST set
33
+ * SSO_SESSION_MAX_AGE=43200 — the default is a normal-app default, not a
34
+ * safe-for-everything one.
35
+ */
36
+ sessionMaxAge: number;
37
+ /** Where to send the browser after a logout completes. */
38
+ postLogoutRedirectUri?: string;
39
+ /**
40
+ * Set ONLY by an app that can actually keep a secret — one whose callback
41
+ * runs on a server. Leave it unset for anything shipped to a browser; a
42
+ * secret in a static bundle is not a secret, and PKCE alone is the correct,
43
+ * fully standard design there.
44
+ *
45
+ * Setting it makes this a CONFIDENTIAL client: the secret is sent as
46
+ * `client_secret_post` (in the body), which is the method BID registers
47
+ * confidential clients with.
48
+ *
49
+ * IT DOES NOT RELAX PKCE. A secret proves which APP is asking; the PKCE
50
+ * verifier proves the request belongs to the browser that started the login.
51
+ * They defend against different attacks, so one is never an excuse to drop
52
+ * the other — and there is a test that fails if anyone makes it one.
53
+ */
54
+ clientSecret?: string;
55
+ }
56
+ /** Seconds in a week — the fleet default from F084.7. */
57
+ declare const DEFAULT_SESSION_MAX_AGE: number;
58
+ declare function loadSsoConfig(env?: NodeJS.ProcessEnv): SsoConfig;
59
+
60
+ /**
61
+ * The signing-key cache.
62
+ *
63
+ * ── THE ONE BEHAVIOUR THIS FILE EXISTS FOR ────────────────────────────────
64
+ *
65
+ * It refetches on an UNKNOWN KEY ID, not on a timer (F084.4's constraint, and
66
+ * it is the right one). An interval is a guess about when somebody else will
67
+ * rotate their key; an unknown kid is the event itself. With an interval, the
68
+ * window between "BID rotated" and "the interval elapsed" is a window where
69
+ * every login in every app fails, and the length of that window is a number
70
+ * nobody chose on purpose.
71
+ *
72
+ * ── AND THE PART AN OBVIOUS IMPLEMENTATION GETS WRONG ─────────────────────
73
+ *
74
+ * "Unknown kid ⇒ refetch" turns anyone who can send this app a token into
75
+ * someone who can make it hammer BID: a stream of tokens with random kids is a
76
+ * stream of fetches against the one service the whole fleet logs in through.
77
+ * So a refetch is rate-limited by time. The cost of the floor is real and worth
78
+ * stating: a rotation landing inside the cooldown makes logins fail for up to
79
+ * that many milliseconds. Seconds of failure for one app beats a way to aim
80
+ * traffic at BID from outside.
81
+ *
82
+ * ── TWO FAILURES, NOT ONE (components-F084.50) ───────────────────────────────────────
83
+ *
84
+ * Reported by helpdesk, measured in the published 0.1.0 dist: at process start
85
+ * the key set is EMPTY, so the first verification fetches. If BID is down in
86
+ * that second, NOTHING verifies for that app — not just new logins. A process
87
+ * that has been running and has seen a kid survives the same outage untouched.
88
+ *
89
+ * The recovery was never the defect: `lastFetchAt` is set only on SUCCESS, so a
90
+ * failed fetch does not block the next attempt and the cache self-heals the
91
+ * moment BID answers again. The defect was that `getKey` threw the SAME error
92
+ * for two states whose correct answers are opposites:
93
+ *
94
+ * cannot reach the issuer transient. Retry, or answer 503. The token may
95
+ * well be perfectly good — we simply did not look.
96
+ * kid is not published permanent. This token was not signed by us.
97
+ * Reject it, 401, and do not retry.
98
+ *
99
+ * One error name for both is how a caller ends up rejecting a legitimate user
100
+ * because a foreign service had a bad minute — or, worse, retrying a forgery.
101
+ * Hence two subclasses. They BOTH extend JwksError, so a 0.1.0 consumer whose
102
+ * catch tests `instanceof JwksError` keeps working across the upgrade.
103
+ *
104
+ * ── WHAT WE DELIBERATELY DID NOT DO ───────────────────────────────────────
105
+ *
106
+ * We do NOT persist the key set. A stored key set that survives a REVOCATION is
107
+ * a worse failure than a login that is down for two minutes — helpdesk's own
108
+ * point, and the reason `keys = body.keys` replaces rather than merges. Writing
109
+ * the set to disk would reintroduce exactly what that line exists to prevent.
110
+ */
111
+
112
+ type SigningKey = Awaited<ReturnType<typeof importJWK>>;
113
+ interface JwksCacheOptions {
114
+ /** Absolute URL of the key set, taken from BID's discovery document. */
115
+ jwksUri: string;
116
+ /**
117
+ * The floor between two refetches. Below it, an unknown kid is rejected
118
+ * without asking BID again.
119
+ */
120
+ minRefetchIntervalMs?: number;
121
+ /**
122
+ * Fetch the key set once at construction, so the cold window sits at BOOT
123
+ * rather than in front of the first user who tries to log in.
124
+ *
125
+ * It is fire-and-forget ON PURPOSE: a failed warm-up must never stop the app
126
+ * from starting. A package that refuses to boot because someone else's
127
+ * service is down has made the outage worse, not better — the first getKey
128
+ * will simply fetch, exactly as it does today.
129
+ */
130
+ warmUp?: boolean;
131
+ /** Injectable for tests. */
132
+ fetchImpl?: typeof fetch;
133
+ /** Injectable for tests, so the cooldown can be exercised without waiting. */
134
+ now?: () => number;
135
+ }
136
+ interface JwksCache {
137
+ /** Resolve a key for this kid, refetching once if it is unknown. */
138
+ getKey(kid: string, alg: string): Promise<SigningKey>;
139
+ /** How many times the remote key set has actually been fetched. */
140
+ readonly fetchCount: number;
141
+ }
142
+ declare class JwksError extends Error {
143
+ constructor(message: string);
144
+ }
145
+ /**
146
+ * The issuer could not be consulted — unreachable, a non-2xx, or a body that is
147
+ * not a key set. Also thrown when an unknown kid arrives inside the refetch
148
+ * cooldown, because there too the honest statement is "we did not look".
149
+ *
150
+ * TRANSIENT. The right response is to retry, or to answer 503 — never to
151
+ * conclude anything about the token, which may be perfectly valid.
152
+ */
153
+ declare class JwksUnavailableError extends JwksError {
154
+ constructor(message: string);
155
+ }
156
+ /**
157
+ * The issuer WAS consulted and does not publish this key id.
158
+ *
159
+ * PERMANENT. The token was not signed by this issuer. Reject it; retrying only
160
+ * asks the same question again and BID will keep giving the same answer.
161
+ */
162
+ declare class JwksUnknownKeyError extends JwksError {
163
+ constructor(message: string);
164
+ }
165
+ declare function createJwksCache(options: JwksCacheOptions): JwksCache;
166
+
167
+ /**
168
+ * The core: framework-free, public-client OAuth 2.1 against Broberg ID.
169
+ *
170
+ * ── THIS PACKAGE IS A PUBLIC CLIENT. THERE IS NO CLIENT SECRET. ───────────
171
+ *
172
+ * Deliberate, and F084.4 AC#4 greps the published tarball to keep it that way.
173
+ * PKCE alone carries the exchange. That is safe here for a reason worth stating
174
+ * rather than assuming: BID matches redirect addresses EXACTLY (measured — one
175
+ * trailing slash is refused), so the authorization code is delivered to this
176
+ * app's own server and nowhere else. An attacker who knows the client id can
177
+ * start a flow; they cannot receive its result.
178
+ *
179
+ * What it buys is the thing the card actually asks for: a secret that does not
180
+ * exist cannot be committed, leaked in a log, copied into a second app, or left
181
+ * behind in a repository someone later makes public.
182
+ *
183
+ * ── AND WHAT THIS PACKAGE MUST NEVER LEARN TO DO ──────────────────────────
184
+ *
185
+ * No passwords. No passkey registration. No social-provider keys. No email
186
+ * verification. All of it lives in BID. A client that CAN do any of it is a
187
+ * client somebody eventually uses to do it — and then the identity rules exist
188
+ * in two places and drift.
189
+ */
190
+
191
+ interface Discovery {
192
+ /** What the issuer says it signs with. Absent on some providers. */
193
+ id_token_signing_alg_values_supported?: string[];
194
+ issuer: string;
195
+ authorization_endpoint: string;
196
+ token_endpoint: string;
197
+ jwks_uri: string;
198
+ userinfo_endpoint?: string;
199
+ end_session_endpoint?: string;
200
+ }
201
+ declare class SsoError extends Error {
202
+ constructor(message: string);
203
+ }
204
+ /**
205
+ * F095.2 — the user must log in again: there is no live session, no stored
206
+ * tokens, or the renewal was refused. `reason` says which; "no_session" means
207
+ * there never was a login to recover, the others mean there was one and it is
208
+ * spent. Thrown by getAccessToken.
209
+ */
210
+ declare class SsoReauthError extends SsoError {
211
+ readonly reason: "no_session" | "no_tokens" | "expired" | "refresh_failed";
212
+ constructor(message: string, reason: "no_session" | "no_tokens" | "expired" | "refresh_failed");
213
+ }
214
+ /**
215
+ * F095.2 — BID's app API answered with an error, or did not answer at all.
216
+ * `status` is BID's HTTP status (null when nothing came back: timeout, network);
217
+ * `code` is BID's own `error` field when it sent one. The message carries the
218
+ * URL, the status and the code — never the body, never a token.
219
+ */
220
+ declare class SsoAppApiError extends SsoError {
221
+ readonly status: number | null;
222
+ readonly code: string | undefined;
223
+ constructor(message: string, status: number | null, code: string | undefined);
224
+ }
225
+ /**
226
+ * F095.2 — BID refused because the user's token lacks a scope (403
227
+ * insufficient_scope). For the profile writes that is `profile:write`: the app
228
+ * must request it at login, and a user who logged in before it did must log in
229
+ * again. That is the one actionable fact, so it gets its own class.
230
+ */
231
+ declare class SsoInsufficientScopeError extends SsoAppApiError {
232
+ readonly scope: string;
233
+ constructor(message: string, scope: string);
234
+ }
235
+ /** F095.2 — an avatar refused BEFORE it was sent: too big, or not PNG/JPEG/WebP. */
236
+ declare class SsoAvatarRejectedError extends SsoError {
237
+ readonly reason: "too_large" | "unsupported_type";
238
+ constructor(message: string, reason: "too_large" | "unsupported_type");
239
+ }
240
+ /** BID's limit for a profile picture (llms.txt step 7a: "max 2 MB", read as 2 MiB). */
241
+ declare const MAX_AVATAR_BYTES: number;
242
+ /** The picture types BID accepts. */
243
+ declare const AVATAR_TYPES: readonly string[];
244
+ /** The signed-in user's own profile, as BID's GET /api/app/profile returns it. */
245
+ interface BidProfile {
246
+ sub: string;
247
+ name: string | null;
248
+ picture: string | null;
249
+ email: string | null;
250
+ /** BID's own account page — mail, password, passkeys, 2FA and sessions live there. */
251
+ account_url: string;
252
+ }
253
+ /**
254
+ * What `beginLogin` produces. Every field except `url` must survive the round
255
+ * trip to BID and back — the adapter stores them in a short-lived cookie.
256
+ *
257
+ * They are NOT optional extras. `state` is what makes the callback provably the
258
+ * answer to THIS request, and `nonce` is what stops a valid token minted for
259
+ * some other login from being replayed into this one.
260
+ */
261
+ interface LoginStart {
262
+ url: string;
263
+ state: string;
264
+ codeVerifier: string;
265
+ nonce: string;
266
+ }
267
+ interface SsoClaims extends JWTPayload {
268
+ sub: string;
269
+ email?: string;
270
+ name?: string;
271
+ picture?: string;
272
+ email_verified?: boolean;
273
+ }
274
+ interface LoginResult {
275
+ claims: SsoClaims;
276
+ /**
277
+ * The claims in `claims` that came from the UNSIGNED userinfo response and
278
+ * not from the signed ID token (components-F084.51, since 0.8.0). Sorted; an
279
+ * empty list means everything in `claims` was signed (or userinfo was not
280
+ * reached). When both sources carry a claim, the signed token's value wins
281
+ * and the claim is not listed.
282
+ *
283
+ * On BID today this always contains "email" and "email_verified": the ID
284
+ * token does not carry them (measured by broberg-id, 20 Sep 2026). A decision
285
+ * that must rest on a signed identity binds on `claims.sub`, never on email.
286
+ */
287
+ unverifiedClaims: string[];
288
+ idToken: string;
289
+ accessToken?: string;
290
+ refreshToken?: string;
291
+ /** Seconds the access token lives, when BID said so (`expires_in`). */
292
+ expiresIn?: number;
293
+ }
294
+ /**
295
+ * `prompt=none` asks BID to answer WITHOUT showing anything (components-F084.6).
296
+ *
297
+ * The delivery method decides whether it works, and this package only ever
298
+ * produces a URL for a FULL TOP-LEVEL REDIRECT. It never returns anything an
299
+ * app could put in a hidden iframe, because an iframe against the identity
300
+ * provider is third-party context: Safari has blocked it for years and Chrome
301
+ * is retiring it. That path works in testing on a Mac and fails for every user
302
+ * on an iPhone — a failure that looks like "you are not logged in".
303
+ */
304
+ interface BeginLoginOptions {
305
+ prompt?: "none" | "login" | "consent" | "select_account";
306
+ /** Extra scopes for this one request, on top of the configured set. */
307
+ scopes?: string[];
308
+ }
309
+ interface SsoClient {
310
+ discovery(): Promise<Discovery>;
311
+ beginLogin(options?: BeginLoginOptions): Promise<LoginStart>;
312
+ completeLogin(input: {
313
+ params: URLSearchParams;
314
+ state: string;
315
+ codeVerifier: string;
316
+ nonce: string;
317
+ }): Promise<LoginResult>;
318
+ verifyIdToken(idToken: string, options?: {
319
+ nonce?: string;
320
+ }): Promise<SsoClaims>;
321
+ logoutUrl(options?: {
322
+ idTokenHint?: string;
323
+ postLogoutRedirectUri?: string;
324
+ }): Promise<string>;
325
+ /**
326
+ * Does this address belong to the signed-in user's BID account, and is it
327
+ * verified there? Asks BID's POST /api/app/address-ownership with the user's
328
+ * access token. Throws SsoError on anything that is not one of the three
329
+ * answers — see the implementation for why that must never become "unverified".
330
+ */
331
+ addressOwnership(accessToken: string, address: string): Promise<AddressOwnership>;
332
+ /**
333
+ * F095.1 — trade a refresh token for a new access token (`grant_type=refresh_token`).
334
+ * Throws SsoError on refusal; the message carries the status and BID's error
335
+ * code, never a token.
336
+ */
337
+ refreshTokens(refreshToken: string): Promise<{
338
+ accessToken: string;
339
+ refreshToken?: string;
340
+ expiresIn?: number;
341
+ }>;
342
+ /**
343
+ * Invite users to BID on behalf of this app (the app's OWN key, `bidk_…`).
344
+ * One result per email. Throws SsoError on anything BID did not clearly say.
345
+ */
346
+ inviteUsers(appKey: string, input: InviteUsersInput): Promise<InvitationResult[]>;
347
+ /**
348
+ * Where each user is in the move to BID. Throws SsoError on any state it does
349
+ * not know — never a silent "ready", which would switch off a user's old login
350
+ * while they cannot get in.
351
+ */
352
+ migrationStatus(appKey: string, emails: string[]): Promise<MigrationStatus>;
353
+ /**
354
+ * Verify an OIDC Back-Channel Logout token from BID («Log ud overalt»).
355
+ * Throws SsoError on anything that is not a valid, sub-only logout token.
356
+ */
357
+ verifyLogoutToken(token: string): Promise<LogoutToken>;
358
+ /**
359
+ * F095.2 — the signed-in user's own profile (scope `profile`), with the user's
360
+ * access token. Throws SsoAppApiError on any refusal.
361
+ */
362
+ getProfile(accessToken: string): Promise<BidProfile>;
363
+ /** F095.2 — change the user's own name (scope `profile:write`). Returns the new profile. */
364
+ updateProfile(accessToken: string, name: string): Promise<BidProfile>;
365
+ /**
366
+ * F095.2 — replace the user's picture (scope `profile:write`). Refuses more than
367
+ * MAX_AVATAR_BYTES or a type outside AVATAR_TYPES with SsoAvatarRejectedError
368
+ * BEFORE anything is sent.
369
+ */
370
+ uploadAvatar(accessToken: string, bytes: Uint8Array | ArrayBuffer, contentType: string): Promise<BidProfile>;
371
+ /** F095.2 — remove the user's picture (scope `profile:write`). */
372
+ removeAvatar(accessToken: string): Promise<BidProfile>;
373
+ /** Exposed for tests and for a health check; not needed in normal use. */
374
+ readonly jwks: JwksCache;
375
+ }
376
+ type AddressOwnership = "verified" | "unverified" | "not_on_account";
377
+ type InvitationOutcome = "invited" | "already_invited" | "existing" | "invalid" | "too_soon" | "too_many_today" | "send_failed";
378
+ interface InviteUsersInput {
379
+ customerName: string;
380
+ appUrl: string;
381
+ appName?: string;
382
+ switchDate?: string;
383
+ users: Array<{
384
+ email: string;
385
+ name: string;
386
+ }>;
387
+ }
388
+ interface InvitationResult {
389
+ email: string;
390
+ outcome: InvitationOutcome;
391
+ mailId?: string;
392
+ problem?: string;
393
+ }
394
+ type MigrationState = "ready" | "invited" | "expired" | "not_invited";
395
+ interface MigrationStatus {
396
+ users: Array<{
397
+ email: string;
398
+ state: MigrationState;
399
+ expiresAt?: string;
400
+ }>;
401
+ counts: Record<string, number>;
402
+ complete: boolean;
403
+ }
404
+ /** A verified back-channel logout: every session for `sub` issued at or before `iat` is over. */
405
+ interface LogoutToken {
406
+ sub: string;
407
+ iat: number;
408
+ jti: string;
409
+ exp: number;
410
+ }
411
+ /** BID's own limit per invitation call. */
412
+ declare const MAX_INVITATIONS = 500;
413
+ interface CreateSsoClientOptions {
414
+ fetchImpl?: typeof fetch;
415
+ /**
416
+ * Ceiling for the app-API calls (addressOwnership, inviteUsers,
417
+ * migrationStatus). A hanging BID must not hang the app. Default 10 000 ms.
418
+ */
419
+ timeoutMs?: number;
420
+ /** Passed through to the key cache; see jwks.ts for why there is a floor. */
421
+ minRefetchIntervalMs?: number;
422
+ }
423
+ declare function createSsoClient(config: SsoConfig, options?: CreateSsoClientOptions): SsoClient;
424
+
425
+ /**
426
+ * Server-side tokens for calling Broberg ID on the user's behalf (F095.1).
427
+ *
428
+ * The login already RECEIVES an access token and a refresh token; before 0.9.0
429
+ * ssoRoutes dropped both, so an app could not call BID's app API (the account
430
+ * page: read and change your own name and picture) as the user.
431
+ *
432
+ * ── WHY NOT IN THE COOKIE ────────────────────────────────────────────────
433
+ * The session cookie is SIGNED, not encrypted, and the browser can read it. A
434
+ * refresh token is the longest-lived, highest-value credential in the whole
435
+ * flow: whoever holds it can mint access tokens until it is revoked. So the
436
+ * tokens live server-side, keyed by a random `sid` that is the only thing the
437
+ * cookie carries. A stolen cookie still needs the server to be useful.
438
+ */
439
+ interface TokenSet {
440
+ /** The BID subject the tokens belong to — lets «Log ud overalt» find them. */
441
+ sub: string;
442
+ accessToken: string;
443
+ refreshToken?: string;
444
+ /** Unix seconds. */
445
+ expiresAt: number;
446
+ }
447
+ /**
448
+ * Where the tokens live. The APP provides it in production, backed by
449
+ * something every instance shares (SQLite on one machine, a database or Redis
450
+ * on several) — the same reason BackchannelStore has no in-memory default.
451
+ */
452
+ interface TokenStore {
453
+ get(sid: string): Promise<TokenSet | undefined>;
454
+ set(sid: string, tokens: TokenSet): Promise<void>;
455
+ delete(sid: string): Promise<void>;
456
+ /** Drop every token set for `sub` — what «Log ud overalt» calls. */
457
+ deleteSub(sub: string): Promise<void>;
458
+ }
459
+ /**
460
+ * An in-process TokenStore. Fine for tests and a single dev process. In
461
+ * production it LOSES every user's tokens on a restart (they must log in again
462
+ * before the account page works) and is not shared between machines — so it
463
+ * says so, once, when created.
464
+ */
465
+ declare function memoryTokenStore(options?: {
466
+ quiet?: boolean;
467
+ }): TokenStore & {
468
+ readonly size: number;
469
+ };
470
+
471
+ /**
472
+ * The app's OWN session cookie — signed, not encrypted.
473
+ *
474
+ * Signed is the right choice and the distinction matters: the contents are not
475
+ * secret (a user may read their own id and name), but they must not be
476
+ * FORGEABLE. Encryption would hide a subject id the user already knows while
477
+ * doing nothing extra about forgery, which is the actual risk.
478
+ *
479
+ * What goes in is deliberately small: who you are and when this stops being
480
+ * true. Claims that can change — a name, a role, a picture — belong in the
481
+ * app's own store keyed by `sub`, because a cookie is a cache nobody can
482
+ * invalidate, and a stale role in a cookie is a permission that outlives its
483
+ * revocation.
484
+ */
485
+ interface SessionPayload {
486
+ /** The subject from Broberg ID. The one stable identifier. */
487
+ sub: string;
488
+ /** Unix seconds. Checked on every read. */
489
+ exp: number;
490
+ /**
491
+ * Unix seconds, when this session was minted. What «Log ud overalt» compares
492
+ * against (F084.151). Absent on sessions minted before 0.6.0 — those count as
493
+ * older than any logout.
494
+ */
495
+ iat?: number;
496
+ /**
497
+ * F095.1 — the key to this session's server-side tokens (ssoRoutes'
498
+ * `tokenStore`). Random, 128 bits. The tokens themselves are NEVER in the
499
+ * cookie: it is signed, not encrypted, and the browser can read it.
500
+ */
501
+ sid?: string;
502
+ /** Optional convenience copies; never authorisation data. */
503
+ email?: string;
504
+ name?: string;
505
+ /** A URL, never bytes. The picture lives wherever BID put it; this is a
506
+ * reference, so a changed picture propagates without every app storing a
507
+ * copy that then goes stale. */
508
+ picture?: string;
509
+ /**
510
+ * Whether BID has verified `email` (F084.152). Absent when BID did not say.
511
+ * Bind access to an address only when this is `true`.
512
+ */
513
+ email_verified?: boolean;
514
+ }
515
+ /**
516
+ * Sign an arbitrary string. The primitive underneath BOTH cookies this package
517
+ * sets — the session and the short-lived login transaction.
518
+ *
519
+ * They are separate functions on purpose. The first version of this file had
520
+ * the transaction ride inside the session envelope with `exp: 0`, and the
521
+ * expiry check (`exp * 1000 <= now()`) then rejected it every single time: the
522
+ * transaction cookie could never be read back, so every login would have failed
523
+ * with "state does not match" — a message pointing at the wrong thing entirely.
524
+ * Two different lifetimes wanted two different envelopes, not one envelope with
525
+ * a sentinel in it.
526
+ */
527
+ declare function signValue(value: string, secret: string, options?: {
528
+ maxAgeSeconds?: number;
529
+ now?: () => number;
530
+ }): Promise<string>;
531
+ /**
532
+ * Verify the signature, and — only if you ask — the age (components-F084.53).
533
+ *
534
+ * THE PREFIX ON THAT NUMBER IS NOT DECORATION. F-numbers are per-project, and
535
+ * this comment ships in the .d.ts — so a consumer reading it in their editor
536
+ * resolves the number against whatever board THEY know. broberg-id found this
537
+ * by reading their own F084.53, which is "no limit on password guesses": a
538
+ * different feature, in a different repo, behind the same string.
539
+ *
540
+ * WITHOUT `maxAgeSeconds` this is signature-only, exactly as before. That is
541
+ * not laziness: the login-transaction cookie has lived on its browser `Max-Age`
542
+ * since the package shipped, and making the check mandatory would invalidate
543
+ * every cookie already in a user's browser — an instant logout for everyone.
544
+ *
545
+ * WITH `maxAgeSeconds`, a value the SERVER can date is refused once it is too
546
+ * old. Reported by broberg-id: `Max-Age` is the client's claim about when a
547
+ * cookie stopped being valid, and a client may simply not make that claim, so
548
+ * the server used to accept a correctly-signed transaction value forever.
549
+ *
550
+ * THE MIXED CASE IS THE ONE THAT DECIDES WHETHER THIS IS WORTH ANYTHING: a
551
+ * value signed by the OLD code (no timestamp) verified by the NEW code (with a
552
+ * limit). That happens during every rollout, and it FAILS CLOSED — otherwise an
553
+ * undated value would be the way around the very limit being added, and the
554
+ * limit would only apply to those not trying to avoid it.
555
+ */
556
+ declare function verifyValue(token: string | undefined | null, secret: string, options?: {
557
+ maxAgeSeconds?: number;
558
+ now?: () => number;
559
+ }): Promise<string | null>;
560
+ declare function signSession(payload: SessionPayload, secret: string): Promise<string>;
561
+ /**
562
+ * Returns null for ANY reason the cookie cannot be trusted — tampered,
563
+ * truncated, wrong secret, expired, or simply not one of ours.
564
+ *
565
+ * Deliberately one return value rather than distinguishing them to the caller:
566
+ * an app that can tell "bad signature" from "expired" will eventually branch on
567
+ * it, and there is no branch where a forged cookie should do anything other
568
+ * than what an absent one does.
569
+ */
570
+ declare function verifySession(token: string | undefined | null, secret: string, now?: () => number): Promise<SessionPayload | null>;
571
+ /** Serialise a Set-Cookie value. `secure` is off only for http://localhost. */
572
+ declare function cookieHeader(name: string, value: string, opts: {
573
+ maxAge: number;
574
+ secure: boolean;
575
+ sameSite?: "Lax" | "Strict";
576
+ path?: string;
577
+ }): string;
578
+ declare function readCookie(header: string | null | undefined, name: string): string | undefined;
579
+
580
+ export { AVATAR_TYPES as A, type BeginLoginOptions as B, type CreateSsoClientOptions as C, DEFAULT_SESSION_MAX_AGE as D, createSsoClient as E, loadSsoConfig as F, memoryTokenStore as G, readCookie as H, type InvitationOutcome as I, type JwksCache as J, signSession as K, type LoginResult as L, MAX_AVATAR_BYTES as M, signValue as N, verifySession as O, verifyValue as P, type SsoClient as S, type TokenStore as T, type SsoConfig as a, type SessionPayload as b, type AddressOwnership as c, type BidProfile as d, type Discovery as e, type InvitationResult as f, type InviteUsersInput as g, type JwksCacheOptions as h, JwksError as i, JwksUnavailableError as j, JwksUnknownKeyError as k, type LoginStart as l, type LogoutToken as m, MAX_INVITATIONS as n, type MigrationState as o, type MigrationStatus as p, SsoAppApiError as q, SsoAvatarRejectedError as r, type SsoClaims as s, SsoConfigError as t, SsoError as u, SsoInsufficientScopeError as v, SsoReauthError as w, type TokenSet as x, cookieHeader as y, createJwksCache as z };