@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.
package/src/hono.ts CHANGED
@@ -1,14 +1,34 @@
1
1
  import type { Context, MiddlewareHandler } from "hono";
2
2
  import { extractCookie } from "./cookie.ts";
3
- import { makeSessionCookie } from "./cookie.ts";
4
- import { importPublicKey } from "./token.ts";
5
- import { validateToken } from "./token.ts";
3
+ import {
4
+ DEFAULT_AUTH_URL,
5
+ DEFAULT_REVALIDATE_SECONDS,
6
+ handleLoginCallback,
7
+ redirectToLogin,
8
+ revalidateToken,
9
+ } from "./flow.ts";
10
+ import {
11
+ bindingFetcher,
12
+ type FetchLike,
13
+ fetchJwks,
14
+ hasSiteAccessFor,
15
+ publicFetcher,
16
+ verifyTokenRemote,
17
+ } from "./remote.ts";
18
+ import { peekTokenKid, peekTokenPayload, validateToken } from "./token.ts";
6
19
  import type { CookieConfig, PublicKeyEntry, TokenPayload } from "./types.ts";
7
20
 
21
+ declare module "hono" {
22
+ interface ContextVariableMap {
23
+ user: TokenPayload;
24
+ }
25
+ }
26
+
8
27
  interface AuthOptions {
9
28
  /**
10
29
  * Service binding name (e.g. "AUTH"). If provided, validates tokens
11
30
  * via the auth worker service binding instead of fetching public keys.
31
+ * Binding mode also rejects tokens for deleted users and revoked sessions.
12
32
  */
13
33
  binding?: string;
14
34
 
@@ -23,15 +43,30 @@ interface AuthOptions {
23
43
  */
24
44
  authUrl?: string;
25
45
 
26
- /** Cookie name. Defaults to "uln_auth". */
46
+ /**
47
+ * Cookie name. Defaults to "__Host-uln_auth": the `__Host-` prefix means only this exact host can set it, so a page on
48
+ * a sibling subdomain can't plant a session cookie (session fixation / cookie tossing).
49
+ */
27
50
  cookie?: string;
28
51
 
29
52
  /** Cookie/token max age in seconds. Defaults to 86400 (24h). */
30
53
  maxAge?: number;
54
+
55
+ /**
56
+ * The registered origin of this site, used for the scope check. Defaults to the request URL's origin.
57
+ * Set this when the Worker is reached through a different hostname than the one registered in auth.
58
+ */
59
+ siteOrigin?: string;
60
+
61
+ /**
62
+ * Public-key mode only: confirm a locally valid token with the auth service at most this often (seconds, per
63
+ * token), so password resets, deleted users and removed scopes take effect within this window instead of when the
64
+ * token expires. If the auth service is unreachable the local result stands. Defaults to 300; 0 disables.
65
+ */
66
+ revalidateSeconds?: number;
31
67
  }
32
68
 
33
- const DEFAULT_AUTH_URL = "https://auth.uln.industries";
34
- const DEFAULT_COOKIE = "uln_auth";
69
+ const DEFAULT_COOKIE = "__Host-uln_auth";
35
70
  const DEFAULT_MAX_AGE = 86400;
36
71
 
37
72
  function isPublicPath(pathname: string, patterns: string[]): boolean {
@@ -48,98 +83,55 @@ function isPublicPath(pathname: string, patterns: string[]): boolean {
48
83
 
49
84
  // --- Public key caching (client mode) ---
50
85
 
51
- let cachedKeys: PublicKeyEntry[] | null = null;
52
- let cachedAt = 0;
53
- const CACHE_TTL = 3600_000; // 1 hour
54
-
55
- async function getPublicKeys(authUrl: string): Promise<PublicKeyEntry[]> {
56
- if (cachedKeys && Date.now() - cachedAt < CACHE_TTL) {
57
- return cachedKeys;
58
- }
59
- const res = await fetch(`${authUrl}/.well-known/jwk.json`);
60
- if (!res.ok) throw new Error(`Failed to fetch JWKS from ${authUrl}: ${res.status}`);
61
- const jwks = (await res.json()) as { keys: (JsonWebKey & { kid: string })[] };
62
- cachedKeys = await Promise.all(
63
- jwks.keys.map(async (jwk) => ({
64
- kid: jwk.kid,
65
- key: await importPublicKey(jwk),
66
- })),
67
- );
68
- cachedAt = Date.now();
69
- return cachedKeys;
86
+ interface KeyCache {
87
+ keys: PublicKeyEntry[];
88
+ fetchedAt: number;
70
89
  }
71
90
 
72
- // --- Token validation ---
73
-
74
- async function validateWithPublicKeys(token: string, authUrl: string, maxAge: number): Promise<TokenPayload | null> {
75
- const publicKeys = await getPublicKeys(authUrl);
76
- return validateToken({ publicKeys, maxAgeSeconds: maxAge }, token);
91
+ const keyCaches = new Map<string, KeyCache>();
92
+ const CACHE_TTL = 10 * 60_000;
93
+ /** Minimum spacing between forced refreshes triggered by an unknown `kid`, so bad tokens can't hammer JWKS. */
94
+ const MIN_REFRESH_INTERVAL = 10_000;
95
+
96
+ async function getPublicKeys(authUrl: string, wantedKid: string | null): Promise<PublicKeyEntry[]> {
97
+ const cached = keyCaches.get(authUrl);
98
+ const age = cached ? Date.now() - cached.fetchedAt : Number.POSITIVE_INFINITY;
99
+ const hasKid = wantedKid === null || cached?.keys.some((k) => k.kid === wantedKid);
100
+ // After a key rotation, tokens carry a kid we haven't seen yet — refetch instead of waiting out the TTL.
101
+ if (cached && age < CACHE_TTL && (hasKid || age < MIN_REFRESH_INTERVAL)) {
102
+ return cached.keys;
103
+ }
104
+ const keys = await fetchJwks(publicFetcher(authUrl));
105
+ keyCaches.set(authUrl, { keys, fetchedAt: Date.now() });
106
+ return keys;
77
107
  }
78
108
 
79
- async function validateWithBinding(token: string, binding: Fetcher): Promise<TokenPayload | null> {
80
- const res = await binding.fetch("https://auth/api/verify-token", {
81
- method: "POST",
82
- headers: { "Content-Type": "application/json" },
83
- body: JSON.stringify({ token }),
84
- });
85
- if (!res.ok) return null;
86
- const data = (await res.json()) as {
87
- user: {
88
- id: string;
89
- email: string;
90
- name: string;
91
- admin: boolean;
92
- scopes: string[];
93
- flags: Record<string, string[]>;
94
- kid: string;
95
- };
96
- };
97
- return {
98
- sub: data.user.id,
99
- email: data.user.email,
100
- name: data.user.name,
101
- admin: data.user.admin,
102
- scopes: data.user.scopes,
103
- flags: data.user.flags,
104
- kid: data.user.kid,
105
- iat: 0,
106
- exp: 0,
107
- };
109
+ /** Test hook: forget cached JWKS. */
110
+ export function clearPublicKeyCache(): void {
111
+ keyCaches.clear();
108
112
  }
109
113
 
110
- async function exchangeCodeWithPublicEndpoint(
111
- code: string,
112
- redirectOrigin: string,
113
- authUrl: string,
114
- ): Promise<string | null> {
115
- const res = await fetch(`${authUrl}/api/exchange-code`, {
116
- method: "POST",
117
- headers: { "Content-Type": "application/json" },
118
- body: JSON.stringify({ code, redirectOrigin }),
119
- });
120
- if (!res.ok) return null;
121
- const data = (await res.json()) as { token?: string };
122
- return typeof data.token === "string" ? data.token : null;
114
+ function isFetcher(value: unknown): value is { fetch(input: string, init?: RequestInit): Promise<Response> } {
115
+ return typeof value === "object" && value !== null && "fetch" in value && typeof value.fetch === "function";
123
116
  }
124
117
 
125
- async function exchangeCodeWithBinding(code: string, redirectOrigin: string, binding: Fetcher): Promise<string | null> {
126
- const res = await binding.fetch("https://auth/api/exchange-code", {
127
- method: "POST",
128
- headers: { "Content-Type": "application/json" },
129
- body: JSON.stringify({ code, redirectOrigin }),
130
- });
131
- if (!res.ok) return null;
132
- const data = (await res.json()) as { token?: string };
133
- return typeof data.token === "string" ? data.token : null;
118
+ function getBinding(env: unknown, name: string): FetchLike {
119
+ const binding: unknown = typeof env === "object" && env !== null ? Reflect.get(env, name) : undefined;
120
+ if (!isFetcher(binding)) throw new Error(`Service binding "${name}" not found in env`);
121
+ return bindingFetcher(binding);
134
122
  }
135
123
 
136
124
  /**
137
125
  * Hono middleware that handles the full auth flow:
138
126
  *
139
- * 1. If `?code=` in URL → exchange for token, set cookie, redirect to clean URL
140
- * 2. If session cookie present → validate, set `c.get("user")`
127
+ * 1. If `?code=` in URL → check it belongs to this browser's login, exchange for token, set cookie, redirect to
128
+ * clean URL
129
+ * 2. If session cookie or `Authorization: Bearer` present → validate, check site access, set `c.get("user")`
141
130
  * 3. If unauthenticated → redirect to auth.uln.industries/login
142
131
  *
132
+ * Tokens issued to another origin are rejected. Users without access to this site (not admin, origin not in
133
+ * `scopes`) get a 403.
134
+ *
143
135
  * Usage:
144
136
  * ```ts
145
137
  * import { auth } from "@uln/auth/hono";
@@ -163,33 +155,38 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
163
155
  const maxAge = options?.maxAge ?? DEFAULT_MAX_AGE;
164
156
  const publicPaths = options?.public ?? [];
165
157
  const bindingName = options?.binding;
158
+ const revalidateSeconds = options?.revalidateSeconds ?? DEFAULT_REVALIDATE_SECONDS;
166
159
 
167
160
  const cookieConf: CookieConfig = { name: cookieName, maxAgeSeconds: maxAge, secure: true };
168
161
 
169
- async function validate(token: string, env: Record<string, unknown>): Promise<TokenPayload | null> {
170
- if (bindingName) {
171
- const binding = env[bindingName] as Fetcher | undefined;
172
- if (!binding) throw new Error(`Service binding "${bindingName}" not found in env`);
173
- return validateWithBinding(token, binding);
174
- }
175
- return validateWithPublicKeys(token, authUrl, maxAge);
162
+ function authService(env: unknown): FetchLike {
163
+ return bindingName ? getBinding(env, bindingName) : publicFetcher(authUrl);
176
164
  }
177
165
 
178
- async function exchangeCode(
179
- code: string,
180
- redirectOrigin: string,
181
- env: Record<string, unknown>,
182
- ): Promise<string | null> {
166
+ /**
167
+ * Validate a token issued to one of `audiences` (this site's origins); a token for any other audience — including
168
+ * one from before audience binding — counts as no session, so the user is sent to sign in. Scopes are checked by
169
+ * the caller.
170
+ */
171
+ async function validate(token: string, env: unknown, audiences: string[]): Promise<TokenPayload | null> {
183
172
  if (bindingName) {
184
- const binding = env[bindingName] as Fetcher | undefined;
185
- if (!binding) throw new Error(`Service binding "${bindingName}" not found in env`);
186
- return exchangeCodeWithBinding(code, redirectOrigin, binding);
173
+ // The unverified `aud` only picks which audience to ask about (one call instead of one per origin); the auth
174
+ // service still checks it. Tokens for any other audience are refused without a call.
175
+ const audience = peekTokenPayload(token)?.aud;
176
+ if (audience === undefined || !audiences.includes(audience)) return null;
177
+ return verifyTokenRemote(getBinding(env, bindingName), token, audience);
187
178
  }
188
- return exchangeCodeWithPublicEndpoint(code, redirectOrigin, authUrl);
179
+ const publicKeys = await getPublicKeys(authUrl, peekTokenKid(token));
180
+ const local = await validateToken({ publicKeys, maxAgeSeconds: maxAge }, token);
181
+ if (!local || local.aud === undefined || !audiences.includes(local.aud)) return null;
182
+ if (revalidateSeconds <= 0) return local;
183
+ return revalidateToken(publicFetcher(authUrl), token, local, local.aud, revalidateSeconds);
189
184
  }
190
185
 
191
186
  return async (c, next) => {
192
187
  const url = new URL(c.req.url);
188
+ const scopeOrigin = options?.siteOrigin ?? url.origin;
189
+ const site = { audiences: [url.origin, scopeOrigin], scopeOrigin };
193
190
 
194
191
  // Public paths bypass auth
195
192
  if (isPublicPath(url.pathname, publicPaths)) {
@@ -197,63 +194,52 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
197
194
  }
198
195
 
199
196
  // Step 1: Handle ?code= callback (login redirect landing)
200
- const incomingCode = url.searchParams.get("code");
201
- if (incomingCode) {
202
- const token = await exchangeCode(incomingCode, url.origin, c.env as Record<string, unknown>);
203
- if (!token) {
204
- return c.text("Invalid code", 401);
205
- }
206
- const payload = await validate(token, c.env as Record<string, unknown>);
207
- if (!payload) {
208
- return c.text("Invalid token", 401);
209
- }
210
-
211
- url.searchParams.delete("code");
212
- return new Response(null, {
213
- status: 302,
214
- headers: {
215
- Location: url.toString(),
216
- "Set-Cookie": makeSessionCookie(cookieConf, token),
217
- },
218
- });
219
- }
197
+ const callback = await handleLoginCallback(c.req.raw, {
198
+ auth: authService(c.env),
199
+ authUrl,
200
+ cookieConfig: cookieConf,
201
+ validate: (token) => validate(token, c.env, site.audiences),
202
+ scopeOrigin: site.scopeOrigin,
203
+ });
204
+ if (callback) return callback;
220
205
 
221
206
  // Step 2: Check session cookie
222
207
  const cookieHeader = c.req.header("Cookie");
223
208
  if (cookieHeader) {
224
209
  const token = extractCookie(cookieHeader, cookieName);
225
210
  if (token) {
226
- const payload = await validate(token, c.env as Record<string, unknown>);
211
+ const payload = await validate(token, c.env, site.audiences);
227
212
  if (payload) {
213
+ if (!hasSiteAccessFor(payload, site)) {
214
+ return c.text("You don't have access to this site", 403);
215
+ }
228
216
  c.set("user", payload);
229
217
  return next();
230
218
  }
231
219
  }
232
220
  }
233
221
 
234
- // Step 4: Check Authorization header
222
+ // Step 3: Check Authorization header
235
223
  const authHeader = c.req.header("Authorization");
236
224
  if (authHeader?.startsWith("Bearer ")) {
237
225
  const token = authHeader.substring(7);
238
- const payload = await validate(token, c.env as Record<string, unknown>);
239
- if (payload) {
240
- c.set("user", payload);
241
- return next();
242
- }
243
- return c.json({ error: "Invalid token" }, 401);
226
+ const payload = await validate(token, c.env, site.audiences);
227
+ if (!payload) return c.json({ error: "Invalid token" }, 401);
228
+ if (!hasSiteAccessFor(payload, site)) return c.json({ error: "Forbidden" }, 403);
229
+ c.set("user", payload);
230
+ return next();
244
231
  }
245
232
 
246
- // Step 5: Redirect to login
247
- const loginUrl = new URL("/login", authUrl);
248
- loginUrl.searchParams.set("redirect", c.req.url);
249
- loginUrl.searchParams.set("response_type", "code");
250
- return c.redirect(loginUrl.toString(), 302);
233
+ // Step 4: Redirect to login
234
+ return redirectToLogin(c.req.url, authUrl);
251
235
  };
252
236
  }
253
237
 
254
- /** Type-safe helper to get the authenticated user from context. */
238
+ /** Get the authenticated user from context. Throws if called on a route that `auth()` doesn't cover. */
255
239
  export function getUser(c: Context): TokenPayload {
256
- return c.get("user") as TokenPayload;
240
+ const user: TokenPayload | undefined = c.get("user");
241
+ if (!user) throw new Error("getUser() called on a route that is not behind the auth() middleware");
242
+ return user;
257
243
  }
258
244
 
259
245
  export type { TokenPayload } from "./types.ts";
package/src/index.ts CHANGED
@@ -1,29 +1,37 @@
1
- export type {
2
- User,
3
- UserStore,
4
- RateLimitEntry,
5
- RateLimitStore,
6
- RateLimitConfig,
7
- TokenPayload,
8
- SigningConfig,
9
- VerifyConfig,
10
- PublicKeyEntry,
11
- CookieConfig,
12
- } from "./types.ts";
13
-
14
- export { hashPassword, verifyPassword } from "./password.ts";
15
- export { createToken, validateToken, generateKeyPair, importPrivateKey, importPublicKey } from "./token.ts";
16
- export type { CreateTokenInput } from "./token.ts";
17
- export { makeSessionCookie, makeClearCookie, extractCookie } from "./cookie.ts";
18
- export { checkRateLimit, recordFailedAttempt, clearRateLimit } from "./rate-limit.ts";
1
+ export { extractCookie, makeClearCookie, makeSessionCookie } from "./cookie.ts";
2
+ export { checkBearerToken, getAuthUser, getClientIP, redirectToLogin } from "./middleware.ts";
3
+ export { hashPassword, MAX_PASSWORD_LENGTH, needsRehash, verifyPassword } from "./password.ts";
19
4
  export type { RateLimitResult } from "./rate-limit.ts";
20
- export { getClientIP, checkBearerToken, getAuthUser, redirectToLogin } from "./middleware.ts";
21
- export { createUser, verifyUserCredentials, changePassword, isValidEmail } from "./users.ts";
5
+ export { checkRateLimit, clearRateLimit, consumeRateLimit, recordFailedAttempt } from "./rate-limit.ts";
6
+ export { hasSiteAccess } from "./remote.ts";
7
+ export type { CreateTokenInput } from "./token.ts";
8
+ export {
9
+ createToken,
10
+ generateKeyPair,
11
+ importPrivateKey,
12
+ importPublicKey,
13
+ isTokenPayload,
14
+ validateToken,
15
+ } from "./token.ts";
22
16
  export {
23
- generateTotpSecret,
24
- getTotpUri,
25
- verifyTotpCode,
26
17
  base32Encode,
27
18
  generateBackupCodes,
19
+ generateTotpSecret,
20
+ getTotpUri,
28
21
  hashBackupCode,
22
+ verifyTotpCode,
23
+ verifyTotpStep,
29
24
  } from "./totp.ts";
25
+ export type {
26
+ CookieConfig,
27
+ PublicKeyEntry,
28
+ RateLimitConfig,
29
+ RateLimitEntry,
30
+ RateLimitStore,
31
+ SigningConfig,
32
+ TokenPayload,
33
+ User,
34
+ UserStore,
35
+ VerifyConfig,
36
+ } from "./types.ts";
37
+ export { changePassword, createUser, isValidEmail, verifyUserCredentials } from "./users.ts";
package/src/middleware.ts CHANGED
@@ -1,11 +1,19 @@
1
- import { extractCookie, makeSessionCookie } from "./cookie.ts";
1
+ import { extractCookie } from "./cookie.ts";
2
+ import { DEFAULT_AUTH_URL, handleLoginCallback, revalidateToken } from "./flow.ts";
3
+ import { hasSiteAccessFor, publicFetcher } from "./remote.ts";
2
4
  import { validateToken } from "./token.ts";
3
5
  import type { CookieConfig, TokenPayload, VerifyConfig } from "./types.ts";
4
6
 
7
+ export { redirectToLogin } from "./flow.ts";
8
+
5
9
  export interface AuthCodeExchangeConfig {
6
10
  authUrl: string;
7
11
  }
8
12
 
13
+ /**
14
+ * Best-effort client IP. Prefers Cloudflare's `cf-connecting-ip`, which clients cannot spoof when the request
15
+ * arrives through Cloudflare. `x-forwarded-for` is only a fallback and is attacker-controlled off Cloudflare.
16
+ */
9
17
  export function getClientIP(request: Request): string {
10
18
  return (
11
19
  request.headers.get("cf-connecting-ip") ??
@@ -14,15 +22,29 @@ export function getClientIP(request: Request): string {
14
22
  );
15
23
  }
16
24
 
25
+ /** Constant-time check that the request carries `Authorization: Bearer <expectedToken>`. */
17
26
  export function checkBearerToken(request: Request, expectedToken: string): boolean {
18
- const header = request.headers.get("Authorization");
19
- return header === `Bearer ${expectedToken}`;
27
+ const header = request.headers.get("Authorization") ?? "";
28
+ const expected = `Bearer ${expectedToken}`;
29
+ const a = new TextEncoder().encode(header);
30
+ const b = new TextEncoder().encode(expected);
31
+ let diff = a.length ^ b.length;
32
+ for (let i = 0; i < b.length; i++) {
33
+ diff |= (a[i] ?? 0) ^ (b[i] ?? 0);
34
+ }
35
+ return diff === 0;
20
36
  }
21
37
 
38
+ /**
39
+ * Get the authenticated user from the session cookie, validated locally against `verifyConfig` (and confirmed with
40
+ * the auth service when `verifyConfig.revalidate` is set). Returns null if not authenticated, or if the token wasn't
41
+ * issued to — or the user doesn't have access to — `siteOrigin` (defaults to the request's origin).
42
+ */
22
43
  export async function getAuthUser(
23
44
  request: Request,
24
45
  verifyConfig: VerifyConfig,
25
46
  cookieConfig: CookieConfig,
47
+ siteOrigin?: string,
26
48
  ): Promise<TokenPayload | null> {
27
49
  const cookieHeader = request.headers.get("Cookie");
28
50
  if (!cookieHeader) return null;
@@ -30,36 +52,29 @@ export async function getAuthUser(
30
52
  const token = extractCookie(cookieHeader, cookieConfig.name);
31
53
  if (!token) return null;
32
54
 
33
- return validateToken(verifyConfig, token);
34
- }
35
-
36
- export function redirectToLogin(currentUrl: string): Response {
37
- const loginUrl = new URL("/login", "https://auth.uln.industries");
38
- loginUrl.searchParams.set("redirect", currentUrl);
39
- loginUrl.searchParams.set("response_type", "code");
40
- return Response.redirect(loginUrl.toString(), 302);
41
- }
55
+ const audience = new URL(request.url).origin;
56
+ const scopeOrigin = siteOrigin ?? audience;
57
+ const site = { audiences: [audience, scopeOrigin], scopeOrigin };
58
+ const payload = await validateToken(verifyConfig, token);
59
+ if (!payload || !hasSiteAccessFor(payload, site)) return null;
42
60
 
43
- async function exchangeCode(
44
- code: string,
45
- redirectOrigin: string,
46
- config: AuthCodeExchangeConfig,
47
- ): Promise<string | null> {
48
- const res = await fetch(`${config.authUrl}/api/exchange-code`, {
49
- method: "POST",
50
- headers: { "Content-Type": "application/json" },
51
- body: JSON.stringify({ code, redirectOrigin }),
52
- });
53
- if (!res.ok) return null;
54
- const data = (await res.json()) as { token?: string };
55
- return typeof data.token === "string" ? data.token : null;
61
+ const { revalidate } = verifyConfig;
62
+ if (!revalidate) return payload;
63
+ const current = await revalidateToken(
64
+ publicFetcher(revalidate.authUrl),
65
+ token,
66
+ payload,
67
+ payload.aud ?? audience,
68
+ revalidate.intervalSeconds,
69
+ );
70
+ return current && hasSiteAccessFor(current, site) ? current : null;
56
71
  }
57
72
 
58
73
  /**
59
74
  * Handle the auth callback on consuming sites.
60
- * If the request URL has a `?code=` param, exchanges it for a token,
61
- * sets a session cookie, and redirects to the same URL without the code param.
62
- * Returns null if there's no callback param.
75
+ * If the request URL has a `?code=` param (and the login was started by this browser), exchanges it for a token,
76
+ * sets a session cookie, and redirects to the same URL without the callback params.
77
+ * Returns 403 if the user doesn't have access to this site, or null if there's no callback param.
63
78
  */
64
79
  export async function handleCallback(
65
80
  request: Request,
@@ -67,26 +82,11 @@ export async function handleCallback(
67
82
  cookieConfig: CookieConfig,
68
83
  exchangeConfig?: AuthCodeExchangeConfig,
69
84
  ): Promise<Response | null> {
70
- const url = new URL(request.url);
71
- const code = url.searchParams.get("code");
72
- if (code) {
73
- if (!exchangeConfig) return redirectToLogin(url.origin);
74
-
75
- const token = await exchangeCode(code, url.origin, exchangeConfig);
76
- if (!token) return redirectToLogin(url.origin);
77
-
78
- const payload = await validateToken(verifyConfig, token);
79
- if (!payload) return redirectToLogin(url.origin);
80
-
81
- url.searchParams.delete("code");
82
- return new Response(null, {
83
- status: 302,
84
- headers: {
85
- Location: url.toString(),
86
- "Set-Cookie": makeSessionCookie(cookieConfig, token),
87
- },
88
- });
89
- }
90
-
91
- return null;
85
+ const authUrl = exchangeConfig?.authUrl ?? DEFAULT_AUTH_URL;
86
+ return handleLoginCallback(request, {
87
+ auth: publicFetcher(authUrl),
88
+ authUrl,
89
+ cookieConfig,
90
+ validate: (token) => validateToken(verifyConfig, token),
91
+ });
92
92
  }
package/src/password.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  const SALT_LENGTH = 32;
2
2
  const KEY_LENGTH = 64;
3
- const ITERATIONS = 100_000;
3
+ // 100k is the maximum PBKDF2 iteration count the Cloudflare Workers runtime accepts in one call. Higher work factors
4
+ // chain calls: each stage's output is the next stage's key material (same salt), so a guess still costs every stage.
5
+ const STAGE_ITERATIONS = 100_000;
6
+ /** OWASP's recommendation for PBKDF2-HMAC-SHA256. New hashes use this; older ones are upgraded on the next login. */
7
+ const ITERATIONS = 600_000;
8
+ const MAX_ITERATIONS = 10_000_000;
9
+ /** Upper bound enforced wherever a password is *set*. Verification accepts any length so no stored password is locked out. */
10
+ export const MAX_PASSWORD_LENGTH = 1024;
11
+ const HEX_REGEX = /^(?:[0-9a-f]{2})+$/;
12
+ const PREFIX = "pbkdf2-sha256";
4
13
 
5
14
  function toHex(buffer: ArrayBuffer): string {
6
15
  return Array.from(new Uint8Array(buffer))
@@ -16,44 +25,75 @@ function fromHex(hex: string): Uint8Array<ArrayBuffer> {
16
25
  return bytes;
17
26
  }
18
27
 
19
- async function deriveKey(password: string, salt: Uint8Array<ArrayBuffer>): Promise<ArrayBuffer> {
20
- const encoder = new TextEncoder();
21
- const keyMaterial = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"]);
22
- return crypto.subtle.deriveBits(
23
- {
24
- name: "PBKDF2",
25
- salt,
26
- iterations: ITERATIONS,
27
- hash: "SHA-256",
28
- },
29
- keyMaterial,
30
- KEY_LENGTH * 8,
31
- );
28
+ async function deriveKey(password: string, salt: Uint8Array<ArrayBuffer>, iterations: number): Promise<ArrayBuffer> {
29
+ let material: BufferSource = new TextEncoder().encode(password);
30
+ let derived = new ArrayBuffer(0);
31
+ for (let done = 0; done < iterations; done += STAGE_ITERATIONS) {
32
+ const keyMaterial = await crypto.subtle.importKey("raw", material, "PBKDF2", false, ["deriveBits"]);
33
+ derived = await crypto.subtle.deriveBits(
34
+ { name: "PBKDF2", salt, iterations: STAGE_ITERATIONS, hash: "SHA-256" },
35
+ keyMaterial,
36
+ KEY_LENGTH * 8,
37
+ );
38
+ material = new Uint8Array(derived);
39
+ }
40
+ return derived;
41
+ }
42
+
43
+ interface ParsedHash {
44
+ iterations: number;
45
+ salt: Uint8Array<ArrayBuffer>;
46
+ key: Uint8Array<ArrayBuffer>;
32
47
  }
33
48
 
34
- /** Hash a password with PBKDF2-SHA256 (100k iterations, 32-byte salt, 64-byte key). Returns `hex(salt):hex(key)`. */
49
+ /** `pbkdf2-sha256$<iterations>$<salt>$<key>`, or the original `<salt>:<key>` (one 100k stage). */
50
+ function parseHash(storedHash: string): ParsedHash | null {
51
+ let iterations = STAGE_ITERATIONS;
52
+ let saltHex: string;
53
+ let keyHex: string;
54
+ if (storedHash.startsWith(`${PREFIX}$`)) {
55
+ const [, rawIterations = "", salt = "", key = ""] = storedHash.split("$");
56
+ if (!/^[1-9]\d*$/.test(rawIterations)) return null;
57
+ iterations = Number(rawIterations);
58
+ if (iterations % STAGE_ITERATIONS !== 0 || iterations > MAX_ITERATIONS) return null;
59
+ saltHex = salt;
60
+ keyHex = key;
61
+ } else {
62
+ const separatorIndex = storedHash.indexOf(":");
63
+ if (separatorIndex === -1) return null;
64
+ saltHex = storedHash.substring(0, separatorIndex);
65
+ keyHex = storedHash.substring(separatorIndex + 1);
66
+ }
67
+ if (!HEX_REGEX.test(saltHex) || !HEX_REGEX.test(keyHex)) return null;
68
+ return { iterations, salt: fromHex(saltHex), key: fromHex(keyHex) };
69
+ }
70
+
71
+ /**
72
+ * Hash a password with PBKDF2-SHA256 (600k iterations, 32-byte salt, 64-byte key). Returns
73
+ * `pbkdf2-sha256$600000$hex(salt)$hex(key)`.
74
+ */
35
75
  export async function hashPassword(password: string): Promise<string> {
36
76
  const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
37
- const derived = await deriveKey(password, salt);
38
- return `${toHex(salt.buffer)}:${toHex(derived)}`;
77
+ const derived = await deriveKey(password, salt, ITERATIONS);
78
+ return `${PREFIX}$${ITERATIONS}$${toHex(salt.buffer)}$${toHex(derived)}`;
39
79
  }
40
80
 
41
- /** Verify a password against a stored `hex(salt):hex(key)` hash. Uses constant-time comparison. */
81
+ /** Verify a password against any hash `hashPassword` produces now or produced before. Uses constant-time comparison. */
42
82
  export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
43
- const separatorIndex = storedHash.indexOf(":");
44
- if (separatorIndex === -1) return false;
45
-
46
- const saltHex = storedHash.substring(0, separatorIndex);
47
- const keyHex = storedHash.substring(separatorIndex + 1);
48
- const salt = fromHex(saltHex);
49
- const expectedKey = fromHex(keyHex);
50
- const derived = new Uint8Array(await deriveKey(password, salt));
83
+ const parsed = parseHash(storedHash);
84
+ if (!parsed) return false;
85
+ const derived = new Uint8Array(await deriveKey(password, parsed.salt, parsed.iterations));
51
86
 
52
- if (derived.length !== expectedKey.length) return false;
87
+ if (derived.length !== parsed.key.length) return false;
53
88
 
54
89
  let diff = 0;
55
90
  for (let i = 0; i < derived.length; i++) {
56
- diff |= (derived[i] ?? 0) ^ (expectedKey[i] ?? 0);
91
+ diff |= (derived[i] ?? 0) ^ (parsed.key[i] ?? 0);
57
92
  }
58
93
  return diff === 0;
59
94
  }
95
+
96
+ /** Whether a stored hash is weaker than what `hashPassword` produces now, so it should be replaced after a login. */
97
+ export function needsRehash(storedHash: string): boolean {
98
+ return !storedHash.startsWith(`${PREFIX}$`) || (parseHash(storedHash)?.iterations ?? 0) < ITERATIONS;
99
+ }