import { createHash, timingSafeEqual } from "node:crypto"; // The parts of OAuth that are plain rules, without the database (tested in oauth-rules.test.ts). export type GrantPermission = "read" | "write"; /** Scopes are the two token permissions; "write" includes reading. */ export const SCOPES = ["read", "write"] as const; /** * The permission a client asks for. Unknown scopes are ignored, and a client that names neither * (or doesn't send a scope) asks for read & write; the user can still narrow it to read-only. */ export function requestedPermission(scope: string | null | undefined): GrantPermission { const scopes = new Set((scope ?? "").split(" ").filter(Boolean)); return scopes.has("read") && !scopes.has("write") ? "read" : "write"; } export const scopeOf = (permission: GrantPermission) => (permission === "write" ? "read write" : "read"); const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]); // Schemes a redirect must never use: they'd run code or read local data in the browser. const FORBIDDEN_SCHEMES = new Set(["javascript:", "data:", "file:", "vbscript:", "blob:", "about:", "ws:", "wss:"]); /** * Redirect URIs a client may register: https anywhere, http only to this machine (CLI agents * listen on a local port), or a native app's private-use scheme (RFC 8252). No fragments. */ export function isAllowedRedirectUri(uri: string) { let url: URL; try { url = new URL(uri); } catch { return false; } if (url.hash || uri.length > 2000 || url.username || url.password) return false; if (url.protocol === "https:") return true; if (url.protocol === "http:") return LOOPBACK.has(url.hostname); return /^[a-z][a-z0-9+.-]*:$/.test(url.protocol) && !FORBIDDEN_SCHEMES.has(url.protocol); } /** * Whether a redirect URI in a request is one the client registered: exactly, except that loopback * redirects may use any port, because a native app picks a free one each time (RFC 8252 §7.3). */ export function redirectUriMatches(registered: string[], uri: string) { if (registered.includes(uri)) return true; let url: URL; try { url = new URL(uri); } catch { return false; } if (url.protocol !== "http:" || !LOOPBACK.has(url.hostname)) return false; const withoutPort = (u: URL) => `${u.protocol}//${u.hostname}${u.pathname}${u.search}`; return registered.some((r) => { try { const candidate = new URL(r); return candidate.protocol === "http:" && withoutPort(candidate) === withoutPort(url); } catch { return false; } }); } /** A PKCE S256 challenge: base64url of a sha256, 43 characters. */ export const isCodeChallenge = (challenge: string) => /^[A-Za-z0-9_-]{43}$/.test(challenge); /** Checks a PKCE code verifier (RFC 7636: 43–128 unreserved characters) against its S256 challenge. */ export function verifyPkce(verifier: string, challenge: string) { if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) return false; const expected = Buffer.from(createHash("sha256").update(verifier).digest("base64url")); const actual = Buffer.from(challenge); return expected.length === actual.length && timingSafeEqual(expected, actual); } /** Adds OAuth response parameters to a client's redirect URI, keeping its own query. */ export function withParams(redirectUri: string, params: Record) { const url = new URL(redirectUri); for (const [key, value] of Object.entries(params)) if (value) url.searchParams.set(key, value); return url.toString(); }