Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
16b5b51
feat(agent-bff): add oauth deps and token encryption key config
Tonours Jun 26, 2026
d6e8907
feat(agent-bff): add token cipher and session store with encrypted at…
Tonours Jun 26, 2026
54b257a
feat(agent-bff): add forest server client, oauth errors and pkce helpers
Tonours Jun 26, 2026
809f589
feat(agent-bff): add mode 1 oauth authorize and token endpoints
Tonours Jun 26, 2026
0453e15
feat(agent-bff): wire oauth routes and lazy saas token refresh
Tonours Jun 26, 2026
f37980d
fix(agent-bff): cap pending authorization codes to bound memory dos
Tonours Jun 26, 2026
d31caaf
fix(agent-bff): harden forest server client url, client lookup and re…
Tonours Jun 26, 2026
219ba73
fix(agent-bff): keep booting when the forest server is unreachable at…
Tonours Jun 26, 2026
574bab9
refactor(agent-bff): reject empty authorize params and split token ha…
Tonours Jun 26, 2026
57f2cad
refactor(agent-bff): reduce oauth config and route dispatch complexity
Tonours Jun 26, 2026
bd81365
fix(agent-bff): address review feedback on oauth error mapping, timeo…
Tonours Jun 26, 2026
07550cb
test(agent-bff): align tests with review fixes and raise coverage
Tonours Jun 26, 2026
cbde6e5
fix(agent-bff): distinguish transient refresh failures, guard refresh…
Tonours Jun 26, 2026
a35a769
test(agent-bff): cover identity delegation, param pollution, 502 mapp…
Tonours Jun 26, 2026
4ca4af3
refactor(agent-bff): extract RouteHandler type alias for route dispatch
Tonours Jun 26, 2026
f819f6b
refactor(agent-bff): validate encryption key only when present
Tonours Jun 26, 2026
bd4f843
refactor(agent-bff): compute renderingId validity once in toServerTokens
Tonours Jun 26, 2026
68098c1
fix(agent-bff): enforce token expiry cap, zero pending-code cap and r…
Tonours Jun 26, 2026
a38bedb
fix(agent-bff): release auth code on post-exchange failure to keep to…
Tonours Jun 26, 2026
2449541
fix(agent-bff): reject auth-code claims at cap instead of evicting li…
Tonours Jun 26, 2026
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
4 changes: 4 additions & 0 deletions packages/agent-bff/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@
"test": "jest"
},
"dependencies": {
"@forestadmin/forestadmin-client": "1.40.3",
"@koa/bodyparser": "^6.1.0",
"jsonwebtoken": "^9.0.3",
"koa": "^3.0.1",
"zod": "4.3.6"
},
"devDependencies": {
"@types/jsonwebtoken": "^9.0.1",
"@types/koa": "^2.13.5",
"@types/supertest": "^6.0.2",
"supertest": "^7.1.3"
Expand Down
78 changes: 77 additions & 1 deletion packages/agent-bff/src/cli-core.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,93 @@
import type { BFFConfig } from './config/env-config';
import type { Logger } from './ports/logger-port';
import type { Middleware } from 'koa';

import { bodyParser } from '@koa/bodyparser';

import createConsoleLogger from './adapters/console-logger';
import { parseConfig } from './config/env-config';
import { extractErrorMessage } from './errors';
import BFFHttpServer from './http/bff-http-server';
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 version from './version';

const BODY_LIMIT = '16kb';
const SESSION_TTL_SECONDS = 24 * 60 * 60;

interface ResolvedOAuthConfig {
forestServerUrl: string;
forestEnvSecret: string;
forestAppUrl: string;
forestAuthSecret: string;
tokenEncryptionKey: string;
}

function resolveOAuthConfig(config: BFFConfig): ResolvedOAuthConfig | undefined {
Comment thread
Tonours marked this conversation as resolved.
const { forestServerUrl, forestEnvSecret, forestAppUrl, forestAuthSecret, tokenEncryptionKey } =
config;

if (
forestServerUrl &&
forestEnvSecret &&
forestAppUrl &&
forestAuthSecret &&
tokenEncryptionKey
) {
return { forestServerUrl, forestEnvSecret, forestAppUrl, forestAuthSecret, tokenEncryptionKey };
}

return undefined;
}

async function buildOAuthMiddlewares(config: BFFConfig, logger: Logger): Promise<Middleware[]> {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const oauthConfig = resolveOAuthConfig(config);

if (!oauthConfig) {
logger('Warn', 'OAuth routes disabled: required configuration is missing');

return [];
}

const { forestServerUrl, forestEnvSecret, forestAppUrl, forestAuthSecret, tokenEncryptionKey } =
oauthConfig;

const serverClient = new ForestServerClient({ forestServerUrl, envSecret: forestEnvSecret });
const environmentId = await serverClient.fetchEnvironmentId();

const sessionStore = createInMemorySessionStore({
cipher: createTokenCipher(tokenEncryptionKey),
now: () => Date.now(),
sessionTtlSeconds: SESSION_TTL_SECONDS,
});

const oauthRoutes = createOAuthRoutes({
serverClient,
sessionStore,
forestAppUrl,
authSecret: forestAuthSecret,
environmentId,
logger,
});

return [bodyParser({ jsonLimit: BODY_LIMIT }), oauthRoutes];
}

export default async function runCli(
env: NodeJS.ProcessEnv,
logger: Logger = createConsoleLogger(),
): Promise<BFFHttpServer> {
const config = parseConfig(env);
const server = new BFFHttpServer({ port: config.httpPort, version, config, logger });
const middlewares = await buildOAuthMiddlewares(config, logger);
const server = new BFFHttpServer({
port: config.httpPort,
version,
config,
logger,
middlewares,
});

await server.start();

Expand Down
24 changes: 23 additions & 1 deletion packages/agent-bff/src/config/env-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@ export interface BFFConfig {
forestServerUrl?: string;
forestAppUrl?: string;
agentUrl?: string;
tokenEncryptionKey?: string;
httpPort: number;
presence: PresenceMap;
hasAllRequired: boolean;
}

const DECIMAL_INTEGER = /^\d+$/;
const MAX_PORT = 65535;
const ENCRYPTION_KEY_BYTES = 32;
const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
const HTTP_URL_SCHEMA = z.url({ protocol: /^https?$/ });

function normalize(value: string | undefined): string | undefined {
Expand All @@ -55,6 +58,22 @@ function isHttpUrl(value: string): boolean {
return !/\s/.test(value) && HTTP_URL_SCHEMA.safeParse(value).success;
}

function isValidEncryptionKey(value: string): boolean {
return BASE64_PATTERN.test(value) && Buffer.from(value, 'base64').length === ENCRYPTION_KEY_BYTES;
}

function parseEncryptionKey(raw: string | undefined): string | undefined {
const value = normalize(raw);

if (value !== undefined && !isValidEncryptionKey(value)) {
throw new ConfigurationError(
`Invalid configuration: BFF_TOKEN_ENCRYPTION_KEY must be base64-encoded and exactly ${ENCRYPTION_KEY_BYTES} bytes (AES-256).`,
);
}

return value;
}

export function parseConfig(env: NodeJS.ProcessEnv): BFFConfig {
const normalized = Object.fromEntries(
REQUIRED_KEYS.map(key => [key, normalize(env[key])]),
Expand All @@ -72,14 +91,17 @@ export function parseConfig(env: NodeJS.ProcessEnv): BFFConfig {
REQUIRED_KEYS.map(key => [key, normalized[key] !== undefined]),
) as PresenceMap;

const tokenEncryptionKey = parseEncryptionKey(env.BFF_TOKEN_ENCRYPTION_KEY);

return {
forestAuthSecret: normalized.FOREST_AUTH_SECRET,
forestEnvSecret: normalized.FOREST_ENV_SECRET,
forestServerUrl: normalized.FOREST_SERVER_URL,
forestAppUrl: normalized.FOREST_APP_URL,
agentUrl: normalized.AGENT_URL,
tokenEncryptionKey,
httpPort: parsePort(env.HTTP_PORT),
presence,
hasAllRequired: REQUIRED_KEYS.every(key => presence[key]),
hasAllRequired: REQUIRED_KEYS.every(key => presence[key]) && tokenEncryptionKey !== undefined,
};
}
6 changes: 6 additions & 0 deletions packages/agent-bff/src/http/bff-http-server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { BFFConfig } from '../config/env-config';
import type { Logger } from '../ports/logger-port';
import type { Server } from 'http';
import type { Middleware } from 'koa';

import http from 'http';
import Koa from 'koa';
Expand All @@ -12,6 +13,7 @@ export interface BFFHttpServerOptions {
version: string;
config: BFFConfig;
logger?: Logger;
middlewares?: Middleware[];
}

export default class BFFHttpServer {
Expand Down Expand Up @@ -41,6 +43,10 @@ export default class BFFHttpServer {

await next();
});

for (const middleware of this.options.middlewares ?? []) {
this.app.use(middleware);
}
}

async start(): Promise<void> {
Expand Down
15 changes: 15 additions & 0 deletions packages/agent-bff/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,18 @@ export { default as DEFAULT_BFF_PORT } from './defaults';
export { default as createConsoleLogger } from './adapters/console-logger';
export type { Logger, LoggerLevel } from './ports/logger-port';
export { default as version } from './version';
export { default as ForestServerClient, OAuthExchangeError } from './oauth/forest-server-client';
export type {
ServerTokens,
RegisteredClient,
ExchangeCodeParams,
} from './oauth/forest-server-client';
export { default as createOAuthRoutes } from './oauth/oauth-routes';
export { default as createInMemorySessionStore } from './oauth/session-store';
export type { SessionStore, StoredSession, CreatedSession } from './oauth/session-store';
export { default as createTokenCipher } from './oauth/token-cipher';
export { default as ensureFreshServerAccess } from './oauth/session-lifecycle';
export { issueBffAccessToken, BFF_ACCESS_TOKEN_TYPE } from './oauth/bff-token';
export type { BffAccessTokenPayload } from './oauth/bff-token';
export { createPkcePair } from './oauth/pkce';
export { OAuthRequestError } from './oauth/oauth-error';
53 changes: 53 additions & 0 deletions packages/agent-bff/src/oauth/bff-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { UserInfo } from '@forestadmin/forestadmin-client';

import jsonwebtoken from 'jsonwebtoken';

export const BFF_ACCESS_TOKEN_TYPE = 'bff_access';
export const BFF_ACCESS_TOKEN_MAX_EXPIRES_IN = 15 * 60;

export interface IssueBffAccessTokenParams {
sid: string;
user: UserInfo;
renderingId: number;
authSecret: string;
expiresInSeconds: number;
}

export interface BffAccessTokenPayload {
type: typeof BFF_ACCESS_TOKEN_TYPE;
sid: string;
id: number;
email: string;
first_name: string;
last_name: string;
team: string;
rendering_id: string;
permission_level: string;
tags: Record<string, string>;
}

export function issueBffAccessToken({
sid,
user,
renderingId,
authSecret,
expiresInSeconds,
}: IssueBffAccessTokenParams): string {
const payload: BffAccessTokenPayload = {
type: BFF_ACCESS_TOKEN_TYPE,
sid,
id: user.id,
email: user.email,
first_name: user.firstName,
last_name: user.lastName,
team: user.team,
rendering_id: String(renderingId),
permission_level: user.permissionLevel,
tags: user.tags ?? {},
};

return jsonwebtoken.sign(payload, authSecret, {
algorithm: 'HS256',
expiresIn: Math.min(expiresInSeconds, BFF_ACCESS_TOKEN_MAX_EXPIRES_IN),
});
}
Loading
Loading