@uln/auth 0.4.2 → 0.6.1

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.
@@ -1,15 +1,25 @@
1
1
  import type { CookieConfig, TokenPayload, VerifyConfig } from "./types.ts";
2
+ export { redirectToLogin } from "./flow.ts";
2
3
  export interface AuthCodeExchangeConfig {
3
4
  authUrl: string;
4
5
  }
6
+ /**
7
+ * Best-effort client IP. Prefers Cloudflare's `cf-connecting-ip`, which clients cannot spoof when the request
8
+ * arrives through Cloudflare. `x-forwarded-for` is only a fallback and is attacker-controlled off Cloudflare.
9
+ */
5
10
  export declare function getClientIP(request: Request): string;
11
+ /** Constant-time check that the request carries `Authorization: Bearer <expectedToken>`. */
6
12
  export declare function checkBearerToken(request: Request, expectedToken: string): boolean;
7
- export declare function getAuthUser(request: Request, verifyConfig: VerifyConfig, cookieConfig: CookieConfig): Promise<TokenPayload | null>;
8
- export declare function redirectToLogin(currentUrl: string): Response;
13
+ /**
14
+ * Get the authenticated user from the session cookie, validated locally against `verifyConfig` (and confirmed with
15
+ * the auth service when `verifyConfig.revalidate` is set). Returns null if not authenticated, or if the token wasn't
16
+ * issued to — or the user doesn't have access to — `siteOrigin` (defaults to the request's origin).
17
+ */
18
+ export declare function getAuthUser(request: Request, verifyConfig: VerifyConfig, cookieConfig: CookieConfig, siteOrigin?: string): Promise<TokenPayload | null>;
9
19
  /**
10
20
  * Handle the auth callback on consuming sites.
11
- * If the request URL has a `?code=` param, exchanges it for a token,
12
- * sets a session cookie, and redirects to the same URL without the code param.
13
- * Returns null if there's no callback param.
21
+ * If the request URL has a `?code=` param (and the login was started by this browser), exchanges it for a token,
22
+ * sets a session cookie, and redirects to the same URL without the callback params.
23
+ * Returns 403 if the user doesn't have access to this site, or null if there's no callback param.
14
24
  */
15
25
  export declare function handleCallback(request: Request, verifyConfig: VerifyConfig, cookieConfig: CookieConfig, exchangeConfig?: AuthCodeExchangeConfig): Promise<Response | null>;
@@ -33,6 +33,8 @@ function base64urlEncode(data) {
33
33
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
34
34
  }
35
35
  function base64urlDecode(str) {
36
+ if (!/^[A-Za-z0-9_-]*$/.test(str))
37
+ throw new Error("Invalid base64url");
36
38
  const padded = str.replace(/-/g, "+").replace(/_/g, "/");
37
39
  const binary = atob(padded);
38
40
  const bytes = new Uint8Array(binary.length);
@@ -60,8 +62,8 @@ async function importPrivateKey(jwk) {
60
62
  async function importPublicKey(jwk) {
61
63
  return crypto.subtle.importKey("jwk", jwk, ECDSA_PARAMS, false, ["verify"]);
62
64
  }
63
- async function createToken(config, user) {
64
- const now = Math.floor(Date.now() / 1000);
65
+ async function createToken(config, user, options = {}) {
66
+ const now = options.issuedAt ?? Math.floor(Date.now() / 1000);
65
67
  const payload = {
66
68
  sub: user.id,
67
69
  email: user.email,
@@ -71,7 +73,10 @@ async function createToken(config, user) {
71
73
  flags: user.flags,
72
74
  kid: config.kid,
73
75
  iat: now,
74
- exp: now + config.maxAgeSeconds
76
+ exp: now + config.maxAgeSeconds,
77
+ ...user.audience === undefined ? {} : { aud: user.audience },
78
+ ...user.sessionId === undefined ? {} : { sid: user.sessionId },
79
+ ...user.deviceBound ? { dbsc: true } : {}
75
80
  };
76
81
  const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
77
82
  const payloadBytes = encoder.encode(payloadB64);
@@ -79,25 +84,46 @@ async function createToken(config, user) {
79
84
  const sigB64 = base64urlEncode(new Uint8Array(signature));
80
85
  return `${payloadB64}.${sigB64}`;
81
86
  }
87
+ var CLOCK_SKEW_SECONDS = 60;
88
+ function isStringArray(value) {
89
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
90
+ }
91
+ function isFlags(value) {
92
+ if (typeof value !== "object" || value === null || Array.isArray(value))
93
+ return false;
94
+ return Object.values(value).every(isStringArray);
95
+ }
96
+ function isTokenPayload(value) {
97
+ if (typeof value !== "object" || value === null)
98
+ return false;
99
+ if (!("sub" in value) || !("email" in value) || !("name" in value) || !("admin" in value) || !("scopes" in value) || !("flags" in value) || !("kid" in value) || !("iat" in value) || !("exp" in value)) {
100
+ return false;
101
+ }
102
+ return typeof value.sub === "string" && typeof value.email === "string" && typeof value.name === "string" && typeof value.admin === "boolean" && isStringArray(value.scopes) && isFlags(value.flags) && typeof value.kid === "string" && Number.isSafeInteger(value.iat) && Number.isSafeInteger(value.exp) && (!("aud" in value) || value.aud === undefined || typeof value.aud === "string") && (!("sid" in value) || value.sid === undefined || typeof value.sid === "string") && (!("dbsc" in value) || value.dbsc === undefined || typeof value.dbsc === "boolean");
103
+ }
82
104
  async function validateToken(config, token) {
83
- const dotIndex = token.indexOf(".");
84
- if (dotIndex === -1)
105
+ const parts = token.split(".");
106
+ if (parts.length !== 2)
107
+ return null;
108
+ const [payloadB64 = "", sigB64 = ""] = parts;
109
+ if (!payloadB64 || !sigB64)
85
110
  return null;
86
- const payloadB64 = token.substring(0, dotIndex);
87
- const sigB64 = token.substring(dotIndex + 1);
88
- let payload;
111
+ let decoded;
89
112
  try {
90
- const decoded = new TextDecoder().decode(base64urlDecode(payloadB64));
91
- payload = JSON.parse(decoded);
113
+ decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
92
114
  } catch {
93
115
  return null;
94
116
  }
95
- if (typeof payload.sub !== "string" || typeof payload.exp !== "number" || typeof payload.kid !== "string") {
117
+ if (!isTokenPayload(decoded))
96
118
  return null;
97
- }
119
+ const payload = decoded;
98
120
  const now = Math.floor(Date.now() / 1000);
99
121
  if (now > payload.exp)
100
122
  return null;
123
+ if (payload.iat > now + CLOCK_SKEW_SECONDS)
124
+ return null;
125
+ if (now - payload.iat > config.maxAgeSeconds)
126
+ return null;
101
127
  const entry = config.publicKeys.find((k) => k.kid === payload.kid);
102
128
  if (!entry)
103
129
  return null;
@@ -112,68 +138,302 @@ async function validateToken(config, token) {
112
138
  }
113
139
  return payload;
114
140
  }
141
+ function peekTokenPayload(token) {
142
+ const payloadB64 = token.split(".")[0];
143
+ if (!payloadB64)
144
+ return null;
145
+ try {
146
+ const decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
147
+ return isTokenPayload(decoded) ? decoded : null;
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+ function peekTokenKid(token) {
153
+ const payloadB64 = token.split(".")[0];
154
+ if (!payloadB64)
155
+ return null;
156
+ try {
157
+ const decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
158
+ if (typeof decoded !== "object" || decoded === null || !("kid" in decoded))
159
+ return null;
160
+ return typeof decoded.kid === "string" ? decoded.kid : null;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ // src/remote.ts
167
+ function publicFetcher(authUrl) {
168
+ return { fetch: (path, init) => fetch(new URL(path, authUrl), init) };
169
+ }
170
+ function bindingFetcher(binding) {
171
+ return { fetch: (path, init) => binding.fetch(new URL(path, "https://auth").toString(), init) };
172
+ }
173
+ function isLoopbackHostname(hostname) {
174
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
175
+ }
176
+ function toOrigin(value) {
177
+ try {
178
+ return new URL(value).origin;
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+ function hasSiteAccess(payload, origin) {
184
+ return hasSiteAccessFor(payload, { audiences: [origin], scopeOrigin: origin });
185
+ }
186
+ function hasSiteAccessFor(payload, site) {
187
+ const scopeOrigin = toOrigin(site.scopeOrigin);
188
+ if (!scopeOrigin || payload.aud === undefined)
189
+ return false;
190
+ if (!site.audiences.some((audience) => toOrigin(audience) === payload.aud))
191
+ return false;
192
+ if (payload.admin)
193
+ return true;
194
+ if (isLoopbackHostname(new URL(scopeOrigin).hostname))
195
+ return true;
196
+ return payload.scopes.includes(scopeOrigin);
197
+ }
198
+ async function readJsonObject(res) {
199
+ try {
200
+ const body = await res.json();
201
+ return typeof body === "object" && body !== null ? body : null;
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+ async function exchangeAuthCode(auth, code, redirectOrigin, codeVerifier) {
207
+ const res = await auth.fetch("/api/exchange-code", {
208
+ method: "POST",
209
+ headers: { "Content-Type": "application/json" },
210
+ body: JSON.stringify(codeVerifier === undefined ? { code, redirectOrigin } : { code, redirectOrigin, codeVerifier })
211
+ });
212
+ if (!res.ok)
213
+ return null;
214
+ const body = await readJsonObject(res);
215
+ if (!body || !("token" in body) || typeof body.token !== "string")
216
+ return null;
217
+ return body.token;
218
+ }
219
+ async function checkTokenRemote(auth, token, audience) {
220
+ let res;
221
+ try {
222
+ res = await auth.fetch("/api/verify-token", {
223
+ method: "POST",
224
+ headers: { "Content-Type": "application/json" },
225
+ body: JSON.stringify(audience === undefined ? { token } : { token, audience })
226
+ });
227
+ } catch {
228
+ return { status: "unavailable" };
229
+ }
230
+ if (res.status >= 500)
231
+ return { status: "unavailable" };
232
+ if (!res.ok)
233
+ return { status: "invalid" };
234
+ const body = await readJsonObject(res);
235
+ if (!body || !("user" in body) || typeof body.user !== "object" || body.user === null)
236
+ return { status: "invalid" };
237
+ const user = body.user;
238
+ if (!("id" in user))
239
+ return { status: "invalid" };
240
+ const candidate = { ...user, sub: user.id, iat: "iat" in user ? user.iat : 0, exp: "exp" in user ? user.exp : 0 };
241
+ if (!isTokenPayload(candidate))
242
+ return { status: "invalid" };
243
+ return {
244
+ status: "valid",
245
+ payload: {
246
+ sub: candidate.sub,
247
+ email: candidate.email,
248
+ name: candidate.name,
249
+ admin: candidate.admin,
250
+ scopes: candidate.scopes,
251
+ flags: candidate.flags,
252
+ kid: candidate.kid,
253
+ iat: candidate.iat,
254
+ exp: candidate.exp,
255
+ ...candidate.aud === undefined ? {} : { aud: candidate.aud }
256
+ }
257
+ };
258
+ }
259
+ async function verifyTokenRemote(auth, token, audience) {
260
+ const result = await checkTokenRemote(auth, token, audience);
261
+ return result.status === "valid" ? result.payload : null;
262
+ }
263
+ async function fetchJwks(auth) {
264
+ const res = await auth.fetch("/.well-known/jwk.json");
265
+ if (!res.ok)
266
+ throw new Error(`Failed to fetch JWKS: ${res.status}`);
267
+ const body = await readJsonObject(res);
268
+ if (!body || !("keys" in body) || !Array.isArray(body.keys))
269
+ throw new Error("Malformed JWKS response");
270
+ const entries = [];
271
+ for (const jwk of body.keys) {
272
+ if (typeof jwk !== "object" || jwk === null || !("kid" in jwk) || typeof jwk.kid !== "string")
273
+ continue;
274
+ if (!("kty" in jwk) || typeof jwk.kty !== "string")
275
+ continue;
276
+ const { kid, ...keyData } = jwk;
277
+ entries.push({ kid, key: await importPublicKey({ ...keyData, kty: jwk.kty }) });
278
+ }
279
+ return entries;
280
+ }
281
+
282
+ // src/version.ts
283
+ var LIBRARY_VERSION = "0.6.1";
284
+
285
+ // src/flow.ts
286
+ var DEFAULT_AUTH_URL = "https://auth.uln.industries";
287
+ var STATE_COOKIE = "__Host-uln_auth_state";
288
+ var INSECURE_STATE_COOKIE = "uln_auth_state";
289
+ var STATE_PARAM = "uln_state";
290
+ var CHALLENGE_PARAM = "uln_challenge";
291
+ var VERSION_PARAM = "uln_v";
292
+ var STATE_MAX_AGE_SECONDS = 600;
293
+ function base64url(bytes) {
294
+ let binary = "";
295
+ for (const byte of bytes)
296
+ binary += String.fromCharCode(byte);
297
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
298
+ }
299
+ function randomToken() {
300
+ return base64url(crypto.getRandomValues(new Uint8Array(32)));
301
+ }
302
+ async function pkceChallenge(verifier) {
303
+ return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
304
+ }
305
+ function stateCookie(value, secure, maxAgeSeconds) {
306
+ const name = secure ? STATE_COOKIE : INSECURE_STATE_COOKIE;
307
+ const parts = [`${name}=${value}`, `Max-Age=${maxAgeSeconds}`, "Path=/", "HttpOnly", "SameSite=Lax"];
308
+ if (secure)
309
+ parts.push("Secure");
310
+ return parts.join("; ");
311
+ }
312
+ function readStateCookie(request, secure) {
313
+ const name = secure ? STATE_COOKIE : INSECURE_STATE_COOKIE;
314
+ const [state = "", verifier = "", ...rest] = (extractCookie(request.headers.get("Cookie") ?? "", name) ?? "").split(".");
315
+ return state && verifier && rest.length === 0 ? { state, verifier } : null;
316
+ }
317
+ function stripCallbackParams(url) {
318
+ url.searchParams.delete("code");
319
+ url.searchParams.delete(STATE_PARAM);
320
+ url.searchParams.delete(CHALLENGE_PARAM);
321
+ url.searchParams.delete(VERSION_PARAM);
322
+ }
323
+ async function redirectToLogin(currentUrl, authUrl = DEFAULT_AUTH_URL) {
324
+ const returnUrl = new URL(currentUrl);
325
+ stripCallbackParams(returnUrl);
326
+ const state = randomToken();
327
+ const verifier = randomToken();
328
+ returnUrl.searchParams.set(STATE_PARAM, state);
329
+ returnUrl.searchParams.set(CHALLENGE_PARAM, await pkceChallenge(verifier));
330
+ returnUrl.searchParams.set(VERSION_PARAM, LIBRARY_VERSION);
331
+ const loginUrl = new URL("/login", authUrl);
332
+ loginUrl.searchParams.set("redirect", returnUrl.toString());
333
+ loginUrl.searchParams.set("response_type", "code");
334
+ const headers = new Headers({ Location: loginUrl.toString() });
335
+ headers.append("Set-Cookie", stateCookie(`${state}.${verifier}`, returnUrl.protocol === "https:", STATE_MAX_AGE_SECONDS));
336
+ return new Response(null, { status: 302, headers });
337
+ }
338
+ async function handleLoginCallback(request, options) {
339
+ const url = new URL(request.url);
340
+ const code = url.searchParams.get("code");
341
+ if (!code)
342
+ return null;
343
+ const state = url.searchParams.get(STATE_PARAM);
344
+ const secure = url.protocol === "https:";
345
+ const expected = readStateCookie(request, secure);
346
+ stripCallbackParams(url);
347
+ const startOver = () => redirectToLogin(url.toString(), options.authUrl);
348
+ if (!state || !expected || state !== expected.state)
349
+ return startOver();
350
+ const token = await exchangeAuthCode(options.auth, code, url.origin, expected.verifier);
351
+ if (!token)
352
+ return startOver();
353
+ const payload = await options.validate(token);
354
+ if (!payload)
355
+ return startOver();
356
+ const scopeOrigin = options.scopeOrigin ?? url.origin;
357
+ if (!hasSiteAccessFor(payload, { audiences: [url.origin, scopeOrigin], scopeOrigin })) {
358
+ return new Response("You don't have access to this site", { status: 403 });
359
+ }
360
+ const headers = new Headers({ Location: url.toString() });
361
+ headers.append("Set-Cookie", makeSessionCookie(options.cookieConfig, token));
362
+ headers.append("Set-Cookie", stateCookie("", secure, 0));
363
+ return new Response(null, { status: 302, headers });
364
+ }
365
+ var confirmedAt = new Map;
366
+ var MAX_CONFIRMED = 5000;
367
+ var DEFAULT_REVALIDATE_SECONDS = 300;
368
+ async function revalidateToken(auth, token, local, audience, intervalSeconds = DEFAULT_REVALIDATE_SECONDS) {
369
+ const last = confirmedAt.get(token);
370
+ if (last !== undefined && Date.now() - last < intervalSeconds * 1000)
371
+ return local;
372
+ const result = await checkTokenRemote(auth, token, audience);
373
+ if (result.status === "unavailable")
374
+ return local;
375
+ confirmedAt.delete(token);
376
+ if (result.status === "invalid")
377
+ return null;
378
+ if (confirmedAt.size >= MAX_CONFIRMED) {
379
+ const oldest = confirmedAt.keys().next();
380
+ if (!oldest.done)
381
+ confirmedAt.delete(oldest.value);
382
+ }
383
+ confirmedAt.set(token, Date.now());
384
+ return result.payload;
385
+ }
386
+ function clearRevalidationCache() {
387
+ confirmedAt.clear();
388
+ }
115
389
 
116
390
  // src/middleware.ts
117
391
  function getClientIP(request) {
118
392
  return request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
119
393
  }
120
394
  function checkBearerToken(request, expectedToken) {
121
- const header = request.headers.get("Authorization");
122
- return header === `Bearer ${expectedToken}`;
395
+ const header = request.headers.get("Authorization") ?? "";
396
+ const expected = `Bearer ${expectedToken}`;
397
+ const a = new TextEncoder().encode(header);
398
+ const b = new TextEncoder().encode(expected);
399
+ let diff = a.length ^ b.length;
400
+ for (let i = 0;i < b.length; i++) {
401
+ diff |= (a[i] ?? 0) ^ (b[i] ?? 0);
402
+ }
403
+ return diff === 0;
123
404
  }
124
- async function getAuthUser(request, verifyConfig, cookieConfig) {
405
+ async function getAuthUser(request, verifyConfig, cookieConfig, siteOrigin) {
125
406
  const cookieHeader = request.headers.get("Cookie");
126
407
  if (!cookieHeader)
127
408
  return null;
128
409
  const token = extractCookie(cookieHeader, cookieConfig.name);
129
410
  if (!token)
130
411
  return null;
131
- return validateToken(verifyConfig, token);
132
- }
133
- function redirectToLogin(currentUrl) {
134
- const loginUrl = new URL("/login", "https://auth.uln.industries");
135
- loginUrl.searchParams.set("redirect", currentUrl);
136
- loginUrl.searchParams.set("response_type", "code");
137
- return Response.redirect(loginUrl.toString(), 302);
138
- }
139
- async function exchangeCode(code, redirectOrigin, config) {
140
- const res = await fetch(`${config.authUrl}/api/exchange-code`, {
141
- method: "POST",
142
- headers: { "Content-Type": "application/json" },
143
- body: JSON.stringify({ code, redirectOrigin })
144
- });
145
- if (!res.ok)
412
+ const audience = new URL(request.url).origin;
413
+ const scopeOrigin = siteOrigin ?? audience;
414
+ const site = { audiences: [audience, scopeOrigin], scopeOrigin };
415
+ const payload = await validateToken(verifyConfig, token);
416
+ if (!payload || !hasSiteAccessFor(payload, site))
146
417
  return null;
147
- const data = await res.json();
148
- return typeof data.token === "string" ? data.token : null;
418
+ const { revalidate } = verifyConfig;
419
+ if (!revalidate)
420
+ return payload;
421
+ const current = await revalidateToken(publicFetcher(revalidate.authUrl), token, payload, payload.aud ?? audience, revalidate.intervalSeconds);
422
+ return current && hasSiteAccessFor(current, site) ? current : null;
149
423
  }
150
424
  async function handleCallback(request, verifyConfig, cookieConfig, exchangeConfig) {
151
- const url = new URL(request.url);
152
- const code = url.searchParams.get("code");
153
- if (code) {
154
- if (!exchangeConfig)
155
- return redirectToLogin(url.origin);
156
- const token = await exchangeCode(code, url.origin, exchangeConfig);
157
- if (!token)
158
- return redirectToLogin(url.origin);
159
- const payload = await validateToken(verifyConfig, token);
160
- if (!payload)
161
- return redirectToLogin(url.origin);
162
- url.searchParams.delete("code");
163
- return new Response(null, {
164
- status: 302,
165
- headers: {
166
- Location: url.toString(),
167
- "Set-Cookie": makeSessionCookie(cookieConfig, token)
168
- }
169
- });
170
- }
171
- return null;
425
+ const authUrl = exchangeConfig?.authUrl ?? DEFAULT_AUTH_URL;
426
+ return handleLoginCallback(request, {
427
+ auth: publicFetcher(authUrl),
428
+ authUrl,
429
+ cookieConfig,
430
+ validate: (token) => validateToken(verifyConfig, token)
431
+ });
172
432
  }
173
433
  export {
174
- redirectToLogin,
175
- handleCallback,
176
- getClientIP,
434
+ checkBearerToken,
177
435
  getAuthUser,
178
- checkBearerToken
436
+ getClientIP,
437
+ handleCallback,
438
+ redirectToLogin
179
439
  };
@@ -1,4 +1,11 @@
1
- /** Hash a password with PBKDF2-SHA256 (100k iterations, 32-byte salt, 64-byte key). Returns `hex(salt):hex(key)`. */
1
+ /** Upper bound enforced wherever a password is *set*. Verification accepts any length so no stored password is locked out. */
2
+ export declare const MAX_PASSWORD_LENGTH = 1024;
3
+ /**
4
+ * Hash a password with PBKDF2-SHA256 (600k iterations, 32-byte salt, 64-byte key). Returns
5
+ * `pbkdf2-sha256$600000$hex(salt)$hex(key)`.
6
+ */
2
7
  export declare function hashPassword(password: string): Promise<string>;
3
- /** Verify a password against a stored `hex(salt):hex(key)` hash. Uses constant-time comparison. */
8
+ /** Verify a password against any hash `hashPassword` produces now or produced before. Uses constant-time comparison. */
4
9
  export declare function verifyPassword(password: string, storedHash: string): Promise<boolean>;
10
+ /** Whether a stored hash is weaker than what `hashPassword` produces now, so it should be replaced after a login. */
11
+ export declare function needsRehash(storedHash: string): boolean;
package/dist/password.js CHANGED
@@ -2,7 +2,12 @@
2
2
  // src/password.ts
3
3
  var SALT_LENGTH = 32;
4
4
  var KEY_LENGTH = 64;
5
- var ITERATIONS = 1e5;
5
+ var STAGE_ITERATIONS = 1e5;
6
+ var ITERATIONS = 600000;
7
+ var MAX_ITERATIONS = 1e7;
8
+ var MAX_PASSWORD_LENGTH = 1024;
9
+ var HEX_REGEX = /^(?:[0-9a-f]{2})+$/;
10
+ var PREFIX = "pbkdf2-sha256";
6
11
  function toHex(buffer) {
7
12
  return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
8
13
  }
@@ -13,39 +18,64 @@ function fromHex(hex) {
13
18
  }
14
19
  return bytes;
15
20
  }
16
- async function deriveKey(password, salt) {
17
- const encoder = new TextEncoder;
18
- const keyMaterial = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"]);
19
- return crypto.subtle.deriveBits({
20
- name: "PBKDF2",
21
- salt,
22
- iterations: ITERATIONS,
23
- hash: "SHA-256"
24
- }, keyMaterial, KEY_LENGTH * 8);
21
+ async function deriveKey(password, salt, iterations) {
22
+ let material = new TextEncoder().encode(password);
23
+ let derived = new ArrayBuffer(0);
24
+ for (let done = 0;done < iterations; done += STAGE_ITERATIONS) {
25
+ const keyMaterial = await crypto.subtle.importKey("raw", material, "PBKDF2", false, ["deriveBits"]);
26
+ derived = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations: STAGE_ITERATIONS, hash: "SHA-256" }, keyMaterial, KEY_LENGTH * 8);
27
+ material = new Uint8Array(derived);
28
+ }
29
+ return derived;
30
+ }
31
+ function parseHash(storedHash) {
32
+ let iterations = STAGE_ITERATIONS;
33
+ let saltHex;
34
+ let keyHex;
35
+ if (storedHash.startsWith(`${PREFIX}$`)) {
36
+ const [, rawIterations = "", salt = "", key = ""] = storedHash.split("$");
37
+ if (!/^[1-9]\d*$/.test(rawIterations))
38
+ return null;
39
+ iterations = Number(rawIterations);
40
+ if (iterations % STAGE_ITERATIONS !== 0 || iterations > MAX_ITERATIONS)
41
+ return null;
42
+ saltHex = salt;
43
+ keyHex = key;
44
+ } else {
45
+ const separatorIndex = storedHash.indexOf(":");
46
+ if (separatorIndex === -1)
47
+ return null;
48
+ saltHex = storedHash.substring(0, separatorIndex);
49
+ keyHex = storedHash.substring(separatorIndex + 1);
50
+ }
51
+ if (!HEX_REGEX.test(saltHex) || !HEX_REGEX.test(keyHex))
52
+ return null;
53
+ return { iterations, salt: fromHex(saltHex), key: fromHex(keyHex) };
25
54
  }
26
55
  async function hashPassword(password) {
27
56
  const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
28
- const derived = await deriveKey(password, salt);
29
- return `${toHex(salt.buffer)}:${toHex(derived)}`;
57
+ const derived = await deriveKey(password, salt, ITERATIONS);
58
+ return `${PREFIX}$${ITERATIONS}$${toHex(salt.buffer)}$${toHex(derived)}`;
30
59
  }
31
60
  async function verifyPassword(password, storedHash) {
32
- const separatorIndex = storedHash.indexOf(":");
33
- if (separatorIndex === -1)
61
+ const parsed = parseHash(storedHash);
62
+ if (!parsed)
34
63
  return false;
35
- const saltHex = storedHash.substring(0, separatorIndex);
36
- const keyHex = storedHash.substring(separatorIndex + 1);
37
- const salt = fromHex(saltHex);
38
- const expectedKey = fromHex(keyHex);
39
- const derived = new Uint8Array(await deriveKey(password, salt));
40
- if (derived.length !== expectedKey.length)
64
+ const derived = new Uint8Array(await deriveKey(password, parsed.salt, parsed.iterations));
65
+ if (derived.length !== parsed.key.length)
41
66
  return false;
42
67
  let diff = 0;
43
68
  for (let i = 0;i < derived.length; i++) {
44
- diff |= (derived[i] ?? 0) ^ (expectedKey[i] ?? 0);
69
+ diff |= (derived[i] ?? 0) ^ (parsed.key[i] ?? 0);
45
70
  }
46
71
  return diff === 0;
47
72
  }
73
+ function needsRehash(storedHash) {
74
+ return !storedHash.startsWith(`${PREFIX}$`) || (parseHash(storedHash)?.iterations ?? 0) < ITERATIONS;
75
+ }
48
76
  export {
49
- verifyPassword,
50
- hashPassword
77
+ MAX_PASSWORD_LENGTH,
78
+ hashPassword,
79
+ needsRehash,
80
+ verifyPassword
51
81
  };
@@ -3,6 +3,17 @@ export interface RateLimitResult {
3
3
  blocked: boolean;
4
4
  remainingSeconds?: number;
5
5
  }
6
+ /**
7
+ * Count an attempt against `key` and report whether the caller is now over the limit. The attempt is recorded
8
+ * *before* the decision, so concurrent requests can't all pass a check that ran before any of them was counted.
9
+ * Clear the key on success. Race-free only when the store implements `increment`.
10
+ */
11
+ export declare function consumeRateLimit(store: RateLimitStore, key: string, config: RateLimitConfig): Promise<RateLimitResult>;
12
+ /**
13
+ * @deprecated Check-then-record is racy: concurrent requests all pass the check before any is recorded. Use
14
+ * `consumeRateLimit`.
15
+ */
6
16
  export declare function checkRateLimit(store: RateLimitStore, key: string, config: RateLimitConfig): Promise<RateLimitResult>;
17
+ /** @deprecated Pair of `checkRateLimit`; use `consumeRateLimit`. */
7
18
  export declare function recordFailedAttempt(store: RateLimitStore, key: string, config: RateLimitConfig): Promise<void>;
8
19
  export declare function clearRateLimit(store: RateLimitStore, key: string): Promise<void>;
@@ -1,5 +1,19 @@
1
1
  // @bun
2
2
  // src/rate-limit.ts
3
+ async function consumeRateLimit(store, key, config) {
4
+ const entry = store.increment ? await store.increment(key, config.windowSeconds) : await incrementViaGetSet(store, key, config);
5
+ if (entry.attempts <= config.maxAttempts)
6
+ return { blocked: false };
7
+ const remaining = config.windowSeconds - Math.floor((Date.now() - entry.firstAttempt) / 1000);
8
+ return { blocked: true, remainingSeconds: Math.max(remaining, 1) };
9
+ }
10
+ async function incrementViaGetSet(store, key, config) {
11
+ const existing = await store.get(key);
12
+ const windowOver = existing !== null && Date.now() - existing.firstAttempt >= config.windowSeconds * 1000;
13
+ const entry = existing && !windowOver ? { attempts: existing.attempts + 1, firstAttempt: existing.firstAttempt } : { attempts: 1, firstAttempt: Date.now() };
14
+ await store.set(key, entry, config.windowSeconds);
15
+ return entry;
16
+ }
3
17
  async function checkRateLimit(store, key, config) {
4
18
  const entry = await store.get(key);
5
19
  if (!entry)
@@ -15,6 +29,10 @@ async function checkRateLimit(store, key, config) {
15
29
  return { blocked: false };
16
30
  }
17
31
  async function recordFailedAttempt(store, key, config) {
32
+ if (store.increment) {
33
+ await store.increment(key, config.windowSeconds);
34
+ return;
35
+ }
18
36
  const entry = await store.get(key);
19
37
  if (entry) {
20
38
  await store.set(key, { attempts: entry.attempts + 1, firstAttempt: entry.firstAttempt }, config.windowSeconds);
@@ -26,7 +44,8 @@ async function clearRateLimit(store, key) {
26
44
  await store.delete(key);
27
45
  }
28
46
  export {
29
- recordFailedAttempt,
47
+ checkRateLimit,
30
48
  clearRateLimit,
31
- checkRateLimit
49
+ consumeRateLimit,
50
+ recordFailedAttempt
32
51
  };