Skip to content

Standardize cron / Bearer auth across API routes #51

@studert

Description

@studert

Problem

The codebase has two different patterns for Bearer-token-protected API routes:

  1. The shared helper requireBearerSecret(request, ENV_NAME) (and its cron-specific wrapper requireCronSecret) used by every /api/sync/* route.
  2. A custom local validateAuth(request) in /api/invoices/ingest/route.ts that re-implements the same pattern slightly differently.

Two implementations of the same primitive means a future hardening (e.g. constant-time comparison, audit logging on auth failure, rate limiting) has to be applied twice and can drift. It's also harder for a new contributor — or an AI agent — to discover the canonical way to add a new protected route.

Evidence

src/app/api/invoices/ingest/route.ts:21-26:

function validateAuth(request: NextRequest): boolean {
  const secret = process.env.INVOICE_INGEST_SECRET;
  if (!secret) return false;
  const authHeader = request.headers.get(\"authorization\");
  return authHeader === `Bearer ${secret}`;
}

vs the shared helper used by /api/sync/* routes (see src/lib/cron-auth.ts or wherever requireBearerSecret lives).

The local one:

  • Does plain string equality (timing-attack-vulnerable; the shared helper should use timingSafeEqual if it doesn't already).
  • Returns a boolean instead of returning a NextResponse | null like the shared helper, so the calling code has to write its own 401 response.

Proposed approach

  1. Read src/lib/cron-auth.ts (or the canonical location of requireBearerSecret) to confirm the contract.
  2. If the shared helper does not already use a constant-time comparison, fix that first as part of this issue (use crypto.timingSafeEqual after coercing to Buffer of equal length).
  3. Replace validateAuth in /api/invoices/ingest/route.ts with a call to the shared helper passing \"INVOICE_INGEST_SECRET\".
  4. Sweep the rest of src/app/api/**/route.ts for any other one-off Bearer checks and replace them.
  5. Add a unit test for requireBearerSecret:
    • missing header → returns 401 response
    • wrong secret → returns 401
    • correct secret → returns null (i.e. "continue")
    • missing env var → returns 500 / clear error (or whatever the helper currently does — match it)

Acceptance criteria

  • No route-local Bearer-validation helpers remain in src/app/api/.
  • requireBearerSecret uses constant-time comparison.
  • Behavior of /api/invoices/ingest is unchanged (auth still works, error responses still match).
  • Unit tests cover the helper.
  • pnpm lint && pnpm typecheck && pnpm test pass.

Verification

  1. POST /api/invoices/ingest with no Authorization header → 401.
  2. POST with wrong Bearer → 401.
  3. POST with correct Bearer + valid PDF → 200 (or expected response).
  4. Existing /api/sync/* routes still work unchanged.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:authNextAuth / login / invitespriority:mediumImportant, not urgenttech-debtCode quality, refactor, debt

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions