@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/client.d.ts CHANGED
@@ -1,6 +1,6 @@
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
  import type { PublicKeyEntry } from "./types.ts";
6
6
  export declare function fetchPublicKeys(baseUrl: string): Promise<PublicKeyEntry[]>;
package/dist/client.js CHANGED
@@ -130,9 +130,60 @@ async function getAuthUser(request, verifyConfig, cookieConfig) {
130
130
  return null;
131
131
  return validateToken(verifyConfig, token);
132
132
  }
133
- function redirectToLogin(currentUrl) {
134
- const loginUrl = `https://auth.uln.industries/login?redirect=${encodeURIComponent(currentUrl)}`;
135
- return Response.redirect(loginUrl, 302);
133
+ function redirectToLogin(currentUrl, responseType = "code") {
134
+ const loginUrl = new URL("/login", "https://auth.uln.industries");
135
+ loginUrl.searchParams.set("redirect", currentUrl);
136
+ if (responseType === "code")
137
+ loginUrl.searchParams.set("response_type", "code");
138
+ return Response.redirect(loginUrl.toString(), 302);
139
+ }
140
+ async function exchangeCode(code, redirectOrigin, config) {
141
+ const res = await fetch(`${config.authUrl}/api/exchange-code`, {
142
+ method: "POST",
143
+ headers: { "Content-Type": "application/json" },
144
+ body: JSON.stringify({ code, redirectOrigin })
145
+ });
146
+ if (!res.ok)
147
+ return null;
148
+ const data = await res.json();
149
+ return typeof data.token === "string" ? data.token : null;
150
+ }
151
+ async function handleCallback(request, verifyConfig, cookieConfig, exchangeConfig) {
152
+ const url = new URL(request.url);
153
+ const code = url.searchParams.get("code");
154
+ if (code) {
155
+ if (!exchangeConfig)
156
+ return redirectToLogin(url.origin);
157
+ const token2 = await exchangeCode(code, url.origin, exchangeConfig);
158
+ if (!token2)
159
+ return redirectToLogin(url.origin, "code");
160
+ const payload2 = await validateToken(verifyConfig, token2);
161
+ if (!payload2)
162
+ return redirectToLogin(url.origin, "code");
163
+ url.searchParams.delete("code");
164
+ return new Response(null, {
165
+ status: 302,
166
+ headers: {
167
+ Location: url.toString(),
168
+ "Set-Cookie": makeSessionCookie(cookieConfig, token2)
169
+ }
170
+ });
171
+ }
172
+ const token = url.searchParams.get("token");
173
+ if (!token)
174
+ return null;
175
+ const payload = await validateToken(verifyConfig, token);
176
+ if (!payload) {
177
+ return redirectToLogin(url.origin);
178
+ }
179
+ url.searchParams.delete("token");
180
+ return new Response(null, {
181
+ status: 302,
182
+ headers: {
183
+ Location: url.toString(),
184
+ "Set-Cookie": makeSessionCookie(cookieConfig, token)
185
+ }
186
+ });
136
187
  }
137
188
  // src/client.ts
138
189
  async function fetchPublicKeys(baseUrl) {
@@ -151,6 +202,7 @@ export {
151
202
  makeSessionCookie,
152
203
  makeClearCookie,
153
204
  importPublicKey,
205
+ handleCallback,
154
206
  getClientIP,
155
207
  getAuthUser,
156
208
  fetchPublicKeys,
package/dist/hono.d.ts CHANGED
@@ -19,6 +19,8 @@ interface AuthOptions {
19
19
  cookie?: string;
20
20
  /** Cookie/token max age in seconds. Defaults to 86400 (24h). */
21
21
  maxAge?: number;
22
+ /** Redirect callback mode. "code" avoids putting signed tokens in URLs. Defaults to "code". */
23
+ responseType?: "code" | "token";
22
24
  }
23
25
  /**
24
26
  * Hono middleware that handles the full auth flow:
package/dist/hono.js CHANGED
@@ -172,10 +172,33 @@ async function validateWithBinding(token, binding) {
172
172
  exp: 0
173
173
  };
174
174
  }
175
+ async function exchangeCodeWithPublicEndpoint(code, redirectOrigin, authUrl) {
176
+ const res = await fetch(`${authUrl}/api/exchange-code`, {
177
+ method: "POST",
178
+ headers: { "Content-Type": "application/json" },
179
+ body: JSON.stringify({ code, redirectOrigin })
180
+ });
181
+ if (!res.ok)
182
+ return null;
183
+ const data = await res.json();
184
+ return typeof data.token === "string" ? data.token : null;
185
+ }
186
+ async function exchangeCodeWithBinding(code, redirectOrigin, binding) {
187
+ const res = await binding.fetch("https://auth/api/exchange-code", {
188
+ method: "POST",
189
+ headers: { "Content-Type": "application/json" },
190
+ body: JSON.stringify({ code, redirectOrigin })
191
+ });
192
+ if (!res.ok)
193
+ return null;
194
+ const data = await res.json();
195
+ return typeof data.token === "string" ? data.token : null;
196
+ }
175
197
  function auth(options) {
176
198
  const authUrl = options?.authUrl ?? DEFAULT_AUTH_URL;
177
199
  const cookieName = options?.cookie ?? DEFAULT_COOKIE;
178
200
  const maxAge = options?.maxAge ?? DEFAULT_MAX_AGE;
201
+ const responseType = options?.responseType ?? "code";
179
202
  const publicPaths = options?.public ?? [];
180
203
  const bindingName = options?.binding;
181
204
  const cookieConf = { name: cookieName, maxAgeSeconds: maxAge, secure: true };
@@ -188,11 +211,39 @@ function auth(options) {
188
211
  }
189
212
  return validateWithPublicKeys(token, authUrl, maxAge);
190
213
  }
214
+ async function exchangeCode(code, redirectOrigin, env) {
215
+ if (bindingName) {
216
+ const binding = env[bindingName];
217
+ if (!binding)
218
+ throw new Error(`Service binding "${bindingName}" not found in env`);
219
+ return exchangeCodeWithBinding(code, redirectOrigin, binding);
220
+ }
221
+ return exchangeCodeWithPublicEndpoint(code, redirectOrigin, authUrl);
222
+ }
191
223
  return async (c, next) => {
192
224
  const url = new URL(c.req.url);
193
225
  if (isPublicPath(url.pathname, publicPaths)) {
194
226
  return next();
195
227
  }
228
+ const incomingCode = url.searchParams.get("code");
229
+ if (incomingCode) {
230
+ const token = await exchangeCode(incomingCode, url.origin, c.env);
231
+ if (!token) {
232
+ return c.text("Invalid code", 401);
233
+ }
234
+ const payload = await validate(token, c.env);
235
+ if (!payload) {
236
+ return c.text("Invalid token", 401);
237
+ }
238
+ url.searchParams.delete("code");
239
+ return new Response(null, {
240
+ status: 302,
241
+ headers: {
242
+ Location: url.toString(),
243
+ "Set-Cookie": makeSessionCookie(cookieConf, token)
244
+ }
245
+ });
246
+ }
196
247
  const incomingToken = url.searchParams.get("token");
197
248
  if (incomingToken) {
198
249
  const payload = await validate(incomingToken, c.env);
@@ -229,8 +280,11 @@ function auth(options) {
229
280
  }
230
281
  return c.json({ error: "Invalid token" }, 401);
231
282
  }
232
- const loginUrl = `${authUrl}/login?redirect=${encodeURIComponent(c.req.url)}`;
233
- return c.redirect(loginUrl, 302);
283
+ const loginUrl = new URL("/login", authUrl);
284
+ loginUrl.searchParams.set("redirect", c.req.url);
285
+ if (responseType === "code")
286
+ loginUrl.searchParams.set("response_type", "code");
287
+ return c.redirect(loginUrl.toString(), 302);
234
288
  };
235
289
  }
236
290
  function getUser(c) {
package/dist/index.d.ts CHANGED
@@ -6,4 +6,5 @@ export { makeSessionCookie, makeClearCookie, extractCookie } from "./cookie.ts";
6
6
  export { checkRateLimit, recordFailedAttempt, clearRateLimit } from "./rate-limit.ts";
7
7
  export type { RateLimitResult } from "./rate-limit.ts";
8
8
  export { getClientIP, checkBearerToken, getAuthUser, redirectToLogin } from "./middleware.ts";
9
- export { createUser, verifyUserCredentials, changePassword } from "./users.ts";
9
+ export { createUser, verifyUserCredentials, changePassword, isValidEmail } from "./users.ts";
10
+ export { generateTotpSecret, getTotpUri, verifyTotpCode, base32Encode, generateBackupCodes, hashBackupCode, } from "./totp.ts";
package/dist/index.js CHANGED
@@ -130,9 +130,60 @@ async function getAuthUser(request, verifyConfig, cookieConfig) {
130
130
  return null;
131
131
  return validateToken(verifyConfig, token);
132
132
  }
133
- function redirectToLogin(currentUrl) {
134
- const loginUrl = `https://auth.uln.industries/login?redirect=${encodeURIComponent(currentUrl)}`;
135
- return Response.redirect(loginUrl, 302);
133
+ function redirectToLogin(currentUrl, responseType = "code") {
134
+ const loginUrl = new URL("/login", "https://auth.uln.industries");
135
+ loginUrl.searchParams.set("redirect", currentUrl);
136
+ if (responseType === "code")
137
+ loginUrl.searchParams.set("response_type", "code");
138
+ return Response.redirect(loginUrl.toString(), 302);
139
+ }
140
+ async function exchangeCode(code, redirectOrigin, config) {
141
+ const res = await fetch(`${config.authUrl}/api/exchange-code`, {
142
+ method: "POST",
143
+ headers: { "Content-Type": "application/json" },
144
+ body: JSON.stringify({ code, redirectOrigin })
145
+ });
146
+ if (!res.ok)
147
+ return null;
148
+ const data = await res.json();
149
+ return typeof data.token === "string" ? data.token : null;
150
+ }
151
+ async function handleCallback(request, verifyConfig, cookieConfig, exchangeConfig) {
152
+ const url = new URL(request.url);
153
+ const code = url.searchParams.get("code");
154
+ if (code) {
155
+ if (!exchangeConfig)
156
+ return redirectToLogin(url.origin);
157
+ const token2 = await exchangeCode(code, url.origin, exchangeConfig);
158
+ if (!token2)
159
+ return redirectToLogin(url.origin, "code");
160
+ const payload2 = await validateToken(verifyConfig, token2);
161
+ if (!payload2)
162
+ return redirectToLogin(url.origin, "code");
163
+ url.searchParams.delete("code");
164
+ return new Response(null, {
165
+ status: 302,
166
+ headers: {
167
+ Location: url.toString(),
168
+ "Set-Cookie": makeSessionCookie(cookieConfig, token2)
169
+ }
170
+ });
171
+ }
172
+ const token = url.searchParams.get("token");
173
+ if (!token)
174
+ return null;
175
+ const payload = await validateToken(verifyConfig, token);
176
+ if (!payload) {
177
+ return redirectToLogin(url.origin);
178
+ }
179
+ url.searchParams.delete("token");
180
+ return new Response(null, {
181
+ status: 302,
182
+ headers: {
183
+ Location: url.toString(),
184
+ "Set-Cookie": makeSessionCookie(cookieConfig, token)
185
+ }
186
+ });
136
187
  }
137
188
 
138
189
  // src/password.ts
@@ -183,7 +234,13 @@ async function verifyPassword(password, storedHash) {
183
234
  }
184
235
 
185
236
  // src/users.ts
237
+ var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
238
+ function isValidEmail(email) {
239
+ return email.length <= 254 && EMAIL_REGEX.test(email);
240
+ }
186
241
  async function createUser(store, input) {
242
+ if (!isValidEmail(input.email))
243
+ throw new Error("Invalid email address");
187
244
  const existing = await store.getUserByEmail(input.email);
188
245
  if (existing)
189
246
  throw new Error(`User with email ${input.email} already exists`);
@@ -196,6 +253,8 @@ async function createUser(store, input) {
196
253
  passwordHash,
197
254
  isAdmin: input.isAdmin ?? false,
198
255
  emailVerified: input.isAdmin ?? false,
256
+ totpSecret: null,
257
+ totpEnabled: false,
199
258
  createdAt: now,
200
259
  updatedAt: now
201
260
  };
@@ -209,6 +268,17 @@ async function verifyUserCredentials(store, email, password) {
209
268
  const valid = await verifyPassword(password, user.passwordHash);
210
269
  return valid ? user : null;
211
270
  }
271
+ async function updateName(store, userId, name) {
272
+ const user = await store.getUser(userId);
273
+ if (!user)
274
+ throw new Error(`User ${userId} not found`);
275
+ const updated = {
276
+ ...user,
277
+ name,
278
+ updatedAt: new Date().toISOString()
279
+ };
280
+ await store.saveUser(updated);
281
+ }
212
282
  async function changePassword(store, userId, newPassword) {
213
283
  const user = await store.getUser(userId);
214
284
  if (!user)
@@ -247,25 +317,152 @@ async function recordFailedAttempt(store, key, config) {
247
317
  async function clearRateLimit(store, key) {
248
318
  await store.delete(key);
249
319
  }
320
+
321
+ // src/totp.ts
322
+ var TOTP_PERIOD = 30;
323
+ var TOTP_DIGITS = 6;
324
+ var TOTP_WINDOW = 1;
325
+ var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
326
+ function base32Encode(data) {
327
+ let bits = 0;
328
+ let value = 0;
329
+ let output = "";
330
+ for (const byte of data) {
331
+ value = value << 8 | byte;
332
+ bits += 8;
333
+ while (bits >= 5) {
334
+ bits -= 5;
335
+ output += BASE32_ALPHABET[value >>> bits & 31];
336
+ }
337
+ }
338
+ if (bits > 0) {
339
+ output += BASE32_ALPHABET[value << 5 - bits & 31];
340
+ }
341
+ return output;
342
+ }
343
+ function base32Decode(encoded) {
344
+ const cleaned = encoded.replace(/[=\s]/g, "").toUpperCase();
345
+ const output = [];
346
+ let bits = 0;
347
+ let value = 0;
348
+ for (const char of cleaned) {
349
+ const idx = BASE32_ALPHABET.indexOf(char);
350
+ if (idx === -1)
351
+ throw new Error(`Invalid base32 character: ${char}`);
352
+ value = value << 5 | idx;
353
+ bits += 5;
354
+ if (bits >= 8) {
355
+ bits -= 8;
356
+ output.push(value >>> bits & 255);
357
+ }
358
+ }
359
+ return new Uint8Array(output);
360
+ }
361
+ function generateTotpSecret() {
362
+ const bytes = new Uint8Array(20);
363
+ crypto.getRandomValues(bytes);
364
+ return base32Encode(bytes);
365
+ }
366
+ function getTotpUri(secret, email, issuer) {
367
+ const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(email)}`;
368
+ const params = new URLSearchParams({
369
+ secret,
370
+ issuer,
371
+ algorithm: "SHA1",
372
+ digits: String(TOTP_DIGITS),
373
+ period: String(TOTP_PERIOD)
374
+ });
375
+ return `otpauth://totp/${label}?${params.toString()}`;
376
+ }
377
+ async function generateCode(secret, counter) {
378
+ const keyBytes = base32Decode(secret);
379
+ const keyBuffer = new ArrayBuffer(keyBytes.byteLength);
380
+ new Uint8Array(keyBuffer).set(keyBytes);
381
+ const key = await crypto.subtle.importKey("raw", keyBuffer, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
382
+ const counterBuf = new ArrayBuffer(8);
383
+ const view = new DataView(counterBuf);
384
+ view.setUint32(4, counter);
385
+ const hmac = new Uint8Array(await crypto.subtle.sign("HMAC", key, counterBuf));
386
+ const offset = (hmac[hmac.length - 1] ?? 0) & 15;
387
+ const b0 = hmac[offset] ?? 0;
388
+ const b1 = hmac[offset + 1] ?? 0;
389
+ const b2 = hmac[offset + 2] ?? 0;
390
+ const b3 = hmac[offset + 3] ?? 0;
391
+ const code = (b0 & 127) << 24 | (b1 & 255) << 16 | (b2 & 255) << 8 | b3 & 255;
392
+ return String(code % 10 ** TOTP_DIGITS).padStart(TOTP_DIGITS, "0");
393
+ }
394
+ async function currentTotpCode(secret, nowMs = Date.now()) {
395
+ const step = Math.floor(nowMs / 1000 / TOTP_PERIOD);
396
+ return generateCode(secret, step);
397
+ }
398
+ async function verifyTotpCode(secret, code) {
399
+ if (code.length !== TOTP_DIGITS)
400
+ return false;
401
+ const now = Math.floor(Date.now() / 1000);
402
+ const currentStep = Math.floor(now / TOTP_PERIOD);
403
+ for (let offset = -TOTP_WINDOW;offset <= TOTP_WINDOW; offset++) {
404
+ const expected = await generateCode(secret, currentStep + offset);
405
+ if (timingSafeEqual(code, expected))
406
+ return true;
407
+ }
408
+ return false;
409
+ }
410
+ var BACKUP_CODE_COUNT = 10;
411
+ var BACKUP_CODE_LENGTH = 8;
412
+ var BACKUP_CODE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
413
+ function generateBackupCodes() {
414
+ const codes = [];
415
+ for (let i = 0;i < BACKUP_CODE_COUNT; i++) {
416
+ const bytes = new Uint8Array(BACKUP_CODE_LENGTH);
417
+ crypto.getRandomValues(bytes);
418
+ let code = "";
419
+ for (const byte of bytes) {
420
+ code += BACKUP_CODE_CHARS[byte % BACKUP_CODE_CHARS.length];
421
+ }
422
+ codes.push(code);
423
+ }
424
+ return codes;
425
+ }
426
+ async function hashBackupCode(code) {
427
+ const data = new TextEncoder().encode(code.toLowerCase());
428
+ const hash = await crypto.subtle.digest("SHA-256", data);
429
+ return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
430
+ }
431
+ function timingSafeEqual(a, b) {
432
+ if (a.length !== b.length)
433
+ return false;
434
+ let result = 0;
435
+ for (let i = 0;i < a.length; i++) {
436
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
437
+ }
438
+ return result === 0;
439
+ }
250
440
  export {
251
441
  verifyUserCredentials,
442
+ verifyTotpCode,
252
443
  verifyPassword,
253
444
  validateToken,
254
445
  redirectToLogin,
255
446
  recordFailedAttempt,
256
447
  makeSessionCookie,
257
448
  makeClearCookie,
449
+ isValidEmail,
258
450
  importPublicKey,
259
451
  importPrivateKey,
260
452
  hashPassword,
453
+ hashBackupCode,
454
+ getTotpUri,
261
455
  getClientIP,
262
456
  getAuthUser,
457
+ generateTotpSecret,
263
458
  generateKeyPair,
459
+ generateBackupCodes,
264
460
  extractCookie,
265
461
  createUser,
266
462
  createToken,
267
463
  clearRateLimit,
268
464
  checkRateLimit,
269
465
  checkBearerToken,
270
- changePassword
466
+ changePassword,
467
+ base32Encode
271
468
  };
@@ -1,5 +1,15 @@
1
1
  import type { CookieConfig, TokenPayload, VerifyConfig } from "./types.ts";
2
+ export interface AuthCodeExchangeConfig {
3
+ authUrl: string;
4
+ }
2
5
  export declare function getClientIP(request: Request): string;
3
6
  export declare function checkBearerToken(request: Request, expectedToken: string): boolean;
4
7
  export declare function getAuthUser(request: Request, verifyConfig: VerifyConfig, cookieConfig: CookieConfig): Promise<TokenPayload | null>;
5
- export declare function redirectToLogin(currentUrl: string): Response;
8
+ export declare function redirectToLogin(currentUrl: string, responseType?: "code" | "token"): Response;
9
+ /**
10
+ * Handle the auth callback on consuming sites.
11
+ * If the request URL has a `?code=` or legacy `?token=` param, sets a session cookie,
12
+ * and redirects to the same URL without the token param.
13
+ * Returns null if there's no callback param.
14
+ */
15
+ export declare function handleCallback(request: Request, verifyConfig: VerifyConfig, cookieConfig: CookieConfig, exchangeConfig?: AuthCodeExchangeConfig): Promise<Response | null>;
@@ -130,12 +130,64 @@ async function getAuthUser(request, verifyConfig, cookieConfig) {
130
130
  return null;
131
131
  return validateToken(verifyConfig, token);
132
132
  }
133
- function redirectToLogin(currentUrl) {
134
- const loginUrl = `https://auth.uln.industries/login?redirect=${encodeURIComponent(currentUrl)}`;
135
- return Response.redirect(loginUrl, 302);
133
+ function redirectToLogin(currentUrl, responseType = "code") {
134
+ const loginUrl = new URL("/login", "https://auth.uln.industries");
135
+ loginUrl.searchParams.set("redirect", currentUrl);
136
+ if (responseType === "code")
137
+ loginUrl.searchParams.set("response_type", "code");
138
+ return Response.redirect(loginUrl.toString(), 302);
139
+ }
140
+ async function exchangeCode(code, redirectOrigin, config) {
141
+ const res = await fetch(`${config.authUrl}/api/exchange-code`, {
142
+ method: "POST",
143
+ headers: { "Content-Type": "application/json" },
144
+ body: JSON.stringify({ code, redirectOrigin })
145
+ });
146
+ if (!res.ok)
147
+ return null;
148
+ const data = await res.json();
149
+ return typeof data.token === "string" ? data.token : null;
150
+ }
151
+ async function handleCallback(request, verifyConfig, cookieConfig, exchangeConfig) {
152
+ const url = new URL(request.url);
153
+ const code = url.searchParams.get("code");
154
+ if (code) {
155
+ if (!exchangeConfig)
156
+ return redirectToLogin(url.origin);
157
+ const token2 = await exchangeCode(code, url.origin, exchangeConfig);
158
+ if (!token2)
159
+ return redirectToLogin(url.origin, "code");
160
+ const payload2 = await validateToken(verifyConfig, token2);
161
+ if (!payload2)
162
+ return redirectToLogin(url.origin, "code");
163
+ url.searchParams.delete("code");
164
+ return new Response(null, {
165
+ status: 302,
166
+ headers: {
167
+ Location: url.toString(),
168
+ "Set-Cookie": makeSessionCookie(cookieConfig, token2)
169
+ }
170
+ });
171
+ }
172
+ const token = url.searchParams.get("token");
173
+ if (!token)
174
+ return null;
175
+ const payload = await validateToken(verifyConfig, token);
176
+ if (!payload) {
177
+ return redirectToLogin(url.origin);
178
+ }
179
+ url.searchParams.delete("token");
180
+ return new Response(null, {
181
+ status: 302,
182
+ headers: {
183
+ Location: url.toString(),
184
+ "Set-Cookie": makeSessionCookie(cookieConfig, token)
185
+ }
186
+ });
136
187
  }
137
188
  export {
138
189
  redirectToLogin,
190
+ handleCallback,
139
191
  getClientIP,
140
192
  getAuthUser,
141
193
  checkBearerToken
@@ -1,2 +1,4 @@
1
+ /** Hash a password with PBKDF2-SHA256 (100k iterations, 32-byte salt, 64-byte key). Returns `hex(salt):hex(key)`. */
1
2
  export declare function hashPassword(password: string): Promise<string>;
3
+ /** Verify a password against a stored `hex(salt):hex(key)` hash. Uses constant-time comparison. */
2
4
  export declare function verifyPassword(password: string, storedHash: string): Promise<boolean>;
package/dist/service.d.ts CHANGED
@@ -13,5 +13,13 @@ export declare function verifyToken(authService: Fetcher, token: string): Promis
13
13
  export declare function getAuthUser(request: Request, authService: Fetcher, cookieConfig: CookieConfig): Promise<TokenPayload | null>;
14
14
  /**
15
15
  * Redirect to the centralized login page at auth.uln.industries.
16
+ * Requests one-time auth-code callbacks by default so signed tokens are not sent through URLs.
16
17
  */
17
- export declare function redirectToLogin(currentUrl: string): Response;
18
+ export declare function redirectToLogin(currentUrl: string, responseType?: "code" | "token"): Response;
19
+ /**
20
+ * Handle an auth callback on a consuming Worker using the auth service binding.
21
+ * Preferred `?code=` callbacks are exchanged for signed tokens before setting the local cookie.
22
+ * Legacy `?token=` callbacks are still accepted during migration and are stripped immediately.
23
+ * Returns null if the request has no auth callback parameters.
24
+ */
25
+ export declare function handleCallback(request: Request, authService: Fetcher, cookieConfig: CookieConfig): Promise<Response | null>;
package/dist/service.js CHANGED
@@ -54,14 +54,63 @@ async function getAuthUser(request, authService, cookieConfig) {
54
54
  return null;
55
55
  return verifyToken(authService, token);
56
56
  }
57
- function redirectToLogin(currentUrl) {
58
- const loginUrl = `https://auth.uln.industries/login?redirect=${encodeURIComponent(currentUrl)}`;
59
- return Response.redirect(loginUrl, 302);
57
+ function redirectToLogin(currentUrl, responseType = "code") {
58
+ const loginUrl = new URL("/login", "https://auth.uln.industries");
59
+ loginUrl.searchParams.set("redirect", currentUrl);
60
+ if (responseType === "code")
61
+ loginUrl.searchParams.set("response_type", "code");
62
+ return Response.redirect(loginUrl.toString(), 302);
63
+ }
64
+ async function exchangeCode(authService, code, redirectOrigin) {
65
+ const res = await authService.fetch("https://auth/api/exchange-code", {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/json" },
68
+ body: JSON.stringify({ code, redirectOrigin })
69
+ });
70
+ if (!res.ok)
71
+ return null;
72
+ const data = await res.json();
73
+ return typeof data.token === "string" ? data.token : null;
74
+ }
75
+ async function handleCallback(request, authService, cookieConfig) {
76
+ const url = new URL(request.url);
77
+ const code = url.searchParams.get("code");
78
+ if (code) {
79
+ const token2 = await exchangeCode(authService, code, url.origin);
80
+ if (!token2)
81
+ return redirectToLogin(url.origin, "code");
82
+ const payload2 = await verifyToken(authService, token2);
83
+ if (!payload2)
84
+ return redirectToLogin(url.origin, "code");
85
+ url.searchParams.delete("code");
86
+ return new Response(null, {
87
+ status: 302,
88
+ headers: {
89
+ Location: url.toString(),
90
+ "Set-Cookie": makeSessionCookie(cookieConfig, token2)
91
+ }
92
+ });
93
+ }
94
+ const token = url.searchParams.get("token");
95
+ if (!token)
96
+ return null;
97
+ const payload = await verifyToken(authService, token);
98
+ if (!payload)
99
+ return redirectToLogin(url.origin, "code");
100
+ url.searchParams.delete("token");
101
+ return new Response(null, {
102
+ status: 302,
103
+ headers: {
104
+ Location: url.toString(),
105
+ "Set-Cookie": makeSessionCookie(cookieConfig, token)
106
+ }
107
+ });
60
108
  }
61
109
  export {
62
110
  verifyToken,
63
111
  redirectToLogin,
64
112
  makeSessionCookie,
65
113
  makeClearCookie,
114
+ handleCallback,
66
115
  getAuthUser
67
116
  };
package/dist/token.d.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  import type { SigningConfig, TokenPayload, VerifyConfig } from "./types.ts";
2
+ /** Generate an ECDSA P-256 key pair for token signing. Returns a short `kid` and both JWK exports. */
2
3
  export declare function generateKeyPair(): Promise<{
3
4
  kid: string;
4
5
  privateKeyJwk: JsonWebKey;
5
6
  publicKeyJwk: JsonWebKey;
6
7
  }>;
8
+ /** Import an ECDSA P-256 private key from JWK for signing. */
7
9
  export declare function importPrivateKey(jwk: JsonWebKey): Promise<CryptoKey>;
10
+ /** Import an ECDSA P-256 public key from JWK for verification. */
8
11
  export declare function importPublicKey(jwk: JsonWebKey): Promise<CryptoKey>;
9
12
  export interface CreateTokenInput {
10
13
  id: string;
@@ -14,5 +17,7 @@ export interface CreateTokenInput {
14
17
  scopes: string[];
15
18
  flags: Record<string, string[]>;
16
19
  }
20
+ /** Create a signed token: `base64url(JSON payload).base64url(ECDSA-SHA256 signature)`. */
17
21
  export declare function createToken(config: SigningConfig, user: CreateTokenInput): Promise<string>;
22
+ /** Validate a signed token. Returns the payload if valid and not expired, or null. Matches `kid` to find the correct public key. */
18
23
  export declare function validateToken(config: VerifyConfig, token: string): Promise<TokenPayload | null>;
package/dist/totp.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * TOTP (Time-based One-Time Password) implementation per RFC 6238.
3
+ * Uses Web Crypto API only — no node:crypto imports.
4
+ *
5
+ * Algorithm: HMAC-SHA1, 6 digits, 30-second period, ±1 step window.
6
+ */
7
+ export declare function base32Encode(data: Uint8Array): string;
8
+ /** Generate a 20-byte random TOTP secret, returned as base32. */
9
+ export declare function generateTotpSecret(): string;
10
+ /** Build an otpauth:// URI for authenticator apps. */
11
+ export declare function getTotpUri(secret: string, email: string, issuer: string): string;
12
+ /** Six-digit code for the current 30-second step (deterministic counterpart to verifyTotpCode in tests). */
13
+ export declare function currentTotpCode(secret: string, nowMs?: number): Promise<string>;
14
+ /** Verify a TOTP code against the secret. Allows ±1 time step for clock drift. */
15
+ export declare function verifyTotpCode(secret: string, code: string): Promise<boolean>;
16
+ /** Generate a set of one-time backup codes for 2FA recovery. Returns plain-text codes. */
17
+ export declare function generateBackupCodes(): string[];
18
+ /** Hash a backup code for storage. Uses SHA-256 (no salt needed — codes are high-entropy). */
19
+ export declare function hashBackupCode(code: string): Promise<string>;