@ultimat3/auth 29.3.2 → 30.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.
Files changed (67) hide show
  1. package/CLAUDE.md +46 -58
  2. package/README.md +162 -9
  3. package/package.json +20 -5
  4. package/src/adapter.ts +88 -2
  5. package/src/api-key-resolver.ts +3 -3
  6. package/src/auth.ts +29 -7
  7. package/src/authorization-server-clients.ts +169 -0
  8. package/src/authorization-server-config.ts +239 -0
  9. package/src/authorization-server-errors.ts +23 -0
  10. package/src/authorization-server-postgres-sql.ts +107 -0
  11. package/src/authorization-server-postgres.ts +222 -0
  12. package/src/authorization-server-register.ts +98 -0
  13. package/src/authorization-server-request.ts +126 -0
  14. package/src/authorization-server-routes.ts +181 -0
  15. package/src/authorization-server-schema.ts +82 -0
  16. package/src/authorization-server-store.ts +189 -0
  17. package/src/authorization-server-tokens.ts +118 -0
  18. package/src/authorization-server-types.ts +108 -0
  19. package/src/authorization-server-wire.ts +135 -0
  20. package/src/authorization-server.ts +178 -0
  21. package/src/base64url.ts +40 -0
  22. package/src/builtin-adapter-violation.ts +12 -9
  23. package/src/builtin-adapter.ts +31 -5
  24. package/src/cbor-encode.ts +52 -0
  25. package/src/cbor.ts +129 -0
  26. package/src/client-scope.ts +2 -5
  27. package/src/client.ts +16 -0
  28. package/src/cose-key.ts +129 -0
  29. package/src/errors.ts +51 -0
  30. package/src/grant-authorization-code.ts +113 -0
  31. package/src/grant-client-credentials.ts +55 -0
  32. package/src/grant-device.ts +188 -0
  33. package/src/index.ts +108 -3
  34. package/src/jwks.ts +81 -12
  35. package/src/jwt-bearer-resolver.ts +80 -0
  36. package/src/limiter-install.ts +31 -2
  37. package/src/memory-adapter.ts +18 -0
  38. package/src/mfa-challenge.ts +13 -7
  39. package/src/mfa-replay-postgres.ts +71 -0
  40. package/src/mfa.ts +24 -13
  41. package/src/oauth-bearer-resolver.ts +44 -0
  42. package/src/oauth-cookie.ts +2 -1
  43. package/src/oauth-discovery.ts +17 -6
  44. package/src/oauth-exchange.ts +23 -12
  45. package/src/oauth-profile.ts +20 -9
  46. package/src/oauth-route.ts +5 -5
  47. package/src/passkey-assertion.ts +60 -0
  48. package/src/passkey-challenge.ts +59 -0
  49. package/src/passkey-client.ts +156 -0
  50. package/src/passkey-config.ts +92 -0
  51. package/src/passkey-errors.ts +89 -0
  52. package/src/passkey-evidence.ts +74 -0
  53. package/src/passkey-login.ts +127 -0
  54. package/src/passkey-memory.ts +77 -0
  55. package/src/passkey-postgres.ts +155 -0
  56. package/src/passkey-step-up.ts +135 -0
  57. package/src/passkey-verify.ts +180 -0
  58. package/src/passkey-wire.ts +61 -0
  59. package/src/soft-authenticator.ts +208 -0
  60. package/src/subject-target.ts +73 -0
  61. package/src/tables.ts +46 -0
  62. package/src/token-endpoints.ts +61 -0
  63. package/src/tokens.ts +2 -25
  64. package/src/verification-purposes.ts +61 -0
  65. package/src/verify.ts +86 -12
  66. package/src/webauthn-data.ts +111 -0
  67. package/src/workload.ts +67 -15
package/CLAUDE.md CHANGED
@@ -21,9 +21,8 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
21
21
  `idleSlideMs`, `password.minLength`, the two argon2 costs, every `rateLimit` number), and every
22
22
  runtime one through `assertFiniteAuthCount` — `jwks.ttlMs`, the three OAuth legs' `timeoutMs`
23
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
24
+ `oauth.handshake.ttlMs`, `session.cookie.maxAgeSeconds`, `kdf.maxConcurrent`/`maxQueued`.
25
+ **`mfa.drift` and the KDF gate are the two that HANG** — mutate `verifyTotp` with the
27
26
  `NaN` case, never by deleting the screen (that wedges `mfa.test.ts`).
28
27
  - **A number is screened ABOVE the write it feeds.** `issueVerification` screens its TTL before
29
28
  `putVerification` (which upserts on `(purpose, identifier)`); `verify.test.ts` asserts no row
@@ -49,7 +48,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
49
48
  concurrent guesses all passed the check. `assertAuthLimiterPolicy` (once, in `defineAuth`)
50
49
  refuses a per-process limiter under `scope: 'shared'` (`X_AUTH_LIMITER_NOT_SHARED`) and different
51
50
  `maxAttempts`/`windowMs`/`lockoutMs` (`X_AUTH_LIMITER_POLICY_MISMATCH`). `maxKeys` is not
52
- compared. Nothing here reads the environment to guess a replica count.
51
+ compared.
53
52
  - **Every credential check runs through `openLoginAttempt`** (`login-attempt.ts`): account → ip →
54
53
  org reserved in that fixed order BEFORE the KDF or the TOTP compare. A failure calls nothing (the
55
54
  reservations are the record); `release()` for no verdict (a shed KDF, a store that is down, a
@@ -58,9 +57,8 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
58
57
  - **`postgresAuthLimiter` is the shared limiter: ONE row per key in `x_auth_lockouts`, holding the
59
58
  instants still inside the window** (`attempts_ms bigint[]` — a sliding window). The take is one
60
59
  `insert … on conflict do update` reading the window off `x_auth_lockouts.<column>`, never a CTE
61
- and never a second table: only the row the statement locks is re-read after a wait. `admitted`
62
- is a column because `returning` cannot see the old row; no row back is never an admission. A
63
- live lockout is never rewritten. `PgExecutor` (`@ultimat3/core`'s, re-exported) is structural:
60
+ and never a second table: only the row the statement locks is re-read after a wait. No row back is never an
61
+ admission; a live lockout is never rewritten. Full rule: `docs/history/auth.md`. `PgExecutor` (`@ultimat3/core`'s, re-exported) is structural:
64
62
  the pool is the host's.
65
63
  - **`configureAuthLimiters` is the HOST's install point and takes a FACTORY**, called with the
66
64
  RESOLVED policy once per bucket; the comparison still runs on what comes back. Precedence:
@@ -83,15 +81,12 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
83
81
  - **`oauthProviderUnknown(provider, supported)` scopes its list to its reader**: the route passes
84
82
  `BUILTIN_OAUTH_PROVIDER_IDS` (an anonymous caller), `providerFor()` passes `oauthProviderIds()`.
85
83
  - **`verifyIdToken({ keys })` is required, with no default.** `'token-endpoint-tls'` is the OIDC
86
- Core 3.1.3.7 exemption and `exchangeOAuthCode` is its only entitled caller — and since 26.0.0
87
- it defaults to the provider's `providerJwks` whenever there is a `jwksUri`, using the exemption
88
- only for a provider with none or when the caller names it (`idTokenKeys`). `HS256` and
84
+ Core 3.1.3.7 exemption and `exchangeOAuthCode` is its only entitled caller; it defaults to
85
+ `providerJwks` whenever there is a `jwksUri`. `HS256` and
89
86
  `alg: none` are refused in `decodeJwtHeader`. It checks `nbf` and `azp` too.
90
87
  - **`resolveGrants` is a seam, never a group-to-role table**, called on EVERY login. Absent leaves
91
- the row alone. A user created with no roles and no org logs a warning.
92
- Its second argument (`OAuthGrantContext`, `oauth-grant-context.ts`, #670) is the token set, the
93
- whole verified id-token payload and a LAZY, once-per-login, `sub`-checked `userinfo()` — read
94
- through `oauth-profile.ts`'s `userinfoBody`, never a second userinfo reader.
88
+ the row alone. A user created with no roles and no org logs a warning. Its second argument is
89
+ `OAuthGrantContext`; never a second userinfo reader. Full rule: `docs/history/auth.md`.
95
90
  - **`verifySession` writes at most once per `idleSlideMs`** (default `idleTtlMs / 20`). Never cache
96
91
  the USER row — `authenticate` re-reads it every request so a revoked role bites on the next one.
97
92
  In `observed`, `null` is an ANSWER (clears the stored value) and `undefined` is silence.
@@ -108,14 +103,8 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
108
103
  `order by created_at desc limit 1`); `consumeVerification` still compares in constant time.
109
104
  `consumed_at` is the adapter's `Clock`, bound, never `now()`.
110
105
  - **Foreign text in a `cause:` goes through `renderCauseValue`, in a `fix:` through
111
- `renderFixLiteral`, and in a shell `fix:` through `renderFixShellArg`.** Foreign here:
112
- `providerDetail()`'s return, `claims.iss`, `accountLocked`'s `key` (an `org:` key is never
113
- rendered at all — it was looked up from the caller's address), and every OAuth endpoint URL
114
- in a command position (`jwks.ts`, `oauth-profile.ts`, `oauth-exchange.ts`,
115
- `oauth-discovery.ts`) — the registry is filled by an issuer's own discovery document. A LINE
116
- that would not run degrades to prose: `jwks.ts`'s `readTheKeySet(tail)` decides with core's
117
- `isFixShellSafe`. `oauth-discovery.ts` still owes that repair (it keeps `auth` on
118
- `FIX_SHELL_ARG_PINS`). `${provider}` is registry-validated boot config, not foreign.
106
+ `renderFixLiteral`, and in a shell `fix:` through `renderFixShellArg`.** A line that would
107
+ not run degrades to prose (core's `isFixShellSafe`); `oauth-discovery.ts` still owes it. Full rule: `docs/history/auth.md`.
119
108
  - Cookies are read through `@ultimat3/core`'s `readCookie` (`readSessionCookie`, the oauth
120
109
  handshake), which never throws on a malformed value (`decodeURIComponent('%')`).
121
110
  They are WRITTEN through its `serializeSetCookie`, never a literal (`bun run set-cookie-literals`,
@@ -143,15 +132,14 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
143
132
  `providerDetail(response, 'coded-only')` (that request carries `client_secret`).
144
133
  - **`x_users.mfa_secret` is SEALED** (`mfa-secret.ts`, purpose `auth:x_users.mfa_secret`):
145
134
  `saveTotpSecret` writes, `openTotpSecret` reads and REFUSES a value core's `isSealed` does not
146
- recognise (`X_MFA_SECRET_UNSEALED`, fix `x auth seal-mfa --json`). No plaintext fallback, ever —
147
- `sealMfaSecrets` is the one-shot — each write a compare-and-set (`replaceMfaSecret`), a row that
148
- changed underneath is `skipped` — and `x doctor` counts what is left. Whether a value is sealed is
149
- `isSealed`'s to say, never a `like` in an adapter (`listUsersWithMfaSecret` returns them all).
135
+ recognise (`X_MFA_SECRET_UNSEALED`, fix `x auth seal-mfa --json`). No plaintext fallback, ever. Full rule: `docs/history/auth.md`.
150
136
  - **`providerJwks` memoises only the DEFAULT client**; a caller supplying options gets its own.
151
137
  - **A JWKS refresh is single-flighted through core's `singleFlight` with
152
138
  `deadlineMs = timeoutMs * 2`**; eviction frees the key, never the work, and a `generationFence`
153
139
  generation check (read, never `guard`) stops a superseded refresh overwriting the cache.
154
140
  `schedule` is injectable.
141
+ - **A failed JWKS refresh is rated and survivable, in `keyFor`, above the transport** (`JWKS_FAILURE_COOLDOWN_MS`,
142
+ `JWKS_STALE_IF_ERROR_MS`; constants, no option). Full rule: `docs/history/auth.md`.
155
143
  - **A success clears the ACCOUNT bucket and nothing else** — clearing the address bucket made the
156
144
  limiter inert against stuffing. A password proven with a second factor still owed clears
157
145
  NOTHING: `completeMfa` is what clears, or a repeat of the first leg wipes the wrong codes.
@@ -178,6 +166,9 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
178
166
  `ApiKeyCaller` is structurally http's `BearerCaller` and mcp's `ResolvedToken` — declared here
179
167
  because tier 2 imports neither. The store is a thunk (a mount is declared before boot installs
180
168
  the database client). No second in-memory key store: `MemoryAuthAdapter` IS an `ApiKeyStore`.
169
+ - **`jwtBearerResolver` is its twin for an external JWT**: `checkWorkloadToken`'s checks, then
170
+ scope, then `accept(raw)`. Every refusal — a throwing key source too — is `null` plus one
171
+ `auth.jwt_bearer.refused` line naming the check, never a claim. `keys` is a thunk.
181
172
  - Rotate the session id on any privilege change (`rotateSession`, called by `updatePrivileges` in
182
173
  `privileges.ts`), never patch the row. A `passwordHash` change also ends every OTHER session of
183
174
  that user (`deleteOtherSessions`), or all of them when the caller passed no session of theirs.
@@ -188,26 +179,27 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
188
179
  (borrowed from http, in `AUTH_BORROWED_ERROR_CODES`). The pool is core's `flightGate`, the
189
180
  refusal auth's own through core's `overflow:` seam. `configureKdfGate()` is the ONE install point
190
181
  and deliberately not a `defineAuth` key.
191
- - **MFA's second leg is `completeMfa(auth, challenge, code)`** (`mfa-challenge.ts`). `login()`
192
- ends in `X_MFA_REQUIRED` whose `meta.challenge` is a `seal()` value (purpose
193
- `auth:mfa-challenge`: user id, nonce, expiry on the server's clock, `MFA_CHALLENGE_TTL_MS`) —
194
- `meta`, never `cause`, and never a bare user id: a second leg keyed by one is unauthenticated by
195
- construction. The code is reserved against the same three buckets before it is compared, and a
196
- spent step is refused through `auth.totpReplay` (checked and remembered with no `await`
197
- between). It needs the app's master key (`X_SEAL_KEY_MISSING`). No route ships: the handler
198
- decides how the challenge travels. Anything that is not six digits is a RECOVERY CODE: hashed
199
- (`recoveryCodeHash`) and consumed by `adapter.consumeRecoveryCode` — one conditional UPDATE, so
200
- single-use is storage's guarantee. There is no read-then-write redemption helper.
201
- - **`mfa.required` is the literal `false`, and `defineAuth` refuses a `true`** (`X_CONFIG_INVALID`):
202
- enforcing it would lock out every un-enrolled user. `mfa.issuer` is read by
203
- `enrolTotp(auth, { account })`.
182
+ - **MFA's second leg is `completeMfa(auth, challenge, code)`** (`mfa-challenge.ts`). `X_MFA_REQUIRED`'s
183
+ `meta.challenge` is a `seal()` value, never a bare user id; the code is reserved against the three
184
+ buckets before it is compared; a recovery code is consumed by one conditional UPDATE. Full rule: `docs/history/auth.md`.
185
+ - **`mfa.required` is the literal `false`, and `defineAuth` refuses a `true`** (`X_CONFIG_INVALID`).
186
+ `mfa.issuer` is read by `enrolTotp(auth, { account })`.
204
187
  - **A TOTP secret that decodes to zero bytes is NO key**: `verifyTotp` returns the generic failure
205
188
  (`{ ok: false, step: null }`), `totpCode` and `enrolTotp` throw `X_MFA_SECRET_INVALID`. The secret
206
189
  never reaches `cause:`/`fix:`. No length floor beyond one byte.
190
+ - **`TotpReplayGuard` is one atomic `claim(subject, step, at): Promise<boolean>`**; a rejection is
191
+ no verdict (`attempt.release()`, rethrown). Precedence: `config.totpReplay` →
192
+ `configureTotpReplay`'s factory → memory. `postgresTotpReplayGuard`: `x_totp_steps`, one
193
+ `on conflict do nothing`; `purgeExpired(nowMs)` keeps slack past the drift.
207
194
  - **`memoryTotpReplayGuard`'s table is bounded and the eviction ORDER is the guarantee**: subjects
208
195
  whose steps are all below the drift floor are forgotten first; past `DEFAULT_MAX_TOTP_SUBJECTS`,
209
196
  evict by newest spent step ascending, least-recently-seen tie-break. `maxSubjects` is normalised
210
197
  first (`boundedSubjects`): anything not a positive finite integer takes the default.
198
+ - **Passkeys are verified by hand** (`passkey-*.ts`, `cbor.ts`, `cose-key.ts`, `webauthn-data.ts`):
199
+ UV required, origin exact, the key's alg (never the assertion's), counter non-regression by
200
+ compare-and-set, challenges single-use in `x_passkey_challenges` (never a process map). A step-up
201
+ challenge is DERIVED (`stepUpChallenge`), never supplied. Browser half: `./client`, two modules
202
+ (`client-bundle.test.ts`); never import the barrel there. `docs/history/auth.md`.
211
203
  - SAML is out of scope permanently (XML-DSig canonicalisation has no Bun native). Put an
212
204
  OIDC-speaking bridge in front and register that.
213
205
 
@@ -223,36 +215,38 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
223
215
  | `rate-limit.ts` | per-ip, per-account and per-org buckets as reservations, lockout, scope check, `loginFailed()` |
224
216
  | `login-attempt.ts` | one attempt's reservations across the three buckets, taken before the check and settled after it |
225
217
  | `rate-limit-postgres.ts` | the SHARED limiter: one table, one row per key, a one-statement take, over a structural `PgExecutor` |
226
- | `limiter-install.ts` | the host's one install point for that limiter — the factory, what it built, and the purge over it |
218
+ | `limiter-install.ts` | the host's one install point for that limiter — the factory, what it built, and the purge over it, and the TOTP guard's |
227
219
  | `oauth.ts` | `OAuthProvider`, PKCE, `beginOAuth`, the callback gate. No I/O, no env |
228
220
  | `oauth-builtins.ts` | the two shipped IdPs (`github`, `google`), as data. Imports only the type, so no cycle |
229
221
  | `oauth-registry.ts` | the registry: `registerOAuthProvider`, `providerFor`, `oauthProviderIds` |
230
222
  | `oauth-discovery.ts` | `/.well-known/openid-configuration` → an `OAuthProvider`. One `fetch` |
231
223
  | `jwks.ts` | `crypto.subtle` signature verification, cached by `kid`, one shared in-flight refresh. No dependency |
232
- | `workload.ts` | a workload JWT (K8s SA / SPIFFE / IMDS / RFC 8693) → a `ServiceIdentity` |
224
+ | `workload.ts` | a workload JWT (K8s SA / SPIFFE / IMDS / RFC 8693) → a `ServiceIdentity`; `checkWorkloadToken`, the verdict form |
225
+ | `jwt-bearer-resolver.ts` | an external bearer JWT → `{ actor, scopes } \| null` |
233
226
  | `revocation.ts` | per-user, per-org and before-an-instant sweeps; `disableUser` (sessions and api keys) |
234
227
  | `mfa-challenge.ts` | the sealed challenge `X_MFA_REQUIRED` carries, and `completeMfa` |
228
+ | `mfa-replay-postgres.ts` | the shared replay guard: one table, the claim, the sweep |
235
229
  | `mfa-secret.ts` | the TOTP secret at rest: seal on write, open on read, the one-shot that seals older rows |
236
230
  | `directory.ts` | `describeUser` (allow-list projection), `listOrgUsers`, external-id lookup |
237
231
  | `privileges.ts` | `updatePrivileges` — the grant, the rotation it requires, and the sessions a changed password ends |
238
232
  | `oauth-cookie.ts` | the handshake's home between the two legs: seal, open, the cookie |
239
233
  | `oauth-exchange.ts` | `oauthCredentials` + the one POST to the token endpoint |
240
234
  | `id-token.ts` | id token → claims this handshake may believe |
241
- | `id-token-fixture.ts` | the one string-input JWT builder the OAuth tests share. Off `index.ts` |
242
- | `auth-fixture.ts` | the KDF parameters, the password and the `AuthError` catcher the three `auth*.test.ts` suites share. Off `index.ts` |
235
+ | `*-fixture.ts` | test-only, off `index.ts`: `id-token-` (the string-input JWT builder), `auth-` (KDF params, password, `AuthError` catcher for `auth*.test.ts`), `oauth-login-` (adapter, clock, profile) |
243
236
  | `oauth-profile.ts` | claims or userinfo → one `OAuthProfile`; `userinfoBody`, the one userinfo reader |
244
237
  | `oauth-grant-context.ts` | `resolveGrants`' second argument: tokens, every id-token claim, a lazy subject-checked `userinfo()` |
245
238
  | `oauth-login.ts` | profile → account link → session. `completeOAuthLogin` is the entry point |
246
- | `oauth-login-fixture.ts` | the adapter, clock and profile the three `oauth-login*` suites share. Off `index.ts` |
247
239
  | `oauth-paths.ts` | the one declaration of where the two routes live. Imports nothing |
248
240
  | `errors.ts` | the codes this package owns and borrows, their titles, the one `registerErrorCodes()` call, `AuthError`, and every non-OAuth factory |
249
241
  | `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 |
250
242
  | `oauth-route.ts` | `oauthLogin(auth)` — the redirect out and the callback back |
251
- | `kdf-gate.ts` | the one bound on concurrent argon2 work, and the `X_OVERLOADED` past it — core's `flightGate` with auth's own refusal injected |
243
+ | `kdf-gate.ts` | the one bound on concurrent argon2 work: core's `flightGate`, auth's `X_OVERLOADED` |
252
244
  | `email.ts` | `normaliseEmail` — the one normalisation an address gets before it is an identity key |
253
245
  | `json.ts` | reading untrusted JSON: `isRecord`, and a base64url JWT segment as an object or `null` |
254
- | `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 |
255
- | `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) |
246
+ | `sign-out.ts` | `signOutHeaders({ session? })` — the expired session cookie plus `Clear-Site-Data: "cache", "storage"` (`SIGN_OUT_CLEAR_SITE_DATA`), never `"cookies"`. Secure contexts only |
247
+ | `subject-target.ts` | `authSubjectTarget({ executor, user })`: the identity tables as a `defineSubject({ targets })` entry, structural (`@ultimat3/jobs` imports nothing from here). Export = `AUTH_SUBJECT_COLUMNS` (never `password_hash`, `mfa_secret`, a recovery digest); erase = api keys, pending verifications, then the user (sessions, accounts cascade). Three idempotent statements, no transaction of its own |
248
+ | `passkey-login.ts` / `passkey-step-up.ts` / `passkey-evidence.ts` | the factories; the offline re-check. `passkey-verify.ts` + `passkey-assertion.ts` are every check; `*-postgres`/`*-memory` the store; `soft-authenticator.ts` (`./soft-authenticator`) the WebCrypto authenticator tests sign with |
249
+ | `client-scope.ts` | `clientScopeOf(actor)`, the OPAQUE id for `<meta name="ultimate-scope">`: keyed SHA-256 (`SESSION_SECRET`, else a per-process key and one warning) over kind, id, impersonator; `''` anonymous |
256
250
 
257
251
  ```bash
258
252
  bun test packages/auth
@@ -262,16 +256,10 @@ bun run --filter @ultimat3/auth typecheck
262
256
  Gotchas:
263
257
  - `exactOptionalPropertyTypes` — declare optional fields as `x?: T | undefined`.
264
258
  - `noUncheckedIndexedAccess` — index a `Record` into a local before narrowing it.
265
- - `X_NOT_IMPLEMENTED` is core's, `X_FORBIDDEN` is policy's. `errors.ts` registers only the codes
266
- this package **owns**, unconditionally, and lists the borrowed two in `AUTH_BORROWED_ERROR_CODES`
267
- without a title. A `hasErrorCode()` guard would suppress the `X_ERROR_CODE_DUPLICATE` that is
268
- supposed to fire when two packages claim one code.
269
- - Tests run against `MemoryAuthAdapter`; the one ADAPTER test that boots a database is
270
- `adapter-parity-identity.test.ts` (PGlite). The two other exceptions are
271
- `postgresAuthLimiter`'s, and they are exceptions in the shape the repo already has: the
272
- scripted-executor twin (`rate-limit-postgres.test.ts`) proves the protocol with no server,
273
- and `rate-limit-postgres.live.test.ts` is `describe.skip` without `TEST_DATABASE_URL` — the
274
- same pairing `@ultimat3/http`'s rate-limit store and `@ultimat3/realtime`'s Postgres files
275
- use. A limiter whose statements were never executed is a credential control nobody has run.
259
+ - `errors.ts` registers only the codes this package **owns**, unconditionally; borrowed ones
260
+ (`X_FORBIDDEN`, `X_NOT_IMPLEMENTED`, …) sit untitled in `AUTH_BORROWED_ERROR_CODES`. Never a
261
+ `hasErrorCode()` guard.
262
+ - Tests run against `MemoryAuthAdapter`; `adapter-parity-identity.test.ts` boots PGlite. The two
263
+ Postgres guards pair a scripted twin with a `*.live.test.ts` (skips without `TEST_DATABASE_URL`).
276
264
 
277
265
  Why each rule above is shaped the way it is: [`docs/history/auth.md`](../../docs/history/auth.md).
package/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
  authorizes on a session row, a user row or an api key — http, actions, jobs and MCP all read
5
5
  `ctx.actor` and hand it to `@ultimat3/policy`. One authz system, never two.
6
6
 
7
+ `bun add @ultimat3/auth` · guide: [Auth](https://github.com/developerz-ai/ultimate/wiki/Auth)
8
+
7
9
  ```ts
8
10
  import { defineAuth, login, oauthLogin, postgresAuthAdapter } from '@ultimat3/auth';
9
11
 
@@ -108,7 +110,41 @@ const actor = actorFromService(identity); // kind: 'service', id: the caller's
108
110
 
109
111
  Signature first, then issuer, audience, `exp` and `nbf`. `scope` (space-delimited) or `scp` (an
110
112
  array) become the actor's scopes; a token with neither carries none. There is no trusted-channel
111
- exemption here — the token arrived in a header.
113
+ exemption here — the token arrived in a header. The `WorkloadToken` also carries `raw`, the whole
114
+ decoded payload, read only after every check held — an issuer's own claim (`tid`, `groups`) is the
115
+ app's to judge. `checkWorkloadToken` is the same checks as a verdict naming the one that refused.
116
+
117
+ ### An external issuer's bearer, as a `resolveToken`
118
+
119
+ `jwtBearerResolver` is `apiKeyResolver`'s twin for a JWT some other issuer minted — an IdP's token
120
+ an agent presents to the app's MCP endpoint or a bearer mount:
121
+
122
+ ```ts
123
+ import { actorFromService, jwksClient, jwtBearerResolver } from '@ultimat3/auth';
124
+
125
+ const ISSUER = 'https://login.example.com/tenant-a/v2.0';
126
+ const keys = jwksClient({ provider: 'idp', jwksUri: `${ISSUER}/discovery/v2.0/keys` });
127
+
128
+ /** `resolveToken: resolveAgent` — on the mount, and on `defineAppMcp()`. */
129
+ export const resolveAgent = jwtBearerResolver({
130
+ keys: () => keys,
131
+ issuers: [ISSUER],
132
+ audience: 'api://ledger',
133
+ requiredScope: 'ledger.agent',
134
+ accept: (raw) => raw['tid'] === 'tenant-a',
135
+ actorOf: (token) => actorFromService(token.identity),
136
+ });
137
+ ```
138
+
139
+ | Presented | Answer |
140
+ |---|---|
141
+ | a token every check accepts | `{ actor: actorOf(token), scopes }` — the token's `scope`/`scp` as a `Set` |
142
+ | bad signature or `alg: none`, wrong issuer or audience, expired, not yet valid, without `requiredScope`, refused by `accept` | `null`, plus one `auth.jwt_bearer.refused` log line naming the check — never a claim's value, never the token |
143
+ | a key set that cannot be read, or an unknown `kid` | `null` (`check: 'key-set'`): fail closed, and the two look the same |
144
+ | an `accept` or `actorOf` that throws | the throw — the app's bug, not a verdict |
145
+
146
+ `keys` is a THUNK read when a token arrives, so discovery opens no network at import; hold the
147
+ client and return it. The mount answers `null` as `401 X_UNAUTHENTICATED`.
112
148
 
113
149
  **mTLS is out of scope.** TLS termination is the mesh's job ([axiom
114
150
  7](../../docs/idea/README.md)); the framework's part is reading a trusted
@@ -301,6 +337,11 @@ a `jwksUri` — Google and every discovered provider do; GitHub issues no id tok
301
337
  exemption trusts every hop the response crossed (a TLS-inspecting proxy, a bad DNS answer); a
302
338
  signature trusts only the provider. A bad signature is `X_OAUTH_TOKEN_INVALID` (400) and no session.
303
339
 
340
+ **An IdP outage does not end every login.** A failed refresh leaves a key already held answering for
341
+ one more hour past its TTL (`JWKS_STALE_IF_ERROR_MS`), logged `auth.jwks.stale_served`; past it,
342
+ `X_OAUTH_EXCHANGE_FAILED`. No fetch runs within 30 s of a failed one (`JWKS_FAILURE_COOLDOWN_MS`),
343
+ so a forged `kid` costs no request during an outage. Both are constants, not options.
344
+
304
345
  | Where | Option | Default |
305
346
  |---|---|---|
306
347
  | `exchangeOAuthCode(handshake, callback, { keys })` | `keys?: IdTokenKeys` | the provider's key set, else `'token-endpoint-tls'` |
@@ -370,7 +411,7 @@ export const { start, callback } = oauthLogin(auth, {
370
411
  resolveGrants: async (_profile, context) => {
371
412
  const claims = await context.userinfo();
372
413
  const apps = Array.isArray(claims['allowed_oauth_apps']) ? claims['allowed_oauth_apps'] : [];
373
- return apps.includes('bank-integrations') ? { roles: ['member'] } : { roles: [] };
414
+ return apps.includes('ledger') ? { roles: ['member'] } : { roles: [] };
374
415
  },
375
416
  });
376
417
  ```
@@ -419,7 +460,10 @@ every enrolled user's second factor.
419
460
  |---|---|
420
461
  | `enrolTotp(auth, { account, issuer?, secret? })` | `{ secret, uri, digits, periodSeconds }` — `issuer` omitted is `auth.mfa.issuer`, `secret` omitted mints one |
421
462
  | `verifyTotp({ secret, code, at, drift?, usedSteps? })` | `{ ok, step }`. `step` is the window the code belonged to, `null` on no match |
422
- | `memoryTotpReplayGuard(drift?, maxSubjects?)` | the in-process `{ isUsed, remember, size }`; a fleet passes a Redis-backed pair of the same two methods |
463
+ | `TotpReplayGuard` | one atomic method, `claim(subject, step, at): Promise<boolean>`: `true` the first time, `false` for a replay. A store that cannot answer rejects: a down database is a fault, never a wrong code |
464
+ | `memoryTotpReplayGuard(drift?, maxSubjects?)` | the in-process guard, `{ claim, isUsed, size }`; `isUsed` is for a test. The default only where no host installed one |
465
+ | `postgresTotpReplayGuard({ executor })` | the shared guard: `x_totp_steps` (`SQL_TOTP_REPLAY_TABLE`), one `insert … on conflict do nothing` per claim, `purgeExpired(nowMs)` for the sweep. A framework boot installs it |
466
+ | `configureTotpReplay(() => guard)` / `resetTotpReplay()` | the host's install point, beside `configureAuthLimiters`. Precedence: `defineAuth({ totpReplay })` → the installed factory → memory |
423
467
  | `generateRecoveryCodes(count = 10)` | `{ codes, hashes }`. `codes` is shown once and is never re-derivable |
424
468
  | `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 |
425
469
  | `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 |
@@ -499,8 +543,11 @@ export const secondFactor = (challenge: string, code: string, ip: string) =>
499
543
  A code guess is reserved before it is compared, so six digits are worth `maxAttempts` tries per
500
544
  window. Repeating the first leg does not buy them back: a proven password with a second factor
501
545
  still owed clears nothing — `completeMfa` is what clears the account's window. A spent step is
502
- refused through `auth.totpReplay` (`defineAuth({ totpReplay })`, in-process by default). No route
503
- ships.
546
+ claimed through `auth.totpReplay`, once the code has verified, so a wrong guess never writes and a
547
+ lost claim is the same refusal. A booted process holds `postgresTotpReplayGuard`, so a code is good
548
+ once across every replica; with no host, the default is one process' memory. A guard that rejects is
549
+ a fault: it propagates, and the guess is not counted. `defineAuth` refuses a `totpReplay` with no
550
+ `claim` (`X_CONFIG_INVALID`). No route ships.
504
551
 
505
552
  `code` is either the six digits or a **recovery code**: anything that is not six digits is hashed
506
553
  and consumed through the adapter, metered exactly as a wrong digit string is, and gone after one
@@ -543,17 +590,91 @@ const verification = await consumeVerification(runtime, {
543
590
 
544
591
  | Name | Is |
545
592
  |---|---|
546
- | `VERIFICATION_PURPOSES` | `['email-verify', 'password-reset']` — the whole set, and `VerificationPurpose` is derived from it |
593
+ | `VERIFICATION_PURPOSES` | `['email-verify', 'password-reset']` — the two built in. `VerificationPurpose` is `string` (`As of 2026-11`): any declared name |
594
+ | `defineVerificationPurpose({ name, template, ttlMs, identifier? })` | `{ name, issue, consume }` on the same code path — an invite, an email change, an account deletion. Twice or with no template: `X_VERIFICATION_PURPOSE_INVALID`. `emailVerification` and `passwordReset` are the built-in instances |
547
595
  | `VERIFICATION_TEMPLATES` | purpose → **catalog key** (`auth.email-verify`, `auth.password-reset`), never copy: the body lives in the app's i18n catalog |
548
596
  | `DEFAULT_VERIFICATION_TTL_MS` | 24 h for `email-verify`, 1 h for `password-reset` — a reset link is a password |
549
597
  | `MailSender` | the injected port: `send(template, to, data, locale)`. `@ultimat3/mail` is tier 4 and this package is tier 2, so the app wires it |
550
598
  | `issueVerification(runtime, input)` | `{ token, expiresAt }`. The token comes back for a test or a CLI; production only ever mails it |
551
- | `consumeVerification(runtime, input)` | the `AuthVerification` row, or `X_UNAUTHENTICATED`. Unknown, spent, expired and mismatched are one answer |
599
+ | `consumeVerification(runtime, input)` | the `AuthVerification` row, or `X_UNAUTHENTICATED`. Unknown, spent, expired and mismatched are one answer. An undeclared purpose on either door is `X_VERIFICATION_PURPOSE_UNKNOWN` |
552
600
 
553
601
  One live token per `(purpose, identifier)`. A wrong guess destroys nothing: the store's
554
602
  `takeVerification(purpose, identifier, tokenHash)` matches before it consumes, so an
555
603
  unauthenticated POST with any token cannot kill the victim's live link.
556
604
 
605
+ ## Passkeys — WebAuthn sign-in and digest-bound step-up
606
+
607
+ `As of 2026-11`. Verified by hand on Bun's WebCrypto — CBOR, COSE (ES256, RS256) and DER parsed in
608
+ this package, no WebAuthn dependency. Declared once: `defineAuth({ passkeys: { rpId, origins } })`
609
+ (`X_CONFIG_INVALID` at definition for an rp id that is not a bare host, or an origin not on it).
610
+
611
+ ```ts
612
+ import { defineAuth, memoryAuthAdapter, passkeyLogin, passkeyStepUp } from '@ultimat3/auth';
613
+ import { newPasskey, getPasskey } from '@ultimat3/auth/client';
614
+
615
+ const auth = defineAuth({
616
+ adapter: memoryAuthAdapter(),
617
+ passkeys: { rpId: 'localhost', origins: ['http://localhost:3000'] },
618
+ });
619
+ const passkeys = passkeyLogin(auth);
620
+ declare const userId: string;
621
+
622
+ // Each server call sits in one of the app's own actions; its options travel to the browser as
623
+ // JSON, where the browser half (`@ultimat3/auth/client`, in an island) answers them.
624
+ const created = await newPasskey(await passkeys.registerOptions({ userId }));
625
+ if (created.status === 'ok') await passkeys.register({ userId, response: created.response });
626
+ const asserted = await getPasskey(await passkeys.loginOptions());
627
+ if (asserted.status === 'ok') {
628
+ const { actor, cookie } = await passkeys.login({ response: asserted.response });
629
+ }
630
+
631
+ // Step-up: the WebAuthn challenge is derived from a nonce, the user and a digest the SERVER
632
+ // computes from the action's input — a caller can only present what was asked for.
633
+ const erase = passkeyStepUp<{ readonly memberId: string }>(auth, {
634
+ ttlMs: 60_000,
635
+ digest: ({ input }) => `erase-member:v1:${input.memberId}`,
636
+ });
637
+ ```
638
+
639
+ | Export | What |
640
+ |---|---|
641
+ | `passkeyLogin(auth)` | `{ registerOptions({ userId }), register({ userId, response }), loginOptions(), login({ response, ip?, userAgent? }) }` — `login` answers `login()`'s `LoginResult`, MFA-satisfied |
642
+ | `passkeyStepUp(auth, { digest, ttlMs? })` | `{ options({ userId, input }), verify({ userId, input, response }) }`; `verify` answers the stored `PasskeyEvidence` or throws `X_PASSKEY_STEP_UP_REQUIRED` (no proof, a proof for other bytes, no passkey registered) |
643
+ | `verifyPasskeyEvidence(evidence, { rpId, origins })` | `true` only when the row is a user-verified signature, by its own key, over the challenge its nonce, digest and user derive — no adapter, no clock: a separate process re-checks it |
644
+ | `@ultimat3/auth/client` | `newPasskey`, `getPasskey`, `passkeysSupported`: outcomes `ok` / `cancelled` / `unsupported` / `failed`, never a throw. Two modules, ~2.3 kB minified (`client-bundle.test.ts`) |
645
+ | testing a ceremony | `softAuthenticator({ origin })` from `@ultimat3/auth/soft-authenticator`: real WebCrypto keys answering `create` / `get` — this package's own suites and an app's share the one implementation and the one path |
646
+ | `PasskeyStore` | the nine `AuthAdapter` members over `x_passkeys`, `x_passkey_challenges` (sha256 of the challenge, TTL, taken by `DELETE … RETURNING`) and `x_passkey_evidence`; both adapters, one suite (`passkey-store.test.ts`, PGlite) |
647
+
648
+ Every ceremony checks `clientDataJSON`'s type, the origin exactly, `rpIdHash`, UP **and** UV, the
649
+ signature under the stored key's algorithm, and a counter that must move forward unless both are
650
+ zero. Registration asks `attestation: 'none'` and trusts no statement. Which check failed is the
651
+ `auth.passkey.refused` log line; the code is `X_PASSKEY_INVALID`, `X_PASSKEY_CHALLENGE_EXPIRED`
652
+ (unknown, spent or late — one answer) or `X_PASSKEY_STEP_UP_REQUIRED`.
653
+
654
+ ## Authorization server
655
+
656
+ `authorizationServer({ issuer, store, consentPath, lifetimes, scopes, … })` (`As of 2026-11`) — an
657
+ OAuth 2.1 server: authorization code + PKCE (S256 only), refresh rotation with replay revocation,
658
+ `client_credentials`, the device grant, RFC 7009/7662/7591/8414/8707/9207. `routes` go in
659
+ `runtime.routes`; the consent and device pages are the app's (`readRequest`/`approve`/`deny`,
660
+ `readDevice`/`approveDevice`/`denyDevice`). `oauthBearerResolver(server, { actor, resource? })` is
661
+ the `resolveToken`. Stores: `memoryAuthServerStore()`, `postgresAuthServerStore({ executor })`
662
+ over `SQL_AUTH_SERVER_TABLES`, which the boot applies. Full table and the rule each test holds:
663
+ [wiki Auth](https://github.com/developerz-ai/ultimate/wiki/Auth#authorization-server).
664
+
665
+ | File | Job |
666
+ |---|---|
667
+ | `authorization-server.ts` | the factory and the consent calls |
668
+ | `authorization-server-config.ts` | options, screened once (`X_AUTH_SERVER_INVALID`) |
669
+ | `authorization-server-request.ts` | screening an authorize request — pure |
670
+ | `authorization-server-clients.ts` · `-register.ts` | metadata-document and registered clients, secrets · RFC 7591 |
671
+ | `authorization-server-tokens.ts` | opaque hashed tokens, PKCE, `resolve` |
672
+ | `grant-authorization-code.ts` · `grant-client-credentials.ts` · `grant-device.ts` | the grants |
673
+ | `token-endpoints.ts` | revocation, introspection |
674
+ | `authorization-server-routes.ts` · `-wire.ts` | the routes · the structural `Route` shape, forms, client auth, RFC 6749 JSON |
675
+ | `authorization-server-store.ts` · `-postgres*.ts` · `-schema.ts` | the contract + memory · Postgres · DDL |
676
+ | `oauth-bearer-resolver.ts` | grant → `{ actor, scopes }`, per call |
677
+
557
678
  ## Revocation, offboarding and access review
558
679
 
559
680
  | Call | Blast radius |
@@ -895,10 +1016,20 @@ export const resolveKey = apiKeyResolver(() => {
895
1016
  | a store that FAILS | the throw, untouched — a database that is down is not a wrong key |
896
1017
 
897
1018
  The store is a THUNK, read when a token is presented: a mount is declared when its module is
898
- evaluated, and `postgresAuthAdapter()` takes the process's database client, which boot installs
899
- later. In a test the store is `memoryAuthAdapter()` — every `AuthAdapter` is an
1019
+ evaluated, and which store serves (memory or Postgres) is often decided at boot.
1020
+ `postgresAuthAdapter()` with no client reads the ambient `db()` on every call, so the instance can
1021
+ be built before boot installs the client, and joins an open `withTransaction`. In a test the store is `memoryAuthAdapter()` — every `AuthAdapter` is an
900
1022
  `ApiKeyVerifyStore` (an `ApiKeyStore` that can also `findUserById`).
901
1023
 
1024
+ ## A person's identity, for a subject export or erase
1025
+
1026
+ `authSubjectTarget({ executor, user })` is the identity tables as a `defineSubject({ targets })`
1027
+ entry (`@ultimat3/jobs`). `user(subject)` names the `x_users.id` the subject is
1028
+ (`({ row }) => String(row.userId)`), or `undefined` for none. The export carries the `x_users` row
1029
+ (`AUTH_SUBJECT_COLUMNS`) — never `password_hash`, `mfa_secret` or a recovery digest; the erase
1030
+ deletes the user (sessions and accounts cascade), their API keys and pending verifications. A
1031
+ user in several orgs is erased from all of them: list it `{ target, retain }` when that is wrong.
1032
+
902
1033
  ## Errors
903
1034
 
904
1035
  | Code | When |
@@ -918,6 +1049,10 @@ later. In a test the store is `memoryAuthAdapter()` — every `AuthAdapter` is a
918
1049
  | `X_API_KEY_INVALID` | key unknown, revoked, expired, wrong, or owned by a user who is gone or disabled |
919
1050
  | `X_ENV_MISSING` | `oauthCredentials()` found no client id or secret for an enabled provider |
920
1051
  | `X_AUTH_WRITE_FAILED` | an `AuthAdapter` lost a write it accepted — `emailVerifiedNotStored(provider, userId)` when `updateUser` drops the OAuth verified stamp |
1052
+ | `X_PASSKEY_INVALID` | a passkey response failed its signature, origin, rp id, UP/UV flag, owner or counter check |
1053
+ | `X_PASSKEY_CHALLENGE_EXPIRED` | a passkey ceremony named a challenge that is unknown, already used or past its TTL |
1054
+ | `X_PASSKEY_STEP_UP_REQUIRED` | a step-up `verify` with no proof, a proof for other bytes, or a user with no passkey |
1055
+ | `X_AUTH_SIGN_IN_UNCONFIGURED` | `signInUnconfigured(role, missing)`, thrown by the boot guard `x g sign-in` writes into the app: a `web` or `sync` role outside development and test with sign-in keys unset. Off the socket — a boot refusal, never a response |
921
1056
 
922
1057
  ```bash
923
1058
  bun test packages/auth
@@ -932,3 +1067,21 @@ a job boundary the class is gone and the `code` is what survives — match on th
932
1067
  | Class | Code | Declared in |
933
1068
  |---|---|---|
934
1069
  | `AuthError` | any `AuthErrorCode` — `AUTH_ERROR_CODES` | `src/errors.ts` |
1070
+
1071
+ ## Supporting exports
1072
+
1073
+ `As of 2026-11-02`.
1074
+
1075
+ | Name | What |
1076
+ |---|---|
1077
+ | `JwtBearerCaller` | what `jwtBearerResolver` answers: `{ actor, scopes }`, structurally `ApiKeyCaller` |
1078
+ | `JwtBearerResolverOptions` | what `jwtBearerResolver` takes |
1079
+ | `JwtBearerCheck` | the check a refusal's log line names: a `WorkloadCheck`, or `key-set`, `scope`, `accept` |
1080
+ | `WorkloadCheck` · `WorkloadTokenCheck` | `checkWorkloadToken`'s verdict: the token, or the check that refused it |
1081
+ | `PostgresTotpReplayGuard` | the `TotpReplayGuard` `postgresTotpReplayGuard` returns |
1082
+ | `PostgresTotpReplayGuardOptions` | what `postgresTotpReplayGuard` takes |
1083
+ | `TotpReplayFactory` | `() => TotpReplayGuard`: what `configureTotpReplay` takes |
1084
+ | `PasskeyPolicy` · `PasskeyPolicyInput` · `PasskeyRelyingParty` · `DEFAULT_PASSKEY_CHALLENGE_TTL_MS` | the `passkeys` block, resolved and as written; the `{ rpId, origins }` a verifier needs |
1085
+ | `PasskeyCredential` · `PasskeyChallengeRecord` · `PasskeyEvidence` · `PasskeyCeremony` · `PasskeyStore` | the passkey rows and the adapter slice that stores them |
1086
+ | `PasskeyCreationOptions` · `PasskeyRequestOptions` · `PasskeyRegistrationResponse` · `PasskeyAssertionResponse` · `PasskeyCredentialDescriptor` | the JSON wire of a ceremony (WebAuthn L3's `*JSON` dictionaries) |
1087
+ | `PasskeyLogin` · `PasskeyStepUp` · `PasskeyStepUpOptions` | what the two factories answer and take |
package/package.json CHANGED
@@ -1,7 +1,20 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "29.3.2",
3
+ "version": "30.0.0",
4
4
  "description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
5
+ "keywords": [
6
+ "ultimate",
7
+ "bun",
8
+ "framework",
9
+ "auth",
10
+ "sessions",
11
+ "oauth",
12
+ "mfa",
13
+ "api-keys",
14
+ "passkeys",
15
+ "webauthn"
16
+ ],
17
+ "homepage": "https://github.com/developerz-ai/ultimate/wiki/Auth",
5
18
  "license": "MIT",
6
19
  "type": "module",
7
20
  "repository": {
@@ -14,7 +27,9 @@
14
27
  "provenance": true
15
28
  },
16
29
  "exports": {
17
- ".": "./src/index.ts"
30
+ ".": "./src/index.ts",
31
+ "./client": "./src/client.ts",
32
+ "./soft-authenticator": "./src/soft-authenticator.ts"
18
33
  },
19
34
  "files": [
20
35
  "src",
@@ -32,8 +47,8 @@
32
47
  "test": "bun test"
33
48
  },
34
49
  "dependencies": {
35
- "@ultimat3/core": "29.3.2",
36
- "@ultimat3/db": "29.3.2",
37
- "@ultimat3/schema": "29.3.2"
50
+ "@ultimat3/core": "30.0.0",
51
+ "@ultimat3/db": "30.0.0",
52
+ "@ultimat3/schema": "30.0.0"
38
53
  }
39
54
  }
package/src/adapter.ts CHANGED
@@ -178,7 +178,7 @@ export interface AccountStore {
178
178
 
179
179
  export interface AuthVerification {
180
180
  readonly id: string;
181
- /** `email-verify` | `password-reset` — see `verify.ts`. */
181
+ /** A declared purpose's name — `email-verify`, `password-reset` or any `defineVerificationPurpose`. */
182
182
  readonly purpose: string;
183
183
  /** The email address the token was issued for. */
184
184
  readonly identifier: string;
@@ -230,6 +230,91 @@ export interface ApiKeyStore {
230
230
  revokeApiKey(id: string, at: Date): Promise<boolean>;
231
231
  }
232
232
 
233
+ /** Which ceremony a stored challenge was issued for; a challenge redeems only for its own. */
234
+ export type PasskeyCeremony = 'register' | 'login' | 'step-up';
235
+
236
+ /**
237
+ * One registered passkey. Every byte field is base64url TEXT, so both adapters store and compare
238
+ * the same string and a row is readable in a SQL console.
239
+ */
240
+ export interface PasskeyCredential {
241
+ /** The credential id the authenticator minted — globally unique, the lookup key. */
242
+ readonly id: string;
243
+ readonly userId: string;
244
+ /** The COSE_Key exactly as registered; `importCoseKey` turns it back into a verifying key. */
245
+ readonly publicKey: string;
246
+ /** COSE algorithm: `-7` (ES256) or `-257` (RS256). */
247
+ readonly algorithm: number;
248
+ /** The last signature counter seen. `0` forever for an authenticator that keeps none. */
249
+ readonly signCount: number;
250
+ readonly transports: readonly string[];
251
+ readonly createdAt: Date;
252
+ readonly lastUsedAt: Date | null;
253
+ }
254
+
255
+ /** An issued, not yet redeemed ceremony challenge. Never the challenge itself: its digest. */
256
+ export interface PasskeyChallengeRecord {
257
+ /** `sha256Hex` of the base64url challenge — what `clientDataJSON` echoes, hashed. */
258
+ readonly id: string;
259
+ readonly ceremony: PasskeyCeremony;
260
+ /** The user it was issued to; `null` for a usernameless sign-in. */
261
+ readonly userId: string | null;
262
+ /** Step-up only: the server digest and the nonce the challenge was derived from, base64url. */
263
+ readonly digest: string | null;
264
+ readonly nonce: string | null;
265
+ readonly expiresAt: Date;
266
+ readonly createdAt: Date;
267
+ }
268
+
269
+ /**
270
+ * A verified step-up assertion, kept whole: everything `verifyPasskeyEvidence` needs to check it
271
+ * again later, in another process, with no other row — the key included, so deleting the passkey
272
+ * does not un-prove what it approved.
273
+ */
274
+ export interface PasskeyEvidence {
275
+ readonly id: string;
276
+ readonly userId: string;
277
+ readonly credentialId: string;
278
+ /** The bytes the person approved, base64url. */
279
+ readonly digest: string;
280
+ readonly nonce: string;
281
+ readonly authenticatorData: string;
282
+ readonly clientDataJSON: string;
283
+ readonly signature: string;
284
+ readonly publicKey: string;
285
+ readonly algorithm: number;
286
+ readonly createdAt: Date;
287
+ }
288
+
289
+ export interface PasskeyStore {
290
+ /** A credential id already stored — any user's — is `X_AUTH_WRITE_FAILED` (§7.1 step 22). */
291
+ putPasskey(credential: PasskeyCredential): Promise<PasskeyCredential>;
292
+ findPasskey(id: string): Promise<PasskeyCredential | null>;
293
+ /** Oldest first. */
294
+ listPasskeys(userId: string): Promise<readonly PasskeyCredential[]>;
295
+ /**
296
+ * Compare-and-set on the counter: write `next` (and `at` as last use) only while the row still
297
+ * holds `expected`, in ONE atomic step. Two assertions racing on one counter value must not
298
+ * both advance it — that is the cloned-authenticator signal.
299
+ */
300
+ advancePasskeyCounter(id: string, expected: number, next: number, at: Date): Promise<boolean>;
301
+ /** Scoped to the owner: one user cannot delete another's passkey by naming its id. */
302
+ deletePasskey(userId: string, id: string): Promise<boolean>;
303
+ /** Also drops every challenge that expired before `record.createdAt`, so the table stays bounded. */
304
+ putPasskeyChallenge(record: PasskeyChallengeRecord): Promise<void>;
305
+ /**
306
+ * Read AND delete in one atomic step, only for its own ceremony: two concurrent redemptions of
307
+ * one challenge must not both find it. Expiry is the caller's to judge, on its clock.
308
+ */
309
+ takePasskeyChallenge(
310
+ id: string,
311
+ ceremony: PasskeyCeremony,
312
+ ): Promise<PasskeyChallengeRecord | null>;
313
+ putPasskeyEvidence(evidence: PasskeyEvidence): Promise<void>;
314
+ /** Every stored approval of one digest, oldest first. */
315
+ listPasskeyEvidence(digest: string): Promise<readonly PasskeyEvidence[]>;
316
+ }
317
+
233
318
  /**
234
319
  * The full seam. One blessed implementation ships (`PostgresAuthAdapter`); Better Auth, or any
235
320
  * other identity backend, binds by implementing this and nothing else changes upstream.
@@ -239,7 +324,8 @@ export interface AuthAdapter
239
324
  SessionStore,
240
325
  AccountStore,
241
326
  VerificationStore,
242
- ApiKeyStore {
327
+ ApiKeyStore,
328
+ PasskeyStore {
243
329
  /** Reported by `Auth.adapter.name` so the driver in use is never a guess. */
244
330
  readonly name: string;
245
331
  /**