feat(auth): accept Bearer tokens for headless API clients

`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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-03 21:51:54 +02:00
1 parent 7507c29c4c
commit 54f689247e
3 files changed
+392 -26

No files matched your search

@@ -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<HttpError>);
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();
});
});
+173 -25
View File
@@ -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<T extends (...args: any[]) => Promise<Response>>(
}
/**
* 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<AuthContext> {
// 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<AuthContext> {
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<ReturnType<typeof getSession>> extends infer R
? R extends { user: infer U }
? U
: never
: never;
}> {
export async function requireAdmin(): Promise<AuthContext> {
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<ReturnType<typeof getSession>> 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");
}
+1 -1
View File
@@ -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)