@ultimat3/auth 23.0.0 → 25.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 +95 -46
- package/README.md +245 -93
- package/package.json +5 -5
- package/src/adapter.ts +49 -18
- package/src/api-key-resolver.ts +14 -9
- package/src/api-keys.ts +61 -14
- package/src/auth.ts +56 -53
- package/src/builtin-adapter-violation.ts +43 -0
- package/src/builtin-adapter.ts +77 -9
- package/src/client-scope.ts +8 -2
- package/src/directory.ts +2 -13
- package/src/email.ts +1 -1
- package/src/errors.ts +80 -48
- package/src/guards.ts +2 -2
- package/src/id-token.ts +2 -3
- package/src/index.ts +57 -30
- package/src/jwks.ts +18 -7
- package/src/kdf-gate.ts +6 -6
- package/src/limiter-install.ts +1 -1
- package/src/login-attempt.ts +70 -0
- package/src/memory-adapter.ts +73 -7
- package/src/mfa-challenge.ts +151 -0
- package/src/mfa-secret.ts +91 -0
- package/src/mfa.ts +14 -28
- package/src/oauth-builtins.ts +6 -0
- package/src/oauth-cookie.ts +17 -7
- package/src/oauth-discovery.ts +24 -5
- package/src/oauth-errors.ts +4 -84
- package/src/oauth-exchange.ts +13 -7
- package/src/oauth-grant-context.ts +106 -0
- package/src/oauth-login.ts +78 -23
- package/src/oauth-profile.ts +48 -11
- package/src/oauth-registry.ts +16 -1
- package/src/oauth-route.ts +75 -22
- package/src/oauth.ts +4 -3
- package/src/policy-bridge.ts +51 -8
- package/src/privileges.ts +46 -18
- package/src/rate-limit-postgres.ts +113 -101
- package/src/rate-limit.ts +50 -16
- package/src/revocation.ts +25 -25
- package/src/session.ts +69 -37
- package/src/tables.ts +8 -10
- package/src/tokens.ts +2 -5
- package/src/verify.ts +4 -3
package/README.md
CHANGED
|
@@ -5,10 +5,10 @@ authorizes on a session row, a user row or an api key — http, actions, jobs an
|
|
|
5
5
|
`ctx.actor` and hand it to `@ultimat3/policy`. One authz system, never two.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
|
-
import {
|
|
8
|
+
import { defineAuth, login, oauthLogin, postgresAuthAdapter } from '@ultimat3/auth';
|
|
9
9
|
|
|
10
10
|
export const auth = defineAuth({
|
|
11
|
-
adapter:
|
|
11
|
+
adapter: postgresAuthAdapter(), // or memoryAuthAdapter(), or your Better Auth binding
|
|
12
12
|
session: { absoluteTtlMs: 30 * 864e5, idleTtlMs: 7 * 864e5 },
|
|
13
13
|
password: { minLength: 12 },
|
|
14
14
|
mfa: { issuer: 'Acme' }, // the authenticator app's name; `required` only as `false`
|
|
@@ -40,10 +40,13 @@ const { start, callback } = oauthLogin(auth);
|
|
|
40
40
|
- The lockout counts attempts against one identity, so it has to be **one** count. `AuthLimiter`
|
|
41
41
|
is async on every member and declares the policy it enforces; `defineAuth` refuses a limiter
|
|
42
42
|
that disagrees with the app's declaration.
|
|
43
|
+
- An attempt is **reserved** before the KDF, never checked and recorded after it: `reserve(key)`
|
|
44
|
+
counts the guess in the step that admits it, so a concurrent burst spends the allowance as it
|
|
45
|
+
arrives. `refund(reservation)` gives one back on success; a failure calls nothing.
|
|
43
46
|
|
|
44
47
|
## The lockout across replicas
|
|
45
48
|
|
|
46
|
-
`
|
|
49
|
+
`memoryAuthLimiter` keeps its table in the process, so `maxAttempts: 5` at `replicas: 3` lets an
|
|
47
50
|
account survive 15 guesses and hides each replica's lockout from the other two. An app that runs
|
|
48
51
|
more than one process says so and brings a limiter that says the same:
|
|
49
52
|
|
|
@@ -70,8 +73,10 @@ refuses any pairing that would make it a lie:
|
|
|
70
73
|
`account:<email>` and `ip:<addr>` were the only key shapes, so one tenant's misconfigured
|
|
71
74
|
integration hammering login from 400 addresses was capped by neither — each IP bucket allowed its
|
|
72
75
|
own quota, the account buckets protected individuals, and the shared limiter saturated behind
|
|
73
|
-
them. `orgKey(orgId)` is the third shape,
|
|
74
|
-
and still before the KDF runs.
|
|
76
|
+
them. `orgKey(orgId)` is the third shape, reserved in `login()` once the address resolves to an org
|
|
77
|
+
and still before the KDF runs. A locked tenant answers `X_ACCOUNT_LOCKED` with no org id in it —
|
|
78
|
+
the id was looked up from the address the caller typed — and an attempt the tenant cap turns away
|
|
79
|
+
is not counted against the account or the address.
|
|
75
80
|
|
|
76
81
|
`rateLimit.orgMaxAttempts` is its own number, defaulting to `maxAttempts * 20`: a whole tenant
|
|
77
82
|
sharing five attempts is a denial of service against that tenant. A success on one member's
|
|
@@ -91,7 +96,7 @@ const { identity } = await verifyWorkloadToken({
|
|
|
91
96
|
token,
|
|
92
97
|
issuers: ['https://kubernetes.default.svc'],
|
|
93
98
|
audience: 'https://ledger.internal',
|
|
94
|
-
keys:
|
|
99
|
+
keys: jwksClient({ provider: 'k8s', jwksUri: 'https://kubernetes.default.svc/openid/v1/jwks' }),
|
|
95
100
|
clock,
|
|
96
101
|
});
|
|
97
102
|
const actor = actorFromService(identity); // kind: 'service', id: the caller's own sub
|
|
@@ -103,14 +108,18 @@ exemption here — the token arrived in a header.
|
|
|
103
108
|
|
|
104
109
|
**mTLS is out of scope.** TLS termination is the mesh's job ([axiom
|
|
105
110
|
7](../../docs/idea/README.md)); the framework's part is reading a trusted
|
|
106
|
-
`x-forwarded-client-cert` through `@ultimat3/http`'s trusted-proxy seam, which that package owns
|
|
111
|
+
`x-forwarded-client-cert` through `@ultimat3/http`'s trusted-proxy seam, which that package owns —
|
|
112
|
+
and only once the app declares `configureHttp({ trustClientCertHeader: true })`: trusting the
|
|
113
|
+
proxy's `x-forwarded-for` is not a promise that it strips a certificate header the client sent.
|
|
107
114
|
|
|
108
115
|
`maxKeys` is not compared — it bounds one process' table, not a limit. A custom limiter therefore
|
|
109
116
|
does **not** own its own configuration: the policy stays the app's single statement of the limits,
|
|
110
117
|
and the boot check is what keeps it true.
|
|
111
118
|
|
|
112
|
-
**A shared limiter ships, `As of 2026-
|
|
113
|
-
|
|
119
|
+
**A shared limiter ships, `As of 2026-10`** — `postgresAuthLimiter({ executor, clock, policy })`,
|
|
120
|
+
one table (`x_auth_lockouts`), one row per key holding the instants still inside the window, so
|
|
121
|
+
the window still SLIDES across replicas and a take is ONE statement: forty concurrent guesses
|
|
122
|
+
against `maxAttempts: 5` are admitted five times. Until it landed,
|
|
114
123
|
`scope: 'shared'` was a declaration nothing in the framework could satisfy while `x new` scaffolded
|
|
115
124
|
`replicas: 2` — `maxAttempts × 2` guesses per account. `executor` is a `PgExecutor`, anything
|
|
116
125
|
speaking `query(text, values)`; **never `Bun.sql`**, whose `.query` is `undefined`.
|
|
@@ -152,7 +161,17 @@ defineAuth({
|
|
|
152
161
|
Both limiters share one table: the keys are prefixed (`account:`, `ip:`, `org:`) and every limit
|
|
153
162
|
travels as a statement parameter, so the tenant bucket's wider allowance cannot leak into the
|
|
154
163
|
account bucket's. It reports `maxKeys: undefined` — there is no in-process table to bound — and
|
|
155
|
-
|
|
164
|
+
the table does not forget on its own.
|
|
165
|
+
|
|
166
|
+
A limiter of your own implements the same five members:
|
|
167
|
+
|
|
168
|
+
| Member | Contract |
|
|
169
|
+
|---|---|
|
|
170
|
+
| `reserve(key)` | count one attempt atomically; `X_ACCOUNT_LOCKED`, counting nothing, while the key is locked; the attempt that fills the window is admitted and starts the lockout. Answers `{ key, atMs }` |
|
|
171
|
+
| `refund(reservation)` | remove that ONE attempt, and lift a lockout the window no longer justifies |
|
|
172
|
+
| `recordSuccess(key)` | clear the key — its window and its lockout |
|
|
173
|
+
| `lockedUntil(key)` / `reset()` | the live deadline or `null`; drop everything |
|
|
174
|
+
| `purgeExpired?()` | only for a limiter backed by a table |
|
|
156
175
|
|
|
157
176
|
**An app does not have to write any of that, `As of 2026-08-22`.** The boot fills a seam and every
|
|
158
177
|
`defineAuth` in the process picks it up:
|
|
@@ -174,14 +193,14 @@ A **factory** and not a limiter, because the host runs before the app: `defineAu
|
|
|
174
193
|
limiter enforces against what the app declared, so a limiter built at boot on the framework
|
|
175
194
|
defaults would be `X_AUTH_LIMITER_POLICY_MISMATCH` for every app that tuned its numbers. The
|
|
176
195
|
factory is called once per bucket, with the resolved policy, so the two halves cannot disagree.
|
|
177
|
-
Precedence is `defineAuth({ limiter })` → the installed factory → `
|
|
196
|
+
Precedence is `defineAuth({ limiter })` → the installed factory → `memoryAuthLimiter`, and
|
|
178
197
|
`resetAuthLimiters()` puts the per-process default back. `@ultimat3/cli`'s `startServices` calls it
|
|
179
198
|
on every boot, so a scaffolded app gets a fleet-wide lockout with nothing to remember.
|
|
180
199
|
|
|
181
|
-
|
|
182
|
-
it drops
|
|
183
|
-
host handed the limiter, and it sweeps only the WIDEST window installed — a sweep on a
|
|
184
|
-
one deletes
|
|
200
|
+
The table does not forget on its own, and `purgeAuthLimits()` is the framework's reader for that:
|
|
201
|
+
it drops every key whose window has emptied and whose lockout has expired, measured against the
|
|
202
|
+
clock the host handed the limiter, and it sweeps only the WIDEST window installed — a sweep on a
|
|
203
|
+
narrower one deletes attempts another limiter is still counting, which hands a sprayer them back.
|
|
185
204
|
`@ultimat3/jobs`' `purge()` job is what calls it hourly; `x dev` and every role container declare
|
|
186
205
|
that sweep at boot.
|
|
187
206
|
|
|
@@ -222,12 +241,13 @@ registerOAuthProvider(await discoverOAuthProvider({ id: 'bigco-sso', issuer: 'ht
|
|
|
222
241
|
| `registerOAuthProvider(provider)` | the frozen provider; `X_OAUTH_PROVIDER_DUPLICATE` on a second claim of one id |
|
|
223
242
|
| `providerFor(id)` | the provider, or throws `X_OAUTH_PROVIDER_UNKNOWN` — never `undefined` |
|
|
224
243
|
| `hasOAuthProvider(id)` | whether the id is registered |
|
|
225
|
-
| `BUILTIN_OAUTH_PROVIDER_IDS` | the three shipped ids — the only list an **anonymous** refusal
|
|
244
|
+
| `BUILTIN_OAUTH_PROVIDER_IDS` | the three shipped ids — the only list an **anonymous** refusal is built from, and it is named in the `auth.oauth.refused` log line, never in the response body |
|
|
226
245
|
| `oauthProviderIds()` | every registered id, live. **NOT** what `defineAuth({ providers })` defaults to — that is `[]`, so an app names what it enabled |
|
|
227
246
|
|
|
228
247
|
`discoverOAuthProvider` refuses a document with no `jwks_uri`: without a key set there is nothing
|
|
229
248
|
to check an id token's signature against, and the token-endpoint TLS exemption below is a thing a
|
|
230
|
-
caller declares, not one a provider inherits by omission.
|
|
249
|
+
caller declares, not one a provider inherits by omission. It refuses a document whose `issuer` is
|
|
250
|
+
not the one asked for, too (OIDC Discovery §4.3).
|
|
231
251
|
|
|
232
252
|
**SAML is out of scope and will stay out of scope.** XML-DSig canonicalisation has no Bun native
|
|
233
253
|
and implementing it would mean a real dependency in the primitive vocabulary, which
|
|
@@ -243,7 +263,7 @@ because a default is what silently makes a second door as trusting as the first.
|
|
|
243
263
|
| `keys` | Means |
|
|
244
264
|
|---|---|
|
|
245
265
|
| `'token-endpoint-tls'` | this token came off a TLS response from the provider's own token endpoint — the one case OIDC Core 3.1.3.7 exempts. `exchangeOAuthCode` passes it, and that is the only shipped call site that may |
|
|
246
|
-
| a `JwksKeySource` | the signature is checked. `providerJwks(providerFor(id))`, or `
|
|
266
|
+
| a `JwksKeySource` | the signature is checked. `providerJwks(providerFor(id))`, or `jwksClient({ provider, jwksUri })` |
|
|
247
267
|
|
|
248
268
|
```ts
|
|
249
269
|
const keys = providerJwks(providerFor('bigco-sso'));
|
|
@@ -282,43 +302,71 @@ convention and business convention never ships ([axiom
|
|
|
282
302
|
**every** login — so removing somebody from a group in the IdP takes effect at their next sign-in,
|
|
283
303
|
rather than never. A seam that returns the stored answer writes nothing.
|
|
284
304
|
|
|
305
|
+
**The profile is not all the IdP said.** `resolveGrants(profile, context)` takes a second argument
|
|
306
|
+
(`OAuthGrantContext`) for an IdP that keeps authorization facts off the id token — groups,
|
|
307
|
+
entitlements, an `allowed_oauth_apps` claim served on `/userinfo` only:
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
import { defineAuth, memoryAuthAdapter, oauthLogin } from '@ultimat3/auth';
|
|
311
|
+
|
|
312
|
+
const auth = defineAuth({ adapter: memoryAuthAdapter(), providers: ['google'] });
|
|
313
|
+
|
|
314
|
+
export const { start, callback } = oauthLogin(auth, {
|
|
315
|
+
resolveGrants: async (_profile, context) => {
|
|
316
|
+
const claims = await context.userinfo();
|
|
317
|
+
const apps = Array.isArray(claims['allowed_oauth_apps']) ? claims['allowed_oauth_apps'] : [];
|
|
318
|
+
return apps.includes('bank-integrations') ? { roles: ['member'] } : { roles: [] };
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
| `context.` | What it is |
|
|
324
|
+
|---|---|
|
|
325
|
+
| `provider` | the provider id this login came through |
|
|
326
|
+
| `tokens` | the exchange's `OAuthTokens`, in flight — the framework stores none of them; keeping one is the app storing a credential |
|
|
327
|
+
| `idTokenClaims` | every claim of the VERIFIED id token, the ones `OAuthProfile` drops included (`hd`, `groups`); `null` without one |
|
|
328
|
+
| `userinfo()` | the provider's userinfo body, every claim, read with the access token — lazily (no call, no request) and at most once per login; none at all when the profile was already read off userinfo (GitHub), whose body it hands back. A body naming another `sub` is `X_OAUTH_EXCHANGE_FAILED`; so is a provider with no userinfo endpoint, and a failed read, which is not remembered |
|
|
329
|
+
|
|
285
330
|
## MFA — TOTP and recovery codes
|
|
286
331
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
rows, because `AuthAdapter` has no MFA member and adding one would break every third-party
|
|
290
|
-
adapter.
|
|
332
|
+
TOTP is pure functions over a secret and a clock; what is STORED goes through two writers and one
|
|
333
|
+
reader, `As of 2026-10`: the secret is sealed at rest, and a recovery code is consumed atomically.
|
|
291
334
|
|
|
292
335
|
```ts
|
|
293
336
|
import type { Auth } from '@ultimat3/auth';
|
|
294
|
-
import {
|
|
295
|
-
import { systemClock } from '@ultimat3/core';
|
|
337
|
+
import { enrolTotp, generateRecoveryCodes, saveTotpSecret } from '@ultimat3/auth';
|
|
296
338
|
|
|
297
339
|
declare const auth: Auth; // the `defineAuth` at the top — `enrolTotp` reads `auth.mfa.issuer`
|
|
340
|
+
declare const userId: string;
|
|
298
341
|
|
|
299
342
|
const enrolment = enrolTotp(auth, { account: 'ada@example.com' }); // issuer: auth.mfa.issuer
|
|
300
|
-
// enrolment.uri
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const recovery = generateRecoveryCodes(); // { codes, hashes } — show `codes` ONCE
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
export function secondFactorHolds(userId: string, secret: string, code: string): boolean {
|
|
307
|
-
const at = systemClock.now();
|
|
308
|
-
const { ok, step } = verifyTotp({ secret, code, at });
|
|
309
|
-
if (!ok || step === null || guard.isUsed(userId, step)) return false;
|
|
310
|
-
guard.remember(userId, step, at);
|
|
311
|
-
return true;
|
|
312
|
-
}
|
|
343
|
+
// enrolment.uri -> otpauth://… the QR code
|
|
344
|
+
await saveTotpSecret(auth, userId, enrolment.secret); // sealed, onto x_users
|
|
345
|
+
|
|
346
|
+
const recovery = generateRecoveryCodes(); // { codes, hashes } — show `codes` ONCE
|
|
347
|
+
await auth.adapter.updateUser(userId, { recoveryCodeHashes: recovery.hashes });
|
|
313
348
|
```
|
|
314
349
|
|
|
350
|
+
Checking a code at sign-in is `completeMfa` (below) — it opens the secret, meters the guess and
|
|
351
|
+
refuses a spent code; an app does not call `verifyTotp` on a stored secret itself.
|
|
352
|
+
|
|
353
|
+
**The secret at rest is sealed** under the app's master key (`purpose: 'auth:x_users.mfa_secret'`).
|
|
354
|
+
A TOTP seed is symmetric, so it cannot be a digest — and in the clear, a dump of `x_users` was
|
|
355
|
+
every enrolled user's second factor.
|
|
356
|
+
|
|
357
|
+
| | |
|
|
358
|
+
|---|---|
|
|
359
|
+
| write | `saveTotpSecret(auth, userId, secret)` — refuses a secret nothing can derive a code from (`X_MFA_SECRET_INVALID`), needs the master key (`X_SEAL_KEY_MISSING`) |
|
|
360
|
+
| read | `openTotpSecret(stored)` — **refuses a value that is not sealed** (`X_MFA_SECRET_UNSEALED`, fix `x auth seal-mfa --json`). There is no plaintext fallback, by decision: a fallback kept "for now" is a plaintext column forever |
|
|
361
|
+
| rows from before sealing | `x auth seal-mfa` once, or `sealMfaSecrets({ adapter })` on an adapter of your own — idempotent, answers `{ sealed, alreadySealed, skipped }`; each write is a compare-and-set (`adapter.replaceMfaSecret`), so a re-enrolment mid-run is `skipped`, never overwritten. `x doctor` reports how many are left |
|
|
362
|
+
|
|
315
363
|
| Call | Answers |
|
|
316
364
|
|---|---|
|
|
317
365
|
| `enrolTotp(auth, { account, issuer?, secret? })` | `{ secret, uri, digits, periodSeconds }` — `issuer` omitted is `auth.mfa.issuer`, `secret` omitted mints one |
|
|
318
366
|
| `verifyTotp({ secret, code, at, drift?, usedSteps? })` | `{ ok, step }`. `step` is the window the code belonged to, `null` on no match |
|
|
319
|
-
| `
|
|
367
|
+
| `memoryTotpReplayGuard(drift?, maxSubjects?)` | the in-process `{ isUsed, remember, size }`; a fleet passes a Redis-backed pair of the same two methods |
|
|
320
368
|
| `generateRecoveryCodes(count = 10)` | `{ codes, hashes }`. `codes` is shown once and is never re-derivable |
|
|
321
|
-
| `
|
|
369
|
+
| `recoveryCodeHash(code)` | what a code is stored and looked up as — dashes, spaces and case removed first. `auth.adapter.consumeRecoveryCode(userId, hash)` removes it in ONE statement and answers whether it was there, so two requests carrying one code cannot both redeem it |
|
|
322
370
|
| `totpStep(at, stepSeconds?)` / `totpCode(secret, step, digits?)` | the RFC 6238 halves, for a test that has to mint a valid code. `totpCode` throws `X_MFA_SECRET_INVALID` on a secret that decodes to zero bytes |
|
|
323
371
|
|
|
324
372
|
**A secret the decoder cannot read verifies nothing, `As of 2026-08`.** `base32Decode` answers
|
|
@@ -360,11 +408,49 @@ at `login()` instead is a lockout — `actorFromUser` degrades only a user who H
|
|
|
360
408
|
package ships no enrolment route to send the rest to. Gate it in your own sign-in handler —
|
|
361
409
|
`if (user.mfaSecret === null)` send them to `enrolTotp` before you call `createSession`.
|
|
362
410
|
|
|
363
|
-
**The second leg of login is
|
|
364
|
-
throw `X_MFA_REQUIRED` before any session exists
|
|
365
|
-
`
|
|
366
|
-
|
|
367
|
-
|
|
411
|
+
**The second leg of login is `completeMfa`, `As of 2026-10`.** `login()` and
|
|
412
|
+
`completeOAuthLogin()` throw `X_MFA_REQUIRED` before any session exists, and the error's
|
|
413
|
+
`meta.challenge` is what carries the proven first leg to the second: a value sealed under the
|
|
414
|
+
app's master key (`x secrets init`; `X_SEAL_KEY_MISSING` without one), naming the user, good for
|
|
415
|
+
`MFA_CHALLENGE_TTL_MS` (five minutes) on the server's clock. It is `meta`, never `cause` — the
|
|
416
|
+
problem document publishes `cause` — so the sign-in handler decides how it travels.
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
import { type Auth, completeMfa, login } from '@ultimat3/auth';
|
|
420
|
+
import { isUltimateError } from '@ultimat3/core';
|
|
421
|
+
|
|
422
|
+
declare const auth: Auth;
|
|
423
|
+
|
|
424
|
+
export async function signIn(email: string, password: string, ip: string) {
|
|
425
|
+
try {
|
|
426
|
+
return { session: await login(auth, { email, password, ip }) };
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if (!isUltimateError(error) || error.code !== 'X_MFA_REQUIRED') throw error;
|
|
429
|
+
return { challenge: String(error.meta?.['challenge']) }; // a cookie, or your own body
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export const secondFactor = (challenge: string, code: string, ip: string) =>
|
|
434
|
+
completeMfa(auth, challenge, code, { ip }); // what `login()` answers: actor, session, cookie
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
| `completeMfa` is given | Answer |
|
|
438
|
+
|---|---|
|
|
439
|
+
| a live challenge and the current code, or an unused recovery code | `{ actor, session, token, cookie }`, `mfaSatisfied: true` |
|
|
440
|
+
| a wrong code, or one already spent | `X_UNAUTHENTICATED`; the guess stays counted |
|
|
441
|
+
| a value this app did not seal, an expired one, or one for an account now disabled or un-enrolled | `X_UNAUTHENTICATED`, one refusal for all of them |
|
|
442
|
+
| a guess past `rateLimit.maxAttempts` | `X_ACCOUNT_LOCKED` — the same account, address and tenant buckets a password guess spends |
|
|
443
|
+
|
|
444
|
+
A code guess is reserved before it is compared, so six digits are worth `maxAttempts` tries per
|
|
445
|
+
window. Repeating the first leg does not buy them back: a proven password with a second factor
|
|
446
|
+
still owed clears nothing — `completeMfa` is what clears the account's window. A spent step is
|
|
447
|
+
refused through `auth.totpReplay` (`defineAuth({ totpReplay })`, in-process by default). No route
|
|
448
|
+
ships.
|
|
449
|
+
|
|
450
|
+
`code` is either the six digits or a **recovery code**: anything that is not six digits is hashed
|
|
451
|
+
and consumed through the adapter, metered exactly as a wrong digit string is, and gone after one
|
|
452
|
+
use. A stored secret that is not sealed is `X_MFA_SECRET_UNSEALED` (HTTP 500) before any guess is
|
|
453
|
+
counted — the deployment's fault, never the caller's.
|
|
368
454
|
|
|
369
455
|
## Email verification and password reset
|
|
370
456
|
|
|
@@ -377,14 +463,14 @@ import {
|
|
|
377
463
|
consumeVerification,
|
|
378
464
|
issueVerification,
|
|
379
465
|
type MailSender,
|
|
380
|
-
|
|
466
|
+
memoryAuthAdapter,
|
|
381
467
|
type VerificationRuntime,
|
|
382
468
|
} from '@ultimat3/auth';
|
|
383
469
|
import { systemClock } from '@ultimat3/core';
|
|
384
470
|
|
|
385
471
|
declare const mail: MailSender; // the app wires @ultimat3/mail's `send` here
|
|
386
472
|
|
|
387
|
-
const runtime: VerificationRuntime = { store:
|
|
473
|
+
const runtime: VerificationRuntime = { store: memoryAuthAdapter(), clock: systemClock, mail };
|
|
388
474
|
|
|
389
475
|
const issued = await issueVerification(runtime, {
|
|
390
476
|
purpose: 'password-reset',
|
|
@@ -422,9 +508,15 @@ unauthenticated POST with any token cannot kill the victim's live link.
|
|
|
422
508
|
| `revokeUserSessions(auth, userId, reason)` | one person, their current session included |
|
|
423
509
|
| `revokeOrgSessions(auth, orgId, reason)` | one tenant, at 03:00, without touching another |
|
|
424
510
|
| `revokeSessionsCreatedBefore(auth, at, reason)` | everything minted under a rotated secret |
|
|
425
|
-
| `disableUser(auth, userId, reason)` | stamps `disabledAt
|
|
426
|
-
| `listOrgUsers(auth, orgId, { role })` | the quarterly access review, as safe summaries |
|
|
427
|
-
| `updatePrivileges(auth, userId, patch, session?)` | the grant, plus the session rotation it requires |
|
|
511
|
+
| `disableUser(auth, userId, reason)` | stamps `disabledAt`, kills the sessions **and** revokes the user's live api keys — `{ user, sessionsRevoked, apiKeysRevoked }`. Re-enabling restores neither |
|
|
512
|
+
| `listOrgUsers(auth, orgId, { role })` | the quarterly access review, as safe summaries — in byte (code-point) order of `email` on both adapters, whatever the database collation |
|
|
513
|
+
| `updatePrivileges(auth, userId, patch, session?)` | the grant, plus the session rotation it requires. A `passwordHash` change ends every OTHER session of that user (`sessionsRevoked`) — all of them when no session of theirs was passed, as in a reset |
|
|
514
|
+
|
|
515
|
+
Rotation (`rotateSession`, and `updatePrivileges` through it) mints a new **id**, never a new
|
|
516
|
+
lifetime: `createdAt` and `absoluteExpiresAt` carry over, so repeated grants cannot outlive the
|
|
517
|
+
original ceiling and `revokeSessionsCreatedBefore` still catches a rotated session. The rotated
|
|
518
|
+
cookie's `Max-Age` counts down to that ceiling. `createSession` accepts the same two instants and
|
|
519
|
+
clamps them — `createdAt` to now, the ceiling to `createdAt + absoluteTtlMs` — so they only shorten.
|
|
428
520
|
|
|
429
521
|
`reason` is a required argument on every revocation, for the reason `crossTenant()` requires one:
|
|
430
522
|
an incident review asks who killed these sessions and why, and a `delete` with no line answers
|
|
@@ -444,8 +536,8 @@ an adapter implementation, not a dependency of this package.
|
|
|
444
536
|
|
|
445
537
|
| Driver | Use |
|
|
446
538
|
|---|---|
|
|
447
|
-
| `
|
|
448
|
-
| `
|
|
539
|
+
| `postgresAuthAdapter(client?, clock?)` | Postgres via `@ultimat3/db` (the `PostgresAuthAdapter` type) — the process client and `systemClock` by default; the clock stamps `x_verifications.consumed_at` |
|
|
540
|
+
| `memoryAuthAdapter(clock?)` | `x new` before a database exists, and every test in this package (the `MemoryAuthAdapter` type). Its clock — `systemClock` by default — stamps every instant it writes |
|
|
449
541
|
| your own | implement `AuthAdapter`; DDL in `tables.ts` shows what the columns mean |
|
|
450
542
|
|
|
451
543
|
**An adapter stores and matches the address it is handed — it never folds case.** `x_users.email`
|
|
@@ -454,10 +546,8 @@ would not. Normalisation happens once, above the seam, in `normaliseEmail` (`ema
|
|
|
454
546
|
lowercase, nothing else. Call it before `findUserByEmail`/`createUser` in any login route of your
|
|
455
547
|
own, and key any bucket of your own with it — `accountKey` does.
|
|
456
548
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
still satisfies the interface. Calling one an adapter has not implemented is `X_NOT_IMPLEMENTED`
|
|
460
|
-
with the method named, not a silent no-op.
|
|
549
|
+
Every member of the seam is required: an adapter missing one is a compile error, never a runtime
|
|
550
|
+
refusal.
|
|
461
551
|
|
|
462
552
|
`x_users` gained two columns in 1.3.0 — `scopes text[]` and `external_id text unique` — plus an
|
|
463
553
|
`org_id` index.
|
|
@@ -490,7 +580,10 @@ row would not be, and nothing does.
|
|
|
490
580
|
|
|
491
581
|
## Cookie
|
|
492
582
|
|
|
493
|
-
`__Host-x_session`, set by `sessionCookie(token, policy)`.
|
|
583
|
+
`__Host-x_session`, set by `sessionCookie(token, policy)`. A **rotated** session keeps its
|
|
584
|
+
original ceiling, so its cookie takes the time left, not the full TTL:
|
|
585
|
+
`sessionCookie(issued.token, policy, { maxAgeSeconds: remainingMaxAgeSeconds(issued.session, clock.now()) })`
|
|
586
|
+
— what `updatePrivileges` already does for you.
|
|
494
587
|
|
|
495
588
|
| Attribute | Attack it closes |
|
|
496
589
|
|---|---|
|
|
@@ -500,6 +593,13 @@ row would not be, and nothing does.
|
|
|
500
593
|
| `__Host-` + `Path=/` + no `Domain` | a sibling subdomain overwriting it (session fixation) |
|
|
501
594
|
| `Max-Age` | a client keeping it past the server's absolute ceiling |
|
|
502
595
|
|
|
596
|
+
Every cookie this package writes — the session, its clearing line, the OAuth handshake and its
|
|
597
|
+
clearing line — is built by `@ultimat3/core`'s `serializeSetCookie`, never spelled by hand
|
|
598
|
+
(`bun run set-cookie-literals`). `As of 2026-10-06` the attributes are written `Max-Age=…; Path=/`
|
|
599
|
+
rather than `Path=/; Max-Age=…` — the same cookie to a browser, which gives attribute order no
|
|
600
|
+
meaning — and a cookie `name` that is not an RFC 6265 token is `X_COOKIE_INVALID` instead of a
|
|
601
|
+
line a browser would mangle.
|
|
602
|
+
|
|
503
603
|
## Signing out
|
|
504
604
|
|
|
505
605
|
`logout(auth, token)` ends the session row. The **response** does the rest: append
|
|
@@ -536,49 +636,64 @@ the previous scope's persisted rows and queued writes).
|
|
|
536
636
|
`beginOAuth`, the handshake cookie and `completeOAuthLogin`. Provider configs stay pure data —
|
|
537
637
|
importing `oauth.ts` performs no network I/O and reads no env.
|
|
538
638
|
|
|
639
|
+
Mount both in the app's own server through **`runtime.routes`**: `apps/<app>/runtime.ts`
|
|
640
|
+
exporting `runtime` (`RuntimeOverrides`). `x dev` and `runRole` both read that file and mount its
|
|
641
|
+
routes through the whole pipeline — `configureAuthenticator`, the rate limiter, the security
|
|
642
|
+
headers, the SIGTERM drain. Never a second `Bun.serve` beside the app: it stands on its own
|
|
643
|
+
socket, outside every one of those, and a login flow is the last surface that should run
|
|
644
|
+
unthrottled and unheadered.
|
|
645
|
+
|
|
539
646
|
```ts
|
|
647
|
+
// apps/web/runtime.ts
|
|
648
|
+
import {
|
|
649
|
+
type AuthRouteDescriptor,
|
|
650
|
+
defineAuth,
|
|
651
|
+
oauthLogin,
|
|
652
|
+
postgresAuthAdapter,
|
|
653
|
+
} from '@ultimat3/auth';
|
|
654
|
+
import type { ServeOptions } from '@ultimat3/cli/serve';
|
|
655
|
+
import type { Route } from '@ultimat3/http';
|
|
656
|
+
|
|
657
|
+
// The app's one `auth` — imported from wherever it declares it.
|
|
658
|
+
const auth = defineAuth({ adapter: postgresAuthAdapter(), providers: ['github'] });
|
|
540
659
|
const { start, callback } = oauthLogin(auth);
|
|
541
660
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
},
|
|
661
|
+
// A descriptor takes a bare `Request`: the pipeline's `UltimateRequest` carries it as `raw`.
|
|
662
|
+
const mounted = (leg: AuthRouteDescriptor): Route => ({
|
|
663
|
+
method: leg.method,
|
|
664
|
+
path: leg.path,
|
|
665
|
+
handler: (request) => leg.handle(request.raw),
|
|
666
|
+
meta: { name: leg.name, auth: leg.auth },
|
|
549
667
|
});
|
|
668
|
+
|
|
669
|
+
export const runtime: NonNullable<ServeOptions['runtime']> = {
|
|
670
|
+
routes: [start, callback].map(mounted),
|
|
671
|
+
};
|
|
550
672
|
```
|
|
551
673
|
|
|
552
674
|
| | `start` | `callback` |
|
|
553
675
|
|---|---|---|
|
|
554
676
|
| path | `/auth/oauth/:provider` | `/auth/oauth/:provider/callback` |
|
|
555
677
|
| success | `302` to the provider, `Set-Cookie: __Host-x_oauth_<provider>` | `303` to `successPath`, `Set-Cookie: __Host-x_session` **and** the handshake cleared |
|
|
556
|
-
| failure |
|
|
678
|
+
| failure | coded JSON, status per code: `code` + `title` + `docs`, one fixed `cause`, `fix: x errors explain <CODE> --json` | the same, handshake cleared either way |
|
|
557
679
|
|
|
558
680
|
A **descriptor**, never a mounted handler — the same category as `mcpHttpRoute()`. `@ultimat3/http`
|
|
559
681
|
is tier 2 like this package, so auth may not import it, and `defineRoute` is tier 4 and describes
|
|
560
682
|
a rendered page. A bare `Request` in, a `Response` out: drivable from a test, mountable by any
|
|
561
|
-
router that can match a `:param
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
(`apps/web/server.ts` is three lines that call it), and `As of 2026-08` `ServeOptions` has no
|
|
565
|
-
routes seam — the route list is built inside `serveApp` and closed. So a second `Bun.serve` in an
|
|
566
|
-
app does not extend that server, it stands beside it: on its own socket, outside the pipeline, and
|
|
567
|
-
therefore outside `configureAuthenticator`, the rate limiter, the security headers and the
|
|
568
|
-
SIGTERM drain. A login flow is the last surface that should be the one running unthrottled and
|
|
569
|
-
unheadered.
|
|
570
|
-
|
|
571
|
-
Until the seam exists, an app serving these descriptors serves them itself and pays for all of
|
|
572
|
-
that itself — a second port to publish and health-check, its own throttle in front of `callback`,
|
|
573
|
-
its own security headers, and a drain that does not strand a handshake mid-flight. There is no
|
|
574
|
-
mounting API to call today; do not write one, and do not read this section as promising one.
|
|
683
|
+
router that can match a `:param` — and `runtime.routes` is the one an app has. It mounts after the
|
|
684
|
+
framework's own routes and before the pages; every field it shares the file with is in
|
|
685
|
+
[`wiki/Configuration.md`](../../wiki/Configuration.md#runtime-overrides--appsappruntimets).
|
|
575
686
|
|
|
576
687
|
**The paths are not configurable.** `X_OAUTH_STATE_INVALID` has always told the caller to restart
|
|
577
688
|
at `GET /auth/oauth/<provider>`; it now quotes `oauthStartPath()`, the same declaration the mount
|
|
578
689
|
reads. A movable base path is that sentence going stale again.
|
|
579
690
|
|
|
580
|
-
|
|
581
|
-
|
|
691
|
+
`oauthLogin` needs an origin to build the provider's `redirect_uri` from: `baseUrl`, or `APP_URL`.
|
|
692
|
+
With neither, the start leg refuses with `X_ENV_MISSING` rather than trust a request header.
|
|
693
|
+
|
|
694
|
+
**Failure is JSON, not a redirect carrying `?error=`.** The body is the same for every refusal of
|
|
695
|
+
one code; the authored cause and fix are the `auth.oauth.refused` log line, where only an operator
|
|
696
|
+
reads them. The callback is the one request whose failure a developer must read, and there is no `?next=` on the success hop either: an
|
|
582
697
|
attacker-supplied return target on the endpoint that hands out a session is the classic open
|
|
583
698
|
redirect, and `nextAfterSignIn` in `@ultimat3/http` is the one implementation of that check.
|
|
584
699
|
|
|
@@ -649,6 +764,9 @@ JWT signed with the `.p8` key, which Apple expires every six months.
|
|
|
649
764
|
the status alone there mints a session from a failed exchange.
|
|
650
765
|
- An address is only linked to an existing account when **both** sides verified it (`link:
|
|
651
766
|
'verified-email'`). Otherwise whoever registered the address first inherits the login.
|
|
767
|
+
- An address the provider did **not** verify never creates an account either — it is the same
|
|
768
|
+
`loginFailed()` the taken-address case answers. Minting one is a pre-hijack: the owner's
|
|
769
|
+
`register()` then collides and their verified login is refused, with the attacker still linked.
|
|
652
770
|
|
|
653
771
|
## API keys — how an agent authenticates
|
|
654
772
|
|
|
@@ -656,12 +774,44 @@ JWT signed with the `.p8` key, which Apple expires every six months.
|
|
|
656
774
|
looked up by the non-secret id.
|
|
657
775
|
|
|
658
776
|
```ts
|
|
777
|
+
import { type Auth, apiKeyActor, issueApiKey, verifyApiKey } from '@ultimat3/auth';
|
|
778
|
+
|
|
779
|
+
declare const auth: Auth;
|
|
780
|
+
declare const orgId: string;
|
|
781
|
+
|
|
659
782
|
const { plaintext, record } = issueApiKey({ env: 'prod', scopes: ['post:publish'], orgId });
|
|
660
783
|
await auth.adapter.putApiKey(record);
|
|
661
|
-
const actor = apiKeyActor(await verifyApiKey(auth.adapter, plaintext)); // kind: 'agent'
|
|
784
|
+
export const actor = apiKeyActor(await verifyApiKey(auth.adapter, plaintext)); // kind: 'agent'
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
`verifyApiKey` answers `{ record, owner }`. A key is its owner's credential, `As of 2026-10`:
|
|
788
|
+
|
|
789
|
+
| The key | What verification does |
|
|
790
|
+
|---|---|
|
|
791
|
+
| names no `userId` | nothing more — a key no user owns is cut by its scopes and its org alone |
|
|
792
|
+
| names a `userId` | re-reads that user from the store. Missing or disabled is the same `X_API_KEY_INVALID` every other rejection is, on the very next use; `disableUser` also revokes the key outright |
|
|
793
|
+
|
|
794
|
+
A key's scopes only ever shrink on the way to the actor — never the owning user's roles, never a
|
|
795
|
+
default set:
|
|
796
|
+
|
|
797
|
+
- `*` and `<resource>:*` are refused at `issueApiKey` (`X_CONFIG_INVALID`), and dropped from a row
|
|
798
|
+
that already holds one.
|
|
799
|
+
- A key a user owns keeps only the scopes that owner's grants cover. This package can read the
|
|
800
|
+
row's `permissions` and `scopes` (`directGrants`) and cannot expand a role — that is
|
|
801
|
+
`@ultimat3/policy`'s — so an app whose users hold roles says what they grant:
|
|
802
|
+
|
|
803
|
+
```ts
|
|
804
|
+
import { type ApiKeyVerifyStore, apiKeyResolver, directGrants } from '@ultimat3/auth';
|
|
805
|
+
import { expandRoles } from '@ultimat3/policy';
|
|
806
|
+
|
|
807
|
+
declare const keys: ApiKeyVerifyStore;
|
|
808
|
+
|
|
809
|
+
export const resolveKey = apiKeyResolver(() => keys, {
|
|
810
|
+
grantsOf: (owner) => [...directGrants(owner), ...expandRoles(owner.roles)],
|
|
811
|
+
});
|
|
662
812
|
```
|
|
663
813
|
|
|
664
|
-
|
|
814
|
+
Without `grantsOf`, a key owned by a user who holds only roles resolves with no scopes at all.
|
|
665
815
|
|
|
666
816
|
### The resolver a mount takes
|
|
667
817
|
|
|
@@ -670,27 +820,28 @@ An api key's scopes become **exactly** the agent actor's scopes — never the ow
|
|
|
670
820
|
for both doors.
|
|
671
821
|
|
|
672
822
|
```ts
|
|
673
|
-
import type {
|
|
674
|
-
import { apiKeyResolver,
|
|
823
|
+
import type { ApiKeyVerifyStore } from '@ultimat3/auth';
|
|
824
|
+
import { apiKeyResolver, postgresAuthAdapter } from '@ultimat3/auth';
|
|
675
825
|
|
|
676
|
-
let keys:
|
|
826
|
+
let keys: ApiKeyVerifyStore | undefined;
|
|
677
827
|
|
|
678
828
|
/** `resolveToken: resolveKey` — on the mount, and on `defineAppMcp()`. */
|
|
679
829
|
export const resolveKey = apiKeyResolver(() => {
|
|
680
|
-
keys ??=
|
|
830
|
+
keys ??= postgresAuthAdapter();
|
|
681
831
|
return keys;
|
|
682
832
|
});
|
|
683
833
|
```
|
|
684
834
|
|
|
685
835
|
| Presented | Answer |
|
|
686
836
|
|---|---|
|
|
687
|
-
| a live key | `{ actor, scopes }` — the agent actor for the key's org, and
|
|
688
|
-
| malformed, unknown, wrong secret, revoked, expired | `null`, all
|
|
837
|
+
| a live key | `{ actor, scopes }` — the agent actor for the key's org, and the scopes that actor carries as a `Set` |
|
|
838
|
+
| malformed, unknown, wrong secret, revoked, expired, or owned by a user who is gone or disabled | `null`, all alike: the mount answers one indistinguishable 401 |
|
|
689
839
|
| a store that FAILS | the throw, untouched — a database that is down is not a wrong key |
|
|
690
840
|
|
|
691
841
|
The store is a THUNK, read when a token is presented: a mount is declared when its module is
|
|
692
|
-
evaluated, and `
|
|
693
|
-
later. In a test the store is `
|
|
842
|
+
evaluated, and `postgresAuthAdapter()` takes the process's database client, which boot installs
|
|
843
|
+
later. In a test the store is `memoryAuthAdapter()` — every `AuthAdapter` is an
|
|
844
|
+
`ApiKeyVerifyStore` (an `ApiKeyStore` that can also `findUserById`).
|
|
694
845
|
|
|
695
846
|
## Errors
|
|
696
847
|
|
|
@@ -699,6 +850,7 @@ later. In a test the store is `new MemoryAdapter()` — every `AuthAdapter` is a
|
|
|
699
850
|
| `X_UNAUTHENTICATED` | no actor, unknown session, or any failed credential path |
|
|
700
851
|
| `X_SESSION_EXPIRED` | idle or absolute expiry, named in `cause` |
|
|
701
852
|
| `X_MFA_REQUIRED` | password proven, second factor outstanding |
|
|
853
|
+
| `X_MFA_SECRET_UNSEALED` | a stored `mfa_secret` is not sealed; it is refused, never read. `x auth seal-mfa --json` |
|
|
702
854
|
| `X_OAUTH_STATE_INVALID` | state, nonce or PKCE verifier did not match |
|
|
703
855
|
| `X_OAUTH_EXCHANGE_FAILED` | the provider refused the exchange, or returned no usable identity |
|
|
704
856
|
| `X_OAUTH_TOKEN_INVALID` | the id token failed its signature, issuer, audience or expiry check, or no key in the published set matched its `kid` |
|
|
@@ -706,10 +858,10 @@ later. In a test the store is `new MemoryAdapter()` — every `AuthAdapter` is a
|
|
|
706
858
|
| `X_OAUTH_PROVIDER_DUPLICATE` | two `registerOAuthProvider` calls claimed one id — at boot, never at a login |
|
|
707
859
|
| `X_OAUTH_DENIED` | the user pressed Cancel, or the provider declined — `403`, never a `502` |
|
|
708
860
|
| `X_PASSWORD_WEAK` | strength check rejected the password |
|
|
709
|
-
| `X_ACCOUNT_LOCKED` | the per-ip, per-account or per-org bucket is inside its lockout |
|
|
710
|
-
| `X_API_KEY_INVALID` | key unknown, revoked, expired or
|
|
861
|
+
| `X_ACCOUNT_LOCKED` | the per-ip, per-account or per-org bucket is inside its lockout. The cause names the caller's own address or email, never an org |
|
|
862
|
+
| `X_API_KEY_INVALID` | key unknown, revoked, expired, wrong, or owned by a user who is gone or disabled |
|
|
711
863
|
| `X_ENV_MISSING` | `oauthCredentials()` found no client id or secret for an enabled provider |
|
|
712
|
-
| `
|
|
864
|
+
| `X_AUTH_WRITE_FAILED` | an `AuthAdapter` lost a write it accepted — `emailVerifiedNotStored(provider, userId)` when `updateUser` drops the OAuth verified stamp |
|
|
713
865
|
|
|
714
866
|
```bash
|
|
715
867
|
bun test packages/auth
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/auth",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.0.0",
|
|
4
4
|
"description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,15 +25,15 @@
|
|
|
25
25
|
"LICENSE"
|
|
26
26
|
],
|
|
27
27
|
"engines": {
|
|
28
|
-
"bun": ">=1.4.
|
|
28
|
+
"bun": ">=1.4.2"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/db": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
35
|
+
"@ultimat3/core": "25.0.0",
|
|
36
|
+
"@ultimat3/db": "25.0.0",
|
|
37
|
+
"@ultimat3/schema": "25.0.0"
|
|
38
38
|
}
|
|
39
39
|
}
|