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");
function toToken(r: RecordModel): ApiToken {
const repos = (r.expand?.repos ?? []) as RecordModel[];
return {
id: r.id,
userId: r.user,
name: r.name,
hint: r.hint,
permission: r.permission,
allRepos: r.all_repos,
repos: r.all_repos
? []
: repos.map((p) => ({ id: p.id, fullName: `${p.expand?.owner?.username}/${p.name}` })).sort((a, b) => a.fullName.localeCompare(b.fullName)),
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";
}
export function tokenCovers(token: ApiToken, repoId: string, needWrite: boolean) {
if (needWrite && token.permission !== "write") return false;
return token.allRepos || token.repos.some((r) => r.id === repoId);
}