Vectrum
Sign In
401 lines · 17.4 KB · TypeScript
import "server-only";
import { createHash, randomBytes } from "node:crypto";
import { appAgentKey, type AgentRef } from "./actor";
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<string, unknown>) {
  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<string, string | undefined>,
): 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 = <T>(fn: () => Promise<T>) => 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, the agent it is, 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,client,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) };
  const agent: AgentRef = { key: appAgentKey(row.client, row.user), name: row.expand?.client?.name ?? "Agent" };
  return { user: toUser(user), scope, agent };
}

/** 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;
  /** The agent key its activity is recorded under. */
  agentKey: 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<ConnectedApp[]> {
  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,
    agentKey: appAgentKey(r.client, r.user),
    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,
  };
}