@ultimat3/auth 2.0.0 → 3.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 +44 -0
- package/README.md +25 -4
- package/package.json +4 -4
- package/src/auth.ts +26 -4
- package/src/errors.ts +20 -0
- package/src/index.ts +4 -0
- package/src/mfa.ts +104 -9
- package/src/password.ts +48 -8
package/CLAUDE.md
CHANGED
|
@@ -18,6 +18,18 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
18
18
|
|
|
19
19
|
- Every credential failure throws `loginFailed()` — one code, one cause, one fix. Adding a
|
|
20
20
|
parameter to it re-opens account enumeration.
|
|
21
|
+
- **A stored hash Bun cannot read is the generic failure, and it burns the same KDF** (`As of
|
|
22
|
+
2026-08`). `Bun.password.verify` THROWS rather than answering `false` on an unsupported algorithm
|
|
23
|
+
(a Django `pbkdf2_sha256$…` row: `UnsupportedAlgorithm`) or a malformed PHC string
|
|
24
|
+
(`InvalidEncoding`), so `verifyPassword` catching nothing was two faults at once: a bare `Error`
|
|
25
|
+
out of `login()` — a 500 where `loginFailed()`'s `X_UNAUTHENTICATED` belongs — and an enumeration
|
|
26
|
+
oracle on exactly the rows that have not migrated off the legacy scheme, which is the normal
|
|
27
|
+
state of a table mid-migration. `verifyAgainst` (`password.ts`) answers `null` there, and `null`
|
|
28
|
+
joins the no-user branch, `''` with it. Nothing is logged: the algorithm of an unreadable hash is
|
|
29
|
+
the same oracle one layer down. An `AuthError` out of the gate (`X_OVERLOADED`) is **re-thrown**,
|
|
30
|
+
never folded into the failure — a shed is load, not a verdict. Supported-but-old stays a verdict:
|
|
31
|
+
bcrypt verifies natively and `needsRehash` flags it, which is the rehash-on-login lever a legacy
|
|
32
|
+
migration rewrites rows with, and `password.test.ts` pins both halves.
|
|
21
33
|
- The limiter's table is **bounded**, and the eviction order is part of the guarantee. `ipKey`
|
|
22
34
|
mints one entry per source address, so half the keys are attacker-chosen and a spray from an
|
|
23
35
|
IPv6 /64 is a fresh key per attempt. Every bucket carries `forgetAtMs` — window emptied *and*
|
|
@@ -182,6 +194,38 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
182
194
|
`auth.limiter` around `verifyTotp` — today it is wired only into `login`, so a six-digit code
|
|
183
195
|
would be the one credential in this package with no lockout. `TotpReplayGuard` is already built
|
|
184
196
|
and must be the completion's, not a second one.
|
|
197
|
+
- **`mfa.required` is the literal `false`, and `defineAuth` refuses a `true` that reaches it
|
|
198
|
+
anyway** (`X_CONFIG_INVALID`, borrowed from core), `As of 2026-08`. It resolved onto the frozen
|
|
199
|
+
`Auth` and **nothing read it** — `login()` and `signInWithOAuth()` branch on `user.mfaSecret`
|
|
200
|
+
alone and mint `mfaSatisfied: true` otherwise, so `required: true` handed a user who had never
|
|
201
|
+
enrolled a fully-privileged session while reading as "this deployment requires a second factor".
|
|
202
|
+
Enforcing it was the tempting fix and is a **lockout**: `actorFromUser` degrades a session only
|
|
203
|
+
when `mfaSecret !== null`, so an un-enrolled user has no half-authenticated actor to enrol
|
|
204
|
+
through, this package ships no enrolment route to send them to, and `mfaRequired()`'s own `fix:`
|
|
205
|
+
(`verifyTotp({ secret: user.mfaSecret, … })`) cannot be followed with a null secret — a dead
|
|
206
|
+
`fix:`, the defect `oauth-paths.ts` exists to stop. So the unenforceable declaration is refused
|
|
207
|
+
where it is written, exactly as `assertAuthLimiterPolicy` refuses a per-process limiter under a
|
|
208
|
+
fleet-wide lockout. The literal type is the build error and the runtime check is for the JS
|
|
209
|
+
caller and the JSON config the type cannot reach, the split `invariantColumns()`'s Proxy keeps.
|
|
210
|
+
`mfa.issuer` is the half that stayed, and it now has a reader: `enrolTotp(auth, { account })`
|
|
211
|
+
takes the `Auth` every other entry point takes, so the product name an authenticator app shows
|
|
212
|
+
is declared once at `defineAuth` instead of restated at every enrolment.
|
|
213
|
+
- **`createTotpReplayGuard`'s table is bounded, and the eviction ORDER is the guarantee.** It
|
|
214
|
+
pruned steps inside one subject's `Set` and never revisited a subject who stopped signing in, so
|
|
215
|
+
the map carried one permanent entry per user for the life of the process. Evicting a subject
|
|
216
|
+
makes a step they have already spent replayable again, so the order cannot be recency of
|
|
217
|
+
insertion: a subject whose every step has fallen below the drift floor is **forgotten** (nothing
|
|
218
|
+
outside ±drift is ever offered to `verifyTotp`, so that entry answers exactly as a missing one),
|
|
219
|
+
and only if that is not enough does `DEFAULT_MAX_TOTP_SUBJECTS` evict live state, sorted by
|
|
220
|
+
newest spent step ascending with a least-recently-seen tie-break — the subject who just proved a
|
|
221
|
+
code is always the last one out. Same shape as the limiter's "a live lockout outranks its own
|
|
222
|
+
deadline". `remember` re-files its subject so the map's iteration order IS that tie-break.
|
|
223
|
+
`maxSubjects` is **normalised before any of that arithmetic runs** (`boundedSubjects`): the cap
|
|
224
|
+
ran on the caller's number unchecked, so `Infinity` left the table exactly as unbounded as it was
|
|
225
|
+
before it was capped, and `NaN` — every comparison against which is false — made the eviction
|
|
226
|
+
loop's `used.size <= evictTo` never true, emptying the table on the first sweep and handing back
|
|
227
|
+
a replay of the code the subject had just spent. Anything that is not a positive finite integer
|
|
228
|
+
takes `DEFAULT_MAX_TOTP_SUBJECTS`; a fraction still floors.
|
|
185
229
|
- SAML is out of scope permanently: XML-DSig canonicalisation has no Bun native and would need a
|
|
186
230
|
real dependency. Put an OIDC-speaking bridge in front and register that.
|
|
187
231
|
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ export const auth = defineAuth({
|
|
|
11
11
|
adapter: new BuiltinAdapter(), // or MemoryAdapter, or your Better Auth binding
|
|
12
12
|
session: { absoluteTtlMs: 30 * 864e5, idleTtlMs: 7 * 864e5 },
|
|
13
13
|
password: { minLength: 12 },
|
|
14
|
-
mfa: { issuer: 'Acme' },
|
|
14
|
+
mfa: { issuer: 'Acme' }, // the authenticator app's name; `required` only as `false`
|
|
15
15
|
providers: ['github', 'google'],
|
|
16
16
|
link: 'verified-email', // the default; `'never'` is the only other value
|
|
17
17
|
});
|
|
@@ -215,10 +215,13 @@ rows, because `AuthAdapter` has no MFA member and adding one would break every t
|
|
|
215
215
|
adapter.
|
|
216
216
|
|
|
217
217
|
```ts
|
|
218
|
+
import type { Auth } from '@ultimat3/auth';
|
|
218
219
|
import { createTotpReplayGuard, enrolTotp, generateRecoveryCodes, verifyTotp } from '@ultimat3/auth';
|
|
219
220
|
import { systemClock } from '@ultimat3/core';
|
|
220
221
|
|
|
221
|
-
const
|
|
222
|
+
declare const auth: Auth; // the `defineAuth` at the top — `enrolTotp` reads `auth.mfa.issuer`
|
|
223
|
+
|
|
224
|
+
const enrolment = enrolTotp(auth, { account: 'ada@example.com' }); // issuer: auth.mfa.issuer
|
|
222
225
|
// enrolment.uri -> otpauth://… the QR code
|
|
223
226
|
// enrolment.secret -> base32, store it against the user
|
|
224
227
|
|
|
@@ -236,9 +239,9 @@ export function secondFactorHolds(userId: string, secret: string, code: string):
|
|
|
236
239
|
|
|
237
240
|
| Call | Answers |
|
|
238
241
|
|---|---|
|
|
239
|
-
| `enrolTotp({
|
|
242
|
+
| `enrolTotp(auth, { account, issuer?, secret? })` | `{ secret, uri, digits, periodSeconds }` — `issuer` omitted is `auth.mfa.issuer`, `secret` omitted mints one |
|
|
240
243
|
| `verifyTotp({ secret, code, at, drift?, usedSteps? })` | `{ ok, step }`. `step` is the window the code belonged to, `null` on no match |
|
|
241
|
-
| `createTotpReplayGuard(drift?)` | the in-process `{ isUsed, remember }`; a fleet passes a Redis-backed pair of the same two methods |
|
|
244
|
+
| `createTotpReplayGuard(drift?, maxSubjects?)` | the in-process `{ isUsed, remember, size }`; a fleet passes a Redis-backed pair of the same two methods |
|
|
242
245
|
| `generateRecoveryCodes(count = 10)` | `{ codes, hashes }`. `codes` is shown once and is never re-derivable |
|
|
243
246
|
| `redeemRecoveryCode(code, hashes)` | the **remaining** hashes, or `null`. Persisting that array is what makes a code single-use |
|
|
244
247
|
| `totpStep(at, stepSeconds?)` / `totpCode(secret, step, digits?)` | the RFC 6238 halves, for a test that has to mint a valid code |
|
|
@@ -251,6 +254,24 @@ side of now, so without the guard the same six digits log in twice inside a minu
|
|
|
251
254
|
answers `{ ok: false, step }` — the step still named — when `usedSteps` already holds it, which is
|
|
252
255
|
how a replay is told apart from a wrong code.
|
|
253
256
|
|
|
257
|
+
The guard's table is **bounded** (`DEFAULT_MAX_TOTP_SUBJECTS`, 10,000), because a per-subject map
|
|
258
|
+
that only ever grows is one process' lifetime away from an OOM. A subject whose every remembered
|
|
259
|
+
step has fallen below the drift floor is *forgotten* — `verifyTotp` can never offer that step
|
|
260
|
+
again, so the entry answers exactly as a missing one — and only if that is not enough does the cap
|
|
261
|
+
evict live state, furthest from the live window first. The order is the guarantee: evicting a
|
|
262
|
+
subject makes a step they have already spent replayable, so the subject who just authenticated is
|
|
263
|
+
always the last one out.
|
|
264
|
+
|
|
265
|
+
**`mfa.required` is accepted only as `false`, and that is deliberate.** The field exists and is
|
|
266
|
+
typed as the literal `false`, so `required: true` is a compile error; a `true` that reaches
|
|
267
|
+
`defineAuth` from JavaScript or from JSON — where the type cannot — is refused at boot with
|
|
268
|
+
`X_CONFIG_INVALID` naming the key, never an unknown-key error and never a silent accept. Nothing
|
|
269
|
+
read it: both credential paths branch on `user.mfaSecret` alone, so an un-enrolled user was handed
|
|
270
|
+
a full session under a config that read as "this deployment requires a second factor". Enforcing it
|
|
271
|
+
at `login()` instead is a lockout — `actorFromUser` degrades only a user who HAS a secret, and this
|
|
272
|
+
package ships no enrolment route to send the rest to. Gate it in your own sign-in handler —
|
|
273
|
+
`if (user.mfaSecret === null)` send them to `enrolTotp` before you call `createSession`.
|
|
274
|
+
|
|
254
275
|
**The second leg of login is the app's, `As of 2026-08`.** `login()` and `completeOAuthLogin()`
|
|
255
276
|
throw `X_MFA_REQUIRED` before any session exists; finishing the flow is `verifyTotp` followed by
|
|
256
277
|
`createSession({ mfaSatisfied: true })` in the app's own route. The framework ships no
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/auth",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/core": "3.0.0",
|
|
35
|
+
"@ultimat3/db": "3.0.0",
|
|
36
|
+
"@ultimat3/schema": "3.0.0"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/auth.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { type Clock, systemClock, uuid } 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
|
-
import { mfaRequired, sessionUnknown } from './errors';
|
|
10
|
+
import { mfaRequired, mfaRequiredUnenforceable, sessionUnknown } from './errors';
|
|
11
11
|
import type { OAuthProviderId } from './oauth';
|
|
12
12
|
import { oauthProviderIds } from './oauth-registry';
|
|
13
13
|
import {
|
|
@@ -106,10 +106,25 @@ export const VerificationSchema = t.object({
|
|
|
106
106
|
*/
|
|
107
107
|
export type OAuthLinkPolicy = 'verified-email' | 'never';
|
|
108
108
|
|
|
109
|
+
/** The product name an authenticator app shows when the app declared none. */
|
|
110
|
+
export const DEFAULT_MFA_ISSUER = 'Ultimate';
|
|
111
|
+
|
|
109
112
|
export interface AuthMfaPolicy {
|
|
110
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Shown in the authenticator app. Usually the product name, and `enrolTotp(auth, …)` reads it
|
|
115
|
+
* from here so it is written once — a call may still name its own for the one enrolment.
|
|
116
|
+
*/
|
|
111
117
|
readonly issuer: string;
|
|
112
|
-
|
|
118
|
+
/**
|
|
119
|
+
* The literal `false`, so `required: true` is a type error rather than a comment — the shape
|
|
120
|
+
* `OAuthProvider.usesPkce` has, and for the same reason: the value nothing enforces has to be
|
|
121
|
+
* unrepresentable, not discouraged. This package cannot make a second factor mandatory. Both
|
|
122
|
+
* credential paths branch on `user.mfaSecret` alone and `actorFromUser` degrades only a user
|
|
123
|
+
* who HAS a secret, so a user who never enrolled has no half-authenticated actor to enrol
|
|
124
|
+
* through and no enrolment route to reach — refusing them at `login()` locks them out for good.
|
|
125
|
+
* `defineAuth` refuses the declaration outright; the app gates its own sign-in handler.
|
|
126
|
+
*/
|
|
127
|
+
readonly required: false;
|
|
113
128
|
}
|
|
114
129
|
|
|
115
130
|
export interface AuthConfigInput {
|
|
@@ -166,6 +181,13 @@ export function defineAuth(config: AuthConfigInput): Auth {
|
|
|
166
181
|
const orgLimits = orgRateLimit(rateLimit);
|
|
167
182
|
const orgLimiter = config.orgLimiter ?? createAuthLimiter(clock, orgLimits);
|
|
168
183
|
if (config.orgLimiter !== undefined) assertAuthLimiterPolicy(orgLimits, config.orgLimiter);
|
|
184
|
+
// Read through a widened local on purpose: the field's type is the literal `false`, so this
|
|
185
|
+
// branch is unreachable from TypeScript and reachable from every JS caller and every config
|
|
186
|
+
// parsed out of JSON — the same split `invariantColumns()` keeps its Proxy behind a compile
|
|
187
|
+
// error for. A declaration this package cannot enforce is refused where it is written.
|
|
188
|
+
const declaredMfa: { readonly required?: unknown } = config.mfa ?? {};
|
|
189
|
+
if (declaredMfa.required === true) throw mfaRequiredUnenforceable();
|
|
190
|
+
const mfa: AuthMfaPolicy = { issuer: config.mfa?.issuer ?? DEFAULT_MFA_ISSUER, required: false };
|
|
169
191
|
return Object.freeze({
|
|
170
192
|
adapter: config.adapter,
|
|
171
193
|
clock,
|
|
@@ -177,7 +199,7 @@ export function defineAuth(config: AuthConfigInput): Auth {
|
|
|
177
199
|
// exactly when the app declared 'shared' and left this limiter to the default.
|
|
178
200
|
orgRateLimit: orgLimiter.policy,
|
|
179
201
|
orgLimiter,
|
|
180
|
-
mfa
|
|
202
|
+
mfa,
|
|
181
203
|
providers: config.providers ?? oauthProviderIds(),
|
|
182
204
|
link: config.link ?? 'verified-email',
|
|
183
205
|
});
|
package/src/errors.ts
CHANGED
|
@@ -174,6 +174,26 @@ export const mfaRequired = (userId: string): AuthError =>
|
|
|
174
174
|
meta: { userId },
|
|
175
175
|
});
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* `defineAuth({ mfa: { required: true } })`, refused where it is declared. The option read as a
|
|
179
|
+
* security guarantee — "this deployment requires a second factor" — and nothing in this package
|
|
180
|
+
* could make it true: `login()` and `signInWithOAuth()` branch on `user.mfaSecret` alone, so a
|
|
181
|
+
* user who never enrolled was handed a fully-privileged session under it, and `actorFromUser`
|
|
182
|
+
* strips privileges only for a user who HAS a secret, so there is no half-authenticated actor to
|
|
183
|
+
* hand them instead. Enforcing it inside `login()` would refuse exactly the people with nothing to
|
|
184
|
+
* offer, with no enrolment route shipped to send them to — a permanent lockout, not a second
|
|
185
|
+
* factor. So the declaration is refused at boot, exactly as `assertAuthLimiterPolicy` refuses a
|
|
186
|
+
* per-process limiter under a fleet-wide lockout: a guarantee this package cannot show holds is
|
|
187
|
+
* never assumed. `X_CONFIG_INVALID` is core's code, borrowed rather than re-declared.
|
|
188
|
+
*/
|
|
189
|
+
export const mfaRequiredUnenforceable = (): AuthError =>
|
|
190
|
+
new AuthError({
|
|
191
|
+
code: 'X_CONFIG_INVALID',
|
|
192
|
+
cause:
|
|
193
|
+
'defineAuth({ mfa: { required: true } }) declares a second factor this package does not enforce: login() mints a full session for any user whose mfaSecret is null',
|
|
194
|
+
fix: 'drop required from defineAuth({ mfa }) and gate it in your own sign-in handler, which is where the enrolment route lives: if (user.mfaSecret === null) send them to enrolTotp(auth, { account: user.email }) instead of createSession(auth.sessions, ...)',
|
|
195
|
+
});
|
|
196
|
+
|
|
177
197
|
/**
|
|
178
198
|
* The `fix:` quotes `oauthStartPath` rather than a hand-written path. That is not tidiness: this
|
|
179
199
|
* line shipped naming `GET /auth/oauth/<provider>` while `@ultimat3/auth` mounted no route at all,
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ export type {
|
|
|
41
41
|
export {
|
|
42
42
|
AccountSchema,
|
|
43
43
|
authenticate,
|
|
44
|
+
DEFAULT_MFA_ISSUER,
|
|
44
45
|
defineAuth,
|
|
45
46
|
login,
|
|
46
47
|
logout,
|
|
@@ -72,6 +73,7 @@ export {
|
|
|
72
73
|
forbidden,
|
|
73
74
|
kdfOverloaded,
|
|
74
75
|
mfaRequired,
|
|
76
|
+
mfaRequiredUnenforceable,
|
|
75
77
|
oauthAccountNotLinked,
|
|
76
78
|
oauthDenied,
|
|
77
79
|
oauthExchangeFailed,
|
|
@@ -120,6 +122,7 @@ export {
|
|
|
120
122
|
export { MemoryAdapter } from './memory-adapter';
|
|
121
123
|
export type {
|
|
122
124
|
EnrolTotpInput,
|
|
125
|
+
MemoryTotpReplayGuard,
|
|
123
126
|
RecoveryCodeSet,
|
|
124
127
|
TotpEnrolment,
|
|
125
128
|
TotpReplayGuard,
|
|
@@ -130,6 +133,7 @@ export {
|
|
|
130
133
|
base32Decode,
|
|
131
134
|
base32Encode,
|
|
132
135
|
createTotpReplayGuard,
|
|
136
|
+
DEFAULT_MAX_TOTP_SUBJECTS,
|
|
133
137
|
enrolTotp,
|
|
134
138
|
generateRecoveryCodes,
|
|
135
139
|
generateTotpSecret,
|
package/src/mfa.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// phishing page stays valid for the rest of its 30 seconds. Recovery codes are hashed at rest
|
|
4
4
|
// and single-use, so a database dump is not a permanent MFA bypass.
|
|
5
5
|
|
|
6
|
+
import type { Auth } from './auth';
|
|
6
7
|
import { randomBytes, sha256Hex, timingSafeEqual } from './tokens';
|
|
7
8
|
|
|
8
9
|
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
@@ -63,17 +64,28 @@ export interface TotpEnrolment {
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
export interface EnrolTotpInput {
|
|
66
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Omitted, the issuer is `auth.mfa.issuer` — one declaration, at `defineAuth`, so the product
|
|
69
|
+
* name an authenticator app shows is not restated at every enrolment. Named here only when one
|
|
70
|
+
* call needs a different one (a separate admin console entry, say).
|
|
71
|
+
*/
|
|
72
|
+
readonly issuer?: string | undefined;
|
|
67
73
|
readonly account: string;
|
|
68
74
|
readonly secret?: string | undefined;
|
|
69
75
|
}
|
|
70
76
|
|
|
71
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Takes the `Auth` every other entry point in this package takes, and for the same reason: the
|
|
79
|
+
* issuer is configuration, and a pure function that could not read the configuration is what made
|
|
80
|
+
* `defineAuth({ mfa: { issuer } })` a string the framework wrote down and never read.
|
|
81
|
+
*/
|
|
82
|
+
export function enrolTotp(auth: Auth, input: EnrolTotpInput): TotpEnrolment {
|
|
72
83
|
const secret = input.secret ?? generateTotpSecret();
|
|
73
|
-
const
|
|
84
|
+
const issuer = input.issuer ?? auth.mfa.issuer;
|
|
85
|
+
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(input.account)}`;
|
|
74
86
|
const query = new URLSearchParams({
|
|
75
87
|
secret,
|
|
76
|
-
issuer
|
|
88
|
+
issuer,
|
|
77
89
|
algorithm: 'SHA1',
|
|
78
90
|
digits: String(TOTP_DIGITS),
|
|
79
91
|
period: String(TOTP_STEP_SECONDS),
|
|
@@ -148,23 +160,106 @@ export interface TotpReplayGuard {
|
|
|
148
160
|
remember(subject: string, step: number, at: Date): void;
|
|
149
161
|
}
|
|
150
162
|
|
|
163
|
+
/** What `createTotpReplayGuard` returns: the interface, plus the bound it keeps, observable. */
|
|
164
|
+
export interface MemoryTotpReplayGuard extends TotpReplayGuard {
|
|
165
|
+
readonly size: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Hard bound on tracked subjects. One entry is one user who has completed a TOTP check inside the
|
|
170
|
+
* last drift window, so the natural cardinality is far below this — the cap is the backstop, the
|
|
171
|
+
* same one `DEFAULT_MAX_AUTH_LIMIT_KEYS` is for the limiter's table.
|
|
172
|
+
*/
|
|
173
|
+
export const DEFAULT_MAX_TOTP_SUBJECTS = 10_000;
|
|
174
|
+
|
|
175
|
+
/** An idle guard still sweeps this often, so one burst's subjects do not sit until the next. */
|
|
176
|
+
const SWEEP_EVERY_STEPS = 2;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The cap arithmetic ran on the caller's number unchecked, and the two values a misread config
|
|
180
|
+
* hands you each defeated the bound in their own way: `Infinity` makes `used.size > cap` never
|
|
181
|
+
* true, so the table is exactly as unbounded as before it was capped; `NaN` makes EVERY comparison
|
|
182
|
+
* false, so `used.size <= evictTo` never stops the eviction loop and one sweep empties the table —
|
|
183
|
+
* including the subject who just authenticated, whose step is then replayable. Anything that is
|
|
184
|
+
* not a positive finite integer is a config the caller did not mean, so it takes the default
|
|
185
|
+
* rather than a bound derived from it. A fraction still floors: `2.5` is a caller who meant 2.
|
|
186
|
+
*/
|
|
187
|
+
function boundedSubjects(maxSubjects: number): number {
|
|
188
|
+
if (!Number.isFinite(maxSubjects) || maxSubjects < 1) return DEFAULT_MAX_TOTP_SUBJECTS;
|
|
189
|
+
return Math.floor(maxSubjects);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The last step this subject has spent — how close their entry still is to the live window. */
|
|
193
|
+
const newestStep = (steps: ReadonlySet<number>): number => {
|
|
194
|
+
let newest = Number.NEGATIVE_INFINITY;
|
|
195
|
+
for (const step of steps) newest = Math.max(newest, step);
|
|
196
|
+
return newest;
|
|
197
|
+
};
|
|
198
|
+
|
|
151
199
|
/**
|
|
152
200
|
* In-memory by default because a single web process is the common case; a multi-process
|
|
153
201
|
* deployment passes a Redis-backed guard with the same two methods. Steps older than the
|
|
154
202
|
* drift window are dropped — nothing outside it can be replayed anyway.
|
|
203
|
+
*
|
|
204
|
+
* Bounded, because the subject map only ever grew: pruning happened inside one subject's `Set`
|
|
205
|
+
* and never revisited a subject who stopped signing in, so the table carried one permanent entry
|
|
206
|
+
* per user for the life of the process. Two rules keep it flat, and the ORDER is the guarantee —
|
|
207
|
+
* evicting a subject makes a step they have already spent replayable again, so it may never be
|
|
208
|
+
* the subject who just authenticated. A subject whose every step has fallen below the drift floor
|
|
209
|
+
* is *forgotten*, not evicted: `verifyTotp` only ever offers a step within ±drift of now, so that
|
|
210
|
+
* entry answers exactly as a missing one and dropping it changes no decision. Only if forgetting
|
|
211
|
+
* is not enough does the cap evict live state, furthest from the live window first — the shape
|
|
212
|
+
* `createAuthLimiter` evicts by, where a live lockout is the last bucket to go.
|
|
155
213
|
*/
|
|
156
|
-
export function createTotpReplayGuard(
|
|
214
|
+
export function createTotpReplayGuard(
|
|
215
|
+
drift: number = TOTP_DRIFT_STEPS,
|
|
216
|
+
maxSubjects: number = DEFAULT_MAX_TOTP_SUBJECTS,
|
|
217
|
+
): MemoryTotpReplayGuard {
|
|
157
218
|
const used = new Map<string, Set<number>>();
|
|
219
|
+
const cap = boundedSubjects(maxSubjects);
|
|
220
|
+
// Batched down to 90% of the cap so the sort below is paid once per 10% of it, not per check.
|
|
221
|
+
const evictTo = Math.max(1, Math.floor(cap * 0.9));
|
|
222
|
+
let lastSweepStep = Number.NEGATIVE_INFINITY;
|
|
223
|
+
|
|
224
|
+
const prune = (steps: Set<number>, floor: number): void => {
|
|
225
|
+
for (const known of steps) {
|
|
226
|
+
if (known < floor) steps.delete(known);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const sweep = (now: number, floor: number): void => {
|
|
231
|
+
lastSweepStep = now;
|
|
232
|
+
for (const [subject, steps] of used) {
|
|
233
|
+
prune(steps, floor);
|
|
234
|
+
if (steps.size === 0) used.delete(subject);
|
|
235
|
+
}
|
|
236
|
+
if (used.size <= cap) return;
|
|
237
|
+
// Map iteration is insertion order and `remember` re-files the subject it touches, so this
|
|
238
|
+
// sort — stable by specification — breaks a tie on the newest step by least recently seen.
|
|
239
|
+
// Both keys point the same way: the subject who just proved a code is the last one out.
|
|
240
|
+
const furthest = [...used.entries()].sort((a, b) => newestStep(a[1]) - newestStep(b[1]));
|
|
241
|
+
for (const [subject] of furthest) {
|
|
242
|
+
if (used.size <= evictTo) break;
|
|
243
|
+
used.delete(subject);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
158
247
|
return {
|
|
248
|
+
get size() {
|
|
249
|
+
return used.size;
|
|
250
|
+
},
|
|
159
251
|
isUsed: (subject, step) => used.get(subject)?.has(step) === true,
|
|
160
252
|
remember: (subject, step, at) => {
|
|
253
|
+
const now = totpStep(at);
|
|
254
|
+
const floor = now - drift;
|
|
161
255
|
const steps = used.get(subject) ?? new Set<number>();
|
|
162
|
-
|
|
163
|
-
for (const known of steps) {
|
|
164
|
-
if (known < floor) steps.delete(known);
|
|
165
|
-
}
|
|
256
|
+
prune(steps, floor);
|
|
166
257
|
steps.add(step);
|
|
258
|
+
// Deleted before it is set, so this subject moves to the back of the iteration order and
|
|
259
|
+
// that order is least-recently-remembered first. Nothing else observes it.
|
|
260
|
+
used.delete(subject);
|
|
167
261
|
used.set(subject, steps);
|
|
262
|
+
if (used.size > cap || now - lastSweepStep >= SWEEP_EVERY_STEPS) sweep(now, floor);
|
|
168
263
|
},
|
|
169
264
|
};
|
|
170
265
|
}
|
package/src/password.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// a migration. Verification always burns a full KDF even when the user does not exist —
|
|
4
4
|
// otherwise response time answers "is this email registered?" for free.
|
|
5
5
|
|
|
6
|
-
import { passwordWeak } from './errors';
|
|
6
|
+
import { AuthError, passwordWeak } from './errors';
|
|
7
7
|
import { kdfGate } from './kdf-gate';
|
|
8
8
|
|
|
9
9
|
export interface PasswordParams {
|
|
@@ -100,12 +100,51 @@ export function needsRehash(
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
export interface VerifyPasswordInput {
|
|
103
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* `null` when no user matched. The KDF still runs, on a throwaway hash — and a stored hash Bun
|
|
105
|
+
* cannot read takes that same branch, so neither is separable from a wrong password.
|
|
106
|
+
*/
|
|
104
107
|
readonly hash: string | null;
|
|
105
108
|
readonly password: string;
|
|
106
109
|
readonly params?: PasswordParams | undefined;
|
|
107
110
|
}
|
|
108
111
|
|
|
112
|
+
/** The KDF the happy path would have burnt, then the one failure shape. Never a cheap answer. */
|
|
113
|
+
async function burnAndFail(
|
|
114
|
+
password: string,
|
|
115
|
+
params: PasswordParams,
|
|
116
|
+
): Promise<PasswordVerification> {
|
|
117
|
+
await hashPassword(password, params);
|
|
118
|
+
return FAILED;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `false` is a wrong password, `null` is a stored hash Bun cannot read at all.
|
|
123
|
+
*
|
|
124
|
+
* `Bun.password.verify` THROWS rather than answering on a hash it cannot parse — measured, bun
|
|
125
|
+
* 1.3.14: a Django `pbkdf2_sha256$...` row is `UnsupportedAlgorithm`, a truncated bcrypt string is
|
|
126
|
+
* `InvalidEncoding`. Letting that escape was two faults. A bare `Error` reached `login()`, so the
|
|
127
|
+
* caller answered 500 instead of the one credential failure; and it landed on exactly the rows
|
|
128
|
+
* that have not migrated off the legacy scheme, which makes "has this account been migrated" —
|
|
129
|
+
* and therefore "does this account exist" — readable from the outside. That is the enumeration
|
|
130
|
+
* oracle the whole file is built to close, on the one table where a foreign hash is normal.
|
|
131
|
+
*
|
|
132
|
+
* Supported-but-old is a different thing and stays a verdict: bcrypt verifies natively here and
|
|
133
|
+
* `needsRehash` flags it, which is the lever a legacy migration rewrites rows with.
|
|
134
|
+
*
|
|
135
|
+
* Nothing is logged. The algorithm of an unreadable hash is the oracle again, one layer down.
|
|
136
|
+
*/
|
|
137
|
+
async function verifyAgainst(password: string, hash: string): Promise<boolean | null> {
|
|
138
|
+
try {
|
|
139
|
+
return await kdfGate().run(async () => await Bun.password.verify(password, hash));
|
|
140
|
+
} catch (error) {
|
|
141
|
+
// The gate shedding (`X_OVERLOADED`) is load, never a verdict on the credential: swallowing it
|
|
142
|
+
// would answer "wrong password" for a request this process refused to do the work for.
|
|
143
|
+
if (error instanceof AuthError) throw error;
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
109
148
|
/**
|
|
110
149
|
* Never short-circuits on a missing user: the `hashPassword` call in the `null` branch costs
|
|
111
150
|
* the same order of magnitude as the verify in the happy branch, so the two are not separable
|
|
@@ -113,13 +152,14 @@ export interface VerifyPasswordInput {
|
|
|
113
152
|
*/
|
|
114
153
|
export async function verifyPassword(input: VerifyPasswordInput): Promise<PasswordVerification> {
|
|
115
154
|
const params = input.params ?? DEFAULT_PASSWORD_PARAMS;
|
|
116
|
-
// Read into a local so the
|
|
155
|
+
// Read into a local so the two branches below narrow it without a cast.
|
|
117
156
|
const hash = input.hash;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const ok = await
|
|
157
|
+
// `''` joins the no-user branch rather than reaching the KDF: an account with no password
|
|
158
|
+
// credential (oauth-only) answers `false` from Bun for free, and a failure that costs nothing
|
|
159
|
+
// is a stopwatch away from "this address exists but has never set a password".
|
|
160
|
+
if (hash === null || hash === '') return await burnAndFail(input.password, params);
|
|
161
|
+
const ok = await verifyAgainst(input.password, hash);
|
|
162
|
+
if (ok === null) return await burnAndFail(input.password, params);
|
|
123
163
|
if (!ok) return FAILED;
|
|
124
164
|
return { ok: true, needsRehash: needsRehash(hash, params) };
|
|
125
165
|
}
|