@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.
package/dist/hono.d.ts CHANGED
@@ -1,391 +1,7 @@
1
1
  import * as hono_types from 'hono/types';
2
2
  import { Context, Hono, MiddlewareHandler } from 'hono';
3
- import { importJWK, JWTPayload } from 'jose';
4
-
5
- interface SsoConfig {
6
- /** Broberg ID's origin, e.g. https://id.broberg.ai — the BARE origin. */
7
- issuer: string;
8
- /** The client id this app was registered under (administratively, in BID). */
9
- clientId: string;
10
- /** Exactly the redirect registered in BID. Exact match — one character decides. */
11
- redirectUri: string;
12
- /** Requested scopes. */
13
- scopes: string[];
14
- /** Signs the local session cookie. 32+ bytes of randomness. */
15
- cookieSecret: string;
16
- /** Local session cookie name. */
17
- cookieName: string;
18
- /**
19
- * How long this app trusts its OWN session, in seconds.
20
- *
21
- * F084.7 decided the fleet's numbers: 7 days for a normal app, and 12 hours
22
- * + 30 minutes of inactivity for anything holding personal or health data.
23
- * The default here is the 7 days. An app handling patient data MUST set
24
- * SSO_SESSION_MAX_AGE=43200 — the default is a normal-app default, not a
25
- * safe-for-everything one.
26
- */
27
- sessionMaxAge: number;
28
- /** Where to send the browser after a logout completes. */
29
- postLogoutRedirectUri?: string;
30
- /**
31
- * Set ONLY by an app that can actually keep a secret — one whose callback
32
- * runs on a server. Leave it unset for anything shipped to a browser; a
33
- * secret in a static bundle is not a secret, and PKCE alone is the correct,
34
- * fully standard design there.
35
- *
36
- * Setting it makes this a CONFIDENTIAL client: the secret is sent as
37
- * `client_secret_post` (in the body), which is the method BID registers
38
- * confidential clients with.
39
- *
40
- * IT DOES NOT RELAX PKCE. A secret proves which APP is asking; the PKCE
41
- * verifier proves the request belongs to the browser that started the login.
42
- * They defend against different attacks, so one is never an excuse to drop
43
- * the other — and there is a test that fails if anyone makes it one.
44
- */
45
- clientSecret?: string;
46
- }
47
-
48
- /**
49
- * The signing-key cache.
50
- *
51
- * ── THE ONE BEHAVIOUR THIS FILE EXISTS FOR ────────────────────────────────
52
- *
53
- * It refetches on an UNKNOWN KEY ID, not on a timer (F084.4's constraint, and
54
- * it is the right one). An interval is a guess about when somebody else will
55
- * rotate their key; an unknown kid is the event itself. With an interval, the
56
- * window between "BID rotated" and "the interval elapsed" is a window where
57
- * every login in every app fails, and the length of that window is a number
58
- * nobody chose on purpose.
59
- *
60
- * ── AND THE PART AN OBVIOUS IMPLEMENTATION GETS WRONG ─────────────────────
61
- *
62
- * "Unknown kid ⇒ refetch" turns anyone who can send this app a token into
63
- * someone who can make it hammer BID: a stream of tokens with random kids is a
64
- * stream of fetches against the one service the whole fleet logs in through.
65
- * So a refetch is rate-limited by time. The cost of the floor is real and worth
66
- * stating: a rotation landing inside the cooldown makes logins fail for up to
67
- * that many milliseconds. Seconds of failure for one app beats a way to aim
68
- * traffic at BID from outside.
69
- *
70
- * ── TWO FAILURES, NOT ONE (components-F084.50) ───────────────────────────────────────
71
- *
72
- * Reported by helpdesk, measured in the published 0.1.0 dist: at process start
73
- * the key set is EMPTY, so the first verification fetches. If BID is down in
74
- * that second, NOTHING verifies for that app — not just new logins. A process
75
- * that has been running and has seen a kid survives the same outage untouched.
76
- *
77
- * The recovery was never the defect: `lastFetchAt` is set only on SUCCESS, so a
78
- * failed fetch does not block the next attempt and the cache self-heals the
79
- * moment BID answers again. The defect was that `getKey` threw the SAME error
80
- * for two states whose correct answers are opposites:
81
- *
82
- * cannot reach the issuer transient. Retry, or answer 503. The token may
83
- * well be perfectly good — we simply did not look.
84
- * kid is not published permanent. This token was not signed by us.
85
- * Reject it, 401, and do not retry.
86
- *
87
- * One error name for both is how a caller ends up rejecting a legitimate user
88
- * because a foreign service had a bad minute — or, worse, retrying a forgery.
89
- * Hence two subclasses. They BOTH extend JwksError, so a 0.1.0 consumer whose
90
- * catch tests `instanceof JwksError` keeps working across the upgrade.
91
- *
92
- * ── WHAT WE DELIBERATELY DID NOT DO ───────────────────────────────────────
93
- *
94
- * We do NOT persist the key set. A stored key set that survives a REVOCATION is
95
- * a worse failure than a login that is down for two minutes — helpdesk's own
96
- * point, and the reason `keys = body.keys` replaces rather than merges. Writing
97
- * the set to disk would reintroduce exactly what that line exists to prevent.
98
- */
99
-
100
- type SigningKey = Awaited<ReturnType<typeof importJWK>>;
101
- interface JwksCache {
102
- /** Resolve a key for this kid, refetching once if it is unknown. */
103
- getKey(kid: string, alg: string): Promise<SigningKey>;
104
- /** How many times the remote key set has actually been fetched. */
105
- readonly fetchCount: number;
106
- }
107
-
108
- /**
109
- * The core: framework-free, public-client OAuth 2.1 against Broberg ID.
110
- *
111
- * ── THIS PACKAGE IS A PUBLIC CLIENT. THERE IS NO CLIENT SECRET. ───────────
112
- *
113
- * Deliberate, and F084.4 AC#4 greps the published tarball to keep it that way.
114
- * PKCE alone carries the exchange. That is safe here for a reason worth stating
115
- * rather than assuming: BID matches redirect addresses EXACTLY (measured — one
116
- * trailing slash is refused), so the authorization code is delivered to this
117
- * app's own server and nowhere else. An attacker who knows the client id can
118
- * start a flow; they cannot receive its result.
119
- *
120
- * What it buys is the thing the card actually asks for: a secret that does not
121
- * exist cannot be committed, leaked in a log, copied into a second app, or left
122
- * behind in a repository someone later makes public.
123
- *
124
- * ── AND WHAT THIS PACKAGE MUST NEVER LEARN TO DO ──────────────────────────
125
- *
126
- * No passwords. No passkey registration. No social-provider keys. No email
127
- * verification. All of it lives in BID. A client that CAN do any of it is a
128
- * client somebody eventually uses to do it — and then the identity rules exist
129
- * in two places and drift.
130
- */
131
-
132
- interface Discovery {
133
- /** What the issuer says it signs with. Absent on some providers. */
134
- id_token_signing_alg_values_supported?: string[];
135
- issuer: string;
136
- authorization_endpoint: string;
137
- token_endpoint: string;
138
- jwks_uri: string;
139
- userinfo_endpoint?: string;
140
- end_session_endpoint?: string;
141
- }
142
- /** The signed-in user's own profile, as BID's GET /api/app/profile returns it. */
143
- interface BidProfile {
144
- sub: string;
145
- name: string | null;
146
- picture: string | null;
147
- email: string | null;
148
- /** BID's own account page — mail, password, passkeys, 2FA and sessions live there. */
149
- account_url: string;
150
- }
151
- /**
152
- * What `beginLogin` produces. Every field except `url` must survive the round
153
- * trip to BID and back — the adapter stores them in a short-lived cookie.
154
- *
155
- * They are NOT optional extras. `state` is what makes the callback provably the
156
- * answer to THIS request, and `nonce` is what stops a valid token minted for
157
- * some other login from being replayed into this one.
158
- */
159
- interface LoginStart {
160
- url: string;
161
- state: string;
162
- codeVerifier: string;
163
- nonce: string;
164
- }
165
- interface SsoClaims extends JWTPayload {
166
- sub: string;
167
- email?: string;
168
- name?: string;
169
- picture?: string;
170
- email_verified?: boolean;
171
- }
172
- interface LoginResult {
173
- claims: SsoClaims;
174
- /**
175
- * The claims in `claims` that came from the UNSIGNED userinfo response and
176
- * not from the signed ID token (components-F084.51, since 0.8.0). Sorted; an
177
- * empty list means everything in `claims` was signed (or userinfo was not
178
- * reached). When both sources carry a claim, the signed token's value wins
179
- * and the claim is not listed.
180
- *
181
- * On BID today this always contains "email" and "email_verified": the ID
182
- * token does not carry them (measured by broberg-id, 20 Sep 2026). A decision
183
- * that must rest on a signed identity binds on `claims.sub`, never on email.
184
- */
185
- unverifiedClaims: string[];
186
- idToken: string;
187
- accessToken?: string;
188
- refreshToken?: string;
189
- /** Seconds the access token lives, when BID said so (`expires_in`). */
190
- expiresIn?: number;
191
- }
192
- /**
193
- * `prompt=none` asks BID to answer WITHOUT showing anything (components-F084.6).
194
- *
195
- * The delivery method decides whether it works, and this package only ever
196
- * produces a URL for a FULL TOP-LEVEL REDIRECT. It never returns anything an
197
- * app could put in a hidden iframe, because an iframe against the identity
198
- * provider is third-party context: Safari has blocked it for years and Chrome
199
- * is retiring it. That path works in testing on a Mac and fails for every user
200
- * on an iPhone — a failure that looks like "you are not logged in".
201
- */
202
- interface BeginLoginOptions {
203
- prompt?: "none" | "login" | "consent" | "select_account";
204
- /** Extra scopes for this one request, on top of the configured set. */
205
- scopes?: string[];
206
- }
207
- interface SsoClient {
208
- discovery(): Promise<Discovery>;
209
- beginLogin(options?: BeginLoginOptions): Promise<LoginStart>;
210
- completeLogin(input: {
211
- params: URLSearchParams;
212
- state: string;
213
- codeVerifier: string;
214
- nonce: string;
215
- }): Promise<LoginResult>;
216
- verifyIdToken(idToken: string, options?: {
217
- nonce?: string;
218
- }): Promise<SsoClaims>;
219
- logoutUrl(options?: {
220
- idTokenHint?: string;
221
- postLogoutRedirectUri?: string;
222
- }): Promise<string>;
223
- /**
224
- * Does this address belong to the signed-in user's BID account, and is it
225
- * verified there? Asks BID's POST /api/app/address-ownership with the user's
226
- * access token. Throws SsoError on anything that is not one of the three
227
- * answers — see the implementation for why that must never become "unverified".
228
- */
229
- addressOwnership(accessToken: string, address: string): Promise<AddressOwnership>;
230
- /**
231
- * F095.1 — trade a refresh token for a new access token (`grant_type=refresh_token`).
232
- * Throws SsoError on refusal; the message carries the status and BID's error
233
- * code, never a token.
234
- */
235
- refreshTokens(refreshToken: string): Promise<{
236
- accessToken: string;
237
- refreshToken?: string;
238
- expiresIn?: number;
239
- }>;
240
- /**
241
- * Invite users to BID on behalf of this app (the app's OWN key, `bidk_…`).
242
- * One result per email. Throws SsoError on anything BID did not clearly say.
243
- */
244
- inviteUsers(appKey: string, input: InviteUsersInput): Promise<InvitationResult[]>;
245
- /**
246
- * Where each user is in the move to BID. Throws SsoError on any state it does
247
- * not know — never a silent "ready", which would switch off a user's old login
248
- * while they cannot get in.
249
- */
250
- migrationStatus(appKey: string, emails: string[]): Promise<MigrationStatus>;
251
- /**
252
- * Verify an OIDC Back-Channel Logout token from BID («Log ud overalt»).
253
- * Throws SsoError on anything that is not a valid, sub-only logout token.
254
- */
255
- verifyLogoutToken(token: string): Promise<LogoutToken>;
256
- /**
257
- * F095.2 — the signed-in user's own profile (scope `profile`), with the user's
258
- * access token. Throws SsoAppApiError on any refusal.
259
- */
260
- getProfile(accessToken: string): Promise<BidProfile>;
261
- /** F095.2 — change the user's own name (scope `profile:write`). Returns the new profile. */
262
- updateProfile(accessToken: string, name: string): Promise<BidProfile>;
263
- /**
264
- * F095.2 — replace the user's picture (scope `profile:write`). Refuses more than
265
- * MAX_AVATAR_BYTES or a type outside AVATAR_TYPES with SsoAvatarRejectedError
266
- * BEFORE anything is sent.
267
- */
268
- uploadAvatar(accessToken: string, bytes: Uint8Array | ArrayBuffer, contentType: string): Promise<BidProfile>;
269
- /** F095.2 — remove the user's picture (scope `profile:write`). */
270
- removeAvatar(accessToken: string): Promise<BidProfile>;
271
- /** Exposed for tests and for a health check; not needed in normal use. */
272
- readonly jwks: JwksCache;
273
- }
274
- type AddressOwnership = "verified" | "unverified" | "not_on_account";
275
- type InvitationOutcome = "invited" | "already_invited" | "existing" | "invalid" | "too_soon" | "too_many_today" | "send_failed";
276
- interface InviteUsersInput {
277
- customerName: string;
278
- appUrl: string;
279
- appName?: string;
280
- switchDate?: string;
281
- users: Array<{
282
- email: string;
283
- name: string;
284
- }>;
285
- }
286
- interface InvitationResult {
287
- email: string;
288
- outcome: InvitationOutcome;
289
- mailId?: string;
290
- problem?: string;
291
- }
292
- type MigrationState = "ready" | "invited" | "expired" | "not_invited";
293
- interface MigrationStatus {
294
- users: Array<{
295
- email: string;
296
- state: MigrationState;
297
- expiresAt?: string;
298
- }>;
299
- counts: Record<string, number>;
300
- complete: boolean;
301
- }
302
- /** A verified back-channel logout: every session for `sub` issued at or before `iat` is over. */
303
- interface LogoutToken {
304
- sub: string;
305
- iat: number;
306
- jti: string;
307
- exp: number;
308
- }
309
-
310
- /**
311
- * Server-side tokens for calling Broberg ID on the user's behalf (F095.1).
312
- *
313
- * The login already RECEIVES an access token and a refresh token; before 0.9.0
314
- * ssoRoutes dropped both, so an app could not call BID's app API (the account
315
- * page: read and change your own name and picture) as the user.
316
- *
317
- * ── WHY NOT IN THE COOKIE ────────────────────────────────────────────────
318
- * The session cookie is SIGNED, not encrypted, and the browser can read it. A
319
- * refresh token is the longest-lived, highest-value credential in the whole
320
- * flow: whoever holds it can mint access tokens until it is revoked. So the
321
- * tokens live server-side, keyed by a random `sid` that is the only thing the
322
- * cookie carries. A stolen cookie still needs the server to be useful.
323
- */
324
- interface TokenSet {
325
- /** The BID subject the tokens belong to — lets «Log ud overalt» find them. */
326
- sub: string;
327
- accessToken: string;
328
- refreshToken?: string;
329
- /** Unix seconds. */
330
- expiresAt: number;
331
- }
332
- /**
333
- * Where the tokens live. The APP provides it in production, backed by
334
- * something every instance shares (SQLite on one machine, a database or Redis
335
- * on several) — the same reason BackchannelStore has no in-memory default.
336
- */
337
- interface TokenStore {
338
- get(sid: string): Promise<TokenSet | undefined>;
339
- set(sid: string, tokens: TokenSet): Promise<void>;
340
- delete(sid: string): Promise<void>;
341
- /** Drop every token set for `sub` — what «Log ud overalt» calls. */
342
- deleteSub(sub: string): Promise<void>;
343
- }
344
-
345
- /**
346
- * The app's OWN session cookie — signed, not encrypted.
347
- *
348
- * Signed is the right choice and the distinction matters: the contents are not
349
- * secret (a user may read their own id and name), but they must not be
350
- * FORGEABLE. Encryption would hide a subject id the user already knows while
351
- * doing nothing extra about forgery, which is the actual risk.
352
- *
353
- * What goes in is deliberately small: who you are and when this stops being
354
- * true. Claims that can change — a name, a role, a picture — belong in the
355
- * app's own store keyed by `sub`, because a cookie is a cache nobody can
356
- * invalidate, and a stale role in a cookie is a permission that outlives its
357
- * revocation.
358
- */
359
- interface SessionPayload {
360
- /** The subject from Broberg ID. The one stable identifier. */
361
- sub: string;
362
- /** Unix seconds. Checked on every read. */
363
- exp: number;
364
- /**
365
- * Unix seconds, when this session was minted. What «Log ud overalt» compares
366
- * against (F084.151). Absent on sessions minted before 0.6.0 — those count as
367
- * older than any logout.
368
- */
369
- iat?: number;
370
- /**
371
- * F095.1 — the key to this session's server-side tokens (ssoRoutes'
372
- * `tokenStore`). Random, 128 bits. The tokens themselves are NEVER in the
373
- * cookie: it is signed, not encrypted, and the browser can read it.
374
- */
375
- sid?: string;
376
- /** Optional convenience copies; never authorisation data. */
377
- email?: string;
378
- name?: string;
379
- /** A URL, never bytes. The picture lives wherever BID put it; this is a
380
- * reference, so a changed picture propagates without every app storing a
381
- * copy that then goes stale. */
382
- picture?: string;
383
- /**
384
- * Whether BID has verified `email` (F084.152). Absent when BID did not say.
385
- * Bind access to an address only when this is `true`.
386
- */
387
- email_verified?: boolean;
388
- }
3
+ import { S as SsoClient, a as SsoConfig, T as TokenStore, b as SessionPayload } from './session-DsjOyCJb.js';
4
+ import 'jose';
389
5
 
390
6
  /**
391
7
  * Where «Log ud overalt» is remembered (F084.151). The APP provides it, backed by