From 54f689247ea94af23525dc52a313a4c80e7cd2a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 3 May 2026 21:41:26 +0200 Subject: [PATCH] feat(auth): accept Bearer tokens for headless API clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `requireAuth()` keeps the existing cookie-session path as the primary check; when no session cookie is present it now falls back to validating `Authorization: Bearer hlk_*` against the existing `ApiToken` table (HMAC-SHA-256 hash, expiry + revocation enforced). Behaviour for browser clients is unchanged — the cookie path runs first and short-circuits before the header lookup. `requireAdmin()` stays cookie-only by design. Wide-Event annotation tags `auth_method: "bearer"` so the source is distinguishable in Loki. `auth.bearer.success` and `auth.bearer.failure` audit entries record the token id + reason. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/__tests__/require-auth-bearer.test.ts | 218 ++++++++++++++++++ src/lib/api-handler.ts | 198 ++++++++++++++-- src/lib/logging/types.ts | 2 +- 3 files changed, 392 insertions(+), 26 deletions(-) create mode 100644 src/lib/__tests__/require-auth-bearer.test.ts diff --git a/src/lib/__tests__/require-auth-bearer.test.ts b/src/lib/__tests__/require-auth-bearer.test.ts new file mode 100644 index 000000000..2298ded55 --- /dev/null +++ b/src/lib/__tests__/require-auth-bearer.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// --- Mocks must be hoisted before importing the module under test. --- + +vi.mock("@/lib/db", () => ({ + prisma: { + apiToken: { + findUnique: vi.fn(), + update: vi.fn(), + }, + user: { + findUnique: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(), +})); + +vi.mock("@/lib/auth/hmac", () => ({ + hashToken: vi.fn(), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ + emitIfSampled: vi.fn(), +})); + +const headersGet = vi.fn<(name: string) => string | null>(); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: headersGet })), + cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })), +})); + +// --- Imports use the mocked modules above. --- + +import { requireAuth, HttpError } from "../api-handler"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { hashToken } from "@/lib/auth/hmac"; +import { auditLog } from "@/lib/auth/audit"; + +const FAKE_HASH = "deadbeefcafef00d"; +const RAW_TOKEN = "hlk_" + "a".repeat(64); + +const FAKE_USER = { + id: "user-1", + role: "USER" as const, + username: "marc", + email: "marc@example.com", +}; + +function setBearerHeader(value: string | null): void { + headersGet.mockReset(); + headersGet.mockImplementation((name: string) => + name.toLowerCase() === "authorization" ? value : null, + ); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(hashToken).mockReturnValue(FAKE_HASH); + vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); + vi.mocked(auditLog).mockResolvedValue(undefined as never); +}); + +describe("requireAuth — Bearer token path", () => { + it("authenticates a valid Bearer token and returns AuthContext", async () => { + vi.mocked(getSession).mockResolvedValue(null); + setBearerHeader(`Bearer ${RAW_TOKEN}`); + + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "token-1", + userId: "user-1", + permissions: ["medication:ingest"], + revoked: false, + expiresAt, + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never); + + const ctx = await requireAuth(); + + expect(hashToken).toHaveBeenCalledWith(RAW_TOKEN); + expect(ctx.user.id).toBe("user-1"); + expect(ctx.session.id).toBe("token-1"); + expect(ctx.session.expiresAt).toEqual(expiresAt); + + // lastUsedAt refresh is fire-and-forget but should still have been triggered. + expect(prisma.apiToken.update).toHaveBeenCalledWith({ + where: { id: "token-1" }, + data: { lastUsedAt: expect.any(Date) }, + }); + expect(auditLog).toHaveBeenCalledWith( + "auth.bearer.success", + expect.objectContaining({ userId: "user-1" }), + ); + }); + + it("rejects a revoked token with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + setBearerHeader(`Bearer ${RAW_TOKEN}`); + + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "token-2", + userId: "user-1", + permissions: [], + revoked: true, + expiresAt: null, + } as never); + + await expect(requireAuth()).rejects.toMatchObject({ + statusCode: 401, + } satisfies Partial); + expect(auditLog).toHaveBeenCalledWith( + "auth.bearer.failure", + expect.objectContaining({ + details: expect.objectContaining({ reason: "revoked" }), + }), + ); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("rejects an expired token with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + setBearerHeader(`Bearer ${RAW_TOKEN}`); + + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "token-3", + userId: "user-1", + permissions: [], + revoked: false, + expiresAt: new Date(Date.now() - 60_000), + } as never); + + await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 }); + expect(auditLog).toHaveBeenCalledWith( + "auth.bearer.failure", + expect.objectContaining({ + details: expect.objectContaining({ reason: "expired" }), + }), + ); + }); + + it("rejects an unknown token with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + setBearerHeader(`Bearer ${RAW_TOKEN}`); + + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null); + + await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 }); + expect(auditLog).toHaveBeenCalledWith( + "auth.bearer.failure", + expect.objectContaining({ + details: expect.objectContaining({ reason: "unknown_token" }), + }), + ); + }); + + it("rejects a Bearer token missing the requested permission with 403", async () => { + vi.mocked(getSession).mockResolvedValue(null); + setBearerHeader(`Bearer ${RAW_TOKEN}`); + + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "token-4", + userId: "user-1", + permissions: ["something:else"], + revoked: false, + expiresAt: null, + } as never); + + await expect(requireAuth("medication:ingest")).rejects.toMatchObject({ + statusCode: 403, + }); + expect(auditLog).toHaveBeenCalledWith( + "auth.bearer.failure", + expect.objectContaining({ + details: expect.objectContaining({ + reason: "insufficient_permissions", + required: "medication:ingest", + }), + }), + ); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("returns 401 when neither cookie nor Bearer is present", async () => { + vi.mocked(getSession).mockResolvedValue(null); + setBearerHeader(null); + + await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 }); + expect(prisma.apiToken.findUnique).not.toHaveBeenCalled(); + }); +}); + +describe("requireAuth — cookie path remains intact", () => { + it("returns the session payload without consulting the Bearer header", async () => { + const cookieSession = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) }, + user: { ...FAKE_USER, role: "USER" as const }, + }; + vi.mocked(getSession).mockResolvedValue(cookieSession as never); + // Even if a Bearer header is present, the cookie wins (existing behaviour). + setBearerHeader(`Bearer ${RAW_TOKEN}`); + + const ctx = await requireAuth("medication:ingest"); + + expect(ctx).toEqual(cookieSession); + // Cookie short-circuits before any token logic runs. + expect(prisma.apiToken.findUnique).not.toHaveBeenCalled(); + expect(hashToken).not.toHaveBeenCalled(); + expect(auditLog).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 900604e1b..febb0ff33 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -1,8 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; +import { headers } from "next/headers"; +import type { User } from "@/generated/prisma/client"; import { WideEventBuilder } from "./logging/event-builder"; import { eventStorage, getEvent } from "./logging/context"; import { emitIfSampled } from "./logging/transports"; import { getSession } from "./auth/session"; +import { hashToken } from "./auth/hmac"; +import { prisma } from "./db"; +import { auditLog } from "./auth/audit"; /** * Custom error class for HTTP errors with status codes. @@ -98,17 +103,176 @@ export function apiHandler Promise>( } /** - * Require an authenticated session. Throws HttpError(401) if not authenticated. + * Authenticated request context. Returned by both session-cookie and Bearer-token + * authentication paths. The `session.id` is the session-record id for cookie auth + * and the `ApiToken` id for bearer auth — callers must not assume the id refers + * to a Session row. + */ +export type AuthContext = { + session: { id: string; expiresAt: Date }; + user: User; +}; + +/** + * Require an authenticated request. Throws HttpError(401) / HttpError(403) on failure. + * + * Auth precedence (cookie-first, never both): + * 1. Valid session cookie → cookie path (existing behaviour, requiredPermission ignored). + * 2. No cookie + `Authorization: Bearer hlk_<...>` → API token path. + * 3. Neither → 401. + * + * @param requiredPermission Optional permission scope. Only enforced for Bearer + * auth — cookie sessions always pass (full user access). When set and missing + * from `ApiToken.permissions`, throws HttpError(403). + */ +export async function requireAuth( + requiredPermission?: string, +): Promise { + // 1. Session cookie path — unchanged. + const sessionData = await getSession(); + if (sessionData) { + const evt = getEvent(); + if (evt) { + evt.setAuth({ + user_id: sessionData.user.id, + user_role: sessionData.user.role, + auth_method: "session", + }); + } + return sessionData; + } + + // 2. Bearer-token path. + // `headers()` is only valid inside a Next.js request scope. Outside one + // (e.g. during direct unit tests of legacy routes that pre-date Bearer auth) + // we treat the absence of a header context as "no Bearer present" and fall + // through to the unauthenticated case below. + let authHeader: string | null = null; + try { + const headerList = await headers(); + authHeader = headerList.get("authorization"); + } catch { + authHeader = null; + } + if (authHeader?.startsWith("Bearer ")) { + return await authenticateBearer(authHeader.slice(7), requiredPermission); + } + + // 3. No credentials. + throw new HttpError(401, "Not authenticated"); +} + +/** + * Authenticate a raw Bearer token against `ApiToken`. + * Annotates the Wide Event with `auth_method: "api_key"` and writes audit-log + * entries for both success and failure. + */ +async function authenticateBearer( + rawToken: string, + requiredPermission: string | undefined, +): Promise { + const tokenHashValue = hashToken(rawToken); + + const apiToken = await prisma.apiToken.findUnique({ + where: { tokenHash: tokenHashValue }, + select: { + id: true, + userId: true, + permissions: true, + revoked: true, + expiresAt: true, + }, + }); + + if (!apiToken) { + auditLog("auth.bearer.failure", { + details: { reason: "unknown_token" }, + }).catch(() => {}); + throw new HttpError(401, "Invalid token"); + } + + if (apiToken.revoked) { + auditLog("auth.bearer.failure", { + userId: apiToken.userId, + details: { reason: "revoked", tokenId: apiToken.id }, + }).catch(() => {}); + throw new HttpError(401, "Invalid token"); + } + + if (apiToken.expiresAt && apiToken.expiresAt <= new Date()) { + auditLog("auth.bearer.failure", { + userId: apiToken.userId, + details: { reason: "expired", tokenId: apiToken.id }, + }).catch(() => {}); + throw new HttpError(401, "Token expired"); + } + + if ( + requiredPermission && + !apiToken.permissions.includes(requiredPermission) + ) { + auditLog("auth.bearer.failure", { + userId: apiToken.userId, + details: { + reason: "insufficient_permissions", + tokenId: apiToken.id, + required: requiredPermission, + }, + }).catch(() => {}); + throw new HttpError(403, "Insufficient permissions"); + } + + const user = await prisma.user.findUnique({ + where: { id: apiToken.userId }, + }); + + if (!user) { + auditLog("auth.bearer.failure", { + userId: apiToken.userId, + details: { reason: "user_missing", tokenId: apiToken.id }, + }).catch(() => {}); + throw new HttpError(401, "Invalid token"); + } + + // Fire-and-forget: refresh lastUsedAt without blocking the request. + prisma.apiToken + .update({ + where: { id: apiToken.id }, + data: { lastUsedAt: new Date() }, + }) + .catch(() => {}); + + auditLog("auth.bearer.success", { + userId: user.id, + details: { tokenId: apiToken.id }, + }).catch(() => {}); + + const evt = getEvent(); + if (evt) { + evt.setAuth({ + user_id: user.id, + user_role: user.role, + auth_method: "bearer", + }); + } + + // Use the token expiry as the session expiry; fall back to a 30-day window if + // the token has no fixed expiry so the contract `{ expiresAt: Date }` holds. + const expiresAt = + apiToken.expiresAt ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + + return { + session: { id: apiToken.id, expiresAt }, + user, + }; +} + +/** + * Require an authenticated admin user. Throws HttpError(401) or HttpError(403). + * Cookie-only — Bearer tokens never elevate to admin (security boundary). * Automatically annotates the Wide Event with auth context. */ -export async function requireAuth(): Promise<{ - session: { id: string; expiresAt: Date }; - user: Awaited> extends infer R - ? R extends { user: infer U } - ? U - : never - : never; -}> { +export async function requireAdmin(): Promise { const sessionData = await getSession(); if (!sessionData) throw new HttpError(401, "Not authenticated"); @@ -121,22 +285,6 @@ export async function requireAuth(): Promise<{ }); } - return sessionData; -} - -/** - * Require an authenticated admin user. Throws HttpError(401) or HttpError(403). - * Automatically annotates the Wide Event with auth context. - */ -export async function requireAdmin(): Promise<{ - session: { id: string; expiresAt: Date }; - user: Awaited> extends infer R - ? R extends { user: infer U } - ? U - : never - : never; -}> { - const sessionData = await requireAuth(); if (sessionData.user.role !== "ADMIN") { throw new HttpError(403, "Admin access required"); } diff --git a/src/lib/logging/types.ts b/src/lib/logging/types.ts index 6b76e03ef..47c23d615 100644 --- a/src/lib/logging/types.ts +++ b/src/lib/logging/types.ts @@ -51,7 +51,7 @@ export interface WideEvent { auth?: { user_id?: string; user_role?: string; - auth_method?: "session" | "api_key" | "cron_secret" | "telegram_webhook" | "webhook_secret"; + auth_method?: "session" | "bearer" | "api_key" | "cron_secret" | "telegram_webhook" | "webhook_secret"; }; // Business-Daten (was wurde getan)