Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/agent-bff/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
58 changes: 53 additions & 5 deletions packages/agent-bff/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <bff_access>` | 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

Expand Down
19 changes: 19 additions & 0 deletions packages/agent-bff/src/agent/agent-stub.ts
Original file line number Diff line number Diff line change
@@ -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',
},
};
};
}
11 changes: 11 additions & 0 deletions packages/agent-bff/src/agent/build-agent-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface AgentQuery {
timezone: string;
}

export interface BuildAgentQueryParams {
timezone: string;
}

export function buildAgentQuery({ timezone }: BuildAgentQueryParams): AgentQuery {
return { timezone };
}
44 changes: 18 additions & 26 deletions packages/agent-bff/src/api-key/api-key-middleware.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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,
Expand All @@ -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;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}

ctx.state.agentToken = authenticated.agentToken;
Expand Down
69 changes: 69 additions & 0 deletions packages/agent-bff/src/auth/auth-mode-middleware.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

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();
};
}
30 changes: 30 additions & 0 deletions packages/agent-bff/src/auth/auth-mode.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Loading
Loading