diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 4733b9ac2..ba78ad92f 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -6,9 +6,8 @@ import { eventStorage, getEvent } from "./logging/context"; import { emitIfSampled } from "./logging/transports"; import { redactOptional, redactSecrets } from "./logging/redact"; import { getSession } from "./auth/session"; -import { hashToken } from "./auth/hmac"; -import { prisma } from "./db"; import { auditLog } from "./auth/audit"; +import { resolveBearerToken, BearerAuthError } from "./auth/bearer"; import { AssistantDisabledError } from "./feature-flags"; import { ConsentRequiredError } from "./ai/consent-guard"; @@ -291,110 +290,52 @@ export async function requireAuth( /** * 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. + * + * The validation itself lives in the transport-agnostic `resolveBearerToken` + * (`./auth/bearer`) — the single source of truth shared with the MCP wire. This + * wrapper adds the HTTP-edge concerns: the `auth.bearer.*` audit trail for both + * outcomes and the Wide-Event `auth_method: "bearer"` annotation, then maps the + * result onto the `AuthContext` contract (`session.id` carries the token id). + * + * Authorisation contract (unchanged): a route that declares no + * `requiredPermission` accepts any valid token; one that declares a scope + * accepts wildcard (`["*"]`) tokens and narrow-scope tokens that list it. */ 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"); + let resolution; + try { + resolution = await resolveBearerToken(rawToken, requiredPermission); + } catch (err) { + if (err instanceof BearerAuthError) { + auditLog("auth.bearer.failure", { + userId: err.userId ?? null, + details: { + reason: err.reason, + ...(err.tokenId ? { tokenId: err.tokenId } : {}), + ...(err.reason === "insufficient_permissions" + ? { required: requiredPermission } + : {}), + }, + }).catch(() => {}); + const message = + err.statusCode === 403 + ? "Insufficient permissions" + : err.reason === "expired" + ? "Token expired" + : "Invalid token"; + throw new HttpError(err.statusCode, message); + } + throw err; } - 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"); - } - - // `["*"]` is the wildcard scope: granted to the iOS app on login so it - // can call any authenticated route. Narrow scopes (e.g. - // `["medication:ingest"]`) gate specific routes. - // - // Authorisation contract: - // - Route declares no `requiredPermission`: any valid token passes - // (both wildcard and narrow-scope). The route does not gate on - // scope, so authentication alone is sufficient. - // - Route declares a `requiredPermission`: wildcard tokens pass; - // narrow-scope tokens must list the required permission. - // - // v1.4.25 W10 reconcile (code-review H2): the previous "no - // requiredPermission ⇒ wildcard only" rule misread the contract. - // Routes without a declared scope (`/api/measurements/by-external-ids`, - // `/api/personal-records`, `/api/medications/[id]/glp1`, - // `/api/dashboard/glp1`) 403'd every narrow-scope token even though - // those routes did not intend to restrict by scope at all. Allowing - // any authenticated token through when the route does not declare a - // scope unblocks the v1.5 iOS endpoints while keeping the scoped- - // route gating below intact. - const hasWildcardPermission = apiToken.permissions.includes("*"); - - if ( - requiredPermission && - !hasWildcardPermission && - !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(() => {}); + const { user, tokenId, expiresAt } = resolution; auditLog("auth.bearer.success", { userId: user.id, - details: { tokenId: apiToken.id }, + details: { tokenId }, }).catch(() => {}); const evt = getEvent(); @@ -406,13 +347,8 @@ async function authenticateBearer( }); } - // 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 }, + session: { id: tokenId, expiresAt }, user, }; } diff --git a/src/lib/auth/__tests__/bearer.test.ts b/src/lib/auth/__tests__/bearer.test.ts new file mode 100644 index 000000000..f3dc89159 --- /dev/null +++ b/src/lib/auth/__tests__/bearer.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + apiToken: { findUnique: vi.fn(), update: vi.fn() }, + user: { findUnique: vi.fn() }, + }, +})); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() })); + +import { resolveBearerToken, BearerAuthError } from "../bearer"; +import { prisma } from "@/lib/db"; +import { hashToken } from "@/lib/auth/hmac"; + +const FAKE_HASH = "deadbeefcafef00d"; +const RAW_TOKEN = "hlk_" + "a".repeat(64); +const FAKE_USER = { id: "user-1", role: "USER" }; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(hashToken).mockReturnValue(FAKE_HASH); + vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); +}); + +describe("resolveBearerToken", () => { + it("looks the token up by HMAC hash, never the plaintext", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "token-1", + userId: "user-1", + permissions: ["*"], + revoked: false, + expiresAt: new Date(Date.now() + 86_400_000), + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never); + + const res = await resolveBearerToken(RAW_TOKEN); + + expect(hashToken).toHaveBeenCalledWith(RAW_TOKEN); + expect(prisma.apiToken.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { tokenHash: FAKE_HASH } }), + ); + expect(res.user.id).toBe("user-1"); + expect(res.tokenId).toBe("token-1"); + expect(res.permissions).toEqual(["*"]); + expect(prisma.apiToken.update).toHaveBeenCalledWith({ + where: { id: "token-1" }, + data: { lastUsedAt: expect.any(Date) }, + }); + }); + + it("rejects an unknown token (401)", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null as never); + await expect(resolveBearerToken(RAW_TOKEN)).rejects.toMatchObject({ + statusCode: 401, + reason: "unknown_token", + } satisfies Partial); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("rejects a revoked token (401)", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "token-2", + userId: "user-1", + permissions: [], + revoked: true, + expiresAt: null, + } as never); + await expect(resolveBearerToken(RAW_TOKEN)).rejects.toMatchObject({ + statusCode: 401, + reason: "revoked", + }); + }); + + it("rejects an expired token (401)", async () => { + 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(resolveBearerToken(RAW_TOKEN)).rejects.toMatchObject({ + statusCode: 401, + reason: "expired", + }); + }); + + it("rejects a narrow-scope token missing the required permission (403)", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "token-4", + userId: "user-1", + permissions: ["something:else"], + revoked: false, + expiresAt: null, + } as never); + await expect( + resolveBearerToken(RAW_TOKEN, "health:write"), + ).rejects.toMatchObject({ + statusCode: 403, + reason: "insufficient_permissions", + }); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("admits any valid token when no permission is required (REQ-SEC-5)", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "token-5", + userId: "user-1", + permissions: ["medication:ingest"], + revoked: false, + expiresAt: null, + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never); + const res = await resolveBearerToken(RAW_TOKEN); + expect(res.user.id).toBe("user-1"); + }); + + it("admits a wildcard token for any required permission", async () => { + vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ + id: "token-6", + userId: "user-1", + permissions: ["*"], + revoked: false, + expiresAt: null, + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never); + const res = await resolveBearerToken(RAW_TOKEN, "health:write"); + expect(res.user.id).toBe("user-1"); + }); +}); diff --git a/src/lib/auth/bearer.ts b/src/lib/auth/bearer.ts new file mode 100644 index 000000000..694f27f14 --- /dev/null +++ b/src/lib/auth/bearer.ts @@ -0,0 +1,144 @@ +/** + * Canonical Bearer-token validation — the single source of truth for resolving + * a raw `hlk_` token to a user. + * + * Extracted from `api-handler.ts` so it can be reused outside a Next.js request + * scope (the local MCP stdio transport runs in a plain Node process and must not + * drag `next/server` / `next/headers` / the Wide-Event machinery into its import + * graph). The HTTP edge (`requireAuth` → `authenticateBearer`) and the MCP edge + * both call this one function, so the revoked / expired / scope / `lastUsedAt` + * semantics can never drift between the two wires. + * + * This module is deliberately lean: it depends only on the HMAC hasher and + * Prisma. Audit-log emission + Wide-Event annotation stay with each caller (the + * HTTP path keeps its existing `auth.bearer.*` audit trail), so this layer adds + * no I/O beyond the two reads the validation needs. + */ +import type { User } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { hashToken } from "@/lib/auth/hmac"; + +/** Why a Bearer token was rejected. Stable handles for audit details. */ +export type BearerAuthReason = + | "unknown_token" + | "revoked" + | "expired" + | "insufficient_permissions" + | "user_missing"; + +/** + * Thrown when a token fails validation. Carries the HTTP status the HTTP edge + * should surface plus the machine `reason` (and, when known, the owning + * `userId` / `tokenId`) so a caller can write a faithful audit entry without + * re-deriving them. + */ +export class BearerAuthError extends Error { + constructor( + public readonly statusCode: 401 | 403, + public readonly reason: BearerAuthReason, + public readonly userId?: string, + public readonly tokenId?: string, + ) { + super(reason); + this.name = "BearerAuthError"; + } +} + +/** The validated identity behind a Bearer token. */ +export interface BearerResolution { + user: User; + /** The `ApiToken` row id (the token-id half of the `:` binding). */ + tokenId: string; + /** The token's granted scopes (`["*"]` = wildcard). */ + permissions: string[]; + /** Token expiry, or a 30-day fallback window when the token has no fixed expiry. */ + expiresAt: Date; +} + +/** + * Resolve a raw `hlk_` token to its user, or throw `BearerAuthError`. + * + * Lookups are by HMAC hash only — the plaintext token is never compared against + * a stored value. When `requiredPermission` is set, a non-wildcard token must + * list it; an unset `requiredPermission` admits any valid token (matching the + * "route declares no scope ⇒ authentication alone is sufficient" contract). + * + * `lastUsedAt` is refreshed fire-and-forget on success. + */ +export async function resolveBearerToken( + rawToken: string, + requiredPermission?: string, +): 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) { + throw new BearerAuthError(401, "unknown_token"); + } + + if (apiToken.revoked) { + throw new BearerAuthError(401, "revoked", apiToken.userId, apiToken.id); + } + + if (apiToken.expiresAt && apiToken.expiresAt <= new Date()) { + throw new BearerAuthError(401, "expired", apiToken.userId, apiToken.id); + } + + // `["*"]` is the wildcard scope. A route that declares no `requiredPermission` + // accepts any valid token; one that declares a scope accepts wildcard tokens + // and narrow-scope tokens that list that scope. + const hasWildcardPermission = apiToken.permissions.includes("*"); + if ( + requiredPermission && + !hasWildcardPermission && + !apiToken.permissions.includes(requiredPermission) + ) { + throw new BearerAuthError( + 403, + "insufficient_permissions", + apiToken.userId, + apiToken.id, + ); + } + + const user = await prisma.user.findUnique({ + where: { id: apiToken.userId }, + }); + + if (!user) { + throw new BearerAuthError( + 401, + "user_missing", + apiToken.userId, + apiToken.id, + ); + } + + // Fire-and-forget: refresh lastUsedAt without blocking the caller. + prisma.apiToken + .update({ + where: { id: apiToken.id }, + data: { lastUsedAt: new Date() }, + }) + .catch(() => {}); + + const expiresAt = + apiToken.expiresAt ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + + return { + user, + tokenId: apiToken.id, + permissions: apiToken.permissions, + expiresAt, + }; +}