@ultimat3/auth 23.0.0 → 25.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 +95 -46
- package/README.md +245 -93
- package/package.json +5 -5
- package/src/adapter.ts +49 -18
- package/src/api-key-resolver.ts +14 -9
- package/src/api-keys.ts +61 -14
- package/src/auth.ts +56 -53
- package/src/builtin-adapter-violation.ts +43 -0
- package/src/builtin-adapter.ts +77 -9
- package/src/client-scope.ts +8 -2
- package/src/directory.ts +2 -13
- package/src/email.ts +1 -1
- package/src/errors.ts +80 -48
- package/src/guards.ts +2 -2
- package/src/id-token.ts +2 -3
- package/src/index.ts +57 -30
- package/src/jwks.ts +18 -7
- package/src/kdf-gate.ts +6 -6
- package/src/limiter-install.ts +1 -1
- package/src/login-attempt.ts +70 -0
- package/src/memory-adapter.ts +73 -7
- package/src/mfa-challenge.ts +151 -0
- package/src/mfa-secret.ts +91 -0
- package/src/mfa.ts +14 -28
- package/src/oauth-builtins.ts +6 -0
- package/src/oauth-cookie.ts +17 -7
- package/src/oauth-discovery.ts +24 -5
- package/src/oauth-errors.ts +4 -84
- package/src/oauth-exchange.ts +13 -7
- package/src/oauth-grant-context.ts +106 -0
- package/src/oauth-login.ts +78 -23
- package/src/oauth-profile.ts +48 -11
- package/src/oauth-registry.ts +16 -1
- package/src/oauth-route.ts +75 -22
- package/src/oauth.ts +4 -3
- package/src/policy-bridge.ts +51 -8
- package/src/privileges.ts +46 -18
- package/src/rate-limit-postgres.ts +113 -101
- package/src/rate-limit.ts +50 -16
- package/src/revocation.ts +25 -25
- package/src/session.ts +69 -37
- package/src/tables.ts +8 -10
- package/src/tokens.ts +2 -5
- package/src/verify.ts +4 -3
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Single responsibility: the public API of @ultimat3/auth. Explicit named exports only — this
|
|
2
2
|
// list is what the http pipeline, the MCP surface and generated apps are allowed to depend on.
|
|
3
3
|
|
|
4
|
+
export type { PgExecutor } from '@ultimat3/core';
|
|
4
5
|
export type {
|
|
5
6
|
AccountStore,
|
|
6
7
|
ApiKeyStore,
|
|
@@ -13,17 +14,27 @@ export type {
|
|
|
13
14
|
CreateUserInput,
|
|
14
15
|
SessionPatch,
|
|
15
16
|
SessionStore,
|
|
17
|
+
StoredMfaSecret,
|
|
16
18
|
UserPatch,
|
|
17
19
|
UserStore,
|
|
18
20
|
VerificationStore,
|
|
19
21
|
} from './adapter';
|
|
20
22
|
export type { ApiKeyCaller, ApiKeyResolverOptions } from './api-key-resolver';
|
|
21
23
|
export { apiKeyResolver } from './api-key-resolver';
|
|
22
|
-
export type {
|
|
24
|
+
export type {
|
|
25
|
+
ApiKeyActorOptions,
|
|
26
|
+
ApiKeySummary,
|
|
27
|
+
ApiKeyVerifyStore,
|
|
28
|
+
IssueApiKeyInput,
|
|
29
|
+
IssuedApiKey,
|
|
30
|
+
ParsedApiKey,
|
|
31
|
+
VerifiedApiKey,
|
|
32
|
+
} from './api-keys';
|
|
23
33
|
export {
|
|
24
34
|
API_KEY_NAMESPACE,
|
|
25
35
|
apiKeyActor,
|
|
26
36
|
describeApiKey,
|
|
37
|
+
directGrants,
|
|
27
38
|
issueApiKey,
|
|
28
39
|
parseApiKey,
|
|
29
40
|
revokeApiKey,
|
|
@@ -50,8 +61,7 @@ export {
|
|
|
50
61
|
UserSchema,
|
|
51
62
|
VerificationSchema,
|
|
52
63
|
} from './auth';
|
|
53
|
-
|
|
54
|
-
export { BuiltinAdapter } from './builtin-adapter';
|
|
64
|
+
export { type PostgresAuthAdapter, postgresAuthAdapter } from './builtin-adapter';
|
|
55
65
|
/** The opaque per-principal id a per-request document hands the page's client store. */
|
|
56
66
|
export type { ClientScopeOptions } from './client-scope';
|
|
57
67
|
export { clientScopeOf } from './client-scope';
|
|
@@ -68,20 +78,19 @@ export {
|
|
|
68
78
|
AuthError,
|
|
69
79
|
accountLocked,
|
|
70
80
|
apiKeyInvalid,
|
|
81
|
+
authForbidden,
|
|
71
82
|
authLimiterNotShared,
|
|
72
83
|
authLimiterPolicyMismatch,
|
|
73
|
-
|
|
84
|
+
authUnauthenticated,
|
|
74
85
|
authUniqueViolation,
|
|
75
86
|
authWriteFailed,
|
|
76
|
-
forbidden,
|
|
77
87
|
kdfOverloaded,
|
|
78
|
-
mfaRequired,
|
|
79
88
|
mfaRequiredUnenforceable,
|
|
80
89
|
mfaSecretInvalid,
|
|
90
|
+
mfaSecretUnsealed,
|
|
81
91
|
passwordWeak,
|
|
82
92
|
sessionExpired,
|
|
83
93
|
sessionUnknown,
|
|
84
|
-
unauthenticated,
|
|
85
94
|
} from './errors';
|
|
86
95
|
export { currentActor, requireActor } from './guards';
|
|
87
96
|
export type { IdTokenClaims, VerifyIdTokenInput } from './id-token';
|
|
@@ -99,22 +108,22 @@ export type {
|
|
|
99
108
|
JwtHeader,
|
|
100
109
|
} from './jwks';
|
|
101
110
|
export {
|
|
102
|
-
createJwksClient,
|
|
103
111
|
decodeJwtHeader,
|
|
112
|
+
jwksClient,
|
|
104
113
|
providerJwks,
|
|
105
114
|
verifyJwtSignature,
|
|
106
115
|
} from './jwks';
|
|
107
116
|
export type { KdfGate, KdfLimits } from './kdf-gate';
|
|
108
117
|
export {
|
|
118
|
+
boundedKdfGate,
|
|
109
119
|
configureKdfGate,
|
|
110
|
-
createKdfGate,
|
|
111
120
|
kdfGate,
|
|
112
121
|
} from './kdf-gate';
|
|
113
122
|
export type { AuthLimiterFactory } from './limiter-install';
|
|
114
123
|
// `installedAuthLimiter` is deliberately absent: `defineAuth` is the one reader, and a second
|
|
115
124
|
// caller building limiters out of band would be a second answer to where failures are counted.
|
|
116
125
|
export { configureAuthLimiters, purgeAuthLimits, resetAuthLimiters } from './limiter-install';
|
|
117
|
-
export {
|
|
126
|
+
export { type MemoryAuthAdapter, memoryAuthAdapter } from './memory-adapter';
|
|
118
127
|
export type {
|
|
119
128
|
EnrolTotpInput,
|
|
120
129
|
MemoryTotpReplayGuard,
|
|
@@ -127,12 +136,12 @@ export type {
|
|
|
127
136
|
export {
|
|
128
137
|
base32Decode,
|
|
129
138
|
base32Encode,
|
|
130
|
-
createTotpReplayGuard,
|
|
131
139
|
DEFAULT_MAX_TOTP_SUBJECTS,
|
|
132
140
|
enrolTotp,
|
|
133
141
|
generateRecoveryCodes,
|
|
134
142
|
generateTotpSecret,
|
|
135
|
-
|
|
143
|
+
memoryTotpReplayGuard,
|
|
144
|
+
recoveryCodeHash,
|
|
136
145
|
TOTP_DIGITS,
|
|
137
146
|
TOTP_DRIFT_STEPS,
|
|
138
147
|
TOTP_STEP_SECONDS,
|
|
@@ -140,6 +149,23 @@ export {
|
|
|
140
149
|
totpStep,
|
|
141
150
|
verifyTotp,
|
|
142
151
|
} from './mfa';
|
|
152
|
+
export type { CompleteMfaOptions } from './mfa-challenge';
|
|
153
|
+
// `mfaRequired` itself is deliberately absent: the only X_MFA_REQUIRED worth throwing carries a
|
|
154
|
+
// challenge this package sealed, and `mfaChallengeRequired` is what seals one.
|
|
155
|
+
export {
|
|
156
|
+
completeMfa,
|
|
157
|
+
MFA_CHALLENGE_PURPOSE,
|
|
158
|
+
MFA_CHALLENGE_TTL_MS,
|
|
159
|
+
mfaChallengeRequired,
|
|
160
|
+
} from './mfa-challenge';
|
|
161
|
+
export type { MfaSecretStore, SealMfaSecretsReport } from './mfa-secret';
|
|
162
|
+
export {
|
|
163
|
+
countUnsealedMfaSecrets,
|
|
164
|
+
MFA_SECRET_PURPOSE,
|
|
165
|
+
openTotpSecret,
|
|
166
|
+
saveTotpSecret,
|
|
167
|
+
sealMfaSecrets,
|
|
168
|
+
} from './mfa-secret';
|
|
143
169
|
export type {
|
|
144
170
|
BeginOAuthInput,
|
|
145
171
|
OAuthCallback,
|
|
@@ -148,7 +174,7 @@ export type {
|
|
|
148
174
|
OAuthProviderId,
|
|
149
175
|
PkcePair,
|
|
150
176
|
} from './oauth';
|
|
151
|
-
export { assertOAuthCallback, beginOAuth,
|
|
177
|
+
export { assertOAuthCallback, beginOAuth, pkcePair } from './oauth';
|
|
152
178
|
export {
|
|
153
179
|
APPLE_PROVIDER,
|
|
154
180
|
BUILTIN_OAUTH_PROVIDER_IDS,
|
|
@@ -169,16 +195,11 @@ export {
|
|
|
169
195
|
} from './oauth-cookie';
|
|
170
196
|
export type { DiscoverOAuthProviderInput } from './oauth-discovery';
|
|
171
197
|
export { discoverOAuthProvider, discoveryUrl } from './oauth-discovery';
|
|
172
|
-
// The OAuth
|
|
173
|
-
//
|
|
198
|
+
// The OAuth refusals more than one OAuth module raises. A refusal with one thrower is exported
|
|
199
|
+
// from that module, below.
|
|
174
200
|
export type { OAuthExchangeFailure } from './oauth-errors';
|
|
175
201
|
export {
|
|
176
|
-
emailVerifiedNotStored,
|
|
177
|
-
oauthAccountNotLinked,
|
|
178
|
-
oauthDenied,
|
|
179
202
|
oauthExchangeFailed,
|
|
180
|
-
oauthLinkingDisabled,
|
|
181
|
-
oauthProviderDuplicate,
|
|
182
203
|
oauthProviderUnknown,
|
|
183
204
|
oauthStateInvalid,
|
|
184
205
|
oauthTokenInvalid,
|
|
@@ -191,13 +212,20 @@ export type {
|
|
|
191
212
|
OAuthTokens,
|
|
192
213
|
} from './oauth-exchange';
|
|
193
214
|
export { exchangeOAuthCode, oauthCredentials } from './oauth-exchange';
|
|
215
|
+
export type { OAuthGrantContext } from './oauth-grant-context';
|
|
194
216
|
export type {
|
|
195
217
|
CompleteOAuthLoginInput,
|
|
196
218
|
OAuthGrants,
|
|
197
219
|
OAuthSignInInput,
|
|
198
220
|
ResolveOAuthGrants,
|
|
199
221
|
} from './oauth-login';
|
|
200
|
-
export {
|
|
222
|
+
export {
|
|
223
|
+
completeOAuthLogin,
|
|
224
|
+
emailVerifiedNotStored,
|
|
225
|
+
oauthAccountNotLinked,
|
|
226
|
+
oauthLinkingDisabled,
|
|
227
|
+
signInWithOAuth,
|
|
228
|
+
} from './oauth-login';
|
|
201
229
|
export {
|
|
202
230
|
OAUTH_BASE_PATH,
|
|
203
231
|
OAUTH_CALLBACK_ROUTE_PATH,
|
|
@@ -209,12 +237,13 @@ export type { OAuthProfile, OAuthProfileOptions } from './oauth-profile';
|
|
|
209
237
|
export { oauthProfile } from './oauth-profile';
|
|
210
238
|
export {
|
|
211
239
|
hasOAuthProvider,
|
|
240
|
+
oauthProviderDuplicate,
|
|
212
241
|
oauthProviderIds,
|
|
213
242
|
providerFor,
|
|
214
243
|
registerOAuthProvider,
|
|
215
244
|
} from './oauth-registry';
|
|
216
245
|
export type { AuthRouteDescriptor, OAuthLoginOptions, OAuthLoginRoutes } from './oauth-route';
|
|
217
|
-
export { OAUTH_ROUTE_STATUS, oauthLogin } from './oauth-route';
|
|
246
|
+
export { OAUTH_ROUTE_STATUS, oauthDenied, oauthLogin } from './oauth-route';
|
|
218
247
|
export type {
|
|
219
248
|
PasswordParams,
|
|
220
249
|
PasswordPolicy,
|
|
@@ -240,6 +269,8 @@ export {
|
|
|
240
269
|
actorFromApiKey,
|
|
241
270
|
actorFromService,
|
|
242
271
|
actorFromUser,
|
|
272
|
+
apiKeyScopes,
|
|
273
|
+
isWildcardScope,
|
|
243
274
|
resolveActor,
|
|
244
275
|
} from './policy-bridge';
|
|
245
276
|
export type { UpdatePrivilegesResult } from './privileges';
|
|
@@ -248,24 +279,21 @@ export type {
|
|
|
248
279
|
AuthLimiter,
|
|
249
280
|
AuthLimiterScope,
|
|
250
281
|
AuthRateLimitPolicy,
|
|
282
|
+
AuthReservation,
|
|
251
283
|
MemoryAuthLimiter,
|
|
252
284
|
} from './rate-limit';
|
|
253
285
|
export {
|
|
254
286
|
accountKey,
|
|
255
287
|
assertAuthLimiterPolicy,
|
|
256
|
-
createAuthLimiter,
|
|
257
288
|
DEFAULT_AUTH_RATE_LIMIT,
|
|
258
289
|
DEFAULT_MAX_AUTH_LIMIT_KEYS,
|
|
259
290
|
ipKey,
|
|
260
291
|
loginFailed,
|
|
292
|
+
memoryAuthLimiter,
|
|
261
293
|
orgKey,
|
|
262
294
|
orgRateLimit,
|
|
263
295
|
} from './rate-limit';
|
|
264
|
-
export type {
|
|
265
|
-
PgExecutor,
|
|
266
|
-
PostgresAuthLimiter,
|
|
267
|
-
PostgresAuthLimiterOptions,
|
|
268
|
-
} from './rate-limit-postgres';
|
|
296
|
+
export type { PostgresAuthLimiter, PostgresAuthLimiterOptions } from './rate-limit-postgres';
|
|
269
297
|
export {
|
|
270
298
|
postgresAuthLimiter,
|
|
271
299
|
SQL_AUTH_LIMIT_TABLES,
|
|
@@ -295,8 +323,8 @@ export {
|
|
|
295
323
|
DEFAULT_SESSION_POLICY,
|
|
296
324
|
idleSlideMs,
|
|
297
325
|
listDevices,
|
|
298
|
-
readCookie,
|
|
299
326
|
readSessionCookie,
|
|
327
|
+
remainingMaxAgeSeconds,
|
|
300
328
|
revokeOtherSessions,
|
|
301
329
|
revokeSession,
|
|
302
330
|
rotateSession,
|
|
@@ -315,7 +343,6 @@ export {
|
|
|
315
343
|
export {
|
|
316
344
|
randomToken,
|
|
317
345
|
sha256Hex,
|
|
318
|
-
timingSafeEqual,
|
|
319
346
|
} from './tokens';
|
|
320
347
|
export type {
|
|
321
348
|
ConsumeVerificationInput,
|
package/src/jwks.ts
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Clock, Scheduler } from '@ultimat3/core';
|
|
10
10
|
import {
|
|
11
|
-
|
|
12
|
-
createSingleFlight,
|
|
11
|
+
generationFence,
|
|
13
12
|
isFixShellSafe,
|
|
14
13
|
renderFixShellArg,
|
|
15
14
|
renderThrowable,
|
|
15
|
+
singleFlight,
|
|
16
16
|
systemClock,
|
|
17
17
|
} from '@ultimat3/core';
|
|
18
18
|
import { decodeJwtSegment, isRecord } from './json';
|
|
@@ -117,7 +117,7 @@ const algorithmOf = (jwk: Record<string, unknown>): JwtAlgorithm | null => {
|
|
|
117
117
|
* become an unbounded outbound request per attempt, so the refresh is rate-limited by the same
|
|
118
118
|
* TTL as the ordinary one.
|
|
119
119
|
*/
|
|
120
|
-
export function
|
|
120
|
+
export function jwksClient(options: JwksClientOptions): JwksKeySource {
|
|
121
121
|
const clock = options.clock ?? systemClock;
|
|
122
122
|
const ttlMs = options.ttlMs ?? DEFAULT_JWKS_TTL_MS;
|
|
123
123
|
assertFiniteAuthCount(
|
|
@@ -144,7 +144,7 @@ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
|
|
|
144
144
|
// already gave up on could drop a pre-rotation key set on top of the live one, and every login
|
|
145
145
|
// against the new `kid` would start missing again. Read, never `guard`: a superseded refresh is
|
|
146
146
|
// still the honest answer for the callers holding it, and only the shared cache is fenced.
|
|
147
|
-
const fence =
|
|
147
|
+
const fence = generationFence('the published jwks key set');
|
|
148
148
|
// All three refusals below put this URI in a COMMAND position, and it came out of the ISSUER's
|
|
149
149
|
// own discovery document — `jwks_uri` is remote text, and `new URL()` keeps `$`, `(`, `)` and a
|
|
150
150
|
// backtick in a path. `renderFixLiteral` is the wrong tool here, because its double quotes leave
|
|
@@ -207,6 +207,17 @@ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
|
|
|
207
207
|
.catch(() => null);
|
|
208
208
|
if (key !== null) next.set(`${kid}:${alg}`, key);
|
|
209
209
|
}
|
|
210
|
+
// A 200 with nothing importable in it is a failed fetch, not a rotation to zero keys. Adopted,
|
|
211
|
+
// it replaced a working cache with an empty one stamped fresh for a whole TTL.
|
|
212
|
+
if (next.size === 0) {
|
|
213
|
+
throw oauthExchangeFailed({
|
|
214
|
+
provider: options.provider,
|
|
215
|
+
stage: 'jwks',
|
|
216
|
+
detail: 'the key set held no key this runtime can import, so the cached keys were kept',
|
|
217
|
+
status: response.status,
|
|
218
|
+
fix: readTheKeySet(''),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
210
221
|
if (fence.generation() === issued) {
|
|
211
222
|
keys = next;
|
|
212
223
|
fetchedAtMs = clock.now().getTime();
|
|
@@ -224,7 +235,7 @@ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
|
|
|
224
235
|
// running, its own callers keep their promise, and the fence above stops its late answer from
|
|
225
236
|
// landing in the cache. So the worst case is one duplicate JWKS fetch, never a failed
|
|
226
237
|
// verification — which is what makes a deadline here a fix rather than a risk.
|
|
227
|
-
const flight =
|
|
238
|
+
const flight = singleFlight({
|
|
228
239
|
deadlineMs: timeoutMs * JWKS_DEADLINE_FACTOR,
|
|
229
240
|
schedule: options.schedule,
|
|
230
241
|
});
|
|
@@ -284,7 +295,7 @@ const clients = new Map<string, JwksKeySource>();
|
|
|
284
295
|
*
|
|
285
296
|
* Not cached by option identity, deliberately: `fetch` and `clock` are functions and objects, so
|
|
286
297
|
* any canonical key over them either collides (two different proxies, one entry) or never hits.
|
|
287
|
-
* A bespoke client is a bespoke client; `
|
|
298
|
+
* A bespoke client is a bespoke client; `jwksClient` is what it is, and its own cache still
|
|
288
299
|
* works for as long as the caller holds it.
|
|
289
300
|
*/
|
|
290
301
|
export function providerJwks(
|
|
@@ -301,7 +312,7 @@ export function providerJwks(
|
|
|
301
312
|
const bespoke = options !== undefined && Object.keys(options).length > 0;
|
|
302
313
|
const existing = bespoke ? undefined : clients.get(provider.id);
|
|
303
314
|
if (existing !== undefined) return existing;
|
|
304
|
-
const client =
|
|
315
|
+
const client = jwksClient({ ...options, provider: provider.id, jwksUri: provider.jwksUri });
|
|
305
316
|
if (!bespoke) clients.set(provider.id, client);
|
|
306
317
|
return client;
|
|
307
318
|
}
|
package/src/kdf-gate.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// what argon2id costs is MEMORY — 19 MiB per hash at the OWASP floor. The only remaining backstop
|
|
7
7
|
// was `http.maxInflight` (1000), i.e. roughly 19 GB of arenas queued on one box.
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { flightGate } from '@ultimat3/core';
|
|
10
10
|
import { kdfOverloaded } from './errors';
|
|
11
11
|
import { assertFiniteAuthCount } from './policy-numbers';
|
|
12
12
|
|
|
@@ -43,7 +43,7 @@ export interface KdfGate {
|
|
|
43
43
|
* The declared return type stays `KdfGate` rather than core's `FlightGate`: `active` and `queued`
|
|
44
44
|
* are observations no caller here has ever had, and widening a public signature is not a refactor.
|
|
45
45
|
*/
|
|
46
|
-
export function
|
|
46
|
+
export function boundedKdfGate(limits: KdfLimits = DEFAULT_KDF_LIMITS): KdfGate {
|
|
47
47
|
// Screened at the CONSTRUCTOR, because this pair WEDGES rather than fails: core asks
|
|
48
48
|
// `active < maxConcurrent` and then `waiters.length >= maxQueued`, and both are false for `NaN`,
|
|
49
49
|
// so every hash on the box parks in a queue with no bound and nothing to release it — login
|
|
@@ -66,12 +66,12 @@ export function createKdfGate(limits: KdfLimits = DEFAULT_KDF_LIMITS): KdfGate {
|
|
|
66
66
|
'`waiters.length >= NaN` is false, so the queue this gate exists to bound has no bound at all',
|
|
67
67
|
0,
|
|
68
68
|
);
|
|
69
|
-
return
|
|
69
|
+
return flightGate(limits, {
|
|
70
70
|
overflow: (state) => kdfOverloaded(state.active, state.queued),
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
let gate =
|
|
74
|
+
let gate = boundedKdfGate();
|
|
75
75
|
|
|
76
76
|
/** The process-wide gate every `hashPassword`/`verifyPassword` passes through. */
|
|
77
77
|
export const kdfGate = (): KdfGate => gate;
|
|
@@ -83,10 +83,10 @@ export const kdfGate = (): KdfGate => gate;
|
|
|
83
83
|
* runs on a 512 MiB PaaS dyno and a 64 GiB node.
|
|
84
84
|
*/
|
|
85
85
|
export function configureKdfGate(limits: KdfLimits): void {
|
|
86
|
-
gate =
|
|
86
|
+
gate = boundedKdfGate(limits);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
/** Back to the shipped defaults. Tests that call `configureKdfGate` must call this in cleanup. */
|
|
90
90
|
export function resetKdfGate(): void {
|
|
91
|
-
gate =
|
|
91
|
+
gate = boundedKdfGate();
|
|
92
92
|
}
|
package/src/limiter-install.ts
CHANGED
|
@@ -52,7 +52,7 @@ export function configureAuthLimiters(next: AuthLimiterFactory): void {
|
|
|
52
52
|
built = new Map();
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/** Back to `
|
|
55
|
+
/** Back to `memoryAuthLimiter`, the per-process default. A host that installs one calls this on stop. */
|
|
56
56
|
export function resetAuthLimiters(): void {
|
|
57
57
|
factory = undefined;
|
|
58
58
|
built = new Map();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Single responsibility: one credential attempt's reservations across the three buckets — account,
|
|
2
|
+
// source address, tenant — taken BEFORE the expensive check and settled after it. `login` and
|
|
3
|
+
// `completeMfa` both run through this, so a password guess and a code guess are metered alike.
|
|
4
|
+
|
|
5
|
+
import type { Auth } from './auth';
|
|
6
|
+
import { type AuthLimiter, type AuthReservation, ipKey, orgKey } from './rate-limit';
|
|
7
|
+
|
|
8
|
+
export interface LoginAttempt {
|
|
9
|
+
/**
|
|
10
|
+
* The tenant bucket, joined once the address has resolved to an org — still before the KDF. A
|
|
11
|
+
* refusal here gives back what this attempt already took: an attempt the tenant cap turned
|
|
12
|
+
* away never tested a credential, so it must not also count against the account or address.
|
|
13
|
+
*/
|
|
14
|
+
reserveOrg(orgId: string | null): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* No verdict was reached, or the factor checked was proven and another is still owed: every
|
|
17
|
+
* reservation is given back and nothing is cleared.
|
|
18
|
+
*/
|
|
19
|
+
release(): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* The credential is fully proven. Clears the ACCOUNT bucket — the typos before it were this
|
|
22
|
+
* person's — and only refunds the shared ones: an address and a tenant count traffic from many
|
|
23
|
+
* people, so one success is not evidence the failures beside it were benign.
|
|
24
|
+
*/
|
|
25
|
+
succeed(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Taken {
|
|
29
|
+
readonly limiter: AuthLimiter;
|
|
30
|
+
readonly reservation: AuthReservation;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reserves account → ip, in that fixed order (and `reserveOrg` after both), so two concurrent
|
|
35
|
+
* sign-ins never hold two of a shared limiter's rows in opposite orders. A failed attempt calls
|
|
36
|
+
* nothing further: the reservations ARE the recorded failure.
|
|
37
|
+
*/
|
|
38
|
+
export async function openLoginAttempt(
|
|
39
|
+
auth: Auth,
|
|
40
|
+
account: string,
|
|
41
|
+
ip: string | null,
|
|
42
|
+
): Promise<LoginAttempt> {
|
|
43
|
+
const taken: Taken[] = [];
|
|
44
|
+
const release = async (): Promise<void> => {
|
|
45
|
+
for (const { limiter, reservation } of taken.splice(0)) await limiter.refund(reservation);
|
|
46
|
+
};
|
|
47
|
+
const take = async (limiter: AuthLimiter, key: string): Promise<void> => {
|
|
48
|
+
try {
|
|
49
|
+
taken.push({ limiter, reservation: await limiter.reserve(key) });
|
|
50
|
+
} catch (refused) {
|
|
51
|
+
await release();
|
|
52
|
+
throw refused;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
await take(auth.limiter, account);
|
|
57
|
+
if (ip !== null) await take(auth.limiter, ipKey(ip));
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
reserveOrg: async (orgId) => {
|
|
61
|
+
if (orgId !== null) await take(auth.orgLimiter, orgKey(orgId));
|
|
62
|
+
},
|
|
63
|
+
release,
|
|
64
|
+
succeed: async () => {
|
|
65
|
+
const [own, ...shared] = taken.splice(0);
|
|
66
|
+
if (own !== undefined) await own.limiter.recordSuccess(own.reservation.key);
|
|
67
|
+
for (const { limiter, reservation } of shared) await limiter.refund(reservation);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
package/src/memory-adapter.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// database exists and the one every test in this package runs against — the same interface
|
|
3
3
|
// Postgres and Better Auth implement, so a flow that works here works there or the seam is wrong.
|
|
4
4
|
|
|
5
|
-
import { type Clock, systemClock } from '@ultimat3/core';
|
|
5
|
+
import { type Clock, systemClock, timingSafeEqual } from '@ultimat3/core';
|
|
6
6
|
import type {
|
|
7
7
|
AuthAccount,
|
|
8
8
|
AuthAdapter,
|
|
@@ -12,15 +12,33 @@ import type {
|
|
|
12
12
|
AuthVerification,
|
|
13
13
|
CreateUserInput,
|
|
14
14
|
SessionPatch,
|
|
15
|
+
StoredMfaSecret,
|
|
15
16
|
UserPatch,
|
|
16
17
|
UserQuery,
|
|
17
18
|
} from './adapter';
|
|
18
19
|
import { authUniqueViolation } from './errors';
|
|
19
|
-
import { timingSafeEqual } from './tokens';
|
|
20
20
|
|
|
21
21
|
const verificationKey = (purpose: string, identifier: string): string => `${purpose}:${identifier}`;
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* `PostgresAuthAdapter`'s `order by email collate "C"`: byte order over UTF-8, which is CODE-POINT order
|
|
25
|
+
* — never `localeCompare` (the machine's locale) and never `<` on strings, whose UTF-16 code units
|
|
26
|
+
* put an astral character (a lead surrogate, 0xD8xx) ahead of U+E000–U+FFFF.
|
|
27
|
+
*/
|
|
28
|
+
const byCodePoint = (a: string, b: string): number => {
|
|
29
|
+
const left = a[Symbol.iterator]();
|
|
30
|
+
const right = b[Symbol.iterator]();
|
|
31
|
+
for (;;) {
|
|
32
|
+
const x = left.next();
|
|
33
|
+
const y = right.next();
|
|
34
|
+
if (x.done === true || y.done === true)
|
|
35
|
+
return (x.done === true ? 0 : 1) - (y.done === true ? 0 : 1);
|
|
36
|
+
const delta = (x.value.codePointAt(0) ?? 0) - (y.value.codePointAt(0) ?? 0);
|
|
37
|
+
if (delta !== 0) return delta;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export class MemoryAuthAdapter implements AuthAdapter {
|
|
24
42
|
readonly name = 'memory';
|
|
25
43
|
readonly #clock: Clock;
|
|
26
44
|
readonly #users = new Map<string, AuthUser>();
|
|
@@ -32,14 +50,14 @@ export class MemoryAdapter implements AuthAdapter {
|
|
|
32
50
|
/**
|
|
33
51
|
* The clock every instant this adapter stamps comes from — one argument, because a stamp is a
|
|
34
52
|
* fact about WHEN a call happened and a test that cannot move it can only assert a range.
|
|
35
|
-
* Defaults to `systemClock`, so `
|
|
53
|
+
* Defaults to `systemClock`, so `memoryAuthAdapter()` is what it always was.
|
|
36
54
|
*/
|
|
37
55
|
constructor(clock: Clock = systemClock) {
|
|
38
56
|
this.#clock = clock;
|
|
39
57
|
}
|
|
40
58
|
|
|
41
59
|
/**
|
|
42
|
-
* Exact match, because `
|
|
60
|
+
* Exact match, because `PostgresAuthAdapter` issues `where email = $1` against a plain `text ...
|
|
43
61
|
* unique` column and nothing folds case there. Normalising here instead made this the ONE
|
|
44
62
|
* adapter that found an account Postgres would not, which is a linked account under `x dev` and
|
|
45
63
|
* a duplicate one in production. `normaliseEmail` is the caller's, above the seam.
|
|
@@ -56,7 +74,7 @@ export class MemoryAdapter implements AuthAdapter {
|
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
/**
|
|
59
|
-
* The two UNIQUE constraints `x_users` declares, enforced here because `
|
|
77
|
+
* The two UNIQUE constraints `x_users` declares, enforced here because `PostgresAuthAdapter` LEANS on
|
|
60
78
|
* them: `email text not null unique` and `external_id text unique` (`tables.ts`). Without them
|
|
61
79
|
* this adapter — the one `x new` scaffolds and every test runs against — accepted two rows at one
|
|
62
80
|
* address, and the second was unreachable forever, since `findUserByEmail` returns the first.
|
|
@@ -66,6 +84,8 @@ export class MemoryAdapter implements AuthAdapter {
|
|
|
66
84
|
* what makes two spellings one address.
|
|
67
85
|
*/
|
|
68
86
|
async createUser(input: CreateUserInput): Promise<AuthUser> {
|
|
87
|
+
// `id uuid primary key`: a `Map.set` over an existing id REPLACED the first account.
|
|
88
|
+
if (this.#users.has(input.id)) throw authUniqueViolation('createUser', 'x_users', 'id');
|
|
69
89
|
for (const existing of this.#users.values()) {
|
|
70
90
|
if (existing.email === input.email) {
|
|
71
91
|
throw authUniqueViolation('createUser', 'x_users', 'email');
|
|
@@ -107,6 +127,15 @@ export class MemoryAdapter implements AuthAdapter {
|
|
|
107
127
|
async updateUser(id: string, patch: UserPatch): Promise<AuthUser | null> {
|
|
108
128
|
const user = this.#users.get(id);
|
|
109
129
|
if (user === undefined) return null;
|
|
130
|
+
// `external_id text unique`, on the UPDATE as on the INSERT: every OTHER row, and only a
|
|
131
|
+
// value — NULLS DISTINCT, so clearing it never collides. `UserPatch` carries no `email`.
|
|
132
|
+
if (patch.externalId !== undefined && patch.externalId !== null) {
|
|
133
|
+
for (const other of this.#users.values()) {
|
|
134
|
+
if (other.id !== id && other.externalId === patch.externalId) {
|
|
135
|
+
throw authUniqueViolation('updateUser', 'x_users', 'external_id');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
110
139
|
const next: AuthUser = {
|
|
111
140
|
...user,
|
|
112
141
|
passwordHash: patch.passwordHash === undefined ? user.passwordHash : patch.passwordHash,
|
|
@@ -125,6 +154,35 @@ export class MemoryAdapter implements AuthAdapter {
|
|
|
125
154
|
return next;
|
|
126
155
|
}
|
|
127
156
|
|
|
157
|
+
async listUsersWithMfaSecret(): Promise<readonly StoredMfaSecret[]> {
|
|
158
|
+
const stored: StoredMfaSecret[] = [];
|
|
159
|
+
for (const user of this.#users.values()) {
|
|
160
|
+
if (user.mfaSecret !== null) stored.push({ userId: user.id, mfaSecret: user.mfaSecret });
|
|
161
|
+
}
|
|
162
|
+
return stored.sort((a, b) => (a.userId < b.userId ? -1 : a.userId > b.userId ? 1 : 0));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// No `await` between the check and the write: the two are one step, as the UPDATE is.
|
|
166
|
+
async replaceMfaSecret(userId: string, expected: string, next: string): Promise<boolean> {
|
|
167
|
+
const user = this.#users.get(userId);
|
|
168
|
+
if (user === undefined || user.mfaSecret === null) return false;
|
|
169
|
+
if (!timingSafeEqual(user.mfaSecret, expected)) return false;
|
|
170
|
+
this.#users.set(userId, { ...user, mfaSecret: next });
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// No `await` between the check and the write: the two are one step, as the UPDATE is.
|
|
175
|
+
async consumeRecoveryCode(userId: string, codeHash: string): Promise<boolean> {
|
|
176
|
+
const user = this.#users.get(userId);
|
|
177
|
+
const held = (stored: string): boolean => timingSafeEqual(stored, codeHash);
|
|
178
|
+
if (user === undefined || !user.recoveryCodeHashes.some(held)) return false;
|
|
179
|
+
this.#users.set(userId, {
|
|
180
|
+
...user,
|
|
181
|
+
recoveryCodeHashes: user.recoveryCodeHashes.filter((stored) => !held(stored)),
|
|
182
|
+
});
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
128
186
|
async findUserByExternalId(externalId: string): Promise<AuthUser | null> {
|
|
129
187
|
for (const user of this.#users.values()) {
|
|
130
188
|
if (user.externalId === externalId) return user;
|
|
@@ -137,7 +195,7 @@ export class MemoryAdapter implements AuthAdapter {
|
|
|
137
195
|
.filter((user) => user.orgId === orgId)
|
|
138
196
|
.filter((user) => query?.includeDisabled === true || user.disabledAt === null)
|
|
139
197
|
.filter((user) => query?.role === undefined || user.roles.includes(query.role))
|
|
140
|
-
.sort((a, b) => a.email
|
|
198
|
+
.sort((a, b) => byCodePoint(a.email, b.email));
|
|
141
199
|
}
|
|
142
200
|
|
|
143
201
|
async getSession(id: string): Promise<AuthSession | null> {
|
|
@@ -289,3 +347,11 @@ export class MemoryAdapter implements AuthAdapter {
|
|
|
289
347
|
return true;
|
|
290
348
|
}
|
|
291
349
|
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The one way to build the in-memory adapter — the twin of `postgresAuthAdapter()`. The class is a
|
|
353
|
+
* type in the barrel only (`X_FACTORY_NAME_SPELLING`), so `new` is never a second spelling.
|
|
354
|
+
*/
|
|
355
|
+
export function memoryAuthAdapter(clock: Clock = systemClock): MemoryAuthAdapter {
|
|
356
|
+
return new MemoryAuthAdapter(clock);
|
|
357
|
+
}
|