@ultimat3/auth 29.4.0 → 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.
- package/CLAUDE.md +46 -58
- package/README.md +162 -9
- package/package.json +20 -5
- package/src/adapter.ts +88 -2
- package/src/api-key-resolver.ts +3 -3
- package/src/auth.ts +29 -7
- package/src/authorization-server-clients.ts +169 -0
- package/src/authorization-server-config.ts +239 -0
- package/src/authorization-server-errors.ts +23 -0
- package/src/authorization-server-postgres-sql.ts +107 -0
- package/src/authorization-server-postgres.ts +222 -0
- package/src/authorization-server-register.ts +98 -0
- package/src/authorization-server-request.ts +126 -0
- package/src/authorization-server-routes.ts +181 -0
- package/src/authorization-server-schema.ts +82 -0
- package/src/authorization-server-store.ts +189 -0
- package/src/authorization-server-tokens.ts +118 -0
- package/src/authorization-server-types.ts +108 -0
- package/src/authorization-server-wire.ts +135 -0
- package/src/authorization-server.ts +178 -0
- package/src/base64url.ts +40 -0
- package/src/builtin-adapter-violation.ts +12 -9
- package/src/builtin-adapter.ts +31 -5
- package/src/cbor-encode.ts +52 -0
- package/src/cbor.ts +129 -0
- package/src/client-scope.ts +2 -5
- package/src/client.ts +16 -0
- package/src/cose-key.ts +129 -0
- package/src/errors.ts +51 -0
- package/src/grant-authorization-code.ts +113 -0
- package/src/grant-client-credentials.ts +55 -0
- package/src/grant-device.ts +188 -0
- package/src/index.ts +108 -3
- package/src/jwks.ts +81 -12
- package/src/jwt-bearer-resolver.ts +80 -0
- package/src/limiter-install.ts +31 -2
- package/src/memory-adapter.ts +18 -0
- package/src/mfa-challenge.ts +13 -7
- package/src/mfa-replay-postgres.ts +71 -0
- package/src/mfa.ts +24 -13
- package/src/oauth-bearer-resolver.ts +44 -0
- package/src/oauth-cookie.ts +2 -1
- package/src/oauth-discovery.ts +17 -6
- package/src/oauth-exchange.ts +23 -12
- package/src/oauth-profile.ts +20 -9
- package/src/oauth-route.ts +5 -5
- package/src/passkey-assertion.ts +60 -0
- package/src/passkey-challenge.ts +59 -0
- package/src/passkey-client.ts +156 -0
- package/src/passkey-config.ts +92 -0
- package/src/passkey-errors.ts +89 -0
- package/src/passkey-evidence.ts +74 -0
- package/src/passkey-login.ts +127 -0
- package/src/passkey-memory.ts +77 -0
- package/src/passkey-postgres.ts +155 -0
- package/src/passkey-step-up.ts +135 -0
- package/src/passkey-verify.ts +180 -0
- package/src/passkey-wire.ts +61 -0
- package/src/soft-authenticator.ts +208 -0
- package/src/subject-target.ts +73 -0
- package/src/tables.ts +46 -0
- package/src/token-endpoints.ts +61 -0
- package/src/tokens.ts +2 -25
- package/src/verification-purposes.ts +61 -0
- package/src/verify.ts +86 -12
- package/src/webauthn-data.ts +111 -0
- 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`.
|
|
25
|
-
|
|
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.
|
|
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.
|
|
62
|
-
|
|
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
|
|
87
|
-
|
|
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
|
-
|
|
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`.**
|
|
112
|
-
|
|
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`). `
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
|
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
|
|
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"`.
|
|
255
|
-
| `
|
|
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
|
-
- `
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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('
|
|
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
|
-
| `
|
|
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
|
-
|
|
503
|
-
|
|
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
|
|
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
|
|
899
|
-
|
|
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": "
|
|
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": "
|
|
36
|
-
"@ultimat3/db": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
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
|
|
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
|
/**
|