@ultimat3/auth 1.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -35,11 +35,13 @@ export type {
35
35
  AuthMfaPolicy,
36
36
  LoginInput,
37
37
  LoginResult,
38
+ OAuthLinkPolicy,
38
39
  RegisterInput,
39
40
  } from './auth';
40
41
  export {
41
42
  AccountSchema,
42
43
  authenticate,
44
+ DEFAULT_MFA_ISSUER,
43
45
  defineAuth,
44
46
  login,
45
47
  logout,
@@ -50,6 +52,11 @@ export {
50
52
  } from './auth';
51
53
 
52
54
  export { BuiltinAdapter } from './builtin-adapter';
55
+ export type { AuthUserSummary } from './directory';
56
+ export { describeUser, findUserByExternalId, listOrgUsers } from './directory';
57
+ // The one normalisation an address gets before it is an identity key. Public because an app
58
+ // writing its own `AuthAdapter`, or its own login route, has to key exactly the way this does.
59
+ export { normaliseEmail } from './email';
53
60
  export type { AuthErrorCode, AuthThrowCode, OAuthExchangeFailure } from './errors';
54
61
  export {
55
62
  AUTH_BORROWED_ERROR_CODES,
@@ -58,22 +65,31 @@ export {
58
65
  AuthError,
59
66
  accountLocked,
60
67
  apiKeyInvalid,
68
+ authLimiterNotShared,
69
+ authLimiterPolicyMismatch,
61
70
  authNotImplemented,
62
71
  authWriteFailed,
63
72
  emailVerifiedNotStored,
64
73
  forbidden,
74
+ kdfOverloaded,
65
75
  mfaRequired,
76
+ mfaRequiredUnenforceable,
66
77
  oauthAccountNotLinked,
78
+ oauthDenied,
67
79
  oauthExchangeFailed,
80
+ oauthLinkingDisabled,
81
+ oauthProviderDuplicate,
82
+ oauthProviderUnknown,
68
83
  oauthStateInvalid,
69
84
  oauthTokenInvalid,
70
85
  passwordWeak,
86
+ restartAt,
71
87
  sessionExpired,
72
88
  sessionUnknown,
73
89
  unauthenticated,
74
90
  } from './errors';
75
91
 
76
- export { currentActor, requireActor, requireRole, requireScope } from './guards';
92
+ export { currentActor, requireActor } from './guards';
77
93
  export type { IdTokenClaims, VerifyIdTokenInput } from './id-token';
78
94
  export {
79
95
  decodeIdToken,
@@ -81,10 +97,32 @@ export {
81
97
  idTokenEmailVerified,
82
98
  verifyIdToken,
83
99
  } from './id-token';
84
-
100
+ export type {
101
+ IdTokenKeys,
102
+ JwksClientOptions,
103
+ JwksKeySource,
104
+ JwtAlgorithm,
105
+ JwtHeader,
106
+ } from './jwks';
107
+ export {
108
+ createJwksClient,
109
+ DEFAULT_JWKS_TTL_MS,
110
+ decodeJwtHeader,
111
+ providerJwks,
112
+ verifyJwtSignature,
113
+ } from './jwks';
114
+ export type { KdfGate, KdfLimits } from './kdf-gate';
115
+ export {
116
+ configureKdfGate,
117
+ createKdfGate,
118
+ DEFAULT_KDF_LIMITS,
119
+ kdfGate,
120
+ resetKdfGate,
121
+ } from './kdf-gate';
85
122
  export { MemoryAdapter } from './memory-adapter';
86
123
  export type {
87
124
  EnrolTotpInput,
125
+ MemoryTotpReplayGuard,
88
126
  RecoveryCodeSet,
89
127
  TotpEnrolment,
90
128
  TotpReplayGuard,
@@ -95,6 +133,7 @@ export {
95
133
  base32Decode,
96
134
  base32Encode,
97
135
  createTotpReplayGuard,
136
+ DEFAULT_MAX_TOTP_SUBJECTS,
98
137
  enrolTotp,
99
138
  generateRecoveryCodes,
100
139
  generateTotpSecret,
@@ -114,14 +153,14 @@ export type {
114
153
  OAuthProviderId,
115
154
  PkcePair,
116
155
  } from './oauth';
156
+ export { assertOAuthCallback, beginOAuth, createPkce, pkceChallenge } from './oauth';
117
157
  export {
118
- assertOAuthCallback,
119
- beginOAuth,
120
- createPkce,
121
- OAUTH_PROVIDER_IDS,
122
- OAUTH_PROVIDERS,
123
- pkceChallenge,
124
- } from './oauth';
158
+ APPLE_PROVIDER,
159
+ BUILTIN_OAUTH_PROVIDER_IDS,
160
+ BUILTIN_OAUTH_PROVIDERS,
161
+ GITHUB_PROVIDER,
162
+ GOOGLE_PROVIDER,
163
+ } from './oauth-builtins';
125
164
  export type { HandshakeCookieOptions, HandshakeSealOptions } from './oauth-cookie';
126
165
  export {
127
166
  clearHandshakeCookie,
@@ -134,6 +173,8 @@ export {
134
173
  readHandshakeCookie,
135
174
  sealHandshake,
136
175
  } from './oauth-cookie';
176
+ export type { DiscoverOAuthProviderInput } from './oauth-discovery';
177
+ export { discoverOAuthProvider, discoveryUrl } from './oauth-discovery';
137
178
  export type {
138
179
  OAuthClientCredentials,
139
180
  OAuthExchangeOptions,
@@ -141,10 +182,30 @@ export type {
141
182
  OAuthTokens,
142
183
  } from './oauth-exchange';
143
184
  export { exchangeOAuthCode, oauthCredentials } from './oauth-exchange';
144
- export type { CompleteOAuthLoginInput, OAuthSignInInput } from './oauth-login';
185
+ export type {
186
+ CompleteOAuthLoginInput,
187
+ OAuthGrants,
188
+ OAuthSignInInput,
189
+ ResolveOAuthGrants,
190
+ } from './oauth-login';
145
191
  export { completeOAuthLogin, signInWithOAuth } from './oauth-login';
192
+ export {
193
+ OAUTH_BASE_PATH,
194
+ OAUTH_CALLBACK_ROUTE_PATH,
195
+ OAUTH_START_ROUTE_PATH,
196
+ oauthCallbackPath,
197
+ oauthStartPath,
198
+ } from './oauth-paths';
146
199
  export type { OAuthProfile, OAuthProfileOptions } from './oauth-profile';
147
200
  export { oauthProfile } from './oauth-profile';
201
+ export {
202
+ hasOAuthProvider,
203
+ oauthProviderIds,
204
+ providerFor,
205
+ registerOAuthProvider,
206
+ } from './oauth-registry';
207
+ export type { AuthRouteDescriptor, OAuthLoginOptions, OAuthLoginRoutes } from './oauth-route';
208
+ export { OAUTH_ROUTE_STATUS, oauthLogin } from './oauth-route';
148
209
  export type {
149
210
  PasswordParams,
150
211
  PasswordPolicy,
@@ -173,14 +234,34 @@ export {
173
234
  actorFromUser,
174
235
  resolveActor,
175
236
  } from './policy-bridge';
176
- export type { AuthLimiter, AuthRateLimitPolicy } from './rate-limit';
237
+ export type { UpdatePrivilegesResult } from './privileges';
238
+ export { updatePrivileges } from './privileges';
239
+ export type {
240
+ AuthLimiter,
241
+ AuthLimiterScope,
242
+ AuthRateLimitPolicy,
243
+ MemoryAuthLimiter,
244
+ } from './rate-limit';
177
245
  export {
178
246
  accountKey,
247
+ assertAuthLimiterPolicy,
179
248
  createAuthLimiter,
180
249
  DEFAULT_AUTH_RATE_LIMIT,
250
+ DEFAULT_MAX_AUTH_LIMIT_KEYS,
181
251
  ipKey,
182
252
  loginFailed,
253
+ ORG_ATTEMPT_FACTOR,
254
+ orgKey,
255
+ orgRateLimit,
183
256
  } from './rate-limit';
257
+ export type { DisabledUser } from './revocation';
258
+ export {
259
+ disableUser,
260
+ enableUser,
261
+ revokeOrgSessions,
262
+ revokeSessionsCreatedBefore,
263
+ revokeUserSessions,
264
+ } from './revocation';
184
265
  export type {
185
266
  CookieJar,
186
267
  CreateSessionInput,
@@ -196,6 +277,8 @@ export {
196
277
  clearSessionCookie,
197
278
  createSession,
198
279
  DEFAULT_SESSION_POLICY,
280
+ IDLE_SLIDE_DIVISOR,
281
+ idleSlideMs,
199
282
  listDevices,
200
283
  parseSessionToken,
201
284
  readCookie,
@@ -214,11 +297,18 @@ export {
214
297
  X_ACCOUNTS_TABLE,
215
298
  X_API_KEYS_TABLE,
216
299
  X_SESSIONS_TABLE,
300
+ X_USERS_MIGRATION_1_3,
217
301
  X_USERS_TABLE,
218
302
  X_VERIFICATIONS_TABLE,
219
303
  } from './tables';
220
-
221
- export { base64Url, matchesHash, randomToken, sha256Hex, timingSafeEqual } from './tokens';
304
+ export {
305
+ base64Url,
306
+ base64UrlBytes,
307
+ matchesHash,
308
+ randomToken,
309
+ sha256Hex,
310
+ timingSafeEqual,
311
+ } from './tokens';
222
312
  export type {
223
313
  ConsumeVerificationInput,
224
314
  IssuedVerification,
@@ -234,3 +324,9 @@ export {
234
324
  VERIFICATION_PURPOSES,
235
325
  VERIFICATION_TEMPLATES,
236
326
  } from './verify';
327
+ export type {
328
+ VerifyWorkloadTokenInput,
329
+ WorkloadClaims,
330
+ WorkloadToken,
331
+ } from './workload';
332
+ export { verifyWorkloadToken } from './workload';
package/src/json.ts ADDED
@@ -0,0 +1,33 @@
1
+ // Single responsibility: reading untrusted JSON — a provider's response body, or a JWT payload the
2
+ // caller presented. Both questions were answered six and three times over in this package, and on a
3
+ // JWT payload "is this an object" is what gates every claim read after it, so two copies quietly
4
+ // disagreeing about (say) an array would be two different answers to a security question.
5
+
6
+ import { base64UrlBytes } from './tokens';
7
+
8
+ /**
9
+ * An ARRAY IS NOT A RECORD. That is the whole of the disagreement between the two variants of this
10
+ * predicate in the wild, and it matters here: `JSON.parse('[1,2]')` is an object with numeric keys,
11
+ * so without the array check a JWT payload of `[]` narrows to `Record<string, unknown>` and every
12
+ * `parsed['iss']` below it reads `undefined` from a shape no issuer ever sends.
13
+ */
14
+ export const isRecord = (value: unknown): value is Record<string, unknown> =>
15
+ typeof value === 'object' && value !== null && !Array.isArray(value);
16
+
17
+ /**
18
+ * A base64url JWT segment as a JSON object, or `null` for every way it can fail to be one: not
19
+ * base64url, not JSON, not an object. `null` rather than a throw because every caller is holding an
20
+ * attacker-supplied string and each has its own coded refusal to raise — the same reason
21
+ * `base64UrlBytes` and `readCookie` never throw.
22
+ */
23
+ export function decodeJwtSegment(segment: string): Record<string, unknown> | null {
24
+ const bytes = base64UrlBytes(segment);
25
+ if (bytes === null) return null;
26
+ let parsed: unknown;
27
+ try {
28
+ parsed = JSON.parse(new TextDecoder().decode(bytes));
29
+ } catch {
30
+ return null;
31
+ }
32
+ return isRecord(parsed) ? parsed : null;
33
+ }
package/src/jwks.ts ADDED
@@ -0,0 +1,246 @@
1
+ // Single responsibility: verifying a JWT's signature against a provider's published JWKS.
2
+ // `crypto.subtle` covers RS256 and ES256, so this costs no dependency. It exists because the OIDC
3
+ // Core 3.1.3.7 exemption — "a token fetched over TLS straight from the token endpoint needs no
4
+ // signature check" — holds for exactly one channel, and IdP-initiated login, `response_mode=
5
+ // form_post`, back-channel logout and token exchange are all not it. On any of those, an
6
+ // unverified JWT with the right `iss`, `aud` and a victim's `sub` is a full account takeover with
7
+ // no credential, so a signature check has to exist before those doors are opened.
8
+
9
+ import type { Clock } from '@ultimat3/core';
10
+ import { systemClock } from '@ultimat3/core';
11
+ import { oauthExchangeFailed, oauthTokenInvalid } from './errors';
12
+ import { decodeJwtSegment, isRecord } from './json';
13
+ import type { OAuthProvider } from './oauth';
14
+ import type { OAuthFetch } from './oauth-exchange';
15
+ import { base64UrlBytes } from './tokens';
16
+
17
+ /**
18
+ * The two asymmetric algorithms every OP in practice signs with. `HS256` is deliberately absent
19
+ * and so is `none`: a symmetric algorithm verified against a *public* key set is the classic
20
+ * algorithm-confusion forgery, and refusing the header value is how it stays unreachable.
21
+ */
22
+ export type JwtAlgorithm = 'RS256' | 'ES256';
23
+
24
+ const SUPPORTED_ALGORITHMS: readonly string[] = ['RS256', 'ES256'];
25
+
26
+ export interface JwtHeader {
27
+ readonly alg: JwtAlgorithm;
28
+ readonly kid: string | null;
29
+ }
30
+
31
+ /** What a signature check needs: one key, for one `kid`, for one algorithm. */
32
+ export interface JwksKeySource {
33
+ keyFor(kid: string | null, alg: JwtAlgorithm): Promise<CryptoKey>;
34
+ }
35
+
36
+ /**
37
+ * How a caller declares where its trust comes from. `'token-endpoint-tls'` is the OIDC exemption,
38
+ * spelled out rather than defaulted: a call site that never names its channel is a call site that
39
+ * silently inherited "unverified" when somebody added a second way in.
40
+ */
41
+ export type IdTokenKeys = 'token-endpoint-tls' | JwksKeySource;
42
+
43
+ /** Short enough that a rotated key set is picked up on its own; a new `kid` refreshes early. */
44
+ export const DEFAULT_JWKS_TTL_MS = 10 * 60 * 1000;
45
+
46
+ const DEFAULT_TIMEOUT_MS = 10_000;
47
+
48
+ export interface JwksClientOptions {
49
+ /** Named in every refusal this client throws. */
50
+ readonly provider: string;
51
+ readonly jwksUri: string;
52
+ /** Injected in tests; production uses the global. */
53
+ readonly fetch?: OAuthFetch | undefined;
54
+ /** No `Date.now()` in this package: the cache's age is measured against this. */
55
+ readonly clock?: Clock | undefined;
56
+ readonly ttlMs?: number | undefined;
57
+ readonly timeoutMs?: number | undefined;
58
+ }
59
+
60
+ const importParams = (alg: JwtAlgorithm): RsaHashedImportParams | EcKeyImportParams =>
61
+ alg === 'RS256'
62
+ ? { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }
63
+ : { name: 'ECDSA', namedCurve: 'P-256' };
64
+
65
+ const verifyParams = (alg: JwtAlgorithm): AlgorithmIdentifier | EcdsaParams =>
66
+ alg === 'RS256' ? { name: 'RSASSA-PKCS1-v1_5' } : { name: 'ECDSA', hash: 'SHA-256' };
67
+
68
+ /** A JWT header is attacker-supplied, so an unreadable one is `null` and never an exception. */
69
+ export function decodeJwtHeader(segment: string): JwtHeader | null {
70
+ const parsed = decodeJwtSegment(segment);
71
+ if (parsed === null) return null;
72
+ const alg = parsed['alg'];
73
+ if (typeof alg !== 'string' || !SUPPORTED_ALGORITHMS.includes(alg)) return null;
74
+ const kid = parsed['kid'];
75
+ return { alg: alg as JwtAlgorithm, kid: typeof kid === 'string' && kid !== '' ? kid : null };
76
+ }
77
+
78
+ /** A key set entry the import understood. `kty` decides which algorithm it can ever verify. */
79
+ const algorithmOf = (jwk: Record<string, unknown>): JwtAlgorithm | null => {
80
+ const alg = jwk['alg'];
81
+ if (alg === 'RS256' || alg === 'ES256') return alg;
82
+ if (jwk['kty'] === 'RSA') return 'RS256';
83
+ if (jwk['kty'] === 'EC' && jwk['crv'] === 'P-256') return 'ES256';
84
+ return null;
85
+ };
86
+
87
+ /**
88
+ * Caches by `kid` with a TTL, and refetches once when a token names a `kid` the cache has never
89
+ * seen — which is what makes a key rotation heal itself instead of failing every login until the
90
+ * TTL runs out. One refetch, not one per token: an unknown `kid` an attacker invents must not
91
+ * become an unbounded outbound request per attempt, so the refresh is rate-limited by the same
92
+ * TTL as the ordinary one.
93
+ */
94
+ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
95
+ const clock = options.clock ?? systemClock;
96
+ const ttlMs = options.ttlMs ?? DEFAULT_JWKS_TTL_MS;
97
+ let keys = new Map<string, CryptoKey>();
98
+ let fetchedAtMs = Number.NEGATIVE_INFINITY;
99
+ // When the last UNKNOWN-`kid` refresh ran, tracked apart from `fetchedAtMs` because that field
100
+ // is reset by every fetch, ordinary ones included — so gating the early refresh on it would let
101
+ // an attacker's own refresh authorise the next one.
102
+ let lastMissRefreshMs = Number.NEGATIVE_INFINITY;
103
+ let inflight: Promise<Map<string, CryptoKey>> | null = null;
104
+
105
+ const fetchKeys = async (): Promise<Map<string, CryptoKey>> => {
106
+ const doFetch: OAuthFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
107
+ let response: Response;
108
+ try {
109
+ response = await doFetch(options.jwksUri, {
110
+ method: 'GET',
111
+ headers: { Accept: 'application/json' },
112
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
113
+ });
114
+ } catch (error) {
115
+ throw oauthExchangeFailed({
116
+ provider: options.provider,
117
+ stage: 'jwks',
118
+ detail:
119
+ error instanceof Error ? error.message : 'the request failed before a response arrived',
120
+ fix: `curl -sS -m 5 ${options.jwksUri}`,
121
+ });
122
+ }
123
+ if (!response.ok) {
124
+ throw oauthExchangeFailed({
125
+ provider: options.provider,
126
+ stage: 'jwks',
127
+ detail: 'the key set could not be read, so no signature can be checked',
128
+ status: response.status,
129
+ fix: `curl -sS -m 5 ${options.jwksUri}`,
130
+ });
131
+ }
132
+ const body: unknown = await response.json().catch(() => undefined);
133
+ const entries = isRecord(body) && Array.isArray(body['keys']) ? body['keys'] : [];
134
+ const next = new Map<string, CryptoKey>();
135
+ for (const entry of entries) {
136
+ if (!isRecord(entry)) continue;
137
+ const alg = algorithmOf(entry);
138
+ const kid = entry['kid'];
139
+ if (alg === null || typeof kid !== 'string' || kid === '') continue;
140
+ // A key the runtime cannot import is skipped rather than fatal: one malformed entry in a
141
+ // published set must not take down every login the other keys would still have verified.
142
+ const key = await crypto.subtle
143
+ .importKey('jwk', entry as JsonWebKey, importParams(alg), false, ['verify'])
144
+ .catch(() => null);
145
+ if (key !== null) next.set(`${kid}:${alg}`, key);
146
+ }
147
+ keys = next;
148
+ fetchedAtMs = clock.now().getTime();
149
+ return next;
150
+ };
151
+
152
+ // One in-flight refresh shared by every concurrent caller: a cold cache under load is otherwise
153
+ // one outbound request per request in flight, against the IdP, at exactly the worst moment.
154
+ const load = async (): Promise<Map<string, CryptoKey>> => {
155
+ inflight ??= fetchKeys().finally(() => {
156
+ inflight = null;
157
+ });
158
+ return await inflight;
159
+ };
160
+
161
+ const lookup = (current: Map<string, CryptoKey>, kid: string | null, alg: JwtAlgorithm) => {
162
+ if (kid !== null) return current.get(`${kid}:${alg}`) ?? null;
163
+ // No `kid`: unambiguous only when the set holds exactly one key for this algorithm.
164
+ const matches = [...current.entries()].filter(([id]) => id.endsWith(`:${alg}`));
165
+ return matches.length === 1 ? (matches[0]?.[1] ?? null) : null;
166
+ };
167
+
168
+ return {
169
+ async keyFor(kid, alg) {
170
+ const nowMs = clock.now().getTime();
171
+ let current = keys;
172
+ const known = lookup(current, kid, alg) !== null;
173
+ const stale = nowMs >= fetchedAtMs + ttlMs;
174
+ // A `kid` is read out of the attacker-supplied JWT header BEFORE any signature check
175
+ // (`verifyJwtSignature`), and `hooks.authenticate` funnels a bearer token through it — so an
176
+ // unauthenticated caller picks this branch. Unrated, one forged token was one outbound
177
+ // request to the IdP, which then blocks this app's egress and takes every real login with
178
+ // it, on the IdP's unblock timeline rather than a restart; and the framework's own limiter
179
+ // cannot shed it, because `auth` is pipeline stage 6 and `rate-limit` is stage 7. So the
180
+ // early refresh is rate-limited by the same TTL as the ordinary one — which is what the
181
+ // docstring above always promised. Set BEFORE the await so concurrent callers that pass the
182
+ // gate together still coalesce into the one `inflight` fetch.
183
+ const earlyRefresh = !known && !stale && nowMs >= lastMissRefreshMs + ttlMs;
184
+ if (earlyRefresh) lastMissRefreshMs = nowMs;
185
+ if (stale || earlyRefresh) current = await load();
186
+ const key = lookup(current, kid, alg);
187
+ if (key === null) {
188
+ throw oauthTokenInvalid(
189
+ options.provider,
190
+ `no ${alg} key in the published set matches this token's kid`,
191
+ `curl -sS -m 5 ${options.jwksUri} # then confirm the token was signed by this issuer`,
192
+ );
193
+ }
194
+ return key;
195
+ },
196
+ };
197
+ }
198
+
199
+ const clients = new Map<string, JwksKeySource>();
200
+
201
+ /**
202
+ * The provider's own key set, built once per provider id. Memoised because the cache is the point:
203
+ * a client rebuilt per request refetches the key set per request.
204
+ */
205
+ export function providerJwks(
206
+ provider: OAuthProvider,
207
+ options?: Omit<JwksClientOptions, 'provider' | 'jwksUri'>,
208
+ ): JwksKeySource {
209
+ if (provider.jwksUri === null) {
210
+ throw oauthTokenInvalid(
211
+ provider.id,
212
+ 'this provider publishes no jwks_uri, so an id token from it can only be trusted on the token endpoint',
213
+ `register ${provider.id} with an explicit jwksUri, or read its id token only through exchangeOAuthCode()`,
214
+ );
215
+ }
216
+ const existing = clients.get(provider.id);
217
+ if (existing !== undefined) return existing;
218
+ const client = createJwksClient({ ...options, provider: provider.id, jwksUri: provider.jwksUri });
219
+ clients.set(provider.id, client);
220
+ return client;
221
+ }
222
+
223
+ /**
224
+ * `false` for every way a token can fail to be this key set's: a malformed header, an algorithm
225
+ * that is not one of the two asymmetric ones, an unreadable signature, a signature that does not
226
+ * verify. A missing key throws — that is a configuration answer, not a verdict on the token.
227
+ */
228
+ export async function verifyJwtSignature(token: string, keys: JwksKeySource): Promise<boolean> {
229
+ const segments = token.split('.');
230
+ const [headerSegment, payloadSegment, signatureSegment] = segments;
231
+ if (
232
+ segments.length !== 3 ||
233
+ headerSegment === undefined ||
234
+ payloadSegment === undefined ||
235
+ signatureSegment === undefined
236
+ ) {
237
+ return false;
238
+ }
239
+ const header = decodeJwtHeader(headerSegment);
240
+ if (header === null) return false;
241
+ const signature = base64UrlBytes(signatureSegment);
242
+ if (signature === null || signature.length === 0) return false;
243
+ const key = await keys.keyFor(header.kid, header.alg);
244
+ const signed = new TextEncoder().encode(`${headerSegment}.${payloadSegment}`);
245
+ return await crypto.subtle.verify(verifyParams(header.alg), key, signature, signed);
246
+ }
@@ -0,0 +1,86 @@
1
+ // Single responsibility: bounding how many argon2 hashes this process runs at once.
2
+ //
3
+ // WHY it is not covered by the limiters that already exist: `rate-limit.ts`'s `ipKey(ip)` and
4
+ // `@ultimat3/http`'s `auth` bucket are both keyed by SOURCE, so an attacker rotating an IPv6 /64
5
+ // mints a fresh key per attempt and is never throttled by either; and both cap ATTEMPTS, while
6
+ // what argon2id costs is MEMORY — 19 MiB per hash at the OWASP floor. The only remaining backstop
7
+ // was `http.maxInflight` (1000), i.e. roughly 19 GB of arenas queued on one box.
8
+
9
+ import { kdfOverloaded } from './errors';
10
+
11
+ export interface KdfLimits {
12
+ /** Hashes running at once. Multiply by `memoryCost` for the resident ceiling this buys. */
13
+ readonly maxConcurrent: number;
14
+ /** Callers allowed to WAIT for a slot. Past this the answer is a refusal, not a longer queue. */
15
+ readonly maxQueued: number;
16
+ }
17
+
18
+ /**
19
+ * 8 x 19 MiB is ~152 MiB resident, which fits the smallest box anyone runs a web role on, and 64
20
+ * waiters is about two seconds of backlog at that width — long enough to absorb a burst of real
21
+ * logins, short enough that a spray is refused while the process is still answering.
22
+ */
23
+ export const DEFAULT_KDF_LIMITS: KdfLimits = Object.freeze({ maxConcurrent: 8, maxQueued: 64 });
24
+
25
+ export interface KdfGate {
26
+ run<T>(work: () => Promise<T>): Promise<T>;
27
+ }
28
+
29
+ /**
30
+ * A slot is HANDED OVER on release rather than released and re-acquired: decrementing first would
31
+ * let a caller arriving in the same tick past the ceiling while a waiter's continuation is still
32
+ * a queued microtask, which is how a "bounded" pool goes over its bound under exactly the load it
33
+ * exists for.
34
+ */
35
+ export function createKdfGate(limits: KdfLimits = DEFAULT_KDF_LIMITS): KdfGate {
36
+ let active = 0;
37
+ const waiters: Array<() => void> = [];
38
+
39
+ const acquire = async (): Promise<void> => {
40
+ if (active < limits.maxConcurrent) {
41
+ active += 1;
42
+ return;
43
+ }
44
+ if (waiters.length >= limits.maxQueued) throw kdfOverloaded(active, waiters.length);
45
+ await new Promise<void>((resolve) => {
46
+ waiters.push(resolve);
47
+ });
48
+ };
49
+
50
+ const release = (): void => {
51
+ const next = waiters.shift();
52
+ if (next === undefined) active -= 1;
53
+ else next();
54
+ };
55
+
56
+ return {
57
+ async run<T>(work: () => Promise<T>): Promise<T> {
58
+ await acquire();
59
+ try {
60
+ return await work();
61
+ } finally {
62
+ release();
63
+ }
64
+ },
65
+ };
66
+ }
67
+
68
+ let gate = createKdfGate();
69
+
70
+ /** The process-wide gate every `hashPassword`/`verifyPassword` passes through. */
71
+ export const kdfGate = (): KdfGate => gate;
72
+
73
+ /**
74
+ * The ONE install point, the same shape as `configureCursorSigning` in `@ultimat3/core`: a box
75
+ * with more memory may widen the ceiling, and a test needs a narrow one. Not a `defineAuth` key,
76
+ * because this is a property of the MACHINE, not of the app's auth policy — the same app config
77
+ * runs on a 512 MiB PaaS dyno and a 64 GiB node.
78
+ */
79
+ export function configureKdfGate(limits: KdfLimits): void {
80
+ gate = createKdfGate(limits);
81
+ }
82
+
83
+ /** Back to the shipped defaults. Tests that call `configureKdfGate` must call this in cleanup. */
84
+ export function resetKdfGate(): void {
85
+ gate = createKdfGate();
86
+ }