Files
caddy-proxy-manager/src/lib/api-auth.ts
fuomag9 de28478a42 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>
2026-03-26 09:45:45 +01:00

93 lines
2.5 KiB
TypeScript

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 }
);
}