@ultimat3/auth 22.15.0 → 24.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 +79 -36
- package/README.md +178 -52
- package/package.json +5 -4
- package/src/adapter.ts +43 -16
- package/src/api-key-resolver.ts +55 -0
- package/src/api-keys.ts +59 -12
- package/src/auth.ts +48 -45
- package/src/builtin-adapter-violation.ts +43 -0
- package/src/builtin-adapter.ts +61 -6
- package/src/client-scope.ts +8 -2
- package/src/directory.ts +2 -13
- package/src/errors.ts +77 -45
- package/src/index.ts +48 -20
- package/src/jwks.ts +11 -0
- package/src/login-attempt.ts +70 -0
- package/src/memory-adapter.ts +41 -0
- package/src/mfa-challenge.ts +151 -0
- package/src/mfa-secret.ts +91 -0
- package/src/mfa.ts +9 -23
- package/src/oauth-builtins.ts +6 -0
- package/src/oauth-cookie.ts +8 -4
- package/src/oauth-discovery.ts +24 -5
- package/src/oauth-errors.ts +4 -84
- package/src/oauth-exchange.ts +13 -7
- package/src/oauth-login.ts +50 -9
- package/src/oauth-registry.ts +16 -1
- package/src/oauth-route.ts +75 -22
- package/src/policy-bridge.ts +51 -8
- package/src/privileges.ts +28 -16
- package/src/rate-limit-postgres.ts +113 -101
- package/src/rate-limit.ts +47 -13
- package/src/revocation.ts +25 -25
- package/src/session.ts +4 -30
- package/src/tables.ts +7 -9
- package/src/verify.ts +2 -2
- package/src/auth-fixture.ts +0 -26
- package/src/id-token-fixture.ts +0 -20
- package/src/oauth-login-fixture.ts +0 -53
package/CLAUDE.md
CHANGED
|
@@ -5,7 +5,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
5
5
|
| Rule | |
|
|
6
6
|
|---|---|
|
|
7
7
|
| Deps | `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/db`. No external deps. |
|
|
8
|
-
| Never import | `@ultimat3/policy`, `@ultimat3/http` (tier 2 consumers), `@ultimat3/mail` (
|
|
8
|
+
| Never import | `@ultimat3/policy`, `@ultimat3/http` (tier 2 consumers), `@ultimat3/mail` (tier 4, upward) |
|
|
9
9
|
| Policy seam | `PolicyActorFields` in `policy-bridge.ts` mirrors policy's shape structurally |
|
|
10
10
|
| Http seam | `RequestLike` / `CookieJar` in `session.ts` and `AuthRouteDescriptor` in `oauth-route.ts`; http binds to them, not the reverse |
|
|
11
11
|
| Mail seam | injected `MailSender` port in `verify.ts`; the app wires `@ultimat3/mail`'s `send` |
|
|
@@ -41,16 +41,26 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
41
41
|
- **The limiter's table is bounded, and the eviction order is the guarantee.** Every bucket carries
|
|
42
42
|
`forgetAtMs`; `policy.maxKeys` (`DEFAULT_MAX_AUTH_LIMIT_KEYS`) is the backstop, and a **live
|
|
43
43
|
lockout outranks its own deadline** in the comparator. Never reduce that sort to recency.
|
|
44
|
-
- **`AuthLimiter` is async on every member
|
|
45
|
-
`
|
|
46
|
-
|
|
47
|
-
`
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
`
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
44
|
+
- **`AuthLimiter` is async on every member, declares the policy it enforces, and is a
|
|
45
|
+
RESERVATION.** `reserve(key)` counts the attempt in the step that admits it (refused, counting
|
|
46
|
+
nothing, while locked); `refund(reservation)` gives ONE back and lifts a lockout the window no
|
|
47
|
+
longer justifies; `recordSuccess(key)` clears the key. There is no check-then-record pair: N
|
|
48
|
+
concurrent guesses all passed the check. `assertAuthLimiterPolicy` (once, in `defineAuth`)
|
|
49
|
+
refuses a per-process limiter under `scope: 'shared'` (`X_AUTH_LIMITER_NOT_SHARED`) and different
|
|
50
|
+
`maxAttempts`/`windowMs`/`lockoutMs` (`X_AUTH_LIMITER_POLICY_MISMATCH`). `maxKeys` is not
|
|
51
|
+
compared. Nothing here reads the environment to guess a replica count.
|
|
52
|
+
- **Every credential check runs through `openLoginAttempt`** (`login-attempt.ts`): account → ip →
|
|
53
|
+
org reserved in that fixed order BEFORE the KDF or the TOTP compare. A failure calls nothing (the
|
|
54
|
+
reservations are the record); `release()` for no verdict (a shed KDF, a store that is down, a
|
|
55
|
+
bucket that refused) or a factor proven with another still owed; `succeed()` clears the account
|
|
56
|
+
and refunds the shared two.
|
|
57
|
+
- **`postgresAuthLimiter` is the shared limiter: ONE row per key in `x_auth_lockouts`, holding the
|
|
58
|
+
instants still inside the window** (`attempts_ms bigint[]` — a sliding window). The take is one
|
|
59
|
+
`insert … on conflict do update` reading the window off `x_auth_lockouts.<column>`, never a CTE
|
|
60
|
+
and never a second table: only the row the statement locks is re-read after a wait. `admitted`
|
|
61
|
+
is a column because `returning` cannot see the old row; no row back is never an admission. A
|
|
62
|
+
live lockout is never rewritten. `PgExecutor` (`@ultimat3/core`'s, re-exported) is structural:
|
|
63
|
+
the pool is the host's.
|
|
54
64
|
- **`configureAuthLimiters` is the HOST's install point and takes a FACTORY**, called with the
|
|
55
65
|
RESOLVED policy once per bucket; the comparison still runs on what comes back. Precedence:
|
|
56
66
|
`config.limiter` → the installed factory → `createAuthLimiter`. `installedAuthLimiter` and
|
|
@@ -90,22 +100,27 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
90
100
|
- `takeVerification(purpose, identifier, tokenHash)` consumes **only on a hash match**, in one
|
|
91
101
|
conditional statement (`consumed_at is null` on the UPDATE and its subselect,
|
|
92
102
|
`order by created_at desc limit 1`); `consumeVerification` still compares in constant time.
|
|
103
|
+
`consumed_at` is the adapter's `Clock`, bound, never `now()`.
|
|
93
104
|
- **Foreign text in a `cause:` goes through `renderCauseValue`, in a `fix:` through
|
|
94
105
|
`renderFixLiteral`, and in a shell `fix:` through `renderFixShellArg`.** Foreign here:
|
|
95
|
-
`providerDetail()`'s return, `claims.iss`, `accountLocked`'s `key
|
|
106
|
+
`providerDetail()`'s return, `claims.iss`, `accountLocked`'s `key` (an `org:` key is never
|
|
107
|
+
rendered at all — it was looked up from the caller's address), and every OAuth endpoint URL
|
|
96
108
|
in a command position (`jwks.ts`, `oauth-profile.ts`, `oauth-exchange.ts`,
|
|
97
109
|
`oauth-discovery.ts`) — the registry is filled by an issuer's own discovery document. A LINE
|
|
98
110
|
that would not run degrades to prose: `jwks.ts`'s `readTheKeySet(tail)` decides with core's
|
|
99
111
|
`isFixShellSafe`. `oauth-discovery.ts` still owes that repair (it keeps `auth` on
|
|
100
112
|
`FIX_SHELL_ARG_PINS`). `${provider}` is registry-validated boot config, not foreign.
|
|
101
|
-
-
|
|
113
|
+
- Cookies are read through `@ultimat3/core`'s `readCookie` (`readSessionCookie`, the oauth
|
|
114
|
+
handshake), which never throws on a malformed value (`decodeURIComponent('%')`).
|
|
102
115
|
- A token endpoint's HTTP 200 is not success — read `error`.
|
|
103
116
|
- Link by address only when the provider **and** the local account both verified it
|
|
104
117
|
(`link: 'verified-email'`, default; `'never'` is the only other value).
|
|
105
118
|
- **The OAuth route paths are not configurable.** `oauth-paths.ts` imports nothing and is read by
|
|
106
119
|
both `oauth-errors.ts` and `oauth-route.ts`; every "start over" fix is `restartAt(provider)`.
|
|
107
120
|
- The routes are **descriptors** (`AuthRouteDescriptor`), never mounted handlers.
|
|
108
|
-
- The callback answers failure as **coded JSON
|
|
121
|
+
- The callback answers failure as **coded JSON** — a fixed body per code (`code`, `title`, `docs`,
|
|
122
|
+
one `cause`, `fix: x errors explain <CODE> --json`); the authored cause and fix are the
|
|
123
|
+
`auth.oauth.refused` log line — and success redirects to a fixed `successPath` —
|
|
109
124
|
never `?next=` (`nextAfterSignIn` in `@ultimat3/http` is the one open-redirect check).
|
|
110
125
|
- The handshake cookie is cleared on **every** callback outcome.
|
|
111
126
|
- Refresh is **not implemented**, so the framework **stores no provider token**: `accountFor`
|
|
@@ -118,35 +133,58 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
118
133
|
an uncoded adapter throw logs `auth.oauth.uncoded_failure`, a rejecting `OAuthFetch` logs
|
|
119
134
|
`auth.oauth.token_fetch_failed`, and the `POST /token` body is read as
|
|
120
135
|
`providerDetail(response, 'coded-only')` (that request carries `client_secret`).
|
|
121
|
-
- **`x_users.mfa_secret` is
|
|
122
|
-
|
|
136
|
+
- **`x_users.mfa_secret` is SEALED** (`mfa-secret.ts`, purpose `auth:x_users.mfa_secret`):
|
|
137
|
+
`saveTotpSecret` writes, `openTotpSecret` reads and REFUSES a value core's `isSealed` does not
|
|
138
|
+
recognise (`X_MFA_SECRET_UNSEALED`, fix `x auth seal-mfa --json`). No plaintext fallback, ever —
|
|
139
|
+
`sealMfaSecrets` is the one-shot — each write a compare-and-set (`replaceMfaSecret`), a row that
|
|
140
|
+
changed underneath is `skipped` — and `x doctor` counts what is left. Whether a value is sealed is
|
|
141
|
+
`isSealed`'s to say, never a `like` in an adapter (`listUsersWithMfaSecret` returns them all).
|
|
123
142
|
- **`providerJwks` memoises only the DEFAULT client**; a caller supplying options gets its own.
|
|
124
143
|
- **A JWKS refresh is single-flighted through core's `createSingleFlight` with
|
|
125
144
|
`deadlineMs = timeoutMs * 2`**; eviction frees the key, never the work, and a `createFence`
|
|
126
145
|
generation check (read, never `guard`) stops a superseded refresh overwriting the cache.
|
|
127
146
|
`schedule` is injectable.
|
|
128
147
|
- **A success clears the ACCOUNT bucket and nothing else** — clearing the address bucket made the
|
|
129
|
-
limiter inert against stuffing.
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
limiter inert against stuffing. A password proven with a second factor still owed clears
|
|
149
|
+
NOTHING: `completeMfa` is what clears, or a repeat of the first leg wipes the wrong codes.
|
|
150
|
+
- **`MemoryAdapter.createUser` and `updateUser` enforce `x_users.id`, `email` and `external_id`
|
|
151
|
+
uniqueness** (`authUniqueViolation`, `X_AUTH_WRITE_FAILED`), NULLS DISTINCT like Postgres;
|
|
152
|
+
`BuiltinAdapter` answers the same code through `builtin-adapter-violation.ts`.
|
|
132
153
|
**`MemoryAdapter` takes a `Clock`** (default `systemClock`) and stamps every instant from it.
|
|
133
154
|
`adapter-parity.test.ts` pins both.
|
|
134
|
-
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
155
|
+
- Every `AuthAdapter` member is REQUIRED — a missing one is a compile error at `defineAuth`, and
|
|
156
|
+
no caller in this package refuses at runtime with `X_NOT_IMPLEMENTED`.
|
|
157
|
+
- **An api key is its owner's credential.** `verifyApiKey` re-reads the owner (after the secret
|
|
158
|
+
matched, never before) and refuses a missing or disabled one as the same `X_API_KEY_INVALID`; a
|
|
159
|
+
key with `userId === null` has no owner and is unchanged. `disableUser` revokes the user's live
|
|
160
|
+
keys beside their sessions, and logs before its first write.
|
|
161
|
+
- **A key's scopes only ever shrink** (`apiKeyScopes`, `policy-bridge.ts`): `*` and `<res>:*` are
|
|
162
|
+
refused at `issueApiKey` (`X_CONFIG_INVALID`) and dropped from a stored row; an OWNED key keeps
|
|
163
|
+
only what its owner's grants cover. Roles are policy's to expand, so the default grants are the
|
|
164
|
+
row's `permissions` + `scopes` (`directGrants`) and a role-based app passes
|
|
165
|
+
`apiKeyResolver(store, { grantsOf })`. Never union a key with the owner's roles.
|
|
166
|
+
- **`apiKeyResolver(() => store)` is the ONE token → caller mapping** (`api-key-resolver.ts`):
|
|
167
|
+
`verifyApiKey` → `apiKeyActor`, `X_API_KEY_INVALID` → `null`, everything else rethrown. Its
|
|
168
|
+
`ApiKeyCaller` is structurally http's `BearerCaller` and mcp's `ResolvedToken` — declared here
|
|
169
|
+
because tier 2 imports neither. The store is a thunk (a mount is declared before boot installs
|
|
170
|
+
the database client). No second in-memory key store: `MemoryAdapter` IS an `ApiKeyStore`.
|
|
138
171
|
- Rotate the session id on any privilege change (`rotateSession`, called by `updatePrivileges` in
|
|
139
|
-
`privileges.ts`), never patch the row.
|
|
172
|
+
`privileges.ts`), never patch the row. A `passwordHash` change also ends every OTHER session of
|
|
173
|
+
that user (`deleteOtherSessions`), or all of them when the caller passed no session of theirs.
|
|
140
174
|
- **Every argon2 call goes through `kdfGate()`** — width 8, queue 64, `X_OVERLOADED` past it
|
|
141
175
|
(borrowed from http, in `AUTH_BORROWED_ERROR_CODES`). The pool is core's `createFlightGate`, the
|
|
142
176
|
refusal auth's own through core's `overflow:` seam. `configureKdfGate()` is the ONE install point
|
|
143
177
|
and deliberately not a `defineAuth` key.
|
|
144
|
-
- **MFA
|
|
145
|
-
|
|
146
|
-
`
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
`auth.
|
|
178
|
+
- **MFA's second leg is `completeMfa(auth, challenge, code)`** (`mfa-challenge.ts`). `login()`
|
|
179
|
+
ends in `X_MFA_REQUIRED` whose `meta.challenge` is a `seal()` value (purpose
|
|
180
|
+
`auth:mfa-challenge`: user id, nonce, expiry on the server's clock, `MFA_CHALLENGE_TTL_MS`) —
|
|
181
|
+
`meta`, never `cause`, and never a bare user id: a second leg keyed by one is unauthenticated by
|
|
182
|
+
construction. The code is reserved against the same three buckets before it is compared, and a
|
|
183
|
+
spent step is refused through `auth.totpReplay` (checked and remembered with no `await`
|
|
184
|
+
between). It needs the app's master key (`X_SEAL_KEY_MISSING`). No route ships: the handler
|
|
185
|
+
decides how the challenge travels. Anything that is not six digits is a RECOVERY CODE: hashed
|
|
186
|
+
(`recoveryCodeHash`) and consumed by `adapter.consumeRecoveryCode` — one conditional UPDATE, so
|
|
187
|
+
single-use is storage's guarantee. There is no read-then-write redemption helper.
|
|
150
188
|
- **`mfa.required` is the literal `false`, and `defineAuth` refuses a `true`** (`X_CONFIG_INVALID`):
|
|
151
189
|
enforcing it would lock out every un-enrolled user. `mfa.issuer` is read by
|
|
152
190
|
`enrolTotp(auth, { account })`.
|
|
@@ -168,8 +206,10 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
168
206
|
| `policy-bridge.ts` | the one funnel: identity → `Actor`, all four `ActorKind`s |
|
|
169
207
|
| `session.ts` | two expiries, rotation, revocation, device list, the cookie |
|
|
170
208
|
| `adapter.ts` | the seam; `builtin-adapter.ts` (Postgres) + `memory-adapter.ts` |
|
|
171
|
-
| `
|
|
172
|
-
| `rate-limit
|
|
209
|
+
| `builtin-adapter-violation.ts` | a Postgres unique violation on an auth table → `authUniqueViolation` |
|
|
210
|
+
| `rate-limit.ts` | per-ip, per-account and per-org buckets as reservations, lockout, scope check, `loginFailed()` |
|
|
211
|
+
| `login-attempt.ts` | one attempt's reservations across the three buckets, taken before the check and settled after it |
|
|
212
|
+
| `rate-limit-postgres.ts` | the SHARED limiter: one table, one row per key, a one-statement take, over a structural `PgExecutor` |
|
|
173
213
|
| `limiter-install.ts` | the host's one install point for that limiter — the factory, what it built, and the purge over it |
|
|
174
214
|
| `oauth.ts` | `OAuthProvider`, PKCE, `beginOAuth`, the callback gate. No I/O, no env |
|
|
175
215
|
| `oauth-builtins.ts` | the three shipped IdPs, as data. Imports only the type, so no cycle |
|
|
@@ -177,9 +217,11 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
177
217
|
| `oauth-discovery.ts` | `/.well-known/openid-configuration` → an `OAuthProvider`. One `fetch` |
|
|
178
218
|
| `jwks.ts` | `crypto.subtle` signature verification, cached by `kid`, one shared in-flight refresh. No dependency |
|
|
179
219
|
| `workload.ts` | a workload JWT (K8s SA / SPIFFE / IMDS / RFC 8693) → a `ServiceIdentity` |
|
|
180
|
-
| `revocation.ts` | per-user, per-org and before-an-instant sweeps; `disableUser` |
|
|
220
|
+
| `revocation.ts` | per-user, per-org and before-an-instant sweeps; `disableUser` (sessions and api keys) |
|
|
221
|
+
| `mfa-challenge.ts` | the sealed challenge `X_MFA_REQUIRED` carries, and `completeMfa` |
|
|
222
|
+
| `mfa-secret.ts` | the TOTP secret at rest: seal on write, open on read, the one-shot that seals older rows |
|
|
181
223
|
| `directory.ts` | `describeUser` (allow-list projection), `listOrgUsers`, external-id lookup |
|
|
182
|
-
| `privileges.ts` | `updatePrivileges` — the grant,
|
|
224
|
+
| `privileges.ts` | `updatePrivileges` — the grant, the rotation it requires, and the sessions a changed password ends |
|
|
183
225
|
| `oauth-cookie.ts` | the handshake's home between the two legs: seal, open, the cookie |
|
|
184
226
|
| `oauth-exchange.ts` | `oauthCredentials` + the one POST to the token endpoint |
|
|
185
227
|
| `id-token.ts` | id token → claims this handshake may believe |
|
|
@@ -190,7 +232,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
190
232
|
| `oauth-login-fixture.ts` | the adapter, clock and profile the three `oauth-login*` suites share. Off `index.ts` |
|
|
191
233
|
| `oauth-paths.ts` | the one declaration of where the two routes live. Imports nothing |
|
|
192
234
|
| `errors.ts` | the codes this package owns and borrows, their titles, the one `registerErrorCodes()` call, `AuthError`, and every non-OAuth factory |
|
|
193
|
-
| `oauth-errors.ts` | the OAuth
|
|
235
|
+
| `oauth-errors.ts` | the OAuth refusals more than one OAuth module raises, and `restartAt`. A refusal with one thrower lives beside it; declares no code and registers nothing |
|
|
194
236
|
| `oauth-route.ts` | `oauthLogin(auth)` — the redirect out and the callback back |
|
|
195
237
|
| `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 |
|
|
196
238
|
| `email.ts` | `normaliseEmail` — the one normalisation an address gets before it is an identity key |
|
|
@@ -210,7 +252,8 @@ Gotchas:
|
|
|
210
252
|
this package **owns**, unconditionally, and lists the borrowed two in `AUTH_BORROWED_ERROR_CODES`
|
|
211
253
|
without a title. A `hasErrorCode()` guard would suppress the `X_ERROR_CODE_DUPLICATE` that is
|
|
212
254
|
supposed to fire when two packages claim one code.
|
|
213
|
-
- Tests run against `MemoryAdapter`;
|
|
255
|
+
- Tests run against `MemoryAdapter`; the one ADAPTER test that boots a database is
|
|
256
|
+
`adapter-parity-identity.test.ts` (PGlite). The two other exceptions are
|
|
214
257
|
`postgresAuthLimiter`'s, and they are exceptions in the shape the repo already has: the
|
|
215
258
|
scripted-executor twin (`rate-limit-postgres.test.ts`) proves the protocol with no server,
|
|
216
259
|
and `rate-limit-postgres.live.test.ts` is `describe.skip` without `TEST_DATABASE_URL` — the
|
package/README.md
CHANGED
|
@@ -40,6 +40,9 @@ 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
|
|
|
@@ -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
|
|
@@ -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:
|
|
@@ -178,10 +197,10 @@ Precedence is `defineAuth({ limiter })` → the installed factory → `createAut
|
|
|
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
|
|
@@ -284,41 +304,44 @@ rather than never. A seam that returns the stored answer writes nothing.
|
|
|
284
304
|
|
|
285
305
|
## MFA — TOTP and recovery codes
|
|
286
306
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
rows, because `AuthAdapter` has no MFA member and adding one would break every third-party
|
|
290
|
-
adapter.
|
|
307
|
+
TOTP is pure functions over a secret and a clock; what is STORED goes through two writers and one
|
|
308
|
+
reader, `As of 2026-10`: the secret is sealed at rest, and a recovery code is consumed atomically.
|
|
291
309
|
|
|
292
310
|
```ts
|
|
293
311
|
import type { Auth } from '@ultimat3/auth';
|
|
294
|
-
import {
|
|
295
|
-
import { systemClock } from '@ultimat3/core';
|
|
312
|
+
import { enrolTotp, generateRecoveryCodes, saveTotpSecret } from '@ultimat3/auth';
|
|
296
313
|
|
|
297
314
|
declare const auth: Auth; // the `defineAuth` at the top — `enrolTotp` reads `auth.mfa.issuer`
|
|
315
|
+
declare const userId: string;
|
|
298
316
|
|
|
299
317
|
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
|
-
}
|
|
318
|
+
// enrolment.uri -> otpauth://… the QR code
|
|
319
|
+
await saveTotpSecret(auth, userId, enrolment.secret); // sealed, onto x_users
|
|
320
|
+
|
|
321
|
+
const recovery = generateRecoveryCodes(); // { codes, hashes } — show `codes` ONCE
|
|
322
|
+
await auth.adapter.updateUser(userId, { recoveryCodeHashes: recovery.hashes });
|
|
313
323
|
```
|
|
314
324
|
|
|
325
|
+
Checking a code at sign-in is `completeMfa` (below) — it opens the secret, meters the guess and
|
|
326
|
+
refuses a spent code; an app does not call `verifyTotp` on a stored secret itself.
|
|
327
|
+
|
|
328
|
+
**The secret at rest is sealed** under the app's master key (`purpose: 'auth:x_users.mfa_secret'`).
|
|
329
|
+
A TOTP seed is symmetric, so it cannot be a digest — and in the clear, a dump of `x_users` was
|
|
330
|
+
every enrolled user's second factor.
|
|
331
|
+
|
|
332
|
+
| | |
|
|
333
|
+
|---|---|
|
|
334
|
+
| 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`) |
|
|
335
|
+
| 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 |
|
|
336
|
+
| 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 |
|
|
337
|
+
|
|
315
338
|
| Call | Answers |
|
|
316
339
|
|---|---|
|
|
317
340
|
| `enrolTotp(auth, { account, issuer?, secret? })` | `{ secret, uri, digits, periodSeconds }` — `issuer` omitted is `auth.mfa.issuer`, `secret` omitted mints one |
|
|
318
341
|
| `verifyTotp({ secret, code, at, drift?, usedSteps? })` | `{ ok, step }`. `step` is the window the code belonged to, `null` on no match |
|
|
319
342
|
| `createTotpReplayGuard(drift?, maxSubjects?)` | the in-process `{ isUsed, remember, size }`; a fleet passes a Redis-backed pair of the same two methods |
|
|
320
343
|
| `generateRecoveryCodes(count = 10)` | `{ codes, hashes }`. `codes` is shown once and is never re-derivable |
|
|
321
|
-
| `
|
|
344
|
+
| `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
345
|
| `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
346
|
|
|
324
347
|
**A secret the decoder cannot read verifies nothing, `As of 2026-08`.** `base32Decode` answers
|
|
@@ -360,11 +383,49 @@ at `login()` instead is a lockout — `actorFromUser` degrades only a user who H
|
|
|
360
383
|
package ships no enrolment route to send the rest to. Gate it in your own sign-in handler —
|
|
361
384
|
`if (user.mfaSecret === null)` send them to `enrolTotp` before you call `createSession`.
|
|
362
385
|
|
|
363
|
-
**The second leg of login is
|
|
364
|
-
throw `X_MFA_REQUIRED` before any session exists
|
|
365
|
-
`
|
|
366
|
-
|
|
367
|
-
|
|
386
|
+
**The second leg of login is `completeMfa`, `As of 2026-10`.** `login()` and
|
|
387
|
+
`completeOAuthLogin()` throw `X_MFA_REQUIRED` before any session exists, and the error's
|
|
388
|
+
`meta.challenge` is what carries the proven first leg to the second: a value sealed under the
|
|
389
|
+
app's master key (`x secrets init`; `X_SEAL_KEY_MISSING` without one), naming the user, good for
|
|
390
|
+
`MFA_CHALLENGE_TTL_MS` (five minutes) on the server's clock. It is `meta`, never `cause` — the
|
|
391
|
+
problem document publishes `cause` — so the sign-in handler decides how it travels.
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
import { type Auth, completeMfa, login } from '@ultimat3/auth';
|
|
395
|
+
import { isUltimateError } from '@ultimat3/core';
|
|
396
|
+
|
|
397
|
+
declare const auth: Auth;
|
|
398
|
+
|
|
399
|
+
export async function signIn(email: string, password: string, ip: string) {
|
|
400
|
+
try {
|
|
401
|
+
return { session: await login(auth, { email, password, ip }) };
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (!isUltimateError(error) || error.code !== 'X_MFA_REQUIRED') throw error;
|
|
404
|
+
return { challenge: String(error.meta?.['challenge']) }; // a cookie, or your own body
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export const secondFactor = (challenge: string, code: string, ip: string) =>
|
|
409
|
+
completeMfa(auth, challenge, code, { ip }); // what `login()` answers: actor, session, cookie
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
| `completeMfa` is given | Answer |
|
|
413
|
+
|---|---|
|
|
414
|
+
| a live challenge and the current code, or an unused recovery code | `{ actor, session, token, cookie }`, `mfaSatisfied: true` |
|
|
415
|
+
| a wrong code, or one already spent | `X_UNAUTHENTICATED`; the guess stays counted |
|
|
416
|
+
| 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 |
|
|
417
|
+
| a guess past `rateLimit.maxAttempts` | `X_ACCOUNT_LOCKED` — the same account, address and tenant buckets a password guess spends |
|
|
418
|
+
|
|
419
|
+
A code guess is reserved before it is compared, so six digits are worth `maxAttempts` tries per
|
|
420
|
+
window. Repeating the first leg does not buy them back: a proven password with a second factor
|
|
421
|
+
still owed clears nothing — `completeMfa` is what clears the account's window. A spent step is
|
|
422
|
+
refused through `auth.totpReplay` (`defineAuth({ totpReplay })`, in-process by default). No route
|
|
423
|
+
ships.
|
|
424
|
+
|
|
425
|
+
`code` is either the six digits or a **recovery code**: anything that is not six digits is hashed
|
|
426
|
+
and consumed through the adapter, metered exactly as a wrong digit string is, and gone after one
|
|
427
|
+
use. A stored secret that is not sealed is `X_MFA_SECRET_UNSEALED` (HTTP 500) before any guess is
|
|
428
|
+
counted — the deployment's fault, never the caller's.
|
|
368
429
|
|
|
369
430
|
## Email verification and password reset
|
|
370
431
|
|
|
@@ -422,9 +483,9 @@ unauthenticated POST with any token cannot kill the victim's live link.
|
|
|
422
483
|
| `revokeUserSessions(auth, userId, reason)` | one person, their current session included |
|
|
423
484
|
| `revokeOrgSessions(auth, orgId, reason)` | one tenant, at 03:00, without touching another |
|
|
424
485
|
| `revokeSessionsCreatedBefore(auth, at, reason)` | everything minted under a rotated secret |
|
|
425
|
-
| `disableUser(auth, userId, reason)` | stamps `disabledAt
|
|
486
|
+
| `disableUser(auth, userId, reason)` | stamps `disabledAt`, kills the sessions **and** revokes the user's live api keys — `{ user, sessionsRevoked, apiKeysRevoked }`. Re-enabling restores neither |
|
|
426
487
|
| `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 |
|
|
488
|
+
| `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 |
|
|
428
489
|
|
|
429
490
|
`reason` is a required argument on every revocation, for the reason `crossTenant()` requires one:
|
|
430
491
|
an incident review asks who killed these sessions and why, and a `delete` with no line answers
|
|
@@ -444,7 +505,7 @@ an adapter implementation, not a dependency of this package.
|
|
|
444
505
|
|
|
445
506
|
| Driver | Use |
|
|
446
507
|
|---|---|
|
|
447
|
-
| `BuiltinAdapter` | Postgres via `@ultimat3/db
|
|
508
|
+
| `BuiltinAdapter` | Postgres via `@ultimat3/db`. `new BuiltinAdapter(client?, clock?)` — the process client and `systemClock` by default; the clock stamps `x_verifications.consumed_at` |
|
|
448
509
|
| `MemoryAdapter` | `x new` before a database exists, and every test in this package. `new MemoryAdapter(clock)` — `systemClock` by default — stamps every instant it writes |
|
|
449
510
|
| your own | implement `AuthAdapter`; DDL in `tables.ts` shows what the columns mean |
|
|
450
511
|
|
|
@@ -454,10 +515,8 @@ would not. Normalisation happens once, above the seam, in `normaliseEmail` (`ema
|
|
|
454
515
|
lowercase, nothing else. Call it before `findUserByEmail`/`createUser` in any login route of your
|
|
455
516
|
own, and key any bucket of your own with it — `accountKey` does.
|
|
456
517
|
|
|
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.
|
|
518
|
+
Every member of the seam is required: an adapter missing one is a compile error, never a runtime
|
|
519
|
+
refusal.
|
|
461
520
|
|
|
462
521
|
`x_users` gained two columns in 1.3.0 — `scopes text[]` and `external_id text unique` — plus an
|
|
463
522
|
`org_id` index.
|
|
@@ -553,7 +612,7 @@ Bun.serve({
|
|
|
553
612
|
|---|---|---|
|
|
554
613
|
| path | `/auth/oauth/:provider` | `/auth/oauth/:provider/callback` |
|
|
555
614
|
| 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 |
|
|
615
|
+
| 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
616
|
|
|
558
617
|
A **descriptor**, never a mounted handler — the same category as `mcpHttpRoute()`. `@ultimat3/http`
|
|
559
618
|
is tier 2 like this package, so auth may not import it, and `defineRoute` is tier 4 and describes
|
|
@@ -577,8 +636,12 @@ mounting API to call today; do not write one, and do not read this section as pr
|
|
|
577
636
|
at `GET /auth/oauth/<provider>`; it now quotes `oauthStartPath()`, the same declaration the mount
|
|
578
637
|
reads. A movable base path is that sentence going stale again.
|
|
579
638
|
|
|
580
|
-
|
|
581
|
-
|
|
639
|
+
`oauthLogin` needs an origin to build the provider's `redirect_uri` from: `baseUrl`, or `APP_URL`.
|
|
640
|
+
With neither, the start leg refuses with `X_ENV_MISSING` rather than trust a request header.
|
|
641
|
+
|
|
642
|
+
**Failure is JSON, not a redirect carrying `?error=`.** The body is the same for every refusal of
|
|
643
|
+
one code; the authored cause and fix are the `auth.oauth.refused` log line, where only an operator
|
|
644
|
+
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
645
|
attacker-supplied return target on the endpoint that hands out a session is the classic open
|
|
583
646
|
redirect, and `nextAfterSignIn` in `@ultimat3/http` is the one implementation of that check.
|
|
584
647
|
|
|
@@ -656,12 +719,74 @@ JWT signed with the `.p8` key, which Apple expires every six months.
|
|
|
656
719
|
looked up by the non-secret id.
|
|
657
720
|
|
|
658
721
|
```ts
|
|
722
|
+
import { type Auth, apiKeyActor, issueApiKey, verifyApiKey } from '@ultimat3/auth';
|
|
723
|
+
|
|
724
|
+
declare const auth: Auth;
|
|
725
|
+
declare const orgId: string;
|
|
726
|
+
|
|
659
727
|
const { plaintext, record } = issueApiKey({ env: 'prod', scopes: ['post:publish'], orgId });
|
|
660
728
|
await auth.adapter.putApiKey(record);
|
|
661
|
-
const actor = apiKeyActor(await verifyApiKey(auth.adapter, plaintext)); // kind: 'agent'
|
|
729
|
+
export const actor = apiKeyActor(await verifyApiKey(auth.adapter, plaintext)); // kind: 'agent'
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
`verifyApiKey` answers `{ record, owner }`. A key is its owner's credential, `As of 2026-10`:
|
|
733
|
+
|
|
734
|
+
| The key | What verification does |
|
|
735
|
+
|---|---|
|
|
736
|
+
| names no `userId` | nothing more — a key no user owns is cut by its scopes and its org alone |
|
|
737
|
+
| 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 |
|
|
738
|
+
|
|
739
|
+
A key's scopes only ever shrink on the way to the actor — never the owning user's roles, never a
|
|
740
|
+
default set:
|
|
741
|
+
|
|
742
|
+
- `*` and `<resource>:*` are refused at `issueApiKey` (`X_CONFIG_INVALID`), and dropped from a row
|
|
743
|
+
that already holds one.
|
|
744
|
+
- A key a user owns keeps only the scopes that owner's grants cover. This package can read the
|
|
745
|
+
row's `permissions` and `scopes` (`directGrants`) and cannot expand a role — that is
|
|
746
|
+
`@ultimat3/policy`'s — so an app whose users hold roles says what they grant:
|
|
747
|
+
|
|
748
|
+
```ts
|
|
749
|
+
import { type ApiKeyVerifyStore, apiKeyResolver, directGrants } from '@ultimat3/auth';
|
|
750
|
+
import { expandRoles } from '@ultimat3/policy';
|
|
751
|
+
|
|
752
|
+
declare const keys: ApiKeyVerifyStore;
|
|
753
|
+
|
|
754
|
+
export const resolveKey = apiKeyResolver(() => keys, {
|
|
755
|
+
grantsOf: (owner) => [...directGrants(owner), ...expandRoles(owner.roles)],
|
|
756
|
+
});
|
|
662
757
|
```
|
|
663
758
|
|
|
664
|
-
|
|
759
|
+
Without `grantsOf`, a key owned by a user who holds only roles resolves with no scopes at all.
|
|
760
|
+
|
|
761
|
+
### The resolver a mount takes
|
|
762
|
+
|
|
763
|
+
`apiKeyResolver(() => store)` is that last line as the `resolveToken` a bearer mount
|
|
764
|
+
(`defineApi({ http: { mounts } })`) and an MCP endpoint (`defineAppMcp`) both take — one resolver
|
|
765
|
+
for both doors.
|
|
766
|
+
|
|
767
|
+
```ts
|
|
768
|
+
import type { ApiKeyVerifyStore } from '@ultimat3/auth';
|
|
769
|
+
import { apiKeyResolver, BuiltinAdapter } from '@ultimat3/auth';
|
|
770
|
+
|
|
771
|
+
let keys: ApiKeyVerifyStore | undefined;
|
|
772
|
+
|
|
773
|
+
/** `resolveToken: resolveKey` — on the mount, and on `defineAppMcp()`. */
|
|
774
|
+
export const resolveKey = apiKeyResolver(() => {
|
|
775
|
+
keys ??= new BuiltinAdapter();
|
|
776
|
+
return keys;
|
|
777
|
+
});
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
| Presented | Answer |
|
|
781
|
+
|---|---|
|
|
782
|
+
| a live key | `{ actor, scopes }` — the agent actor for the key's org, and the scopes that actor carries as a `Set` |
|
|
783
|
+
| 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 |
|
|
784
|
+
| a store that FAILS | the throw, untouched — a database that is down is not a wrong key |
|
|
785
|
+
|
|
786
|
+
The store is a THUNK, read when a token is presented: a mount is declared when its module is
|
|
787
|
+
evaluated, and `new BuiltinAdapter()` takes the process's database client, which boot installs
|
|
788
|
+
later. In a test the store is `new MemoryAdapter()` — every `AuthAdapter` is an
|
|
789
|
+
`ApiKeyVerifyStore` (an `ApiKeyStore` that can also `findUserById`).
|
|
665
790
|
|
|
666
791
|
## Errors
|
|
667
792
|
|
|
@@ -670,6 +795,7 @@ An api key's scopes become **exactly** the agent actor's scopes — never the ow
|
|
|
670
795
|
| `X_UNAUTHENTICATED` | no actor, unknown session, or any failed credential path |
|
|
671
796
|
| `X_SESSION_EXPIRED` | idle or absolute expiry, named in `cause` |
|
|
672
797
|
| `X_MFA_REQUIRED` | password proven, second factor outstanding |
|
|
798
|
+
| `X_MFA_SECRET_UNSEALED` | a stored `mfa_secret` is not sealed; it is refused, never read. `x auth seal-mfa --json` |
|
|
673
799
|
| `X_OAUTH_STATE_INVALID` | state, nonce or PKCE verifier did not match |
|
|
674
800
|
| `X_OAUTH_EXCHANGE_FAILED` | the provider refused the exchange, or returned no usable identity |
|
|
675
801
|
| `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` |
|
|
@@ -677,10 +803,10 @@ An api key's scopes become **exactly** the agent actor's scopes — never the ow
|
|
|
677
803
|
| `X_OAUTH_PROVIDER_DUPLICATE` | two `registerOAuthProvider` calls claimed one id — at boot, never at a login |
|
|
678
804
|
| `X_OAUTH_DENIED` | the user pressed Cancel, or the provider declined — `403`, never a `502` |
|
|
679
805
|
| `X_PASSWORD_WEAK` | strength check rejected the password |
|
|
680
|
-
| `X_ACCOUNT_LOCKED` | the per-ip, per-account or per-org bucket is inside its lockout |
|
|
681
|
-
| `X_API_KEY_INVALID` | key unknown, revoked, expired or
|
|
806
|
+
| `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 |
|
|
807
|
+
| `X_API_KEY_INVALID` | key unknown, revoked, expired, wrong, or owned by a user who is gone or disabled |
|
|
682
808
|
| `X_ENV_MISSING` | `oauthCredentials()` found no client id or secret for an enabled provider |
|
|
683
|
-
| `
|
|
809
|
+
| `X_AUTH_WRITE_FAILED` | an `AuthAdapter` lost a write it accepted — `emailVerifiedNotStored(provider, userId)` when `updateUser` drops the OAuth verified stamp |
|
|
684
810
|
|
|
685
811
|
```bash
|
|
686
812
|
bun test packages/auth
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/auth",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.0.0",
|
|
4
4
|
"description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/*-fixture.ts",
|
|
22
23
|
"CLAUDE.md",
|
|
23
24
|
"README.md",
|
|
24
25
|
"LICENSE"
|
|
@@ -31,8 +32,8 @@
|
|
|
31
32
|
"test": "bun test"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
35
|
+
"@ultimat3/core": "24.0.0",
|
|
36
|
+
"@ultimat3/db": "24.0.0",
|
|
37
|
+
"@ultimat3/schema": "24.0.0"
|
|
37
38
|
}
|
|
38
39
|
}
|