From 45f23f669fbf2af7621a74b0d0e040113ac3d35d Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 26 Jan 2026 20:28:24 -0500 Subject: [PATCH 1/3] fix(pulse): support Bearer token authentication Update pulse validation to use validateAuthContext which supports both x-api-key and Authorization Bearer token authentication methods. Co-Authored-By: Claude Opus 4.5 --- lib/pulse/validateGetPulseRequest.ts | 30 +++++++++---------------- lib/pulse/validateUpdatePulseRequest.ts | 30 +++++++++---------------- 2 files changed, 20 insertions(+), 40 deletions(-) diff --git a/lib/pulse/validateGetPulseRequest.ts b/lib/pulse/validateGetPulseRequest.ts index b3aa1d3..1f6d23d 100644 --- a/lib/pulse/validateGetPulseRequest.ts +++ b/lib/pulse/validateGetPulseRequest.ts @@ -1,7 +1,6 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; -import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; export type GetPulseRequestResult = { accountId: string; @@ -9,7 +8,8 @@ export type GetPulseRequestResult = { /** * Validates GET /api/pulse request. - * Handles authentication via x-api-key and optional account_id query parameter. + * Handles authentication via x-api-key or Authorization bearer token, + * and optional account_id query parameter. * * @param request - The NextRequest object * @returns A NextResponse with an error if validation fails, or the validated result @@ -17,26 +17,16 @@ export type GetPulseRequestResult = { export async function validateGetPulseRequest( request: NextRequest, ): Promise { - const accountIdOrError = await getApiKeyAccountId(request); - if (accountIdOrError instanceof NextResponse) { - return accountIdOrError; - } - let accountId = accountIdOrError; - const { searchParams } = new URL(request.url); const targetAccountId = searchParams.get("account_id"); - if (targetAccountId) { - const apiKey = request.headers.get("x-api-key"); - const overrideResult = await validateOverrideAccountId({ - apiKey, - targetAccountId, - }); - if (overrideResult instanceof NextResponse) { - return overrideResult; - } - accountId = overrideResult.accountId; + const authResult = await validateAuthContext(request, { + accountId: targetAccountId ?? undefined, + }); + + if (authResult instanceof NextResponse) { + return authResult; } - return { accountId }; + return { accountId: authResult.accountId }; } diff --git a/lib/pulse/validateUpdatePulseRequest.ts b/lib/pulse/validateUpdatePulseRequest.ts index 466c6df..42ffe18 100644 --- a/lib/pulse/validateUpdatePulseRequest.ts +++ b/lib/pulse/validateUpdatePulseRequest.ts @@ -1,7 +1,6 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; -import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { safeParseJson } from "@/lib/networking/safeParseJson"; import { validateUpdatePulseBody } from "./validateUpdatePulseBody"; @@ -12,7 +11,8 @@ export type UpdatePulseRequestResult = { /** * Validates PATCH /api/pulse request. - * Handles authentication via x-api-key, body validation, and optional account_id override. + * Handles authentication via x-api-key or Authorization bearer token, + * body validation, and optional account_id override. * * @param request - The NextRequest object * @returns A NextResponse with an error if validation fails, or the validated result @@ -20,12 +20,6 @@ export type UpdatePulseRequestResult = { export async function validateUpdatePulseRequest( request: NextRequest, ): Promise { - const accountIdOrError = await getApiKeyAccountId(request); - if (accountIdOrError instanceof NextResponse) { - return accountIdOrError; - } - let accountId = accountIdOrError; - const body = await safeParseJson(request); const validated = validateUpdatePulseBody(body); if (validated instanceof NextResponse) { @@ -33,17 +27,13 @@ export async function validateUpdatePulseRequest( } const { active, account_id: targetAccountId } = validated; - if (targetAccountId) { - const apiKey = request.headers.get("x-api-key"); - const overrideResult = await validateOverrideAccountId({ - apiKey, - targetAccountId, - }); - if (overrideResult instanceof NextResponse) { - return overrideResult; - } - accountId = overrideResult.accountId; + const authResult = await validateAuthContext(request, { + accountId: targetAccountId, + }); + + if (authResult instanceof NextResponse) { + return authResult; } - return { accountId, active }; + return { accountId: authResult.accountId, active }; } From 243c355cc0a0b11642b3560f3e83c1cd572c42be Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 26 Jan 2026 20:30:02 -0500 Subject: [PATCH 2/3] docs: update CLAUDE.md to require validateAuthContext for API auth Emphasize using validateAuthContext() instead of getApiKeyAccountId() directly to ensure both x-api-key and Bearer token auth are supported. Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5b86b98..9b8ff4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,23 +147,35 @@ export async function selectTableName({ **Never use `account_id` in request bodies or tool schemas.** Always derive the account ID from authentication: -- **API routes**: Use `x-api-key` header via `getApiKeyAccountId()` +- **API routes**: Use `validateAuthContext()` (supports both `x-api-key` and `Authorization: Bearer` tokens) - **MCP tools**: Use `extra.authInfo` via `resolveAccountId()` Both API keys and Privy access tokens resolve to an `accountId`. Never accept `account_id` as user input. ### API Routes +**CRITICAL: Always use `validateAuthContext()` for authentication.** This function supports both `x-api-key` header AND `Authorization: Bearer` token authentication. Never use `getApiKeyAccountId()` directly in route handlers - it only supports API keys and will reject Bearer tokens from the frontend. + ```typescript -import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +const authResult = await validateAuthContext(request, { + accountId: body.account_id, // Optional: for account_id override + organizationId: body.organization_id, // Optional: for org context +}); -const accountIdOrError = await getApiKeyAccountId(request); -if (accountIdOrError instanceof NextResponse) { - return accountIdOrError; +if (authResult instanceof NextResponse) { + return authResult; } -const accountId = accountIdOrError; + +const { accountId, orgId, authToken } = authResult; ``` +`validateAuthContext` handles: +- Both `x-api-key` and `Authorization: Bearer` authentication +- Account ID override validation (org keys can access member accounts) +- Organization access validation + ### MCP Tools ```typescript @@ -181,6 +193,7 @@ This ensures: - Callers cannot impersonate other accounts - Authentication is always enforced - Account ID is derived from validated credentials +- Frontend apps using Bearer tokens work correctly ## Input Validation From 53017456afbdc8b9195c97c4ebb7e70c8b8dbcf9 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 26 Jan 2026 20:35:12 -0500 Subject: [PATCH 3/3] test: update pulse validation tests to use validateAuthContext Update tests to mock validateAuthContext instead of getApiKeyAccountId, reflecting the change to support both x-api-key and Bearer token auth. Add tests for Bearer token authentication scenarios. Co-Authored-By: Claude Opus 4.5 --- .../__tests__/validateGetPulseRequest.test.ts | 93 +++++++++------ .../validateUpdatePulseRequest.test.ts | 111 ++++++++++-------- 2 files changed, 121 insertions(+), 83 deletions(-) diff --git a/lib/pulse/__tests__/validateGetPulseRequest.test.ts b/lib/pulse/__tests__/validateGetPulseRequest.test.ts index cedd6f0..cc7f8d5 100644 --- a/lib/pulse/__tests__/validateGetPulseRequest.test.ts +++ b/lib/pulse/__tests__/validateGetPulseRequest.test.ts @@ -1,16 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; -vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ - getApiKeyAccountId: vi.fn(), +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), })); -vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ - validateOverrideAccountId: vi.fn(), -})); - -import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; -import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { validateGetPulseRequest } from "../validateGetPulseRequest"; /** @@ -29,10 +24,13 @@ describe("validateGetPulseRequest", () => { vi.clearAllMocks(); }); - describe("authentication", () => { - it("returns 401 when API key is missing", async () => { - vi.mocked(getApiKeyAccountId).mockResolvedValue( - NextResponse.json({ status: "error", message: "x-api-key header required" }, { status: 401 }), + describe("authentication with x-api-key", () => { + it("returns 401 when no auth is provided", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json( + { status: "error", error: "Exactly one of x-api-key or Authorization must be provided" }, + { status: 401 }, + ), ); const request = createMockRequest("https://api.example.com/api/pulse"); @@ -44,45 +42,69 @@ describe("validateGetPulseRequest", () => { } }); - it("returns 401 when API key is invalid", async () => { - vi.mocked(getApiKeyAccountId).mockResolvedValue( - NextResponse.json({ status: "error", message: "Unauthorized" }, { status: 401 }), - ); + it("returns accountId when authenticated with x-api-key", async () => { + const accountId = "account-123"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "valid-key", + }); const request = createMockRequest("https://api.example.com/api/pulse", { - "x-api-key": "invalid-key", + "x-api-key": "valid-key", }); const result = await validateGetPulseRequest(request); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(401); - } + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ accountId }); }); }); - describe("successful validation", () => { - it("returns accountId when authenticated without account_id param", async () => { - const accountId = "account-123"; - vi.mocked(getApiKeyAccountId).mockResolvedValue(accountId); + describe("authentication with Bearer token", () => { + it("returns accountId when authenticated with Bearer token", async () => { + const accountId = "account-456"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "bearer-token", + }); const request = createMockRequest("https://api.example.com/api/pulse", { - "x-api-key": "valid-key", + Authorization: "Bearer bearer-token", }); const result = await validateGetPulseRequest(request); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ accountId }); }); + + it("returns 401 when Bearer token is invalid", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const request = createMockRequest("https://api.example.com/api/pulse", { + Authorization: "Bearer invalid-token", + }); + const result = await validateGetPulseRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(401); + } + }); }); describe("account_id override", () => { - it("returns overridden accountId when authorized", async () => { - const orgAccountId = "org-123"; + it("passes account_id to validateAuthContext when provided", async () => { + const accountId = "account-123"; const targetAccountId = "11111111-1111-4111-a111-111111111111"; - vi.mocked(getApiKeyAccountId).mockResolvedValue(orgAccountId); - vi.mocked(validateOverrideAccountId).mockResolvedValue({ accountId: targetAccountId }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: targetAccountId, + orgId: null, + authToken: "valid-key", + }); const request = createMockRequest( `https://api.example.com/api/pulse?account_id=${targetAccountId}`, @@ -90,21 +112,18 @@ describe("validateGetPulseRequest", () => { ); const result = await validateGetPulseRequest(request); - expect(validateOverrideAccountId).toHaveBeenCalledWith({ - apiKey: "org-api-key", - targetAccountId, + expect(validateAuthContext).toHaveBeenCalledWith(request, { + accountId: targetAccountId, }); expect(result).toEqual({ accountId: targetAccountId }); }); it("returns 403 when not authorized for account_id", async () => { - const orgAccountId = "org-123"; const targetAccountId = "11111111-1111-4111-a111-111111111111"; - vi.mocked(getApiKeyAccountId).mockResolvedValue(orgAccountId); - vi.mocked(validateOverrideAccountId).mockResolvedValue( + vi.mocked(validateAuthContext).mockResolvedValue( NextResponse.json( - { status: "error", message: "Access denied to specified accountId" }, + { status: "error", error: "Access denied to specified account_id" }, { status: 403 }, ), ); diff --git a/lib/pulse/__tests__/validateUpdatePulseRequest.test.ts b/lib/pulse/__tests__/validateUpdatePulseRequest.test.ts index 072821b..2204978 100644 --- a/lib/pulse/__tests__/validateUpdatePulseRequest.test.ts +++ b/lib/pulse/__tests__/validateUpdatePulseRequest.test.ts @@ -1,20 +1,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; -vi.mock("@/lib/auth/getApiKeyAccountId", () => ({ - getApiKeyAccountId: vi.fn(), -})); - -vi.mock("@/lib/accounts/validateOverrideAccountId", () => ({ - validateOverrideAccountId: vi.fn(), +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), })); vi.mock("@/lib/networking/safeParseJson", () => ({ safeParseJson: vi.fn(), })); -import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; -import { validateOverrideAccountId } from "@/lib/accounts/validateOverrideAccountId"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { safeParseJson } from "@/lib/networking/safeParseJson"; import { validateUpdatePulseRequest } from "../validateUpdatePulseRequest"; @@ -34,26 +29,8 @@ describe("validateUpdatePulseRequest", () => { vi.clearAllMocks(); }); - describe("authentication", () => { - it("returns 401 when API key is missing", async () => { - vi.mocked(getApiKeyAccountId).mockResolvedValue( - NextResponse.json({ status: "error", message: "x-api-key header required" }, { status: 401 }), - ); - - const request = createMockRequest("https://api.example.com/api/pulse"); - const result = await validateUpdatePulseRequest(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(401); - } - }); - }); - describe("body validation", () => { it("returns 400 when active field is missing", async () => { - const accountId = "account-123"; - vi.mocked(getApiKeyAccountId).mockResolvedValue(accountId); vi.mocked(safeParseJson).mockResolvedValue({}); const request = createMockRequest("https://api.example.com/api/pulse", { @@ -68,8 +45,6 @@ describe("validateUpdatePulseRequest", () => { }); it("returns 400 when active is not a boolean", async () => { - const accountId = "account-123"; - vi.mocked(getApiKeyAccountId).mockResolvedValue(accountId); vi.mocked(safeParseJson).mockResolvedValue({ active: "true" }); const request = createMockRequest("https://api.example.com/api/pulse", { @@ -84,11 +59,33 @@ describe("validateUpdatePulseRequest", () => { }); }); - describe("successful validation", () => { - it("returns accountId and active when valid", async () => { + describe("authentication with x-api-key", () => { + it("returns 401 when no auth is provided", async () => { + vi.mocked(safeParseJson).mockResolvedValue({ active: true }); + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json( + { status: "error", error: "Exactly one of x-api-key or Authorization must be provided" }, + { status: 401 }, + ), + ); + + const request = createMockRequest("https://api.example.com/api/pulse"); + const result = await validateUpdatePulseRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(401); + } + }); + + it("returns accountId and active when authenticated with x-api-key", async () => { const accountId = "account-123"; - vi.mocked(getApiKeyAccountId).mockResolvedValue(accountId); vi.mocked(safeParseJson).mockResolvedValue({ active: true }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "valid-key", + }); const request = createMockRequest("https://api.example.com/api/pulse", { "x-api-key": "valid-key", @@ -98,52 +95,74 @@ describe("validateUpdatePulseRequest", () => { expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ accountId, active: true }); }); + }); - it("returns active: false when specified", async () => { - const accountId = "account-123"; - vi.mocked(getApiKeyAccountId).mockResolvedValue(accountId); + describe("authentication with Bearer token", () => { + it("returns accountId and active when authenticated with Bearer token", async () => { + const accountId = "account-456"; vi.mocked(safeParseJson).mockResolvedValue({ active: false }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "bearer-token", + }); const request = createMockRequest("https://api.example.com/api/pulse", { - "x-api-key": "valid-key", + Authorization: "Bearer bearer-token", }); const result = await validateUpdatePulseRequest(request); expect(result).not.toBeInstanceOf(NextResponse); expect(result).toEqual({ accountId, active: false }); }); + + it("returns 401 when Bearer token is invalid", async () => { + vi.mocked(safeParseJson).mockResolvedValue({ active: true }); + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const request = createMockRequest("https://api.example.com/api/pulse", { + Authorization: "Bearer invalid-token", + }); + const result = await validateUpdatePulseRequest(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(401); + } + }); }); describe("account_id override", () => { - it("returns overridden accountId when authorized", async () => { - const orgAccountId = "org-123"; + it("passes account_id to validateAuthContext when provided in body", async () => { const targetAccountId = "11111111-1111-4111-a111-111111111111"; - vi.mocked(getApiKeyAccountId).mockResolvedValue(orgAccountId); vi.mocked(safeParseJson).mockResolvedValue({ active: true, account_id: targetAccountId }); - vi.mocked(validateOverrideAccountId).mockResolvedValue({ accountId: targetAccountId }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: targetAccountId, + orgId: null, + authToken: "valid-key", + }); const request = createMockRequest("https://api.example.com/api/pulse", { "x-api-key": "org-api-key", }); const result = await validateUpdatePulseRequest(request); - expect(validateOverrideAccountId).toHaveBeenCalledWith({ - apiKey: "org-api-key", - targetAccountId, + expect(validateAuthContext).toHaveBeenCalledWith(request, { + accountId: targetAccountId, }); expect(result).toEqual({ accountId: targetAccountId, active: true }); }); it("returns 403 when not authorized for account_id", async () => { - const orgAccountId = "org-123"; const targetAccountId = "11111111-1111-4111-a111-111111111111"; - vi.mocked(getApiKeyAccountId).mockResolvedValue(orgAccountId); vi.mocked(safeParseJson).mockResolvedValue({ active: true, account_id: targetAccountId }); - vi.mocked(validateOverrideAccountId).mockResolvedValue( + vi.mocked(validateAuthContext).mockResolvedValue( NextResponse.json( - { status: "error", message: "Access denied to specified accountId" }, + { status: "error", error: "Access denied to specified account_id" }, { status: 403 }, ), );