@uln/auth 0.3.0 → 0.4.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/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.3.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 d1 migrations apply uln-auth --remote && 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.1",
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,6 +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 type { AuthCodeExchangeConfig } from "./middleware.ts";
4
5
  export { getAuthUser, redirectToLogin, handleCallback, getClientIP, checkBearerToken } from "./middleware.ts";
5
6
 
6
7
  import { importPublicKey } from "./token.ts";
package/src/hono.ts CHANGED
@@ -107,10 +107,36 @@ async function validateWithBinding(token: string, binding: Fetcher): Promise<Tok
107
107
  };
108
108
  }
109
109
 
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;
123
+ }
124
+
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;
134
+ }
135
+
110
136
  /**
111
137
  * Hono middleware that handles the full auth flow:
112
138
  *
113
- * 1. If `?token=` in URL → validate, set cookie, redirect to clean URL
139
+ * 1. If `?code=` in URL → exchange for token, set cookie, redirect to clean URL
114
140
  * 2. If session cookie present → validate, set `c.get("user")`
115
141
  * 3. If unauthenticated → redirect to auth.uln.industries/login
116
142
  *
@@ -149,6 +175,19 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
149
175
  return validateWithPublicKeys(token, authUrl, maxAge);
150
176
  }
151
177
 
178
+ async function exchangeCode(
179
+ code: string,
180
+ redirectOrigin: string,
181
+ env: Record<string, unknown>,
182
+ ): Promise<string | null> {
183
+ 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);
187
+ }
188
+ return exchangeCodeWithPublicEndpoint(code, redirectOrigin, authUrl);
189
+ }
190
+
152
191
  return async (c, next) => {
153
192
  const url = new URL(c.req.url);
154
193
 
@@ -157,21 +196,24 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
157
196
  return next();
158
197
  }
159
198
 
160
- // Step 1: Handle ?token= callback (login redirect landing)
161
- const incomingToken = url.searchParams.get("token");
162
- if (incomingToken) {
163
- const payload = await validate(incomingToken, c.env as Record<string, unknown>);
199
+ // 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>);
164
207
  if (!payload) {
165
208
  return c.text("Invalid token", 401);
166
209
  }
167
210
 
168
- // Set cookie and redirect to clean URL (strip token param)
169
- url.searchParams.delete("token");
211
+ url.searchParams.delete("code");
170
212
  return new Response(null, {
171
213
  status: 302,
172
214
  headers: {
173
215
  Location: url.toString(),
174
- "Set-Cookie": makeSessionCookie(cookieConf, incomingToken),
216
+ "Set-Cookie": makeSessionCookie(cookieConf, token),
175
217
  },
176
218
  });
177
219
  }
@@ -189,7 +231,7 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
189
231
  }
190
232
  }
191
233
 
192
- // Step 3: Check Authorization header
234
+ // Step 4: Check Authorization header
193
235
  const authHeader = c.req.header("Authorization");
194
236
  if (authHeader?.startsWith("Bearer ")) {
195
237
  const token = authHeader.substring(7);
@@ -201,9 +243,11 @@ export function auth(options?: AuthOptions): MiddlewareHandler {
201
243
  return c.json({ error: "Invalid token" }, 401);
202
244
  }
203
245
 
204
- // Step 4: Redirect to login
205
- const loginUrl = `${authUrl}/login?redirect=${encodeURIComponent(c.req.url)}`;
206
- return c.redirect(loginUrl, 302);
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);
207
251
  };
208
252
  }
209
253
 
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
@@ -2,6 +2,10 @@ 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") ??
@@ -30,36 +34,59 @@ export async function getAuthUser(
30
34
  }
31
35
 
32
36
  export function redirectToLogin(currentUrl: string): Response {
33
- const loginUrl = `https://auth.uln.industries/login?redirect=${encodeURIComponent(currentUrl)}`;
34
- return Response.redirect(loginUrl, 302);
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
+ }
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;
35
56
  }
36
57
 
37
58
  /**
38
59
  * Handle the auth callback on consuming sites.
39
- * If the request URL has a `?token=` param, validates it, sets a session cookie,
40
- * and redirects to the same URL without the token param.
41
- * Returns null if there's no token param (not a callback request).
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.
42
63
  */
43
64
  export async function handleCallback(
44
65
  request: Request,
45
66
  verifyConfig: VerifyConfig,
46
67
  cookieConfig: CookieConfig,
68
+ exchangeConfig?: AuthCodeExchangeConfig,
47
69
  ): Promise<Response | null> {
48
70
  const url = new URL(request.url);
49
- const token = url.searchParams.get("token");
50
- if (!token) return null;
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);
51
77
 
52
- const payload = await validateToken(verifyConfig, token);
53
- if (!payload) {
54
- return redirectToLogin(url.origin);
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
+ });
55
89
  }
56
90
 
57
- url.searchParams.delete("token");
58
- return new Response(null, {
59
- status: 302,
60
- headers: {
61
- Location: url.toString(),
62
- "Set-Cookie": makeSessionCookie(cookieConfig, token),
63
- },
64
- });
91
+ return null;
65
92
  }
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;
package/src/service.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { extractCookie } from "./cookie.ts";
1
+ import { extractCookie, makeSessionCookie } from "./cookie.ts";
2
2
  import type { CookieConfig, TokenPayload } from "./types.ts";
3
3
  export { makeSessionCookie, makeClearCookie } from "./cookie.ts";
4
4
  export type { TokenPayload, CookieConfig } from "./types.ts";
@@ -58,8 +58,55 @@ export async function getAuthUser(
58
58
 
59
59
  /**
60
60
  * Redirect to the centralized login page at auth.uln.industries.
61
+ * Requests one-time auth-code callbacks so signed tokens are not sent through URLs.
61
62
  */
62
63
  export function redirectToLogin(currentUrl: string): Response {
63
- const loginUrl = `https://auth.uln.industries/login?redirect=${encodeURIComponent(currentUrl)}`;
64
- return Response.redirect(loginUrl, 302);
64
+ const loginUrl = new URL("/login", "https://auth.uln.industries");
65
+ loginUrl.searchParams.set("redirect", currentUrl);
66
+ loginUrl.searchParams.set("response_type", "code");
67
+ return Response.redirect(loginUrl.toString(), 302);
68
+ }
69
+
70
+ async function exchangeCode(authService: Fetcher, code: string, redirectOrigin: string): Promise<string | null> {
71
+ const res = await authService.fetch("https://auth/api/exchange-code", {
72
+ method: "POST",
73
+ headers: { "Content-Type": "application/json" },
74
+ body: JSON.stringify({ code, redirectOrigin }),
75
+ });
76
+ if (!res.ok) return null;
77
+ const data = (await res.json()) as { token?: string };
78
+ return typeof data.token === "string" ? data.token : null;
79
+ }
80
+
81
+ /**
82
+ * Handle an auth callback on a consuming Worker using the auth service binding.
83
+ * Preferred `?code=` callbacks are exchanged for signed tokens before setting the local cookie.
84
+ * Returns null if the request has no auth callback parameters.
85
+ */
86
+ export async function handleCallback(
87
+ request: Request,
88
+ authService: Fetcher,
89
+ cookieConfig: CookieConfig,
90
+ ): Promise<Response | null> {
91
+ const url = new URL(request.url);
92
+
93
+ const code = url.searchParams.get("code");
94
+ if (code) {
95
+ const token = await exchangeCode(authService, code, url.origin);
96
+ if (!token) return redirectToLogin(url.origin);
97
+
98
+ const payload = await verifyToken(authService, token);
99
+ if (!payload) return redirectToLogin(url.origin);
100
+
101
+ url.searchParams.delete("code");
102
+ return new Response(null, {
103
+ status: 302,
104
+ headers: {
105
+ Location: url.toString(),
106
+ "Set-Cookie": makeSessionCookie(cookieConfig, token),
107
+ },
108
+ });
109
+ }
110
+
111
+ return null;
65
112
  }