mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
42d49953f5
commit
f3e004998a
3 files changed
+312
-102
No files matched your search
+38
-102
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user