Vectrum
Sign In
119 lines · 4.5 KB · TypeScript
import { createHash, randomBytes } from "node:crypto";
import { db, filter, findFirst, orNull, type RecordModel } from "./db";
import { toUser } from "./users";

export const TOKEN_PREFIX = "vec_";
/** Prefix of tokens issued before the rename to Vectrum; still accepted. */
const LEGACY_TOKEN_PREFIX = "mng_";

export type TokenPermission = "read" | "write";

export type ApiToken = {
  id: string;
  userId: string;
  name: string;
  hint: string;
  permission: TokenPermission;
  allRepos: boolean;
  repos: { id: string; fullName: string }[];
  expiresAt: number | null;
  expired: boolean;
  createdAt: number;
  lastUsedAt: number | null;
};

const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");

/** The repositories a token or grant lists, with `expand: "repos.owner"`. */
export function coveredRepos(r: RecordModel) {
  if (r.all_repos) return [];
  return ((r.expand?.repos ?? []) as RecordModel[])
    .map((p) => ({ id: p.id, fullName: `${p.expand?.owner?.username}/${p.name}` }))
    .sort((a, b) => a.fullName.localeCompare(b.fullName));
}

function toToken(r: RecordModel): ApiToken {
  return {
    id: r.id,
    userId: r.user,
    name: r.name,
    hint: r.hint,
    permission: r.permission,
    allRepos: r.all_repos,
    repos: coveredRepos(r),
    expiresAt: orNull(r.expires_at),
    expired: !!r.expires_at && r.expires_at < Date.now(),
    createdAt: r.created_at,
    lastUsedAt: orNull(r.last_used_at),
  };
}

const EXPAND = "repos.owner";

export class TokenError extends Error {}

/** Creates a token and returns its secret value; only its hash is stored. */
export async function createToken(
  userId: string,
  input: { name: string; permission: TokenPermission; expiresInDays: number | null; repoIds: string[] | null },
) {
  const name = input.name.trim().slice(0, 100);
  if (!name) throw new TokenError("Give your token a name so you can recognize it later.");
  if (input.repoIds && input.repoIds.length === 0) throw new TokenError("Select at least one repository.");
  const secret = `${TOKEN_PREFIX}${randomBytes(30).toString("base64url")}`;
  const now = Date.now();
  await (await db()).collection("api_tokens").create({
    user: userId,
    name,
    token_hash: sha256(secret),
    hint: secret.slice(-4),
    permission: input.permission,
    all_repos: !input.repoIds,
    repos: input.repoIds ?? [],
    expires_at: input.expiresInDays ? now + input.expiresInDays * 86400e3 : 0,
    created_at: now,
  });
  return secret;
}

export async function listTokens(userId: string) {
  const rows = await (await db())
    .collection("api_tokens")
    .getFullList({ filter: filter("user = {:userId}", { userId }), sort: "-created_at", expand: EXPAND });
  return rows.map(toToken);
}

export async function deleteToken(userId: string, tokenId: string) {
  const token = await findFirst("api_tokens", filter("id = {:tokenId} && user = {:userId}", { tokenId, userId }), { fields: "id" });
  if (token) await (await db()).collection("api_tokens").delete(token.id);
}

/** Resolves a secret token to its owner, or null if unknown or expired. */
export async function authenticateToken(secret: string) {
  if (!secret.startsWith(TOKEN_PREFIX) && !secret.startsWith(LEGACY_TOKEN_PREFIX)) return null;
  const row = await findFirst("api_tokens", filter("token_hash = {:hash}", { hash: sha256(secret) }), { expand: `user,${EXPAND}` });
  const user = row?.expand?.user as RecordModel | undefined;
  if (!row || !user || (row.expires_at && row.expires_at < Date.now())) return null;
  await (await db()).collection("api_tokens").update(row.id, { last_used_at: Date.now() });
  return { user: toUser(user), token: toToken(row) };
}

/**
 * Credentials of git and registry requests: HTTP Basic auth with an API token as the password
 * (the username is ignored). Null without credentials, "invalid" for an unknown or expired token.
 */
export async function basicAuthToken(request: Request) {
  const header = request.headers.get("authorization");
  if (!header?.startsWith("Basic ")) return null;
  const decoded = Buffer.from(header.slice(6), "base64").toString("utf8");
  const password = decoded.slice(decoded.indexOf(":") + 1);
  return (await authenticateToken(password)) ?? "invalid";
}

/** What a token may reach. OAuth grants for agents (lib/oauth.ts) have the same shape. */
export type TokenScope = Pick<ApiToken, "permission" | "allRepos" | "repos">;

export function tokenCovers(token: TokenScope, repoId: string, needWrite: boolean) {
  if (needWrite && token.permission !== "write") return false;
  return token.allRepos || token.repos.some((r) => r.id === repoId);
}