import "server-only"; import { createHash, randomBytes } from "node:crypto"; import { db, filter, findById, findFirst, orNull, withLock, type RecordModel } from "./db"; import { isAllowedRedirectUri, isCodeChallenge, redirectUriMatches, requestedPermission, scopeOf, verifyPkce, withParams, type GrantPermission, } from "./oauth-rules"; import { serverOrigin } from "./request-origin"; import { coveredRepos, type TokenScope } from "./tokens"; import { toUser } from "./users"; /* * An OAuth 2.1 authorization server for MCP clients (AI agents), as the MCP authorization spec * describes it: * * /.well-known/oauth-protected-resource[/mcp] which server issues tokens for /mcp (RFC 9728) * /.well-known/oauth-authorization-server the endpoints below (RFC 8414) * POST /oauth/register dynamic client registration (RFC 7591) * /oauth/authorize the user signs in and approves (PKCE S256 only) * POST /oauth/token authorization_code and refresh_token grants * POST /oauth/revoke token revocation (RFC 7009) * * Clients are public (no secrets). A grant carries the same permissions as an API token: read or * read & write, for all of the user's repositories or selected ones. Access tokens last an hour; * refresh tokens rotate on every use and expire after 30 days without one. */ export const ACCESS_TOKEN_PREFIX = "vat_"; const REFRESH_TOKEN_PREFIX = "vrt_"; const ACCESS_TOKEN_SECONDS = 3600; const REFRESH_TOKEN_MS = 30 * 86400e3; const CODE_MS = 5 * 60e3; /** Registered clients nobody approves within this long are deleted. */ const UNUSED_CLIENT_MS = 86400e3; const sha256 = (s: string) => createHash("sha256").update(s).digest("hex"); const newSecret = (prefix: string) => `${prefix}${randomBytes(30).toString("base64url")}`; /** The authorization server's identifier, which clients check against the metadata they fetched. */ export const issuer = serverOrigin; /** The MCP endpoint, which is what tokens are issued for (the "resource" of RFC 8707). */ export async function mcpResource() { return `${await serverOrigin()}/mcp`; } /** A client may name the MCP endpoint or the server itself as the resource it wants a token for. */ async function isOurResource(resource: string) { const strip = (s: string) => s.replace(/\/+$/, ""); return [await mcpResource(), await serverOrigin()].includes(strip(resource)); } export class OAuthError extends Error { constructor( readonly code: string, message: string, readonly status = 400, ) { super(message); } } const invalidGrant = (message: string) => new OAuthError("invalid_grant", message); // --------------------------------------------------------------------------- // Clients export type OAuthClient = { id: string; name: string; uri: string | null; redirectUris: string[] }; const toClient = (r: RecordModel): OAuthClient => ({ id: r.id, name: r.name, uri: orNull(r.client_uri), redirectUris: r.redirect_uris ?? [] }); export async function getClient(clientId: string) { // Record ids are 15 lowercase letters and digits; anything else can't be one. if (!/^[a-z0-9]{15}$/.test(clientId)) return null; const row = await findById("oauth_clients", clientId); return row ? toClient(row) : null; } /** Registers a client from its RFC 7591 metadata and answers with the registration. */ export async function registerClient(metadata: Record) { const uris = metadata.redirect_uris; if (!Array.isArray(uris) || uris.length === 0 || uris.length > 10 || !uris.every((u) => typeof u === "string" && isAllowedRedirectUri(u))) { throw new OAuthError( "invalid_redirect_uri", "redirect_uris must list 1 to 10 URIs: https, http on localhost, or an app's own scheme, without fragments.", ); } const grantTypes = metadata.grant_types; if (grantTypes !== undefined && !(Array.isArray(grantTypes) && grantTypes.includes("authorization_code"))) { throw new OAuthError("invalid_client_metadata", "Only the authorization_code grant (with refresh_token) is supported."); } const responseTypes = metadata.response_types; if (responseTypes !== undefined && !(Array.isArray(responseTypes) && responseTypes.includes("code"))) { throw new OAuthError("invalid_client_metadata", "Only the code response type is supported."); } const name = typeof metadata.client_name === "string" ? metadata.client_name.trim().slice(0, 100) : ""; const uri = typeof metadata.client_uri === "string" && /^https?:\/\//.test(metadata.client_uri) ? metadata.client_uri.slice(0, 500) : ""; const now = Date.now(); const row = await (await db()).collection("oauth_clients").create({ name: name || new URL(uris[0]).hostname || "Unnamed application", client_uri: uri, redirect_uris: uris, created_at: now, }); pruneUnusedClients().catch((e) => console.error("[oauth] could not prune clients:", e)); // Every client is public: whatever authentication method it asked for, it gets "none" (RFC 7591 // lets the server replace requested values). return { client_id: row.id, client_id_issued_at: Math.floor(now / 1000), client_name: row.name, ...(uri ? { client_uri: uri } : {}), redirect_uris: uris, grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", }; } async function pruneUnusedClients() { const { items } = await (await db()).collection("oauth_clients").getList(1, 100, { filter: filter("last_authorized_at = 0 && created_at < {:cutoff}", { cutoff: Date.now() - UNUSED_CLIENT_MS }), fields: "id", skipTotal: true, }); for (const { id } of items) await (await db()).collection("oauth_clients").delete(id); } // --------------------------------------------------------------------------- // Authorizing export type AuthorizeRequest = { client: OAuthClient; redirectUri: string; state: string | null; codeChallenge: string; /** What the client asked for; the user may narrow it. */ permission: GrantPermission; }; /** * Checks the query of an authorization request. Problems with the client or its redirect URI are * shown to the user (`fatal`): sending anything to an unverified address could leak to an attacker. * Other problems go back to the client (`redirect`), as OAuth specifies. */ export async function parseAuthorizeRequest( params: Record, ): Promise<{ request: AuthorizeRequest } | { fatal: string } | { redirect: string }> { const client = await getClient(params.client_id ?? ""); if (!client) return { fatal: "This application isn't registered here (anymore). Start connecting it again from the application." }; const redirectUri = params.redirect_uri ?? ""; if (!redirectUriMatches(client.redirectUris, redirectUri)) { return { fatal: "The application asked to return to an address it didn't register, so Vectrum won't send you there." }; } const state = params.state || null; const fail = async (error: string, description: string) => ({ redirect: withParams(redirectUri, { error, error_description: description, state, iss: await issuer() }), }); if (params.response_type !== "code") return fail("unsupported_response_type", "Only response_type=code is supported."); const codeChallenge = params.code_challenge ?? ""; if (params.code_challenge_method !== "S256" || !isCodeChallenge(codeChallenge)) { return fail("invalid_request", "PKCE is required, with code_challenge_method=S256."); } if (params.resource && !(await isOurResource(params.resource))) { return fail("invalid_target", `Tokens can only be issued for ${await mcpResource()}.`); } return { request: { client, redirectUri, state, codeChallenge, permission: requestedPermission(params.scope) } }; } export async function deniedRedirect(request: AuthorizeRequest) { return withParams(request.redirectUri, { error: "access_denied", error_description: "The user didn't approve the request.", state: request.state, iss: await issuer(), }); } /** Records the user's approval and returns where to send them: back to the client, with a code. */ export async function approve(request: AuthorizeRequest, userId: string, grant: { permission: GrantPermission; repoIds: string[] | null }) { const pb = await db(); const code = newSecret(""); const now = Date.now(); await pb.collection("oauth_codes").create({ code_hash: sha256(code), client: request.client.id, user: userId, redirect_uri: request.redirectUri, code_challenge: request.codeChallenge, permission: grant.permission, all_repos: !grant.repoIds, repos: grant.repoIds ?? [], expires_at: now + CODE_MS, }); await pb.collection("oauth_clients").update(request.client.id, { last_authorized_at: now }); // Codes that were never exchanged. const stale = await pb.collection("oauth_codes").getFullList({ filter: filter("expires_at < {:now}", { now }), fields: "id" }); for (const { id } of stale) await pb.collection("oauth_codes").delete(id); return withParams(request.redirectUri, { code, state: request.state, iss: await issuer() }); } // --------------------------------------------------------------------------- // Tokens export type TokenResponse = { access_token: string; token_type: "Bearer"; expires_in: number; refresh_token: string; scope: string; }; /** New access and refresh tokens, with the fields that store them on a grant. */ function newTokens(permission: GrantPermission) { const access = newSecret(ACCESS_TOKEN_PREFIX); const refresh = newSecret(REFRESH_TOKEN_PREFIX); const now = Date.now(); return { fields: { access_hash: sha256(access), access_expires_at: now + ACCESS_TOKEN_SECONDS * 1000, refresh_hash: sha256(refresh), refresh_expires_at: now + REFRESH_TOKEN_MS, }, response: { access_token: access, token_type: "Bearer", expires_in: ACCESS_TOKEN_SECONDS, refresh_token: refresh, scope: scopeOf(permission), } satisfies TokenResponse, }; } // Exchanges and refreshes are read-modify-write sequences on codes and grants; one lock keeps two // requests from both using the same code or refresh token. const withTokenLock = (fn: () => Promise) => withLock("oauth", fn); export async function exchangeCode(input: { code: string; clientId: string; redirectUri: string; codeVerifier: string; resource?: string }) { return withTokenLock(async () => { const pb = await db(); const row = input.code ? await findFirst("oauth_codes", filter("code_hash = {:hash}", { hash: sha256(input.code) })) : null; if (!row) throw invalidGrant("The authorization code is invalid or was already used."); // A code works once, whatever happens next. await pb.collection("oauth_codes").delete(row.id); if (row.expires_at < Date.now()) throw invalidGrant("The authorization code has expired."); if (row.client !== input.clientId) throw invalidGrant("The authorization code was issued to another client."); if (row.redirect_uri !== input.redirectUri) throw invalidGrant("redirect_uri doesn't match the authorization request."); if (!verifyPkce(input.codeVerifier, row.code_challenge)) throw invalidGrant("code_verifier doesn't match the code_challenge."); if (input.resource && !(await isOurResource(input.resource))) { throw new OAuthError("invalid_target", `Tokens can only be issued for ${await mcpResource()}.`); } // Approving an application again replaces what it was allowed before. const previous = await pb.collection("oauth_grants").getFullList({ filter: filter("client = {:client} && user = {:user}", { client: row.client, user: row.user }), fields: "id", }); for (const { id } of previous) await pb.collection("oauth_grants").delete(id); const tokens = newTokens(row.permission); await pb.collection("oauth_grants").create({ client: row.client, user: row.user, permission: row.permission, all_repos: row.all_repos, repos: row.repos, ...tokens.fields, created_at: Date.now(), }); return tokens.response; }); } export async function refreshGrant(input: { refreshToken: string; clientId: string; resource?: string }) { return withTokenLock(async () => { const pb = await db(); const hash = sha256(input.refreshToken); const row = input.refreshToken ? await findFirst("oauth_grants", filter("refresh_hash = {:hash}", { hash })) : null; if (!row) { // A refresh token that was already replaced: someone else may hold a copy, so end the grant. const reused = input.refreshToken ? await findFirst("oauth_grants", filter("previous_refresh_hash = {:hash}", { hash }), { fields: "id" }) : null; if (reused) await pb.collection("oauth_grants").delete(reused.id); throw invalidGrant("The refresh token is invalid, expired or was already used."); } if (row.client !== input.clientId) throw invalidGrant("The refresh token was issued to another client."); if (row.refresh_expires_at < Date.now()) { await pb.collection("oauth_grants").delete(row.id); throw invalidGrant("The refresh token has expired."); } if (input.resource && !(await isOurResource(input.resource))) { throw new OAuthError("invalid_target", `Tokens can only be issued for ${await mcpResource()}.`); } const tokens = newTokens(row.permission); await pb.collection("oauth_grants").update(row.id, { ...tokens.fields, previous_refresh_hash: hash }); return tokens.response; }); } /** Resolves an access token to its user and what it may reach, or null if unknown or expired. */ export async function authenticateAccessToken(secret: string) { if (!secret.startsWith(ACCESS_TOKEN_PREFIX)) return null; const row = await findFirst("oauth_grants", filter("access_hash = {:hash}", { hash: sha256(secret) }), { expand: "user,repos.owner" }); const user = row?.expand?.user as RecordModel | undefined; const now = Date.now(); if (!row || !user || row.access_expires_at < now) return null; // Agents call often; once a minute is enough to show when a grant was last used. if (now - row.last_used_at > 60e3) await (await db()).collection("oauth_grants").update(row.id, { last_used_at: now }); const scope: TokenScope = { permission: row.permission, allRepos: row.all_repos, repos: coveredRepos(row) }; return { user: toUser(user), scope }; } /** Ends the grant an access or refresh token belongs to. Unknown tokens are ignored (RFC 7009). */ export async function revokeToken(secret: string) { const hash = sha256(secret); const row = await findFirst("oauth_grants", filter("access_hash = {:hash} || refresh_hash = {:hash}", { hash }), { fields: "id" }); if (row) await (await db()).collection("oauth_grants").delete(row.id); } // --------------------------------------------------------------------------- // Connected applications, for the settings page export type ConnectedApp = { id: string; name: string; uri: string | null; permission: GrantPermission; allRepos: boolean; repos: { id: string; fullName: string }[]; createdAt: number; lastUsedAt: number | null; }; export async function listConnectedApps(userId: string): Promise { const rows = await (await db()).collection("oauth_grants").getFullList({ filter: filter("user = {:userId} && refresh_expires_at > {:now}", { userId, now: Date.now() }), sort: "-created_at", expand: "client,repos.owner", }); return rows.map((r) => ({ id: r.id, name: r.expand?.client?.name ?? "Unknown application", uri: orNull(r.expand?.client?.client_uri), permission: r.permission, allRepos: r.all_repos, repos: coveredRepos(r), createdAt: r.created_at, lastUsedAt: orNull(r.last_used_at), })); } export async function disconnectApp(userId: string, grantId: string) { const row = await findFirst("oauth_grants", filter("id = {:grantId} && user = {:userId}", { grantId, userId }), { fields: "id" }); if (row) await (await db()).collection("oauth_grants").delete(row.id); } // --------------------------------------------------------------------------- // Discovery /** RFC 9728: tells a client that got a 401 from /mcp where to get a token. */ export async function protectedResourceMetadata() { return { resource: await mcpResource(), authorization_servers: [await issuer()], scopes_supported: ["read", "write"], bearer_methods_supported: ["header"], resource_name: "Vectrum", }; } /** RFC 8414: the authorization server's endpoints and what they support. */ export async function authorizationServerMetadata() { const origin = await issuer(); return { issuer: origin, authorization_endpoint: `${origin}/oauth/authorize`, token_endpoint: `${origin}/oauth/token`, registration_endpoint: `${origin}/oauth/register`, revocation_endpoint: `${origin}/oauth/revoke`, scopes_supported: ["read", "write"], response_types_supported: ["code"], response_modes_supported: ["query"], grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], revocation_endpoint_auth_methods_supported: ["none"], authorization_response_iss_parameter_supported: true, }; }