mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user