refactor(auth): extract Bearer-token validation into a reusable resolver

Move the canonical Bearer-token validation out of the api-handler request
wrapper into src/lib/auth/bearer.ts so it can run outside a Next.js request
scope without dragging next/server, next/headers, or the Wide-Event machinery
into the import graph. requireAuth's Bearer path now delegates to it and keeps
the existing auth.bearer.* audit trail and Wide-Event annotation, so the
revoked / expired / scope / lastUsedAt semantics live in one place and cannot
drift between callers.

Covered by a focused unit test pinning the hash-only lookup and the
revoked / expired / insufficient-permission rejections; the existing
require-auth Bearer suite stays green.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 13:25:43 +02:00
1 parent 42d49953f5
commit f3e004998a
3 files changed
+312 -102

No files matched your search

+38 -102
View File
@@ -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<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");
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,
};
}
+130
View File
@@ -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<BearerAuthError>);
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");
});
});
+144
View File
@@ -0,0 +1,144 @@
/**
* Canonical Bearer-token validation — the single source of truth for resolving
* a raw `hlk_<hex>` 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 `<user>:<token>` 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_<hex>` 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<BearerResolution> {
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,
};
}