@uln/auth 0.4.1 → 0.6.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/dist/index.js CHANGED
@@ -22,7 +22,6 @@ function extractCookie(cookieHeader, name) {
22
22
  }
23
23
  return null;
24
24
  }
25
-
26
25
  // src/token.ts
27
26
  var encoder = new TextEncoder;
28
27
  function base64urlEncode(data) {
@@ -33,6 +32,8 @@ function base64urlEncode(data) {
33
32
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
34
33
  }
35
34
  function base64urlDecode(str) {
35
+ if (!/^[A-Za-z0-9_-]*$/.test(str))
36
+ throw new Error("Invalid base64url");
36
37
  const padded = str.replace(/-/g, "+").replace(/_/g, "/");
37
38
  const binary = atob(padded);
38
39
  const bytes = new Uint8Array(binary.length);
@@ -60,8 +61,8 @@ async function importPrivateKey(jwk) {
60
61
  async function importPublicKey(jwk) {
61
62
  return crypto.subtle.importKey("jwk", jwk, ECDSA_PARAMS, false, ["verify"]);
62
63
  }
63
- async function createToken(config, user) {
64
- const now = Math.floor(Date.now() / 1000);
64
+ async function createToken(config, user, options = {}) {
65
+ const now = options.issuedAt ?? Math.floor(Date.now() / 1000);
65
66
  const payload = {
66
67
  sub: user.id,
67
68
  email: user.email,
@@ -71,7 +72,8 @@ async function createToken(config, user) {
71
72
  flags: user.flags,
72
73
  kid: config.kid,
73
74
  iat: now,
74
- exp: now + config.maxAgeSeconds
75
+ exp: now + config.maxAgeSeconds,
76
+ ...user.audience === undefined ? {} : { aud: user.audience }
75
77
  };
76
78
  const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
77
79
  const payloadBytes = encoder.encode(payloadB64);
@@ -79,25 +81,46 @@ async function createToken(config, user) {
79
81
  const sigB64 = base64urlEncode(new Uint8Array(signature));
80
82
  return `${payloadB64}.${sigB64}`;
81
83
  }
84
+ var CLOCK_SKEW_SECONDS = 60;
85
+ function isStringArray(value) {
86
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
87
+ }
88
+ function isFlags(value) {
89
+ if (typeof value !== "object" || value === null || Array.isArray(value))
90
+ return false;
91
+ return Object.values(value).every(isStringArray);
92
+ }
93
+ function isTokenPayload(value) {
94
+ if (typeof value !== "object" || value === null)
95
+ return false;
96
+ 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)) {
97
+ return false;
98
+ }
99
+ 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");
100
+ }
82
101
  async function validateToken(config, token) {
83
- const dotIndex = token.indexOf(".");
84
- if (dotIndex === -1)
102
+ const parts = token.split(".");
103
+ if (parts.length !== 2)
85
104
  return null;
86
- const payloadB64 = token.substring(0, dotIndex);
87
- const sigB64 = token.substring(dotIndex + 1);
88
- let payload;
105
+ const [payloadB64 = "", sigB64 = ""] = parts;
106
+ if (!payloadB64 || !sigB64)
107
+ return null;
108
+ let decoded;
89
109
  try {
90
- const decoded = new TextDecoder().decode(base64urlDecode(payloadB64));
91
- payload = JSON.parse(decoded);
110
+ decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
92
111
  } catch {
93
112
  return null;
94
113
  }
95
- if (typeof payload.sub !== "string" || typeof payload.exp !== "number" || typeof payload.kid !== "string") {
114
+ if (!isTokenPayload(decoded))
96
115
  return null;
97
- }
116
+ const payload = decoded;
98
117
  const now = Math.floor(Date.now() / 1000);
99
118
  if (now > payload.exp)
100
119
  return null;
120
+ if (payload.iat > now + CLOCK_SKEW_SECONDS)
121
+ return null;
122
+ if (now - payload.iat > config.maxAgeSeconds)
123
+ return null;
101
124
  const entry = config.publicKeys.find((k) => k.kid === payload.kid);
102
125
  if (!entry)
103
126
  return null;
@@ -112,69 +135,301 @@ async function validateToken(config, token) {
112
135
  }
113
136
  return payload;
114
137
  }
138
+ function peekTokenPayload(token) {
139
+ const payloadB64 = token.split(".")[0];
140
+ if (!payloadB64)
141
+ return null;
142
+ try {
143
+ const decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
144
+ return isTokenPayload(decoded) ? decoded : null;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+ function peekTokenKid(token) {
150
+ const payloadB64 = token.split(".")[0];
151
+ if (!payloadB64)
152
+ return null;
153
+ try {
154
+ const decoded = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
155
+ if (typeof decoded !== "object" || decoded === null || !("kid" in decoded))
156
+ return null;
157
+ return typeof decoded.kid === "string" ? decoded.kid : null;
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
115
162
 
116
- // src/middleware.ts
117
- function getClientIP(request) {
118
- return request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
163
+ // src/remote.ts
164
+ function publicFetcher(authUrl) {
165
+ return { fetch: (path, init) => fetch(new URL(path, authUrl), init) };
119
166
  }
120
- function checkBearerToken(request, expectedToken) {
121
- const header = request.headers.get("Authorization");
122
- return header === `Bearer ${expectedToken}`;
167
+ function bindingFetcher(binding) {
168
+ return { fetch: (path, init) => binding.fetch(new URL(path, "https://auth").toString(), init) };
123
169
  }
124
- async function getAuthUser(request, verifyConfig, cookieConfig) {
125
- const cookieHeader = request.headers.get("Cookie");
126
- if (!cookieHeader)
127
- return null;
128
- const token = extractCookie(cookieHeader, cookieConfig.name);
129
- if (!token)
170
+ function isLoopbackHostname(hostname) {
171
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
172
+ }
173
+ function toOrigin(value) {
174
+ try {
175
+ return new URL(value).origin;
176
+ } catch {
130
177
  return null;
131
- return validateToken(verifyConfig, token);
178
+ }
132
179
  }
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);
180
+ function hasSiteAccess(payload, origin) {
181
+ return hasSiteAccessFor(payload, { audiences: [origin], scopeOrigin: origin });
182
+ }
183
+ function hasSiteAccessFor(payload, site) {
184
+ const scopeOrigin = toOrigin(site.scopeOrigin);
185
+ if (!scopeOrigin || payload.aud === undefined)
186
+ return false;
187
+ if (!site.audiences.some((audience) => toOrigin(audience) === payload.aud))
188
+ return false;
189
+ if (payload.admin)
190
+ return true;
191
+ if (isLoopbackHostname(new URL(scopeOrigin).hostname))
192
+ return true;
193
+ return payload.scopes.includes(scopeOrigin);
194
+ }
195
+ async function readJsonObject(res) {
196
+ try {
197
+ const body = await res.json();
198
+ return typeof body === "object" && body !== null ? body : null;
199
+ } catch {
200
+ return null;
201
+ }
138
202
  }
139
- async function exchangeCode(code, redirectOrigin, config) {
140
- const res = await fetch(`${config.authUrl}/api/exchange-code`, {
203
+ async function exchangeAuthCode(auth, code, redirectOrigin, codeVerifier) {
204
+ const res = await auth.fetch("/api/exchange-code", {
141
205
  method: "POST",
142
206
  headers: { "Content-Type": "application/json" },
143
- body: JSON.stringify({ code, redirectOrigin })
207
+ body: JSON.stringify(codeVerifier === undefined ? { code, redirectOrigin } : { code, redirectOrigin, codeVerifier })
144
208
  });
145
209
  if (!res.ok)
146
210
  return null;
147
- const data = await res.json();
148
- return typeof data.token === "string" ? data.token : null;
211
+ const body = await readJsonObject(res);
212
+ if (!body || !("token" in body) || typeof body.token !== "string")
213
+ return null;
214
+ return body.token;
149
215
  }
150
- async function handleCallback(request, verifyConfig, cookieConfig, exchangeConfig) {
216
+ async function checkTokenRemote(auth, token, audience) {
217
+ let res;
218
+ try {
219
+ res = await auth.fetch("/api/verify-token", {
220
+ method: "POST",
221
+ headers: { "Content-Type": "application/json" },
222
+ body: JSON.stringify(audience === undefined ? { token } : { token, audience })
223
+ });
224
+ } catch {
225
+ return { status: "unavailable" };
226
+ }
227
+ if (res.status >= 500)
228
+ return { status: "unavailable" };
229
+ if (!res.ok)
230
+ return { status: "invalid" };
231
+ const body = await readJsonObject(res);
232
+ if (!body || !("user" in body) || typeof body.user !== "object" || body.user === null)
233
+ return { status: "invalid" };
234
+ const user = body.user;
235
+ if (!("id" in user))
236
+ return { status: "invalid" };
237
+ const candidate = { ...user, sub: user.id, iat: "iat" in user ? user.iat : 0, exp: "exp" in user ? user.exp : 0 };
238
+ if (!isTokenPayload(candidate))
239
+ return { status: "invalid" };
240
+ return {
241
+ status: "valid",
242
+ payload: {
243
+ sub: candidate.sub,
244
+ email: candidate.email,
245
+ name: candidate.name,
246
+ admin: candidate.admin,
247
+ scopes: candidate.scopes,
248
+ flags: candidate.flags,
249
+ kid: candidate.kid,
250
+ iat: candidate.iat,
251
+ exp: candidate.exp,
252
+ ...candidate.aud === undefined ? {} : { aud: candidate.aud }
253
+ }
254
+ };
255
+ }
256
+ async function verifyTokenRemote(auth, token, audience) {
257
+ const result = await checkTokenRemote(auth, token, audience);
258
+ return result.status === "valid" ? result.payload : null;
259
+ }
260
+ async function fetchJwks(auth) {
261
+ const res = await auth.fetch("/.well-known/jwk.json");
262
+ if (!res.ok)
263
+ throw new Error(`Failed to fetch JWKS: ${res.status}`);
264
+ const body = await readJsonObject(res);
265
+ if (!body || !("keys" in body) || !Array.isArray(body.keys))
266
+ throw new Error("Malformed JWKS response");
267
+ const entries = [];
268
+ for (const jwk of body.keys) {
269
+ if (typeof jwk !== "object" || jwk === null || !("kid" in jwk) || typeof jwk.kid !== "string")
270
+ continue;
271
+ if (!("kty" in jwk) || typeof jwk.kty !== "string")
272
+ continue;
273
+ const { kid, ...keyData } = jwk;
274
+ entries.push({ kid, key: await importPublicKey({ ...keyData, kty: jwk.kty }) });
275
+ }
276
+ return entries;
277
+ }
278
+
279
+ // src/flow.ts
280
+ var DEFAULT_AUTH_URL = "https://auth.uln.industries";
281
+ var STATE_COOKIE = "__Host-uln_auth_state";
282
+ var INSECURE_STATE_COOKIE = "uln_auth_state";
283
+ var STATE_PARAM = "uln_state";
284
+ var CHALLENGE_PARAM = "uln_challenge";
285
+ var STATE_MAX_AGE_SECONDS = 600;
286
+ function base64url(bytes) {
287
+ let binary = "";
288
+ for (const byte of bytes)
289
+ binary += String.fromCharCode(byte);
290
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
291
+ }
292
+ function randomToken() {
293
+ return base64url(crypto.getRandomValues(new Uint8Array(32)));
294
+ }
295
+ async function pkceChallenge(verifier) {
296
+ return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
297
+ }
298
+ function stateCookie(value, secure, maxAgeSeconds) {
299
+ const name = secure ? STATE_COOKIE : INSECURE_STATE_COOKIE;
300
+ const parts = [`${name}=${value}`, `Max-Age=${maxAgeSeconds}`, "Path=/", "HttpOnly", "SameSite=Lax"];
301
+ if (secure)
302
+ parts.push("Secure");
303
+ return parts.join("; ");
304
+ }
305
+ function readStateCookie(request, secure) {
306
+ const name = secure ? STATE_COOKIE : INSECURE_STATE_COOKIE;
307
+ const [state = "", verifier = "", ...rest] = (extractCookie(request.headers.get("Cookie") ?? "", name) ?? "").split(".");
308
+ return state && verifier && rest.length === 0 ? { state, verifier } : null;
309
+ }
310
+ function stripCallbackParams(url) {
311
+ url.searchParams.delete("code");
312
+ url.searchParams.delete(STATE_PARAM);
313
+ url.searchParams.delete(CHALLENGE_PARAM);
314
+ }
315
+ async function redirectToLogin(currentUrl, authUrl = DEFAULT_AUTH_URL) {
316
+ const returnUrl = new URL(currentUrl);
317
+ stripCallbackParams(returnUrl);
318
+ const state = randomToken();
319
+ const verifier = randomToken();
320
+ returnUrl.searchParams.set(STATE_PARAM, state);
321
+ returnUrl.searchParams.set(CHALLENGE_PARAM, await pkceChallenge(verifier));
322
+ const loginUrl = new URL("/login", authUrl);
323
+ loginUrl.searchParams.set("redirect", returnUrl.toString());
324
+ loginUrl.searchParams.set("response_type", "code");
325
+ const headers = new Headers({ Location: loginUrl.toString() });
326
+ headers.append("Set-Cookie", stateCookie(`${state}.${verifier}`, returnUrl.protocol === "https:", STATE_MAX_AGE_SECONDS));
327
+ return new Response(null, { status: 302, headers });
328
+ }
329
+ async function handleLoginCallback(request, options) {
151
330
  const url = new URL(request.url);
152
331
  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
- });
332
+ if (!code)
333
+ return null;
334
+ const state = url.searchParams.get(STATE_PARAM);
335
+ const secure = url.protocol === "https:";
336
+ const expected = readStateCookie(request, secure);
337
+ stripCallbackParams(url);
338
+ const startOver = () => redirectToLogin(url.toString(), options.authUrl);
339
+ if (!state || !expected || state !== expected.state)
340
+ return startOver();
341
+ const token = await exchangeAuthCode(options.auth, code, url.origin, expected.verifier);
342
+ if (!token)
343
+ return startOver();
344
+ const payload = await options.validate(token);
345
+ if (!payload)
346
+ return startOver();
347
+ const scopeOrigin = options.scopeOrigin ?? url.origin;
348
+ if (!hasSiteAccessFor(payload, { audiences: [url.origin, scopeOrigin], scopeOrigin })) {
349
+ return new Response("You don't have access to this site", { status: 403 });
170
350
  }
171
- return null;
351
+ const headers = new Headers({ Location: url.toString() });
352
+ headers.append("Set-Cookie", makeSessionCookie(options.cookieConfig, token));
353
+ headers.append("Set-Cookie", stateCookie("", secure, 0));
354
+ return new Response(null, { status: 302, headers });
355
+ }
356
+ var confirmedAt = new Map;
357
+ var MAX_CONFIRMED = 5000;
358
+ var DEFAULT_REVALIDATE_SECONDS = 300;
359
+ async function revalidateToken(auth, token, local, audience, intervalSeconds = DEFAULT_REVALIDATE_SECONDS) {
360
+ const last = confirmedAt.get(token);
361
+ if (last !== undefined && Date.now() - last < intervalSeconds * 1000)
362
+ return local;
363
+ const result = await checkTokenRemote(auth, token, audience);
364
+ if (result.status === "unavailable")
365
+ return local;
366
+ confirmedAt.delete(token);
367
+ if (result.status === "invalid")
368
+ return null;
369
+ if (confirmedAt.size >= MAX_CONFIRMED) {
370
+ const oldest = confirmedAt.keys().next();
371
+ if (!oldest.done)
372
+ confirmedAt.delete(oldest.value);
373
+ }
374
+ confirmedAt.set(token, Date.now());
375
+ return result.payload;
376
+ }
377
+ function clearRevalidationCache() {
378
+ confirmedAt.clear();
172
379
  }
173
380
 
381
+ // src/middleware.ts
382
+ function getClientIP(request) {
383
+ return request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
384
+ }
385
+ function checkBearerToken(request, expectedToken) {
386
+ const header = request.headers.get("Authorization") ?? "";
387
+ const expected = `Bearer ${expectedToken}`;
388
+ const a = new TextEncoder().encode(header);
389
+ const b = new TextEncoder().encode(expected);
390
+ let diff = a.length ^ b.length;
391
+ for (let i = 0;i < b.length; i++) {
392
+ diff |= (a[i] ?? 0) ^ (b[i] ?? 0);
393
+ }
394
+ return diff === 0;
395
+ }
396
+ async function getAuthUser(request, verifyConfig, cookieConfig, siteOrigin) {
397
+ const cookieHeader = request.headers.get("Cookie");
398
+ if (!cookieHeader)
399
+ return null;
400
+ const token = extractCookie(cookieHeader, cookieConfig.name);
401
+ if (!token)
402
+ return null;
403
+ const audience = new URL(request.url).origin;
404
+ const scopeOrigin = siteOrigin ?? audience;
405
+ const site = { audiences: [audience, scopeOrigin], scopeOrigin };
406
+ const payload = await validateToken(verifyConfig, token);
407
+ if (!payload || !hasSiteAccessFor(payload, site))
408
+ return null;
409
+ const { revalidate } = verifyConfig;
410
+ if (!revalidate)
411
+ return payload;
412
+ const current = await revalidateToken(publicFetcher(revalidate.authUrl), token, payload, payload.aud ?? audience, revalidate.intervalSeconds);
413
+ return current && hasSiteAccessFor(current, site) ? current : null;
414
+ }
415
+ async function handleCallback(request, verifyConfig, cookieConfig, exchangeConfig) {
416
+ const authUrl = exchangeConfig?.authUrl ?? DEFAULT_AUTH_URL;
417
+ return handleLoginCallback(request, {
418
+ auth: publicFetcher(authUrl),
419
+ authUrl,
420
+ cookieConfig,
421
+ validate: (token) => validateToken(verifyConfig, token)
422
+ });
423
+ }
174
424
  // src/password.ts
175
425
  var SALT_LENGTH = 32;
176
426
  var KEY_LENGTH = 64;
177
- var ITERATIONS = 1e5;
427
+ var STAGE_ITERATIONS = 1e5;
428
+ var ITERATIONS = 600000;
429
+ var MAX_ITERATIONS = 1e7;
430
+ var MAX_PASSWORD_LENGTH = 1024;
431
+ var HEX_REGEX = /^(?:[0-9a-f]{2})+$/;
432
+ var PREFIX = "pbkdf2-sha256";
178
433
  function toHex(buffer) {
179
434
  return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
180
435
  }
@@ -185,98 +440,76 @@ function fromHex(hex) {
185
440
  }
186
441
  return bytes;
187
442
  }
188
- async function deriveKey(password, salt) {
189
- const encoder2 = new TextEncoder;
190
- const keyMaterial = await crypto.subtle.importKey("raw", encoder2.encode(password), "PBKDF2", false, ["deriveBits"]);
191
- return crypto.subtle.deriveBits({
192
- name: "PBKDF2",
193
- salt,
194
- iterations: ITERATIONS,
195
- hash: "SHA-256"
196
- }, keyMaterial, KEY_LENGTH * 8);
443
+ async function deriveKey(password, salt, iterations) {
444
+ let material = new TextEncoder().encode(password);
445
+ let derived = new ArrayBuffer(0);
446
+ for (let done = 0;done < iterations; done += STAGE_ITERATIONS) {
447
+ const keyMaterial = await crypto.subtle.importKey("raw", material, "PBKDF2", false, ["deriveBits"]);
448
+ derived = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations: STAGE_ITERATIONS, hash: "SHA-256" }, keyMaterial, KEY_LENGTH * 8);
449
+ material = new Uint8Array(derived);
450
+ }
451
+ return derived;
452
+ }
453
+ function parseHash(storedHash) {
454
+ let iterations = STAGE_ITERATIONS;
455
+ let saltHex;
456
+ let keyHex;
457
+ if (storedHash.startsWith(`${PREFIX}$`)) {
458
+ const [, rawIterations = "", salt = "", key = ""] = storedHash.split("$");
459
+ if (!/^[1-9]\d*$/.test(rawIterations))
460
+ return null;
461
+ iterations = Number(rawIterations);
462
+ if (iterations % STAGE_ITERATIONS !== 0 || iterations > MAX_ITERATIONS)
463
+ return null;
464
+ saltHex = salt;
465
+ keyHex = key;
466
+ } else {
467
+ const separatorIndex = storedHash.indexOf(":");
468
+ if (separatorIndex === -1)
469
+ return null;
470
+ saltHex = storedHash.substring(0, separatorIndex);
471
+ keyHex = storedHash.substring(separatorIndex + 1);
472
+ }
473
+ if (!HEX_REGEX.test(saltHex) || !HEX_REGEX.test(keyHex))
474
+ return null;
475
+ return { iterations, salt: fromHex(saltHex), key: fromHex(keyHex) };
197
476
  }
198
477
  async function hashPassword(password) {
199
478
  const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
200
- const derived = await deriveKey(password, salt);
201
- return `${toHex(salt.buffer)}:${toHex(derived)}`;
479
+ const derived = await deriveKey(password, salt, ITERATIONS);
480
+ return `${PREFIX}$${ITERATIONS}$${toHex(salt.buffer)}$${toHex(derived)}`;
202
481
  }
203
482
  async function verifyPassword(password, storedHash) {
204
- const separatorIndex = storedHash.indexOf(":");
205
- if (separatorIndex === -1)
483
+ const parsed = parseHash(storedHash);
484
+ if (!parsed)
206
485
  return false;
207
- const saltHex = storedHash.substring(0, separatorIndex);
208
- const keyHex = storedHash.substring(separatorIndex + 1);
209
- const salt = fromHex(saltHex);
210
- const expectedKey = fromHex(keyHex);
211
- const derived = new Uint8Array(await deriveKey(password, salt));
212
- if (derived.length !== expectedKey.length)
486
+ const derived = new Uint8Array(await deriveKey(password, parsed.salt, parsed.iterations));
487
+ if (derived.length !== parsed.key.length)
213
488
  return false;
214
489
  let diff = 0;
215
490
  for (let i = 0;i < derived.length; i++) {
216
- diff |= (derived[i] ?? 0) ^ (expectedKey[i] ?? 0);
491
+ diff |= (derived[i] ?? 0) ^ (parsed.key[i] ?? 0);
217
492
  }
218
493
  return diff === 0;
219
494
  }
220
-
221
- // src/users.ts
222
- var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
223
- function isValidEmail(email) {
224
- return email.length <= 254 && EMAIL_REGEX.test(email);
225
- }
226
- async function createUser(store, input) {
227
- if (!isValidEmail(input.email))
228
- throw new Error("Invalid email address");
229
- const existing = await store.getUserByEmail(input.email);
230
- if (existing)
231
- throw new Error(`User with email ${input.email} already exists`);
232
- const now = new Date().toISOString();
233
- const passwordHash = input.password ? await hashPassword(input.password) : await hashPassword(crypto.randomUUID());
234
- const user = {
235
- id: crypto.randomUUID(),
236
- email: input.email.toLowerCase(),
237
- name: input.name,
238
- passwordHash,
239
- isAdmin: input.isAdmin ?? false,
240
- emailVerified: input.isAdmin ?? false,
241
- totpSecret: null,
242
- totpEnabled: false,
243
- createdAt: now,
244
- updatedAt: now
245
- };
246
- await store.saveUser(user);
247
- return user;
248
- }
249
- async function verifyUserCredentials(store, email, password) {
250
- const user = await store.getUserByEmail(email.toLowerCase());
251
- if (!user)
252
- return null;
253
- const valid = await verifyPassword(password, user.passwordHash);
254
- return valid ? user : null;
495
+ function needsRehash(storedHash) {
496
+ return !storedHash.startsWith(`${PREFIX}$`) || (parseHash(storedHash)?.iterations ?? 0) < ITERATIONS;
255
497
  }
256
- async function updateName(store, userId, name) {
257
- const user = await store.getUser(userId);
258
- if (!user)
259
- throw new Error(`User ${userId} not found`);
260
- const updated = {
261
- ...user,
262
- name,
263
- updatedAt: new Date().toISOString()
264
- };
265
- await store.saveUser(updated);
498
+ // src/rate-limit.ts
499
+ async function consumeRateLimit(store, key, config) {
500
+ const entry = store.increment ? await store.increment(key, config.windowSeconds) : await incrementViaGetSet(store, key, config);
501
+ if (entry.attempts <= config.maxAttempts)
502
+ return { blocked: false };
503
+ const remaining = config.windowSeconds - Math.floor((Date.now() - entry.firstAttempt) / 1000);
504
+ return { blocked: true, remainingSeconds: Math.max(remaining, 1) };
266
505
  }
267
- async function changePassword(store, userId, newPassword) {
268
- const user = await store.getUser(userId);
269
- if (!user)
270
- throw new Error(`User ${userId} not found`);
271
- const updated = {
272
- ...user,
273
- passwordHash: await hashPassword(newPassword),
274
- updatedAt: new Date().toISOString()
275
- };
276
- await store.saveUser(updated);
506
+ async function incrementViaGetSet(store, key, config) {
507
+ const existing = await store.get(key);
508
+ const windowOver = existing !== null && Date.now() - existing.firstAttempt >= config.windowSeconds * 1000;
509
+ const entry = existing && !windowOver ? { attempts: existing.attempts + 1, firstAttempt: existing.firstAttempt } : { attempts: 1, firstAttempt: Date.now() };
510
+ await store.set(key, entry, config.windowSeconds);
511
+ return entry;
277
512
  }
278
-
279
- // src/rate-limit.ts
280
513
  async function checkRateLimit(store, key, config) {
281
514
  const entry = await store.get(key);
282
515
  if (!entry)
@@ -292,6 +525,10 @@ async function checkRateLimit(store, key, config) {
292
525
  return { blocked: false };
293
526
  }
294
527
  async function recordFailedAttempt(store, key, config) {
528
+ if (store.increment) {
529
+ await store.increment(key, config.windowSeconds);
530
+ return;
531
+ }
295
532
  const entry = await store.get(key);
296
533
  if (entry) {
297
534
  await store.set(key, { attempts: entry.attempts + 1, firstAttempt: entry.firstAttempt }, config.windowSeconds);
@@ -302,7 +539,6 @@ async function recordFailedAttempt(store, key, config) {
302
539
  async function clearRateLimit(store, key) {
303
540
  await store.delete(key);
304
541
  }
305
-
306
542
  // src/totp.ts
307
543
  var TOTP_PERIOD = 30;
308
544
  var TOTP_DIGITS = 6;
@@ -380,17 +616,21 @@ async function currentTotpCode(secret, nowMs = Date.now()) {
380
616
  const step = Math.floor(nowMs / 1000 / TOTP_PERIOD);
381
617
  return generateCode(secret, step);
382
618
  }
383
- async function verifyTotpCode(secret, code) {
384
- if (code.length !== TOTP_DIGITS)
385
- return false;
386
- const now = Math.floor(Date.now() / 1000);
387
- const currentStep = Math.floor(now / TOTP_PERIOD);
619
+ async function verifyTotpStep(secret, code, nowMs = Date.now()) {
620
+ if (!/^\d{6}$/.test(code))
621
+ return null;
622
+ const currentStep = Math.floor(nowMs / 1000 / TOTP_PERIOD);
623
+ let matched = null;
388
624
  for (let offset = -TOTP_WINDOW;offset <= TOTP_WINDOW; offset++) {
389
- const expected = await generateCode(secret, currentStep + offset);
625
+ const step = currentStep + offset;
626
+ const expected = await generateCode(secret, step);
390
627
  if (timingSafeEqual(code, expected))
391
- return true;
628
+ matched = step;
392
629
  }
393
- return false;
630
+ return matched;
631
+ }
632
+ async function verifyTotpCode(secret, code) {
633
+ return await verifyTotpStep(secret, code) !== null;
394
634
  }
395
635
  var BACKUP_CODE_COUNT = 10;
396
636
  var BACKUP_CODE_LENGTH = 8;
@@ -398,11 +638,14 @@ var BACKUP_CODE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
398
638
  function generateBackupCodes() {
399
639
  const codes = [];
400
640
  for (let i = 0;i < BACKUP_CODE_COUNT; i++) {
401
- const bytes = new Uint8Array(BACKUP_CODE_LENGTH);
402
- crypto.getRandomValues(bytes);
403
641
  let code = "";
404
- for (const byte of bytes) {
405
- code += BACKUP_CODE_CHARS[byte % BACKUP_CODE_CHARS.length];
642
+ while (code.length < BACKUP_CODE_LENGTH) {
643
+ const bytes = new Uint8Array(BACKUP_CODE_LENGTH);
644
+ crypto.getRandomValues(bytes);
645
+ for (const byte of bytes) {
646
+ if (byte < 252 && code.length < BACKUP_CODE_LENGTH)
647
+ code += BACKUP_CODE_CHARS[byte % BACKUP_CODE_CHARS.length];
648
+ }
406
649
  }
407
650
  codes.push(code);
408
651
  }
@@ -422,32 +665,109 @@ function timingSafeEqual(a, b) {
422
665
  }
423
666
  return result === 0;
424
667
  }
668
+ // src/users.ts
669
+ var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
670
+ function isValidEmail(email) {
671
+ return email.length <= 254 && EMAIL_REGEX.test(email);
672
+ }
673
+ async function createUser(store, input) {
674
+ if (!isValidEmail(input.email))
675
+ throw new Error("Invalid email address");
676
+ if (input.password !== undefined && input.password.length > MAX_PASSWORD_LENGTH)
677
+ throw new Error("Password is too long");
678
+ const existing = await store.getUserByEmail(input.email);
679
+ if (existing)
680
+ throw new Error(`User with email ${input.email} already exists`);
681
+ const now = new Date().toISOString();
682
+ const passwordHash = input.password ? await hashPassword(input.password) : await hashPassword(crypto.randomUUID());
683
+ const user = {
684
+ id: crypto.randomUUID(),
685
+ email: input.email.toLowerCase(),
686
+ name: input.name,
687
+ passwordHash,
688
+ isAdmin: input.isAdmin ?? false,
689
+ emailVerified: input.isAdmin ?? false,
690
+ totpSecret: null,
691
+ totpEnabled: false,
692
+ createdAt: now,
693
+ updatedAt: now
694
+ };
695
+ await store.saveUser(user);
696
+ return user;
697
+ }
698
+ var dummyHash = null;
699
+ async function verifyUserCredentials(store, email, password) {
700
+ const user = await store.getUserByEmail(email.toLowerCase());
701
+ if (!user) {
702
+ dummyHash ??= hashPassword(crypto.randomUUID());
703
+ await verifyPassword(password, await dummyHash);
704
+ return null;
705
+ }
706
+ const valid = await verifyPassword(password, user.passwordHash);
707
+ if (!valid)
708
+ return null;
709
+ if (!needsRehash(user.passwordHash))
710
+ return user;
711
+ const upgraded = { ...user, passwordHash: await hashPassword(password), updatedAt: new Date().toISOString() };
712
+ await store.saveUser(upgraded);
713
+ return upgraded;
714
+ }
715
+ async function updateName(store, userId, name) {
716
+ const user = await store.getUser(userId);
717
+ if (!user)
718
+ throw new Error(`User ${userId} not found`);
719
+ const updated = {
720
+ ...user,
721
+ name,
722
+ updatedAt: new Date().toISOString()
723
+ };
724
+ await store.saveUser(updated);
725
+ }
726
+ async function changePassword(store, userId, newPassword) {
727
+ if (newPassword.length > MAX_PASSWORD_LENGTH)
728
+ throw new Error("Password is too long");
729
+ const user = await store.getUser(userId);
730
+ if (!user)
731
+ throw new Error(`User ${userId} not found`);
732
+ const updated = {
733
+ ...user,
734
+ passwordHash: await hashPassword(newPassword),
735
+ updatedAt: new Date().toISOString()
736
+ };
737
+ await store.saveUser(updated);
738
+ }
425
739
  export {
426
- verifyUserCredentials,
427
- verifyTotpCode,
428
- verifyPassword,
429
- validateToken,
430
- redirectToLogin,
431
- recordFailedAttempt,
432
- makeSessionCookie,
433
- makeClearCookie,
434
- isValidEmail,
435
- importPublicKey,
436
- importPrivateKey,
437
- hashPassword,
438
- hashBackupCode,
439
- getTotpUri,
440
- getClientIP,
441
- getAuthUser,
442
- generateTotpSecret,
443
- generateKeyPair,
444
- generateBackupCodes,
445
- extractCookie,
446
- createUser,
447
- createToken,
448
- clearRateLimit,
449
- checkRateLimit,
450
- checkBearerToken,
740
+ MAX_PASSWORD_LENGTH,
741
+ base32Encode,
451
742
  changePassword,
452
- base32Encode
743
+ checkBearerToken,
744
+ checkRateLimit,
745
+ clearRateLimit,
746
+ consumeRateLimit,
747
+ createToken,
748
+ createUser,
749
+ extractCookie,
750
+ generateBackupCodes,
751
+ generateKeyPair,
752
+ generateTotpSecret,
753
+ getAuthUser,
754
+ getClientIP,
755
+ getTotpUri,
756
+ hasSiteAccess,
757
+ hashBackupCode,
758
+ hashPassword,
759
+ importPrivateKey,
760
+ importPublicKey,
761
+ isTokenPayload,
762
+ isValidEmail,
763
+ makeClearCookie,
764
+ makeSessionCookie,
765
+ needsRehash,
766
+ recordFailedAttempt,
767
+ redirectToLogin,
768
+ validateToken,
769
+ verifyPassword,
770
+ verifyTotpCode,
771
+ verifyTotpStep,
772
+ verifyUserCredentials
453
773
  };