diff --git a/packages/agent-bff/.env.example b/packages/agent-bff/.env.example index 0deceff452..9ef8762fbd 100644 --- a/packages/agent-bff/.env.example +++ b/packages/agent-bff/.env.example @@ -3,4 +3,7 @@ FOREST_ENV_SECRET= FOREST_SERVER_URL=https://api.forestadmin.com FOREST_APP_URL=https://app.forestadmin.com AGENT_URL=http://localhost:3351 +BFF_TOKEN_ENCRYPTION_KEY= HTTP_PORT=3450 +BFF_ALLOWED_ORIGINS=http://localhost:4200 +BFF_DEFAULT_TIMEZONE=Europe/Paris diff --git a/packages/agent-bff/README.md b/packages/agent-bff/README.md index 0fdfee8597..75efb8788a 100644 --- a/packages/agent-bff/README.md +++ b/packages/agent-bff/README.md @@ -3,9 +3,10 @@ Standalone REST BFF (Backend-For-Frontend) that lets a trusted third-party UI call a Forest Admin agent from a browser without learning MCP or JSON:API. -This package is the Slice 0 scaffold: a bootable Koa 3 server with a `/health` endpoint, a version -header, and env-driven config validation. Auth, data endpoints, and OpenAPI generation land in later -slices. +It is a bootable Koa 3 server with a `/health` endpoint, a version header, env-driven config +validation, OAuth (Mode 1) + API-key (Mode 2) auth, and a hardened request edge (timezone, CORS, +auth-mode precedence, structured error contract). The data-endpoint proxy and OpenAPI generation +land in later slices. ## Usage @@ -34,14 +35,61 @@ yarn start:dev # node --env-file=.env dist/cli.js | `FOREST_SERVER_URL` | yes | Forest SaaS API base URL. | | `FOREST_APP_URL` | yes | Forest front base URL (OAuth front-channel, later slices). | | `AGENT_URL` | yes | The customer agent base URL the BFF calls via agent-client. | +| `BFF_TOKEN_ENCRYPTION_KEY`| for OAuth | Base64-encoded 32-byte AES-256 key encrypting stored refresh tokens. Until it is set, the `/oauth/*` token-issuance routes are disabled and `/health` reports `degraded`; already-issued `bff_access` tokens still authenticate on `/agent/*` whenever `FOREST_AUTH_SECRET` is present. | | `HTTP_PORT` | no | Server port, integer 0–65535. Defaults to `3450`. `0` binds an OS-assigned ephemeral port. | +| `BFF_ALLOWED_ORIGINS`| no | Comma-separated CORS allow-list of exact origins (scheme + host + port). No wildcard. Empty ⇒ no cross-origin browser access. | +| `BFF_DEFAULT_TIMEZONE`| no | Fallback IANA timezone used when a request carries neither an `X-Forest-Timezone` header nor a body `timezone`. | ### Config validation -- A malformed value (a non-http(s) `*_URL`, a `HTTP_PORT` that is not a decimal integer in 0–65535) - fails fast at boot: the process exits with a clear error and never echoes the offending value. +- A malformed value (a non-http(s) `*_URL`, a `HTTP_PORT` that is not a decimal integer in 0–65535, + a non-IANA `BFF_DEFAULT_TIMEZONE`) fails fast at boot: the process exits with a clear error and + never echoes the offending value. - A required var that is absent (or empty / whitespace-only) does not crash the server. It boots and reports the gap through `/health` (503 `degraded`). +- Malformed `BFF_ALLOWED_ORIGINS` entries (including a literal `*`) are dropped and logged once at + boot (`Warn`); they never enter the allow-list, so a wildcard origin can never be served. + +## Request edge (`/agent/*`) + +Every agent call flows through a request edge that enforces three cross-cutting concerns before the +(Slice-3) proxy runs. Errors use a structured, type-first contract — `{ error: { type, status, +message, details? } }` — so consumers branch on `error.type`, never on message text. + +### Auth-mode precedence + +| Presented credentials | Result | +| ---------------------------------------------- | ------------------------------- | +| `Authorization: Bearer ` | Mode 1 (OAuth session) | +| `X-Forest-Bff-Key` | Mode 2 (API key) | +| both | `400 ambiguous_credentials` | +| neither | `401 unauthorized` | + +A Mode 1 `bff_access` that is malformed or wrong-typed → `401 unauthorized`; one that is validly +signed but expired → `401 session_expired` (the client should refresh). Refresh-token reuse remains +a `POST /oauth/token` concern (`session_invalidated`, OAuth/RFC shape). + +### CORS + +Two layers, both driven by exact-origin matching (case-insensitive scheme/host, default ports +normalized away, no trailing slash, no wildcard, no subdomain matching): + +- **Layer 1 (transport)** — the only layer that sets `Access-Control-Allow-Origin`. An origin in + `BFF_ALLOWED_ORIGINS` is echoed back exactly; anything else gets no CORS headers (the browser + blocks). Applies to `POST /oauth/token` too. Preflight `OPTIONS` from an allow-listed origin gets + the allowed methods + headers; credentials are never enabled. +- **Layer 2 (per-key authorization, Mode 2 only)** — when the resolved key has a non-empty + `allowedOrigins`, the request `Origin` must also be in that list (a missing `Origin` is rejected), + else `403 origin_not_allowed`. An empty per-key list is a no-op. + +**Local development:** browsers still enforce CORS against `localhost`, so add your dev origin(s) to +`BFF_ALLOWED_ORIGINS` (e.g. `BFF_ALLOWED_ORIGINS=http://localhost:4200`) — there is no dev bypass. + +### Timezone + +The BFF always forwards an explicit `timezone` to the agent, resolved in order: (1) `X-Forest-Timezone` +header, (2) body `timezone` field, (3) `BFF_DEFAULT_TIMEZONE`. None → `400 missing_timezone`; a +non-IANA value → `400 invalid_timezone`. ## Sessions & token rotation diff --git a/packages/agent-bff/src/agent/agent-stub.ts b/packages/agent-bff/src/agent/agent-stub.ts new file mode 100644 index 0000000000..ff971c9510 --- /dev/null +++ b/packages/agent-bff/src/agent/agent-stub.ts @@ -0,0 +1,19 @@ +import type { Middleware } from 'koa'; + +import { buildAgentQuery } from './build-agent-query'; + +export default function createAgentStubMiddleware(): Middleware { + return async function agentStubMiddleware(ctx) { + const query = buildAgentQuery({ timezone: ctx.state.timezone as string }); + ctx.state.agentQuery = query; + + ctx.status = 501; + ctx.body = { + error: { + type: 'not_implemented', + status: 501, + message: 'Agent proxy is not implemented yet', + }, + }; + }; +} diff --git a/packages/agent-bff/src/agent/build-agent-query.ts b/packages/agent-bff/src/agent/build-agent-query.ts new file mode 100644 index 0000000000..73067b0653 --- /dev/null +++ b/packages/agent-bff/src/agent/build-agent-query.ts @@ -0,0 +1,11 @@ +export interface AgentQuery { + timezone: string; +} + +export interface BuildAgentQueryParams { + timezone: string; +} + +export function buildAgentQuery({ timezone }: BuildAgentQueryParams): AgentQuery { + return { timezone }; +} diff --git a/packages/agent-bff/src/api-key/api-key-middleware.ts b/packages/agent-bff/src/api-key/api-key-middleware.ts index 640b4ed46a..fdc60da445 100644 --- a/packages/agent-bff/src/api-key/api-key-middleware.ts +++ b/packages/agent-bff/src/api-key/api-key-middleware.ts @@ -1,9 +1,9 @@ import type { ApiKeyAuthenticator, AuthenticatedApiKey } from './api-key-authenticator'; import type { Logger } from '../ports/logger-port'; -import type { Context, Middleware } from 'koa'; +import type { Middleware } from 'koa'; import { fingerprintApiKey } from './api-key'; -import { ApiKeyError, toErrorBody } from './api-key-error'; +import { ApiKeyError } from './api-key-error'; export const BFF_KEY_HEADER = 'X-Forest-Bff-Key'; @@ -12,27 +12,9 @@ export interface ApiKeyMiddlewareOptions { 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' } }; -} - +// On authentication failure this middleware rethrows `ApiKeyError` rather than +// writing the response itself; it must be mounted behind an error middleware +// that serializes the structured body (see `createErrorMiddleware`). export default function createApiKeyMiddleware({ authenticator, logger, @@ -51,9 +33,19 @@ export default function createApiKeyMiddleware({ try { authenticated = await authenticator.authenticate(rawKey); } catch (error) { - writeError(ctx, error, rawKey, logger); - - return; + if (error instanceof ApiKeyError) { + logger('Warn', 'BFF API key rejected', { + keyHash: fingerprintApiKey(rawKey), + type: error.type, + }); + } else { + logger('Error', 'BFF API key middleware failure', { + keyHash: fingerprintApiKey(rawKey), + cause: error instanceof Error ? `${error.name}: ${error.message}` : String(error), + }); + } + + throw error; } ctx.state.agentToken = authenticated.agentToken; diff --git a/packages/agent-bff/src/auth/auth-mode-middleware.ts b/packages/agent-bff/src/auth/auth-mode-middleware.ts new file mode 100644 index 0000000000..f0e79c527f --- /dev/null +++ b/packages/agent-bff/src/auth/auth-mode-middleware.ts @@ -0,0 +1,69 @@ +import type { BffAccessTokenPayload } from '../oauth/bff-token'; +import type { Middleware } from 'koa'; + +import jsonwebtoken from 'jsonwebtoken'; + +import { extractBearerToken, resolveAuthMode } from './auth-mode'; +import { sessionExpired, unauthorized } from '../http/bff-http-error'; +import { BFF_ACCESS_TOKEN_TYPE } from '../oauth/bff-token'; + +export const BFF_KEY_HEADER = 'X-Forest-Bff-Key'; + +export interface AuthModeMiddlewareOptions { + authSecret: string; +} + +function verifyBffAccess(token: string, authSecret: string): BffAccessTokenPayload { + let decoded: unknown; + + // Verify the signature first but ignore expiration, so the token type can be + // checked before expiry: a wrong-typed token must be `unauthorized`, and only + // a genuine expired `bff_access` should map to `session_expired`. + try { + decoded = jsonwebtoken.verify(token, authSecret, { + algorithms: ['HS256'], + ignoreExpiration: true, + }); + } catch { + throw unauthorized(); + } + + if ( + typeof decoded !== 'object' || + decoded === null || + (decoded as { type?: unknown }).type !== BFF_ACCESS_TOKEN_TYPE + ) { + throw unauthorized(); + } + + const { exp } = decoded as { exp?: unknown }; + + if (typeof exp !== 'number') { + throw unauthorized(); + } + + if (exp * 1000 <= Date.now()) { + throw sessionExpired(); + } + + return decoded as BffAccessTokenPayload; +} + +export default function createAuthModeMiddleware({ + authSecret, +}: AuthModeMiddlewareOptions): Middleware { + return async function authModeMiddleware(ctx, next) { + const bearer = extractBearerToken(ctx.get('Authorization')); + const apiKey = ctx.get(BFF_KEY_HEADER); + + const mode = resolveAuthMode({ hasBearer: bearer !== undefined, hasApiKey: apiKey !== '' }); + + ctx.state.authMode = mode; + + if (mode === 'oauth') { + ctx.state.principal = verifyBffAccess(bearer as string, authSecret); + } + + await next(); + }; +} diff --git a/packages/agent-bff/src/auth/auth-mode.ts b/packages/agent-bff/src/auth/auth-mode.ts new file mode 100644 index 0000000000..9e20a397ea --- /dev/null +++ b/packages/agent-bff/src/auth/auth-mode.ts @@ -0,0 +1,30 @@ +import { ambiguousCredentials, unauthorized } from '../http/bff-http-error'; + +export type AuthMode = 'oauth' | 'api-key'; + +const BEARER_PATTERN = /^Bearer[ \t]+(.+)$/i; + +export function extractBearerToken(authorization: string | undefined): string | undefined { + if (!authorization) return undefined; + + const match = BEARER_PATTERN.exec(authorization.trim()); + if (!match) return undefined; + + const token = match[1].trim(); + + return token === '' ? undefined : token; +} + +export function resolveAuthMode({ + hasBearer, + hasApiKey, +}: { + hasBearer: boolean; + hasApiKey: boolean; +}): AuthMode { + if (hasBearer && hasApiKey) throw ambiguousCredentials(); + if (hasApiKey) return 'api-key'; + if (hasBearer) return 'oauth'; + + throw unauthorized(); +} diff --git a/packages/agent-bff/src/cli-core.ts b/packages/agent-bff/src/cli-core.ts index 633453b535..c391cd1738 100644 --- a/packages/agent-bff/src/cli-core.ts +++ b/packages/agent-bff/src/cli-core.ts @@ -5,22 +5,56 @@ import type { Middleware } from 'koa'; import { bodyParser } from '@koa/bodyparser'; import createConsoleLogger from './adapters/console-logger'; +import createAgentStubMiddleware from './agent/agent-stub'; import createApiKeyAuthenticator from './api-key/api-key-authenticator'; import ApiKeyClient from './api-key/api-key-client'; import createApiKeyMiddleware from './api-key/api-key-middleware'; import createResolveCache from './api-key/resolve-cache'; +import createAuthModeMiddleware from './auth/auth-mode-middleware'; import { parseConfig } from './config/env-config'; +import createCorsMiddleware from './cors/cors-middleware'; +import createPerKeyOriginMiddleware from './cors/per-key-origin'; import { extractErrorMessage } from './errors'; +import { unauthorized } from './http/bff-http-error'; import BFFHttpServer from './http/bff-http-server'; +import createErrorMiddleware from './http/error-middleware'; import ForestServerClient from './oauth/forest-server-client'; import createOAuthRoutes from './oauth/oauth-routes'; import createInMemorySessionStore from './oauth/session-store'; import createTokenCipher from './oauth/token-cipher'; +import createTimezoneMiddleware from './timezone/timezone-middleware'; import version from './version'; const BODY_LIMIT = '16kb'; const SESSION_TTL_SECONDS = 24 * 60 * 60; +function isAgentPath(path: string): boolean { + return path === '/agent' || path.startsWith('/agent/'); +} + +function agentScoped(middleware: Middleware): Middleware { + return async function scoped(ctx, next) { + if (!isAgentPath(ctx.path)) { + await next(); + + return; + } + + await middleware(ctx, next); + }; +} + +function createApiKeyUnavailableGuard(logger: Logger): Middleware { + return async function apiKeyUnavailableGuard(ctx, next) { + if (ctx.state.authMode === 'api-key') { + logger('Error', 'API key auth requested but the resolver is not configured'); + throw unauthorized('Credentials could not be validated'); + } + + await next(); + }; +} + interface ResolvedOAuthConfig { forestServerUrl: string; forestEnvSecret: string; @@ -76,7 +110,7 @@ async function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): Promise logger, }); - return [bodyParser({ jsonLimit: BODY_LIMIT }), oauthRoutes]; + return [oauthRoutes]; } interface ResolvedApiKeyConfig { @@ -118,14 +152,51 @@ function buildApiKeyMiddleware(config: BFFConfig, logger: Logger): Middleware | return createApiKeyMiddleware({ authenticator, logger }); } +function buildAgentMiddlewares(config: BFFConfig, logger: Logger): Middleware[] { + const { forestAuthSecret, defaultTimezone } = config; + + if (!forestAuthSecret) { + logger('Warn', 'Agent edge disabled: FOREST_AUTH_SECRET is missing'); + + return []; + } + + const apiKeyStep = buildApiKeyMiddleware(config, logger) ?? createApiKeyUnavailableGuard(logger); + + const chain: Middleware[] = [ + createAuthModeMiddleware({ authSecret: forestAuthSecret }), + apiKeyStep, + createPerKeyOriginMiddleware(), + createTimezoneMiddleware({ defaultTimezone }), + createAgentStubMiddleware(), + ]; + + return chain.map(agentScoped); +} + export default async function runCli( env: NodeJS.ProcessEnv, logger: Logger = createConsoleLogger(), ): Promise { const config = parseConfig(env); + + if (config.invalidAllowedOrigins.length > 0) { + logger('Warn', 'Ignoring malformed BFF_ALLOWED_ORIGINS entries', { + entries: config.invalidAllowedOrigins, + }); + } + const oauthMiddlewares = await buildOAuthMiddlewares(config, logger); - const apiKeyMiddleware = buildApiKeyMiddleware(config, logger); - const middlewares = [...oauthMiddlewares, ...(apiKeyMiddleware ? [apiKeyMiddleware] : [])]; + const agentMiddlewares = buildAgentMiddlewares(config, logger); + const agentErrorMiddleware = + agentMiddlewares.length > 0 ? [agentScoped(createErrorMiddleware({ logger }))] : []; + const middlewares = [ + createCorsMiddleware({ allowedOrigins: config.allowedOrigins }), + ...agentErrorMiddleware, + bodyParser({ jsonLimit: BODY_LIMIT }), + ...oauthMiddlewares, + ...agentMiddlewares, + ]; const server = new BFFHttpServer({ port: config.httpPort, version, diff --git a/packages/agent-bff/src/config/env-config.ts b/packages/agent-bff/src/config/env-config.ts index 5c90810eda..3ca941a48c 100644 --- a/packages/agent-bff/src/config/env-config.ts +++ b/packages/agent-bff/src/config/env-config.ts @@ -1,7 +1,9 @@ import { z } from 'zod'; +import { parseAllowedOrigins } from '../cors/origin'; import DEFAULT_BFF_PORT from '../defaults'; import { ConfigurationError } from '../errors'; +import { isValidTimezone } from '../timezone/timezone'; export const REQUIRED_KEYS = [ 'FOREST_AUTH_SECRET', @@ -24,6 +26,9 @@ export interface BFFConfig { forestAppUrl?: string; agentUrl?: string; tokenEncryptionKey?: string; + allowedOrigins: string[]; + invalidAllowedOrigins: string[]; + defaultTimezone?: string; httpPort: number; presence: PresenceMap; hasAllRequired: boolean; @@ -74,6 +79,18 @@ function parseEncryptionKey(raw: string | undefined): string | undefined { return value; } +function parseDefaultTimezone(raw: string | undefined): string | undefined { + const value = normalize(raw); + + if (value !== undefined && !isValidTimezone(value)) { + throw new ConfigurationError( + `Invalid configuration: BFF_DEFAULT_TIMEZONE must be a valid IANA timezone.`, + ); + } + + return value; +} + export function parseConfig(env: NodeJS.ProcessEnv): BFFConfig { const normalized = Object.fromEntries( REQUIRED_KEYS.map(key => [key, normalize(env[key])]), @@ -92,6 +109,10 @@ export function parseConfig(env: NodeJS.ProcessEnv): BFFConfig { ) as PresenceMap; const tokenEncryptionKey = parseEncryptionKey(env.BFF_TOKEN_ENCRYPTION_KEY); + const { origins: allowedOrigins, invalid: invalidAllowedOrigins } = parseAllowedOrigins( + env.BFF_ALLOWED_ORIGINS, + ); + const defaultTimezone = parseDefaultTimezone(env.BFF_DEFAULT_TIMEZONE); return { forestAuthSecret: normalized.FOREST_AUTH_SECRET, @@ -100,6 +121,9 @@ export function parseConfig(env: NodeJS.ProcessEnv): BFFConfig { forestAppUrl: normalized.FOREST_APP_URL, agentUrl: normalized.AGENT_URL, tokenEncryptionKey, + allowedOrigins, + invalidAllowedOrigins, + defaultTimezone, httpPort: parsePort(env.HTTP_PORT), presence, hasAllRequired: REQUIRED_KEYS.every(key => presence[key]) && tokenEncryptionKey !== undefined, diff --git a/packages/agent-bff/src/cors/cors-middleware.ts b/packages/agent-bff/src/cors/cors-middleware.ts new file mode 100644 index 0000000000..0ca76a7dd4 --- /dev/null +++ b/packages/agent-bff/src/cors/cors-middleware.ts @@ -0,0 +1,40 @@ +import type { Middleware } from 'koa'; + +import { originAllowed } from './origin'; + +export const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'; +export const ALLOWED_HEADERS = + 'Authorization, Content-Type, X-Forest-Timezone, X-Forest-Bff-Key, X-Request-Id'; +export const PREFLIGHT_MAX_AGE_SECONDS = 600; + +export interface CorsMiddlewareOptions { + allowedOrigins: string[]; +} + +export default function createCorsMiddleware({ + allowedOrigins, +}: CorsMiddlewareOptions): Middleware { + return async function corsMiddleware(ctx, next) { + const origin = ctx.get('Origin'); + + if (origin) ctx.vary('Origin'); + + const allowed = origin ? originAllowed(origin, allowedOrigins) : false; + + if (allowed) ctx.set('Access-Control-Allow-Origin', origin); + + if (ctx.method === 'OPTIONS') { + if (allowed) { + ctx.set('Access-Control-Allow-Methods', ALLOWED_METHODS); + ctx.set('Access-Control-Allow-Headers', ALLOWED_HEADERS); + ctx.set('Access-Control-Max-Age', String(PREFLIGHT_MAX_AGE_SECONDS)); + } + + ctx.status = 204; + + return; + } + + await next(); + }; +} diff --git a/packages/agent-bff/src/cors/origin.ts b/packages/agent-bff/src/cors/origin.ts new file mode 100644 index 0000000000..967c62cf10 --- /dev/null +++ b/packages/agent-bff/src/cors/origin.ts @@ -0,0 +1,49 @@ +export function normalizeOrigin(raw: string | undefined | null): string | null { + if (raw === undefined || raw === null) return null; + + const trimmed = raw.trim(); + if (trimmed === '' || trimmed === 'null') return null; + + let url: URL; + + try { + url = new URL(trimmed); + } catch { + return null; + } + + if (url.origin === 'null') return null; + + return url.origin; +} + +export function parseAllowedOrigins(raw: string | undefined): { + origins: string[]; + invalid: string[]; +} { + if (raw === undefined || raw.trim() === '') return { origins: [], invalid: [] }; + + const origins: string[] = []; + const invalid: string[] = []; + + const entries = raw + .split(',') + .map(entry => entry.trim()) + .filter(entry => entry !== ''); + + for (const entry of entries) { + const normalized = normalizeOrigin(entry); + + if (normalized === null) invalid.push(entry); + else if (!origins.includes(normalized)) origins.push(normalized); + } + + return { origins, invalid }; +} + +export function originAllowed(requestOrigin: string | undefined, allowList: string[]): boolean { + const normalized = normalizeOrigin(requestOrigin); + if (normalized === null) return false; + + return allowList.some(entry => normalizeOrigin(entry) === normalized); +} diff --git a/packages/agent-bff/src/cors/per-key-origin.ts b/packages/agent-bff/src/cors/per-key-origin.ts new file mode 100644 index 0000000000..7f6fcfc7ee --- /dev/null +++ b/packages/agent-bff/src/cors/per-key-origin.ts @@ -0,0 +1,17 @@ +import type { Middleware } from 'koa'; + +import { originAllowed } from './origin'; +import { originNotAllowed } from '../http/bff-http-error'; + +export default function createPerKeyOriginMiddleware(): Middleware { + return async function perKeyOriginMiddleware(ctx, next) { + const identity = ctx.state.apiKeyIdentity as { allowedOrigins?: string[] } | undefined; + const allowedOrigins = identity?.allowedOrigins ?? []; + + if (allowedOrigins.length > 0 && !originAllowed(ctx.get('Origin'), allowedOrigins)) { + throw originNotAllowed(); + } + + await next(); + }; +} diff --git a/packages/agent-bff/src/http/bff-http-error.ts b/packages/agent-bff/src/http/bff-http-error.ts new file mode 100644 index 0000000000..dbba05fc4a --- /dev/null +++ b/packages/agent-bff/src/http/bff-http-error.ts @@ -0,0 +1,81 @@ +export class BffHttpError extends Error { + readonly status: number; + readonly type: string; + readonly details?: unknown; + + constructor(status: number, type: string, message: string, details?: unknown) { + super(message); + this.name = 'BffHttpError'; + this.status = status; + this.type = type; + this.details = details; + } +} + +export interface BffErrorBody { + error: { + type: string; + status: number; + message: string; + details?: unknown; + }; +} + +export function isSerializableError(error: unknown): error is { + status: number; + type: string; + message: string; + details?: unknown; + retryAfter?: number; +} { + return ( + typeof error === 'object' && + error !== null && + typeof (error as { status?: unknown }).status === 'number' && + typeof (error as { type?: unknown }).type === 'string' && + typeof (error as { message?: unknown }).message === 'string' + ); +} + +export function toErrorBody(error: { + status: number; + type: string; + message: string; + details?: unknown; +}): BffErrorBody { + const body: BffErrorBody = { + error: { type: error.type, status: error.status, message: error.message }, + }; + + if (error.details !== undefined) body.error.details = error.details; + + return body; +} + +export function unauthorized(message = 'Missing or invalid credentials'): BffHttpError { + return new BffHttpError(401, 'unauthorized', message); +} + +export function ambiguousCredentials( + message = 'Both Authorization and X-Forest-Bff-Key were provided', +): BffHttpError { + return new BffHttpError(400, 'ambiguous_credentials', message); +} + +export function sessionExpired(message = 'The BFF session has expired'): BffHttpError { + return new BffHttpError(401, 'session_expired', message); +} + +export function originNotAllowed(message = 'Origin is not allowed for this key'): BffHttpError { + return new BffHttpError(403, 'origin_not_allowed', message); +} + +export function missingTimezone( + message = 'A timezone is required but none was provided', +): BffHttpError { + return new BffHttpError(400, 'missing_timezone', message); +} + +export function invalidTimezone(value: string): BffHttpError { + return new BffHttpError(400, 'invalid_timezone', `Invalid timezone: "${value}"`); +} diff --git a/packages/agent-bff/src/http/error-middleware.ts b/packages/agent-bff/src/http/error-middleware.ts new file mode 100644 index 0000000000..71802f3699 --- /dev/null +++ b/packages/agent-bff/src/http/error-middleware.ts @@ -0,0 +1,59 @@ +import type { Logger } from '../ports/logger-port'; +import type { Middleware } from 'koa'; + +import { isSerializableError, toErrorBody } from './bff-http-error'; + +export interface ErrorMiddlewareOptions { + logger: Logger; +} + +const CLIENT_ERROR_TYPES: Record = { + 413: 'payload_too_large', + 415: 'unsupported_media_type', +}; + +function clientErrorStatus(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) return undefined; + + const { status, statusCode } = error as { status?: unknown; statusCode?: unknown }; + const value = typeof status === 'number' ? status : statusCode; + + return typeof value === 'number' && value >= 400 && value < 500 ? value : undefined; +} + +export default function createErrorMiddleware({ logger }: ErrorMiddlewareOptions): Middleware { + return async function errorMiddleware(ctx, next) { + try { + await next(); + } catch (error) { + if (isSerializableError(error)) { + if (error.retryAfter !== undefined) ctx.set('Retry-After', String(error.retryAfter)); + ctx.status = error.status; + ctx.body = toErrorBody(error); + + return; + } + + const clientStatus = clientErrorStatus(error); + + if (clientStatus !== undefined) { + ctx.status = clientStatus; + ctx.body = toErrorBody({ + status: clientStatus, + type: CLIENT_ERROR_TYPES[clientStatus] ?? 'invalid_request', + message: 'Invalid request', + }); + + return; + } + + logger('Error', 'Unhandled BFF edge error', { + cause: error instanceof Error ? `${error.name}: ${error.message}` : String(error), + }); + ctx.status = 500; + ctx.body = { + error: { type: 'internal_error', status: 500, message: 'Internal server error' }, + }; + } + }; +} diff --git a/packages/agent-bff/src/timezone/timezone-middleware.ts b/packages/agent-bff/src/timezone/timezone-middleware.ts new file mode 100644 index 0000000000..372054077e --- /dev/null +++ b/packages/agent-bff/src/timezone/timezone-middleware.ts @@ -0,0 +1,33 @@ +import type { Middleware } from 'koa'; + +import { resolveTimezone } from './timezone'; + +export const TIMEZONE_HEADER = 'X-Forest-Timezone'; + +export interface TimezoneMiddlewareOptions { + defaultTimezone?: string; +} + +function bodyTimezone(ctx: Parameters[0]): string | undefined { + const { body } = ctx.request as { body?: unknown }; + + if (typeof body !== 'object' || body === null) return undefined; + + const value = (body as { timezone?: unknown }).timezone; + + return typeof value === 'string' ? value : undefined; +} + +export default function createTimezoneMiddleware({ + defaultTimezone, +}: TimezoneMiddlewareOptions): Middleware { + return async function timezoneMiddleware(ctx, next) { + ctx.state.timezone = resolveTimezone({ + header: ctx.get(TIMEZONE_HEADER), + body: bodyTimezone(ctx), + fallback: defaultTimezone, + }); + + await next(); + }; +} diff --git a/packages/agent-bff/src/timezone/timezone.ts b/packages/agent-bff/src/timezone/timezone.ts new file mode 100644 index 0000000000..06efa7ed47 --- /dev/null +++ b/packages/agent-bff/src/timezone/timezone.ts @@ -0,0 +1,40 @@ +import { invalidTimezone, missingTimezone } from '../http/bff-http-error'; + +const VALID_TIMEZONES = new Set(); + +export interface TimezoneSources { + header?: string; + body?: string; + fallback?: string; +} + +export function isValidTimezone(value: string): boolean { + if (value === '') return false; + if (VALID_TIMEZONES.has(value)) return true; + + let canonical: string; + + try { + canonical = Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone; + } catch { + return false; + } + + // Cache the canonical form, not the raw input: Intl matches IANA names + // case-insensitively, so caching raw casings would grow this Set without + // bound on attacker-controlled input. + VALID_TIMEZONES.add(canonical); + + return true; +} + +export function resolveTimezone({ header, body, fallback }: TimezoneSources): string { + const candidate = [header, body, fallback] + .map(value => value?.trim()) + .find(value => value !== undefined && value !== ''); + + if (candidate === undefined) throw missingTimezone(); + if (!isValidTimezone(candidate)) throw invalidTimezone(candidate); + + return candidate; +} diff --git a/packages/agent-bff/test/agent/build-agent-query.test.ts b/packages/agent-bff/test/agent/build-agent-query.test.ts new file mode 100644 index 0000000000..556d65fe61 --- /dev/null +++ b/packages/agent-bff/test/agent/build-agent-query.test.ts @@ -0,0 +1,9 @@ +import { buildAgentQuery } from '../../src/agent/build-agent-query'; + +describe('buildAgentQuery', () => { + it('injects the resolved timezone into the outbound agent query', () => { + expect(buildAgentQuery({ timezone: 'America/New_York' })).toEqual({ + timezone: 'America/New_York', + }); + }); +}); diff --git a/packages/agent-bff/test/api-key/api-key-middleware.test.ts b/packages/agent-bff/test/api-key/api-key-middleware.test.ts index 89c402f09a..4bf59daeee 100644 --- a/packages/agent-bff/test/api-key/api-key-middleware.test.ts +++ b/packages/agent-bff/test/api-key/api-key-middleware.test.ts @@ -6,6 +6,7 @@ import request from 'supertest'; import { invalidApiKey, keyResolutionUnavailable } from '../../src/api-key/api-key-error'; import createApiKeyMiddleware, { BFF_KEY_HEADER } from '../../src/api-key/api-key-middleware'; +import createErrorMiddleware from '../../src/http/error-middleware'; const KEY_ID = 'a'.repeat(16); const SECRET = 'b'.repeat(64); @@ -39,6 +40,8 @@ function buildApp(authenticate: ApiKeyAuthenticator['authenticate']) { }; const app = new Koa(); + app.silent = true; + app.use(createErrorMiddleware({ logger })); app.use(createApiKeyMiddleware({ authenticator: { authenticate }, logger })); app.use(async ctx => { ctx.status = 200; @@ -88,7 +91,7 @@ describe('api key middleware', () => { }); describe('when the authenticator rejects', () => { - it('should return 401 invalid_api_key in the nested error shape', async () => { + it('should surface 401 invalid_api_key in the nested error shape', async () => { const authenticate = jest.fn(async () => { throw invalidApiKey(); }); @@ -115,7 +118,23 @@ describe('api key middleware', () => { expect(response.headers['retry-after']).toBe('5'); }); - it('should return a 500 server_error for an unexpected non-ApiKeyError', async () => { + it('should log a fingerprinted rejection without the raw secret', async () => { + const authenticate = jest.fn(async () => { + throw invalidApiKey(); + }); + const { app, logs } = buildApp(authenticate); + + await request(app.callback()).get('/').set(BFF_KEY_HEADER, RAW); + + expect(logs).toContainEqual( + expect.objectContaining({ level: 'Warn', message: 'BFF API key rejected' }), + ); + expect(JSON.stringify(logs)).not.toContain(SECRET); + }); + }); + + describe('when the authenticator throws an unexpected error', () => { + it('should surface a 500 internal_error and log the failure without the raw secret', async () => { const authenticate = jest.fn(async () => { throw new Error('unexpected boom'); }); @@ -124,9 +143,7 @@ describe('api key middleware', () => { const response = await request(app.callback()).get('/').set(BFF_KEY_HEADER, RAW); expect(response.status).toBe(500); - expect(response.body).toEqual({ - error: { type: 'server_error', status: 500, message: 'API key processing failed' }, - }); + expect(response.body.error.type).toBe('internal_error'); expect(logs).toContainEqual( expect.objectContaining({ level: 'Error', message: 'BFF API key middleware failure' }), ); @@ -135,7 +152,7 @@ describe('api key middleware', () => { }); describe('when a downstream handler throws after the key is accepted', () => { - it('should let the error propagate instead of rewriting it as an API key failure', async () => { + it('should not report it as an API key failure', async () => { const authenticate = jest.fn(async () => ({ agentToken: 'minted-token', identity: IDENTITY, @@ -148,6 +165,7 @@ describe('api key middleware', () => { const app = new Koa(); app.silent = true; + app.use(createErrorMiddleware({ logger })); app.use(createApiKeyMiddleware({ authenticator: { authenticate }, logger })); app.use(async () => { throw new Error('downstream boom'); @@ -156,7 +174,6 @@ describe('api key middleware', () => { const response = await request(app.callback()).get('/').set(BFF_KEY_HEADER, RAW); expect(response.status).toBe(500); - expect(response.body).not.toHaveProperty('error.type', 'server_error'); expect(logs).not.toContainEqual( expect.objectContaining({ message: 'BFF API key middleware failure' }), ); diff --git a/packages/agent-bff/test/auth/auth-mode-middleware.test.ts b/packages/agent-bff/test/auth/auth-mode-middleware.test.ts new file mode 100644 index 0000000000..3de3b9ba4c --- /dev/null +++ b/packages/agent-bff/test/auth/auth-mode-middleware.test.ts @@ -0,0 +1,126 @@ +import jsonwebtoken from 'jsonwebtoken'; +import Koa from 'koa'; +import request from 'supertest'; + +import createAuthModeMiddleware, { BFF_KEY_HEADER } from '../../src/auth/auth-mode-middleware'; +import createErrorMiddleware from '../../src/http/error-middleware'; + +const AUTH_SECRET = 'test-secret'; +const RAW_KEY = `fbff_${'a'.repeat(16)}_${'b'.repeat(64)}`; + +function bffAccess(expiresIn: string | number, type = 'bff_access') { + return jsonwebtoken.sign({ type, sid: 's1' }, AUTH_SECRET, { + algorithm: 'HS256', + expiresIn, + } as jsonwebtoken.SignOptions); +} + +function buildApp() { + const app = new Koa(); + app.silent = true; + app.use(createErrorMiddleware({ logger: () => undefined })); + app.use(createAuthModeMiddleware({ authSecret: AUTH_SECRET })); + app.use(async ctx => { + ctx.status = 200; + ctx.body = { authMode: ctx.state.authMode }; + }); + + return app.callback(); +} + +describe('auth mode middleware', () => { + it('returns 400 ambiguous_credentials when both Bearer and API key are present', async () => { + const response = await request(buildApp()) + .get('/agent/x') + .set('Authorization', `Bearer ${bffAccess('15m')}`) + .set(BFF_KEY_HEADER, RAW_KEY); + + expect(response.status).toBe(400); + expect(response.body.error.type).toBe('ambiguous_credentials'); + }); + + it('returns 401 unauthorized when neither credential is present', async () => { + const response = await request(buildApp()).get('/agent/x'); + + expect(response.status).toBe(401); + expect(response.body.error.type).toBe('unauthorized'); + }); + + it('resolves api-key when only the API key header is present', async () => { + const response = await request(buildApp()).get('/agent/x').set(BFF_KEY_HEADER, RAW_KEY); + + expect(response.status).toBe(200); + expect(response.body.authMode).toBe('api-key'); + }); + + it('resolves oauth for a valid bff_access Bearer', async () => { + const response = await request(buildApp()) + .get('/agent/x') + .set('Authorization', `Bearer ${bffAccess('15m')}`); + + expect(response.status).toBe(200); + expect(response.body.authMode).toBe('oauth'); + }); + + it('accepts a lowercase bearer scheme (auth schemes are case-insensitive)', async () => { + const response = await request(buildApp()) + .get('/agent/x') + .set('Authorization', `bearer ${bffAccess('15m')}`); + + expect(response.status).toBe(200); + expect(response.body.authMode).toBe('oauth'); + }); + + it('returns 401 session_expired for an expired bff_access', async () => { + const response = await request(buildApp()) + .get('/agent/x') + .set('Authorization', `Bearer ${bffAccess(-10)}`); + + expect(response.status).toBe(401); + expect(response.body.error.type).toBe('session_expired'); + }); + + it('returns 401 unauthorized for a bad signature', async () => { + const forged = jsonwebtoken.sign({ type: 'bff_access' }, 'wrong-secret', { + algorithm: 'HS256', + }); + + const response = await request(buildApp()) + .get('/agent/x') + .set('Authorization', `Bearer ${forged}`); + + expect(response.status).toBe(401); + expect(response.body.error.type).toBe('unauthorized'); + }); + + it('returns 401 unauthorized when the token type is not bff_access', async () => { + const response = await request(buildApp()) + .get('/agent/x') + .set('Authorization', `Bearer ${bffAccess('15m', 'agent_token')}`); + + expect(response.status).toBe(401); + expect(response.body.error.type).toBe('unauthorized'); + }); + + it('returns 401 unauthorized for an expired token of the wrong type (type checked before expiry)', async () => { + const response = await request(buildApp()) + .get('/agent/x') + .set('Authorization', `Bearer ${bffAccess(-10, 'agent_token')}`); + + expect(response.status).toBe(401); + expect(response.body.error.type).toBe('unauthorized'); + }); + + it('returns 401 unauthorized for a bff_access token with no exp claim', async () => { + const noExp = jsonwebtoken.sign({ type: 'bff_access', sid: 's1' }, AUTH_SECRET, { + algorithm: 'HS256', + }); + + const response = await request(buildApp()) + .get('/agent/x') + .set('Authorization', `Bearer ${noExp}`); + + expect(response.status).toBe(401); + expect(response.body.error.type).toBe('unauthorized'); + }); +}); diff --git a/packages/agent-bff/test/cli-core.test.ts b/packages/agent-bff/test/cli-core.test.ts index d5d17a507a..e3c053ffca 100644 --- a/packages/agent-bff/test/cli-core.test.ts +++ b/packages/agent-bff/test/cli-core.test.ts @@ -1,5 +1,8 @@ import type { Logger } from '../src/ports/logger-port'; +import jsonwebtoken from 'jsonwebtoken'; +import request from 'supertest'; + import runCli, { reportFatalError } from '../src/cli-core'; import { ConfigurationError } from '../src/errors'; @@ -92,6 +95,89 @@ describe('runCli', () => { }); }); + describe('when FOREST_AUTH_SECRET is absent', () => { + it('should disable the agent edge and log it', async () => { + const logs: string[] = []; + const logger: Logger = (_level, message) => logs.push(message); + + const server = await runCli({ ...VALID_ENV, FOREST_AUTH_SECRET: undefined }, logger); + + try { + expect(logs).toContain('Agent edge disabled: FOREST_AUTH_SECRET is missing'); + } finally { + await server.stop(); + } + }); + }); + + describe('when BFF_ALLOWED_ORIGINS has malformed entries', () => { + it('should log that the malformed entries are ignored', async () => { + const logs: string[] = []; + const logger: Logger = (_level, message) => logs.push(message); + + const server = await runCli( + { ...VALID_ENV, BFF_ALLOWED_ORIGINS: 'https://ok.com, garbage' }, + logger, + ); + + try { + expect(logs).toContain('Ignoring malformed BFF_ALLOWED_ORIGINS entries'); + } finally { + await server.stop(); + } + }); + }); + + describe('when the API key resolver is not configured', () => { + it('should fail closed with 401 for an api-key request instead of reaching the agent stub', async () => { + const server = await runCli({ ...VALID_ENV, FOREST_ENV_SECRET: undefined }, noopLogger); + + try { + const response = await request(server.callback) + .get('/agent/records') + .set('X-Forest-Bff-Key', 'fbff_anything'); + + expect(response.status).toBe(401); + expect(response.body.error.type).toBe('unauthorized'); + } finally { + await server.stop(); + } + }); + + it('should let an oauth Bearer request through the guard to timezone resolution', async () => { + const token = jsonwebtoken.sign({ type: 'bff_access' }, VALID_ENV.FOREST_AUTH_SECRET, { + algorithm: 'HS256', + expiresIn: '15m', + }); + const server = await runCli({ ...VALID_ENV, FOREST_ENV_SECRET: undefined }, noopLogger); + + try { + const response = await request(server.callback) + .get('/agent/records') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(400); + expect(response.body.error.type).toBe('missing_timezone'); + } finally { + await server.stop(); + } + }); + }); + + describe('when a request targets a non-agent path', () => { + it('should skip the agent chain and fall through to 404', async () => { + const server = await runCli({ ...VALID_ENV }, noopLogger); + + try { + const response = await request(server.callback).get('/not-agent'); + + expect(response.status).toBe(404); + } finally { + await server.stop(); + } + }); + }); + describe('when a config value is malformed', () => { it('should throw ConfigurationError naming the key without echoing the secret', async () => { const err = await runCli( diff --git a/packages/agent-bff/test/config/env-config.test.ts b/packages/agent-bff/test/config/env-config.test.ts index 97a12fb859..d08faeb2f7 100644 --- a/packages/agent-bff/test/config/env-config.test.ts +++ b/packages/agent-bff/test/config/env-config.test.ts @@ -162,4 +162,53 @@ describe('parseConfig', () => { ); }); }); + + describe('when resolving BFF_ALLOWED_ORIGINS', () => { + it('should default to an empty allow-list when unset', () => { + const config = parseConfig({ ...VALID_ENV }); + + expect(config.allowedOrigins).toEqual([]); + expect(config.invalidAllowedOrigins).toEqual([]); + }); + + it('should normalize and keep valid comma-separated origins', () => { + const config = parseConfig({ + ...VALID_ENV, + BFF_ALLOWED_ORIGINS: 'https://app.example.com:443, https://zendesk.example.com', + }); + + expect(config.allowedOrigins).toEqual([ + 'https://app.example.com', + 'https://zendesk.example.com', + ]); + }); + + it('should drop malformed and wildcard entries into invalidAllowedOrigins', () => { + const config = parseConfig({ + ...VALID_ENV, + BFF_ALLOWED_ORIGINS: 'https://app.example.com, *, garbage', + }); + + expect(config.allowedOrigins).toEqual(['https://app.example.com']); + expect(config.invalidAllowedOrigins).toEqual(['*', 'garbage']); + }); + }); + + describe('when resolving BFF_DEFAULT_TIMEZONE', () => { + it('should leave it undefined when unset', () => { + expect(parseConfig({ ...VALID_ENV }).defaultTimezone).toBeUndefined(); + }); + + it('should expose a valid IANA timezone', () => { + expect( + parseConfig({ ...VALID_ENV, BFF_DEFAULT_TIMEZONE: 'Europe/Paris' }).defaultTimezone, + ).toBe('Europe/Paris'); + }); + + it('should throw ConfigurationError for a non-IANA timezone', () => { + expect(() => parseConfig({ ...VALID_ENV, BFF_DEFAULT_TIMEZONE: 'Mars/Phobos' })).toThrow( + ConfigurationError, + ); + }); + }); }); diff --git a/packages/agent-bff/test/cors/cors-middleware.test.ts b/packages/agent-bff/test/cors/cors-middleware.test.ts new file mode 100644 index 0000000000..f90630e5a1 --- /dev/null +++ b/packages/agent-bff/test/cors/cors-middleware.test.ts @@ -0,0 +1,100 @@ +import Koa from 'koa'; +import request from 'supertest'; + +import createCorsMiddleware, { + ALLOWED_HEADERS, + ALLOWED_METHODS, + PREFLIGHT_MAX_AGE_SECONDS, +} from '../../src/cors/cors-middleware'; + +const ALLOWED = 'https://app.example.com'; + +function buildApp() { + const terminal = jest.fn(async (ctx: Koa.Context) => { + ctx.status = 200; + ctx.body = { reached: true }; + }); + + const app = new Koa(); + app.use(createCorsMiddleware({ allowedOrigins: [ALLOWED] })); + app.use(terminal); + + return { app, terminal }; +} + +describe('cors middleware (layer 1)', () => { + describe('simple request', () => { + it('echoes the exact allow-listed origin with Vary and no wildcard or credentials', async () => { + const { app } = buildApp(); + + const response = await request(app.callback()).get('/agent/x').set('Origin', ALLOWED); + + expect(response.status).toBe(200); + expect(response.headers['access-control-allow-origin']).toBe(ALLOWED); + expect(response.headers['access-control-allow-origin']).not.toBe('*'); + expect(response.headers['access-control-allow-credentials']).toBeUndefined(); + expect(response.headers.vary).toContain('Origin'); + }); + + it('matches an allow-listed origin regardless of the default port', async () => { + const { app } = buildApp(); + + const response = await request(app.callback()) + .get('/agent/x') + .set('Origin', `${ALLOWED}:443`); + + expect(response.status).toBe(200); + expect(response.headers['access-control-allow-origin']).toBe(`${ALLOWED}:443`); + }); + + it('sends no CORS origin header for a disallowed origin but still proceeds', async () => { + const { app, terminal } = buildApp(); + + const response = await request(app.callback()) + .get('/agent/x') + .set('Origin', 'https://evil.example.com'); + + expect(response.status).toBe(200); + expect(response.headers['access-control-allow-origin']).toBeUndefined(); + expect(terminal).toHaveBeenCalled(); + }); + }); + + describe('preflight', () => { + it('answers an allow-listed OPTIONS with 204, methods, the allowed headers and max-age', async () => { + const { app, terminal } = buildApp(); + + const response = await request(app.callback()).options('/agent/x').set('Origin', ALLOWED); + + expect(response.status).toBe(204); + expect(response.headers['access-control-allow-origin']).toBe(ALLOWED); + expect(response.headers['access-control-allow-methods']).toBe(ALLOWED_METHODS); + expect(response.headers['access-control-allow-headers']).toBe(ALLOWED_HEADERS); + expect(response.headers['access-control-max-age']).toBe(String(PREFLIGHT_MAX_AGE_SECONDS)); + expect(terminal).not.toHaveBeenCalled(); + }); + + it('lists all five allowed request headers', () => { + expect(ALLOWED_HEADERS.split(', ')).toEqual([ + 'Authorization', + 'Content-Type', + 'X-Forest-Timezone', + 'X-Forest-Bff-Key', + 'X-Request-Id', + ]); + }); + + it('answers a disallowed OPTIONS with 204 and no CORS headers, short-circuiting before next', async () => { + const { app, terminal } = buildApp(); + + const response = await request(app.callback()) + .options('/agent/x') + .set('Origin', 'https://evil.example.com'); + + expect(response.status).toBe(204); + expect(response.headers['access-control-allow-origin']).toBeUndefined(); + expect(response.headers['access-control-allow-methods']).toBeUndefined(); + expect(terminal).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/agent-bff/test/cors/origin.test.ts b/packages/agent-bff/test/cors/origin.test.ts new file mode 100644 index 0000000000..94d912888b --- /dev/null +++ b/packages/agent-bff/test/cors/origin.test.ts @@ -0,0 +1,72 @@ +import { normalizeOrigin, originAllowed, parseAllowedOrigins } from '../../src/cors/origin'; + +describe('normalizeOrigin', () => { + it.each([ + ['https://x.com', 'https://x.com'], + ['https://x.com:443', 'https://x.com'], + ['http://x.com:80', 'http://x.com'], + ['https://X.COM', 'https://x.com'], + ['https://x.com/', 'https://x.com'], + ['https://x.com/some/path', 'https://x.com'], + ['https://x.com:8443', 'https://x.com:8443'], + [' https://x.com ', 'https://x.com'], + ])('normalizes %s to %s', (input, expected) => { + expect(normalizeOrigin(input)).toBe(expected); + }); + + it.each([['*'], ['not a url'], [''], ['null'], ['/relative']])( + 'returns null for the non-origin %s', + input => { + expect(normalizeOrigin(input)).toBeNull(); + }, + ); + + it('returns null for undefined and null', () => { + expect(normalizeOrigin(undefined)).toBeNull(); + expect(normalizeOrigin(null)).toBeNull(); + }); +}); + +describe('parseAllowedOrigins', () => { + it('normalizes and keeps valid comma-separated entries', () => { + expect(parseAllowedOrigins('https://a.com, https://b.com:8443')).toEqual({ + origins: ['https://a.com', 'https://b.com:8443'], + invalid: [], + }); + }); + + it('drops malformed and wildcard entries into invalid', () => { + expect(parseAllowedOrigins('https://a.com, *, garbage')).toEqual({ + origins: ['https://a.com'], + invalid: ['*', 'garbage'], + }); + }); + + it('deduplicates entries that normalize to the same origin', () => { + expect(parseAllowedOrigins('https://a.com, https://a.com:443')).toEqual({ + origins: ['https://a.com'], + invalid: [], + }); + }); + + it('returns empty lists when unset or blank', () => { + expect(parseAllowedOrigins(undefined)).toEqual({ origins: [], invalid: [] }); + expect(parseAllowedOrigins(' ')).toEqual({ origins: [], invalid: [] }); + }); +}); + +describe('originAllowed', () => { + const allowList = ['https://a.com']; + + it('matches after normalization ignoring the default port', () => { + expect(originAllowed('https://a.com:443', allowList)).toBe(true); + }); + + it('rejects an origin not in the list', () => { + expect(originAllowed('https://c.com', allowList)).toBe(false); + }); + + it('rejects an absent origin', () => { + expect(originAllowed(undefined, allowList)).toBe(false); + }); +}); diff --git a/packages/agent-bff/test/cors/per-key-origin.test.ts b/packages/agent-bff/test/cors/per-key-origin.test.ts new file mode 100644 index 0000000000..d51aab3273 --- /dev/null +++ b/packages/agent-bff/test/cors/per-key-origin.test.ts @@ -0,0 +1,67 @@ +import Koa from 'koa'; +import request from 'supertest'; + +import createPerKeyOriginMiddleware from '../../src/cors/per-key-origin'; +import createErrorMiddleware from '../../src/http/error-middleware'; + +function buildApp(allowedOrigins: string[]) { + const app = new Koa(); + app.silent = true; + app.use(createErrorMiddleware({ logger: () => undefined })); + app.use(async (ctx, next) => { + ctx.state.apiKeyIdentity = { allowedOrigins }; + await next(); + }); + app.use(createPerKeyOriginMiddleware()); + app.use(async ctx => { + ctx.status = 200; + ctx.body = { reached: true }; + }); + + return app; +} + +describe('per-key origin middleware (layer 2)', () => { + it('proceeds when the origin is in the per-key list', async () => { + const response = await request(buildApp(['https://a.com']).callback()) + .get('/agent/x') + .set('Origin', 'https://a.com'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ reached: true }); + }); + + it('returns 403 origin_not_allowed when the origin is not in the per-key list', async () => { + const response = await request(buildApp(['https://a.com']).callback()) + .get('/agent/x') + .set('Origin', 'https://b.com'); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ + error: { type: 'origin_not_allowed', status: 403, message: expect.any(String) }, + }); + }); + + it('matches a per-key origin returned by SaaS in a non-normalized form', async () => { + const response = await request(buildApp(['https://a.com:443']).callback()) + .get('/agent/x') + .set('Origin', 'https://a.com'); + + expect(response.status).toBe(200); + }); + + it('is a no-op when the per-key list is empty', async () => { + const response = await request(buildApp([]).callback()) + .get('/agent/x') + .set('Origin', 'https://anything.com'); + + expect(response.status).toBe(200); + }); + + it('returns 403 when the per-key list is non-empty and the request has no Origin', async () => { + const response = await request(buildApp(['https://a.com']).callback()).get('/agent/x'); + + expect(response.status).toBe(403); + expect(response.body.error.type).toBe('origin_not_allowed'); + }); +}); diff --git a/packages/agent-bff/test/http/error-contract.test.ts b/packages/agent-bff/test/http/error-contract.test.ts new file mode 100644 index 0000000000..46c1a223af --- /dev/null +++ b/packages/agent-bff/test/http/error-contract.test.ts @@ -0,0 +1,179 @@ +import type { ApiKeyAuthenticator } from '../../src/api-key/api-key-authenticator'; + +import { bodyParser } from '@koa/bodyparser'; +import jsonwebtoken from 'jsonwebtoken'; +import Koa from 'koa'; +import request from 'supertest'; + +import createAgentStubMiddleware from '../../src/agent/agent-stub'; +import { forestIdentityNotAllowed, invalidApiKey } from '../../src/api-key/api-key-error'; +import createApiKeyMiddleware, { BFF_KEY_HEADER } from '../../src/api-key/api-key-middleware'; +import createAuthModeMiddleware from '../../src/auth/auth-mode-middleware'; +import createPerKeyOriginMiddleware from '../../src/cors/per-key-origin'; +import createErrorMiddleware from '../../src/http/error-middleware'; +import { sessionInvalidated, toErrorBody } from '../../src/oauth/oauth-error'; +import createTimezoneMiddleware from '../../src/timezone/timezone-middleware'; + +const AUTH_SECRET = 'contract-secret'; +const RAW_KEY = `fbff_${'a'.repeat(16)}_${'b'.repeat(64)}`; + +function identity(allowedOrigins: string[] = []) { + return { + user: { + id: 1, + email: 'a@b.com', + firstName: 'A', + lastName: 'B', + team: 'T', + tags: [], + permissionLevel: 'admin', + }, + renderingId: 1, + allowedOrigins, + }; +} + +function bffAccess(expiresIn: string | number) { + return jsonwebtoken.sign({ type: 'bff_access', sid: 's1' }, AUTH_SECRET, { + algorithm: 'HS256', + expiresIn, + } as jsonwebtoken.SignOptions); +} + +function buildEdge(authenticate: ApiKeyAuthenticator['authenticate']) { + const logger = () => undefined; + const app = new Koa(); + app.silent = true; + app.use(bodyParser({ jsonLimit: '16kb' })); + app.use(createErrorMiddleware({ logger })); + app.use(createAuthModeMiddleware({ authSecret: AUTH_SECRET })); + app.use(createApiKeyMiddleware({ authenticator: { authenticate }, logger })); + app.use(createPerKeyOriginMiddleware()); + app.use(createTimezoneMiddleware({ defaultTimezone: undefined })); + app.use(createAgentStubMiddleware()); + + return app.callback(); +} + +const resolvesEmpty: ApiKeyAuthenticator['authenticate'] = async () => ({ + agentToken: 'token', + identity: identity(), +}); + +describe('auth/error contract at the /agent edge', () => { + it('unauthorized (401) when no credentials', async () => { + const res = await request(buildEdge(resolvesEmpty)).get('/agent/records'); + + expect(res.status).toBe(401); + expect(res.body.error).toMatchObject({ type: 'unauthorized', status: 401 }); + }); + + it('ambiguous_credentials (400) when both credentials', async () => { + const res = await request(buildEdge(resolvesEmpty)) + .get('/agent/records') + .set('Authorization', `Bearer ${bffAccess('15m')}`) + .set(BFF_KEY_HEADER, RAW_KEY); + + expect(res.status).toBe(400); + expect(res.body.error).toMatchObject({ type: 'ambiguous_credentials', status: 400 }); + }); + + it('session_expired (401) for an expired bff_access', async () => { + const res = await request(buildEdge(resolvesEmpty)) + .get('/agent/records') + .set('Authorization', `Bearer ${bffAccess(-10)}`); + + expect(res.status).toBe(401); + expect(res.body.error).toMatchObject({ type: 'session_expired', status: 401 }); + }); + + it('invalid_api_key (401) when the key does not resolve', async () => { + const authenticate = jest.fn(async () => { + throw invalidApiKey(); + }); + const res = await request(buildEdge(authenticate)) + .get('/agent/records') + .set(BFF_KEY_HEADER, RAW_KEY); + + expect(res.status).toBe(401); + expect(res.body.error).toMatchObject({ type: 'invalid_api_key', status: 401 }); + }); + + it('forest_identity_not_allowed (403) when the identity is rejected', async () => { + const authenticate = jest.fn(async () => { + throw forestIdentityNotAllowed(); + }); + const res = await request(buildEdge(authenticate)) + .get('/agent/records') + .set(BFF_KEY_HEADER, RAW_KEY); + + expect(res.status).toBe(403); + expect(res.body.error).toMatchObject({ type: 'forest_identity_not_allowed', status: 403 }); + }); + + it('origin_not_allowed (403) when the origin is outside the per-key list', async () => { + const authenticate: ApiKeyAuthenticator['authenticate'] = async () => ({ + agentToken: 'token', + identity: identity(['https://allowed.com']), + }); + const res = await request(buildEdge(authenticate)) + .get('/agent/records') + .set(BFF_KEY_HEADER, RAW_KEY) + .set('Origin', 'https://other.com'); + + expect(res.status).toBe(403); + expect(res.body.error).toMatchObject({ type: 'origin_not_allowed', status: 403 }); + }); + + it('missing_timezone (400) when no timezone resolves', async () => { + const res = await request(buildEdge(resolvesEmpty)) + .get('/agent/records') + .set(BFF_KEY_HEADER, RAW_KEY); + + expect(res.status).toBe(400); + expect(res.body.error).toMatchObject({ type: 'missing_timezone', status: 400 }); + }); + + it('invalid_timezone (400) for a non-IANA timezone', async () => { + const res = await request(buildEdge(resolvesEmpty)) + .get('/agent/records') + .set(BFF_KEY_HEADER, RAW_KEY) + .set('X-Forest-Timezone', 'Mars/Phobos'); + + expect(res.status).toBe(400); + expect(res.body.error).toMatchObject({ type: 'invalid_timezone', status: 400 }); + }); + + it('reaches the (stubbed) agent proxy once every check passes', async () => { + const res = await request(buildEdge(resolvesEmpty)) + .get('/agent/records') + .set(BFF_KEY_HEADER, RAW_KEY) + .set('X-Forest-Timezone', 'America/New_York'); + + expect(res.status).toBe(501); + expect(res.body.error.type).toBe('not_implemented'); + }); + + it('exposes session_invalidated in the RFC shape at the OAuth token endpoint', () => { + expect(toErrorBody(sessionInvalidated('reused'))).toEqual({ + error: 'session_invalidated', + error_description: 'reused', + }); + }); + + it('assigns a distinct type to every auth failure so consumers branch on type alone', () => { + const types = [ + 'unauthorized', + 'ambiguous_credentials', + 'session_expired', + 'session_invalidated', + 'invalid_api_key', + 'forest_identity_not_allowed', + 'origin_not_allowed', + 'missing_timezone', + 'invalid_timezone', + ]; + + expect(new Set(types).size).toBe(types.length); + }); +}); diff --git a/packages/agent-bff/test/http/error-middleware.test.ts b/packages/agent-bff/test/http/error-middleware.test.ts new file mode 100644 index 0000000000..fe4df65c7d --- /dev/null +++ b/packages/agent-bff/test/http/error-middleware.test.ts @@ -0,0 +1,70 @@ +import Koa from 'koa'; +import request from 'supertest'; + +import { BffHttpError } from '../../src/http/bff-http-error'; +import createErrorMiddleware from '../../src/http/error-middleware'; + +function buildApp(thrower: () => never) { + const logs: string[] = []; + const app = new Koa(); + app.silent = true; + app.use(createErrorMiddleware({ logger: (_l, message) => logs.push(message) })); + app.use(async () => thrower()); + + return { callback: app.callback(), logs }; +} + +describe('error middleware', () => { + it('serializes a typed error to the structured body with details', async () => { + const { callback } = buildApp(() => { + throw new BffHttpError(422, 'custom', 'boom', { field: 'x' }); + }); + + const res = await request(callback).get('/'); + + expect(res.status).toBe(422); + expect(res.body).toEqual({ + error: { type: 'custom', status: 422, message: 'boom', details: { field: 'x' } }, + }); + }); + + it('maps a client-status http error (e.g. malformed body) to a structured 4xx', async () => { + const { callback } = buildApp(() => { + const error = Object.assign(new Error('Unexpected token in JSON'), { status: 400 }); + throw error; + }); + + const res = await request(callback).get('/'); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: { type: 'invalid_request', status: 400, message: 'Invalid request' }, + }); + }); + + it('maps a known client status to its specific type (413 → payload_too_large)', async () => { + const { callback } = buildApp(() => { + const error = Object.assign(new Error('Payload too large'), { status: 413 }); + throw error; + }); + + const res = await request(callback).get('/'); + + expect(res.status).toBe(413); + expect(res.body.error.type).toBe('payload_too_large'); + }); + + it('maps an unknown error to 500 internal_error and logs it', async () => { + const { callback, logs } = buildApp(() => { + throw new Error('unexpected'); + }); + + const res = await request(callback).get('/'); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ + error: { type: 'internal_error', status: 500, message: 'Internal server error' }, + }); + expect(logs).toContain('Unhandled BFF edge error'); + }); +}); diff --git a/packages/agent-bff/test/timezone/timezone-middleware.test.ts b/packages/agent-bff/test/timezone/timezone-middleware.test.ts new file mode 100644 index 0000000000..4ab2eb3143 --- /dev/null +++ b/packages/agent-bff/test/timezone/timezone-middleware.test.ts @@ -0,0 +1,44 @@ +import { bodyParser } from '@koa/bodyparser'; +import Koa from 'koa'; +import request from 'supertest'; + +import createTimezoneMiddleware, { TIMEZONE_HEADER } from '../../src/timezone/timezone-middleware'; + +function buildApp(defaultTimezone?: string) { + const app = new Koa(); + app.silent = true; + app.use(bodyParser({ jsonLimit: '16kb' })); + app.use(createTimezoneMiddleware({ defaultTimezone })); + app.use(async ctx => { + ctx.status = 200; + ctx.body = { timezone: ctx.state.timezone }; + }); + + return app.callback(); +} + +describe('timezone middleware', () => { + it('resolves the timezone from a JSON body field', async () => { + const response = await request(buildApp()).post('/agent/x').send({ timezone: 'Asia/Tokyo' }); + + expect(response.status).toBe(200); + expect(response.body.timezone).toBe('Asia/Tokyo'); + }); + + it('ignores a non-string body timezone and falls back to the default', async () => { + const response = await request(buildApp('UTC')).post('/agent/x').send({ timezone: 123 }); + + expect(response.status).toBe(200); + expect(response.body.timezone).toBe('UTC'); + }); + + it('prefers the header over the body', async () => { + const response = await request(buildApp()) + .post('/agent/x') + .set(TIMEZONE_HEADER, 'America/New_York') + .send({ timezone: 'Asia/Tokyo' }); + + expect(response.status).toBe(200); + expect(response.body.timezone).toBe('America/New_York'); + }); +}); diff --git a/packages/agent-bff/test/timezone/timezone.test.ts b/packages/agent-bff/test/timezone/timezone.test.ts new file mode 100644 index 0000000000..98dcfdbab6 --- /dev/null +++ b/packages/agent-bff/test/timezone/timezone.test.ts @@ -0,0 +1,49 @@ +import { BffHttpError } from '../../src/http/bff-http-error'; +import { isValidTimezone, resolveTimezone } from '../../src/timezone/timezone'; + +describe('isValidTimezone', () => { + it.each(['Europe/Paris', 'America/New_York', 'UTC'])('accepts %s', tz => { + expect(isValidTimezone(tz)).toBe(true); + }); + + it.each(['Mars/Phobos', 'Not/AZone', ''])('rejects %s', tz => { + expect(isValidTimezone(tz)).toBe(false); + }); + + it('accepts an IANA zone in non-canonical casing', () => { + expect(isValidTimezone('europe/paris')).toBe(true); + }); +}); + +describe('resolveTimezone', () => { + it('prefers the header over body and fallback', () => { + expect( + resolveTimezone({ header: 'America/New_York', body: 'Asia/Tokyo', fallback: 'UTC' }), + ).toBe('America/New_York'); + }); + + it('uses the body when the header is absent', () => { + expect(resolveTimezone({ body: 'Asia/Tokyo', fallback: 'UTC' })).toBe('Asia/Tokyo'); + }); + + it('uses the fallback when header and body are absent', () => { + expect(resolveTimezone({ fallback: 'Europe/Paris' })).toBe('Europe/Paris'); + }); + + it('skips blank sources', () => { + expect(resolveTimezone({ header: ' ', body: '', fallback: 'UTC' })).toBe('UTC'); + }); + + it('throws missing_timezone when nothing resolves', () => { + expect(() => resolveTimezone({})).toThrow( + expect.objectContaining({ type: 'missing_timezone', status: 400 }), + ); + expect(() => resolveTimezone({})).toThrow(BffHttpError); + }); + + it('throws invalid_timezone when the resolved value is not IANA', () => { + expect(() => resolveTimezone({ header: 'Mars/Phobos' })).toThrow( + expect.objectContaining({ type: 'invalid_timezone', status: 400 }), + ); + }); +});