@ultimat3/auth 24.0.0 → 25.1.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 +25 -14
- package/README.md +96 -39
- package/package.json +5 -5
- package/src/adapter.ts +6 -2
- package/src/api-key-resolver.ts +2 -2
- package/src/api-keys.ts +2 -2
- package/src/auth.ts +11 -11
- package/src/builtin-adapter-violation.ts +1 -1
- package/src/builtin-adapter.ts +17 -4
- package/src/email.ts +1 -1
- package/src/errors.ts +3 -3
- package/src/guards.ts +2 -2
- package/src/id-token.ts +2 -3
- package/src/index.ts +11 -10
- package/src/jwks.ts +7 -7
- package/src/kdf-gate.ts +6 -6
- package/src/limiter-install.ts +1 -1
- package/src/memory-adapter.ts +32 -7
- package/src/mfa.ts +5 -5
- package/src/oauth-cookie.ts +10 -4
- package/src/oauth-grant-context.ts +106 -0
- package/src/oauth-login.ts +29 -15
- package/src/oauth-profile.ts +48 -11
- package/src/oauth.ts +4 -3
- package/src/policy-bridge.ts +9 -11
- package/src/privileges.ts +25 -9
- package/src/rate-limit.ts +3 -3
- package/src/session.ts +67 -9
- package/src/tables.ts +1 -1
- package/src/tokens.ts +2 -5
- package/src/verify.ts +2 -1
package/CLAUDE.md
CHANGED
|
@@ -12,7 +12,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
12
12
|
| Better Auth | binds through `AuthAdapter`. It is an adapter, never a dependency. |
|
|
13
13
|
| Errors | `AuthError` from `errors.ts`; never `throw new Error` |
|
|
14
14
|
| Time | take a `Clock`. No `Date.now()` anywhere in this package. |
|
|
15
|
-
| Secrets | compare with `timingSafeEqual` (from `@ultimat3/core
|
|
15
|
+
| Secrets | compare with `timingSafeEqual` (imported from `@ultimat3/core` — never re-exported here, same implementation `@ultimat3/storage` uses); store `sha256Hex`. Never `===` on a secret. |
|
|
16
16
|
|
|
17
17
|
## Non-negotiables
|
|
18
18
|
|
|
@@ -63,7 +63,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
63
63
|
the pool is the host's.
|
|
64
64
|
- **`configureAuthLimiters` is the HOST's install point and takes a FACTORY**, called with the
|
|
65
65
|
RESOLVED policy once per bucket; the comparison still runs on what comes back. Precedence:
|
|
66
|
-
`config.limiter` → the installed factory → `
|
|
66
|
+
`config.limiter` → the installed factory → `memoryAuthLimiter`. `installedAuthLimiter` and
|
|
67
67
|
`installedLimiterCount()` are NOT in `src/index.ts`. `purgeAuthLimits()` sweeps only the
|
|
68
68
|
**widest** window among the built limiters (retained one per `windowMs` in a `Map`), on the
|
|
69
69
|
limiter's own clock. `AuthLimiter.purgeExpired` is optional.
|
|
@@ -86,6 +86,9 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
86
86
|
`alg: none` are refused in `decodeJwtHeader`. It checks `nbf` and `azp` too.
|
|
87
87
|
- **`resolveGrants` is a seam, never a group-to-role table**, called on EVERY login. Absent leaves
|
|
88
88
|
the row alone. A user created with no roles and no org logs a warning.
|
|
89
|
+
Its second argument (`OAuthGrantContext`, `oauth-grant-context.ts`, #670) is the token set, the
|
|
90
|
+
whole verified id-token payload and a LAZY, once-per-login, `sub`-checked `userinfo()` — read
|
|
91
|
+
through `oauth-profile.ts`'s `userinfoBody`, never a second userinfo reader.
|
|
89
92
|
- **`verifySession` writes at most once per `idleSlideMs`** (default `idleTtlMs / 20`). Never cache
|
|
90
93
|
the USER row — `authenticate` re-reads it every request so a revoked role bites on the next one.
|
|
91
94
|
In `observed`, `null` is an ANSWER (clears the stored value) and `undefined` is silence.
|
|
@@ -112,6 +115,8 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
112
115
|
`FIX_SHELL_ARG_PINS`). `${provider}` is registry-validated boot config, not foreign.
|
|
113
116
|
- Cookies are read through `@ultimat3/core`'s `readCookie` (`readSessionCookie`, the oauth
|
|
114
117
|
handshake), which never throws on a malformed value (`decodeURIComponent('%')`).
|
|
118
|
+
They are WRITTEN through its `serializeSetCookie`, never a literal (`bun run set-cookie-literals`,
|
|
119
|
+
`X_SET_COOKIE_HAND_BUILT`); `cookie-golden.test.ts` pins the bytes.
|
|
115
120
|
- A token endpoint's HTTP 200 is not success — read `error`.
|
|
116
121
|
- Link by address only when the provider **and** the local account both verified it
|
|
117
122
|
(`link: 'verified-email'`, default; `'never'` is the only other value).
|
|
@@ -140,18 +145,20 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
140
145
|
changed underneath is `skipped` — and `x doctor` counts what is left. Whether a value is sealed is
|
|
141
146
|
`isSealed`'s to say, never a `like` in an adapter (`listUsersWithMfaSecret` returns them all).
|
|
142
147
|
- **`providerJwks` memoises only the DEFAULT client**; a caller supplying options gets its own.
|
|
143
|
-
- **A JWKS refresh is single-flighted through core's `
|
|
144
|
-
`deadlineMs = timeoutMs * 2`**; eviction frees the key, never the work, and a `
|
|
148
|
+
- **A JWKS refresh is single-flighted through core's `singleFlight` with
|
|
149
|
+
`deadlineMs = timeoutMs * 2`**; eviction frees the key, never the work, and a `generationFence`
|
|
145
150
|
generation check (read, never `guard`) stops a superseded refresh overwriting the cache.
|
|
146
151
|
`schedule` is injectable.
|
|
147
152
|
- **A success clears the ACCOUNT bucket and nothing else** — clearing the address bucket made the
|
|
148
153
|
limiter inert against stuffing. A password proven with a second factor still owed clears
|
|
149
154
|
NOTHING: `completeMfa` is what clears, or a repeat of the first leg wipes the wrong codes.
|
|
150
|
-
- **`
|
|
155
|
+
- **`MemoryAuthAdapter.createUser` and `updateUser` enforce `x_users.id`, `email` and `external_id`
|
|
151
156
|
uniqueness** (`authUniqueViolation`, `X_AUTH_WRITE_FAILED`), NULLS DISTINCT like Postgres;
|
|
152
|
-
`
|
|
153
|
-
**`
|
|
157
|
+
`PostgresAuthAdapter` answers the same code through `builtin-adapter-violation.ts`.
|
|
158
|
+
**`MemoryAuthAdapter` takes a `Clock`** (default `systemClock`) and stamps every instant from it.
|
|
154
159
|
`adapter-parity.test.ts` pins both.
|
|
160
|
+
- **`listUsersByOrg` orders by byte**: `collate "C"` in Postgres, a code-point compare in memory —
|
|
161
|
+
never `localeCompare`, never UTF-16 `<` (`list-users-order.live.test.ts`, on an `en_US` db).
|
|
155
162
|
- Every `AuthAdapter` member is REQUIRED — a missing one is a compile error at `defineAuth`, and
|
|
156
163
|
no caller in this package refuses at runtime with `X_NOT_IMPLEMENTED`.
|
|
157
164
|
- **An api key is its owner's credential.** `verifyApiKey` re-reads the owner (after the secret
|
|
@@ -160,19 +167,22 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
160
167
|
keys beside their sessions, and logs before its first write.
|
|
161
168
|
- **A key's scopes only ever shrink** (`apiKeyScopes`, `policy-bridge.ts`): `*` and `<res>:*` are
|
|
162
169
|
refused at `issueApiKey` (`X_CONFIG_INVALID`) and dropped from a stored row; an OWNED key keeps
|
|
163
|
-
only what its owner's grants cover. Roles are policy's to expand, so the default grants are the
|
|
170
|
+
only what its owner's grants cover (core's `grantCovers`, never a local copy). Roles are policy's to expand, so the default grants are the
|
|
164
171
|
row's `permissions` + `scopes` (`directGrants`) and a role-based app passes
|
|
165
172
|
`apiKeyResolver(store, { grantsOf })`. Never union a key with the owner's roles.
|
|
166
173
|
- **`apiKeyResolver(() => store)` is the ONE token → caller mapping** (`api-key-resolver.ts`):
|
|
167
174
|
`verifyApiKey` → `apiKeyActor`, `X_API_KEY_INVALID` → `null`, everything else rethrown. Its
|
|
168
175
|
`ApiKeyCaller` is structurally http's `BearerCaller` and mcp's `ResolvedToken` — declared here
|
|
169
176
|
because tier 2 imports neither. The store is a thunk (a mount is declared before boot installs
|
|
170
|
-
the database client). No second in-memory key store: `
|
|
177
|
+
the database client). No second in-memory key store: `MemoryAuthAdapter` IS an `ApiKeyStore`.
|
|
171
178
|
- Rotate the session id on any privilege change (`rotateSession`, called by `updatePrivileges` in
|
|
172
179
|
`privileges.ts`), never patch the row. A `passwordHash` change also ends every OTHER session of
|
|
173
180
|
that user (`deleteOtherSessions`), or all of them when the caller passed no session of theirs.
|
|
181
|
+
**A new id, never a new lifetime**: rotation carries `createdAt` and `absoluteExpiresAt` over
|
|
182
|
+
(`createSession` clamps both, so they only shorten), and the rotated cookie's `Max-Age` is
|
|
183
|
+
`remainingMaxAgeSeconds` — `session-rotation.test.ts` pins both adapters.
|
|
174
184
|
- **Every argon2 call goes through `kdfGate()`** — width 8, queue 64, `X_OVERLOADED` past it
|
|
175
|
-
(borrowed from http, in `AUTH_BORROWED_ERROR_CODES`). The pool is core's `
|
|
185
|
+
(borrowed from http, in `AUTH_BORROWED_ERROR_CODES`). The pool is core's `flightGate`, the
|
|
176
186
|
refusal auth's own through core's `overflow:` seam. `configureKdfGate()` is the ONE install point
|
|
177
187
|
and deliberately not a `defineAuth` key.
|
|
178
188
|
- **MFA's second leg is `completeMfa(auth, challenge, code)`** (`mfa-challenge.ts`). `login()`
|
|
@@ -191,7 +201,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
191
201
|
- **A TOTP secret that decodes to zero bytes is NO key**: `verifyTotp` returns the generic failure
|
|
192
202
|
(`{ ok: false, step: null }`), `totpCode` and `enrolTotp` throw `X_MFA_SECRET_INVALID`. The secret
|
|
193
203
|
never reaches `cause:`/`fix:`. No length floor beyond one byte.
|
|
194
|
-
- **`
|
|
204
|
+
- **`memoryTotpReplayGuard`'s table is bounded and the eviction ORDER is the guarantee**: subjects
|
|
195
205
|
whose steps are all below the drift floor are forgotten first; past `DEFAULT_MAX_TOTP_SUBJECTS`,
|
|
196
206
|
evict by newest spent step ascending, least-recently-seen tie-break. `maxSubjects` is normalised
|
|
197
207
|
first (`boundedSubjects`): anything not a positive finite integer takes the default.
|
|
@@ -227,14 +237,15 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
227
237
|
| `id-token.ts` | id token → claims this handshake may believe |
|
|
228
238
|
| `id-token-fixture.ts` | the one string-input JWT builder the OAuth tests share. Off `index.ts` |
|
|
229
239
|
| `auth-fixture.ts` | the KDF parameters, the password and the `AuthError` catcher the three `auth*.test.ts` suites share. Off `index.ts` |
|
|
230
|
-
| `oauth-profile.ts` | claims or userinfo → one `OAuthProfile` |
|
|
240
|
+
| `oauth-profile.ts` | claims or userinfo → one `OAuthProfile`; `userinfoBody`, the one userinfo reader |
|
|
241
|
+
| `oauth-grant-context.ts` | `resolveGrants`' second argument: tokens, every id-token claim, a lazy subject-checked `userinfo()` |
|
|
231
242
|
| `oauth-login.ts` | profile → account link → session. `completeOAuthLogin` is the entry point |
|
|
232
243
|
| `oauth-login-fixture.ts` | the adapter, clock and profile the three `oauth-login*` suites share. Off `index.ts` |
|
|
233
244
|
| `oauth-paths.ts` | the one declaration of where the two routes live. Imports nothing |
|
|
234
245
|
| `errors.ts` | the codes this package owns and borrows, their titles, the one `registerErrorCodes()` call, `AuthError`, and every non-OAuth factory |
|
|
235
246
|
| `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 |
|
|
236
247
|
| `oauth-route.ts` | `oauthLogin(auth)` — the redirect out and the callback back |
|
|
237
|
-
| `kdf-gate.ts` | the one bound on concurrent argon2 work, and the `X_OVERLOADED` past it — core's `
|
|
248
|
+
| `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 |
|
|
238
249
|
| `email.ts` | `normaliseEmail` — the one normalisation an address gets before it is an identity key |
|
|
239
250
|
| `json.ts` | reading untrusted JSON: `isRecord`, and a base64url JWT segment as an object or `null` |
|
|
240
251
|
| `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 |
|
|
@@ -252,7 +263,7 @@ Gotchas:
|
|
|
252
263
|
this package **owns**, unconditionally, and lists the borrowed two in `AUTH_BORROWED_ERROR_CODES`
|
|
253
264
|
without a title. A `hasErrorCode()` guard would suppress the `X_ERROR_CODE_DUPLICATE` that is
|
|
254
265
|
supposed to fire when two packages claim one code.
|
|
255
|
-
- Tests run against `
|
|
266
|
+
- Tests run against `MemoryAuthAdapter`; the one ADAPTER test that boots a database is
|
|
256
267
|
`adapter-parity-identity.test.ts` (PGlite). The two other exceptions are
|
|
257
268
|
`postgresAuthLimiter`'s, and they are exceptions in the shape the repo already has: the
|
|
258
269
|
scripted-executor twin (`rate-limit-postgres.test.ts`) proves the protocol with no server,
|
package/README.md
CHANGED
|
@@ -5,10 +5,10 @@ authorizes on a session row, a user row or an api key — http, actions, jobs an
|
|
|
5
5
|
`ctx.actor` and hand it to `@ultimat3/policy`. One authz system, never two.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
|
-
import {
|
|
8
|
+
import { defineAuth, login, oauthLogin, postgresAuthAdapter } from '@ultimat3/auth';
|
|
9
9
|
|
|
10
10
|
export const auth = defineAuth({
|
|
11
|
-
adapter:
|
|
11
|
+
adapter: postgresAuthAdapter(), // or memoryAuthAdapter(), or your Better Auth binding
|
|
12
12
|
session: { absoluteTtlMs: 30 * 864e5, idleTtlMs: 7 * 864e5 },
|
|
13
13
|
password: { minLength: 12 },
|
|
14
14
|
mfa: { issuer: 'Acme' }, // the authenticator app's name; `required` only as `false`
|
|
@@ -46,7 +46,7 @@ const { start, callback } = oauthLogin(auth);
|
|
|
46
46
|
|
|
47
47
|
## The lockout across replicas
|
|
48
48
|
|
|
49
|
-
`
|
|
49
|
+
`memoryAuthLimiter` keeps its table in the process, so `maxAttempts: 5` at `replicas: 3` lets an
|
|
50
50
|
account survive 15 guesses and hides each replica's lockout from the other two. An app that runs
|
|
51
51
|
more than one process says so and brings a limiter that says the same:
|
|
52
52
|
|
|
@@ -96,7 +96,7 @@ const { identity } = await verifyWorkloadToken({
|
|
|
96
96
|
token,
|
|
97
97
|
issuers: ['https://kubernetes.default.svc'],
|
|
98
98
|
audience: 'https://ledger.internal',
|
|
99
|
-
keys:
|
|
99
|
+
keys: jwksClient({ provider: 'k8s', jwksUri: 'https://kubernetes.default.svc/openid/v1/jwks' }),
|
|
100
100
|
clock,
|
|
101
101
|
});
|
|
102
102
|
const actor = actorFromService(identity); // kind: 'service', id: the caller's own sub
|
|
@@ -193,7 +193,7 @@ A **factory** and not a limiter, because the host runs before the app: `defineAu
|
|
|
193
193
|
limiter enforces against what the app declared, so a limiter built at boot on the framework
|
|
194
194
|
defaults would be `X_AUTH_LIMITER_POLICY_MISMATCH` for every app that tuned its numbers. The
|
|
195
195
|
factory is called once per bucket, with the resolved policy, so the two halves cannot disagree.
|
|
196
|
-
Precedence is `defineAuth({ limiter })` → the installed factory → `
|
|
196
|
+
Precedence is `defineAuth({ limiter })` → the installed factory → `memoryAuthLimiter`, and
|
|
197
197
|
`resetAuthLimiters()` puts the per-process default back. `@ultimat3/cli`'s `startServices` calls it
|
|
198
198
|
on every boot, so a scaffolded app gets a fleet-wide lockout with nothing to remember.
|
|
199
199
|
|
|
@@ -263,7 +263,7 @@ because a default is what silently makes a second door as trusting as the first.
|
|
|
263
263
|
| `keys` | Means |
|
|
264
264
|
|---|---|
|
|
265
265
|
| `'token-endpoint-tls'` | this token came off a TLS response from the provider's own token endpoint — the one case OIDC Core 3.1.3.7 exempts. `exchangeOAuthCode` passes it, and that is the only shipped call site that may |
|
|
266
|
-
| a `JwksKeySource` | the signature is checked. `providerJwks(providerFor(id))`, or `
|
|
266
|
+
| a `JwksKeySource` | the signature is checked. `providerJwks(providerFor(id))`, or `jwksClient({ provider, jwksUri })` |
|
|
267
267
|
|
|
268
268
|
```ts
|
|
269
269
|
const keys = providerJwks(providerFor('bigco-sso'));
|
|
@@ -302,6 +302,31 @@ convention and business convention never ships ([axiom
|
|
|
302
302
|
**every** login — so removing somebody from a group in the IdP takes effect at their next sign-in,
|
|
303
303
|
rather than never. A seam that returns the stored answer writes nothing.
|
|
304
304
|
|
|
305
|
+
**The profile is not all the IdP said.** `resolveGrants(profile, context)` takes a second argument
|
|
306
|
+
(`OAuthGrantContext`) for an IdP that keeps authorization facts off the id token — groups,
|
|
307
|
+
entitlements, an `allowed_oauth_apps` claim served on `/userinfo` only:
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
import { defineAuth, memoryAuthAdapter, oauthLogin } from '@ultimat3/auth';
|
|
311
|
+
|
|
312
|
+
const auth = defineAuth({ adapter: memoryAuthAdapter(), providers: ['google'] });
|
|
313
|
+
|
|
314
|
+
export const { start, callback } = oauthLogin(auth, {
|
|
315
|
+
resolveGrants: async (_profile, context) => {
|
|
316
|
+
const claims = await context.userinfo();
|
|
317
|
+
const apps = Array.isArray(claims['allowed_oauth_apps']) ? claims['allowed_oauth_apps'] : [];
|
|
318
|
+
return apps.includes('bank-integrations') ? { roles: ['member'] } : { roles: [] };
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
| `context.` | What it is |
|
|
324
|
+
|---|---|
|
|
325
|
+
| `provider` | the provider id this login came through |
|
|
326
|
+
| `tokens` | the exchange's `OAuthTokens`, in flight — the framework stores none of them; keeping one is the app storing a credential |
|
|
327
|
+
| `idTokenClaims` | every claim of the VERIFIED id token, the ones `OAuthProfile` drops included (`hd`, `groups`); `null` without one |
|
|
328
|
+
| `userinfo()` | the provider's userinfo body, every claim, read with the access token — lazily (no call, no request) and at most once per login; none at all when the profile was already read off userinfo (GitHub), whose body it hands back. A body naming another `sub` is `X_OAUTH_EXCHANGE_FAILED`; so is a provider with no userinfo endpoint, and a failed read, which is not remembered |
|
|
329
|
+
|
|
305
330
|
## MFA — TOTP and recovery codes
|
|
306
331
|
|
|
307
332
|
TOTP is pure functions over a secret and a clock; what is STORED goes through two writers and one
|
|
@@ -339,7 +364,7 @@ every enrolled user's second factor.
|
|
|
339
364
|
|---|---|
|
|
340
365
|
| `enrolTotp(auth, { account, issuer?, secret? })` | `{ secret, uri, digits, periodSeconds }` — `issuer` omitted is `auth.mfa.issuer`, `secret` omitted mints one |
|
|
341
366
|
| `verifyTotp({ secret, code, at, drift?, usedSteps? })` | `{ ok, step }`. `step` is the window the code belonged to, `null` on no match |
|
|
342
|
-
| `
|
|
367
|
+
| `memoryTotpReplayGuard(drift?, maxSubjects?)` | the in-process `{ isUsed, remember, size }`; a fleet passes a Redis-backed pair of the same two methods |
|
|
343
368
|
| `generateRecoveryCodes(count = 10)` | `{ codes, hashes }`. `codes` is shown once and is never re-derivable |
|
|
344
369
|
| `recoveryCodeHash(code)` | what a code is stored and looked up as — dashes, spaces and case removed first. `auth.adapter.consumeRecoveryCode(userId, hash)` removes it in ONE statement and answers whether it was there, so two requests carrying one code cannot both redeem it |
|
|
345
370
|
| `totpStep(at, stepSeconds?)` / `totpCode(secret, step, digits?)` | the RFC 6238 halves, for a test that has to mint a valid code. `totpCode` throws `X_MFA_SECRET_INVALID` on a secret that decodes to zero bytes |
|
|
@@ -438,14 +463,14 @@ import {
|
|
|
438
463
|
consumeVerification,
|
|
439
464
|
issueVerification,
|
|
440
465
|
type MailSender,
|
|
441
|
-
|
|
466
|
+
memoryAuthAdapter,
|
|
442
467
|
type VerificationRuntime,
|
|
443
468
|
} from '@ultimat3/auth';
|
|
444
469
|
import { systemClock } from '@ultimat3/core';
|
|
445
470
|
|
|
446
471
|
declare const mail: MailSender; // the app wires @ultimat3/mail's `send` here
|
|
447
472
|
|
|
448
|
-
const runtime: VerificationRuntime = { store:
|
|
473
|
+
const runtime: VerificationRuntime = { store: memoryAuthAdapter(), clock: systemClock, mail };
|
|
449
474
|
|
|
450
475
|
const issued = await issueVerification(runtime, {
|
|
451
476
|
purpose: 'password-reset',
|
|
@@ -484,9 +509,15 @@ unauthenticated POST with any token cannot kill the victim's live link.
|
|
|
484
509
|
| `revokeOrgSessions(auth, orgId, reason)` | one tenant, at 03:00, without touching another |
|
|
485
510
|
| `revokeSessionsCreatedBefore(auth, at, reason)` | everything minted under a rotated secret |
|
|
486
511
|
| `disableUser(auth, userId, reason)` | stamps `disabledAt`, kills the sessions **and** revokes the user's live api keys — `{ user, sessionsRevoked, apiKeysRevoked }`. Re-enabling restores neither |
|
|
487
|
-
| `listOrgUsers(auth, orgId, { role })` | the quarterly access review, as safe summaries |
|
|
512
|
+
| `listOrgUsers(auth, orgId, { role })` | the quarterly access review, as safe summaries — in byte (code-point) order of `email` on both adapters, whatever the database collation |
|
|
488
513
|
| `updatePrivileges(auth, userId, patch, session?)` | the grant, plus the session rotation it requires. A `passwordHash` change ends every OTHER session of that user (`sessionsRevoked`) — all of them when no session of theirs was passed, as in a reset |
|
|
489
514
|
|
|
515
|
+
Rotation (`rotateSession`, and `updatePrivileges` through it) mints a new **id**, never a new
|
|
516
|
+
lifetime: `createdAt` and `absoluteExpiresAt` carry over, so repeated grants cannot outlive the
|
|
517
|
+
original ceiling and `revokeSessionsCreatedBefore` still catches a rotated session. The rotated
|
|
518
|
+
cookie's `Max-Age` counts down to that ceiling. `createSession` accepts the same two instants and
|
|
519
|
+
clamps them — `createdAt` to now, the ceiling to `createdAt + absoluteTtlMs` — so they only shorten.
|
|
520
|
+
|
|
490
521
|
`reason` is a required argument on every revocation, for the reason `crossTenant()` requires one:
|
|
491
522
|
an incident review asks who killed these sessions and why, and a `delete` with no line answers
|
|
492
523
|
neither. Each one logs `auth.revocation` before it runs.
|
|
@@ -505,8 +536,8 @@ an adapter implementation, not a dependency of this package.
|
|
|
505
536
|
|
|
506
537
|
| Driver | Use |
|
|
507
538
|
|---|---|
|
|
508
|
-
| `
|
|
509
|
-
| `
|
|
539
|
+
| `postgresAuthAdapter(client?, clock?)` | Postgres via `@ultimat3/db` (the `PostgresAuthAdapter` type) — the process client and `systemClock` by default; the clock stamps `x_verifications.consumed_at` |
|
|
540
|
+
| `memoryAuthAdapter(clock?)` | `x new` before a database exists, and every test in this package (the `MemoryAuthAdapter` type). Its clock — `systemClock` by default — stamps every instant it writes |
|
|
510
541
|
| your own | implement `AuthAdapter`; DDL in `tables.ts` shows what the columns mean |
|
|
511
542
|
|
|
512
543
|
**An adapter stores and matches the address it is handed — it never folds case.** `x_users.email`
|
|
@@ -549,7 +580,10 @@ row would not be, and nothing does.
|
|
|
549
580
|
|
|
550
581
|
## Cookie
|
|
551
582
|
|
|
552
|
-
`__Host-x_session`, set by `sessionCookie(token, policy)`.
|
|
583
|
+
`__Host-x_session`, set by `sessionCookie(token, policy)`. A **rotated** session keeps its
|
|
584
|
+
original ceiling, so its cookie takes the time left, not the full TTL:
|
|
585
|
+
`sessionCookie(issued.token, policy, { maxAgeSeconds: remainingMaxAgeSeconds(issued.session, clock.now()) })`
|
|
586
|
+
— what `updatePrivileges` already does for you.
|
|
553
587
|
|
|
554
588
|
| Attribute | Attack it closes |
|
|
555
589
|
|---|---|
|
|
@@ -559,6 +593,13 @@ row would not be, and nothing does.
|
|
|
559
593
|
| `__Host-` + `Path=/` + no `Domain` | a sibling subdomain overwriting it (session fixation) |
|
|
560
594
|
| `Max-Age` | a client keeping it past the server's absolute ceiling |
|
|
561
595
|
|
|
596
|
+
Every cookie this package writes — the session, its clearing line, the OAuth handshake and its
|
|
597
|
+
clearing line — is built by `@ultimat3/core`'s `serializeSetCookie`, never spelled by hand
|
|
598
|
+
(`bun run set-cookie-literals`). `As of 2026-10-06` the attributes are written `Max-Age=…; Path=/`
|
|
599
|
+
rather than `Path=/; Max-Age=…` — the same cookie to a browser, which gives attribute order no
|
|
600
|
+
meaning — and a cookie `name` that is not an RFC 6265 token is `X_COOKIE_INVALID` instead of a
|
|
601
|
+
line a browser would mangle.
|
|
602
|
+
|
|
562
603
|
## Signing out
|
|
563
604
|
|
|
564
605
|
`logout(auth, token)` ends the session row. The **response** does the rest: append
|
|
@@ -595,17 +636,39 @@ the previous scope's persisted rows and queued writes).
|
|
|
595
636
|
`beginOAuth`, the handshake cookie and `completeOAuthLogin`. Provider configs stay pure data —
|
|
596
637
|
importing `oauth.ts` performs no network I/O and reads no env.
|
|
597
638
|
|
|
639
|
+
Mount both in the app's own server through **`runtime.routes`**: `apps/<app>/runtime.ts`
|
|
640
|
+
exporting `runtime` (`RuntimeOverrides`). `x dev` and `runRole` both read that file and mount its
|
|
641
|
+
routes through the whole pipeline — `configureAuthenticator`, the rate limiter, the security
|
|
642
|
+
headers, the SIGTERM drain. Never a second `Bun.serve` beside the app: it stands on its own
|
|
643
|
+
socket, outside every one of those, and a login flow is the last surface that should run
|
|
644
|
+
unthrottled and unheadered.
|
|
645
|
+
|
|
598
646
|
```ts
|
|
647
|
+
// apps/web/runtime.ts
|
|
648
|
+
import {
|
|
649
|
+
type AuthRouteDescriptor,
|
|
650
|
+
defineAuth,
|
|
651
|
+
oauthLogin,
|
|
652
|
+
postgresAuthAdapter,
|
|
653
|
+
} from '@ultimat3/auth';
|
|
654
|
+
import type { ServeOptions } from '@ultimat3/cli/serve';
|
|
655
|
+
import type { Route } from '@ultimat3/http';
|
|
656
|
+
|
|
657
|
+
// The app's one `auth` — imported from wherever it declares it.
|
|
658
|
+
const auth = defineAuth({ adapter: postgresAuthAdapter(), providers: ['github'] });
|
|
599
659
|
const { start, callback } = oauthLogin(auth);
|
|
600
660
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
},
|
|
661
|
+
// A descriptor takes a bare `Request`: the pipeline's `UltimateRequest` carries it as `raw`.
|
|
662
|
+
const mounted = (leg: AuthRouteDescriptor): Route => ({
|
|
663
|
+
method: leg.method,
|
|
664
|
+
path: leg.path,
|
|
665
|
+
handler: (request) => leg.handle(request.raw),
|
|
666
|
+
meta: { name: leg.name, auth: leg.auth },
|
|
608
667
|
});
|
|
668
|
+
|
|
669
|
+
export const runtime: NonNullable<ServeOptions['runtime']> = {
|
|
670
|
+
routes: [start, callback].map(mounted),
|
|
671
|
+
};
|
|
609
672
|
```
|
|
610
673
|
|
|
611
674
|
| | `start` | `callback` |
|
|
@@ -617,20 +680,9 @@ Bun.serve({
|
|
|
617
680
|
A **descriptor**, never a mounted handler — the same category as `mcpHttpRoute()`. `@ultimat3/http`
|
|
618
681
|
is tier 2 like this package, so auth may not import it, and `defineRoute` is tier 4 and describes
|
|
619
682
|
a rendered page. A bare `Request` in, a `Response` out: drivable from a test, mountable by any
|
|
620
|
-
router that can match a `:param
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
(`apps/web/server.ts` is three lines that call it), and `As of 2026-08` `ServeOptions` has no
|
|
624
|
-
routes seam — the route list is built inside `serveApp` and closed. So a second `Bun.serve` in an
|
|
625
|
-
app does not extend that server, it stands beside it: on its own socket, outside the pipeline, and
|
|
626
|
-
therefore outside `configureAuthenticator`, the rate limiter, the security headers and the
|
|
627
|
-
SIGTERM drain. A login flow is the last surface that should be the one running unthrottled and
|
|
628
|
-
unheadered.
|
|
629
|
-
|
|
630
|
-
Until the seam exists, an app serving these descriptors serves them itself and pays for all of
|
|
631
|
-
that itself — a second port to publish and health-check, its own throttle in front of `callback`,
|
|
632
|
-
its own security headers, and a drain that does not strand a handshake mid-flight. There is no
|
|
633
|
-
mounting API to call today; do not write one, and do not read this section as promising one.
|
|
683
|
+
router that can match a `:param` — and `runtime.routes` is the one an app has. It mounts after the
|
|
684
|
+
framework's own routes and before the pages; every field it shares the file with is in
|
|
685
|
+
[`wiki/Configuration.md`](../../wiki/Configuration.md#runtime-overrides--appsappruntimets).
|
|
634
686
|
|
|
635
687
|
**The paths are not configurable.** `X_OAUTH_STATE_INVALID` has always told the caller to restart
|
|
636
688
|
at `GET /auth/oauth/<provider>`; it now quotes `oauthStartPath()`, the same declaration the mount
|
|
@@ -712,6 +764,9 @@ JWT signed with the `.p8` key, which Apple expires every six months.
|
|
|
712
764
|
the status alone there mints a session from a failed exchange.
|
|
713
765
|
- An address is only linked to an existing account when **both** sides verified it (`link:
|
|
714
766
|
'verified-email'`). Otherwise whoever registered the address first inherits the login.
|
|
767
|
+
- An address the provider did **not** verify never creates an account either — it is the same
|
|
768
|
+
`loginFailed()` the taken-address case answers. Minting one is a pre-hijack: the owner's
|
|
769
|
+
`register()` then collides and their verified login is refused, with the attacker still linked.
|
|
715
770
|
|
|
716
771
|
## API keys — how an agent authenticates
|
|
717
772
|
|
|
@@ -741,7 +796,9 @@ default set:
|
|
|
741
796
|
|
|
742
797
|
- `*` and `<resource>:*` are refused at `issueApiKey` (`X_CONFIG_INVALID`), and dropped from a row
|
|
743
798
|
that already holds one.
|
|
744
|
-
- A key a user owns keeps only the scopes that owner's grants cover
|
|
799
|
+
- A key a user owns keeps only the scopes that owner's grants cover — `@ultimat3/core`'s
|
|
800
|
+
`grantCovers`, the reading `can()` uses: an owner with `billing:invoice:*` covers
|
|
801
|
+
`billing:invoice:read`, never `billing:refund:issue`. This package can read the
|
|
745
802
|
row's `permissions` and `scopes` (`directGrants`) and cannot expand a role — that is
|
|
746
803
|
`@ultimat3/policy`'s — so an app whose users hold roles says what they grant:
|
|
747
804
|
|
|
@@ -766,13 +823,13 @@ for both doors.
|
|
|
766
823
|
|
|
767
824
|
```ts
|
|
768
825
|
import type { ApiKeyVerifyStore } from '@ultimat3/auth';
|
|
769
|
-
import { apiKeyResolver,
|
|
826
|
+
import { apiKeyResolver, postgresAuthAdapter } from '@ultimat3/auth';
|
|
770
827
|
|
|
771
828
|
let keys: ApiKeyVerifyStore | undefined;
|
|
772
829
|
|
|
773
830
|
/** `resolveToken: resolveKey` — on the mount, and on `defineAppMcp()`. */
|
|
774
831
|
export const resolveKey = apiKeyResolver(() => {
|
|
775
|
-
keys ??=
|
|
832
|
+
keys ??= postgresAuthAdapter();
|
|
776
833
|
return keys;
|
|
777
834
|
});
|
|
778
835
|
```
|
|
@@ -784,8 +841,8 @@ export const resolveKey = apiKeyResolver(() => {
|
|
|
784
841
|
| a store that FAILS | the throw, untouched — a database that is down is not a wrong key |
|
|
785
842
|
|
|
786
843
|
The store is a THUNK, read when a token is presented: a mount is declared when its module is
|
|
787
|
-
evaluated, and `
|
|
788
|
-
later. In a test the store is `
|
|
844
|
+
evaluated, and `postgresAuthAdapter()` takes the process's database client, which boot installs
|
|
845
|
+
later. In a test the store is `memoryAuthAdapter()` — every `AuthAdapter` is an
|
|
789
846
|
`ApiKeyVerifyStore` (an `ApiKeyStore` that can also `findUserById`).
|
|
790
847
|
|
|
791
848
|
## Errors
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/auth",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.1.0",
|
|
4
4
|
"description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,15 +25,15 @@
|
|
|
25
25
|
"LICENSE"
|
|
26
26
|
],
|
|
27
27
|
"engines": {
|
|
28
|
-
"bun": ">=1.4.
|
|
28
|
+
"bun": ">=1.4.2"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/db": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
35
|
+
"@ultimat3/core": "25.1.0",
|
|
36
|
+
"@ultimat3/db": "25.1.0",
|
|
37
|
+
"@ultimat3/schema": "25.1.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/adapter.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Single responsibility: the persistence seam. `AuthAdapter` is the one interface auth talks
|
|
2
2
|
// to, split into per-concern stores so a test (or a caller) can satisfy just the slice it uses.
|
|
3
3
|
// Better Auth binds here — it is an adapter implementation, not a dependency. The blessed
|
|
4
|
-
// default is `
|
|
4
|
+
// default is `PostgresAuthAdapter` in `builtin-adapter.ts`; the DDL it expects is in `tables.ts`.
|
|
5
5
|
|
|
6
6
|
export interface AuthUser {
|
|
7
7
|
readonly id: string;
|
|
@@ -137,6 +137,10 @@ export interface SessionStore {
|
|
|
137
137
|
getSession(id: string): Promise<AuthSession | null>;
|
|
138
138
|
createSession(session: AuthSession): Promise<AuthSession>;
|
|
139
139
|
updateSession(id: string, patch: SessionPatch): Promise<AuthSession | null>;
|
|
140
|
+
/**
|
|
141
|
+
* Remove one session and answer whether THIS call removed it, in one atomic step: `rotateSession`
|
|
142
|
+
* treats `true` as its claim on the id, so two concurrent deletes must not both answer `true`.
|
|
143
|
+
*/
|
|
140
144
|
deleteSession(id: string): Promise<boolean>;
|
|
141
145
|
/** Returns how many were killed — the "sign out everywhere else" number shown to the user. */
|
|
142
146
|
deleteOtherSessions(userId: string, keepSessionId: string): Promise<number>;
|
|
@@ -227,7 +231,7 @@ export interface ApiKeyStore {
|
|
|
227
231
|
}
|
|
228
232
|
|
|
229
233
|
/**
|
|
230
|
-
* The full seam. One blessed implementation ships (`
|
|
234
|
+
* The full seam. One blessed implementation ships (`PostgresAuthAdapter`); Better Auth, or any
|
|
231
235
|
* other identity backend, binds by implementing this and nothing else changes upstream.
|
|
232
236
|
*/
|
|
233
237
|
export interface AuthAdapter
|
package/src/api-key-resolver.ts
CHANGED
|
@@ -32,8 +32,8 @@ export interface ApiKeyResolverOptions extends ApiKeyActorOptions {
|
|
|
32
32
|
* `defineAppMcp()` — one resolver for both.
|
|
33
33
|
*
|
|
34
34
|
* `store` is a THUNK, read when a token is presented and never captured: a mount is declared when
|
|
35
|
-
* its module is evaluated, and `
|
|
36
|
-
* boot installs later. `() =>
|
|
35
|
+
* its module is evaluated, and `postgresAuthAdapter()` takes the process's database client, which
|
|
36
|
+
* boot installs later. `() => memoryAuthAdapter()` would be a new, empty store per request — hold
|
|
37
37
|
* the instance and return it.
|
|
38
38
|
*/
|
|
39
39
|
export function apiKeyResolver(
|
package/src/api-keys.ts
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
// evaluation a human does. The plaintext is shown once; only its SHA-256 is ever stored, and
|
|
5
5
|
// lookup happens by the non-secret id so the secret never appears in a query, an index or a log.
|
|
6
6
|
|
|
7
|
-
import { type Clock, randomHex, systemClock } from '@ultimat3/core';
|
|
7
|
+
import { type Clock, randomHex, systemClock, timingSafeEqual } from '@ultimat3/core';
|
|
8
8
|
import type { ApiKeyStore, AuthApiKeyRecord, AuthUser, UserStore } from './adapter';
|
|
9
9
|
import { apiKeyEnvInvalid, apiKeyInvalid, apiKeyScopeWildcard } from './errors';
|
|
10
10
|
import type { PolicyActor } from './policy-bridge';
|
|
11
11
|
import { actorFromApiKey, isWildcardScope } from './policy-bridge';
|
|
12
|
-
import { randomToken, sha256Hex
|
|
12
|
+
import { randomToken, sha256Hex } from './tokens';
|
|
13
13
|
|
|
14
14
|
export const API_KEY_NAMESPACE = 'ult';
|
|
15
15
|
|
package/src/auth.ts
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
// (http, actions, jobs, MCP) authorizes on that and never on a session row, so there is one
|
|
4
4
|
// identity shape in the framework and `@ultimat3/policy` is the only thing that reads it.
|
|
5
5
|
|
|
6
|
-
import { type Clock, systemClock,
|
|
6
|
+
import { type Clock, systemClock, timingSafeEqual, uuidV7 } from '@ultimat3/core';
|
|
7
7
|
import { t } from '@ultimat3/schema';
|
|
8
8
|
import type { AuthAdapter, AuthSession, AuthUser } from './adapter';
|
|
9
9
|
import { normaliseEmail } from './email';
|
|
10
10
|
import { mfaRequiredUnenforceable, sessionUnknown } from './errors';
|
|
11
11
|
import { installedAuthLimiter } from './limiter-install';
|
|
12
12
|
import { openLoginAttempt } from './login-attempt';
|
|
13
|
-
import {
|
|
13
|
+
import { memoryTotpReplayGuard, type TotpReplayGuard } from './mfa';
|
|
14
14
|
import { mfaChallengeRequired } from './mfa-challenge';
|
|
15
15
|
import type { OAuthProviderId } from './oauth';
|
|
16
16
|
import {
|
|
@@ -27,9 +27,9 @@ import {
|
|
|
27
27
|
type AuthRateLimitPolicy,
|
|
28
28
|
accountKey,
|
|
29
29
|
assertAuthLimiterPolicy,
|
|
30
|
-
createAuthLimiter,
|
|
31
30
|
DEFAULT_AUTH_RATE_LIMIT,
|
|
32
31
|
loginFailed,
|
|
32
|
+
memoryAuthLimiter,
|
|
33
33
|
orgRateLimit,
|
|
34
34
|
} from './rate-limit';
|
|
35
35
|
import {
|
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
sessionCookie,
|
|
42
42
|
verifySession,
|
|
43
43
|
} from './session';
|
|
44
|
-
import { sha256Hex
|
|
44
|
+
import { sha256Hex } from './tokens';
|
|
45
45
|
|
|
46
46
|
// The projections safe to hand to a client or an MCP tool: no password hash, no TOTP secret,
|
|
47
47
|
// no token hash. The private columns live in `adapter.ts` and never leave the server.
|
|
@@ -136,7 +136,7 @@ export interface AuthConfigInput {
|
|
|
136
136
|
readonly password?: Partial<PasswordPolicy> | undefined;
|
|
137
137
|
readonly rateLimit?: Partial<AuthRateLimitPolicy> | undefined;
|
|
138
138
|
/**
|
|
139
|
-
* Where failed attempts are counted. Omitted means `
|
|
139
|
+
* Where failed attempts are counted. Omitted means `memoryAuthLimiter`, which is one process'
|
|
140
140
|
* worth of state — correct for dev and tests, and `maxAttempts × N` for N replicas. An app that
|
|
141
141
|
* runs more than one declares `rateLimit.scope: 'shared'` and passes a limiter that says the
|
|
142
142
|
* same, or `defineAuth` refuses here rather than at 3am on the first spray.
|
|
@@ -150,7 +150,7 @@ export interface AuthConfigInput {
|
|
|
150
150
|
readonly mfa?: Partial<AuthMfaPolicy> | undefined;
|
|
151
151
|
/**
|
|
152
152
|
* Which TOTP steps have been spent, read and written by `completeMfa`. Omitted means
|
|
153
|
-
* `
|
|
153
|
+
* `memoryTotpReplayGuard()` — one process' worth of memory, so a code is single-use per replica;
|
|
154
154
|
* a fleet that needs it single-use everywhere passes a shared guard with the same two methods.
|
|
155
155
|
*/
|
|
156
156
|
readonly totpReplay?: TotpReplayGuard | undefined;
|
|
@@ -197,13 +197,13 @@ export function defineAuth(config: AuthConfigInput): Auth {
|
|
|
197
197
|
// scaffolded app had to remember to build a shared limiter itself, which is the opposite of
|
|
198
198
|
// what this framework promises — and `x new` scaffolds two replicas.
|
|
199
199
|
const limiter =
|
|
200
|
-
config.limiter ?? installedAuthLimiter(rateLimit) ??
|
|
200
|
+
config.limiter ?? installedAuthLimiter(rateLimit) ?? memoryAuthLimiter(clock, rateLimit);
|
|
201
201
|
assertAuthLimiterPolicy(rateLimit, limiter);
|
|
202
202
|
// The tenant bucket is a noisy-neighbour cap, not a credential-guessing allowance, so an app
|
|
203
203
|
// that declares `scope: 'shared'` for its LOCKOUT is not also required to ship a shared limiter
|
|
204
204
|
// for this one — per replica it approximates to `orgMaxAttempts × replicas`, which is a
|
|
205
205
|
// throughput ceiling and discloses nothing. Only the LOCAL fallback is exempt, and exempting it
|
|
206
|
-
// is what buys that: `
|
|
206
|
+
// is what buys that: `memoryAuthLimiter` always reports `'process'`, which is the one arm the
|
|
207
207
|
// scope check would refuse. A limiter somebody else supplied — injected by the app or built by
|
|
208
208
|
// the host's factory — is compared exactly as the general bucket's is, because a factory that
|
|
209
209
|
// ignores the policy it was handed otherwise enforces numbers the app never declared while
|
|
@@ -211,7 +211,7 @@ export function defineAuth(config: AuthConfigInput): Auth {
|
|
|
211
211
|
// is refused for one bucket and trusted for the other.
|
|
212
212
|
const orgLimits = orgRateLimit(rateLimit);
|
|
213
213
|
const suppliedOrgLimiter = config.orgLimiter ?? installedAuthLimiter(orgLimits);
|
|
214
|
-
const orgLimiter = suppliedOrgLimiter ??
|
|
214
|
+
const orgLimiter = suppliedOrgLimiter ?? memoryAuthLimiter(clock, orgLimits);
|
|
215
215
|
if (suppliedOrgLimiter !== undefined) assertAuthLimiterPolicy(orgLimits, suppliedOrgLimiter);
|
|
216
216
|
// Read through a widened local on purpose: the field's type is the literal `false`, so this
|
|
217
217
|
// branch is unreachable from TypeScript and reachable from every JS caller and every config
|
|
@@ -232,7 +232,7 @@ export function defineAuth(config: AuthConfigInput): Auth {
|
|
|
232
232
|
orgRateLimit: orgLimiter.policy,
|
|
233
233
|
orgLimiter,
|
|
234
234
|
mfa,
|
|
235
|
-
totpReplay: config.totpReplay ??
|
|
235
|
+
totpReplay: config.totpReplay ?? memoryTotpReplayGuard(),
|
|
236
236
|
// BREAKING (majors only): the default is `[]`, never the live registry.
|
|
237
237
|
//
|
|
238
238
|
// It was `oauthProviderIds()`, so `defineAuth({ providers })`'s own documented purpose — the
|
|
@@ -259,7 +259,7 @@ export interface RegisterInput {
|
|
|
259
259
|
export async function register(auth: Auth, input: RegisterInput): Promise<AuthUser> {
|
|
260
260
|
checkPasswordStrength(input.password, { policy: auth.password });
|
|
261
261
|
return await auth.adapter.createUser({
|
|
262
|
-
id:
|
|
262
|
+
id: uuidV7(auth.clock),
|
|
263
263
|
email: normaliseEmail(input.email),
|
|
264
264
|
passwordHash: await hashPassword(input.password, auth.password.params),
|
|
265
265
|
orgId: input.orgId ?? null,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Single responsibility: an `x_users` unique violation as the code `
|
|
1
|
+
// Single responsibility: an `x_users` unique violation as the code `MemoryAuthAdapter` answers. One
|
|
2
2
|
// duplicate signup must not be `X_AUTH_WRITE_FAILED` under `x dev` and `X_DB_UNIQUE_VIOLATION` in
|
|
3
3
|
// production: an app that branches on the code passes its tests and misses on Postgres.
|
|
4
4
|
|