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<string, string | null | undefined>) {
const url = new URL(redirectUri);
for (const [key, value] of Object.entries(params)) if (value) url.searchParams.set(key, value);
return url.toString();
}