feat: add comprehensive REST API with token auth, OpenAPI docs, and full test coverage
- API token model (SHA-256 hashed, debounced lastUsedAt) with Bearer auth - Dual auth middleware (session + API token) in src/lib/api-auth.ts - 23 REST endpoints under /api/v1/ covering all functionality: tokens, proxy-hosts, l4-proxy-hosts, certificates, ca-certificates, client-certificates, access-lists, settings, instances, users, audit-log, caddy/apply - OpenAPI 3.1 spec at /api/v1/openapi.json with fully typed schemas - Swagger UI docs page at /api-docs in the dashboard - API token management integrated into the Profile page - Fix: next build now works under Node.js (bun:sqlite aliased to better-sqlite3) - 89 new API route unit tests + 11 integration tests (592 total) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
92
src/lib/api-auth.ts
Normal file
92
src/lib/api-auth.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { auth, checkSameOrigin } from "./auth";
|
||||
import { validateToken } from "./models/api-tokens";
|
||||
|
||||
export class ApiAuthError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "ApiAuthError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export type ApiAuthResult = {
|
||||
userId: number;
|
||||
role: string;
|
||||
authMethod: "bearer" | "session";
|
||||
};
|
||||
|
||||
export async function authenticateApiRequest(
|
||||
request: NextRequest
|
||||
): Promise<ApiAuthResult> {
|
||||
// Try Bearer token first
|
||||
const authHeader = request.headers.get("authorization") ?? "";
|
||||
if (authHeader.startsWith("Bearer ")) {
|
||||
const rawToken = authHeader.slice(7);
|
||||
if (!rawToken) {
|
||||
throw new ApiAuthError("Invalid Bearer token", 401);
|
||||
}
|
||||
|
||||
const result = await validateToken(rawToken);
|
||||
if (!result) {
|
||||
throw new ApiAuthError("Invalid or expired API token", 401);
|
||||
}
|
||||
|
||||
return {
|
||||
userId: result.user.id,
|
||||
role: result.user.role,
|
||||
authMethod: "bearer",
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to session auth
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
throw new ApiAuthError("Unauthorized", 401);
|
||||
}
|
||||
|
||||
return {
|
||||
userId: Number(session.user.id),
|
||||
role: session.user.role ?? "user",
|
||||
authMethod: "session",
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireApiUser(request: NextRequest): Promise<ApiAuthResult> {
|
||||
const result = await authenticateApiRequest(request);
|
||||
|
||||
// CSRF check for session-authenticated mutating requests
|
||||
if (result.authMethod === "session") {
|
||||
const method = request.method.toUpperCase();
|
||||
if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
|
||||
const csrfResponse = checkSameOrigin(request);
|
||||
if (csrfResponse) {
|
||||
throw new ApiAuthError("Forbidden", 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function requireApiAdmin(request: NextRequest): Promise<ApiAuthResult> {
|
||||
const result = await requireApiUser(request);
|
||||
if (result.role !== "admin") {
|
||||
throw new ApiAuthError("Administrator privileges required", 403);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to build an error response from an ApiAuthError or generic error.
|
||||
*/
|
||||
export function apiErrorResponse(error: unknown): NextResponse {
|
||||
if (error instanceof ApiAuthError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user