-
Notifications
You must be signed in to change notification settings - Fork 12
feat(agent-bff): BFF API key resolver wiring #1730
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
nbouliol
merged 6 commits into
main
from
feature/prd-663-agent-nodejs-mode-2-resolver-wiring
Jul 1, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
52312ea
feat(agent-bff): resolve BFF API keys and mint agent JWT (Mode 2)
nbouliol 7c30b9b
fix(agent-bff): address Mode 2 resolver review findings
nbouliol 2b27169
fix(agent-bff): validate resolve payload shape and cover error branches
nbouliol bb63ce1
test(agent-bff): cover resolver error-mapping branches
nbouliol 86caf50
fix(agent-bff): harden resolve validation, cache eviction and activation
nbouliol 8c99477
fix(agent-bff): gate API key auth only on the secrets it uses
nbouliol File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import type { ResolvedApiKeyIdentity } from './api-key-client'; | ||
|
|
||
| import jsonwebtoken from 'jsonwebtoken'; | ||
|
|
||
| export const AGENT_TOKEN_EXPIRES_IN = '5m'; | ||
|
|
||
| export interface IssueAgentTokenParams { | ||
| identity: ResolvedApiKeyIdentity; | ||
| authSecret: string; | ||
| } | ||
|
|
||
| function tagsToRecord(tags: { key: string; value: string }[]): Record<string, string> { | ||
| return tags.reduce((memo, { key, value }) => ({ ...memo, [key]: value }), {}); | ||
| } | ||
|
|
||
| export function issueAgentToken({ identity, authSecret }: IssueAgentTokenParams): string { | ||
| const { user, renderingId } = identity; | ||
| const firstName = user.firstName ?? ''; | ||
| const lastName = user.lastName ?? ''; | ||
| const tags = tagsToRecord(user.tags); | ||
|
|
||
| // snake_case aliases: Ruby/Python agents splat JWT claims into Caller (snake_case kwargs). | ||
| return jsonwebtoken.sign( | ||
| { | ||
| id: user.id, | ||
| email: user.email, | ||
| firstName, | ||
| lastName, | ||
| team: user.team, | ||
| renderingId, | ||
| tags, | ||
| permissionLevel: user.permissionLevel, | ||
| first_name: firstName, | ||
| last_name: lastName, | ||
| rendering_id: renderingId, | ||
| permission_level: user.permissionLevel, | ||
| }, | ||
| authSecret, | ||
| { algorithm: 'HS256', expiresIn: AGENT_TOKEN_EXPIRES_IN }, | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import type ApiKeyClient from './api-key-client'; | ||
| import type { ResolvedApiKeyIdentity } from './api-key-client'; | ||
| import type { ApiKeyError } from './api-key-error'; | ||
| import type { ResolveCache } from './resolve-cache'; | ||
|
|
||
| import { issueAgentToken } from './agent-token'; | ||
| import { hashApiKey, parseApiKey } from './api-key'; | ||
| import { ApiKeyResolveError } from './api-key-client'; | ||
| import { | ||
| forestIdentityNotAllowed, | ||
| invalidApiKey, | ||
| invalidRequest, | ||
| keyResolutionUnavailable, | ||
| } from './api-key-error'; | ||
|
|
||
| const UNAVAILABLE_RETRY_AFTER_SECONDS = 5; | ||
| const NEGATIVE_CACHE_STATUSES = new Set([401, 403]); | ||
|
|
||
| export interface ApiKeyAuthenticatorOptions { | ||
| client: ApiKeyClient; | ||
| cache: ResolveCache; | ||
| authSecret: string; | ||
| } | ||
|
|
||
| export interface AuthenticatedApiKey { | ||
| agentToken: string; | ||
| identity: ResolvedApiKeyIdentity; | ||
| } | ||
|
|
||
| export interface ApiKeyAuthenticator { | ||
| authenticate(rawKey: string): Promise<AuthenticatedApiKey>; | ||
| } | ||
|
|
||
| function mapResolveError(error: ApiKeyResolveError): ApiKeyError { | ||
| if (error.unreachable) return keyResolutionUnavailable(UNAVAILABLE_RETRY_AFTER_SECONDS); | ||
|
|
||
| switch (error.status) { | ||
| case 401: | ||
| return invalidApiKey(); | ||
| case 403: | ||
| return forestIdentityNotAllowed(); | ||
| case 400: | ||
| return invalidRequest(); | ||
| case 429: | ||
| return keyResolutionUnavailable(error.retryAfter ?? UNAVAILABLE_RETRY_AFTER_SECONDS); | ||
| default: | ||
| return keyResolutionUnavailable(UNAVAILABLE_RETRY_AFTER_SECONDS); | ||
| } | ||
| } | ||
|
|
||
| export default function createApiKeyAuthenticator({ | ||
| client, | ||
| cache, | ||
| authSecret, | ||
| }: ApiKeyAuthenticatorOptions): ApiKeyAuthenticator { | ||
| function mint(identity: ResolvedApiKeyIdentity): AuthenticatedApiKey { | ||
| return { agentToken: issueAgentToken({ identity, authSecret }), identity }; | ||
| } | ||
|
|
||
| return { | ||
| async authenticate(rawKey) { | ||
| const parsed = parseApiKey(rawKey); | ||
|
|
||
| if (!parsed) throw invalidApiKey(); | ||
|
|
||
| const hash = hashApiKey(parsed.keyId, parsed.secret); | ||
|
|
||
| const cachedIdentity = cache.getPositive(hash); | ||
| if (cachedIdentity) return mint(cachedIdentity); | ||
|
|
||
| const cachedError = cache.getNegative(hash); | ||
| if (cachedError) throw cachedError; | ||
|
|
||
| let identity: ResolvedApiKeyIdentity; | ||
|
|
||
| try { | ||
| identity = await client.resolveApiKey(parsed); | ||
| } catch (error) { | ||
| if (error instanceof ApiKeyResolveError) { | ||
| const mapped = mapResolveError(error); | ||
| if (NEGATIVE_CACHE_STATUSES.has(mapped.status)) cache.setNegative(hash, mapped); | ||
|
|
||
| throw mapped; | ||
| } | ||
|
|
||
| throw error; | ||
| } | ||
|
|
||
| const authenticated = mint(identity); | ||
| cache.setPositive(hash, identity); | ||
|
Tonours marked this conversation as resolved.
|
||
|
|
||
| return authenticated; | ||
| }, | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| import { ApiKeyResolveError } from './api-key-resolve-error'; | ||
|
|
||
| export { ApiKeyResolveError } from './api-key-resolve-error'; | ||
| export type { ApiKeyResolveErrorParams } from './api-key-resolve-error'; | ||
|
|
||
| export interface ApiKeyIdentityUser { | ||
| id: number; | ||
| email: string; | ||
| firstName: string | null; | ||
| lastName: string | null; | ||
| team: string; | ||
| tags: { key: string; value: string }[]; | ||
| permissionLevel: string; | ||
| } | ||
|
|
||
| export interface ResolvedApiKeyIdentity { | ||
| user: ApiKeyIdentityUser; | ||
| renderingId: number; | ||
| allowedOrigins: string[]; | ||
| } | ||
|
|
||
| export interface ApiKeyClientOptions { | ||
| forestServerUrl: string; | ||
| envSecret: string; | ||
| } | ||
|
|
||
| const DEFAULT_HEADERS = { 'Content-Type': 'application/json' } as const; | ||
| const REQUEST_TIMEOUT_MS = 10_000; | ||
| const RESOLVE_PATH = '/liana/v1/bff-api-keys/resolve'; | ||
|
|
||
| interface SaasErrorBody { | ||
| errors?: { name?: string; meta?: { code?: string } }[]; | ||
| } | ||
|
|
||
| export default class ApiKeyClient { | ||
|
Tonours marked this conversation as resolved.
|
||
| private readonly forestServerUrl: string; | ||
| private readonly envSecret: string; | ||
|
|
||
| constructor({ forestServerUrl, envSecret }: ApiKeyClientOptions) { | ||
| this.forestServerUrl = forestServerUrl; | ||
| this.envSecret = envSecret; | ||
| } | ||
|
|
||
| async resolveApiKey(parsedKey: { | ||
| keyId: string; | ||
| secret: string; | ||
| }): Promise<ResolvedApiKeyIdentity> { | ||
| let response: Response; | ||
|
|
||
| try { | ||
| response = await fetch(this.url(RESOLVE_PATH), { | ||
| method: 'POST', | ||
| headers: { ...DEFAULT_HEADERS, 'forest-secret-key': this.envSecret }, | ||
| body: JSON.stringify({ keyId: parsedKey.keyId, secret: parsedKey.secret }), | ||
| signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), | ||
| }); | ||
| } catch { | ||
| throw new ApiKeyResolveError({ unreachable: true }); | ||
| } | ||
|
|
||
| if (!response.ok) { | ||
| const body = (await response.json().catch(() => ({}))) as SaasErrorBody; | ||
| const firstError = body.errors?.[0]; | ||
|
|
||
| throw new ApiKeyResolveError({ | ||
| status: response.status, | ||
| code: firstError?.meta?.code, | ||
| name: firstError?.name, | ||
| retryAfter: ApiKeyClient.parseRetryAfter(response.headers.get('retry-after')), | ||
| }); | ||
| } | ||
|
|
||
| let body: unknown; | ||
|
|
||
| try { | ||
|
macroscopeapp[bot] marked this conversation as resolved.
|
||
| body = await response.json(); | ||
| } catch { | ||
| throw new ApiKeyResolveError({ unreachable: true }); | ||
| } | ||
|
|
||
| // A well-formed HTTP 200 with an incomplete body is an unusable resolution, not a caller | ||
| // error: surface it as unavailable rather than letting a later `user` deref throw a 500. | ||
| if (!ApiKeyClient.isResolvedIdentity(body)) { | ||
| throw new ApiKeyResolveError({ unreachable: true }); | ||
| } | ||
|
|
||
| return body; | ||
| } | ||
|
|
||
| private static isResolvedIdentity(body: unknown): body is ResolvedApiKeyIdentity { | ||
|
Tonours marked this conversation as resolved.
|
||
| if (typeof body !== 'object' || body === null) return false; | ||
|
|
||
| const candidate = body as { user?: unknown; renderingId?: unknown; allowedOrigins?: unknown }; | ||
|
|
||
| return ( | ||
| typeof candidate.renderingId === 'number' && | ||
| Array.isArray(candidate.allowedOrigins) && | ||
| ApiKeyClient.isIdentityUser(candidate.user) | ||
| ); | ||
| } | ||
|
|
||
| // Every field the agent-token mint reads must be present, so a partial body fails here | ||
| // (mapped to unavailable) instead of minting a token with undefined claims later. | ||
| private static isIdentityUser(user: unknown): user is ApiKeyIdentityUser { | ||
| if (typeof user !== 'object' || user === null) return false; | ||
|
|
||
| const candidate = user as Record<string, unknown>; | ||
|
|
||
| return ( | ||
| typeof candidate.id === 'number' && | ||
| typeof candidate.email === 'string' && | ||
| typeof candidate.team === 'string' && | ||
| typeof candidate.permissionLevel === 'string' && | ||
| Array.isArray(candidate.tags) | ||
| ); | ||
| } | ||
|
|
||
| // Retry-After may be an HTTP-date rather than delay-seconds; keep only a positive integer so a | ||
| // date/negative/zero value falls back to the caller's default instead of a useless backoff hint. | ||
| private static parseRetryAfter(header: string | null): number | undefined { | ||
| if (header === null) return undefined; | ||
|
|
||
| const seconds = Number(header); | ||
|
Tonours marked this conversation as resolved.
|
||
|
|
||
| return Number.isInteger(seconds) && seconds > 0 ? seconds : undefined; | ||
| } | ||
|
|
||
| private url(path: string): string { | ||
| return new URL(path, this.forestServerUrl).toString(); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| export type ApiKeyErrorType = | ||
| | 'invalid_api_key' | ||
| | 'forest_identity_not_allowed' | ||
| | 'invalid_request' | ||
| | 'key_resolution_unavailable'; | ||
|
|
||
| export class ApiKeyError extends Error { | ||
| readonly status: number; | ||
| readonly type: ApiKeyErrorType; | ||
| readonly retryAfter?: number; | ||
|
|
||
| constructor(status: number, type: ApiKeyErrorType, message: string, retryAfter?: number) { | ||
| super(message); | ||
| this.name = 'ApiKeyError'; | ||
| this.status = status; | ||
| this.type = type; | ||
| this.retryAfter = retryAfter; | ||
| } | ||
| } | ||
|
|
||
| export interface ApiKeyErrorBody { | ||
| error: { | ||
| type: ApiKeyErrorType; | ||
| status: number; | ||
| message: string; | ||
| }; | ||
| } | ||
|
|
||
| export function toErrorBody(error: ApiKeyError): ApiKeyErrorBody { | ||
| return { error: { type: error.type, status: error.status, message: error.message } }; | ||
| } | ||
|
|
||
| export function invalidApiKey(message = 'Invalid API key'): ApiKeyError { | ||
| return new ApiKeyError(401, 'invalid_api_key', message); | ||
| } | ||
|
|
||
| export function forestIdentityNotAllowed(message = 'Forest identity not allowed'): ApiKeyError { | ||
| return new ApiKeyError(403, 'forest_identity_not_allowed', message); | ||
| } | ||
|
|
||
| export function invalidRequest(message = 'Invalid request'): ApiKeyError { | ||
| return new ApiKeyError(400, 'invalid_request', message); | ||
| } | ||
|
|
||
| export function keyResolutionUnavailable( | ||
| retryAfter: number, | ||
| message = 'Key resolution unavailable', | ||
| ): ApiKeyError { | ||
| return new ApiKeyError(503, 'key_resolution_unavailable', message, retryAfter); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import type { ApiKeyAuthenticator, AuthenticatedApiKey } from './api-key-authenticator'; | ||
| import type { Logger } from '../ports/logger-port'; | ||
| import type { Context, Middleware } from 'koa'; | ||
|
|
||
| import { fingerprintApiKey } from './api-key'; | ||
| import { ApiKeyError, toErrorBody } from './api-key-error'; | ||
|
|
||
| export const BFF_KEY_HEADER = 'X-Forest-Bff-Key'; | ||
|
|
||
| export interface ApiKeyMiddlewareOptions { | ||
| authenticator: ApiKeyAuthenticator; | ||
| logger: Logger; | ||
| } | ||
|
|
||
| function writeError(ctx: Context, error: unknown, rawKey: string, logger: Logger): void { | ||
| if (error instanceof ApiKeyError) { | ||
| if (error.retryAfter !== undefined) ctx.set('Retry-After', String(error.retryAfter)); | ||
| ctx.status = error.status; | ||
| ctx.body = toErrorBody(error); | ||
| logger('Warn', 'BFF API key rejected', { | ||
| keyHash: fingerprintApiKey(rawKey), | ||
| type: error.type, | ||
| }); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| logger('Error', 'BFF API key middleware failure', { | ||
| keyHash: fingerprintApiKey(rawKey), | ||
| cause: error instanceof Error ? `${error.name}: ${error.message}` : String(error), | ||
| }); | ||
| ctx.status = 500; | ||
| ctx.body = { error: { type: 'server_error', status: 500, message: 'API key processing failed' } }; | ||
| } | ||
|
|
||
| export default function createApiKeyMiddleware({ | ||
| authenticator, | ||
| logger, | ||
| }: ApiKeyMiddlewareOptions): Middleware { | ||
| return async function apiKeyMiddleware(ctx, next) { | ||
| const rawKey = ctx.get(BFF_KEY_HEADER); | ||
|
|
||
| if (!rawKey) { | ||
| await next(); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| let authenticated: AuthenticatedApiKey; | ||
|
|
||
| try { | ||
| authenticated = await authenticator.authenticate(rawKey); | ||
| } catch (error) { | ||
| writeError(ctx, error, rawKey, logger); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| ctx.state.agentToken = authenticated.agentToken; | ||
| ctx.state.apiKeyIdentity = authenticated.identity; | ||
| ctx.set('Cache-Control', 'no-store'); | ||
| logger('Info', 'Resolved BFF API key', { | ||
| keyHash: fingerprintApiKey(rawKey), | ||
| renderingId: authenticated.identity.renderingId, | ||
| }); | ||
|
|
||
| await next(); | ||
| }; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.