Vectrum
Sign In
83 lines · 3.4 KB · TypeScript
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();
}