cx-nexredirect/lib/api-auth.ts

51 lines
1.9 KiB
TypeScript

import crypto from "crypto";
import { NextResponse } from "next/server";
import { getDb, type ApiTokenRow } from "./db";
export type Scope = "read:domains" | "write:domains" | "read:analytics" | "read:hits";
export const ALL_SCOPES: Scope[] = ["read:domains", "write:domains", "read:analytics", "read:hits"];
export function generateToken(): { plaintext: string; hash: string } {
const random = crypto.randomBytes(32).toString("hex");
const plaintext = `nrx_${random}`;
const hash = crypto.createHash("sha256").update(plaintext).digest("hex");
return { plaintext, hash };
}
function hashToken(plaintext: string): string {
return crypto.createHash("sha256").update(plaintext).digest("hex");
}
export type AuthedToken = {
id: number;
name: string;
scopes: Scope[];
};
export function authenticateToken(req: Request): AuthedToken | null {
const auth = req.headers.get("authorization");
if (!auth || !auth.toLowerCase().startsWith("bearer ")) return null;
const token = auth.slice(7).trim();
if (!token.startsWith("nrx_")) return null;
const hash = hashToken(token);
const row = getDb()
.prepare("SELECT * FROM api_tokens WHERE token_hash = ? AND revoked_at IS NULL LIMIT 1")
.get(hash) as ApiTokenRow | undefined;
if (!row) return null;
getDb().prepare("UPDATE api_tokens SET last_used_at = ? WHERE id = ?").run(Date.now(), row.id);
let scopes: Scope[] = [];
try { scopes = JSON.parse(row.scopes); } catch {}
return { id: row.id, name: row.name, scopes };
}
export function requireScope(req: Request, scope: Scope): AuthedToken | NextResponse {
const t = authenticateToken(req);
if (!t) return NextResponse.json({ error: "unauthorized", code: "no_token" }, { status: 401 });
if (!t.scopes.includes(scope)) {
return NextResponse.json({ error: "forbidden", code: "missing_scope", required: scope }, { status: 403 });
}
return t;
}