@uln/auth 0.2.0 → 0.4.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/totp.js ADDED
@@ -0,0 +1,129 @@
1
+ // @bun
2
+ // src/totp.ts
3
+ var TOTP_PERIOD = 30;
4
+ var TOTP_DIGITS = 6;
5
+ var TOTP_WINDOW = 1;
6
+ var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
7
+ function base32Encode(data) {
8
+ let bits = 0;
9
+ let value = 0;
10
+ let output = "";
11
+ for (const byte of data) {
12
+ value = value << 8 | byte;
13
+ bits += 8;
14
+ while (bits >= 5) {
15
+ bits -= 5;
16
+ output += BASE32_ALPHABET[value >>> bits & 31];
17
+ }
18
+ }
19
+ if (bits > 0) {
20
+ output += BASE32_ALPHABET[value << 5 - bits & 31];
21
+ }
22
+ return output;
23
+ }
24
+ function base32Decode(encoded) {
25
+ const cleaned = encoded.replace(/[=\s]/g, "").toUpperCase();
26
+ const output = [];
27
+ let bits = 0;
28
+ let value = 0;
29
+ for (const char of cleaned) {
30
+ const idx = BASE32_ALPHABET.indexOf(char);
31
+ if (idx === -1)
32
+ throw new Error(`Invalid base32 character: ${char}`);
33
+ value = value << 5 | idx;
34
+ bits += 5;
35
+ if (bits >= 8) {
36
+ bits -= 8;
37
+ output.push(value >>> bits & 255);
38
+ }
39
+ }
40
+ return new Uint8Array(output);
41
+ }
42
+ function generateTotpSecret() {
43
+ const bytes = new Uint8Array(20);
44
+ crypto.getRandomValues(bytes);
45
+ return base32Encode(bytes);
46
+ }
47
+ function getTotpUri(secret, email, issuer) {
48
+ const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(email)}`;
49
+ const params = new URLSearchParams({
50
+ secret,
51
+ issuer,
52
+ algorithm: "SHA1",
53
+ digits: String(TOTP_DIGITS),
54
+ period: String(TOTP_PERIOD)
55
+ });
56
+ return `otpauth://totp/${label}?${params.toString()}`;
57
+ }
58
+ async function generateCode(secret, counter) {
59
+ const keyBytes = base32Decode(secret);
60
+ const keyBuffer = new ArrayBuffer(keyBytes.byteLength);
61
+ new Uint8Array(keyBuffer).set(keyBytes);
62
+ const key = await crypto.subtle.importKey("raw", keyBuffer, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
63
+ const counterBuf = new ArrayBuffer(8);
64
+ const view = new DataView(counterBuf);
65
+ view.setUint32(4, counter);
66
+ const hmac = new Uint8Array(await crypto.subtle.sign("HMAC", key, counterBuf));
67
+ const offset = (hmac[hmac.length - 1] ?? 0) & 15;
68
+ const b0 = hmac[offset] ?? 0;
69
+ const b1 = hmac[offset + 1] ?? 0;
70
+ const b2 = hmac[offset + 2] ?? 0;
71
+ const b3 = hmac[offset + 3] ?? 0;
72
+ const code = (b0 & 127) << 24 | (b1 & 255) << 16 | (b2 & 255) << 8 | b3 & 255;
73
+ return String(code % 10 ** TOTP_DIGITS).padStart(TOTP_DIGITS, "0");
74
+ }
75
+ async function currentTotpCode(secret, nowMs = Date.now()) {
76
+ const step = Math.floor(nowMs / 1000 / TOTP_PERIOD);
77
+ return generateCode(secret, step);
78
+ }
79
+ async function verifyTotpCode(secret, code) {
80
+ if (code.length !== TOTP_DIGITS)
81
+ return false;
82
+ const now = Math.floor(Date.now() / 1000);
83
+ const currentStep = Math.floor(now / TOTP_PERIOD);
84
+ for (let offset = -TOTP_WINDOW;offset <= TOTP_WINDOW; offset++) {
85
+ const expected = await generateCode(secret, currentStep + offset);
86
+ if (timingSafeEqual(code, expected))
87
+ return true;
88
+ }
89
+ return false;
90
+ }
91
+ var BACKUP_CODE_COUNT = 10;
92
+ var BACKUP_CODE_LENGTH = 8;
93
+ var BACKUP_CODE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
94
+ function generateBackupCodes() {
95
+ const codes = [];
96
+ for (let i = 0;i < BACKUP_CODE_COUNT; i++) {
97
+ const bytes = new Uint8Array(BACKUP_CODE_LENGTH);
98
+ crypto.getRandomValues(bytes);
99
+ let code = "";
100
+ for (const byte of bytes) {
101
+ code += BACKUP_CODE_CHARS[byte % BACKUP_CODE_CHARS.length];
102
+ }
103
+ codes.push(code);
104
+ }
105
+ return codes;
106
+ }
107
+ async function hashBackupCode(code) {
108
+ const data = new TextEncoder().encode(code.toLowerCase());
109
+ const hash = await crypto.subtle.digest("SHA-256", data);
110
+ return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
111
+ }
112
+ function timingSafeEqual(a, b) {
113
+ if (a.length !== b.length)
114
+ return false;
115
+ let result = 0;
116
+ for (let i = 0;i < a.length; i++) {
117
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
118
+ }
119
+ return result === 0;
120
+ }
121
+ export {
122
+ verifyTotpCode,
123
+ hashBackupCode,
124
+ getTotpUri,
125
+ generateTotpSecret,
126
+ generateBackupCodes,
127
+ currentTotpCode,
128
+ base32Encode
129
+ };
package/dist/types.d.ts CHANGED
@@ -5,6 +5,8 @@ export interface User {
5
5
  passwordHash: string;
6
6
  isAdmin: boolean;
7
7
  emailVerified: boolean;
8
+ totpSecret: string | null;
9
+ totpEnabled: boolean;
8
10
  createdAt: string;
9
11
  updatedAt: string;
10
12
  }
package/dist/users.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { User, UserStore } from "./types.ts";
2
+ /** Validate email format. Rejects missing local/domain parts, whitespace, and emails over 254 chars. */
3
+ export declare function isValidEmail(email: string): boolean;
2
4
  export declare function createUser(store: UserStore, input: {
3
5
  email: string;
4
6
  name: string;
@@ -6,4 +8,5 @@ export declare function createUser(store: UserStore, input: {
6
8
  isAdmin?: boolean;
7
9
  }): Promise<User>;
8
10
  export declare function verifyUserCredentials(store: UserStore, email: string, password: string): Promise<User | null>;
11
+ export declare function updateName(store: UserStore, userId: string, name: string): Promise<void>;
9
12
  export declare function changePassword(store: UserStore, userId: string, newPassword: string): Promise<void>;
package/dist/users.js CHANGED
@@ -47,7 +47,13 @@ async function verifyPassword(password, storedHash) {
47
47
  }
48
48
 
49
49
  // src/users.ts
50
+ var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
51
+ function isValidEmail(email) {
52
+ return email.length <= 254 && EMAIL_REGEX.test(email);
53
+ }
50
54
  async function createUser(store, input) {
55
+ if (!isValidEmail(input.email))
56
+ throw new Error("Invalid email address");
51
57
  const existing = await store.getUserByEmail(input.email);
52
58
  if (existing)
53
59
  throw new Error(`User with email ${input.email} already exists`);
@@ -60,6 +66,8 @@ async function createUser(store, input) {
60
66
  passwordHash,
61
67
  isAdmin: input.isAdmin ?? false,
62
68
  emailVerified: input.isAdmin ?? false,
69
+ totpSecret: null,
70
+ totpEnabled: false,
63
71
  createdAt: now,
64
72
  updatedAt: now
65
73
  };
@@ -73,6 +81,17 @@ async function verifyUserCredentials(store, email, password) {
73
81
  const valid = await verifyPassword(password, user.passwordHash);
74
82
  return valid ? user : null;
75
83
  }
84
+ async function updateName(store, userId, name) {
85
+ const user = await store.getUser(userId);
86
+ if (!user)
87
+ throw new Error(`User ${userId} not found`);
88
+ const updated = {
89
+ ...user,
90
+ name,
91
+ updatedAt: new Date().toISOString()
92
+ };
93
+ await store.saveUser(updated);
94
+ }
76
95
  async function changePassword(store, userId, newPassword) {
77
96
  const user = await store.getUser(userId);
78
97
  if (!user)
@@ -86,6 +105,8 @@ async function changePassword(store, userId, newPassword) {
86
105
  }
87
106
  export {
88
107
  verifyUserCredentials,
108
+ updateName,
109
+ isValidEmail,
89
110
  createUser,
90
111
  changePassword
91
112
  };
package/package.json CHANGED
@@ -1,64 +1,68 @@
1
1
  {
2
- "name": "@uln/auth",
3
- "version": "0.2.0",
4
- "type": "module",
5
- "exports": {
6
- ".": {
7
- "bun": "./src/index.ts",
8
- "import": "./dist/index.js",
9
- "types": "./dist/index.d.ts"
10
- },
11
- "./client": {
12
- "bun": "./src/client.ts",
13
- "import": "./dist/client.js",
14
- "types": "./dist/client.d.ts"
15
- },
16
- "./service": {
17
- "bun": "./src/service.ts",
18
- "import": "./dist/service.js",
19
- "types": "./dist/service.d.ts"
20
- },
21
- "./hono": {
22
- "bun": "./src/hono.ts",
23
- "import": "./dist/hono.js",
24
- "types": "./dist/hono.d.ts"
25
- }
26
- },
27
- "files": [
28
- "dist",
29
- "src",
30
- "!src/**/*.test.ts"
31
- ],
32
- "scripts": {
33
- "dev": "bunx vite",
34
- "dev:worker": "bunx wrangler dev",
35
- "build": "bun run scripts/build.ts",
36
- "check": "biome check --write .",
37
- "test": "bun test",
38
- "deploy": "bunx wrangler deploy",
39
- "publish:check": "bun run scripts/publish.ts check",
40
- "publish:release": "bun run scripts/publish.ts release"
41
- },
42
- "devDependencies": {
43
- "@biomejs/biome": "^1.9.4",
44
- "@cloudflare/workers-types": "^4.20260317.1",
45
- "@types/bun": "latest",
46
- "@types/react": "^19.2.14",
47
- "@types/react-dom": "^19.2.3",
48
- "@vitejs/plugin-react": "^6.0.1",
49
- "hono": "^4.12.8",
50
- "react": "^19.2.4",
51
- "react-dom": "^19.2.4",
52
- "react-router": "^7.13.1",
53
- "vite": "^8.0.1"
54
- },
55
- "peerDependencies": {
56
- "hono": "^4",
57
- "typescript": "^5"
58
- },
59
- "peerDependenciesMeta": {
60
- "hono": {
61
- "optional": true
62
- }
63
- }
2
+ "name": "@uln/auth",
3
+ "version": "0.4.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "bun": "./src/index.ts",
8
+ "import": "./dist/index.js",
9
+ "types": "./dist/index.d.ts"
10
+ },
11
+ "./client": {
12
+ "bun": "./src/client.ts",
13
+ "import": "./dist/client.js",
14
+ "types": "./dist/client.d.ts"
15
+ },
16
+ "./service": {
17
+ "bun": "./src/service.ts",
18
+ "import": "./dist/service.js",
19
+ "types": "./dist/service.d.ts"
20
+ },
21
+ "./hono": {
22
+ "bun": "./src/hono.ts",
23
+ "import": "./dist/hono.js",
24
+ "types": "./dist/hono.d.ts"
25
+ }
26
+ },
27
+ "files": ["dist", "src", "!src/**/*.test.ts"],
28
+ "scripts": {
29
+ "dev": "bunx vite",
30
+ "dev:worker": "bunx wrangler dev",
31
+ "build": "bun run scripts/build.ts",
32
+ "check": "biome check --write .",
33
+ "test": "bun test",
34
+ "ci": "biome check . && bun test --coverage",
35
+ "migrate:local": "bunx wrangler d1 migrations apply uln-auth --local",
36
+ "migrate:remote": "bunx wrangler d1 migrations apply uln-auth --remote",
37
+ "deploy": "bun run migrate:remote && bunx wrangler deploy",
38
+ "publish:check": "bun run scripts/publish.ts check",
39
+ "publish:release": "bun run scripts/publish.ts release"
40
+ },
41
+ "devDependencies": {
42
+ "@biomejs/biome": "^1.9.4",
43
+ "@uln/ui": "^0.2.0",
44
+ "@cloudflare/workers-types": "^4.20260317.1",
45
+ "@types/bun": "latest",
46
+ "@types/qrcode": "^1.5.6",
47
+ "@types/react": "^19.2.14",
48
+ "@types/react-dom": "^19.2.3",
49
+ "@uln/log": "^0.3.0",
50
+ "@vitejs/plugin-react": "^6.0.1",
51
+ "hono": "^4.12.8",
52
+ "qrcode": "^1.5.4",
53
+ "react": "^19.2.4",
54
+ "react-dom": "^19.2.4",
55
+ "react-router": "^7.13.1",
56
+ "vite": "^8.0.1"
57
+ },
58
+ "peerDependencies": {
59
+ "hono": "^4",
60
+ "typescript": "^5"
61
+ },
62
+ "peerDependenciesMeta": {
63
+ "hono": {
64
+ "optional": true
65
+ }
66
+ },
67
+ "dependencies": {}
64
68
  }
package/src/client.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export type { TokenPayload, VerifyConfig, PublicKeyEntry, CookieConfig } from "./types.ts";
2
2
  export { validateToken, importPublicKey } from "./token.ts";
3
3
  export { makeSessionCookie, makeClearCookie, extractCookie } from "./cookie.ts";
4
- export { getAuthUser, redirectToLogin, getClientIP, checkBearerToken } from "./middleware.ts";
4
+ export { getAuthUser, redirectToLogin, handleCallback, getClientIP, checkBearerToken } from "./middleware.ts";
5
5
 
6
6
  import { importPublicKey } from "./token.ts";
7
7
  import type { PublicKeyEntry } from "./types.ts";
package/src/hono.ts CHANGED
@@ -28,6 +28,9 @@ interface AuthOptions {
28
28
 
29
29
  /** Cookie/token max age in seconds. Defaults to 86400 (24h). */
30
30
  maxAge?: number;
31
+
32
+ /** Redirect callback mode. "code" avoids putting signed tokens in URLs. Defaults to "code". */
33
+ responseType?: "code" | "token";
31
34
  }
32
35
 
33
36
  const DEFAULT_AUTH_URL = "https://auth.uln.industries";
@@ -107,6 +110,32 @@ async function validateWithBinding(token: string, binding: Fetcher): Promise<Tok
107
110
  };
108
111
  }
109
112
 
113
+ async function exchangeCodeWithPublicEndpoint(
114
+ code: string,
115
+ redirectOrigin: string,
116
+ authUrl: string,
117
+ ): Promise<string | null> {
118
+ const res = await fetch(`${authUrl}/api/exchange-code`, {
119
+ method: "POST",
120
+ headers: { "Content-Type": "application/json" },
121
+ body: JSON.stringify({ code, redirectOrigin }),
122
+ });
123
+ if (!res.ok) return null;
124
+ const data = (await res.json()) as { token?: string };
125
+ return typeof data.token === "string" ? data.token : null;
126
+ }
127
+
128
+ async function exchangeCodeWithBinding(code: string, redirectOrigin: string, binding: Fetcher): Promise<string | null> {
129
+ const res = await binding.fetch("https://auth/api/exchange-code", {
130
+ method: "POST",
131
+ headers: { "Content-Type": "application/json" },
132
+ body: JSON.stringify({ code, redirectOrigin }),
133
+ });
134
+ if (!res.ok) return null;
135
+ const data = (await res.json()) as { token?: string };
136
+ return typeof data.token === "string" ? data.token : null;
137
+ }
138
+
110
139
  /**
111
140
  * Hono middleware that handles the full auth flow:
112
141
  *
@@ -135,6 +164,7 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
135
164
  const authUrl = options?.authUrl ?? DEFAULT_AUTH_URL;
136
165
  const cookieName = options?.cookie ?? DEFAULT_COOKIE;
137
166
  const maxAge = options?.maxAge ?? DEFAULT_MAX_AGE;
167
+ const responseType = options?.responseType ?? "code";
138
168
  const publicPaths = options?.public ?? [];
139
169
  const bindingName = options?.binding;
140
170
 
@@ -149,6 +179,19 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
149
179
  return validateWithPublicKeys(token, authUrl, maxAge);
150
180
  }
151
181
 
182
+ async function exchangeCode(
183
+ code: string,
184
+ redirectOrigin: string,
185
+ env: Record<string, unknown>,
186
+ ): Promise<string | null> {
187
+ if (bindingName) {
188
+ const binding = env[bindingName] as Fetcher | undefined;
189
+ if (!binding) throw new Error(`Service binding "${bindingName}" not found in env`);
190
+ return exchangeCodeWithBinding(code, redirectOrigin, binding);
191
+ }
192
+ return exchangeCodeWithPublicEndpoint(code, redirectOrigin, authUrl);
193
+ }
194
+
152
195
  return async (c, next) => {
153
196
  const url = new URL(c.req.url);
154
197
 
@@ -157,7 +200,29 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
157
200
  return next();
158
201
  }
159
202
 
160
- // Step 1: Handle ?token= callback (login redirect landing)
203
+ // Step 1: Handle ?code= callback (preferred login redirect landing)
204
+ const incomingCode = url.searchParams.get("code");
205
+ if (incomingCode) {
206
+ const token = await exchangeCode(incomingCode, url.origin, c.env as Record<string, unknown>);
207
+ if (!token) {
208
+ return c.text("Invalid code", 401);
209
+ }
210
+ const payload = await validate(token, c.env as Record<string, unknown>);
211
+ if (!payload) {
212
+ return c.text("Invalid token", 401);
213
+ }
214
+
215
+ url.searchParams.delete("code");
216
+ return new Response(null, {
217
+ status: 302,
218
+ headers: {
219
+ Location: url.toString(),
220
+ "Set-Cookie": makeSessionCookie(cookieConf, token),
221
+ },
222
+ });
223
+ }
224
+
225
+ // Step 2: Handle legacy ?token= callback
161
226
  const incomingToken = url.searchParams.get("token");
162
227
  if (incomingToken) {
163
228
  const payload = await validate(incomingToken, c.env as Record<string, unknown>);
@@ -176,7 +241,7 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
176
241
  });
177
242
  }
178
243
 
179
- // Step 2: Check session cookie
244
+ // Step 3: Check session cookie
180
245
  const cookieHeader = c.req.header("Cookie");
181
246
  if (cookieHeader) {
182
247
  const token = extractCookie(cookieHeader, cookieName);
@@ -189,7 +254,7 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
189
254
  }
190
255
  }
191
256
 
192
- // Step 3: Check Authorization header
257
+ // Step 4: Check Authorization header
193
258
  const authHeader = c.req.header("Authorization");
194
259
  if (authHeader?.startsWith("Bearer ")) {
195
260
  const token = authHeader.substring(7);
@@ -201,9 +266,11 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
201
266
  return c.json({ error: "Invalid token" }, 401);
202
267
  }
203
268
 
204
- // Step 4: Redirect to login
205
- const loginUrl = `${authUrl}/login?redirect=${encodeURIComponent(c.req.url)}`;
206
- return c.redirect(loginUrl, 302);
269
+ // Step 5: Redirect to login
270
+ const loginUrl = new URL("/login", authUrl);
271
+ loginUrl.searchParams.set("redirect", c.req.url);
272
+ if (responseType === "code") loginUrl.searchParams.set("response_type", "code");
273
+ return c.redirect(loginUrl.toString(), 302);
207
274
  };
208
275
  }
209
276
 
package/src/index.ts CHANGED
@@ -18,4 +18,12 @@ export { makeSessionCookie, makeClearCookie, extractCookie } from "./cookie.ts";
18
18
  export { checkRateLimit, recordFailedAttempt, clearRateLimit } from "./rate-limit.ts";
19
19
  export type { RateLimitResult } from "./rate-limit.ts";
20
20
  export { getClientIP, checkBearerToken, getAuthUser, redirectToLogin } from "./middleware.ts";
21
- export { createUser, verifyUserCredentials, changePassword } from "./users.ts";
21
+ export { createUser, verifyUserCredentials, changePassword, isValidEmail } from "./users.ts";
22
+ export {
23
+ generateTotpSecret,
24
+ getTotpUri,
25
+ verifyTotpCode,
26
+ base32Encode,
27
+ generateBackupCodes,
28
+ hashBackupCode,
29
+ } from "./totp.ts";
package/src/middleware.ts CHANGED
@@ -1,7 +1,11 @@
1
- import { extractCookie } from "./cookie.ts";
1
+ import { extractCookie, makeSessionCookie } from "./cookie.ts";
2
2
  import { validateToken } from "./token.ts";
3
3
  import type { CookieConfig, TokenPayload, VerifyConfig } from "./types.ts";
4
4
 
5
+ export interface AuthCodeExchangeConfig {
6
+ authUrl: string;
7
+ }
8
+
5
9
  export function getClientIP(request: Request): string {
6
10
  return (
7
11
  request.headers.get("cf-connecting-ip") ??
@@ -29,7 +33,75 @@ export async function getAuthUser(
29
33
  return validateToken(verifyConfig, token);
30
34
  }
31
35
 
32
- export function redirectToLogin(currentUrl: string): Response {
33
- const loginUrl = `https://auth.uln.industries/login?redirect=${encodeURIComponent(currentUrl)}`;
34
- return Response.redirect(loginUrl, 302);
36
+ export function redirectToLogin(currentUrl: string, responseType: "code" | "token" = "code"): Response {
37
+ const loginUrl = new URL("/login", "https://auth.uln.industries");
38
+ loginUrl.searchParams.set("redirect", currentUrl);
39
+ if (responseType === "code") loginUrl.searchParams.set("response_type", "code");
40
+ return Response.redirect(loginUrl.toString(), 302);
41
+ }
42
+
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;
56
+ }
57
+
58
+ /**
59
+ * Handle the auth callback on consuming sites.
60
+ * If the request URL has a `?code=` or legacy `?token=` param, sets a session cookie,
61
+ * and redirects to the same URL without the token param.
62
+ * Returns null if there's no callback param.
63
+ */
64
+ export async function handleCallback(
65
+ request: Request,
66
+ verifyConfig: VerifyConfig,
67
+ cookieConfig: CookieConfig,
68
+ exchangeConfig?: AuthCodeExchangeConfig,
69
+ ): 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, "code");
77
+
78
+ const payload = await validateToken(verifyConfig, token);
79
+ if (!payload) return redirectToLogin(url.origin, "code");
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
+ const token = url.searchParams.get("token");
92
+ if (!token) return null;
93
+
94
+ const payload = await validateToken(verifyConfig, token);
95
+ if (!payload) {
96
+ return redirectToLogin(url.origin);
97
+ }
98
+
99
+ url.searchParams.delete("token");
100
+ return new Response(null, {
101
+ status: 302,
102
+ headers: {
103
+ Location: url.toString(),
104
+ "Set-Cookie": makeSessionCookie(cookieConfig, token),
105
+ },
106
+ });
35
107
  }
package/src/password.ts CHANGED
@@ -31,12 +31,14 @@ async function deriveKey(password: string, salt: Uint8Array<ArrayBuffer>): Promi
31
31
  );
32
32
  }
33
33
 
34
+ /** Hash a password with PBKDF2-SHA256 (100k iterations, 32-byte salt, 64-byte key). Returns `hex(salt):hex(key)`. */
34
35
  export async function hashPassword(password: string): Promise<string> {
35
36
  const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
36
37
  const derived = await deriveKey(password, salt);
37
38
  return `${toHex(salt.buffer)}:${toHex(derived)}`;
38
39
  }
39
40
 
41
+ /** Verify a password against a stored `hex(salt):hex(key)` hash. Uses constant-time comparison. */
40
42
  export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
41
43
  const separatorIndex = storedHash.indexOf(":");
42
44
  if (separatorIndex === -1) return false;