@ultimat3/auth 1.2.0 → 2.0.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/CLAUDE.md ADDED
@@ -0,0 +1,231 @@
1
+ # @ultimat3/auth — agent notes
2
+
3
+ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3/policy`'s job.
4
+
5
+ | Rule | |
6
+ |---|---|
7
+ | Deps | `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/db`. No external deps. |
8
+ | Never import | `@ultimat3/policy`, `@ultimat3/http` (tier 2 consumers), `@ultimat3/mail` (sideways) |
9
+ | Policy seam | `PolicyActorFields` in `policy-bridge.ts` mirrors policy's shape structurally |
10
+ | Http seam | `RequestLike` / `CookieJar` in `session.ts` and `AuthRouteDescriptor` in `oauth-route.ts`; http binds to them, not the reverse |
11
+ | Mail seam | injected `MailSender` port in `verify.ts`; the app wires `@ultimat3/mail`'s `send` |
12
+ | Better Auth | binds through `AuthAdapter`. It is an adapter, never a dependency. |
13
+ | Errors | `AuthError` from `errors.ts`; never `throw new Error` |
14
+ | Time | take a `Clock`. No `Date.now()` anywhere in this package. |
15
+ | Secrets | compare with `timingSafeEqual` (from `@ultimat3/core`, re-exported off `tokens.ts` — same implementation `@ultimat3/storage` uses); store `sha256Hex`. Never `===` on a secret. |
16
+
17
+ ## Non-negotiables
18
+
19
+ - Every credential failure throws `loginFailed()` — one code, one cause, one fix. Adding a
20
+ parameter to it re-opens account enumeration.
21
+ - The limiter's table is **bounded**, and the eviction order is part of the guarantee. `ipKey`
22
+ mints one entry per source address, so half the keys are attacker-chosen and a spray from an
23
+ IPv6 /64 is a fresh key per attempt. Every bucket carries `forgetAtMs` — window emptied *and*
24
+ lockout expired, the instant it answers exactly as a missing one — and the sweep drops those
25
+ for free. `policy.maxKeys` (`DEFAULT_MAX_AUTH_LIMIT_KEYS`) is the backstop, and a **live
26
+ lockout outranks its own deadline** in the comparator: without that rank a spray recorded a
27
+ second later sorts ahead of the account it just locked, and filling the table becomes a way to
28
+ buy attempts back. Never reduce that sort to recency.
29
+ - **`AuthLimiter` is async on every member, and it declares the policy it enforces.** A
30
+ synchronous signature is one no shared implementation can satisfy — a lockout that holds across
31
+ replicas is a network round trip — so the interface the comment always promised was unreachable
32
+ by construction. `defineAuth` resolves the app's declaration and compares it against
33
+ `limiter.policy`, once, in `assertAuthLimiterPolicy`: a per-process limiter under
34
+ `scope: 'shared'` is `X_AUTH_LIMITER_NOT_SHARED`, and different `maxAttempts`/`windowMs`/
35
+ `lockoutMs` is `X_AUTH_LIMITER_POLICY_MISMATCH` — both at boot, never at the first spray.
36
+ `maxKeys` is **not** compared: it bounds one process' table, so a shared limiter has no opinion
37
+ on it. The point is that `Auth.rateLimit` is what an operator reads as "what this deployment
38
+ enforces", so an injected limiter may not quietly enforce something else. Nothing here reads the
39
+ environment to guess a replica count. `defineAuth({ limiter })` is the one install point.
40
+ - **`normaliseEmail` is the ONE normalisation, it lives ABOVE the `AuthAdapter` seam, and no
41
+ adapter may fold case** (`As of 2026-08`). `MemoryAdapter` lowercased and trimmed on both
42
+ `findUserByEmail` and `createUser`; `BuiltinAdapter` issues `where email = $1` against a plain
43
+ case-sensitive `text ... unique`. Two adapters, two answers to "does this account exist" — and
44
+ `oauth-login.ts`'s `resolveUser` normalised nothing at all, carrying the provider's display
45
+ casing straight through. So a provider sending `Ada@Example.com` linked the existing account
46
+ under `x dev` and minted a SECOND one in production, at an address `login()` (which lowercases)
47
+ could then never reach; a later `register()` at the lowercase spelling made a third. Every door
48
+ now normalises before the adapter sees the address — `register`, `login`, `profileEmail` in
49
+ `oauth-login.ts`, and `accountKey`, which must key the same way or one address buys a fresh
50
+ lockout budget per spelling. `adapter-parity.test.ts` pins both adapters in one test, the shape
51
+ `jobs/driver-parity.test.ts` established; `MemoryAdapter` normalising again is a failing test.
52
+ Trim and lowercase only: stripping a `+tag` or a gmail dot MERGES two addresses a person kept
53
+ apart, which is takeover between colleagues at one domain.
54
+ - **`json.ts` owns `isRecord` and `decodeJwtSegment`, and this package holds no second copy.**
55
+ `isRecord` was declared six times here and the base64url-JSON-payload decode three
56
+ (`jwks.decodeJwtHeader`, `id-token.decodeSegment`, `workload.verifyWorkloadToken`). All six
57
+ agreed that an array is not a record, which is the fact that matters: on a decoded JWT payload
58
+ the check is what gates every claim read after it, and `JSON.parse('[]')` narrows to
59
+ `Record<string, unknown>` without it. One declaration means one place for that to be true.
60
+ `decodeJwtSegment` answers `null` for all three failures — not base64url, not JSON, not an
61
+ object — because each caller has its own coded refusal to raise.
62
+ - Absolute and idle expiry are two separate computations in `sessionExpiry()`. Do not fold them.
63
+ - PKCE is not provider-dependent. `OAuthProvider.usesPkce` is the literal `true`, not `boolean`,
64
+ so `usesPkce: false` is a type error rather than a comment — and there is no
65
+ `if (provider.usesPkce)` branch left anywhere for it to have been false in. It stays the literal
66
+ now that `registerOAuthProvider` is open to any app: the mechanism has to survive the opening.
67
+ - **Providers are a registry, `OAuthProviderId` is `string`.** A closed union of three consumer
68
+ IdPs made an enterprise OP *unrepresentable* — a type constraint has no runtime escape, so the
69
+ only ways out were forking the package or bypassing OAuth entirely and losing PKCE, the sealed
70
+ handshake, issuer pinning and account linking with it. The three built-ins seed the registry
71
+ through the same `registerOAuthProvider()` an app calls, so there is still one way to do it.
72
+ `providerFor(id)` throws `X_OAUTH_PROVIDER_UNKNOWN` and never answers `undefined`; a second claim
73
+ on one id is `X_OAUTH_PROVIDER_DUPLICATE` at boot, never a silent replacement.
74
+ - **`oauthProviderUnknown(provider, supported)` scopes its list to its reader.** The route passes
75
+ `BUILTIN_OAUTH_PROVIDER_IDS` — an anonymous stranger typed that URL, and the registry now holds
76
+ whatever internal OP this deployment registered. `providerFor()` passes `oauthProviderIds()` —
77
+ its reader is a developer with a stack trace, and the full list is what makes the fix runnable.
78
+ Neither ever passes `defineAuth({ providers })`. One code, two audiences, one sentence that stays
79
+ executable either way because it names `registerOAuthProvider` before it names the list.
80
+ - **`verifyIdToken({ keys })` is required, with no default.** `'token-endpoint-tls'` is the OIDC
81
+ Core 3.1.3.7 exemption stated out loud, and `exchangeOAuthCode` is the only shipped caller
82
+ entitled to it. Anything else — IdP-initiated login, `form_post`, back-channel logout, token
83
+ exchange — passes a `JwksKeySource` and gets the signature checked. A default is exactly what
84
+ would let a second door inherit "unverified" from the first. `HS256` and `alg: none` are refused
85
+ in `decodeJwtHeader` before a key is ever looked up.
86
+ - **`resolveGrants` is a seam, never a group-to-role table.** It is called on EVERY login, not only
87
+ at creation, or "remove them from the group in the IdP" is a no-op forever. Absent means the app
88
+ has no opinion and the stored row is left alone; a seam returning the stored answer writes
89
+ nothing. Creating a user with no roles and no org logs a warning — that account can do nothing.
90
+ - **`verifySession` writes at most once per `idleSlideMs`** (default `idleTtlMs / 20`). Throttling
91
+ the SESSION write is safe; caching the USER row is not — `authenticate` re-reads it on every
92
+ request, and that is what makes a revoked role take effect on the next one with no token-expiry
93
+ lag. Do not cache it.
94
+ - A user's `scopes` column reaches `Actor.scopes`. Hardcoding `[]` there made a scope something no
95
+ human could hold, so `hasScope(actor, 'tenancy:cross')` was satisfiable only by minting a
96
+ `serviceActor` inside the handler — which discards the operator's identity and makes the sweep
97
+ unattributable, the exact property the required reason string exists to preserve.
98
+ - Every revocation takes a `reason` and logs `auth.revocation` before it runs.
99
+ `deleteSessionsForOrg` joins through `x_users`; `x_sessions` does **not** gain an `org_id`,
100
+ because a denormalised membership goes stale the moment somebody moves org and the 03:00 sweep
101
+ then leaves live exactly the sessions it was run to kill.
102
+ - The code flow carries `nonce` inside the id token, not on the redirect. `assertOAuthCallback`
103
+ checks an echoed one when present and never requires it; `verifyIdToken` is the real gate.
104
+ - The handshake crosses two requests, so it is sealed (`sealHandshake`), never handed over in a
105
+ variable. `openHandshake` takes the provider as an argument for the reason `decodeCursor` takes
106
+ a scope: an optional check is one a call site forgets. Expiry is the server's clock, not `Max-Age`.
107
+ - One handshake cookie **per provider** (`handshakeCookieName`), never one shared slot. Two tabs
108
+ are two handshakes in one jar, and a shared name makes the second redirect overwrite the first.
109
+ `clearHandshakeCookie(provider)` for the same reason: clearing all of them cancels the other tab.
110
+ - `takeVerification(purpose, identifier, tokenHash)` consumes the row **only on a hash match**,
111
+ in one conditional statement. The hash is an argument to the consume, not a comparison after it:
112
+ a store that consumes first lets `{identifier:'victim@…', token:'x'}` kill the victim's live
113
+ link, one request per address, unauthenticated. `consumeVerification` still compares in constant
114
+ time on the row it gets back — the seam is an app's to implement, and one that ignores the
115
+ argument would otherwise redeem any token. The Postgres statement carries `consumed_at is null`
116
+ on the UPDATE **and** in its subselect (single-use under two racing redemptions), and
117
+ `order by created_at desc limit 1` so it can only ever consume one row.
118
+ - **Foreign text reaching a `cause:` goes through `renderCauseValue`, and a `fix:` through
119
+ `renderFixLiteral`.** Not for throw-safety — these values are `string` by type, so
120
+ `bun run error-render` (which only sees `unknown`/`any`) will never catch one — but because a
121
+ newline writes a second log line an operator reads as genuine. Three values in this package are
122
+ foreign and all three are rendered at their source: `providerDetail()`'s return (a REMOTE
123
+ server's bytes, rendered there rather than at `oauthExchangeFailed` so the prose details this
124
+ package authors stay unquoted), `claims.iss` in `id-token.ts` (a field of the JWT the caller
125
+ presented), and `accountLocked`'s `key` (built by `ipKey` from a caller-supplied address).
126
+ `${provider}` is NOT in that set — it is registry-validated on every shipped path, so it is boot
127
+ config like `clientIdEnv`, not request data. Swept whole `As of 2026-08`.
128
+ - `readCookie` never throws on a malformed value. The `Cookie:` header is attacker-controlled and
129
+ `decodeURIComponent('%')` is a bare `URIError`, which would escape every coded path in this
130
+ package — the raw value goes to the signature or hash check, which is the readable refusal.
131
+ - A token endpoint's HTTP 200 is not success — GitHub reports a dead code that way. Read `error`.
132
+ - Link by address only when the provider **and** the local account both verified it. That is
133
+ `link: 'verified-email'`, the default; `'never'` is the only other value and there is
134
+ deliberately no "link on any provider address" — it is account takeover at a sloppy
135
+ provider, so it is unrepresentable rather than discouraged. An app that wants it wraps
136
+ `signInWithOAuth`.
137
+ - **The OAuth route paths are not configurable.** `oauth-paths.ts` imports nothing and is
138
+ read by both `errors.ts` and `oauth-route.ts`, so a `fix:` line naming
139
+ `GET /auth/oauth/<provider>` cannot outlive the route again — which is exactly what it did
140
+ through 1.2.0, when the library functions shipped with no route to mount them in. Every
141
+ "start over" fix is built from `restartAt(provider)`.
142
+ - The routes are **descriptors** (`mcpHttpRoute()`'s category), never mounted handlers:
143
+ `@ultimat3/http` is tier 2 like this package and `defineRoute` is tier 4 and renders a
144
+ page, so neither is importable here. A bare `Request` in, a `Response` out.
145
+ - The callback answers failure as **coded JSON**, and the success hop redirects to a fixed
146
+ `successPath` — never `?next=`. `nextAfterSignIn` in `@ultimat3/http` is the one
147
+ implementation of the open-redirect check and a second copy is one that drifts.
148
+ - The handshake cookie is cleared on **every** callback outcome, success and failure alike:
149
+ the code it authorised is spent either way.
150
+ - Refresh is **not implemented**. `AuthAccount` persists `refreshToken` and `expiresAt`, and
151
+ nothing reads them yet — the session is the framework's own credential and does not depend
152
+ on the provider token.
153
+ - The new `AuthAdapter` members are OPTIONAL (`findUserByExternalId`, `listUsersByOrg`,
154
+ `deleteSessionsForUser`, `deleteSessionsForOrg`, `deleteSessionsCreatedBefore`). A required
155
+ member is a breaking change to every third-party adapter; the callers throw
156
+ `X_NOT_IMPLEMENTED` naming the method instead.
157
+ - An api key's scopes are the agent actor's scopes. Never union them with the owner's roles.
158
+ - Rotate the session id on any privilege change (`rotateSession`), never patch the row.
159
+ `updatePrivileges` in `privileges.ts` is the caller that makes that rule exist — it had none
160
+ until 1.3.0, and `SessionPolicy.rotateOnPrivilegeChange` was a flag nothing read.
161
+ - **Every argon2 call goes through `kdfGate()`, and that is the only thing bounding its memory.**
162
+ 19 MiB of arena per hash at the OWASP floor, and both existing gates are per-SOURCE (`ipKey(ip)`,
163
+ 5 attempts; `@ultimat3/http`'s `auth` bucket, 10 per `route|ip:`) so a spray rotating an IPv6 /64
164
+ mints a fresh key every attempt — and both cap ATTEMPTS, not concurrent WORK. The only backstop
165
+ left was `http.maxInflight` (1000), about 19 GB of arenas queued. `kdf-gate.ts` bounds the width
166
+ (8) and the waiting queue (64) and refuses past it with `X_OVERLOADED`, borrowed from http and
167
+ listed in `AUTH_BORROWED_ERROR_CODES` — this package cannot import http, and a shed is a shed
168
+ whichever layer performs it. `configureKdfGate()` is the ONE install point and is deliberately
169
+ not a `defineAuth` key: the ceiling is a property of the machine, not of the app's auth policy.
170
+ - **MFA has a first leg and no second one, and the second one is not a route you can just add.**
171
+ `login()` and `completeOAuthLogin()` throw `X_MFA_REQUIRED` before any session exists; nothing is
172
+ written, so the only value handed over is a user id in `meta`. A `POST /auth/mfa/verify
173
+ { userId, code }` built on that is **unauthenticated by construction** — nothing binds it to a
174
+ completed first factor, so it converts MFA from a second factor into the only factor. That is why
175
+ the `fix:` now tells an app author to finish the flow itself (`verifyTotp` → `createSession({
176
+ mfaSatisfied: true })`) rather than naming a route, and why no route was added under a bug fix.
177
+ The framework's own second leg needs three things landing together, and fewer is worse than none:
178
+ a **sealed pending-MFA credential** built like `sealHandshake` (`oauth-cookie.ts`) — server-clock
179
+ expiry, one cookie, bound to the user id the first factor proved and to nothing the client says;
180
+ the completion shipped as an `AuthRouteDescriptor` the way `oauthLogin()` was (`oauth-route.ts`),
181
+ with its path declared in `oauth-paths.ts`'s style so the `fix:` and the mount cannot drift; and
182
+ `auth.limiter` around `verifyTotp` — today it is wired only into `login`, so a six-digit code
183
+ would be the one credential in this package with no lockout. `TotpReplayGuard` is already built
184
+ and must be the completion's, not a second one.
185
+ - SAML is out of scope permanently: XML-DSig canonicalisation has no Bun native and would need a
186
+ real dependency. Put an OIDC-speaking bridge in front and register that.
187
+
188
+ ## Files
189
+
190
+ | File | Job |
191
+ |---|---|
192
+ | `auth.ts` | `defineAuth`, entity schemas, `login`/`register`/`authenticate`/`logout` |
193
+ | `policy-bridge.ts` | the one funnel: identity → `Actor`, all four `ActorKind`s |
194
+ | `session.ts` | two expiries, rotation, revocation, device list, the cookie |
195
+ | `adapter.ts` | the seam; `builtin-adapter.ts` (Postgres) + `memory-adapter.ts` |
196
+ | `rate-limit.ts` | per-ip, per-account and per-org buckets, lockout, scope check, `loginFailed()` |
197
+ | `oauth.ts` | `OAuthProvider`, PKCE, `beginOAuth`, the callback gate. No I/O, no env |
198
+ | `oauth-builtins.ts` | the three shipped IdPs, as data. Imports only the type, so no cycle |
199
+ | `oauth-registry.ts` | the registry: `registerOAuthProvider`, `providerFor`, `oauthProviderIds` |
200
+ | `oauth-discovery.ts` | `/.well-known/openid-configuration` → an `OAuthProvider`. One `fetch` |
201
+ | `jwks.ts` | `crypto.subtle` signature verification, cached by `kid`. No dependency |
202
+ | `workload.ts` | a workload JWT (K8s SA / SPIFFE / IMDS / RFC 8693) → a `ServiceIdentity` |
203
+ | `revocation.ts` | per-user, per-org and before-an-instant sweeps; `disableUser` |
204
+ | `directory.ts` | `describeUser` (allow-list projection), `listOrgUsers`, external-id lookup |
205
+ | `privileges.ts` | `updatePrivileges` — the grant, and the rotation it requires |
206
+ | `oauth-cookie.ts` | the handshake's home between the two legs: seal, open, the cookie |
207
+ | `oauth-exchange.ts` | `oauthCredentials` + the one POST to the token endpoint |
208
+ | `id-token.ts` | id token → claims this handshake may believe |
209
+ | `id-token-fixture.ts` | the one string-input JWT builder the OAuth tests share. Off `index.ts` |
210
+ | `oauth-profile.ts` | claims or userinfo → one `OAuthProfile` |
211
+ | `oauth-login.ts` | profile → account link → session. `completeOAuthLogin` is the entry point |
212
+ | `oauth-login-fixture.ts` | the adapter, clock and profile the three `oauth-login*` suites share. Off `index.ts` |
213
+ | `oauth-paths.ts` | the one declaration of where the two routes live. Imports nothing |
214
+ | `oauth-route.ts` | `oauthLogin(auth)` — the redirect out and the callback back |
215
+ | `kdf-gate.ts` | the one bound on concurrent argon2 work, and the `X_OVERLOADED` past it |
216
+ | `email.ts` | `normaliseEmail` — the one normalisation an address gets before it is an identity key |
217
+ | `json.ts` | reading untrusted JSON: `isRecord`, and a base64url JWT segment as an object or `null` |
218
+
219
+ ```bash
220
+ bun test packages/auth
221
+ bun run --filter @ultimat3/auth typecheck
222
+ ```
223
+
224
+ Gotchas:
225
+ - `exactOptionalPropertyTypes` — declare optional fields as `x?: T | undefined`.
226
+ - `noUncheckedIndexedAccess` — index a `Record` into a local before narrowing it.
227
+ - `X_NOT_IMPLEMENTED` is core's, `X_FORBIDDEN` is policy's. `errors.ts` registers only the codes
228
+ this package **owns**, unconditionally, and lists the borrowed two in `AUTH_BORROWED_ERROR_CODES`
229
+ without a title. A `hasErrorCode()` guard would suppress the `X_ERROR_CODE_DUPLICATE` that is
230
+ supposed to fire when two packages claim one code.
231
+ - Tests run against `MemoryAdapter`; nothing in this package needs a database.