@ultimat3/auth 20.2.1 → 22.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 +140 -455
- package/README.md +47 -9
- package/package.json +4 -4
- package/src/api-keys.ts +5 -1
- package/src/builtin-adapter.ts +10 -4
- package/src/client-scope.ts +68 -0
- package/src/errors.ts +28 -0
- package/src/index.ts +7 -27
- package/src/memory-adapter.ts +21 -5
- package/src/oauth-exchange.ts +7 -1
- package/src/oauth-profile.ts +11 -3
- package/src/sign-out.ts +44 -0
- package/src/tables.ts +12 -8
package/CLAUDE.md
CHANGED
|
@@ -16,468 +16,149 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
16
16
|
|
|
17
17
|
## Non-negotiables
|
|
18
18
|
|
|
19
|
-
- **
|
|
20
|
-
`X_CONFIG_INVALID`
|
|
21
|
-
`password.minLength`, the two argon2 costs
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
- **
|
|
29
|
-
`
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
`maxKeys` (`Math.max(1, Math.floor(NaN))` is `NaN`, so the sweep bounding a table half of whose
|
|
35
|
-
keys are attacker-chosen never ran) and `mfa.drift`. **`drift` is the one that hangs**: it is a
|
|
36
|
-
LOOP BOUND, not a comparison — `for (offset = -Infinity; offset <= Infinity; offset += 1)` never
|
|
37
|
-
terminates, measured, synchronously, on the login path — and `NaN` makes the loop never run at
|
|
38
|
-
all, so every correct code is rejected as if it were wrong. Editing `verifyTotp` with the screen
|
|
39
|
-
removed WEDGES `mfa.test.ts` rather than failing it; mutate with the `NaN` case.
|
|
40
|
-
|
|
41
|
-
**That sentence was false when it was written and four more sites closed it, `As of 2026-08-26`.**
|
|
42
|
-
It named the options `defineAuth` resolves and the ones a *service* call takes, and missed every
|
|
43
|
-
number a **cookie** or a **mailed link** is bounded by. `oauth.handshake.ttlMs` is the one that
|
|
44
|
-
matters: `openHandshake`'s `now - issuedAt > NaN` is false, so a year-old sealed handshake — the
|
|
45
|
-
state, nonce and PKCE verifier that ARE the callback leg's CSRF defence — opened and returned its
|
|
46
|
-
verifier, while `handshakeCookie` wrote `Max-Age=NaN`, which is not `delta-seconds`, so the
|
|
47
|
-
browser dropped the attribute and kept the cookie for the whole session. One number, both ends of
|
|
48
|
-
the deadline, off together and silently. `session.cookie.maxAgeSeconds` is the same shape beside
|
|
49
|
-
a `policy.absoluteTtlMs` that WAS screened; `kdf.maxConcurrent`/`kdf.maxQueued` are the second
|
|
50
|
-
thing that WEDGES after `mfa.drift` — core asks `active < maxConcurrent` then
|
|
51
|
-
`waiters.length >= maxQueued`, both false for `NaN`, so every `hashPassword` on the box parks in
|
|
52
|
-
an unbounded queue nothing releases and login stops answering rather than shedding.
|
|
53
|
-
|
|
54
|
-
- **A number is screened ABOVE the write it feeds, not beside the arithmetic** (`As of
|
|
55
|
-
2026-08-26`). `issueVerification` read `input.ttlMs ?? DEFAULT_VERIFICATION_TTL_MS[purpose]`
|
|
56
|
-
straight into `new Date(now + ttl)` and threw a bare `RangeError` — `toISOString()` refuses an
|
|
57
|
-
Invalid Date — on the line AFTER `putVerification` had already resolved. Two faults from one
|
|
58
|
-
number: an uncoded throw out of the package, and a durable row whose expiry `consumeVerification`
|
|
59
|
-
compares as `now >= NaN`, false for ever, i.e. a password-reset link that never expires. The
|
|
60
|
-
write also upserts on `(purpose, identifier)`, so the failing call had destroyed whatever live
|
|
61
|
-
token that address held on its way out. `verify.test.ts` asserts the ordering directly (no row
|
|
62
|
-
written, no mail sent, the earlier token still redeemable) — moving the screen below the store
|
|
63
|
-
call fails those three and leaves the coded-refusal case green.
|
|
64
|
-
|
|
65
|
-
- **The minimum is per option, and zero is usually legitimate.** `assertFiniteAuthCount`'s `min` is
|
|
66
|
-
`0 | 1` because only the call site knows what zero MEANS, and picking `1` for tidiness is how a
|
|
67
|
-
screen breaks a working deployment while every test still passes. `maxAgeSeconds: 0` is how a
|
|
68
|
-
cookie is expired — it is what sign-out emits — and `{ maxConcurrent: 0, maxQueued: 0 }` is a
|
|
69
|
-
gate that refuses every hash, which is how `password.test.ts` proves the unreadable-hash path
|
|
70
|
-
burns the same KDF a wrong password does. Both take `min: 0`. A TTL takes `min: 1`, because a
|
|
71
|
-
handshake or a mailed link that is already expired when it is issued is not a configuration.
|
|
72
|
-
|
|
19
|
+
- **Every number this package bounds anything with is screened** (`policy-numbers.ts`): policy
|
|
20
|
+
numbers at `defineAuth` (`X_CONFIG_INVALID` — `session.absoluteTtlMs`, `idleTtlMs`,
|
|
21
|
+
`idleSlideMs`, `password.minLength`, the two argon2 costs, every `rateLimit` number), and every
|
|
22
|
+
runtime one through `assertFiniteAuthCount` — `jwks.ttlMs`, the three OAuth legs' `timeoutMs`
|
|
23
|
+
(screened OUTSIDE each leg's `try`), the limiter's `maxKeys`, `mfa.drift`,
|
|
24
|
+
`oauth.handshake.ttlMs`, `session.cookie.maxAgeSeconds`, `kdf.maxConcurrent`/`maxQueued`. A `NaN`
|
|
25
|
+
makes every comparison false: no expiry, the empty password accepted, a year-old handshake
|
|
26
|
+
opened. **`mfa.drift` and the KDF gate are the two that HANG** — mutate `verifyTotp` with the
|
|
27
|
+
`NaN` case, never by deleting the screen (that wedges `mfa.test.ts`).
|
|
28
|
+
- **A number is screened ABOVE the write it feeds.** `issueVerification` screens its TTL before
|
|
29
|
+
`putVerification` (which upserts on `(purpose, identifier)`); `verify.test.ts` asserts no row
|
|
30
|
+
written, no mail sent, the earlier token still redeemable.
|
|
31
|
+
- **The minimum is per option; zero is often legitimate.** `assertFiniteAuthCount`'s `min` is
|
|
32
|
+
`0 | 1`: `maxAgeSeconds: 0` is sign-out, `{ maxConcurrent: 0, maxQueued: 0 }` is how
|
|
33
|
+
`password.test.ts` proves an unreadable hash burns the KDF. A TTL takes `min: 1`.
|
|
73
34
|
- Every credential failure throws `loginFailed()` — one code, one cause, one fix. Adding a
|
|
74
35
|
parameter to it re-opens account enumeration.
|
|
75
|
-
- **A stored hash Bun cannot read is the generic failure, and it burns the same KDF
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
- The limiter's table is **bounded**, and the eviction order is part of the guarantee. `ipKey`
|
|
88
|
-
mints one entry per source address, so half the keys are attacker-chosen and a spray from an
|
|
89
|
-
IPv6 /64 is a fresh key per attempt. Every bucket carries `forgetAtMs` — window emptied *and*
|
|
90
|
-
lockout expired, the instant it answers exactly as a missing one — and the sweep drops those
|
|
91
|
-
for free. `policy.maxKeys` (`DEFAULT_MAX_AUTH_LIMIT_KEYS`) is the backstop, and a **live
|
|
92
|
-
lockout outranks its own deadline** in the comparator: without that rank a spray recorded a
|
|
93
|
-
second later sorts ahead of the account it just locked, and filling the table becomes a way to
|
|
94
|
-
buy attempts back. Never reduce that sort to recency.
|
|
95
|
-
- **`AuthLimiter` is async on every member, and it declares the policy it enforces.** A
|
|
96
|
-
synchronous signature is one no shared implementation can satisfy — a lockout that holds across
|
|
97
|
-
replicas is a network round trip — so the interface the comment always promised was unreachable
|
|
98
|
-
by construction. `defineAuth` resolves the app's declaration and compares it against
|
|
99
|
-
`limiter.policy`, once, in `assertAuthLimiterPolicy`: a per-process limiter under
|
|
100
|
-
`scope: 'shared'` is `X_AUTH_LIMITER_NOT_SHARED`, and different `maxAttempts`/`windowMs`/
|
|
101
|
-
`lockoutMs` is `X_AUTH_LIMITER_POLICY_MISMATCH` — both at boot, never at the first spray.
|
|
102
|
-
`maxKeys` is **not** compared: it bounds one process' table, so a shared limiter has no opinion
|
|
103
|
-
on it. The point is that `Auth.rateLimit` is what an operator reads as "what this deployment
|
|
104
|
-
enforces", so an injected limiter may not quietly enforce something else. Nothing here reads the
|
|
36
|
+
- **A stored hash Bun cannot read is the generic failure, and it burns the same KDF.**
|
|
37
|
+
`verifyAgainst` (`password.ts`) answers `null` for `Bun.password.verify`'s throw (unsupported
|
|
38
|
+
algorithm, malformed PHC) and `''`, joining the no-user branch; nothing is logged. An
|
|
39
|
+
`X_OVERLOADED` from the gate is RE-THROWN (a shed is load, not a verdict). Supported-but-old
|
|
40
|
+
(bcrypt) stays a verdict and `needsRehash` flags it. `password.test.ts` pins both halves.
|
|
41
|
+
- **The limiter's table is bounded, and the eviction order is the guarantee.** Every bucket carries
|
|
42
|
+
`forgetAtMs`; `policy.maxKeys` (`DEFAULT_MAX_AUTH_LIMIT_KEYS`) is the backstop, and a **live
|
|
43
|
+
lockout outranks its own deadline** in the comparator. Never reduce that sort to recency.
|
|
44
|
+
- **`AuthLimiter` is async on every member and declares the policy it enforces.**
|
|
45
|
+
`assertAuthLimiterPolicy` (once, in `defineAuth`) refuses a per-process limiter under
|
|
46
|
+
`scope: 'shared'` (`X_AUTH_LIMITER_NOT_SHARED`) and different `maxAttempts`/`windowMs`/
|
|
47
|
+
`lockoutMs` (`X_AUTH_LIMITER_POLICY_MISMATCH`). `maxKeys` is not compared. Nothing here reads the
|
|
105
48
|
environment to guess a replica count.
|
|
106
|
-
- **`postgresAuthLimiter` is the shared limiter,
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
clock, and the one that lags must not be able to bring a live lockout forward. `PgExecutor` is
|
|
125
|
-
declared structurally
|
|
126
|
-
even though this package already depends on `@ultimat3/db`: the connection is the HOST's, so the
|
|
127
|
-
limiter takes the pool the boot opened rather than opening a second one.
|
|
128
|
-
- **`configureAuthLimiters` is the HOST's install point and it takes a FACTORY, not a limiter**
|
|
129
|
-
(`As of 2026-08`). `defineAuth({ limiter })` is still the app's, and it still wins; what was
|
|
130
|
-
missing is that `postgresAuthLimiter` shipped with **nowhere a host could install it from**.
|
|
131
|
-
`defineAuth` is the APP's call and the app does not know which pool this process opened —
|
|
132
|
-
`@ultimat3/cli`'s `startServices` resolves that long before `loadApp` imports a single app
|
|
133
|
-
module — so a scaffolded app got a per-POD lockout while `x new` scaffolds `replicas: 2` and
|
|
134
|
-
`docker/helm` runs three: `maxAttempts × N` guesses per account, and a lockout one replica
|
|
135
|
-
established invisible to the rest.
|
|
136
|
-
|
|
137
|
-
A **factory** because the boot cannot know the app's numbers. `assertAuthLimiterPolicy` compares
|
|
138
|
-
what a limiter enforces against what the app declared, so a limiter built at boot on
|
|
139
|
-
`DEFAULT_AUTH_RATE_LIMIT` is `X_AUTH_LIMITER_POLICY_MISMATCH` for every app that tuned one. The
|
|
140
|
-
factory is called with the RESOLVED policy, once per bucket, so the two halves cannot disagree —
|
|
141
|
-
and the comparison still runs on what comes back, so a factory that ignores its argument is
|
|
142
|
-
refused exactly as an injected limiter is. Precedence: `config.limiter` → the installed factory →
|
|
143
|
-
`createAuthLimiter`. `installedAuthLimiter` is deliberately NOT in `src/index.ts`, for the reason
|
|
144
|
-
`registerJob` is not in `@ultimat3/jobs`': a second caller building limiters out of band is a
|
|
145
|
-
second answer to where failures are counted.
|
|
146
|
-
|
|
147
|
-
`purgeAuthLimits()` is the other half, and it exists because `PostgresAuthLimiter.purgeExpired()`
|
|
148
|
-
had **no caller anywhere** — every failure row and every dead lockout was kept forever. It sweeps
|
|
149
|
-
only the **widest** window among the limiters the factory built: they all write the same two
|
|
150
|
-
tables, so a sweep measured on a narrower window deletes failures a wider limiter is still
|
|
151
|
-
counting, which is a sprayer buying attempts back from the cleanup job. No `nowMs` argument —
|
|
152
|
-
a limiter built through the seam holds the clock its host handed it, and that is the clock every
|
|
153
|
-
`at_ms` in those tables was written from. `AuthLimiter.purgeExpired` is OPTIONAL so
|
|
154
|
-
`createAuthLimiter` can keep bounding itself; a limiter with no table declares nothing.
|
|
155
|
-
**What the seam RETAINS is one limiter per window, `As of 2026-08-23`** — a `Map` keyed by
|
|
156
|
-
`windowMs`, because the widest window is the only thing a purge reads. It was a list appended to
|
|
157
|
-
on every `installedAuthLimiter` call, two per `defineAuth`, trimmed by nothing: a process that
|
|
158
|
-
redefines auth (`x dev`'s reload, a test file, a host building one `Auth` per app) held every
|
|
159
|
-
limiter it ever built and the store behind each. `installedLimiterCount()` is the only
|
|
160
|
-
observation of that growth — a purge sweeps exactly one limiter however many are held — and it
|
|
161
|
-
is not exported from `src/index.ts`.
|
|
162
|
-
|
|
163
|
-
- **`normaliseEmail` is the ONE normalisation, it lives ABOVE the `AuthAdapter` seam, and no
|
|
164
|
-
adapter may fold case** (`As of 2026-08`). `MemoryAdapter` lowercased and trimmed on both
|
|
165
|
-
`findUserByEmail` and `createUser`; `BuiltinAdapter` issues `where email = $1` against a plain
|
|
166
|
-
case-sensitive `text ... unique`. Two adapters, two answers to "does this account exist" — and
|
|
167
|
-
`oauth-login.ts`'s `resolveUser` normalised nothing at all, carrying the provider's display
|
|
168
|
-
casing straight through. So a provider sending `Ada@Example.com` linked the existing account
|
|
169
|
-
under `x dev` and minted a SECOND one in production, at an address `login()` (which lowercases)
|
|
170
|
-
could then never reach; a later `register()` at the lowercase spelling made a third. Every door
|
|
171
|
-
now normalises before the adapter sees the address — `register`, `login`, `profileEmail` in
|
|
172
|
-
`oauth-login.ts`, and `accountKey`, which must key the same way or one address buys a fresh
|
|
173
|
-
lockout budget per spelling. `adapter-parity.test.ts` pins both adapters in one test, the shape
|
|
174
|
-
`jobs/driver-parity.test.ts` established; `MemoryAdapter` normalising again is a failing test.
|
|
175
|
-
Trim and lowercase only: stripping a `+tag` or a gmail dot MERGES two addresses a person kept
|
|
176
|
-
apart, which is takeover between colleagues at one domain.
|
|
177
|
-
- **`json.ts` owns `isRecord` and `decodeJwtSegment`, and this package holds no second copy.**
|
|
178
|
-
`isRecord` was declared six times here and the base64url-JSON-payload decode three
|
|
179
|
-
(`jwks.decodeJwtHeader`, `id-token.decodeSegment`, `workload.verifyWorkloadToken`). All six
|
|
180
|
-
agreed that an array is not a record, which is the fact that matters: on a decoded JWT payload
|
|
181
|
-
the check is what gates every claim read after it, and `JSON.parse('[]')` narrows to
|
|
182
|
-
`Record<string, unknown>` without it. One declaration means one place for that to be true.
|
|
183
|
-
`decodeJwtSegment` answers `null` for all three failures — not base64url, not JSON, not an
|
|
184
|
-
object — because each caller has its own coded refusal to raise.
|
|
49
|
+
- **`postgresAuthLimiter` is the shared limiter, a row per FAILURE** (sliding window: the count is
|
|
50
|
+
`at_ms > now - windowMs`). The insert and the count are two statements, never one CTE, and the
|
|
51
|
+
insert takes `pg_advisory_xact_lock` on the key so two outer transactions cannot both read one
|
|
52
|
+
short (guarantee is READ COMMITTED). `auth.ts` records account → ip → org in that fixed order.
|
|
53
|
+
`greatest` on the lockout upsert only extends. `PgExecutor` is structural: the pool is the host's.
|
|
54
|
+
- **`configureAuthLimiters` is the HOST's install point and takes a FACTORY**, called with the
|
|
55
|
+
RESOLVED policy once per bucket; the comparison still runs on what comes back. Precedence:
|
|
56
|
+
`config.limiter` → the installed factory → `createAuthLimiter`. `installedAuthLimiter` and
|
|
57
|
+
`installedLimiterCount()` are NOT in `src/index.ts`. `purgeAuthLimits()` sweeps only the
|
|
58
|
+
**widest** window among the built limiters (retained one per `windowMs` in a `Map`), on the
|
|
59
|
+
limiter's own clock. `AuthLimiter.purgeExpired` is optional.
|
|
60
|
+
- **`normaliseEmail` is the ONE normalisation, it lives ABOVE the `AuthAdapter` seam, and no adapter
|
|
61
|
+
may fold case.** Every door now normalises before the adapter sees the address — `register`,
|
|
62
|
+
`login`, `profileEmail` in `oauth-login.ts`, `accountKey`, and `issueVerification`/
|
|
63
|
+
`consumeVerification`. Trim and lowercase only; never strip a `+tag` or a gmail dot.
|
|
64
|
+
`adapter-parity.test.ts` pins both adapters in one test.
|
|
65
|
+
- **`json.ts` owns `isRecord` and `decodeJwtSegment`; no second copy.** An array is not a record.
|
|
66
|
+
`decodeJwtSegment` answers `null` for all three failures; each caller raises its own refusal.
|
|
185
67
|
- Absolute and idle expiry are two separate computations in `sessionExpiry()`. Do not fold them.
|
|
186
|
-
- PKCE is not provider-dependent
|
|
187
|
-
|
|
188
|
-
`
|
|
189
|
-
|
|
190
|
-
-
|
|
191
|
-
|
|
192
|
-
only ways out were forking the package or bypassing OAuth entirely and losing PKCE, the sealed
|
|
193
|
-
handshake, issuer pinning and account linking with it. The three built-ins seed the registry
|
|
194
|
-
through the same `registerOAuthProvider()` an app calls, so there is still one way to do it.
|
|
195
|
-
`providerFor(id)` throws `X_OAUTH_PROVIDER_UNKNOWN` and never answers `undefined`; a second claim
|
|
196
|
-
on one id is `X_OAUTH_PROVIDER_DUPLICATE` at boot, never a silent replacement.
|
|
197
|
-
- **`oauthProviderUnknown(provider, supported)` scopes its list to its reader.** The route passes
|
|
198
|
-
`BUILTIN_OAUTH_PROVIDER_IDS` — an anonymous stranger typed that URL, and the registry now holds
|
|
199
|
-
whatever internal OP this deployment registered. `providerFor()` passes `oauthProviderIds()` —
|
|
200
|
-
its reader is a developer with a stack trace, and the full list is what makes the fix runnable.
|
|
201
|
-
Neither ever passes `defineAuth({ providers })`. One code, two audiences, one sentence that stays
|
|
202
|
-
executable either way because it names `registerOAuthProvider` before it names the list.
|
|
68
|
+
- PKCE is not provider-dependent: `OAuthProvider.usesPkce` is the literal `true`.
|
|
69
|
+
- **Providers are a registry, `OAuthProviderId` is `string`.** The three built-ins seed it through
|
|
70
|
+
`registerOAuthProvider()`, the call an app makes. `providerFor(id)` throws
|
|
71
|
+
`X_OAUTH_PROVIDER_UNKNOWN`, never `undefined`; a second claim is `X_OAUTH_PROVIDER_DUPLICATE`.
|
|
72
|
+
- **`oauthProviderUnknown(provider, supported)` scopes its list to its reader**: the route passes
|
|
73
|
+
`BUILTIN_OAUTH_PROVIDER_IDS` (an anonymous caller), `providerFor()` passes `oauthProviderIds()`.
|
|
203
74
|
- **`verifyIdToken({ keys })` is required, with no default.** `'token-endpoint-tls'` is the OIDC
|
|
204
|
-
Core 3.1.3.7 exemption
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
nothing. Creating a user with no roles and no org logs a warning — that account can do nothing.
|
|
213
|
-
- **`verifySession` writes at most once per `idleSlideMs`** (default `idleTtlMs / 20`). Throttling
|
|
214
|
-
the SESSION write is safe; caching the USER row is not — `authenticate` re-reads it on every
|
|
215
|
-
request, and that is what makes a revoked role take effect on the next one with no token-expiry
|
|
216
|
-
lag. Do not cache it.
|
|
217
|
-
|
|
218
|
-
**In `verifySession`'s `observed`, `null` is an ANSWER and `undefined` is silence**
|
|
219
|
-
(`As of 2026-09-06`). `observed?.ip ?? session.ip` collapsed the two, so an explicit
|
|
220
|
-
`{ ip: null }` — this request had no client address — could not clear a stale stored one: the
|
|
221
|
-
address from an earlier request was written forward on every verify, and the device list a user
|
|
222
|
-
checks for a session they do not recognise showed it as current. An observation that names
|
|
223
|
-
neither field still costs no write, because a change nobody reported would turn every
|
|
224
|
-
authenticated request back into a write on the hottest table there is.
|
|
225
|
-
- A user's `scopes` column reaches `Actor.scopes`. Hardcoding `[]` there made a scope something no
|
|
226
|
-
human could hold, so `hasScope(actor, 'tenancy:cross')` was satisfiable only by minting a
|
|
227
|
-
`serviceActor` inside the handler — which discards the operator's identity and makes the sweep
|
|
228
|
-
unattributable, the exact property the required reason string exists to preserve.
|
|
75
|
+
Core 3.1.3.7 exemption and `exchangeOAuthCode` is its only entitled caller. `HS256` and
|
|
76
|
+
`alg: none` are refused in `decodeJwtHeader`. It checks `nbf` and `azp` too.
|
|
77
|
+
- **`resolveGrants` is a seam, never a group-to-role table**, called on EVERY login. Absent leaves
|
|
78
|
+
the row alone. A user created with no roles and no org logs a warning.
|
|
79
|
+
- **`verifySession` writes at most once per `idleSlideMs`** (default `idleTtlMs / 20`). Never cache
|
|
80
|
+
the USER row — `authenticate` re-reads it every request so a revoked role bites on the next one.
|
|
81
|
+
In `observed`, `null` is an ANSWER (clears the stored value) and `undefined` is silence.
|
|
82
|
+
- A user's `scopes` column reaches `Actor.scopes`.
|
|
229
83
|
- Every revocation takes a `reason` and logs `auth.revocation` before it runs.
|
|
230
|
-
`deleteSessionsForOrg` joins through `x_users`; `x_sessions` does **not** gain an `org_id
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
- The
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
`
|
|
241
|
-
|
|
242
|
-
in
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
-
|
|
250
|
-
`
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
`
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
`
|
|
282
|
-
|
|
283
|
-
**A screened value is half the contract; the LINE has to run** (`As of 2026-09-06`). A
|
|
284
|
-
placeholder is honest text and is not a command — `curl -sS -m 5 <the provider jwks_uri>` is read
|
|
285
|
-
by a shell as a redirection from a file called `the`, so the one instruction the reader was
|
|
286
|
-
handed fails, which is axiom 4 inverted. `jwks.ts`'s three lines share one `readTheKeySet(tail)`:
|
|
287
|
-
`isFixShellSafe` (core, the predicate `renderFixShellArg` is itself built on) decides, and the
|
|
288
|
-
whole fix degrades to PROSE naming the discovery document when the URI does not travel.
|
|
289
|
-
`oauth-discovery.ts` still owes the same repair and is what keeps `auth` on
|
|
290
|
-
`FIX_SHELL_ARG_PINS`.
|
|
291
|
-
- `readCookie` never throws on a malformed value. The `Cookie:` header is attacker-controlled and
|
|
292
|
-
`decodeURIComponent('%')` is a bare `URIError`, which would escape every coded path in this
|
|
293
|
-
package — the raw value goes to the signature or hash check, which is the readable refusal.
|
|
294
|
-
- A token endpoint's HTTP 200 is not success — GitHub reports a dead code that way. Read `error`.
|
|
295
|
-
- Link by address only when the provider **and** the local account both verified it. That is
|
|
296
|
-
`link: 'verified-email'`, the default; `'never'` is the only other value and there is
|
|
297
|
-
deliberately no "link on any provider address" — it is account takeover at a sloppy
|
|
298
|
-
provider, so it is unrepresentable rather than discouraged. An app that wants it wraps
|
|
299
|
-
`signInWithOAuth`.
|
|
300
|
-
- **The OAuth route paths are not configurable.** `oauth-paths.ts` imports nothing and is
|
|
301
|
-
read by both `oauth-errors.ts` and `oauth-route.ts`, so a `fix:` line naming
|
|
302
|
-
`GET /auth/oauth/<provider>` cannot outlive the route again — which is exactly what it did
|
|
303
|
-
through 1.2.0, when the library functions shipped with no route to mount them in. Every
|
|
304
|
-
"start over" fix is built from `restartAt(provider)`.
|
|
305
|
-
- The routes are **descriptors** (`mcpHttpRoute()`'s category), never mounted handlers:
|
|
306
|
-
`@ultimat3/http` is tier 2 like this package and `defineRoute` is tier 4 and renders a
|
|
307
|
-
page, so neither is importable here. A bare `Request` in, a `Response` out.
|
|
308
|
-
- The callback answers failure as **coded JSON**, and the success hop redirects to a fixed
|
|
309
|
-
`successPath` — never `?next=`. `nextAfterSignIn` in `@ultimat3/http` is the one
|
|
310
|
-
implementation of the open-redirect check and a second copy is one that drifts.
|
|
311
|
-
- The handshake cookie is cleared on **every** callback outcome, success and failure alike:
|
|
312
|
-
the code it authorised is spent either way.
|
|
313
|
-
- Refresh is **not implemented**, and the framework therefore **stores no provider token** (`As of
|
|
314
|
-
2026-08-23`). `accountFor` (`oauth-login.ts`) writes `accessToken: null, refreshToken: null`:
|
|
315
|
-
nothing in this package ever read either column back — `oauth-profile.ts` uses the token from the
|
|
316
|
-
exchange, in flight — while `tables.ts`'s header promised "no column holds a plaintext secret".
|
|
317
|
-
Declared and never wired, the deletion this repo already ran for `jobs.driver`, except this one
|
|
318
|
-
turned a database dump into a set of usable third-party credentials. The COLUMNS and the
|
|
319
|
-
`AuthAccount` fields both stay: the type is the documented adapter seam, and an app that
|
|
320
|
-
deliberately stores tokens implements `linkAccount` itself. `expiresAt` is still written.
|
|
321
|
-
- **`defineAuth({ providers })` defaults to `[]`**, never the live registry (`As of 2026-08-23`,
|
|
322
|
-
BREAKING). It was `oauthProviderIds()`, so nothing was ever "left out" and the uniform-404 the
|
|
323
|
-
option exists for could not fire — while a registry any dependency writes into decided which
|
|
324
|
-
login endpoints the app served. Name what you enabled.
|
|
325
|
-
- **A provider with no credentials answers the SAME 404 an unknown one does.** Past `assertEnabled`,
|
|
326
|
-
`oauthCredentials` threw `X_ENV_MISSING` and the route published a 500 carrying the app's own env
|
|
327
|
-
var names, so `500 = registered here, 404 = not` re-opened the oracle one request later.
|
|
328
|
-
`credentialsFor` (`oauth-route.ts`) catches it on BOTH legs, logs the real cause under
|
|
329
|
-
`auth.oauth.credentials_missing`, and re-throws `oauthProviderUnknown`.
|
|
330
|
-
- **Nothing this package does not own reaches a published `cause:`.** Both legs of the OAuth flow
|
|
331
|
-
are anonymous by definition and `publicBody` serialises `cause`. Two paths carried an internal
|
|
332
|
-
message into it and both now log instead: an uncoded throw from an adapter (`uncoded()` in
|
|
333
|
-
`oauth-route.ts` → `auth.oauth.uncoded_failure`) and a rejecting `OAuthFetch`
|
|
334
|
-
(`postForm` → `auth.oauth.token_fetch_failed`). The `POST /token` leg also reads its response
|
|
335
|
-
body as `providerDetail(response, 'coded-only')` — that request carries `client_secret`, and an
|
|
336
|
-
echoing endpoint put 38 of 42 characters of it into a 502 body. `error`/`error_description` still
|
|
337
|
-
come through; userinfo, discovery and jwks keep the raw fallback, because their requests carry
|
|
338
|
-
no secret.
|
|
339
|
-
- **`x_users.mfa_secret` is a PLAINTEXT secret and `tables.ts` now says so, column by column.** A
|
|
340
|
-
TOTP seed is symmetric, so a digest cannot verify a code; encrypting it needs a key-management
|
|
341
|
-
seam this package does not have. **Deferred deliberately** — `mfa.ts`'s "a database dump is not a
|
|
342
|
-
permanent MFA bypass" is true of the recovery CODES and not of the seed.
|
|
343
|
-
- **`issueVerification`/`consumeVerification` normalise too** — the fourth identity door.
|
|
344
|
-
`putVerification` upserts on `(purpose, identifier)` and `adapter.ts` promises a new token
|
|
345
|
-
invalidates the previous one; that promise was per SPELLING, so N live reset tokens for one
|
|
346
|
-
address could be held at once by varying the case, and each is a password.
|
|
347
|
-
- **`providerJwks` memoises only the DEFAULT client.** The memo was keyed on the provider id alone
|
|
348
|
-
and silently discarded a later caller's options, so an app pinning a corporate egress proxy got
|
|
349
|
-
it only if it called first — the `jobs.driver` shape, with a network path as the substituted
|
|
350
|
-
value. A caller that supplies options gets its own client.
|
|
351
|
-
- **A wedged JWKS refresh no longer holds the shared slot for ever** (`As of 2026-08-23`).
|
|
352
|
-
`createJwksClient` single-flights its refresh through `@ultimat3/core`'s `createSingleFlight`,
|
|
353
|
-
with `deadlineMs = timeoutMs * 2` — derived, never a second invented number: `timeoutMs` bounds
|
|
354
|
-
the network leg (`AbortSignal.timeout`) and everything after it is local work on the same budget,
|
|
355
|
-
so doubling never evicts a slow-but-healthy refresh. It matters because `AbortSignal.timeout`
|
|
356
|
-
bounds only the DEFAULT transport and `options.fetch` is the app's — a `fetch` that ignores its
|
|
357
|
-
signal used to pin the slot for the life of the process, and every later `keyFor` joined a
|
|
358
|
-
promise nothing would resolve. **Eviction frees the KEY, never the work**: the wedged refresh
|
|
359
|
-
runs on and its own callers keep their promise, so the worst case is one duplicate JWKS fetch and
|
|
360
|
-
never a failed verification. `schedule` is injectable so no test waits a deadline out.
|
|
361
|
-
Its consequence is the second half and is not optional: two refreshes can now overlap and both
|
|
362
|
-
end by installing what they read, so a `createFence` generation check is what stops the one the
|
|
363
|
-
client gave up on from dropping a pre-rotation key set on top of the live one. Read
|
|
364
|
-
(`fence.generation() === issued`), never `guard` — a superseded refresh is still the honest
|
|
365
|
-
answer for the callers holding it, and only the shared cache is fenced.
|
|
366
|
-
- **`verifyIdToken` checks `nbf` and `azp`.** `workload.ts` imports `ID_TOKEN_CLOCK_SKEW_MS` from
|
|
367
|
-
`id-token.ts` and then enforced a bound `id-token.ts` did not: an `nbf` ten years out verified.
|
|
368
|
-
`azp` is OIDC Core 3.1.3.7 step 5 — with more than one audience, `aud` naming this client says
|
|
369
|
-
only that the token MENTIONS it. Both only narrow what is accepted.
|
|
370
|
-
- **A success clears the ACCOUNT bucket and nothing else.** `recordSuccess(ipKey(ip))` used to run
|
|
371
|
-
on every login and deleted the whole address bucket, which made it inert against the attack it
|
|
372
|
-
exists for: a stuffing run never spends `maxAttempts` guesses on one account, so
|
|
373
|
-
`4 wrong + 1 login to an account the attacker owns, repeat` never locked. Measured: 5 guesses to
|
|
374
|
-
`X_ACCOUNT_LOCKED` without the reset, 160 and unlocked with it. The cost is a shared NAT
|
|
375
|
-
accumulating failures, which is what `windowMs` bounds and what `X_ACCOUNT_LOCKED`'s `fix:`
|
|
376
|
-
already names the manual escape for.
|
|
377
|
-
- **`MemoryAdapter.createUser` enforces the two UNIQUE constraints `BuiltinAdapter` leans on** —
|
|
378
|
-
`x_users.email` and `x_users.external_id`, `authUniqueViolation` (`X_AUTH_WRITE_FAILED`). It is
|
|
379
|
-
what `x new` scaffolds and what every test runs against, so the duplicate path was only ever
|
|
380
|
-
exercised against the permissive half of the seam: two `register()` calls at one address made two
|
|
381
|
-
rows, and the second was unreachable forever. `adapter-parity.test.ts` pins both halves.
|
|
382
|
-
**A NULL is not a value to either index, `As of 2026-08-23`**: a Postgres unique index is NULLS
|
|
383
|
-
DISTINCT, so `external_id` constrains only the rows that carry one — the check was
|
|
384
|
-
`!== undefined`, and `oauth-login.ts` binds `grants.externalId ?? null` for every first-time
|
|
385
|
-
OAuth user, so the SECOND such signup on a `MemoryAdapter` app failed with `X_AUTH_WRITE_FAILED`
|
|
386
|
-
against a constraint production does not have.
|
|
387
|
-
- **`MemoryAdapter` takes a `Clock`, and every instant it stamps comes from it** —
|
|
388
|
-
`new MemoryAdapter(clock)`, defaulting to `systemClock`, so the no-argument construction every
|
|
389
|
-
test already writes is unchanged. `takeVerification` stamped `consumedAt` with the record's
|
|
390
|
-
own `createdAt` — the moment the link was ISSUED — where `BuiltinAdapter` writes
|
|
391
|
-
`consumed_at = now()`, the moment it was REDEEMED: a redemption an hour later and one a second
|
|
392
|
-
later recorded the identical instant, and a frozen test clock could not move either.
|
|
393
|
-
- The new `AuthAdapter` members are OPTIONAL (`findUserByExternalId`, `listUsersByOrg`,
|
|
394
|
-
`deleteSessionsForUser`, `deleteSessionsForOrg`, `deleteSessionsCreatedBefore`). A required
|
|
395
|
-
member is a breaking change to every third-party adapter; the callers throw
|
|
396
|
-
`X_NOT_IMPLEMENTED` naming the method instead.
|
|
84
|
+
`deleteSessionsForOrg` joins through `x_users`; `x_sessions` does **not** gain an `org_id`.
|
|
85
|
+
- The code flow carries `nonce` inside the id token. `assertOAuthCallback` checks an echoed one when
|
|
86
|
+
present and never requires it; `verifyIdToken` is the real gate.
|
|
87
|
+
- The handshake is sealed (`sealHandshake`); `openHandshake` takes the provider as an argument.
|
|
88
|
+
Expiry is the server's clock, not `Max-Age`. One handshake cookie **per provider**
|
|
89
|
+
(`handshakeCookieName`), and `clearHandshakeCookie(provider)`.
|
|
90
|
+
- `takeVerification(purpose, identifier, tokenHash)` consumes **only on a hash match**, in one
|
|
91
|
+
conditional statement (`consumed_at is null` on the UPDATE and its subselect,
|
|
92
|
+
`order by created_at desc limit 1`); `consumeVerification` still compares in constant time.
|
|
93
|
+
- **Foreign text in a `cause:` goes through `renderCauseValue`, in a `fix:` through
|
|
94
|
+
`renderFixLiteral`, and in a shell `fix:` through `renderFixShellArg`.** Foreign here:
|
|
95
|
+
`providerDetail()`'s return, `claims.iss`, `accountLocked`'s `key`, and every OAuth endpoint URL
|
|
96
|
+
in a command position (`jwks.ts`, `oauth-profile.ts`, `oauth-exchange.ts`,
|
|
97
|
+
`oauth-discovery.ts`) — the registry is filled by an issuer's own discovery document. A LINE
|
|
98
|
+
that would not run degrades to prose: `jwks.ts`'s `readTheKeySet(tail)` decides with core's
|
|
99
|
+
`isFixShellSafe`. `oauth-discovery.ts` still owes that repair (it keeps `auth` on
|
|
100
|
+
`FIX_SHELL_ARG_PINS`). `${provider}` is registry-validated boot config, not foreign.
|
|
101
|
+
- `readCookie` never throws on a malformed value (`decodeURIComponent('%')`).
|
|
102
|
+
- A token endpoint's HTTP 200 is not success — read `error`.
|
|
103
|
+
- Link by address only when the provider **and** the local account both verified it
|
|
104
|
+
(`link: 'verified-email'`, default; `'never'` is the only other value).
|
|
105
|
+
- **The OAuth route paths are not configurable.** `oauth-paths.ts` imports nothing and is read by
|
|
106
|
+
both `oauth-errors.ts` and `oauth-route.ts`; every "start over" fix is `restartAt(provider)`.
|
|
107
|
+
- The routes are **descriptors** (`AuthRouteDescriptor`), never mounted handlers.
|
|
108
|
+
- The callback answers failure as **coded JSON**, and success redirects to a fixed `successPath` —
|
|
109
|
+
never `?next=` (`nextAfterSignIn` in `@ultimat3/http` is the one open-redirect check).
|
|
110
|
+
- The handshake cookie is cleared on **every** callback outcome.
|
|
111
|
+
- Refresh is **not implemented**, so the framework **stores no provider token**: `accountFor`
|
|
112
|
+
(`oauth-login.ts`) writes `accessToken: null, refreshToken: null`. The columns and `AuthAccount`
|
|
113
|
+
fields stay; an app that stores tokens implements `linkAccount` itself.
|
|
114
|
+
- **`defineAuth({ providers })` defaults to `[]`** — name what you enabled.
|
|
115
|
+
- **A provider with no credentials answers the SAME 404 an unknown one does**: `credentialsFor`
|
|
116
|
+
(`oauth-route.ts`) logs `auth.oauth.credentials_missing` and re-throws `oauthProviderUnknown`.
|
|
117
|
+
- **Nothing this package does not own reaches a published `cause:`** on the anonymous OAuth legs:
|
|
118
|
+
an uncoded adapter throw logs `auth.oauth.uncoded_failure`, a rejecting `OAuthFetch` logs
|
|
119
|
+
`auth.oauth.token_fetch_failed`, and the `POST /token` body is read as
|
|
120
|
+
`providerDetail(response, 'coded-only')` (that request carries `client_secret`).
|
|
121
|
+
- **`x_users.mfa_secret` is a PLAINTEXT secret** and `tables.ts` says so; encrypting it needs a
|
|
122
|
+
key-management seam this package does not have. Deferred deliberately.
|
|
123
|
+
- **`providerJwks` memoises only the DEFAULT client**; a caller supplying options gets its own.
|
|
124
|
+
- **A JWKS refresh is single-flighted through core's `createSingleFlight` with
|
|
125
|
+
`deadlineMs = timeoutMs * 2`**; eviction frees the key, never the work, and a `createFence`
|
|
126
|
+
generation check (read, never `guard`) stops a superseded refresh overwriting the cache.
|
|
127
|
+
`schedule` is injectable.
|
|
128
|
+
- **A success clears the ACCOUNT bucket and nothing else** — clearing the address bucket made the
|
|
129
|
+
limiter inert against stuffing.
|
|
130
|
+
- **`MemoryAdapter.createUser` enforces `x_users.email` and `x_users.external_id` uniqueness**
|
|
131
|
+
(`authUniqueViolation`, `X_AUTH_WRITE_FAILED`), NULLS DISTINCT like Postgres.
|
|
132
|
+
**`MemoryAdapter` takes a `Clock`** (default `systemClock`) and stamps every instant from it.
|
|
133
|
+
`adapter-parity.test.ts` pins both.
|
|
134
|
+
- The newer `AuthAdapter` members are OPTIONAL (`findUserByExternalId`, `listUsersByOrg`,
|
|
135
|
+
`deleteSessionsForUser`, `deleteSessionsForOrg`, `deleteSessionsCreatedBefore`); callers throw
|
|
136
|
+
`X_NOT_IMPLEMENTED` naming the method.
|
|
397
137
|
- An api key's scopes are the agent actor's scopes. Never union them with the owner's roles.
|
|
398
|
-
- Rotate the session id on any privilege change (`rotateSession
|
|
399
|
-
`
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
`
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
The framework's own second leg needs three things landing together, and fewer is worse than none:
|
|
423
|
-
a **sealed pending-MFA credential** built like `sealHandshake` (`oauth-cookie.ts`) — server-clock
|
|
424
|
-
expiry, one cookie, bound to the user id the first factor proved and to nothing the client says;
|
|
425
|
-
the completion shipped as an `AuthRouteDescriptor` the way `oauthLogin()` was (`oauth-route.ts`),
|
|
426
|
-
with its path declared in `oauth-paths.ts`'s style so the `fix:` and the mount cannot drift; and
|
|
427
|
-
`auth.limiter` around `verifyTotp` — today it is wired only into `login`, so a six-digit code
|
|
428
|
-
would be the one credential in this package with no lockout. `TotpReplayGuard` is already built
|
|
429
|
-
and must be the completion's, not a second one.
|
|
430
|
-
- **`mfa.required` is the literal `false`, and `defineAuth` refuses a `true` that reaches it
|
|
431
|
-
anyway** (`X_CONFIG_INVALID`, borrowed from core), `As of 2026-08`. It resolved onto the frozen
|
|
432
|
-
`Auth` and **nothing read it** — `login()` and `signInWithOAuth()` branch on `user.mfaSecret`
|
|
433
|
-
alone and mint `mfaSatisfied: true` otherwise, so `required: true` handed a user who had never
|
|
434
|
-
enrolled a fully-privileged session while reading as "this deployment requires a second factor".
|
|
435
|
-
Enforcing it was the tempting fix and is a **lockout**: `actorFromUser` degrades a session only
|
|
436
|
-
when `mfaSecret !== null`, so an un-enrolled user has no half-authenticated actor to enrol
|
|
437
|
-
through, this package ships no enrolment route to send them to, and `mfaRequired()`'s own `fix:`
|
|
438
|
-
(`verifyTotp({ secret: user.mfaSecret, … })`) cannot be followed with a null secret — a dead
|
|
439
|
-
`fix:`, the defect `oauth-paths.ts` exists to stop. So the unenforceable declaration is refused
|
|
440
|
-
where it is written, exactly as `assertAuthLimiterPolicy` refuses a per-process limiter under a
|
|
441
|
-
fleet-wide lockout. The literal type is the build error and the runtime check is for the JS
|
|
442
|
-
caller and the JSON config the type cannot reach, the split `invariantColumns()`'s Proxy keeps.
|
|
443
|
-
`mfa.issuer` is the half that stayed, and it now has a reader: `enrolTotp(auth, { account })`
|
|
444
|
-
takes the `Auth` every other entry point takes, so the product name an authenticator app shows
|
|
445
|
-
is declared once at `defineAuth` instead of restated at every enrolment.
|
|
446
|
-
- **A TOTP secret that decodes to zero bytes is not a weak key, it is NO key** (`As of 2026-08`).
|
|
447
|
-
`base32Decode` answers `new Uint8Array(0)` for any character outside the alphabet and for `''`,
|
|
448
|
-
and `new Bun.CryptoHasher('sha1', new Uint8Array(0))` is a perfectly valid HMAC — so `totpCode`
|
|
449
|
-
returned a six-digit code derived from nothing, every malformed secret in the table shared that
|
|
450
|
-
one stream, and `verifyTotp` accepted a code an attacker computes without knowing any secret.
|
|
451
|
-
Reachable: `enrolTotp(auth, { account, secret })` takes an imported secret, `builtin-adapter.ts`
|
|
452
|
-
maps the column straight through, and a `mfa_secret text not null default ''` column is not
|
|
453
|
-
`null`, so `login()` still demanded a second factor and then accepted the empty-key code. The
|
|
454
|
-
file's own header comment asserted the opposite ("fails the decode closed") for as long as it was
|
|
455
|
-
wrong. Three answers now, one per caller: `verifyTotp` returns `{ ok: false, step: null }` — the
|
|
456
|
-
**generic failure**, the same rule `verifyAgainst` follows for a stored hash Bun cannot read, so
|
|
457
|
-
a broken row is neither a 500 nor an oracle; `totpCode` throws `X_MFA_SECRET_INVALID`, because
|
|
458
|
-
there is no code an unreadable secret is entitled to; `enrolTotp` throws it too, so the value
|
|
459
|
-
never reaches the table. The secret never reaches `cause:` or `fix:` — it is a credential and
|
|
460
|
-
both are logged. No minimum LENGTH is enforced beyond one byte: 10-byte secrets are what several
|
|
461
|
-
authenticator apps issue, so a 16-byte floor would refuse real enrolments to close nothing the
|
|
462
|
-
zero-byte rule leaves open.
|
|
463
|
-
- **`createTotpReplayGuard`'s table is bounded, and the eviction ORDER is the guarantee.** It
|
|
464
|
-
pruned steps inside one subject's `Set` and never revisited a subject who stopped signing in, so
|
|
465
|
-
the map carried one permanent entry per user for the life of the process. Evicting a subject
|
|
466
|
-
makes a step they have already spent replayable again, so the order cannot be recency of
|
|
467
|
-
insertion: a subject whose every step has fallen below the drift floor is **forgotten** (nothing
|
|
468
|
-
outside ±drift is ever offered to `verifyTotp`, so that entry answers exactly as a missing one),
|
|
469
|
-
and only if that is not enough does `DEFAULT_MAX_TOTP_SUBJECTS` evict live state, sorted by
|
|
470
|
-
newest spent step ascending with a least-recently-seen tie-break — the subject who just proved a
|
|
471
|
-
code is always the last one out. Same shape as the limiter's "a live lockout outranks its own
|
|
472
|
-
deadline". `remember` re-files its subject so the map's iteration order IS that tie-break.
|
|
473
|
-
`maxSubjects` is **normalised before any of that arithmetic runs** (`boundedSubjects`): the cap
|
|
474
|
-
ran on the caller's number unchecked, so `Infinity` left the table exactly as unbounded as it was
|
|
475
|
-
before it was capped, and `NaN` — every comparison against which is false — made the eviction
|
|
476
|
-
loop's `used.size <= evictTo` never true, emptying the table on the first sweep and handing back
|
|
477
|
-
a replay of the code the subject had just spent. Anything that is not a positive finite integer
|
|
478
|
-
takes `DEFAULT_MAX_TOTP_SUBJECTS`; a fraction still floors.
|
|
479
|
-
- SAML is out of scope permanently: XML-DSig canonicalisation has no Bun native and would need a
|
|
480
|
-
real dependency. Put an OIDC-speaking bridge in front and register that.
|
|
138
|
+
- Rotate the session id on any privilege change (`rotateSession`, called by `updatePrivileges` in
|
|
139
|
+
`privileges.ts`), never patch the row.
|
|
140
|
+
- **Every argon2 call goes through `kdfGate()`** — width 8, queue 64, `X_OVERLOADED` past it
|
|
141
|
+
(borrowed from http, in `AUTH_BORROWED_ERROR_CODES`). The pool is core's `createFlightGate`, the
|
|
142
|
+
refusal auth's own through core's `overflow:` seam. `configureKdfGate()` is the ONE install point
|
|
143
|
+
and deliberately not a `defineAuth` key.
|
|
144
|
+
- **MFA has a first leg and no second one, and the second is not a route you can just add.**
|
|
145
|
+
`login()` / `completeOAuthLogin()` throw `X_MFA_REQUIRED` before any session exists. A
|
|
146
|
+
`POST /auth/mfa/verify { userId, code }` would be unauthenticated by construction. The follow-up
|
|
147
|
+
needs three things together: a **sealed pending-MFA credential** built like `sealHandshake`, the
|
|
148
|
+
completion as an `AuthRouteDescriptor` with its path in `oauth-paths.ts`'s style, and
|
|
149
|
+
`auth.limiter` around `verifyTotp`. `TotpReplayGuard` is the completion's.
|
|
150
|
+
- **`mfa.required` is the literal `false`, and `defineAuth` refuses a `true`** (`X_CONFIG_INVALID`):
|
|
151
|
+
enforcing it would lock out every un-enrolled user. `mfa.issuer` is read by
|
|
152
|
+
`enrolTotp(auth, { account })`.
|
|
153
|
+
- **A TOTP secret that decodes to zero bytes is NO key**: `verifyTotp` returns the generic failure
|
|
154
|
+
(`{ ok: false, step: null }`), `totpCode` and `enrolTotp` throw `X_MFA_SECRET_INVALID`. The secret
|
|
155
|
+
never reaches `cause:`/`fix:`. No length floor beyond one byte.
|
|
156
|
+
- **`createTotpReplayGuard`'s table is bounded and the eviction ORDER is the guarantee**: subjects
|
|
157
|
+
whose steps are all below the drift floor are forgotten first; past `DEFAULT_MAX_TOTP_SUBJECTS`,
|
|
158
|
+
evict by newest spent step ascending, least-recently-seen tie-break. `maxSubjects` is normalised
|
|
159
|
+
first (`boundedSubjects`): anything not a positive finite integer takes the default.
|
|
160
|
+
- SAML is out of scope permanently (XML-DSig canonicalisation has no Bun native). Put an
|
|
161
|
+
OIDC-speaking bridge in front and register that.
|
|
481
162
|
|
|
482
163
|
## Files
|
|
483
164
|
|
|
@@ -514,6 +195,8 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
514
195
|
| `kdf-gate.ts` | the one bound on concurrent argon2 work, and the `X_OVERLOADED` past it — core's `createFlightGate` with auth's own refusal injected |
|
|
515
196
|
| `email.ts` | `normaliseEmail` — the one normalisation an address gets before it is an identity key |
|
|
516
197
|
| `json.ts` | reading untrusted JSON: `isRecord`, and a base64url JWT segment as an object or `null` |
|
|
198
|
+
| `sign-out.ts` | `signOutHeaders({ session? })` — the expired session cookie plus `Clear-Site-Data: "cache", "storage"` (`SIGN_OUT_CLEAR_SITE_DATA`), never `"cookies"`. A PWA's offline cache does not survive a sign-out, by decision (a cached private page IS the previous principal's data); the page boot's rescope wipe is the second line. Secure contexts only |
|
|
199
|
+
| `client-scope.ts` | `clientScopeOf(actor)` — the OPAQUE per-principal id for `<meta name="ultimate-scope">`: keyed SHA-256 (`SESSION_SECRET`) over kind, id and impersonator, 32 hex; `''` for anonymous. Without `SESSION_SECRET`, a random per-process key and one `auth.client_scope.process_key` warning (rescopes, never leaks) |
|
|
517
200
|
|
|
518
201
|
```bash
|
|
519
202
|
bun test packages/auth
|
|
@@ -533,3 +216,5 @@ Gotchas:
|
|
|
533
216
|
and `rate-limit-postgres.live.test.ts` is `describe.skip` without `TEST_DATABASE_URL` — the
|
|
534
217
|
same pairing `@ultimat3/http`'s rate-limit store and `@ultimat3/realtime`'s Postgres files
|
|
535
218
|
use. A limiter whose statements were never executed is a credential control nobody has run.
|
|
219
|
+
|
|
220
|
+
Why each rule above is shaped the way it is: [`docs/history/auth.md`](../../docs/history/auth.md).
|
package/README.md
CHANGED
|
@@ -460,16 +460,15 @@ still satisfies the interface. Calling one an adapter has not implemented is `X_
|
|
|
460
460
|
with the method named, not a silent no-op.
|
|
461
461
|
|
|
462
462
|
`x_users` gained two columns in 1.3.0 — `scopes text[]` and `external_id text unique` — plus an
|
|
463
|
-
`org_id` index.
|
|
464
|
-
columns are additive with a default, so the migration takes no table rewrite.
|
|
463
|
+
`org_id` index.
|
|
465
464
|
|
|
466
|
-
**
|
|
467
|
-
|
|
468
|
-
`
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
465
|
+
**The framework applies the whole schema at boot, upgrades included — `As of 2026-09-23`
|
|
466
|
+
(22.0.0).** `AUTH_TABLES` (with `AUTH_TABLE_NAMES`) is a row of `@ultimat3/cli`'s
|
|
467
|
+
`FRAMEWORK_SCHEMA`, which every boot applies in foreign-key order: `create table if not exists`,
|
|
468
|
+
then `add column if not exists` for what an older table lacks, so a database created on 1.2 is
|
|
469
|
+
brought up to date with no hand-run migration. There is nothing to paste: the per-table `X_*_TABLE`
|
|
470
|
+
constants and `X_USERS_MIGRATION_1_3` are gone, and a hand-written migration that pasted them
|
|
471
|
+
should be deleted. They are still not `entity()` declarations, so `x db gen` does not see them.
|
|
473
472
|
|
|
474
473
|
## Sessions are not a write path
|
|
475
474
|
|
|
@@ -501,6 +500,36 @@ row would not be, and nothing does.
|
|
|
501
500
|
| `__Host-` + `Path=/` + no `Domain` | a sibling subdomain overwriting it (session fixation) |
|
|
502
501
|
| `Max-Age` | a client keeping it past the server's absolute ceiling |
|
|
503
502
|
|
|
503
|
+
## Signing out
|
|
504
|
+
|
|
505
|
+
`logout(auth, token)` ends the session row. The **response** does the rest: append
|
|
506
|
+
`signOutHeaders()` to it.
|
|
507
|
+
|
|
508
|
+
```ts
|
|
509
|
+
import type { Auth } from '@ultimat3/auth';
|
|
510
|
+
import { logout, signOutHeaders } from '@ultimat3/auth';
|
|
511
|
+
|
|
512
|
+
declare const auth: Auth; // your defineAuth(...)
|
|
513
|
+
declare const token: string; // the session token the request carried
|
|
514
|
+
declare const ctx: { readonly headers: Headers }; // the request context
|
|
515
|
+
|
|
516
|
+
await logout(auth, token);
|
|
517
|
+
for (const [name, value] of signOutHeaders({ session: auth.sessions.policy })) {
|
|
518
|
+
ctx.headers.append(name, value);
|
|
519
|
+
}
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
| Header | Why |
|
|
523
|
+
|---|---|
|
|
524
|
+
| `set-cookie` expiring the session cookie | only when `session` is passed — an app with its own cookie expires that itself |
|
|
525
|
+
| `clear-site-data: "cache", "storage"` (`SIGN_OUT_CLEAR_SITE_DATA`) | drops everything the previous principal left on the origin: the page store's IndexedDB, local storage, the service worker and its cached private pages. **Never `"cookies"`** — the same response sets the cookie that marks the browser signed out |
|
|
526
|
+
|
|
527
|
+
The browser acts on `Clear-Site-Data` only in a secure context (HTTPS, or `localhost`), which is
|
|
528
|
+
every deployed app and every `x dev`. The cost is one worker reinstall and re-precache per
|
|
529
|
+
sign-out: a PWA's offline cache does not outlive the person it was cached for. The realtime side of
|
|
530
|
+
the same fence is in [`packages/realtime/README.md`](../realtime/README.md) (a principal change wipes
|
|
531
|
+
the previous scope's persisted rows and queued writes).
|
|
532
|
+
|
|
504
533
|
## OAuth — log in with GitHub
|
|
505
534
|
|
|
506
535
|
`oauthLogin(auth)` **is** the flow. Two route descriptors, mounted at two fixed paths, composing
|
|
@@ -657,3 +686,12 @@ An api key's scopes become **exactly** the agent actor's scopes — never the ow
|
|
|
657
686
|
bun test packages/auth
|
|
658
687
|
bun run --filter @ultimat3/auth typecheck
|
|
659
688
|
```
|
|
689
|
+
|
|
690
|
+
### Error classes
|
|
691
|
+
|
|
692
|
+
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
693
|
+
a job boundary the class is gone and the `code` is what survives — match on that.
|
|
694
|
+
|
|
695
|
+
| Class | Code | Declared in |
|
|
696
|
+
|---|---|---|
|
|
697
|
+
| `AuthError` | any `AuthErrorCode` — `AUTH_ERROR_CODES` | `src/errors.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/auth",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.0.0",
|
|
4
4
|
"description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/core": "22.0.0",
|
|
35
|
+
"@ultimat3/db": "22.0.0",
|
|
36
|
+
"@ultimat3/schema": "22.0.0"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/api-keys.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { type Clock, randomHex, systemClock } from '@ultimat3/core';
|
|
8
8
|
import type { ApiKeyStore, AuthApiKeyRecord } from './adapter';
|
|
9
|
-
import { apiKeyInvalid } from './errors';
|
|
9
|
+
import { apiKeyEnvInvalid, apiKeyInvalid } from './errors';
|
|
10
10
|
import type { PolicyActor } from './policy-bridge';
|
|
11
11
|
import { actorFromApiKey } from './policy-bridge';
|
|
12
12
|
import { randomToken, sha256Hex, timingSafeEqual } from './tokens';
|
|
@@ -58,7 +58,11 @@ export interface IssuedApiKey {
|
|
|
58
58
|
readonly record: AuthApiKeyRecord;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** What `parseApiKey` can split back out: no `_`, and nothing a log would render two ways. */
|
|
62
|
+
const API_KEY_ENV = /^[a-z0-9-]+$/;
|
|
63
|
+
|
|
61
64
|
export function issueApiKey(input: IssueApiKeyInput): IssuedApiKey {
|
|
65
|
+
if (!API_KEY_ENV.test(input.env)) throw apiKeyEnvInvalid(input.env);
|
|
62
66
|
const clock = input.clock ?? systemClock;
|
|
63
67
|
// Hex, not base64url: the id sits between two `_` delimiters and must not contain one.
|
|
64
68
|
const id = randomHex(8);
|
package/src/builtin-adapter.ts
CHANGED
|
@@ -265,8 +265,13 @@ export class BuiltinAdapter implements AuthAdapter {
|
|
|
265
265
|
return rows.map(toSession);
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
/**
|
|
269
|
+
* On a pair that is already linked the row keeps its `id`, `user_id` and `created_at` and only
|
|
270
|
+
* the tokens move — so the answer is the row `returning *` reports, never the object handed in,
|
|
271
|
+
* which named a user the row does not belong to.
|
|
272
|
+
*/
|
|
268
273
|
async linkAccount(account: AuthAccount): Promise<AuthAccount> {
|
|
269
|
-
await this.#db.
|
|
274
|
+
const row = await this.#db.one<Row>(sql`
|
|
270
275
|
insert into x_accounts (id, user_id, provider, provider_account_id, access_token,
|
|
271
276
|
refresh_token, expires_at, created_at)
|
|
272
277
|
values (${account.id}, ${account.userId}, ${account.provider}, ${account.providerAccountId},
|
|
@@ -274,8 +279,9 @@ export class BuiltinAdapter implements AuthAdapter {
|
|
|
274
279
|
${account.createdAt})
|
|
275
280
|
on conflict (provider, provider_account_id) do update
|
|
276
281
|
set access_token = excluded.access_token, refresh_token = excluded.refresh_token,
|
|
277
|
-
expires_at = excluded.expires_at
|
|
278
|
-
|
|
282
|
+
expires_at = excluded.expires_at
|
|
283
|
+
returning *`);
|
|
284
|
+
return row === null ? account : toAccount(row);
|
|
279
285
|
}
|
|
280
286
|
|
|
281
287
|
async findAccount(provider: string, providerAccountId: string): Promise<AuthAccount | null> {
|
|
@@ -346,7 +352,7 @@ export class BuiltinAdapter implements AuthAdapter {
|
|
|
346
352
|
async listApiKeys(ownerId: string): Promise<readonly AuthApiKeyRecord[]> {
|
|
347
353
|
const rows = await this.#db.query<Row>(sql`
|
|
348
354
|
select * from x_api_keys where user_id = ${ownerId} or org_id = ${ownerId}
|
|
349
|
-
order by created_at desc`);
|
|
355
|
+
order by created_at desc, id desc`);
|
|
350
356
|
return rows.map(toApiKey);
|
|
351
357
|
}
|
|
352
358
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The browser page's client scope id: an OPAQUE name for the principal a per-request document was
|
|
2
|
+
// rendered for, carried in `<meta name="ultimate-scope">` so the page's one client store fences
|
|
3
|
+
// itself per principal (plan 101, decision 8). Keyed SHA-256 over the principal, never the id:
|
|
4
|
+
// the HTML is cacheable by a browser and readable by every script on the page.
|
|
5
|
+
|
|
6
|
+
import type { Actor } from '@ultimat3/core';
|
|
7
|
+
import { logger } from '@ultimat3/core';
|
|
8
|
+
import { clientScopeSecretShort } from './errors';
|
|
9
|
+
|
|
10
|
+
/** 128 bits: a scope only has to differ between principals, never to authenticate anyone. */
|
|
11
|
+
const SCOPE_HEX_CHARS = 32;
|
|
12
|
+
|
|
13
|
+
/** `wiki/Configuration.md`'s floor for `SESSION_SECRET`, the same one the oauth handshake reads. */
|
|
14
|
+
const MIN_SECRET_LENGTH = 32;
|
|
15
|
+
|
|
16
|
+
export interface ClientScopeOptions {
|
|
17
|
+
/** Defaults to `SESSION_SECRET`. */
|
|
18
|
+
readonly secret?: string | undefined;
|
|
19
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Minted on first need when no `SESSION_SECRET` exists: stable per process, never per request. */
|
|
23
|
+
let processKey: string | undefined;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `''` for the anonymous page — the empty scope core reads as "nobody". Otherwise the principal
|
|
27
|
+
* AND who is really acting, so an admin impersonating a user is not the user's own scope: their
|
|
28
|
+
* stores must never share records, on the page or on disk.
|
|
29
|
+
*/
|
|
30
|
+
export function clientScopeOf(actor: Actor, options: ClientScopeOptions = {}): string {
|
|
31
|
+
if (actor.kind === 'anonymous') return '';
|
|
32
|
+
const material = JSON.stringify([
|
|
33
|
+
actor.kind,
|
|
34
|
+
actor.id,
|
|
35
|
+
actor.onBehalfOf?.actorKind ?? null,
|
|
36
|
+
actor.onBehalfOf?.actorId ?? null,
|
|
37
|
+
]);
|
|
38
|
+
return new Bun.CryptoHasher('sha256', scopeKey(options))
|
|
39
|
+
.update(material)
|
|
40
|
+
.digest('hex')
|
|
41
|
+
.slice(0, SCOPE_HEX_CHARS);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function scopeKey(options: ClientScopeOptions): string {
|
|
45
|
+
if (options.secret !== undefined) {
|
|
46
|
+
// The same floor as the env path below: an explicit key is a caller's value, so a short one is
|
|
47
|
+
// refused rather than silently swapped for the process key.
|
|
48
|
+
if (options.secret.length < MIN_SECRET_LENGTH) {
|
|
49
|
+
throw clientScopeSecretShort(options.secret.length, MIN_SECRET_LENGTH);
|
|
50
|
+
}
|
|
51
|
+
return options.secret;
|
|
52
|
+
}
|
|
53
|
+
const configured = (options.env ?? Bun.env)['SESSION_SECRET']?.trim() ?? '';
|
|
54
|
+
if (configured.length >= MIN_SECRET_LENGTH) return configured;
|
|
55
|
+
if (processKey === undefined) {
|
|
56
|
+
// `SESSION_SECRET` is optional in the env schema, so its absence cannot refuse a render. A
|
|
57
|
+
// random per-process key keeps the id opaque; what it costs is stability across replicas and
|
|
58
|
+
// restarts — a principal's page then rescopes, which clears its store, never leaks it.
|
|
59
|
+
processKey = Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) =>
|
|
60
|
+
byte.toString(16).padStart(2, '0'),
|
|
61
|
+
).join('');
|
|
62
|
+
logger.warn('auth.client_scope.process_key', {
|
|
63
|
+
reason: 'SESSION_SECRET unset or shorter than 32 chars',
|
|
64
|
+
fix: 'export SESSION_SECRET="$(openssl rand -hex 32)"',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return processKey;
|
|
68
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -241,6 +241,34 @@ export const authPolicyNumberInvalid = (
|
|
|
241
241
|
meta: { option: key, value: String(value) },
|
|
242
242
|
});
|
|
243
243
|
|
|
244
|
+
/**
|
|
245
|
+
* An explicit key for `clientScopeOf` under the floor `SESSION_SECRET` is held to. The env path
|
|
246
|
+
* already refused a short secret; the option skipped the screen, so `{ secret: 'x' }` keyed every
|
|
247
|
+
* page's scope id with one character. `X_CONFIG_INVALID`, borrowed like the two above.
|
|
248
|
+
*/
|
|
249
|
+
export const clientScopeSecretShort = (length: number, minLength: number): AuthError =>
|
|
250
|
+
new AuthError({
|
|
251
|
+
code: 'X_CONFIG_INVALID',
|
|
252
|
+
cause: `clientScopeOf({ secret }) was passed a key of ${String(length)} characters and at least ${String(minLength)} are required, so the scope id would be keyed by a guessable secret`,
|
|
253
|
+
fix: 'omit secret so clientScopeOf reads SESSION_SECRET, or pass one generated with: openssl rand -hex 32',
|
|
254
|
+
meta: { option: 'secret', minLength },
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* An api key `env` the parser cannot read back. `parseApiKey` splits `ult_<env>_<id>_<secret>` on
|
|
259
|
+
* `_`, so `issueApiKey({ env: 'live_eu' })` minted a key its own verifier refused. Refused at
|
|
260
|
+
* issue — `X_CONFIG_INVALID`, borrowed like the three above — never at first use.
|
|
261
|
+
*/
|
|
262
|
+
export const apiKeyEnvInvalid = (env: string): AuthError =>
|
|
263
|
+
new AuthError({
|
|
264
|
+
code: 'X_CONFIG_INVALID',
|
|
265
|
+
cause: env.includes('_')
|
|
266
|
+
? `api key env ${renderCauseValue(env)} contains "_", which parseApiKey splits on`
|
|
267
|
+
: `api key env ${renderCauseValue(env)} is not lowercase letters, digits and "-", the only spelling that survives parseApiKey and reads the same in every log`,
|
|
268
|
+
fix: "pass env matching ^[a-z0-9-]+$ to issueApiKey (e.g. 'live-eu')",
|
|
269
|
+
meta: { option: 'env' },
|
|
270
|
+
});
|
|
271
|
+
|
|
244
272
|
export const passwordWeak = (reasons: readonly string[]): AuthError =>
|
|
245
273
|
new AuthError({
|
|
246
274
|
code: 'X_PASSWORD_WEAK',
|
package/src/index.ts
CHANGED
|
@@ -20,9 +20,7 @@ export type {
|
|
|
20
20
|
export type { ApiKeySummary, IssueApiKeyInput, IssuedApiKey, ParsedApiKey } from './api-keys';
|
|
21
21
|
export {
|
|
22
22
|
API_KEY_NAMESPACE,
|
|
23
|
-
API_KEY_PREFIX_SEGMENTS,
|
|
24
23
|
apiKeyActor,
|
|
25
|
-
apiKeyPrefix,
|
|
26
24
|
describeApiKey,
|
|
27
25
|
issueApiKey,
|
|
28
26
|
parseApiKey,
|
|
@@ -52,6 +50,9 @@ export {
|
|
|
52
50
|
} from './auth';
|
|
53
51
|
|
|
54
52
|
export { BuiltinAdapter } from './builtin-adapter';
|
|
53
|
+
/** The opaque per-principal id a per-request document hands the page's client store. */
|
|
54
|
+
export type { ClientScopeOptions } from './client-scope';
|
|
55
|
+
export { clientScopeOf } from './client-scope';
|
|
55
56
|
export type { AuthUserSummary } from './directory';
|
|
56
57
|
export { describeUser, findUserByExternalId, listOrgUsers } from './directory';
|
|
57
58
|
// The one normalisation an address gets before it is an identity key. Public because an app
|
|
@@ -97,7 +98,6 @@ export type {
|
|
|
97
98
|
} from './jwks';
|
|
98
99
|
export {
|
|
99
100
|
createJwksClient,
|
|
100
|
-
DEFAULT_JWKS_TTL_MS,
|
|
101
101
|
decodeJwtHeader,
|
|
102
102
|
providerJwks,
|
|
103
103
|
verifyJwtSignature,
|
|
@@ -106,9 +106,7 @@ export type { KdfGate, KdfLimits } from './kdf-gate';
|
|
|
106
106
|
export {
|
|
107
107
|
configureKdfGate,
|
|
108
108
|
createKdfGate,
|
|
109
|
-
DEFAULT_KDF_LIMITS,
|
|
110
109
|
kdfGate,
|
|
111
|
-
resetKdfGate,
|
|
112
110
|
} from './kdf-gate';
|
|
113
111
|
export type { AuthLimiterFactory } from './limiter-install';
|
|
114
112
|
// `installedAuthLimiter` is deliberately absent: `defineAuth` is the one reader, and a second
|
|
@@ -148,7 +146,7 @@ export type {
|
|
|
148
146
|
OAuthProviderId,
|
|
149
147
|
PkcePair,
|
|
150
148
|
} from './oauth';
|
|
151
|
-
export { assertOAuthCallback, beginOAuth, createPkce
|
|
149
|
+
export { assertOAuthCallback, beginOAuth, createPkce } from './oauth';
|
|
152
150
|
export {
|
|
153
151
|
APPLE_PROVIDER,
|
|
154
152
|
BUILTIN_OAUTH_PROVIDER_IDS,
|
|
@@ -163,7 +161,6 @@ export {
|
|
|
163
161
|
handshakeCookie,
|
|
164
162
|
handshakeCookieName,
|
|
165
163
|
handshakeSecret,
|
|
166
|
-
OAUTH_HANDSHAKE_COOKIE_PREFIX,
|
|
167
164
|
openHandshake,
|
|
168
165
|
readHandshakeCookie,
|
|
169
166
|
sealHandshake,
|
|
@@ -229,7 +226,6 @@ export {
|
|
|
229
226
|
DEFAULT_PASSWORD_POLICY,
|
|
230
227
|
hashPassword,
|
|
231
228
|
needsRehash,
|
|
232
|
-
parseHashParams,
|
|
233
229
|
verifyPassword,
|
|
234
230
|
} from './password';
|
|
235
231
|
export type {
|
|
@@ -260,7 +256,6 @@ export {
|
|
|
260
256
|
DEFAULT_MAX_AUTH_LIMIT_KEYS,
|
|
261
257
|
ipKey,
|
|
262
258
|
loginFailed,
|
|
263
|
-
ORG_ATTEMPT_FACTOR,
|
|
264
259
|
orgKey,
|
|
265
260
|
orgRateLimit,
|
|
266
261
|
} from './rate-limit';
|
|
@@ -271,14 +266,7 @@ export type {
|
|
|
271
266
|
} from './rate-limit-postgres';
|
|
272
267
|
export {
|
|
273
268
|
postgresAuthLimiter,
|
|
274
|
-
SQL_AUTH_FORGET_KEY,
|
|
275
|
-
SQL_AUTH_KEY_LOCK,
|
|
276
269
|
SQL_AUTH_LIMIT_TABLES,
|
|
277
|
-
SQL_AUTH_LOCK,
|
|
278
|
-
SQL_AUTH_LOCKED_UNTIL,
|
|
279
|
-
SQL_AUTH_PURGE,
|
|
280
|
-
SQL_AUTH_RECORD_FAILURE,
|
|
281
|
-
SQL_AUTH_RESET,
|
|
282
270
|
} from './rate-limit-postgres';
|
|
283
271
|
export type { DisabledUser } from './revocation';
|
|
284
272
|
export {
|
|
@@ -303,10 +291,8 @@ export {
|
|
|
303
291
|
clearSessionCookie,
|
|
304
292
|
createSession,
|
|
305
293
|
DEFAULT_SESSION_POLICY,
|
|
306
|
-
IDLE_SLIDE_DIVISOR,
|
|
307
294
|
idleSlideMs,
|
|
308
295
|
listDevices,
|
|
309
|
-
parseSessionToken,
|
|
310
296
|
readCookie,
|
|
311
297
|
readSessionCookie,
|
|
312
298
|
revokeOtherSessions,
|
|
@@ -316,21 +302,15 @@ export {
|
|
|
316
302
|
sessionExpiry,
|
|
317
303
|
verifySession,
|
|
318
304
|
} from './session';
|
|
305
|
+
/** A sign-out response's headers: the session cookie expired, and `Clear-Site-Data`. */
|
|
306
|
+
export type { SignOutHeadersOptions } from './sign-out';
|
|
307
|
+
export { SIGN_OUT_CLEAR_SITE_DATA, signOutHeaders } from './sign-out';
|
|
319
308
|
|
|
320
309
|
export {
|
|
321
310
|
AUTH_TABLE_NAMES,
|
|
322
311
|
AUTH_TABLES,
|
|
323
|
-
X_ACCOUNTS_TABLE,
|
|
324
|
-
X_API_KEYS_TABLE,
|
|
325
|
-
X_SESSIONS_TABLE,
|
|
326
|
-
X_USERS_MIGRATION_1_3,
|
|
327
|
-
X_USERS_TABLE,
|
|
328
|
-
X_VERIFICATIONS_TABLE,
|
|
329
312
|
} from './tables';
|
|
330
313
|
export {
|
|
331
|
-
base64Url,
|
|
332
|
-
base64UrlBytes,
|
|
333
|
-
matchesHash,
|
|
334
314
|
randomToken,
|
|
335
315
|
sha256Hex,
|
|
336
316
|
timingSafeEqual,
|
package/src/memory-adapter.ts
CHANGED
|
@@ -209,9 +209,21 @@ export class MemoryAdapter implements AuthAdapter {
|
|
|
209
209
|
.sort((a, b) => b.lastSeenAt.getTime() - a.lastSeenAt.getTime());
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/** Postgres's `on conflict … do update`: the owner, id and created_at stay; the tokens move. */
|
|
212
213
|
async linkAccount(account: AuthAccount): Promise<AuthAccount> {
|
|
213
|
-
|
|
214
|
-
|
|
214
|
+
const key = `${account.provider}:${account.providerAccountId}`;
|
|
215
|
+
const existing = this.#accounts.get(key);
|
|
216
|
+
const stored =
|
|
217
|
+
existing === undefined
|
|
218
|
+
? account
|
|
219
|
+
: {
|
|
220
|
+
...existing,
|
|
221
|
+
accessToken: account.accessToken,
|
|
222
|
+
refreshToken: account.refreshToken,
|
|
223
|
+
expiresAt: account.expiresAt,
|
|
224
|
+
};
|
|
225
|
+
this.#accounts.set(key, stored);
|
|
226
|
+
return stored;
|
|
215
227
|
}
|
|
216
228
|
|
|
217
229
|
async findAccount(provider: string, providerAccountId: string): Promise<AuthAccount | null> {
|
|
@@ -255,9 +267,13 @@ export class MemoryAdapter implements AuthAdapter {
|
|
|
255
267
|
}
|
|
256
268
|
|
|
257
269
|
async listApiKeys(ownerId: string): Promise<readonly AuthApiKeyRecord[]> {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
270
|
+
// `order by created_at desc, id desc`, the builtin adapter's statement.
|
|
271
|
+
return [...this.#apiKeys.values()]
|
|
272
|
+
.filter((key) => key.userId === ownerId || key.orgId === ownerId)
|
|
273
|
+
.sort(
|
|
274
|
+
(a, b) =>
|
|
275
|
+
b.createdAt.getTime() - a.createdAt.getTime() || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0),
|
|
276
|
+
);
|
|
261
277
|
}
|
|
262
278
|
|
|
263
279
|
async touchApiKey(id: string, at: Date): Promise<void> {
|
package/src/oauth-exchange.ts
CHANGED
|
@@ -133,7 +133,13 @@ export async function providerDetail(
|
|
|
133
133
|
try {
|
|
134
134
|
const parsed: unknown = JSON.parse(text);
|
|
135
135
|
if (isRecord(parsed)) {
|
|
136
|
-
|
|
136
|
+
// `message` is not an OAuth error field (RFC 6749 §5.2 defines `error` and
|
|
137
|
+
// `error_description`), and a gateway that echoes the request body puts `client_secret` in
|
|
138
|
+
// it — so a `coded-only` leg never reads it.
|
|
139
|
+
const description =
|
|
140
|
+
parsed['error_description'] ??
|
|
141
|
+
parsed['error'] ??
|
|
142
|
+
(echo === 'coded-only' ? undefined : parsed['message']);
|
|
137
143
|
if (typeof description === 'string' && description !== '') {
|
|
138
144
|
return renderCauseValue(description);
|
|
139
145
|
}
|
package/src/oauth-profile.ts
CHANGED
|
@@ -79,13 +79,21 @@ async function getJson(
|
|
|
79
79
|
// so the rejection is whatever a driver or a proxy threw — and `instanceof` runs the value's
|
|
80
80
|
// own `getPrototypeOf` trap, which would replace this coded refusal with a bare `TypeError`
|
|
81
81
|
// raised from inside the catch that exists to raise it. Same rule as `@ultimat3/cache`.
|
|
82
|
-
|
|
82
|
+
//
|
|
83
|
+
// LOGGED, never published: `oauth-route.ts` serves this cause to whoever typed the callback
|
|
84
|
+
// URL, and a rejection from an injected fetch is a server's internals — a pool DSN, an
|
|
85
|
+
// internal address. The same split `oauth-exchange.ts` makes on the token leg.
|
|
86
|
+
logger.error('auth.oauth.userinfo_fetch_failed', {
|
|
87
|
+
provider,
|
|
88
|
+
url,
|
|
89
|
+
detail: renderThrowable(error),
|
|
90
|
+
});
|
|
83
91
|
throw oauthExchangeFailed({
|
|
84
92
|
provider,
|
|
85
93
|
stage: 'userinfo',
|
|
86
94
|
detail:
|
|
87
|
-
|
|
88
|
-
'flow once it does',
|
|
95
|
+
`nothing left this host for ${url} (egress, DNS or TLS); the reason is in this process ` +
|
|
96
|
+
'log under auth.oauth.userinfo_fetch_failed. Restart the flow once it does',
|
|
89
97
|
// The endpoint reaches the registry from `discoverOAuthProvider` — the OP's own document —
|
|
90
98
|
// so it is REMOTE text, and this line is a command a reader pastes: `$(…)` and a backtick
|
|
91
99
|
// substitute before `curl` is reached. `renderFixShellArg` passes an ordinary endpoint
|
package/src/sign-out.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// What a sign-out RESPONSE carries, beside ending the session row (`logout`): the session cookie
|
|
2
|
+
// expired, and `Clear-Site-Data`, so the browser drops everything the previous principal left on
|
|
3
|
+
// the origin — the page store's IndexedDB, local storage, the service worker and its cached pages.
|
|
4
|
+
|
|
5
|
+
import type { SessionPolicy } from './session';
|
|
6
|
+
import { clearSessionCookie } from './session';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `"cache", "storage"` and never `"cookies"`: the response itself sets the cookie that marks the
|
|
10
|
+
* browser signed out, and `"cookies"` would clear that too. `"storage"` unregisters the service
|
|
11
|
+
* worker and empties Cache Storage — which is the point, since a cached private page is the previous
|
|
12
|
+
* principal's data — so the next load reinstalls the worker and re-precaches. That cost is paid once
|
|
13
|
+
* per sign-out, and it is the trade the framework makes: a PWA's offline cache does not outlive the
|
|
14
|
+
* person it was cached for.
|
|
15
|
+
*/
|
|
16
|
+
export const SIGN_OUT_CLEAR_SITE_DATA = '"cache", "storage"';
|
|
17
|
+
|
|
18
|
+
export interface SignOutHeadersOptions {
|
|
19
|
+
/**
|
|
20
|
+
* The framework session's policy, when the app signs out of `@ultimat3/auth`'s own session: the
|
|
21
|
+
* cookie is then expired here. Absent for an app with its own cookie (it sets that itself).
|
|
22
|
+
*/
|
|
23
|
+
readonly session?: SessionPolicy | undefined;
|
|
24
|
+
readonly cookieName?: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The headers a sign-out response appends, in order: `for (const [name, value] of
|
|
29
|
+
* signOutHeaders()) ctx.headers.append(name, value)`. The browser acts on them only for a secure
|
|
30
|
+
* context (HTTPS, or localhost) — which is every deployed app and every `x dev`.
|
|
31
|
+
*/
|
|
32
|
+
export function signOutHeaders(options: SignOutHeadersOptions = {}): readonly [string, string][] {
|
|
33
|
+
return [
|
|
34
|
+
...(options.session === undefined
|
|
35
|
+
? []
|
|
36
|
+
: [
|
|
37
|
+
['set-cookie', clearSessionCookie(options.session, options.cookieName)] as [
|
|
38
|
+
string,
|
|
39
|
+
string,
|
|
40
|
+
],
|
|
41
|
+
]),
|
|
42
|
+
['clear-site-data', SIGN_OUT_CLEAR_SITE_DATA],
|
|
43
|
+
];
|
|
44
|
+
}
|
package/src/tables.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// Single responsibility: the DDL `BuiltinAdapter` expects
|
|
2
|
-
//
|
|
3
|
-
// never by trusting.
|
|
1
|
+
// Single responsibility: the DDL `BuiltinAdapter` expects, as `AUTH_TABLES` — applied at every boot
|
|
2
|
+
// by `@ultimat3/cli`'s `FRAMEWORK_SCHEMA`, upgrades of an older table included. Plain strings, so
|
|
3
|
+
// what auth stores is verifiable by reading, never by trusting. There is nothing to paste into a
|
|
4
|
+
// migration any more: the per-table exports and `X_USERS_MIGRATION_1_3` are gone (22.0.0).
|
|
4
5
|
//
|
|
5
6
|
// What is at rest, stated column by column rather than as a claim:
|
|
6
7
|
//
|
|
@@ -45,16 +46,18 @@ export const X_USERS_TABLE = `create table if not exists x_users (
|
|
|
45
46
|
create index if not exists x_users_org_id_idx on x_users (org_id)`;
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
49
|
+
* What `x_users` gained in 1.3.0, applied at EVERY boot right after the create, so a table made
|
|
50
|
+
* before 1.3 is brought up to date with no hand-run migration — this was `X_USERS_MIGRATION_1_3`,
|
|
51
|
+
* exported for an app to run and applied by nothing. Idempotent (`if not exists`) and additive:
|
|
52
|
+
* both columns have a default or are nullable, so on a live table it is a catalog update, not a
|
|
53
|
+
* rewrite, and a second boot does nothing.
|
|
51
54
|
*/
|
|
52
|
-
|
|
55
|
+
const X_USERS_UPGRADE = [
|
|
53
56
|
`alter table x_users add column if not exists scopes text[] not null default '{}'`,
|
|
54
57
|
'alter table x_users add column if not exists external_id text',
|
|
55
58
|
'create unique index if not exists x_users_external_id_key on x_users (external_id)',
|
|
56
59
|
'create index if not exists x_users_org_id_idx on x_users (org_id)',
|
|
57
|
-
]);
|
|
60
|
+
].join(';\n');
|
|
58
61
|
|
|
59
62
|
// `id` is the public half of the cookie; `token_hash` is sha256 of the secret half.
|
|
60
63
|
export const X_SESSIONS_TABLE = `create table if not exists x_sessions (
|
|
@@ -114,6 +117,7 @@ export const X_API_KEYS_TABLE = `create table if not exists x_api_keys (
|
|
|
114
117
|
/** Ordered by foreign-key dependency — run them top to bottom. */
|
|
115
118
|
export const AUTH_TABLES: readonly string[] = Object.freeze([
|
|
116
119
|
X_USERS_TABLE,
|
|
120
|
+
X_USERS_UPGRADE,
|
|
117
121
|
X_SESSIONS_TABLE,
|
|
118
122
|
X_ACCOUNTS_TABLE,
|
|
119
123
|
X_VERIFICATIONS_TABLE,
|