Skip to content

feat(agent-bff): BFF API key resolver wiring#1730

Merged
nbouliol merged 6 commits into
mainfrom
feature/prd-663-agent-nodejs-mode-2-resolver-wiring
Jul 1, 2026
Merged

feat(agent-bff): BFF API key resolver wiring#1730
nbouliol merged 6 commits into
mainfrom
feature/prd-663-agent-nodejs-mode-2-resolver-wiring

Conversation

@nbouliol

@nbouliol nbouliol commented Jul 1, 2026

Copy link
Copy Markdown
Member

Context

Mode 2 (developer path) for the BFF: a request presents an fbff_<keyId>_<secret> key in the X-Forest-Bff-Key header. The BFF resolves it server-to-server against the SaaS (POST /liana/v1/bff-api-keys/resolve, already shipped in forestadmin-server), caches the result briefly, and mints a fresh 5m agent JWT per request from the resolved identity. The minted JWT is never cached.

Slice 2 of the auth foundation. The actual agent call is Slice 3 — here the JWT is asserted as correctly minted and attached (ctx.state.agentToken / ctx.state.apiKeyIdentity), with the agent client out of scope.

fixes PRD-663

What's in it

New packages/agent-bff/src/api-key/:

  • api-key.ts — parse fbff_<16hex>_<64hex>, hashApiKey (cache key = sha256 of keyId+secret), fingerprintApiKey (logs)
  • api-key-client.ts / api-key-resolve-error.ts — S2S resolve call, typed errors
  • agent-token.ts — mint 5m JWT (camelCase + snake_case aliases, tags→Record, null→'')
  • resolve-cache.ts — per-instance TTL cache (positive 60s / negative 10s), now() injected, bounded (10k)
  • api-key-error.ts — nested { error: { type, status, message } } contract
  • api-key-authenticator.ts — orchestrates parse → cache → resolve → mint
  • api-key-middleware.ts — reads the header, attaches to ctx.state, Retry-After on 503

Wired in cli-core.ts (mounted independently of OAuth) and exported from index.ts.

Error mapping (SaaS → BFF)

  • 401 → invalid_api_key (negative-cached ≤10s)
  • 403 → forest_identity_not_allowed (negative-cached ≤10s)
  • 400 → invalid_request (not cached)
  • 429 / unreachable / 5xx → 503 key_resolution_unavailable + Retry-After (not cached; SaaS Retry-After propagated on 429)
  • malformed key shape → 401 invalid_api_key locally, no SaaS call

Notes / follow-ups

  • The resolved identity has no role; the minted JWT omits it. To be revisited in Slice 3 when the agent call is wired.
  • Mode 1/Mode 2 precedence, CORS, per-key allowedOrigins enforcement and cross-cutting contract tests are owned by T6 (PRD-666). This PR only exposes allowedOrigins on the attached identity for T6 to consume.

Tests

49 focused tests (test/api-key/): parse, cache TTLs + bound, mint claims/expiry, resolve error mapping, authenticator cache behavior, middleware attach/error/passthrough, no raw secret in logs. Lint + tsc clean.

Includes a second commit addressing local review findings (downstream-error masking, Retry-After NaN, unguarded success JSON, size() staleness, test fetch restore).

🤖 Generated with Claude Code

Note

Add Mode 2 BFF API key authentication middleware to agent-bff

  • Adds a full API key authentication pipeline: key parsing/hashing, a Forest server resolver client, a TTL-aware resolve cache (60s positive / 10s negative), an authenticator, and a Koa middleware gated on the X-Forest-Bff-Key header.
  • On successful authentication, mints a short-lived (5min) HS256 JWT with user identity claims and attaches it to Koa state alongside the resolved identity; sets Cache-Control: no-store.
  • On failure, returns structured ApiKeyError responses (400/401/403/503) with optional Retry-After; unexpected errors return a generic 500.
  • Middleware is conditionally registered in cli-core.ts when forestServerUrl, forestEnvSecret, and forestAuthSecret are all present in config; logs a warning and skips otherwise.
  • Risk: Requests authenticated via API key bypass existing OAuth middleware; cache-full behavior silently drops new entries rather than evicting old ones.

Changes since #1730 opened

  • Enforced strict validation in ApiKeyClient.isResolvedIdentity and added ApiKeyClient.isIdentityUser method to require complete identity structure [86caf50]
  • Implemented LRU eviction in createResolveCache store utility to remove oldest entry when cache reaches capacity [86caf50]
  • Restricted ApiKeyClient.parseRetryAfter to only accept positive integer values for Retry-After headers [86caf50]
  • Added agentUrl requirement to resolveApiKeyConfig configuration validation [86caf50]
  • Refactored createApiKeyAuthenticator.authenticate handler to mint AuthenticatedApiKey once before caching [86caf50]
  • Removed agentUrl requirement from resolveApiKeyConfig function in agent-bff package [8c99477]

Macroscope summarized bb63ce1.

nbouliol and others added 2 commits July 1, 2026 09:43
Read the X-Forest-Bff-Key header, parse fbff_<keyId>_<secret>, resolve it
server-to-server against the SaaS, cache the result (positive 60s / negative
10s, per-instance, bounded), and mint a fresh 5m agent JWT per request from the
resolved identity. The minted JWT is never cached.

Errors map to a nested { error: { type, status, message } } contract:
invalid_api_key (401), forest_identity_not_allowed (403), invalid_request
(400), key_resolution_unavailable (503, + Retry-After; also covers SaaS 429).

fixes PRD-663

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- middleware: confine the try to authenticate() so downstream handler
  errors propagate to Koa instead of being rewritten as API-key failures
- client: parse Retry-After defensively (HTTP-date form -> undefined,
  no NaN) and guard the success-path JSON parse (malformed 200 -> 503)
- resolve-cache: purge expired entries before reporting size()
- tests: restore global.fetch between tests; cover the new guards

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jul 1, 2026

Copy link
Copy Markdown

PRD-663

@qltysh

qltysh Bot commented Jul 1, 2026

Copy link
Copy Markdown

7 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 6): mapResolveError 2
qlty Structure Function with high complexity (count = 15): createApiKeyAuthenticator 2
qlty Structure Function with many parameters (count = 4): constructor 2
qlty Structure Complex binary expression 1

Comment thread packages/agent-bff/src/api-key/api-key-client.ts
const middlewares = await buildOAuthMiddlewares(config, logger);
const oauthMiddlewares = await buildOAuthMiddlewares(config, logger);
const apiKeyMiddleware = buildApiKeyMiddleware(config, logger);
const middlewares = [...oauthMiddlewares, ...(apiKeyMiddleware ? [apiKeyMiddleware] : [])];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/cli-core.ts:128

When API-key-only configuration is provided (e.g. FOREST_APP_URL or BFF_TOKEN_ENCRYPTION_KEY missing), runCli still builds and registers the API-key middleware so requests authenticate successfully. But /health is driven by config.hasAllRequired, which requires all OAuth fields, so it returns 503 degraded. Readiness/liveness probes then keep the service out of rotation or restart it indefinitely despite valid authenticated traffic. Consider making the health check reflect the auth modes actually enabled, or document that OAuth config is required for a healthy status.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent-bff/src/cli-core.ts around line 128:

When API-key-only configuration is provided (e.g. `FOREST_APP_URL` or `BFF_TOKEN_ENCRYPTION_KEY` missing), `runCli` still builds and registers the API-key middleware so requests authenticate successfully. But `/health` is driven by `config.hasAllRequired`, which requires all OAuth fields, so it returns `503 degraded`. Readiness/liveness probes then keep the service out of rotation or restart it indefinitely despite valid authenticated traffic. Consider making the health check reflect the auth modes actually enabled, or document that OAuth config is required for a healthy status.

@qltysh

qltysh Bot commented Jul 1, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on main by 0.04%.

Modified Files with Diff Coverage (10)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent-bff/src/cli-core.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/index.ts100.0%
New Coverage rating: A
packages/agent-bff/src/api-key/api-key-middleware.ts100.0%
New Coverage rating: A
packages/agent-bff/src/api-key/resolve-cache.ts100.0%
New Coverage rating: A
packages/agent-bff/src/api-key/api-key-authenticator.ts100.0%
New Coverage rating: A
packages/agent-bff/src/api-key/api-key-resolve-error.ts100.0%
New Coverage rating: A
packages/agent-bff/src/api-key/api-key.ts100.0%
New Coverage rating: A
packages/agent-bff/src/api-key/agent-token.ts100.0%
New Coverage rating: A
packages/agent-bff/src/api-key/api-key-error.ts100.0%
New Coverage rating: A
packages/agent-bff/src/api-key/api-key-client.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

nbouliol and others added 2 commits July 1, 2026 10:35
- client: an HTTP 200 whose body is missing user/renderingId is an
  unusable resolution, not a caller error; surface it as unavailable
  (503) instead of letting a later user deref throw a 500
- tests: incomplete-200 payloads, and the middleware non-ApiKeyError
  500 branch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cover the SaaS 400 -> invalid_request (not negatively cached), the
out-of-taxonomy status -> 503 default branch, and the rethrow of a
non-ApiKeyResolveError. Brings api-key-authenticator + api-key-error to
full line coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread packages/agent-bff/src/api-key/api-key-authenticator.ts
Comment thread packages/agent-bff/src/api-key/resolve-cache.ts Outdated
Comment thread packages/agent-bff/src/api-key/api-key-client.ts
Comment thread packages/agent-bff/src/api-key/api-key-client.ts
Comment thread packages/agent-bff/src/api-key/agent-token.ts
Comment thread packages/agent-bff/src/cli-core.ts
Comment thread packages/agent-bff/src/api-key/api-key-client.ts
Address review feedback:
- client: validate every field the mint reads (id/email/team/
  permissionLevel/tags array) plus renderingId/allowedOrigins, so a
  partial 200 fails as unavailable instead of caching a body that later
  throws while minting
- authenticator: mint before caching so an unmintable identity is never
  cached
- resolve-cache: evict the oldest entry when full instead of dropping new
  ones, so a burst of negatives cannot freeze the cache
- client: Retry-After must be a positive integer (drop date/negative/zero)
- cli-core: require AGENT_URL to enable API key auth

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread packages/agent-bff/src/cli-core.ts
Revert the AGENT_URL requirement from resolveApiKeyConfig: the middleware
only uses server URL + env secret + auth secret, and gating on AGENT_URL
would silently disable auth when it is missing. AGENT_URL is already a
REQUIRED_KEY, so /health reports degraded without it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nbouliol nbouliol changed the title feat(agent-bff): Mode 2 BFF API key resolver wiring (PRD-663) feat(agent-bff): BFF API key resolver wiring Jul 1, 2026
@nbouliol nbouliol merged commit df806aa into main Jul 1, 2026
56 of 61 checks passed
@nbouliol nbouliol deleted the feature/prd-663-agent-nodejs-mode-2-resolver-wiring branch July 1, 2026 12:56
forest-bot added a commit that referenced this pull request Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants