@uln/auth 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/totp.js CHANGED
@@ -76,17 +76,21 @@ async function currentTotpCode(secret, nowMs = Date.now()) {
76
76
  const step = Math.floor(nowMs / 1000 / TOTP_PERIOD);
77
77
  return generateCode(secret, step);
78
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);
79
+ async function verifyTotpStep(secret, code, nowMs = Date.now()) {
80
+ if (!/^\d{6}$/.test(code))
81
+ return null;
82
+ const currentStep = Math.floor(nowMs / 1000 / TOTP_PERIOD);
83
+ let matched = null;
84
84
  for (let offset = -TOTP_WINDOW;offset <= TOTP_WINDOW; offset++) {
85
- const expected = await generateCode(secret, currentStep + offset);
85
+ const step = currentStep + offset;
86
+ const expected = await generateCode(secret, step);
86
87
  if (timingSafeEqual(code, expected))
87
- return true;
88
+ matched = step;
88
89
  }
89
- return false;
90
+ return matched;
91
+ }
92
+ async function verifyTotpCode(secret, code) {
93
+ return await verifyTotpStep(secret, code) !== null;
90
94
  }
91
95
  var BACKUP_CODE_COUNT = 10;
92
96
  var BACKUP_CODE_LENGTH = 8;
@@ -94,11 +98,14 @@ var BACKUP_CODE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
94
98
  function generateBackupCodes() {
95
99
  const codes = [];
96
100
  for (let i = 0;i < BACKUP_CODE_COUNT; i++) {
97
- const bytes = new Uint8Array(BACKUP_CODE_LENGTH);
98
- crypto.getRandomValues(bytes);
99
101
  let code = "";
100
- for (const byte of bytes) {
101
- code += BACKUP_CODE_CHARS[byte % BACKUP_CODE_CHARS.length];
102
+ while (code.length < BACKUP_CODE_LENGTH) {
103
+ const bytes = new Uint8Array(BACKUP_CODE_LENGTH);
104
+ crypto.getRandomValues(bytes);
105
+ for (const byte of bytes) {
106
+ if (byte < 252 && code.length < BACKUP_CODE_LENGTH)
107
+ code += BACKUP_CODE_CHARS[byte % BACKUP_CODE_CHARS.length];
108
+ }
102
109
  }
103
110
  codes.push(code);
104
111
  }
@@ -119,11 +126,12 @@ function timingSafeEqual(a, b) {
119
126
  return result === 0;
120
127
  }
121
128
  export {
122
- verifyTotpCode,
123
- hashBackupCode,
124
- getTotpUri,
125
- generateTotpSecret,
126
- generateBackupCodes,
129
+ base32Encode,
127
130
  currentTotpCode,
128
- base32Encode
131
+ generateBackupCodes,
132
+ generateTotpSecret,
133
+ getTotpUri,
134
+ hashBackupCode,
135
+ verifyTotpCode,
136
+ verifyTotpStep
129
137
  };
package/dist/types.d.ts CHANGED
@@ -25,6 +25,11 @@ export interface RateLimitStore {
25
25
  get(key: string): Promise<RateLimitEntry | null>;
26
26
  set(key: string, entry: RateLimitEntry, ttlSeconds: number): Promise<void>;
27
27
  delete(key: string): Promise<void>;
28
+ /**
29
+ * Atomically add one attempt (creating the entry if needed) and return the result. Optional, but stores that
30
+ * implement it are race-free under concurrent requests; otherwise a read-modify-write via get/set is used.
31
+ */
32
+ increment?(key: string, ttlSeconds: number): Promise<RateLimitEntry>;
28
33
  }
29
34
  export interface RateLimitConfig {
30
35
  maxAttempts: number;
@@ -40,6 +45,12 @@ export interface TokenPayload {
40
45
  kid: string;
41
46
  iat: number;
42
47
  exp: number;
48
+ /**
49
+ * Audience: the origin this token was issued to (a site's origin, or the auth service's own origin for its session
50
+ * cookie). A token is only honoured where `aud` matches. Absent only on tokens minted before audience binding
51
+ * (library < 0.5); current verifiers reject those.
52
+ */
53
+ aud?: string;
43
54
  }
44
55
  export interface PublicKeyEntry {
45
56
  kid: string;
@@ -53,6 +64,15 @@ export interface SigningConfig {
53
64
  export interface VerifyConfig {
54
65
  publicKeys: PublicKeyEntry[];
55
66
  maxAgeSeconds: number;
67
+ /**
68
+ * Also confirm locally valid tokens with the auth service (at most once per token every `intervalSeconds`, default
69
+ * 300), so password resets, deleted users and removed scopes take effect without waiting for the token to expire.
70
+ * If the auth service can't be reached the local result stands.
71
+ */
72
+ revalidate?: {
73
+ authUrl: string;
74
+ intervalSeconds?: number;
75
+ };
56
76
  }
57
77
  export interface CookieConfig {
58
78
  name: string;
package/dist/users.d.ts CHANGED
@@ -7,6 +7,11 @@ export declare function createUser(store: UserStore, input: {
7
7
  password?: string;
8
8
  isAdmin?: boolean;
9
9
  }): Promise<User>;
10
+ /**
11
+ * Check an email/password pair. When the user doesn't exist a dummy hash is still verified, so response timing
12
+ * doesn't reveal which emails have accounts. A correct password stored under older hashing parameters is re-hashed
13
+ * with the current ones.
14
+ */
10
15
  export declare function verifyUserCredentials(store: UserStore, email: string, password: string): Promise<User | null>;
11
16
  export declare function updateName(store: UserStore, userId: string, name: string): Promise<void>;
12
17
  export declare function changePassword(store: UserStore, userId: string, newPassword: string): Promise<void>;
package/dist/users.js CHANGED
@@ -2,7 +2,12 @@
2
2
  // src/password.ts
3
3
  var SALT_LENGTH = 32;
4
4
  var KEY_LENGTH = 64;
5
- var ITERATIONS = 1e5;
5
+ var STAGE_ITERATIONS = 1e5;
6
+ var ITERATIONS = 600000;
7
+ var MAX_ITERATIONS = 1e7;
8
+ var MAX_PASSWORD_LENGTH = 1024;
9
+ var HEX_REGEX = /^(?:[0-9a-f]{2})+$/;
10
+ var PREFIX = "pbkdf2-sha256";
6
11
  function toHex(buffer) {
7
12
  return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
8
13
  }
@@ -13,38 +18,61 @@ function fromHex(hex) {
13
18
  }
14
19
  return bytes;
15
20
  }
16
- async function deriveKey(password, salt) {
17
- const encoder = new TextEncoder;
18
- const keyMaterial = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"]);
19
- return crypto.subtle.deriveBits({
20
- name: "PBKDF2",
21
- salt,
22
- iterations: ITERATIONS,
23
- hash: "SHA-256"
24
- }, keyMaterial, KEY_LENGTH * 8);
21
+ async function deriveKey(password, salt, iterations) {
22
+ let material = new TextEncoder().encode(password);
23
+ let derived = new ArrayBuffer(0);
24
+ for (let done = 0;done < iterations; done += STAGE_ITERATIONS) {
25
+ const keyMaterial = await crypto.subtle.importKey("raw", material, "PBKDF2", false, ["deriveBits"]);
26
+ derived = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations: STAGE_ITERATIONS, hash: "SHA-256" }, keyMaterial, KEY_LENGTH * 8);
27
+ material = new Uint8Array(derived);
28
+ }
29
+ return derived;
30
+ }
31
+ function parseHash(storedHash) {
32
+ let iterations = STAGE_ITERATIONS;
33
+ let saltHex;
34
+ let keyHex;
35
+ if (storedHash.startsWith(`${PREFIX}$`)) {
36
+ const [, rawIterations = "", salt = "", key = ""] = storedHash.split("$");
37
+ if (!/^[1-9]\d*$/.test(rawIterations))
38
+ return null;
39
+ iterations = Number(rawIterations);
40
+ if (iterations % STAGE_ITERATIONS !== 0 || iterations > MAX_ITERATIONS)
41
+ return null;
42
+ saltHex = salt;
43
+ keyHex = key;
44
+ } else {
45
+ const separatorIndex = storedHash.indexOf(":");
46
+ if (separatorIndex === -1)
47
+ return null;
48
+ saltHex = storedHash.substring(0, separatorIndex);
49
+ keyHex = storedHash.substring(separatorIndex + 1);
50
+ }
51
+ if (!HEX_REGEX.test(saltHex) || !HEX_REGEX.test(keyHex))
52
+ return null;
53
+ return { iterations, salt: fromHex(saltHex), key: fromHex(keyHex) };
25
54
  }
26
55
  async function hashPassword(password) {
27
56
  const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
28
- const derived = await deriveKey(password, salt);
29
- return `${toHex(salt.buffer)}:${toHex(derived)}`;
57
+ const derived = await deriveKey(password, salt, ITERATIONS);
58
+ return `${PREFIX}$${ITERATIONS}$${toHex(salt.buffer)}$${toHex(derived)}`;
30
59
  }
31
60
  async function verifyPassword(password, storedHash) {
32
- const separatorIndex = storedHash.indexOf(":");
33
- if (separatorIndex === -1)
61
+ const parsed = parseHash(storedHash);
62
+ if (!parsed)
34
63
  return false;
35
- const saltHex = storedHash.substring(0, separatorIndex);
36
- const keyHex = storedHash.substring(separatorIndex + 1);
37
- const salt = fromHex(saltHex);
38
- const expectedKey = fromHex(keyHex);
39
- const derived = new Uint8Array(await deriveKey(password, salt));
40
- if (derived.length !== expectedKey.length)
64
+ const derived = new Uint8Array(await deriveKey(password, parsed.salt, parsed.iterations));
65
+ if (derived.length !== parsed.key.length)
41
66
  return false;
42
67
  let diff = 0;
43
68
  for (let i = 0;i < derived.length; i++) {
44
- diff |= (derived[i] ?? 0) ^ (expectedKey[i] ?? 0);
69
+ diff |= (derived[i] ?? 0) ^ (parsed.key[i] ?? 0);
45
70
  }
46
71
  return diff === 0;
47
72
  }
73
+ function needsRehash(storedHash) {
74
+ return !storedHash.startsWith(`${PREFIX}$`) || (parseHash(storedHash)?.iterations ?? 0) < ITERATIONS;
75
+ }
48
76
 
49
77
  // src/users.ts
50
78
  var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -54,6 +82,8 @@ function isValidEmail(email) {
54
82
  async function createUser(store, input) {
55
83
  if (!isValidEmail(input.email))
56
84
  throw new Error("Invalid email address");
85
+ if (input.password !== undefined && input.password.length > MAX_PASSWORD_LENGTH)
86
+ throw new Error("Password is too long");
57
87
  const existing = await store.getUserByEmail(input.email);
58
88
  if (existing)
59
89
  throw new Error(`User with email ${input.email} already exists`);
@@ -74,12 +104,22 @@ async function createUser(store, input) {
74
104
  await store.saveUser(user);
75
105
  return user;
76
106
  }
107
+ var dummyHash = null;
77
108
  async function verifyUserCredentials(store, email, password) {
78
109
  const user = await store.getUserByEmail(email.toLowerCase());
79
- if (!user)
110
+ if (!user) {
111
+ dummyHash ??= hashPassword(crypto.randomUUID());
112
+ await verifyPassword(password, await dummyHash);
80
113
  return null;
114
+ }
81
115
  const valid = await verifyPassword(password, user.passwordHash);
82
- return valid ? user : null;
116
+ if (!valid)
117
+ return null;
118
+ if (!needsRehash(user.passwordHash))
119
+ return user;
120
+ const upgraded = { ...user, passwordHash: await hashPassword(password), updatedAt: new Date().toISOString() };
121
+ await store.saveUser(upgraded);
122
+ return upgraded;
83
123
  }
84
124
  async function updateName(store, userId, name) {
85
125
  const user = await store.getUser(userId);
@@ -93,6 +133,8 @@ async function updateName(store, userId, name) {
93
133
  await store.saveUser(updated);
94
134
  }
95
135
  async function changePassword(store, userId, newPassword) {
136
+ if (newPassword.length > MAX_PASSWORD_LENGTH)
137
+ throw new Error("Password is too long");
96
138
  const user = await store.getUser(userId);
97
139
  if (!user)
98
140
  throw new Error(`User ${userId} not found`);
@@ -104,9 +146,9 @@ async function changePassword(store, userId, newPassword) {
104
146
  await store.saveUser(updated);
105
147
  }
106
148
  export {
107
- verifyUserCredentials,
108
- updateName,
109
- isValidEmail,
149
+ changePassword,
110
150
  createUser,
111
- changePassword
151
+ isValidEmail,
152
+ updateName,
153
+ verifyUserCredentials
112
154
  };
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "@uln/auth",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/ulnd/auth.git"
7
+ },
4
8
  "type": "module",
5
9
  "exports": {
6
10
  ".": {
@@ -24,45 +28,56 @@
24
28
  "types": "./dist/hono.d.ts"
25
29
  }
26
30
  },
27
- "files": ["dist", "src", "!src/**/*.test.ts"],
31
+ "files": [
32
+ "dist",
33
+ "src",
34
+ "!src/**/*.test.ts"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
28
39
  "scripts": {
29
40
  "dev": "bunx vite",
30
- "dev:worker": "bunx wrangler dev",
41
+ "dev:worker": "wrangler dev",
31
42
  "build": "bun run scripts/build.ts",
32
43
  "check": "biome check --write .",
33
44
  "test": "bun test",
34
- "ci": "biome check . && bun test --coverage",
45
+ "ci": "biome check . && bun run typecheck && bun test --coverage",
35
46
  "migrate:local": "bunx wrangler d1 migrations apply uln-auth --local",
36
47
  "migrate:remote": "bunx wrangler d1 migrations apply uln-auth --remote",
37
48
  "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"
49
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p client/tsconfig.json"
40
50
  },
41
51
  "devDependencies": {
42
- "@biomejs/biome": "^1.9.4",
43
- "@uln/ui": "^0.2.0",
44
- "@cloudflare/workers-types": "^4.20260317.1",
45
- "@types/bun": "latest",
52
+ "@biomejs/biome": "^2.5.15",
53
+ "@cloudflare/workers-types": "^5.20261001.1",
54
+ "@types/bun": "^1.4.2",
46
55
  "@types/qrcode": "^1.5.6",
47
- "@types/react": "^19.2.14",
48
- "@types/react-dom": "^19.2.3",
56
+ "@types/react": "^19.3.0",
57
+ "@types/react-dom": "^19.3.0",
49
58
  "@uln/log": "^0.3.0",
50
- "@vitejs/plugin-react": "^6.0.1",
51
- "hono": "^4.12.8",
59
+ "@uln/ui": "^0.2.0",
60
+ "@vitejs/plugin-react": "^6.1.1",
61
+ "hono": "^4.13.12",
52
62
  "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"
63
+ "react": "^19.3.0",
64
+ "react-dom": "^19.3.0",
65
+ "react-router": "^8.4.0",
66
+ "typescript": "^7.0.2",
67
+ "vite": "^8.3.2",
68
+ "wrangler": "^4.145.0"
57
69
  },
58
70
  "peerDependencies": {
59
71
  "hono": "^4",
60
- "typescript": "^5"
72
+ "typescript": "^5 || ^6 || ^7"
61
73
  },
62
74
  "peerDependenciesMeta": {
63
75
  "hono": {
64
76
  "optional": true
65
77
  }
66
78
  },
67
- "dependencies": {}
79
+ "dependencies": {},
80
+ "overrides": {
81
+ "hono": "^4.13.12"
82
+ }
68
83
  }
package/src/client.ts CHANGED
@@ -1,20 +1,14 @@
1
- export type { TokenPayload, VerifyConfig, PublicKeyEntry, CookieConfig } from "./types.ts";
2
- export { validateToken, importPublicKey } from "./token.ts";
3
- export { makeSessionCookie, makeClearCookie, extractCookie } from "./cookie.ts";
4
- export type { AuthCodeExchangeConfig } from "./middleware.ts";
5
- export { getAuthUser, redirectToLogin, handleCallback, getClientIP, checkBearerToken } from "./middleware.ts";
6
-
7
- import { importPublicKey } from "./token.ts";
1
+ import { fetchJwks, publicFetcher } from "./remote.ts";
8
2
  import type { PublicKeyEntry } from "./types.ts";
9
3
 
4
+ export { extractCookie, makeClearCookie, makeSessionCookie } from "./cookie.ts";
5
+ export type { AuthCodeExchangeConfig } from "./middleware.ts";
6
+ export { checkBearerToken, getAuthUser, getClientIP, handleCallback, redirectToLogin } from "./middleware.ts";
7
+ export { hasSiteAccess } from "./remote.ts";
8
+ export { importPublicKey, validateToken } from "./token.ts";
9
+ export type { CookieConfig, PublicKeyEntry, TokenPayload, VerifyConfig } from "./types.ts";
10
+
11
+ /** Fetch and import the auth service's public signing keys. Cache the result; refetch when a token's kid is unknown. */
10
12
  export async function fetchPublicKeys(baseUrl: string): Promise<PublicKeyEntry[]> {
11
- const res = await fetch(`${baseUrl}/.well-known/jwk.json`);
12
- if (!res.ok) throw new Error(`Failed to fetch JWKS from ${baseUrl}: ${res.status}`);
13
- const jwks = (await res.json()) as { keys: (JsonWebKey & { kid: string })[] };
14
- return Promise.all(
15
- jwks.keys.map(async (jwk) => ({
16
- kid: jwk.kid,
17
- key: await importPublicKey(jwk),
18
- })),
19
- );
13
+ return fetchJwks(publicFetcher(baseUrl));
20
14
  }
package/src/flow.ts ADDED
@@ -0,0 +1,170 @@
1
+ import { extractCookie, makeSessionCookie } from "./cookie.ts";
2
+ import { checkTokenRemote, exchangeAuthCode, type FetchLike, hasSiteAccessFor } from "./remote.ts";
3
+ import type { CookieConfig, TokenPayload } from "./types.ts";
4
+
5
+ // The one implementation of the site side of the login flow, shared by `@uln/auth/client`, `/service` and `/hono`.
6
+
7
+ export const DEFAULT_AUTH_URL = "https://auth.uln.industries";
8
+
9
+ /**
10
+ * Short-lived cookie tying a `?code=` callback to the browser that started the login. Its value is
11
+ * `<state>.<pkce verifier>`. The `__Host-` prefix makes browsers refuse it unless it was set by this exact host
12
+ * (Secure, `Path=/`, no `Domain`), so a page on a sibling subdomain can't plant a known state for the whole domain.
13
+ */
14
+ export const STATE_COOKIE = "__Host-uln_auth_state";
15
+ /** The state cookie's name on plain-http origins (local development), where browsers refuse `__Host-` cookies. */
16
+ export const INSECURE_STATE_COOKIE = "uln_auth_state";
17
+ /** Query parameter that carries the state through the auth service and back on the return URL. */
18
+ export const STATE_PARAM = "uln_state";
19
+ /**
20
+ * PKCE (RFC 7636, S256) challenge on the return URL. The auth service stores it with the one-time code and only
21
+ * exchanges the code for a client that presents the verifier, which never leaves this browser's state cookie, so a
22
+ * code that leaks (history, logs, a shared link) is useless on its own.
23
+ */
24
+ export const CHALLENGE_PARAM = "uln_challenge";
25
+ const STATE_MAX_AGE_SECONDS = 600;
26
+
27
+ function base64url(bytes: Uint8Array): string {
28
+ let binary = "";
29
+ for (const byte of bytes) binary += String.fromCharCode(byte);
30
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
31
+ }
32
+
33
+ function randomToken(): string {
34
+ return base64url(crypto.getRandomValues(new Uint8Array(32)));
35
+ }
36
+
37
+ /** S256 code challenge for a PKCE verifier: base64url(SHA-256(verifier)). */
38
+ export async function pkceChallenge(verifier: string): Promise<string> {
39
+ return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
40
+ }
41
+
42
+ function stateCookie(value: string, secure: boolean, maxAgeSeconds: number): string {
43
+ const name = secure ? STATE_COOKIE : INSECURE_STATE_COOKIE;
44
+ const parts = [`${name}=${value}`, `Max-Age=${maxAgeSeconds}`, "Path=/", "HttpOnly", "SameSite=Lax"];
45
+ if (secure) parts.push("Secure");
46
+ return parts.join("; ");
47
+ }
48
+
49
+ function readStateCookie(request: Request, secure: boolean): { state: string; verifier: string } | null {
50
+ const name = secure ? STATE_COOKIE : INSECURE_STATE_COOKIE;
51
+ const [state = "", verifier = "", ...rest] = (extractCookie(request.headers.get("Cookie") ?? "", name) ?? "").split(
52
+ ".",
53
+ );
54
+ return state && verifier && rest.length === 0 ? { state, verifier } : null;
55
+ }
56
+
57
+ function stripCallbackParams(url: URL): void {
58
+ url.searchParams.delete("code");
59
+ url.searchParams.delete(STATE_PARAM);
60
+ url.searchParams.delete(CHALLENGE_PARAM);
61
+ }
62
+
63
+ /**
64
+ * Redirect to the auth service's login page. Sets a one-time state cookie and puts the state on the return URL, which
65
+ * the auth service hands back untouched alongside `?code=`, together with a PKCE challenge for the verifier kept in
66
+ * the cookie. Requests a one-time code callback, so signed tokens never travel in URLs.
67
+ */
68
+ export async function redirectToLogin(currentUrl: string, authUrl: string = DEFAULT_AUTH_URL): Promise<Response> {
69
+ const returnUrl = new URL(currentUrl);
70
+ stripCallbackParams(returnUrl);
71
+ const state = randomToken();
72
+ const verifier = randomToken();
73
+ returnUrl.searchParams.set(STATE_PARAM, state);
74
+ returnUrl.searchParams.set(CHALLENGE_PARAM, await pkceChallenge(verifier));
75
+
76
+ const loginUrl = new URL("/login", authUrl);
77
+ loginUrl.searchParams.set("redirect", returnUrl.toString());
78
+ loginUrl.searchParams.set("response_type", "code");
79
+
80
+ const headers = new Headers({ Location: loginUrl.toString() });
81
+ headers.append(
82
+ "Set-Cookie",
83
+ stateCookie(`${state}.${verifier}`, returnUrl.protocol === "https:", STATE_MAX_AGE_SECONDS),
84
+ );
85
+ return new Response(null, { status: 302, headers });
86
+ }
87
+
88
+ export interface LoginCallbackOptions {
89
+ auth: FetchLike;
90
+ authUrl: string;
91
+ cookieConfig: CookieConfig;
92
+ /** Validate the exchanged token (locally or with the auth service). Null rejects it. */
93
+ validate(token: string): Promise<TokenPayload | null>;
94
+ /** Origin registered for scopes, when it differs from the request URL's origin. */
95
+ scopeOrigin?: string;
96
+ }
97
+
98
+ /**
99
+ * Handle a `?code=` login callback; null when the request isn't one. The code is only exchanged when the request's
100
+ * `uln_state` matches this browser's state cookie, so a code planted in a link by someone else (login CSRF, which
101
+ * would sign the victim in to the attacker's account) is refused, and the exchange proves this browser holds the PKCE
102
+ * verifier. Any failure sends the user back to log in.
103
+ */
104
+ export async function handleLoginCallback(request: Request, options: LoginCallbackOptions): Promise<Response | null> {
105
+ const url = new URL(request.url);
106
+ const code = url.searchParams.get("code");
107
+ if (!code) return null;
108
+
109
+ const state = url.searchParams.get(STATE_PARAM);
110
+ const secure = url.protocol === "https:";
111
+ const expected = readStateCookie(request, secure);
112
+ stripCallbackParams(url);
113
+ const startOver = () => redirectToLogin(url.toString(), options.authUrl);
114
+ if (!state || !expected || state !== expected.state) return startOver();
115
+
116
+ const token = await exchangeAuthCode(options.auth, code, url.origin, expected.verifier);
117
+ if (!token) return startOver();
118
+ const payload = await options.validate(token);
119
+ if (!payload) return startOver();
120
+ const scopeOrigin = options.scopeOrigin ?? url.origin;
121
+ if (!hasSiteAccessFor(payload, { audiences: [url.origin, scopeOrigin], scopeOrigin })) {
122
+ return new Response("You don't have access to this site", { status: 403 });
123
+ }
124
+
125
+ const headers = new Headers({ Location: url.toString() });
126
+ headers.append("Set-Cookie", makeSessionCookie(options.cookieConfig, token));
127
+ headers.append("Set-Cookie", stateCookie("", secure, 0));
128
+ return new Response(null, { status: 302, headers });
129
+ }
130
+
131
+ // --- Revocation checks for locally validated (JWKS) tokens ---
132
+
133
+ /** Token → when the auth service last confirmed it (ms). Per isolate; insertion order doubles as LRU order. */
134
+ const confirmedAt = new Map<string, number>();
135
+ const MAX_CONFIRMED = 5000;
136
+ export const DEFAULT_REVALIDATE_SECONDS = 300;
137
+
138
+ /**
139
+ * Confirm a locally valid token with the auth service, at most once per `intervalSeconds` per token. Returns the
140
+ * payload to use: the auth service's (current admin/scopes/flags) after a fresh check, the local one while a previous
141
+ * confirmation is fresh or when the auth service can't be reached, and null when the auth service rejects the token
142
+ * (deleted user, revoked session, wrong audience).
143
+ */
144
+ export async function revalidateToken(
145
+ auth: FetchLike,
146
+ token: string,
147
+ local: TokenPayload,
148
+ audience: string,
149
+ intervalSeconds: number = DEFAULT_REVALIDATE_SECONDS,
150
+ ): Promise<TokenPayload | null> {
151
+ const last = confirmedAt.get(token);
152
+ if (last !== undefined && Date.now() - last < intervalSeconds * 1000) return local;
153
+
154
+ const result = await checkTokenRemote(auth, token, audience);
155
+ if (result.status === "unavailable") return local;
156
+ confirmedAt.delete(token);
157
+ if (result.status === "invalid") return null;
158
+
159
+ if (confirmedAt.size >= MAX_CONFIRMED) {
160
+ const oldest = confirmedAt.keys().next();
161
+ if (!oldest.done) confirmedAt.delete(oldest.value);
162
+ }
163
+ confirmedAt.set(token, Date.now());
164
+ return result.payload;
165
+ }
166
+
167
+ /** Test hook: forget which tokens were recently confirmed. */
168
+ export function clearRevalidationCache(): void {
169
+ confirmedAt.clear();
170
+ }