import { createHash } from "node:crypto"; import type { Level } from "../../lib/authorize.js"; /** * TASK-0129 — OAuth2 scopes (docs/api/authentication.md §8.1). * * The OAuth scope CATALOG — six resource-qualified scopes * (`:`, resource ∈ pages|databases|files). Apps offer a * subset; the token carries the granted subset; the enforcement cap is the * MAX across them (`oauthScopeCap`). * * CONFLICT C-13.7 (recorded in docs/tasks/phase-13-api.md): the task * requirements text and one example list FIVE scopes (omitting * `files:write`), while the locked design decision names the scope family * "`pages:read`→VIEW, `pages:write`→EDIT, `databases:read`→VIEW, * `databases:write`→EDIT, `files:read`→VIEW, `files:write`→EDIT" — and the * doc §8.1 example grants `databases:write`, which only exists in the * 6-scope family. Resolution: the full 6-scope family (symmetric read/write * per resource); test apps may offer fewer (the E2E app offers five, matching * the "5 scope checkboxes" acceptance text). * * Human descriptions are STATIC server strings (security: the consent screen * renders only these — no client-supplied text, so no XSS vector). */ export const OAUTH_SCOPES = [ "pages:read", "pages:write", "databases:read", "databases:write", "files:read", "files:write" ] as const; export type OAuthScope = (typeof OAUTH_SCOPES)[number]; /** Resources a scope can qualify. */ export const OAUTH_SCOPE_RESOURCES = ["pages", "databases", "files"] as const; export type OAuthScopeResource = (typeof OAUTH_SCOPE_RESOURCES)[number]; /** The resource a scope targets (`pages:read` → `pages`); null for unknown. */ export function scopeResource(scope: string): OAuthScopeResource | null { const i = scope.indexOf(":"); if (i === -1) return null; const r = scope.slice(0, i); return (OAUTH_SCOPE_RESOURCES as readonly string[]).includes(r) ? (r as OAuthScopeResource) : null; } /** Human descriptions for the consent screen (static strings only). */ export const OAUTH_SCOPE_DESCRIPTIONS: Record = { "pages:read": "View pages", "pages:write": "Create and edit pages", "databases:read": "View databases and records", "databases:write": "Create and edit databases and records", "files:read": "View and download files", "files:write": "Upload and manage files" }; /** * The permission LEVEL an OAuth token exercises, derived from its scopes — * the OAuth counterpart of `apiKeyScopeCap` (lib/authorize.ts, TASK-0127): * * - any `*:write` scope → EDIT (mutations; COMMENT-level ops sit below) * - only `*:read` scopes → VIEW (reads) * - no scopes → NONE * * There is deliberately NO scope that reaches FULL_ACCESS: a third-party app * never gets admin (owner/admin-only gates stay closed to OAuth tokens — * recorded in C-13.7). */ export function oauthScopeCap(scopes: string[]): Level { if (scopes.some((s) => s.endsWith(":write"))) return "EDIT"; if (scopes.some((s) => s.endsWith(":read"))) return "VIEW"; return "NONE"; } /** * The OAuth scope whose ABSENCE should be named in a 403 SCOPE_DENIED detail * (`details[0].scope`) when a token's cap is below `need`: * - need ≥ EDIT (write ops) → `:write` * - need ≤ VIEW (read ops) → `:read` * The resource is inferred from the asserted resource scope (pageId → pages, * databaseId → databases, fileId → files); with no resource named it falls * back to the resource of the token's first recognized scope, else "pages". */ export function missingOAuthScope( need: Level, scopes: string[], resource?: { pageId?: string; databaseId?: string; fileId?: string } ): string { const action: "read" | "write" = need === "VIEW" || need === "NONE" ? "read" : "write"; let r: OAuthScopeResource | null = null; if (resource?.pageId) r = "pages"; else if (resource?.databaseId) r = "databases"; else if (resource?.fileId) r = "files"; else { for (const s of scopes) { const sr = scopeResource(s); if (sr) { r = sr; break; } } if (!r) r = "pages"; } return `${r}:${action}`; } /** Split a space-separated `scope` wire value into a clean, ordered list. */ export function parseScopeList(scope?: string | null): string[] { if (!scope) return []; const seen = new Set(); for (const part of scope.split(" ")) { const s = part.trim(); if (s && !seen.has(s)) seen.add(s); } return [...seen]; } /** Serialize to the space-separated wire format (canonical catalog order). */ export function serializeScopeList(scopes: string[]): string { return OAUTH_SCOPES.filter((s) => scopes.includes(s as OAuthScope)).join(" "); } /** PKCE S256 (RFC 7636): BASE64URL(SHA256(ASCII(verifier))). */ export function pkceChallenge(verifier: string): string { return createHash("sha256") .update(verifier, "ascii") .digest("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); }