@ultimat3/auth 22.15.0 → 24.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 +79 -36
- package/README.md +178 -52
- package/package.json +5 -4
- package/src/adapter.ts +43 -16
- package/src/api-key-resolver.ts +55 -0
- package/src/api-keys.ts +59 -12
- package/src/auth.ts +48 -45
- package/src/builtin-adapter-violation.ts +43 -0
- package/src/builtin-adapter.ts +61 -6
- package/src/client-scope.ts +8 -2
- package/src/directory.ts +2 -13
- package/src/errors.ts +77 -45
- package/src/index.ts +48 -20
- package/src/jwks.ts +11 -0
- package/src/login-attempt.ts +70 -0
- package/src/memory-adapter.ts +41 -0
- package/src/mfa-challenge.ts +151 -0
- package/src/mfa-secret.ts +91 -0
- package/src/mfa.ts +9 -23
- package/src/oauth-builtins.ts +6 -0
- package/src/oauth-cookie.ts +8 -4
- package/src/oauth-discovery.ts +24 -5
- package/src/oauth-errors.ts +4 -84
- package/src/oauth-exchange.ts +13 -7
- package/src/oauth-login.ts +50 -9
- package/src/oauth-registry.ts +16 -1
- package/src/oauth-route.ts +75 -22
- package/src/policy-bridge.ts +51 -8
- package/src/privileges.ts +28 -16
- package/src/rate-limit-postgres.ts +113 -101
- package/src/rate-limit.ts +47 -13
- package/src/revocation.ts +25 -25
- package/src/session.ts +4 -30
- package/src/tables.ts +7 -9
- package/src/verify.ts +2 -2
- package/src/auth-fixture.ts +0 -26
- package/src/id-token-fixture.ts +0 -20
- package/src/oauth-login-fixture.ts +0 -53
package/src/errors.ts
CHANGED
|
@@ -29,6 +29,7 @@ export const AUTH_OWNED_ERROR_CODES = [
|
|
|
29
29
|
'X_AUTH_WRITE_FAILED',
|
|
30
30
|
'X_AUTH_LIMITER_NOT_SHARED',
|
|
31
31
|
'X_AUTH_LIMITER_POLICY_MISMATCH',
|
|
32
|
+
'X_MFA_SECRET_UNSEALED',
|
|
32
33
|
] as const;
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -74,6 +75,7 @@ export const AUTH_ERROR_TITLES: Readonly<Record<AuthOwnedErrorCode, string>> = {
|
|
|
74
75
|
X_AUTH_WRITE_FAILED: 'an adapter write returned no row, so it cannot be confirmed',
|
|
75
76
|
X_AUTH_LIMITER_NOT_SHARED: 'the lockout is declared fleet-wide and the limiter is per-process',
|
|
76
77
|
X_AUTH_LIMITER_POLICY_MISMATCH: 'the limiter in use enforces other numbers than the app declared',
|
|
78
|
+
X_MFA_SECRET_UNSEALED: 'a stored totp secret is not sealed, so it is never read as one',
|
|
77
79
|
};
|
|
78
80
|
|
|
79
81
|
// Registered unconditionally, in one call: a second package claiming a code auth owns has to fail
|
|
@@ -162,23 +164,41 @@ export const kdfOverloaded = (active: number, queued: number): AuthError =>
|
|
|
162
164
|
});
|
|
163
165
|
|
|
164
166
|
/**
|
|
165
|
-
* The second leg is
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* route would be unauthenticated by construction and MFA would become the ONLY factor. The design
|
|
170
|
-
* constraint for the real second leg is in `packages/auth/CLAUDE.md`.
|
|
167
|
+
* The second leg is `completeMfa(auth, challenge, code)` (`mfa-challenge.ts`), and the only thing
|
|
168
|
+
* that correlates it with a passed first leg is `meta.challenge`: a sealed, short-lived value the
|
|
169
|
+
* sign-in handler carries to the caller and back. It replaces a bare user id, which made any
|
|
170
|
+
* second leg built on it unauthenticated by construction.
|
|
171
171
|
*
|
|
172
|
-
*
|
|
173
|
-
* (`oauth-route.ts`'s `publicBody`, `@ultimat3/http`'s problem document) publish `cause`
|
|
174
|
-
* `meta`,
|
|
172
|
+
* The challenge is `meta`, never `cause`: both surfaces that render this code to an anonymous
|
|
173
|
+
* caller (`oauth-route.ts`'s `publicBody`, `@ultimat3/http`'s problem document) publish `cause`
|
|
174
|
+
* and drop `meta`, so the handler decides where it travels — a cookie or its own response body.
|
|
175
175
|
*/
|
|
176
|
-
export const mfaRequired = (
|
|
176
|
+
export const mfaRequired = (challenge: string): AuthError =>
|
|
177
177
|
new AuthError({
|
|
178
178
|
code: 'X_MFA_REQUIRED',
|
|
179
179
|
cause: 'this account has TOTP enrolled and the second factor has not been satisfied',
|
|
180
|
-
fix: '
|
|
181
|
-
meta: {
|
|
180
|
+
fix: 'catch X_MFA_REQUIRED in your sign-in handler, hand error.meta.challenge to the caller, and finish with: await completeMfa(auth, challenge, code)',
|
|
181
|
+
meta: { challenge },
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A challenge `completeMfa` will not act on: not one this app sealed, past its expiry, or naming
|
|
186
|
+
* an account that can no longer finish (deleted, disabled, or no longer enrolled). One refusal
|
|
187
|
+
* for all of them — which it was is not the caller's to learn.
|
|
188
|
+
*/
|
|
189
|
+
export const mfaChallengeInvalid = (): AuthError =>
|
|
190
|
+
new AuthError({
|
|
191
|
+
code: 'X_UNAUTHENTICATED',
|
|
192
|
+
cause: 'the second-factor challenge is not one this app issued, or it is no longer usable',
|
|
193
|
+
fix: 'sign in again for a fresh challenge: await login(auth, { email, password })',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/** A wrong or already-spent code. The attempt it cost stays counted against the account. */
|
|
197
|
+
export const mfaCodeRejected = (): AuthError =>
|
|
198
|
+
new AuthError({
|
|
199
|
+
code: 'X_UNAUTHENTICATED',
|
|
200
|
+
cause: 'the second-factor code did not match, or it was already used',
|
|
201
|
+
fix: 'read the current code from the authenticator app and retry: await completeMfa(auth, challenge, code)',
|
|
182
202
|
});
|
|
183
203
|
|
|
184
204
|
/**
|
|
@@ -201,6 +221,19 @@ export const mfaSecretInvalid = (surface: string): AuthError =>
|
|
|
201
221
|
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',
|
|
202
222
|
});
|
|
203
223
|
|
|
224
|
+
/**
|
|
225
|
+
* A stored secret that is not sealed. It is refused rather than read: the plaintext path is the
|
|
226
|
+
* thing sealing removes, and a reader that fell back to it would keep it alive for good. The fix
|
|
227
|
+
* is the one-shot that seals every such row; `x doctor` reports the count before a user does.
|
|
228
|
+
*/
|
|
229
|
+
export const mfaSecretUnsealed = (): AuthError =>
|
|
230
|
+
new AuthError({
|
|
231
|
+
code: 'X_MFA_SECRET_UNSEALED',
|
|
232
|
+
cause:
|
|
233
|
+
'x_users.mfa_secret holds a value that is not sealed — written before second-factor secrets were sealed at rest, or written past saveTotpSecret — and a plaintext secret is never read as one',
|
|
234
|
+
fix: 'x auth seal-mfa --json',
|
|
235
|
+
});
|
|
236
|
+
|
|
204
237
|
/**
|
|
205
238
|
* `defineAuth({ mfa: { required: true } })`, refused where it is declared. The option read as a
|
|
206
239
|
* security guarantee — "this deployment requires a second factor" — and nothing in this package
|
|
@@ -246,23 +279,10 @@ export const authPolicyNumberInvalid = (
|
|
|
246
279
|
meta: { option: key, value: String(value) },
|
|
247
280
|
});
|
|
248
281
|
|
|
249
|
-
/**
|
|
250
|
-
* An explicit key for `clientScopeOf` under the floor `SESSION_SECRET` is held to. The env path
|
|
251
|
-
* already refused a short secret; the option skipped the screen, so `{ secret: 'x' }` keyed every
|
|
252
|
-
* page's scope id with one character. `X_CONFIG_INVALID`, borrowed like the two above.
|
|
253
|
-
*/
|
|
254
|
-
export const clientScopeSecretShort = (length: number, minLength: number): AuthError =>
|
|
255
|
-
new AuthError({
|
|
256
|
-
code: 'X_CONFIG_INVALID',
|
|
257
|
-
cause: `clientScopeOf({ secret }) was passed a key of ${String(length)} characters and at least ${String(minLength)} are required, so the scope id would be keyed by a guessable secret`,
|
|
258
|
-
fix: 'omit secret so clientScopeOf reads SESSION_SECRET, or pass one generated with: openssl rand -hex 32',
|
|
259
|
-
meta: { option: 'secret', minLength },
|
|
260
|
-
});
|
|
261
|
-
|
|
262
282
|
/**
|
|
263
283
|
* An api key `env` the parser cannot read back. `parseApiKey` splits `ult_<env>_<id>_<secret>` on
|
|
264
284
|
* `_`, so `issueApiKey({ env: 'live_eu' })` minted a key its own verifier refused. Refused at
|
|
265
|
-
* issue — `X_CONFIG_INVALID`, borrowed like the
|
|
285
|
+
* issue — `X_CONFIG_INVALID`, borrowed like the two above — never at first use.
|
|
266
286
|
*/
|
|
267
287
|
export const apiKeyEnvInvalid = (env: string): AuthError =>
|
|
268
288
|
new AuthError({
|
|
@@ -274,6 +294,19 @@ export const apiKeyEnvInvalid = (env: string): AuthError =>
|
|
|
274
294
|
meta: { option: 'env' },
|
|
275
295
|
});
|
|
276
296
|
|
|
297
|
+
/**
|
|
298
|
+
* A wildcard scope on an api key. `*` and `<resource>:*` are how a ROLE is granted everything; on
|
|
299
|
+
* a key they are a credential that silently grows with every permission the app adds later.
|
|
300
|
+
* Refused at issue — `X_CONFIG_INVALID`, borrowed like the ones above.
|
|
301
|
+
*/
|
|
302
|
+
export const apiKeyScopeWildcard = (scope: string): AuthError =>
|
|
303
|
+
new AuthError({
|
|
304
|
+
code: 'X_CONFIG_INVALID',
|
|
305
|
+
cause: `api key scope ${renderCauseValue(scope)} is a wildcard, and a key carries exactly the permissions it names`,
|
|
306
|
+
fix: "pass each permission by name to issueApiKey({ scopes }) — e.g. scopes: ['post:read', 'post:publish']",
|
|
307
|
+
meta: { option: 'scopes' },
|
|
308
|
+
});
|
|
309
|
+
|
|
277
310
|
export const passwordWeak = (reasons: readonly string[]): AuthError =>
|
|
278
311
|
new AuthError({
|
|
279
312
|
code: 'X_PASSWORD_WEAK',
|
|
@@ -281,28 +314,35 @@ export const passwordWeak = (reasons: readonly string[]): AuthError =>
|
|
|
281
314
|
fix: 'choose a longer, uncommon password — or relax defineAuth({ password: { minLength } })',
|
|
282
315
|
});
|
|
283
316
|
|
|
317
|
+
/** The tenant bucket's key prefix. Declared here because the refusal below must recognise it. */
|
|
318
|
+
export const ORG_KEY_PREFIX = 'org:';
|
|
319
|
+
|
|
284
320
|
/**
|
|
285
321
|
* The escape is `recordSuccess(key)`, which is what a successful login already calls: it deletes
|
|
286
322
|
* the bucket, so it clears exactly this one key and nothing else. `reset()` exists too and is the
|
|
287
323
|
* wrong reach — it drops every bucket in the table, including the spray this lockout is holding.
|
|
288
324
|
*
|
|
289
|
-
* `
|
|
290
|
-
*
|
|
325
|
+
* An `ip:` or `account:` key is the caller's own input, so it is echoed (escaped). A tenant key is
|
|
326
|
+
* NOT: the org id was looked up from the address the caller typed, and naming it — or even saying
|
|
327
|
+
* "an organisation" — confirms to an anonymous caller that the address belongs to a member.
|
|
291
328
|
*/
|
|
292
|
-
export const accountLocked = (key: string, retryAfterSeconds: number): AuthError =>
|
|
293
|
-
|
|
329
|
+
export const accountLocked = (key: string, retryAfterSeconds: number): AuthError => {
|
|
330
|
+
const tenant = key.startsWith(ORG_KEY_PREFIX);
|
|
331
|
+
return new AuthError({
|
|
294
332
|
code: 'X_ACCOUNT_LOCKED',
|
|
295
|
-
// `renderCauseValue
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
333
|
+
// `renderCauseValue`: `ipKey(ip)` builds the key from whatever address string its caller
|
|
334
|
+
// passed, so a newline in it would write a second log line an operator reads as genuine.
|
|
335
|
+
cause: tenant
|
|
336
|
+
? `sign-in is locked out for another ${retryAfterSeconds}s after repeated failures`
|
|
337
|
+
: `${renderCauseValue(key)} is locked out for another ${retryAfterSeconds}s after repeated failures`,
|
|
338
|
+
fix: tenant
|
|
339
|
+
? `wait ${retryAfterSeconds}s — or clear the one bucket that is holding: await auth.orgLimiter.recordSuccess(orgKey(user.orgId)) — or raise defineAuth({ rateLimit })`
|
|
340
|
+
: `wait ${retryAfterSeconds}s — or clear this one bucket: await auth.limiter.recordSuccess(${renderFixLiteral(key, '<key>')}) — or raise defineAuth({ rateLimit })`,
|
|
302
341
|
// `kdfOverloaded`'s shape: `@ultimat3/http`'s `retryAfterOf` reads exactly this field. `key`
|
|
303
342
|
// stays out — `cause`/`fix` escape it on purpose and `meta` is read by surfaces that do not.
|
|
304
343
|
meta: { retryAfterSeconds },
|
|
305
344
|
});
|
|
345
|
+
};
|
|
306
346
|
|
|
307
347
|
/** One shape for every api-key rejection: unknown, revoked, expired and wrong all look alike. */
|
|
308
348
|
export const apiKeyInvalid = (): AuthError =>
|
|
@@ -376,11 +416,3 @@ export const authLimiterPolicyMismatch = (
|
|
|
376
416
|
fix: `construct the limiter with ${field}: ${declared} — defineAuth({ rateLimit, limiter }) compares the two, and the declaration is what Auth.rateLimit reports`,
|
|
377
417
|
meta: { field, declared, enforced },
|
|
378
418
|
});
|
|
379
|
-
|
|
380
|
-
/** For a custom `AuthAdapter` that implements part of the seam. Nothing shipped throws it. */
|
|
381
|
-
export const authNotImplemented = (feature: string, fix: string): AuthError =>
|
|
382
|
-
new AuthError({
|
|
383
|
-
code: 'X_NOT_IMPLEMENTED',
|
|
384
|
-
cause: `${feature} is not implemented by the built-in driver`,
|
|
385
|
-
fix,
|
|
386
|
-
});
|
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,15 +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
|
-
export type {
|
|
22
|
+
export type { ApiKeyCaller, ApiKeyResolverOptions } from './api-key-resolver';
|
|
23
|
+
export { apiKeyResolver } from './api-key-resolver';
|
|
24
|
+
export type {
|
|
25
|
+
ApiKeyActorOptions,
|
|
26
|
+
ApiKeySummary,
|
|
27
|
+
ApiKeyVerifyStore,
|
|
28
|
+
IssueApiKeyInput,
|
|
29
|
+
IssuedApiKey,
|
|
30
|
+
ParsedApiKey,
|
|
31
|
+
VerifiedApiKey,
|
|
32
|
+
} from './api-keys';
|
|
21
33
|
export {
|
|
22
34
|
API_KEY_NAMESPACE,
|
|
23
35
|
apiKeyActor,
|
|
24
36
|
describeApiKey,
|
|
37
|
+
directGrants,
|
|
25
38
|
issueApiKey,
|
|
26
39
|
parseApiKey,
|
|
27
40
|
revokeApiKey,
|
|
@@ -48,7 +61,6 @@ export {
|
|
|
48
61
|
UserSchema,
|
|
49
62
|
VerificationSchema,
|
|
50
63
|
} from './auth';
|
|
51
|
-
|
|
52
64
|
export { BuiltinAdapter } from './builtin-adapter';
|
|
53
65
|
/** The opaque per-principal id a per-request document hands the page's client store. */
|
|
54
66
|
export type { ClientScopeOptions } from './client-scope';
|
|
@@ -68,14 +80,13 @@ export {
|
|
|
68
80
|
apiKeyInvalid,
|
|
69
81
|
authLimiterNotShared,
|
|
70
82
|
authLimiterPolicyMismatch,
|
|
71
|
-
authNotImplemented,
|
|
72
83
|
authUniqueViolation,
|
|
73
84
|
authWriteFailed,
|
|
74
85
|
forbidden,
|
|
75
86
|
kdfOverloaded,
|
|
76
|
-
mfaRequired,
|
|
77
87
|
mfaRequiredUnenforceable,
|
|
78
88
|
mfaSecretInvalid,
|
|
89
|
+
mfaSecretUnsealed,
|
|
79
90
|
passwordWeak,
|
|
80
91
|
sessionExpired,
|
|
81
92
|
sessionUnknown,
|
|
@@ -130,7 +141,7 @@ export {
|
|
|
130
141
|
enrolTotp,
|
|
131
142
|
generateRecoveryCodes,
|
|
132
143
|
generateTotpSecret,
|
|
133
|
-
|
|
144
|
+
recoveryCodeHash,
|
|
134
145
|
TOTP_DIGITS,
|
|
135
146
|
TOTP_DRIFT_STEPS,
|
|
136
147
|
TOTP_STEP_SECONDS,
|
|
@@ -138,6 +149,23 @@ export {
|
|
|
138
149
|
totpStep,
|
|
139
150
|
verifyTotp,
|
|
140
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';
|
|
141
169
|
export type {
|
|
142
170
|
BeginOAuthInput,
|
|
143
171
|
OAuthCallback,
|
|
@@ -167,16 +195,11 @@ export {
|
|
|
167
195
|
} from './oauth-cookie';
|
|
168
196
|
export type { DiscoverOAuthProviderInput } from './oauth-discovery';
|
|
169
197
|
export { discoverOAuthProvider, discoveryUrl } from './oauth-discovery';
|
|
170
|
-
// The OAuth
|
|
171
|
-
//
|
|
198
|
+
// The OAuth refusals more than one OAuth module raises. A refusal with one thrower is exported
|
|
199
|
+
// from that module, below.
|
|
172
200
|
export type { OAuthExchangeFailure } from './oauth-errors';
|
|
173
201
|
export {
|
|
174
|
-
emailVerifiedNotStored,
|
|
175
|
-
oauthAccountNotLinked,
|
|
176
|
-
oauthDenied,
|
|
177
202
|
oauthExchangeFailed,
|
|
178
|
-
oauthLinkingDisabled,
|
|
179
|
-
oauthProviderDuplicate,
|
|
180
203
|
oauthProviderUnknown,
|
|
181
204
|
oauthStateInvalid,
|
|
182
205
|
oauthTokenInvalid,
|
|
@@ -195,7 +218,13 @@ export type {
|
|
|
195
218
|
OAuthSignInInput,
|
|
196
219
|
ResolveOAuthGrants,
|
|
197
220
|
} from './oauth-login';
|
|
198
|
-
export {
|
|
221
|
+
export {
|
|
222
|
+
completeOAuthLogin,
|
|
223
|
+
emailVerifiedNotStored,
|
|
224
|
+
oauthAccountNotLinked,
|
|
225
|
+
oauthLinkingDisabled,
|
|
226
|
+
signInWithOAuth,
|
|
227
|
+
} from './oauth-login';
|
|
199
228
|
export {
|
|
200
229
|
OAUTH_BASE_PATH,
|
|
201
230
|
OAUTH_CALLBACK_ROUTE_PATH,
|
|
@@ -207,12 +236,13 @@ export type { OAuthProfile, OAuthProfileOptions } from './oauth-profile';
|
|
|
207
236
|
export { oauthProfile } from './oauth-profile';
|
|
208
237
|
export {
|
|
209
238
|
hasOAuthProvider,
|
|
239
|
+
oauthProviderDuplicate,
|
|
210
240
|
oauthProviderIds,
|
|
211
241
|
providerFor,
|
|
212
242
|
registerOAuthProvider,
|
|
213
243
|
} from './oauth-registry';
|
|
214
244
|
export type { AuthRouteDescriptor, OAuthLoginOptions, OAuthLoginRoutes } from './oauth-route';
|
|
215
|
-
export { OAUTH_ROUTE_STATUS, oauthLogin } from './oauth-route';
|
|
245
|
+
export { OAUTH_ROUTE_STATUS, oauthDenied, oauthLogin } from './oauth-route';
|
|
216
246
|
export type {
|
|
217
247
|
PasswordParams,
|
|
218
248
|
PasswordPolicy,
|
|
@@ -238,6 +268,8 @@ export {
|
|
|
238
268
|
actorFromApiKey,
|
|
239
269
|
actorFromService,
|
|
240
270
|
actorFromUser,
|
|
271
|
+
apiKeyScopes,
|
|
272
|
+
isWildcardScope,
|
|
241
273
|
resolveActor,
|
|
242
274
|
} from './policy-bridge';
|
|
243
275
|
export type { UpdatePrivilegesResult } from './privileges';
|
|
@@ -246,6 +278,7 @@ export type {
|
|
|
246
278
|
AuthLimiter,
|
|
247
279
|
AuthLimiterScope,
|
|
248
280
|
AuthRateLimitPolicy,
|
|
281
|
+
AuthReservation,
|
|
249
282
|
MemoryAuthLimiter,
|
|
250
283
|
} from './rate-limit';
|
|
251
284
|
export {
|
|
@@ -259,11 +292,7 @@ export {
|
|
|
259
292
|
orgKey,
|
|
260
293
|
orgRateLimit,
|
|
261
294
|
} from './rate-limit';
|
|
262
|
-
export type {
|
|
263
|
-
PgExecutor,
|
|
264
|
-
PostgresAuthLimiter,
|
|
265
|
-
PostgresAuthLimiterOptions,
|
|
266
|
-
} from './rate-limit-postgres';
|
|
295
|
+
export type { PostgresAuthLimiter, PostgresAuthLimiterOptions } from './rate-limit-postgres';
|
|
267
296
|
export {
|
|
268
297
|
postgresAuthLimiter,
|
|
269
298
|
SQL_AUTH_LIMIT_TABLES,
|
|
@@ -293,7 +322,6 @@ export {
|
|
|
293
322
|
DEFAULT_SESSION_POLICY,
|
|
294
323
|
idleSlideMs,
|
|
295
324
|
listDevices,
|
|
296
|
-
readCookie,
|
|
297
325
|
readSessionCookie,
|
|
298
326
|
revokeOtherSessions,
|
|
299
327
|
revokeSession,
|
package/src/jwks.ts
CHANGED
|
@@ -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();
|
|
@@ -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
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
AuthVerification,
|
|
13
13
|
CreateUserInput,
|
|
14
14
|
SessionPatch,
|
|
15
|
+
StoredMfaSecret,
|
|
15
16
|
UserPatch,
|
|
16
17
|
UserQuery,
|
|
17
18
|
} from './adapter';
|
|
@@ -66,6 +67,8 @@ export class MemoryAdapter implements AuthAdapter {
|
|
|
66
67
|
* what makes two spellings one address.
|
|
67
68
|
*/
|
|
68
69
|
async createUser(input: CreateUserInput): Promise<AuthUser> {
|
|
70
|
+
// `id uuid primary key`: a `Map.set` over an existing id REPLACED the first account.
|
|
71
|
+
if (this.#users.has(input.id)) throw authUniqueViolation('createUser', 'x_users', 'id');
|
|
69
72
|
for (const existing of this.#users.values()) {
|
|
70
73
|
if (existing.email === input.email) {
|
|
71
74
|
throw authUniqueViolation('createUser', 'x_users', 'email');
|
|
@@ -107,6 +110,15 @@ export class MemoryAdapter implements AuthAdapter {
|
|
|
107
110
|
async updateUser(id: string, patch: UserPatch): Promise<AuthUser | null> {
|
|
108
111
|
const user = this.#users.get(id);
|
|
109
112
|
if (user === undefined) return null;
|
|
113
|
+
// `external_id text unique`, on the UPDATE as on the INSERT: every OTHER row, and only a
|
|
114
|
+
// value — NULLS DISTINCT, so clearing it never collides. `UserPatch` carries no `email`.
|
|
115
|
+
if (patch.externalId !== undefined && patch.externalId !== null) {
|
|
116
|
+
for (const other of this.#users.values()) {
|
|
117
|
+
if (other.id !== id && other.externalId === patch.externalId) {
|
|
118
|
+
throw authUniqueViolation('updateUser', 'x_users', 'external_id');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
110
122
|
const next: AuthUser = {
|
|
111
123
|
...user,
|
|
112
124
|
passwordHash: patch.passwordHash === undefined ? user.passwordHash : patch.passwordHash,
|
|
@@ -125,6 +137,35 @@ export class MemoryAdapter implements AuthAdapter {
|
|
|
125
137
|
return next;
|
|
126
138
|
}
|
|
127
139
|
|
|
140
|
+
async listUsersWithMfaSecret(): Promise<readonly StoredMfaSecret[]> {
|
|
141
|
+
const stored: StoredMfaSecret[] = [];
|
|
142
|
+
for (const user of this.#users.values()) {
|
|
143
|
+
if (user.mfaSecret !== null) stored.push({ userId: user.id, mfaSecret: user.mfaSecret });
|
|
144
|
+
}
|
|
145
|
+
return stored.sort((a, b) => (a.userId < b.userId ? -1 : a.userId > b.userId ? 1 : 0));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// No `await` between the check and the write: the two are one step, as the UPDATE is.
|
|
149
|
+
async replaceMfaSecret(userId: string, expected: string, next: string): Promise<boolean> {
|
|
150
|
+
const user = this.#users.get(userId);
|
|
151
|
+
if (user === undefined || user.mfaSecret === null) return false;
|
|
152
|
+
if (!timingSafeEqual(user.mfaSecret, expected)) return false;
|
|
153
|
+
this.#users.set(userId, { ...user, mfaSecret: next });
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// No `await` between the check and the write: the two are one step, as the UPDATE is.
|
|
158
|
+
async consumeRecoveryCode(userId: string, codeHash: string): Promise<boolean> {
|
|
159
|
+
const user = this.#users.get(userId);
|
|
160
|
+
const held = (stored: string): boolean => timingSafeEqual(stored, codeHash);
|
|
161
|
+
if (user === undefined || !user.recoveryCodeHashes.some(held)) return false;
|
|
162
|
+
this.#users.set(userId, {
|
|
163
|
+
...user,
|
|
164
|
+
recoveryCodeHashes: user.recoveryCodeHashes.filter((stored) => !held(stored)),
|
|
165
|
+
});
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
128
169
|
async findUserByExternalId(externalId: string): Promise<AuthUser | null> {
|
|
129
170
|
for (const user of this.#users.values()) {
|
|
130
171
|
if (user.externalId === externalId) return user;
|