@ultimat3/auth 2.0.0 → 4.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 +61 -0
- package/README.md +39 -5
- package/package.json +4 -4
- package/src/auth.ts +26 -4
- package/src/errors.ts +42 -0
- package/src/id-token-fixture.ts +5 -1
- package/src/index.ts +5 -0
- package/src/mfa.ts +129 -11
- 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,55 @@ 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
|
+
- **A TOTP secret that decodes to zero bytes is not a weak key, it is NO key** (`As of 2026-08`).
|
|
214
|
+
`base32Decode` answers `new Uint8Array(0)` for any character outside the alphabet and for `''`,
|
|
215
|
+
and `new Bun.CryptoHasher('sha1', new Uint8Array(0))` is a perfectly valid HMAC — so `totpCode`
|
|
216
|
+
returned a six-digit code derived from nothing, every malformed secret in the table shared that
|
|
217
|
+
one stream, and `verifyTotp` accepted a code an attacker computes without knowing any secret.
|
|
218
|
+
Reachable: `enrolTotp(auth, { account, secret })` takes an imported secret, `builtin-adapter.ts`
|
|
219
|
+
maps the column straight through, and a `mfa_secret text not null default ''` column is not
|
|
220
|
+
`null`, so `login()` still demanded a second factor and then accepted the empty-key code. The
|
|
221
|
+
file's own header comment asserted the opposite ("fails the decode closed") for as long as it was
|
|
222
|
+
wrong. Three answers now, one per caller: `verifyTotp` returns `{ ok: false, step: null }` — the
|
|
223
|
+
**generic failure**, the same rule `verifyAgainst` follows for a stored hash Bun cannot read, so
|
|
224
|
+
a broken row is neither a 500 nor an oracle; `totpCode` throws `X_MFA_SECRET_INVALID`, because
|
|
225
|
+
there is no code an unreadable secret is entitled to; `enrolTotp` throws it too, so the value
|
|
226
|
+
never reaches the table. The secret never reaches `cause:` or `fix:` — it is a credential and
|
|
227
|
+
both are logged. No minimum LENGTH is enforced beyond one byte: 10-byte secrets are what several
|
|
228
|
+
authenticator apps issue, so a 16-byte floor would refuse real enrolments to close nothing the
|
|
229
|
+
zero-byte rule leaves open.
|
|
230
|
+
- **`createTotpReplayGuard`'s table is bounded, and the eviction ORDER is the guarantee.** It
|
|
231
|
+
pruned steps inside one subject's `Set` and never revisited a subject who stopped signing in, so
|
|
232
|
+
the map carried one permanent entry per user for the life of the process. Evicting a subject
|
|
233
|
+
makes a step they have already spent replayable again, so the order cannot be recency of
|
|
234
|
+
insertion: a subject whose every step has fallen below the drift floor is **forgotten** (nothing
|
|
235
|
+
outside ±drift is ever offered to `verifyTotp`, so that entry answers exactly as a missing one),
|
|
236
|
+
and only if that is not enough does `DEFAULT_MAX_TOTP_SUBJECTS` evict live state, sorted by
|
|
237
|
+
newest spent step ascending with a least-recently-seen tie-break — the subject who just proved a
|
|
238
|
+
code is always the last one out. Same shape as the limiter's "a live lockout outranks its own
|
|
239
|
+
deadline". `remember` re-files its subject so the map's iteration order IS that tie-break.
|
|
240
|
+
`maxSubjects` is **normalised before any of that arithmetic runs** (`boundedSubjects`): the cap
|
|
241
|
+
ran on the caller's number unchecked, so `Infinity` left the table exactly as unbounded as it was
|
|
242
|
+
before it was capped, and `NaN` — every comparison against which is false — made the eviction
|
|
243
|
+
loop's `used.size <= evictTo` never true, emptying the table on the first sweep and handing back
|
|
244
|
+
a replay of the code the subject had just spent. Anything that is not a positive finite integer
|
|
245
|
+
takes `DEFAULT_MAX_TOTP_SUBJECTS`; a fraction still floors.
|
|
185
246
|
- SAML is out of scope permanently: XML-DSig canonicalisation has no Bun native and would need a
|
|
186
247
|
real dependency. Put an OIDC-speaking bridge in front and register that.
|
|
187
248
|
|
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,12 +239,25 @@ 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
|
-
| `totpStep(at, stepSeconds?)` / `totpCode(secret, step, digits?)` | the RFC 6238 halves, for a test that has to mint a valid code |
|
|
247
|
+
| `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 |
|
|
248
|
+
|
|
249
|
+
**A secret the decoder cannot read verifies nothing, `As of 2026-08`.** `base32Decode` answers
|
|
250
|
+
zero bytes for any character outside the alphabet and for `''`, and an HMAC keyed with zero bytes
|
|
251
|
+
is a valid HMAC — so `totpCode` used to hand back a six-digit code derived from no secret at all,
|
|
252
|
+
one stream *every* malformed row in the table verified against, computable by anyone. Three
|
|
253
|
+
answers now, and they differ because their callers do: `verifyTotp` returns `{ ok: false, step:
|
|
254
|
+
null }` (a broken stored credential is the generic failure, the rule `verifyAgainst` follows for a
|
|
255
|
+
hash Bun cannot read — never a throw into the login path, never an oracle); `totpCode` throws
|
|
256
|
+
`X_MFA_SECRET_INVALID`, because there is no code an unreadable secret is entitled to; and
|
|
257
|
+
`enrolTotp` throws the same code on an imported `secret`, so a value nothing can ever check never
|
|
258
|
+
reaches the table. A minted secret is readable by construction. Note the direction of the failure:
|
|
259
|
+
a `mfa_secret text not null default ''` column now locks that account out of its second factor
|
|
260
|
+
instead of accepting a code nobody had to know — re-enrol it with `enrolTotp(auth, { account })`.
|
|
245
261
|
|
|
246
262
|
`TOTP_DIGITS` (6), `TOTP_STEP_SECONDS` (30) and `TOTP_DRIFT_STEPS` (±1 window) are exported so an
|
|
247
263
|
app's own copy of the parameters cannot disagree with the verifier's.
|
|
@@ -251,6 +267,24 @@ side of now, so without the guard the same six digits log in twice inside a minu
|
|
|
251
267
|
answers `{ ok: false, step }` — the step still named — when `usedSteps` already holds it, which is
|
|
252
268
|
how a replay is told apart from a wrong code.
|
|
253
269
|
|
|
270
|
+
The guard's table is **bounded** (`DEFAULT_MAX_TOTP_SUBJECTS`, 10,000), because a per-subject map
|
|
271
|
+
that only ever grows is one process' lifetime away from an OOM. A subject whose every remembered
|
|
272
|
+
step has fallen below the drift floor is *forgotten* — `verifyTotp` can never offer that step
|
|
273
|
+
again, so the entry answers exactly as a missing one — and only if that is not enough does the cap
|
|
274
|
+
evict live state, furthest from the live window first. The order is the guarantee: evicting a
|
|
275
|
+
subject makes a step they have already spent replayable, so the subject who just authenticated is
|
|
276
|
+
always the last one out.
|
|
277
|
+
|
|
278
|
+
**`mfa.required` is accepted only as `false`, and that is deliberate.** The field exists and is
|
|
279
|
+
typed as the literal `false`, so `required: true` is a compile error; a `true` that reaches
|
|
280
|
+
`defineAuth` from JavaScript or from JSON — where the type cannot — is refused at boot with
|
|
281
|
+
`X_CONFIG_INVALID` naming the key, never an unknown-key error and never a silent accept. Nothing
|
|
282
|
+
read it: both credential paths branch on `user.mfaSecret` alone, so an un-enrolled user was handed
|
|
283
|
+
a full session under a config that read as "this deployment requires a second factor". Enforcing it
|
|
284
|
+
at `login()` instead is a lockout — `actorFromUser` degrades only a user who HAS a secret, and this
|
|
285
|
+
package ships no enrolment route to send the rest to. Gate it in your own sign-in handler —
|
|
286
|
+
`if (user.mfaSecret === null)` send them to `enrolTotp` before you call `createSession`.
|
|
287
|
+
|
|
254
288
|
**The second leg of login is the app's, `As of 2026-08`.** `login()` and `completeOAuthLogin()`
|
|
255
289
|
throw `X_MFA_REQUIRED` before any session exists; finishing the flow is `verifyTotp` followed by
|
|
256
290
|
`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": "4.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": "4.0.0",
|
|
35
|
+
"@ultimat3/db": "4.0.0",
|
|
36
|
+
"@ultimat3/schema": "4.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
|
@@ -16,6 +16,7 @@ export const AUTH_OWNED_ERROR_CODES = [
|
|
|
16
16
|
'X_UNAUTHENTICATED',
|
|
17
17
|
'X_SESSION_EXPIRED',
|
|
18
18
|
'X_MFA_REQUIRED',
|
|
19
|
+
'X_MFA_SECRET_INVALID',
|
|
19
20
|
'X_OAUTH_STATE_INVALID',
|
|
20
21
|
'X_OAUTH_EXCHANGE_FAILED',
|
|
21
22
|
'X_OAUTH_TOKEN_INVALID',
|
|
@@ -60,6 +61,7 @@ export const AUTH_ERROR_TITLES: Readonly<Record<AuthOwnedErrorCode, string>> = {
|
|
|
60
61
|
X_UNAUTHENTICATED: 'no authenticated actor for this request',
|
|
61
62
|
X_SESSION_EXPIRED: 'session passed its idle or absolute expiry',
|
|
62
63
|
X_MFA_REQUIRED: 'a second factor is required before this session is usable',
|
|
64
|
+
X_MFA_SECRET_INVALID: 'the totp secret is not base32, so it carries no key to check a code with',
|
|
63
65
|
X_OAUTH_STATE_INVALID: 'oauth state, nonce or pkce verifier did not match',
|
|
64
66
|
X_OAUTH_EXCHANGE_FAILED: 'the oauth provider refused the exchange or returned no usable identity',
|
|
65
67
|
X_OAUTH_TOKEN_INVALID: 'id token failed its signature, issuer, audience or expiry check',
|
|
@@ -174,6 +176,46 @@ export const mfaRequired = (userId: string): AuthError =>
|
|
|
174
176
|
meta: { userId },
|
|
175
177
|
});
|
|
176
178
|
|
|
179
|
+
/**
|
|
180
|
+
* A stored or imported TOTP secret the decoder cannot read. `base32Decode` answers zero bytes for
|
|
181
|
+
* any character outside the alphabet AND for `''`, and an HMAC keyed with zero bytes is a valid
|
|
182
|
+
* HMAC — so `totpCode` was handing out a six-digit code derived from no secret at all, one shared
|
|
183
|
+
* stream every malformed row in the table verified against. A code nobody had to know a secret to
|
|
184
|
+
* compute is not a second factor, so there is no code to hand back: this refuses instead.
|
|
185
|
+
*
|
|
186
|
+
* `verifyTotp` does NOT throw it. A login checking a broken row is the generic failure, exactly as
|
|
187
|
+
* `verifyAgainst` treats a stored hash Bun cannot read — a coded throw out of the verify path is a
|
|
188
|
+
* 500 where a credential refusal belongs, and it answers "this account's secret is malformed" to
|
|
189
|
+
* whoever asked. The secret itself never reaches `cause:` or `fix:`: it is a credential, and both
|
|
190
|
+
* are logged.
|
|
191
|
+
*/
|
|
192
|
+
export const mfaSecretInvalid = (surface: string): AuthError =>
|
|
193
|
+
new AuthError({
|
|
194
|
+
code: 'X_MFA_SECRET_INVALID',
|
|
195
|
+
cause: `${surface} was given a totp secret that is not RFC 4648 base32, so it decodes to zero bytes`,
|
|
196
|
+
fix: 'issue a fresh one and store it: const { secret } = enrolTotp(auth, { account: user.email }) — an imported secret must be base32 (A-Z and 2-7, padding, spaces and dashes ignored) and decode to at least one byte',
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* `defineAuth({ mfa: { required: true } })`, refused where it is declared. The option read as a
|
|
201
|
+
* security guarantee — "this deployment requires a second factor" — and nothing in this package
|
|
202
|
+
* could make it true: `login()` and `signInWithOAuth()` branch on `user.mfaSecret` alone, so a
|
|
203
|
+
* user who never enrolled was handed a fully-privileged session under it, and `actorFromUser`
|
|
204
|
+
* strips privileges only for a user who HAS a secret, so there is no half-authenticated actor to
|
|
205
|
+
* hand them instead. Enforcing it inside `login()` would refuse exactly the people with nothing to
|
|
206
|
+
* offer, with no enrolment route shipped to send them to — a permanent lockout, not a second
|
|
207
|
+
* factor. So the declaration is refused at boot, exactly as `assertAuthLimiterPolicy` refuses a
|
|
208
|
+
* per-process limiter under a fleet-wide lockout: a guarantee this package cannot show holds is
|
|
209
|
+
* never assumed. `X_CONFIG_INVALID` is core's code, borrowed rather than re-declared.
|
|
210
|
+
*/
|
|
211
|
+
export const mfaRequiredUnenforceable = (): AuthError =>
|
|
212
|
+
new AuthError({
|
|
213
|
+
code: 'X_CONFIG_INVALID',
|
|
214
|
+
cause:
|
|
215
|
+
'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',
|
|
216
|
+
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, ...)',
|
|
217
|
+
});
|
|
218
|
+
|
|
177
219
|
/**
|
|
178
220
|
* The `fix:` quotes `oauthStartPath` rather than a hand-written path. That is not tidiness: this
|
|
179
221
|
* line shipped naming `GET /auth/oauth/<provider>` while `@ultimat3/auth` mounted no route at all,
|
package/src/id-token-fixture.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// chances for one to drift from what `decodeIdToken` actually parses. Not part of the public
|
|
4
4
|
// API — `index.ts` deliberately does not re-export it.
|
|
5
5
|
|
|
6
|
+
import type { IdTokenClaims } from './id-token';
|
|
6
7
|
import { base64Url } from './tokens';
|
|
7
8
|
|
|
8
9
|
/** `base64Url` takes bytes because every real secret is bytes; a JWT segment is text. */
|
|
@@ -12,5 +13,8 @@ export const base64UrlText = (value: string): string => base64Url(new TextEncode
|
|
|
12
13
|
* Header, payload, and a signature that is not one. Signatures are never checked here — the
|
|
13
14
|
* token is only ever read straight off the token endpoint — so a fixture needs no signer.
|
|
14
15
|
*/
|
|
15
|
-
|
|
16
|
+
// The union, and not `Record<string, unknown>` alone: `IdTokenClaims` is an `interface`, so it has
|
|
17
|
+
// no implicit index signature and the one shape this fixture exists to serialise was the one shape
|
|
18
|
+
// it refused. The record arm stays for the malformed payloads `id-token.test.ts` builds by hand.
|
|
19
|
+
export const unsignedJwt = (claims: IdTokenClaims | Readonly<Record<string, unknown>>): string =>
|
|
16
20
|
`${base64UrlText('{"alg":"RS256"}')}.${base64UrlText(JSON.stringify(claims))}.signature`;
|
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,8 @@ export {
|
|
|
72
73
|
forbidden,
|
|
73
74
|
kdfOverloaded,
|
|
74
75
|
mfaRequired,
|
|
76
|
+
mfaRequiredUnenforceable,
|
|
77
|
+
mfaSecretInvalid,
|
|
75
78
|
oauthAccountNotLinked,
|
|
76
79
|
oauthDenied,
|
|
77
80
|
oauthExchangeFailed,
|
|
@@ -120,6 +123,7 @@ export {
|
|
|
120
123
|
export { MemoryAdapter } from './memory-adapter';
|
|
121
124
|
export type {
|
|
122
125
|
EnrolTotpInput,
|
|
126
|
+
MemoryTotpReplayGuard,
|
|
123
127
|
RecoveryCodeSet,
|
|
124
128
|
TotpEnrolment,
|
|
125
129
|
TotpReplayGuard,
|
|
@@ -130,6 +134,7 @@ export {
|
|
|
130
134
|
base32Decode,
|
|
131
135
|
base32Encode,
|
|
132
136
|
createTotpReplayGuard,
|
|
137
|
+
DEFAULT_MAX_TOTP_SUBJECTS,
|
|
133
138
|
enrolTotp,
|
|
134
139
|
generateRecoveryCodes,
|
|
135
140
|
generateTotpSecret,
|
package/src/mfa.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
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';
|
|
7
|
+
import { mfaSecretInvalid } from './errors';
|
|
6
8
|
import { randomBytes, sha256Hex, timingSafeEqual } from './tokens';
|
|
7
9
|
|
|
8
10
|
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
@@ -29,8 +31,16 @@ export function base32Encode(bytes: Uint8Array): string {
|
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
|
-
* Tolerant by design: padding, spaces and the dashes authenticator apps display are skipped,
|
|
33
|
-
*
|
|
34
|
+
* Tolerant by design: padding, spaces and the dashes authenticator apps display are skipped, and
|
|
35
|
+
* any other character answers zero bytes.
|
|
36
|
+
*
|
|
37
|
+
* Zero bytes is NOT a decode that failed closed — this comment claimed it was, and it was the
|
|
38
|
+
* whole defect. An HMAC keyed with zero bytes is a perfectly valid HMAC, so `totpCode` derived a
|
|
39
|
+
* six-digit code from no secret at all and every unreadable secret in the table shared that one
|
|
40
|
+
* stream: a code an attacker computes without knowing anything verified against all of them.
|
|
41
|
+
* Zero bytes is therefore refused by both callers that need a key (`totpCode`, `enrolTotp`) and
|
|
42
|
+
* read as a non-verdict by `verifyTotp`. The decoder itself still answers rather than throwing,
|
|
43
|
+
* because "can this be read" is a question `enrolTotp` asks about a value it has not accepted yet.
|
|
34
44
|
*/
|
|
35
45
|
export function base32Decode(value: string): Uint8Array {
|
|
36
46
|
const bytes: number[] = [];
|
|
@@ -63,17 +73,32 @@ export interface TotpEnrolment {
|
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
export interface EnrolTotpInput {
|
|
66
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Omitted, the issuer is `auth.mfa.issuer` — one declaration, at `defineAuth`, so the product
|
|
78
|
+
* name an authenticator app shows is not restated at every enrolment. Named here only when one
|
|
79
|
+
* call needs a different one (a separate admin console entry, say).
|
|
80
|
+
*/
|
|
81
|
+
readonly issuer?: string | undefined;
|
|
67
82
|
readonly account: string;
|
|
68
83
|
readonly secret?: string | undefined;
|
|
69
84
|
}
|
|
70
85
|
|
|
71
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Takes the `Auth` every other entry point in this package takes, and for the same reason: the
|
|
88
|
+
* issuer is configuration, and a pure function that could not read the configuration is what made
|
|
89
|
+
* `defineAuth({ mfa: { issuer } })` a string the framework wrote down and never read.
|
|
90
|
+
*/
|
|
91
|
+
export function enrolTotp(auth: Auth, input: EnrolTotpInput): TotpEnrolment {
|
|
72
92
|
const secret = input.secret ?? generateTotpSecret();
|
|
73
|
-
|
|
93
|
+
// Defence in depth, on the one path that puts a caller's own bytes in front of the table: a
|
|
94
|
+
// secret nothing can ever derive a code from is refused before it is written, not after a user
|
|
95
|
+
// is locked out by it. A minted secret is readable by construction, so only an import gets here.
|
|
96
|
+
if (base32Decode(secret).length === 0) throw mfaSecretInvalid('enrolTotp');
|
|
97
|
+
const issuer = input.issuer ?? auth.mfa.issuer;
|
|
98
|
+
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(input.account)}`;
|
|
74
99
|
const query = new URLSearchParams({
|
|
75
100
|
secret,
|
|
76
|
-
issuer
|
|
101
|
+
issuer,
|
|
77
102
|
algorithm: 'SHA1',
|
|
78
103
|
digits: String(TOTP_DIGITS),
|
|
79
104
|
period: String(TOTP_STEP_SECONDS),
|
|
@@ -103,6 +128,10 @@ function counterBytes(step: number): Uint8Array {
|
|
|
103
128
|
/** HMAC-SHA1 + RFC 4226 dynamic truncation. SHA1 here is a spec requirement, not a choice. */
|
|
104
129
|
export function totpCode(secret: string, step: number, digits: number = TOTP_DIGITS): string {
|
|
105
130
|
const key = base32Decode(secret);
|
|
131
|
+
// A zero-length key is not a weak key, it is no key: the hasher below accepts it and answers a
|
|
132
|
+
// code every other unreadable secret answers too. There is no code an unreadable secret is
|
|
133
|
+
// entitled to, so this returns none.
|
|
134
|
+
if (key.length === 0) throw mfaSecretInvalid('totpCode');
|
|
106
135
|
const mac = Uint8Array.from(
|
|
107
136
|
new Bun.CryptoHasher('sha1', key).update(counterBytes(step)).digest(),
|
|
108
137
|
);
|
|
@@ -131,6 +160,12 @@ export interface VerifyTotpInput {
|
|
|
131
160
|
}
|
|
132
161
|
|
|
133
162
|
export function verifyTotp(input: VerifyTotpInput): TotpVerification {
|
|
163
|
+
// A non-verdict, not a verdict and not a throw. It is the rule `verifyAgainst` (`password.ts`)
|
|
164
|
+
// follows for a stored hash Bun cannot read: a broken stored credential is the generic failure,
|
|
165
|
+
// because a coded throw out of a verify path is a 500 where a refusal belongs and it answers
|
|
166
|
+
// "this account's secret is malformed" to whoever asked. It also keeps `totpCode`'s refusal off
|
|
167
|
+
// the login path entirely — nothing below can reach it with a zero-length key.
|
|
168
|
+
if (base32Decode(input.secret).length === 0) return { ok: false, step: null };
|
|
134
169
|
const drift = input.drift ?? TOTP_DRIFT_STEPS;
|
|
135
170
|
const current = totpStep(input.at);
|
|
136
171
|
const candidate = input.code.replaceAll(' ', '');
|
|
@@ -148,23 +183,106 @@ export interface TotpReplayGuard {
|
|
|
148
183
|
remember(subject: string, step: number, at: Date): void;
|
|
149
184
|
}
|
|
150
185
|
|
|
186
|
+
/** What `createTotpReplayGuard` returns: the interface, plus the bound it keeps, observable. */
|
|
187
|
+
export interface MemoryTotpReplayGuard extends TotpReplayGuard {
|
|
188
|
+
readonly size: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Hard bound on tracked subjects. One entry is one user who has completed a TOTP check inside the
|
|
193
|
+
* last drift window, so the natural cardinality is far below this — the cap is the backstop, the
|
|
194
|
+
* same one `DEFAULT_MAX_AUTH_LIMIT_KEYS` is for the limiter's table.
|
|
195
|
+
*/
|
|
196
|
+
export const DEFAULT_MAX_TOTP_SUBJECTS = 10_000;
|
|
197
|
+
|
|
198
|
+
/** An idle guard still sweeps this often, so one burst's subjects do not sit until the next. */
|
|
199
|
+
const SWEEP_EVERY_STEPS = 2;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The cap arithmetic ran on the caller's number unchecked, and the two values a misread config
|
|
203
|
+
* hands you each defeated the bound in their own way: `Infinity` makes `used.size > cap` never
|
|
204
|
+
* true, so the table is exactly as unbounded as before it was capped; `NaN` makes EVERY comparison
|
|
205
|
+
* false, so `used.size <= evictTo` never stops the eviction loop and one sweep empties the table —
|
|
206
|
+
* including the subject who just authenticated, whose step is then replayable. Anything that is
|
|
207
|
+
* not a positive finite integer is a config the caller did not mean, so it takes the default
|
|
208
|
+
* rather than a bound derived from it. A fraction still floors: `2.5` is a caller who meant 2.
|
|
209
|
+
*/
|
|
210
|
+
function boundedSubjects(maxSubjects: number): number {
|
|
211
|
+
if (!Number.isFinite(maxSubjects) || maxSubjects < 1) return DEFAULT_MAX_TOTP_SUBJECTS;
|
|
212
|
+
return Math.floor(maxSubjects);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The last step this subject has spent — how close their entry still is to the live window. */
|
|
216
|
+
const newestStep = (steps: ReadonlySet<number>): number => {
|
|
217
|
+
let newest = Number.NEGATIVE_INFINITY;
|
|
218
|
+
for (const step of steps) newest = Math.max(newest, step);
|
|
219
|
+
return newest;
|
|
220
|
+
};
|
|
221
|
+
|
|
151
222
|
/**
|
|
152
223
|
* In-memory by default because a single web process is the common case; a multi-process
|
|
153
224
|
* deployment passes a Redis-backed guard with the same two methods. Steps older than the
|
|
154
225
|
* drift window are dropped — nothing outside it can be replayed anyway.
|
|
226
|
+
*
|
|
227
|
+
* Bounded, because the subject map only ever grew: pruning happened inside one subject's `Set`
|
|
228
|
+
* and never revisited a subject who stopped signing in, so the table carried one permanent entry
|
|
229
|
+
* per user for the life of the process. Two rules keep it flat, and the ORDER is the guarantee —
|
|
230
|
+
* evicting a subject makes a step they have already spent replayable again, so it may never be
|
|
231
|
+
* the subject who just authenticated. A subject whose every step has fallen below the drift floor
|
|
232
|
+
* is *forgotten*, not evicted: `verifyTotp` only ever offers a step within ±drift of now, so that
|
|
233
|
+
* entry answers exactly as a missing one and dropping it changes no decision. Only if forgetting
|
|
234
|
+
* is not enough does the cap evict live state, furthest from the live window first — the shape
|
|
235
|
+
* `createAuthLimiter` evicts by, where a live lockout is the last bucket to go.
|
|
155
236
|
*/
|
|
156
|
-
export function createTotpReplayGuard(
|
|
237
|
+
export function createTotpReplayGuard(
|
|
238
|
+
drift: number = TOTP_DRIFT_STEPS,
|
|
239
|
+
maxSubjects: number = DEFAULT_MAX_TOTP_SUBJECTS,
|
|
240
|
+
): MemoryTotpReplayGuard {
|
|
157
241
|
const used = new Map<string, Set<number>>();
|
|
242
|
+
const cap = boundedSubjects(maxSubjects);
|
|
243
|
+
// Batched down to 90% of the cap so the sort below is paid once per 10% of it, not per check.
|
|
244
|
+
const evictTo = Math.max(1, Math.floor(cap * 0.9));
|
|
245
|
+
let lastSweepStep = Number.NEGATIVE_INFINITY;
|
|
246
|
+
|
|
247
|
+
const prune = (steps: Set<number>, floor: number): void => {
|
|
248
|
+
for (const known of steps) {
|
|
249
|
+
if (known < floor) steps.delete(known);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const sweep = (now: number, floor: number): void => {
|
|
254
|
+
lastSweepStep = now;
|
|
255
|
+
for (const [subject, steps] of used) {
|
|
256
|
+
prune(steps, floor);
|
|
257
|
+
if (steps.size === 0) used.delete(subject);
|
|
258
|
+
}
|
|
259
|
+
if (used.size <= cap) return;
|
|
260
|
+
// Map iteration is insertion order and `remember` re-files the subject it touches, so this
|
|
261
|
+
// sort — stable by specification — breaks a tie on the newest step by least recently seen.
|
|
262
|
+
// Both keys point the same way: the subject who just proved a code is the last one out.
|
|
263
|
+
const furthest = [...used.entries()].sort((a, b) => newestStep(a[1]) - newestStep(b[1]));
|
|
264
|
+
for (const [subject] of furthest) {
|
|
265
|
+
if (used.size <= evictTo) break;
|
|
266
|
+
used.delete(subject);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
158
270
|
return {
|
|
271
|
+
get size() {
|
|
272
|
+
return used.size;
|
|
273
|
+
},
|
|
159
274
|
isUsed: (subject, step) => used.get(subject)?.has(step) === true,
|
|
160
275
|
remember: (subject, step, at) => {
|
|
276
|
+
const now = totpStep(at);
|
|
277
|
+
const floor = now - drift;
|
|
161
278
|
const steps = used.get(subject) ?? new Set<number>();
|
|
162
|
-
|
|
163
|
-
for (const known of steps) {
|
|
164
|
-
if (known < floor) steps.delete(known);
|
|
165
|
-
}
|
|
279
|
+
prune(steps, floor);
|
|
166
280
|
steps.add(step);
|
|
281
|
+
// Deleted before it is set, so this subject moves to the back of the iteration order and
|
|
282
|
+
// that order is least-recently-remembered first. Nothing else observes it.
|
|
283
|
+
used.delete(subject);
|
|
167
284
|
used.set(subject, steps);
|
|
285
|
+
if (used.size > cap || now - lastSweepStep >= SWEEP_EVERY_STEPS) sweep(now, floor);
|
|
168
286
|
},
|
|
169
287
|
};
|
|
170
288
|
}
|
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
|
}
|