Files
HealthLog/src/lib/auth/bearer.ts
T
Marc-André Bombeck df611227f9 fix(auth): make Bearer scope fail closed on every route
resolveBearerToken took an optional permission, and an unset one admitted
any valid token. 324 of the 330 route files pass no permission, so a token
minted with a narrow scope reached the full-backup export, the labs and
measurement surfaces, the coach, and the bulk deletes — the settings copy
tells users to hand exactly that token to third-party automations.

Invert the default. The scope argument becomes a required ScopeRequirement
union: a route that names no scope now accepts cookie sessions and
cookie-equivalent ["*"] tokens only, and refuses a narrow token with 403 and
an undeclared_scope audit row. A route that means to accept a narrow token
names it, as the FHIR routes already do.

No route file changes. The absence of an argument becomes a positive
statement rather than an omission, so a newly added route denies a newly
minted narrow scope by default — the safe outcome is the one you get by
doing nothing.

/mcp keeps its own posture through an explicit any-valid-token requirement:
it authenticates here and authorises downstream on audience and write scope.
That is the single deliberate fail-open call site in the tree, and making the
parameter required is what keeps it greppable.

An MCP-audience token loses its REST read leg as a result, narrowing it to
/mcp only. The isMcpAudienceToken guard stays as defence in depth.
2026-07-19 00:44:10 +02:00

179 lines
5.9 KiB
TypeScript

/**
* 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"
| "undeclared_scope"
| "user_missing";
/**
* What a caller demands of a token's scope set. Required on every
* `resolveBearerToken` call — there is deliberately no default, so a new wire
* cannot re-open the fail-open hole by simply omitting the argument.
*
* - `scope` — the route names a grant; a non-wildcard token must list it.
* - `wildcard-only` — the REST default. A route that declares no scope accepts
* cookie sessions and cookie-equivalent (`["*"]`) tokens only. A narrow token
* is refused, so adding a route can never silently widen an existing token's
* reach.
* - `any-valid-token` — authentication only; authorisation is decided by the
* caller's own wire. This is the single deliberate fail-open posture in the
* tree (the `/mcp` transport, which gates on audience + `tokenAllowsWrite`
* downstream) and a structural test freezes it to exactly one call site.
*/
export type ScopeRequirement =
| { kind: "scope"; scope: string }
| { kind: "wildcard-only" }
| { kind: "any-valid-token" };
/**
* 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. Authorisation is fail-closed: `requirement` is mandatory and
* a token carrying no `*` grant is admitted only when the caller names a scope
* the token lists, or explicitly opts into `any-valid-token`.
*
* `lastUsedAt` is refreshed fire-and-forget on success.
*/
export async function resolveBearerToken(
rawToken: string,
requirement: ScopeRequirement,
): 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 — cookie-equivalent, and the only shape the
// login / passkey / refresh paths mint. It clears every requirement.
//
// A narrow token has to be named. `wildcard-only` (the REST default, reached
// by every route that passes no scope) refuses it outright; `scope` admits it
// only when the token lists exactly that grant. The deny arm is what a route
// gets by doing nothing, so a newly added route cannot widen an existing
// token's reach without a visible diff.
if (!apiToken.permissions.includes("*")) {
if (requirement.kind === "wildcard-only") {
throw new BearerAuthError(
403,
"undeclared_scope",
apiToken.userId,
apiToken.id,
);
}
if (
requirement.kind === "scope" &&
!apiToken.permissions.includes(requirement.scope)
) {
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,
};
}