diff --git a/packages/agent-bff/package.json b/packages/agent-bff/package.json index d1ee3713c6..50395ecf54 100644 --- a/packages/agent-bff/package.json +++ b/packages/agent-bff/package.json @@ -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" diff --git a/packages/agent-bff/src/cli-core.ts b/packages/agent-bff/src/cli-core.ts index ee7de730c2..59a8844f36 100644 --- a/packages/agent-bff/src/cli-core.ts +++ b/packages/agent-bff/src/cli-core.ts @@ -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 { + 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 { + 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 { 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(); diff --git a/packages/agent-bff/src/config/env-config.ts b/packages/agent-bff/src/config/env-config.ts index b1632f98f1..5c90810eda 100644 --- a/packages/agent-bff/src/config/env-config.ts +++ b/packages/agent-bff/src/config/env-config.ts @@ -23,6 +23,7 @@ export interface BFFConfig { forestServerUrl?: string; forestAppUrl?: string; agentUrl?: string; + tokenEncryptionKey?: string; httpPort: number; presence: PresenceMap; hasAllRequired: boolean; @@ -30,6 +31,8 @@ export interface BFFConfig { 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 { @@ -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])]), @@ -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, }; } diff --git a/packages/agent-bff/src/http/bff-http-server.ts b/packages/agent-bff/src/http/bff-http-server.ts index 748602175c..7f6a086121 100644 --- a/packages/agent-bff/src/http/bff-http-server.ts +++ b/packages/agent-bff/src/http/bff-http-server.ts @@ -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'; @@ -12,6 +13,7 @@ export interface BFFHttpServerOptions { version: string; config: BFFConfig; logger?: Logger; + middlewares?: Middleware[]; } export default class BFFHttpServer { @@ -41,6 +43,10 @@ export default class BFFHttpServer { await next(); }); + + for (const middleware of this.options.middlewares ?? []) { + this.app.use(middleware); + } } async start(): Promise { diff --git a/packages/agent-bff/src/index.ts b/packages/agent-bff/src/index.ts index 3f6f8a1dba..1d4dd7d355 100644 --- a/packages/agent-bff/src/index.ts +++ b/packages/agent-bff/src/index.ts @@ -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'; diff --git a/packages/agent-bff/src/oauth/bff-token.ts b/packages/agent-bff/src/oauth/bff-token.ts new file mode 100644 index 0000000000..6812783973 --- /dev/null +++ b/packages/agent-bff/src/oauth/bff-token.ts @@ -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; +} + +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), + }); +} diff --git a/packages/agent-bff/src/oauth/forest-server-client.ts b/packages/agent-bff/src/oauth/forest-server-client.ts new file mode 100644 index 0000000000..a56f456162 --- /dev/null +++ b/packages/agent-bff/src/oauth/forest-server-client.ts @@ -0,0 +1,186 @@ +import type { UserInfo } from '@forestadmin/forestadmin-client'; + +import createForestAdminClient from '@forestadmin/forestadmin-client'; +import jsonwebtoken from 'jsonwebtoken'; + +import OAuthExchangeError from './oauth-exchange-error'; + +export { OAuthExchangeError }; + +export interface RegisteredClient { + client_id: string; + client_name?: string; + redirect_uris?: string[]; + grant_types?: string[]; +} + +export interface ServerTokens { + saasAccessToken: string; + saasRefreshToken: string; + renderingId: number; + expiresAt: number; +} + +export interface ExchangeCodeParams { + code: string; + codeVerifier: string; + redirectUri: string; + clientId: string; +} + +export interface ForestServerClientOptions { + forestServerUrl: string; + envSecret: string; +} + +const DEFAULT_HEADERS = { 'Content-Type': 'application/json' } as const; +const REQUEST_TIMEOUT_MS = 60_000; + +function fetchWithTimeout(url: string, init: RequestInit): Promise { + return fetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); +} + +export default class ForestServerClient { + private readonly forestServerUrl: string; + private readonly envSecret: string; + private readonly forestClient: ReturnType; + + constructor({ forestServerUrl, envSecret }: ForestServerClientOptions) { + this.forestServerUrl = forestServerUrl; + this.envSecret = envSecret; + this.forestClient = createForestAdminClient({ forestServerUrl, envSecret }); + } + + private url(path: string): string { + return new URL(path, this.forestServerUrl).toString(); + } + + async fetchEnvironmentId(): Promise { + const response = await fetchWithTimeout(this.url('/liana/environment'), { + method: 'GET', + headers: { ...DEFAULT_HEADERS, 'forest-secret-key': this.envSecret }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch environment: ${response.status} ${response.statusText}`); + } + + const body = (await response.json()) as { data?: { id?: string } }; + const environmentId = Number(body.data?.id); + + if (!Number.isInteger(environmentId)) { + throw new Error('Failed to parse environment id from the Forest server response'); + } + + return environmentId; + } + + async getRegisteredClient(clientId: string): Promise { + const response = await fetchWithTimeout( + this.url(`/oauth/register/${encodeURIComponent(clientId)}`), + { + method: 'GET', + headers: DEFAULT_HEADERS, + }, + ); + + if (response.status === 404) return undefined; + + if (!response.ok) { + throw new Error( + `Failed to fetch registered client: ${response.status} ${response.statusText}`, + ); + } + + return response.json() as Promise; + } + + async exchangeCode({ + code, + codeVerifier, + redirectUri, + clientId, + }: ExchangeCodeParams): Promise { + return this.postToken( + { + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: codeVerifier, + }, + true, + ); + } + + async refreshServerToken(saasRefreshToken: string): Promise { + return this.postToken({ grant_type: 'refresh_token', refresh_token: saasRefreshToken }, false); + } + + private async postToken( + payload: Record, + isInitialExchange: boolean, + ): Promise { + const response = await fetchWithTimeout(this.url('/oauth/token'), { + method: 'POST', + headers: { ...DEFAULT_HEADERS, 'forest-secret-key': this.envSecret }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorBody = (await response.json().catch(() => ({}))) as { + error?: string; + error_description?: string; + }; + throw new OAuthExchangeError( + errorBody.error || 'server_error', + errorBody.error_description || 'Failed to exchange token with the Forest server', + ); + } + + const tokens = (await response.json()) as { access_token: string; refresh_token?: string }; + const saasRefreshToken = tokens.refresh_token ?? payload.refresh_token; + + if (saasRefreshToken === undefined) { + throw new OAuthExchangeError( + 'invalid_grant', + 'The Forest server did not return a refresh token', + ); + } + + return ForestServerClient.toServerTokens( + tokens.access_token, + saasRefreshToken, + isInitialExchange, + ); + } + + private static toServerTokens( + saasAccessToken: string, + saasRefreshToken: string, + requireRenderingId: boolean, + ): ServerTokens { + const decoded = jsonwebtoken.decode(saasAccessToken) as { + meta?: { renderingId?: number }; + exp?: number; + } | null; + + const decodedRenderingId = Number(decoded?.meta?.renderingId); + const hasRenderingId = Number.isInteger(decodedRenderingId); + + if (requireRenderingId && !hasRenderingId) { + throw new Error('Failed to decode renderingId from the Forest server access token'); + } + + return { + saasAccessToken, + saasRefreshToken, + renderingId: hasRenderingId ? decodedRenderingId : 0, + expiresAt: decoded?.exp ?? 0, + }; + } + + async getUserInfo(renderingId: number, saasAccessToken: string): Promise { + return this.forestClient.authService.getUserInfo(renderingId, saasAccessToken); + } +} diff --git a/packages/agent-bff/src/oauth/oauth-error.ts b/packages/agent-bff/src/oauth/oauth-error.ts new file mode 100644 index 0000000000..e77333a818 --- /dev/null +++ b/packages/agent-bff/src/oauth/oauth-error.ts @@ -0,0 +1,47 @@ +export class OAuthRequestError extends Error { + readonly status: number; + readonly type: string; + + constructor(status: number, type: string, message: string) { + super(message); + this.name = 'OAuthRequestError'; + this.status = status; + this.type = type; + } +} + +export interface OAuthErrorBody { + error: { type: string; status: number; message: string }; +} + +export function toErrorBody(error: OAuthRequestError): OAuthErrorBody { + return { error: { type: error.type, status: error.status, message: error.message } }; +} + +export function invalidRequest(message: string): OAuthRequestError { + return new OAuthRequestError(400, 'invalid_request', message); +} + +export function invalidClient(message: string): OAuthRequestError { + return new OAuthRequestError(400, 'invalid_client', message); +} + +export function invalidGrant(message: string): OAuthRequestError { + return new OAuthRequestError(400, 'invalid_grant', message); +} + +export function unsupportedGrantType(message: string): OAuthRequestError { + return new OAuthRequestError(400, 'unsupported_grant_type', message); +} + +export function forestIdentityNotAllowed(message: string): OAuthRequestError { + return new OAuthRequestError(403, 'forest_identity_not_allowed', message); +} + +export function sessionExpired(message: string): OAuthRequestError { + return new OAuthRequestError(401, 'session_expired', message); +} + +export function serverError(message: string): OAuthRequestError { + return new OAuthRequestError(502, 'server_error', message); +} diff --git a/packages/agent-bff/src/oauth/oauth-exchange-error.ts b/packages/agent-bff/src/oauth/oauth-exchange-error.ts new file mode 100644 index 0000000000..42e98c5ee8 --- /dev/null +++ b/packages/agent-bff/src/oauth/oauth-exchange-error.ts @@ -0,0 +1,9 @@ +export default class OAuthExchangeError extends Error { + readonly error: string; + + constructor(error: string, description: string) { + super(description); + this.name = 'OAuthExchangeError'; + this.error = error; + } +} diff --git a/packages/agent-bff/src/oauth/oauth-routes.ts b/packages/agent-bff/src/oauth/oauth-routes.ts new file mode 100644 index 0000000000..bb9b125455 --- /dev/null +++ b/packages/agent-bff/src/oauth/oauth-routes.ts @@ -0,0 +1,328 @@ +import type ForestServerClient from './forest-server-client'; +import type { ServerTokens } from './forest-server-client'; +import type { SessionStore } from './session-store'; +import type { Logger } from '../ports/logger-port'; +import type { UserInfo } from '@forestadmin/forestadmin-client'; +import type { Context, Middleware } from 'koa'; + +import { BFF_ACCESS_TOKEN_MAX_EXPIRES_IN, issueBffAccessToken } from './bff-token'; +import { OAuthExchangeError } from './forest-server-client'; +import { + OAuthRequestError, + forestIdentityNotAllowed, + invalidClient, + invalidGrant, + invalidRequest, + toErrorBody, + unsupportedGrantType, +} from './oauth-error'; + +export interface OAuthRoutesOptions { + serverClient: ForestServerClient; + sessionStore: SessionStore; + forestAppUrl: string; + authSecret: string; + environmentId: number; + logger: Logger; +} + +const AUTHORIZE_REQUIRED_PARAMS = [ + 'client_id', + 'redirect_uri', + 'response_type', + 'code_challenge', + 'code_challenge_method', + 'state', +] as const; + +const CODE_VERIFIER_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/; + +const SAFE_EXCHANGE_ERRORS = new Set([ + 'invalid_request', + 'invalid_client', + 'invalid_grant', + 'unauthorized_client', + 'unsupported_grant_type', + 'invalid_scope', +]); + +function getQueryParam(ctx: Context, key: string): string | undefined { + const value = ctx.query[key]; + + if (Array.isArray(value)) { + throw invalidRequest(`Parameter must appear at most once: ${key}`); + } + + return value; +} + +function requireBodyString(body: Record, key: string): string { + const value = body[key]; + + if (typeof value !== 'string' || value === '') { + throw invalidRequest(`Missing required parameter: ${key}`); + } + + return value; +} + +function assertValidCodeVerifier(codeVerifier: string): void { + if (!CODE_VERIFIER_PATTERN.test(codeVerifier)) { + throw invalidRequest('code_verifier is malformed'); + } +} + +function assertRegisteredRedirectUri( + redirectUris: string[] | undefined, + redirectUri: string, + onMismatch: (message: string) => OAuthRequestError, +): void { + if (!Array.isArray(redirectUris) || redirectUris.length === 0) { + throw invalidRequest('Client has no registered redirect_uri'); + } + + if (!redirectUris.includes(redirectUri)) { + throw onMismatch('redirect_uri does not match the registered client'); + } +} + +function toSafeExchangeError(saasError: string): OAuthRequestError { + if (SAFE_EXCHANGE_ERRORS.has(saasError)) { + return new OAuthRequestError(400, saasError, 'Authorization code exchange failed'); + } + + return new OAuthRequestError( + 502, + 'server_error', + 'Forest server rejected the authorization code', + ); +} + +function mapIdentityError(error: unknown): OAuthRequestError { + const name = error instanceof Error ? error.name : ''; + + if (name === 'ForbiddenError' || name === 'NotFoundError') { + return forestIdentityNotAllowed('Caller is not an active user for this rendering'); + } + + return new OAuthRequestError(502, 'identity_resolution_failed', 'Failed to resolve identity'); +} + +async function handleAuthorize(ctx: Context, options: OAuthRoutesOptions): Promise { + for (const param of AUTHORIZE_REQUIRED_PARAMS) { + const value = getQueryParam(ctx, param); + + if (value === undefined || value === '') { + throw invalidRequest(`Missing required parameter: ${param}`); + } + } + + const clientId = getQueryParam(ctx, 'client_id') as string; + const redirectUri = getQueryParam(ctx, 'redirect_uri') as string; + const responseType = getQueryParam(ctx, 'response_type') as string; + const codeChallenge = getQueryParam(ctx, 'code_challenge') as string; + const state = getQueryParam(ctx, 'state') as string; + + if (responseType !== 'code') { + throw invalidRequest('response_type must be "code"'); + } + + const codeChallengeMethod = getQueryParam(ctx, 'code_challenge_method'); + + if (codeChallengeMethod !== 'S256') { + throw invalidRequest('code_challenge_method must be S256'); + } + + const client = await options.serverClient.getRegisteredClient(clientId); + + if (!client) { + throw invalidClient('Unknown client_id'); + } + + assertRegisteredRedirectUri(client.redirect_uris, redirectUri, invalidRequest); + + const authorizeUrl = new URL('/oauth/authorize', options.forestAppUrl); + authorizeUrl.searchParams.set('client_id', clientId); + authorizeUrl.searchParams.set('redirect_uri', redirectUri); + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('code_challenge', codeChallenge); + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); + authorizeUrl.searchParams.set('state', state); + authorizeUrl.searchParams.set('environmentId', String(options.environmentId)); + + ctx.redirect(authorizeUrl.toString()); +} + +function computeExpiresIn(serverTokens: ServerTokens): number { + const saasRemaining = serverTokens.expiresAt - Math.floor(Date.now() / 1000); + + if (saasRemaining <= 0) { + throw invalidGrant('The Forest server access token is already expired'); + } + + return Math.min(BFF_ACCESS_TOKEN_MAX_EXPIRES_IN, saasRemaining); +} + +interface TokenRequest { + code: string; + clientId: string; + codeVerifier: string; + redirectUri: string; +} + +async function parseTokenRequest(ctx: Context, options: OAuthRoutesOptions): Promise { + const { body } = ctx.request as { body?: unknown }; + + if (typeof body !== 'object' || body === null) { + throw invalidRequest('Missing request body'); + } + + const params = body as Record; + + if (params.grant_type !== 'authorization_code') { + throw unsupportedGrantType('Only authorization_code is supported'); + } + + const request: TokenRequest = { + code: requireBodyString(params, 'code'), + clientId: requireBodyString(params, 'client_id'), + codeVerifier: requireBodyString(params, 'code_verifier'), + redirectUri: requireBodyString(params, 'redirect_uri'), + }; + + assertValidCodeVerifier(request.codeVerifier); + + const client = await options.serverClient.getRegisteredClient(request.clientId); + + if (!client) { + throw invalidClient('Unknown client_id'); + } + + assertRegisteredRedirectUri(client.redirect_uris, request.redirectUri, invalidGrant); + + return request; +} + +async function exchangeForServerTokens( + request: TokenRequest, + options: OAuthRoutesOptions, +): Promise { + try { + return await options.serverClient.exchangeCode(request); + } catch (error) { + if (error instanceof OAuthExchangeError) { + options.logger('Warn', 'Forest server code exchange rejected', { saasError: error.error }); + throw toSafeExchangeError(error.error); + } + + throw error; + } +} + +async function resolveIdentity( + serverTokens: ServerTokens, + options: OAuthRoutesOptions, +): Promise { + try { + return await options.serverClient.getUserInfo( + serverTokens.renderingId, + serverTokens.saasAccessToken, + ); + } catch (error) { + options.logger('Warn', 'Failed to resolve user info after exchange', { + renderingId: serverTokens.renderingId, + }); + throw mapIdentityError(error); + } +} + +async function handleToken(ctx: Context, options: OAuthRoutesOptions): Promise { + const request = await parseTokenRequest(ctx, options); + + if (!options.sessionStore.claimAuthorizationCode(request.code)) { + throw invalidGrant('Authorization code has already been used'); + } + + let serverTokens: ServerTokens; + let expiresInSeconds: number; + let user: UserInfo; + + try { + serverTokens = await exchangeForServerTokens(request, options); + expiresInSeconds = computeExpiresIn(serverTokens); + user = await resolveIdentity(serverTokens, options); + } catch (error) { + // why: a post-claim failure (transient exchange/identity/expiry error) is + // not a replay; release the local claim so the client can retry the code. + options.sessionStore.releaseAuthorizationCode(request.code); + throw error; + } + + const { sid, refreshToken } = options.sessionStore.create({ + saasAccessToken: serverTokens.saasAccessToken, + saasRefreshToken: serverTokens.saasRefreshToken, + renderingId: serverTokens.renderingId, + userId: user.id, + }); + + const accessToken = issueBffAccessToken({ + sid, + user, + renderingId: serverTokens.renderingId, + authSecret: options.authSecret, + expiresInSeconds, + }); + + options.logger('Info', 'Issued BFF session token', { + renderingId: serverTokens.renderingId, + userId: user.id, + }); + + ctx.status = 200; + ctx.body = { + access_token: accessToken, + token_type: 'Bearer', + expires_in: expiresInSeconds, + refresh_token: refreshToken, + }; +} + +type RouteHandler = (ctx: Context, options: OAuthRoutesOptions) => Promise; + +function matchRoute(ctx: Context): RouteHandler | undefined { + if (ctx.method === 'GET' && ctx.path === '/oauth/authorize') return handleAuthorize; + if (ctx.method === 'POST' && ctx.path === '/oauth/token') return handleToken; + + return undefined; +} + +function writeError(ctx: Context, error: unknown, options: OAuthRoutesOptions): void { + if (error instanceof OAuthRequestError) { + ctx.status = error.status; + ctx.body = toErrorBody(error); + + return; + } + + options.logger('Error', 'OAuth route failure', { path: ctx.path }); + ctx.status = 500; + ctx.body = { error: { type: 'server_error', status: 500, message: 'OAuth processing failed' } }; +} + +export default function createOAuthRoutes(options: OAuthRoutesOptions): Middleware { + return async function oauthRoutes(ctx, next) { + const handler = matchRoute(ctx); + + if (!handler) { + await next(); + + return; + } + + try { + await handler(ctx, options); + } catch (error) { + writeError(ctx, error, options); + } + }; +} diff --git a/packages/agent-bff/src/oauth/pkce.ts b/packages/agent-bff/src/oauth/pkce.ts new file mode 100644 index 0000000000..117c1d1fbc --- /dev/null +++ b/packages/agent-bff/src/oauth/pkce.ts @@ -0,0 +1,24 @@ +import crypto from 'crypto'; + +export interface PkcePair { + codeVerifier: string; + codeChallenge: string; +} + +function base64url(buffer: Buffer): string { + return buffer.toString('base64url'); +} + +export function createVerifier(): string { + return base64url(crypto.randomBytes(64)); +} + +export function challengeFromVerifier(verifier: string): string { + return base64url(crypto.createHash('sha256').update(verifier).digest()); +} + +export function createPkcePair(): PkcePair { + const codeVerifier = createVerifier(); + + return { codeVerifier, codeChallenge: challengeFromVerifier(codeVerifier) }; +} diff --git a/packages/agent-bff/src/oauth/session-lifecycle.ts b/packages/agent-bff/src/oauth/session-lifecycle.ts new file mode 100644 index 0000000000..cd4ed060ff --- /dev/null +++ b/packages/agent-bff/src/oauth/session-lifecycle.ts @@ -0,0 +1,84 @@ +import type ForestServerClient from './forest-server-client'; +import type { SessionStore } from './session-store'; + +import jsonwebtoken from 'jsonwebtoken'; + +import { OAuthExchangeError } from './forest-server-client'; +import { serverError, sessionExpired } from './oauth-error'; + +export interface EnsureFreshServerAccessParams { + sid: string; + store: SessionStore; + serverClient: ForestServerClient; +} + +const CLIENT_ERROR_CODES = new Set(['invalid_grant', 'invalid_request', 'invalid_client']); + +const inFlightRefreshesBySid = new Map>(); + +function accessTokenExpiry(saasAccessToken: string): number { + const decoded = jsonwebtoken.decode(saasAccessToken) as { exp?: number } | null; + + return decoded?.exp ?? 0; +} + +async function refreshAndPersist( + sid: string, + store: SessionStore, + serverClient: ForestServerClient, +): Promise { + const currentRefresh = store.getSaasRefreshToken(sid); + + if (currentRefresh === undefined) { + throw sessionExpired('Session not found or expired'); + } + + let rotated: Awaited>; + + try { + rotated = await serverClient.refreshServerToken(currentRefresh); + } catch (error) { + if (error instanceof OAuthExchangeError && CLIENT_ERROR_CODES.has(error.error)) { + throw sessionExpired('The Forest server rejected the refresh token'); + } + + throw serverError('Failed to reach the Forest server to refresh the session'); + } + + if (!store.get(sid)) { + throw sessionExpired('Session expired during token refresh'); + } + + store.updateSaasTokens(sid, { + saasAccessToken: rotated.saasAccessToken, + saasRefreshToken: rotated.saasRefreshToken, + }); + + return rotated.saasAccessToken; +} + +export default async function ensureFreshServerAccess({ + sid, + store, + serverClient, +}: EnsureFreshServerAccessParams): Promise { + const session = store.get(sid); + + if (!session) { + throw sessionExpired('Session not found or expired'); + } + + if (accessTokenExpiry(session.saasAccessToken) > Math.floor(Date.now() / 1000)) { + return session.saasAccessToken; + } + + const existing = inFlightRefreshesBySid.get(sid); + if (existing) return existing; + + const refresh = refreshAndPersist(sid, store, serverClient).finally(() => { + inFlightRefreshesBySid.delete(sid); + }); + inFlightRefreshesBySid.set(sid, refresh); + + return refresh; +} diff --git a/packages/agent-bff/src/oauth/session-store.ts b/packages/agent-bff/src/oauth/session-store.ts new file mode 100644 index 0000000000..7d92925e5f --- /dev/null +++ b/packages/agent-bff/src/oauth/session-store.ts @@ -0,0 +1,165 @@ +import type { EncryptedBlob, TokenCipher } from './token-cipher'; + +import crypto from 'crypto'; + +export interface CreateSessionInput { + saasAccessToken: string; + saasRefreshToken: string; + renderingId: number; + userId: number; +} + +export interface StoredSession { + saasAccessToken: string; + saasRefreshTokenBlob: EncryptedBlob; + refreshTokenHash: string; + renderingId: number; + userId: number; + expiresAt: number; +} + +export interface CreatedSession { + sid: string; + refreshToken: string; +} + +export interface UpdateSaasTokensInput { + saasAccessToken: string; + saasRefreshToken: string; +} + +export interface SessionStore { + create(input: CreateSessionInput): CreatedSession; + get(sid: string): StoredSession | undefined; + getSaasRefreshToken(sid: string): string | undefined; + updateSaasTokens(sid: string, tokens: UpdateSaasTokensInput): void; + claimAuthorizationCode(code: string): boolean; + releaseAuthorizationCode(code: string): void; + pendingClaimCount(): number; +} + +export interface SessionStoreOptions { + cipher: TokenCipher; + now: () => number; + sessionTtlSeconds: number; + authCodeTtlSeconds?: number; + maxPendingCodes?: number; +} + +const DEFAULT_AUTH_CODE_TTL_SECONDS = 5 * 60; +const DEFAULT_MAX_PENDING_CODES = 10_000; + +export function createSid(): string { + return crypto.randomBytes(32).toString('base64url'); +} + +function createOpaqueToken(): string { + return crypto.randomBytes(32).toString('base64url'); +} + +function hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('base64url'); +} + +export default function createInMemorySessionStore({ + cipher, + now, + sessionTtlSeconds, + authCodeTtlSeconds = DEFAULT_AUTH_CODE_TTL_SECONDS, + maxPendingCodes = DEFAULT_MAX_PENDING_CODES, +}: SessionStoreOptions): SessionStore { + const sessions = new Map(); + const usedCodes = new Map(); + + function purgeExpiredCodes(): void { + const current = now(); + + for (const [code, expiresAt] of usedCodes) { + if (current >= expiresAt) usedCodes.delete(code); + } + } + + function purgeExpiredSessions(): void { + const current = now(); + + for (const [sid, session] of sessions) { + if (current >= session.expiresAt) sessions.delete(sid); + } + } + + function liveSession(sid: string): StoredSession | undefined { + const session = sessions.get(sid); + if (!session) return undefined; + + if (now() >= session.expiresAt) { + sessions.delete(sid); + + return undefined; + } + + return session; + } + + return { + create(input) { + purgeExpiredSessions(); + const sid = createSid(); + const refreshToken = createOpaqueToken(); + + sessions.set(sid, { + saasAccessToken: input.saasAccessToken, + saasRefreshTokenBlob: cipher.encrypt(input.saasRefreshToken), + refreshTokenHash: hashToken(refreshToken), + renderingId: input.renderingId, + userId: input.userId, + expiresAt: now() + sessionTtlSeconds * 1000, + }); + + return { sid, refreshToken }; + }, + + get(sid) { + const session = liveSession(sid); + + return session ? { ...session } : undefined; + }, + + getSaasRefreshToken(sid) { + const session = liveSession(sid); + + return session ? cipher.decrypt(session.saasRefreshTokenBlob) : undefined; + }, + + updateSaasTokens(sid, tokens) { + const session = liveSession(sid); + if (!session) return; + + session.saasAccessToken = tokens.saasAccessToken; + session.saasRefreshTokenBlob = cipher.encrypt(tokens.saasRefreshToken); + }, + + claimAuthorizationCode(code) { + purgeExpiredCodes(); + if (usedCodes.has(code)) return false; + + // why: never evict a live entry to make room — an evicted code whose + // exchange is still in flight would pass this guard again, breaking + // single-use replay protection. Reject under saturation instead. + if (usedCodes.size >= maxPendingCodes) return false; + + usedCodes.set(code, now() + authCodeTtlSeconds * 1000); + + return true; + }, + + releaseAuthorizationCode(code) { + usedCodes.delete(code); + }, + + pendingClaimCount() { + purgeExpiredCodes(); + + return usedCodes.size; + }, + }; +} diff --git a/packages/agent-bff/src/oauth/token-cipher.ts b/packages/agent-bff/src/oauth/token-cipher.ts new file mode 100644 index 0000000000..63101a21a7 --- /dev/null +++ b/packages/agent-bff/src/oauth/token-cipher.ts @@ -0,0 +1,55 @@ +import crypto from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_BYTES = 12; + +export interface EncryptedBlob { + iv: string; + authTag: string; + ciphertext: string; +} + +export interface TokenCipher { + encrypt(plaintext: string): EncryptedBlob; + decrypt(blob: EncryptedBlob): string; +} + +function bytes(value: string | Uint8Array): Uint8Array { + return new Uint8Array(typeof value === 'string' ? Buffer.from(value, 'base64') : value); +} + +function toBase64(value: Uint8Array): string { + return Buffer.from(value).toString('base64'); +} + +export default function createTokenCipher(base64Key: string): TokenCipher { + const key = bytes(base64Key); + + return { + encrypt(plaintext) { + const iv = new Uint8Array(crypto.randomBytes(IV_BYTES)); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + const ciphertext = new Uint8Array( + Buffer.concat([ + new Uint8Array(cipher.update(plaintext, 'utf8')), + new Uint8Array(cipher.final()), + ]), + ); + + return { + iv: toBase64(iv), + authTag: toBase64(new Uint8Array(cipher.getAuthTag())), + ciphertext: toBase64(ciphertext), + }; + }, + decrypt({ iv, authTag, ciphertext }) { + const decipher = crypto.createDecipheriv(ALGORITHM, key, bytes(iv)); + decipher.setAuthTag(bytes(authTag)); + + return Buffer.concat([ + new Uint8Array(decipher.update(bytes(ciphertext))), + new Uint8Array(decipher.final()), + ]).toString('utf8'); + }, + }; +} diff --git a/packages/agent-bff/test/cli-core.test.ts b/packages/agent-bff/test/cli-core.test.ts index 1fbaa2fdd3..d5d17a507a 100644 --- a/packages/agent-bff/test/cli-core.test.ts +++ b/packages/agent-bff/test/cli-core.test.ts @@ -27,6 +27,71 @@ describe('runCli', () => { }); }); + describe('when BFF_TOKEN_ENCRYPTION_KEY is absent', () => { + it('should disable OAuth and still boot (key gates OAuth, not boot)', async () => { + const logs: string[] = []; + + const logger: Logger = (_level, message) => { + logs.push(message); + }; + + const server = await runCli({ ...VALID_ENV }, logger); + + try { + expect(server).toBeDefined(); + expect(logs).toContain('OAuth routes disabled: required configuration is missing'); + } finally { + await server.stop(); + } + }); + }); + + describe('when the full OAuth configuration is present', () => { + const FULL_ENV = { + ...VALID_ENV, + BFF_TOKEN_ENCRYPTION_KEY: Buffer.alloc(32).toString('base64'), + } satisfies NodeJS.ProcessEnv; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should wire OAuth routes (no disabled warning) and boot', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'ok', + json: async () => ({ data: { id: '42' } }), + }) as unknown as typeof fetch; + + const logs: string[] = []; + + const logger: Logger = (_level, message) => { + logs.push(message); + }; + + const server = await runCli({ ...FULL_ENV }, logger); + + try { + expect(server).toBeDefined(); + expect(logs).not.toContain('OAuth routes disabled: required configuration is missing'); + expect(global.fetch).toHaveBeenCalledTimes(1); + } finally { + await server.stop(); + } + }); + + it('should propagate a fetchEnvironmentId failure out of runCli', async () => { + global.fetch = jest + .fn() + .mockRejectedValue(new Error('forest server unreachable')) as unknown as typeof fetch; + + await expect(runCli({ ...FULL_ENV }, noopLogger)).rejects.toThrow( + 'forest server unreachable', + ); + }); + }); + 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 481e227337..97a12fb859 100644 --- a/packages/agent-bff/test/config/env-config.test.ts +++ b/packages/agent-bff/test/config/env-config.test.ts @@ -8,6 +8,7 @@ const VALID_ENV = { FOREST_SERVER_URL: 'https://api.forestadmin.com', FOREST_APP_URL: 'https://app.forestadmin.com', AGENT_URL: 'https://agent.example.com', + BFF_TOKEN_ENCRYPTION_KEY: Buffer.alloc(32).toString('base64'), } satisfies NodeJS.ProcessEnv; describe('parseConfig', () => { @@ -109,4 +110,56 @@ describe('parseConfig', () => { expect(config.forestAuthSecret).toBe(' abc '); }); }); + + describe('when resolving BFF_TOKEN_ENCRYPTION_KEY', () => { + const validKey = Buffer.alloc(32).toString('base64'); + + it('should not be part of REQUIRED_KEYS (it gates OAuth, not boot)', () => { + expect(REQUIRED_KEYS).not.toContain('BFF_TOKEN_ENCRYPTION_KEY'); + }); + + it('should leave the key undefined and mark hasAllRequired false when absent', () => { + const { BFF_TOKEN_ENCRYPTION_KEY, ...envWithoutKey } = VALID_ENV; + const config = parseConfig(envWithoutKey); + + expect(config.tokenEncryptionKey).toBeUndefined(); + expect(config.hasAllRequired).toBe(false); + }); + + it('should expose the key when a valid base64 32-byte value is provided', () => { + const config = parseConfig({ ...VALID_ENV, BFF_TOKEN_ENCRYPTION_KEY: validKey }); + + expect(config.tokenEncryptionKey).toBe(validKey); + }); + + it('should throw ConfigurationError when the key is too short (< 32 bytes)', () => { + const shortKey = Buffer.alloc(16).toString('base64'); + + expect(() => parseConfig({ ...VALID_ENV, BFF_TOKEN_ENCRYPTION_KEY: shortKey })).toThrow( + ConfigurationError, + ); + }); + + it('should throw ConfigurationError when the key is longer than 32 bytes (AES-256 needs exactly 32)', () => { + const longKey = Buffer.alloc(48).toString('base64'); + + expect(() => parseConfig({ ...VALID_ENV, BFF_TOKEN_ENCRYPTION_KEY: longKey })).toThrow( + ConfigurationError, + ); + }); + + it('should throw ConfigurationError when the key is not valid base64', () => { + expect(() => + parseConfig({ ...VALID_ENV, BFF_TOKEN_ENCRYPTION_KEY: 'not-base64-!!!' }), + ).toThrow(ConfigurationError); + }); + + it('should not echo the key value in the error message', () => { + const badKey = 'too-short-secret-key-value'; + + expect(() => parseConfig({ ...VALID_ENV, BFF_TOKEN_ENCRYPTION_KEY: badKey })).not.toThrow( + new RegExp(badKey), + ); + }); + }); }); diff --git a/packages/agent-bff/test/http/bff-http-server.test.ts b/packages/agent-bff/test/http/bff-http-server.test.ts index ad90d46ce1..ef4073d4aa 100644 --- a/packages/agent-bff/test/http/bff-http-server.test.ts +++ b/packages/agent-bff/test/http/bff-http-server.test.ts @@ -14,6 +14,7 @@ const VALID_ENV = { FOREST_SERVER_URL: 'https://api.forestadmin.com', FOREST_APP_URL: 'https://app.forestadmin.com', AGENT_URL: 'https://agent.example.com', + BFF_TOKEN_ENCRYPTION_KEY: Buffer.alloc(32).toString('base64'), } satisfies NodeJS.ProcessEnv; const noopLogger = () => undefined; @@ -134,6 +135,29 @@ describe('BFFHttpServer', () => { }); }); + describe('when constructed with extra middlewares', () => { + it('should mount them so they handle non-health routes', async () => { + const config = parseConfig({ ...VALID_ENV }); + const server = new BFFHttpServer({ + port: 0, + version: VERSION, + config, + logger: noopLogger, + middlewares: [ + async ctx => { + ctx.status = 418; + ctx.body = { handled: true }; + }, + ], + }); + + const response = await request(server.callback).get('/custom'); + + expect(response.status).toBe(418); + expect(response.body).toEqual({ handled: true }); + }); + }); + describe('when any route is requested', () => { it('should set X-Forest-Bff-Version on a non-health (404) route', async () => { const server = createServer({ ...VALID_ENV }); diff --git a/packages/agent-bff/test/index.test.ts b/packages/agent-bff/test/index.test.ts index 0a21b89cd2..6aa3829c27 100644 --- a/packages/agent-bff/test/index.test.ts +++ b/packages/agent-bff/test/index.test.ts @@ -1,16 +1,26 @@ -import * as pkg from '../src/index'; +import * as bff from '../src/index'; describe('package index', () => { - describe('when importing the public entry point', () => { - it('should re-export the runtime values', () => { - expect(pkg.BFFHttpServer).toBeDefined(); - expect(pkg.parseConfig).toEqual(expect.any(Function)); - expect(pkg.REQUIRED_KEYS).toEqual(expect.any(Array)); - expect(pkg.runCli).toEqual(expect.any(Function)); - expect(pkg.ConfigurationError).toBeDefined(); - expect(pkg.DEFAULT_BFF_PORT).toEqual(expect.any(Number)); - expect(pkg.createConsoleLogger).toEqual(expect.any(Function)); - expect(pkg.version).toEqual(expect.any(String)); + describe('when importing the public surface', () => { + it('should export every advertised value symbol', () => { + expect(bff.BFFHttpServer).toBeDefined(); + expect(bff.parseConfig).toBeDefined(); + expect(bff.REQUIRED_KEYS).toBeDefined(); + expect(bff.runCli).toBeDefined(); + expect(bff.ConfigurationError).toBeDefined(); + expect(bff.DEFAULT_BFF_PORT).toBeDefined(); + expect(bff.createConsoleLogger).toBeDefined(); + expect(bff.version).toBeDefined(); + expect(bff.ForestServerClient).toBeDefined(); + expect(bff.OAuthExchangeError).toBeDefined(); + expect(bff.createOAuthRoutes).toBeDefined(); + expect(bff.createInMemorySessionStore).toBeDefined(); + expect(bff.createTokenCipher).toBeDefined(); + expect(bff.ensureFreshServerAccess).toBeDefined(); + expect(bff.issueBffAccessToken).toBeDefined(); + expect(bff.BFF_ACCESS_TOKEN_TYPE).toBeDefined(); + expect(bff.createPkcePair).toBeDefined(); + expect(bff.OAuthRequestError).toBeDefined(); }); }); }); diff --git a/packages/agent-bff/test/oauth/bff-token.test.ts b/packages/agent-bff/test/oauth/bff-token.test.ts new file mode 100644 index 0000000000..4d00f5fa83 --- /dev/null +++ b/packages/agent-bff/test/oauth/bff-token.test.ts @@ -0,0 +1,108 @@ +import type { UserInfo } from '@forestadmin/forestadmin-client'; + +import jsonwebtoken from 'jsonwebtoken'; + +import { BFF_ACCESS_TOKEN_TYPE, issueBffAccessToken } from '../../src/oauth/bff-token'; + +const AUTH_SECRET = 'auth-secret'; + +const USER: UserInfo = { + id: 42, + email: 'ada@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + team: 'Support', + renderingId: 17, + role: 'Admin', + tags: { plan: 'pro' }, + permissionLevel: 'admin', +}; + +function decode(token: string) { + return jsonwebtoken.verify(token, AUTH_SECRET) as jsonwebtoken.JwtPayload & + Record; +} + +describe('bff-token', () => { + describe('when issuing a BFF access token', () => { + it('should carry type bff_access, sid, whitelisted identity claims and tags', () => { + const token = issueBffAccessToken({ + sid: 'sid-1', + user: USER, + renderingId: 17, + authSecret: AUTH_SECRET, + expiresInSeconds: 900, + }); + + const payload = decode(token); + + expect(payload.type).toBe(BFF_ACCESS_TOKEN_TYPE); + expect(payload.sid).toBe('sid-1'); + expect(payload.id).toBe(42); + expect(payload.email).toBe('ada@example.com'); + expect(payload.first_name).toBe('Ada'); + expect(payload.last_name).toBe('Lovelace'); + expect(payload.rendering_id).toBe('17'); + expect(payload.permission_level).toBe('admin'); + expect(payload.tags).toEqual({ plan: 'pro' }); + }); + + it('should default tags to {} when the user has none', () => { + const token = issueBffAccessToken({ + sid: 'sid-2', + user: { ...USER, tags: undefined as unknown as UserInfo['tags'] }, + renderingId: 17, + authSecret: AUTH_SECRET, + expiresInSeconds: 900, + }); + + expect(decode(token).tags).toEqual({}); + }); + + it('should set exp - iat to the provided expiresInSeconds', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00Z')); + + const token = issueBffAccessToken({ + sid: 'sid-3', + user: USER, + renderingId: 17, + authSecret: AUTH_SECRET, + expiresInSeconds: 120, + }); + + const payload = decode(token); + expect((payload.exp as number) - (payload.iat as number)).toBe(120); + + jest.useRealTimers(); + }); + + it('should not leak any non-whitelisted user field into the payload', () => { + const token = issueBffAccessToken({ + sid: 'sid-4', + user: { ...USER, role: 'SHOULD-NOT-LEAK' as string } as UserInfo, + renderingId: 17, + authSecret: AUTH_SECRET, + expiresInSeconds: 900, + }); + + expect(JSON.stringify(decode(token))).not.toContain('SHOULD-NOT-LEAK'); + }); + + it('should cap the expiry at the 15-minute ceiling even if the caller asks for more', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00Z')); + + const token = issueBffAccessToken({ + sid: 'sid-5', + user: USER, + renderingId: 17, + authSecret: AUTH_SECRET, + expiresInSeconds: 3600, + }); + + const payload = decode(token); + expect((payload.exp as number) - (payload.iat as number)).toBe(15 * 60); + + jest.useRealTimers(); + }); + }); +}); diff --git a/packages/agent-bff/test/oauth/forest-server-client.test.ts b/packages/agent-bff/test/oauth/forest-server-client.test.ts new file mode 100644 index 0000000000..20343f98d5 --- /dev/null +++ b/packages/agent-bff/test/oauth/forest-server-client.test.ts @@ -0,0 +1,241 @@ +import type { UserInfo } from '@forestadmin/forestadmin-client'; + +import jsonwebtoken from 'jsonwebtoken'; + +import ForestServerClient, { OAuthExchangeError } from '../../src/oauth/forest-server-client'; + +const getUserInfoSpy = jest.fn(); + +jest.mock('@forestadmin/forestadmin-client', () => ({ + __esModule: true, + default: jest.fn(() => ({ authService: { getUserInfo: getUserInfoSpy } })), +})); + +const SERVER_URL = 'https://api.forestadmin.com'; +const ENV_SECRET = 'env-secret'; + +function saasAccessToken(renderingId = 17, expSecondsFromNow = 3600): string { + return jsonwebtoken.sign( + { meta: { renderingId }, exp: Math.floor(Date.now() / 1000) + expSecondsFromNow }, + 'irrelevant', + ); +} + +function mockFetchOnce(response: { ok: boolean; status?: number; json?: () => Promise }) { + global.fetch = jest.fn().mockResolvedValue({ + ok: response.ok, + status: response.status ?? (response.ok ? 200 : 400), + statusText: 'mock', + json: response.json ?? (async () => ({})), + }) as unknown as typeof fetch; +} + +describe('ForestServerClient', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('when exchanging an authorization code', () => { + it('should return the SaaS tokens with renderingId and exp decoded from the access token', async () => { + const access = saasAccessToken(17); + mockFetchOnce({ + ok: true, + json: async () => ({ access_token: access, refresh_token: 'R1' }), + }); + + const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); + const tokens = await client.exchangeCode({ + code: 'c', + codeVerifier: 'v', + redirectUri: 'http://localhost/cb', + clientId: 'client-1', + }); + + expect(tokens.saasAccessToken).toBe(access); + expect(tokens.saasRefreshToken).toBe('R1'); + expect(tokens.renderingId).toBe(17); + expect(tokens.expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000)); + }); + + it('should throw OAuthExchangeError with the safe error code on a failed exchange', async () => { + mockFetchOnce({ + ok: false, + status: 400, + json: async () => ({ error: 'invalid_grant', error_description: 'bad code' }), + }); + + const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); + + await expect( + client.exchangeCode({ code: 'c', codeVerifier: 'v', redirectUri: 'r', clientId: 'x' }), + ).rejects.toMatchObject({ error: 'invalid_grant' }); + }); + + it('should throw when the exchange response omits the refresh token', async () => { + mockFetchOnce({ ok: true, json: async () => ({ access_token: saasAccessToken(17) }) }); + + const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); + + await expect( + client.exchangeCode({ code: 'c', codeVerifier: 'v', redirectUri: 'r', clientId: 'x' }), + ).rejects.toMatchObject({ error: 'invalid_grant' }); + }); + }); + + describe('when refreshing the SaaS token', () => { + it('should return the NEW rotated access and refresh tokens', async () => { + const newAccess = saasAccessToken(17); + mockFetchOnce({ + ok: true, + json: async () => ({ access_token: newAccess, refresh_token: 'R2' }), + }); + + const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); + const tokens = await client.refreshServerToken('R1'); + + expect(tokens.saasAccessToken).toBe(newAccess); + expect(tokens.saasRefreshToken).toBe('R2'); + expect(tokens.renderingId).toBe(17); + }); + + it('should throw OAuthExchangeError when the SaaS refresh fails', async () => { + mockFetchOnce({ ok: false, status: 400, json: async () => ({ error: 'invalid_grant' }) }); + + const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); + + await expect(client.refreshServerToken('R1')).rejects.toBeInstanceOf(OAuthExchangeError); + }); + + it('should not require renderingId in the refresh-grant access token', async () => { + const accessWithoutRendering = jsonwebtoken.sign( + { exp: Math.floor(Date.now() / 1000) + 3600 }, + 'irrelevant', + ); + mockFetchOnce({ + ok: true, + json: async () => ({ access_token: accessWithoutRendering, refresh_token: 'R2' }), + }); + + const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); + + await expect(client.refreshServerToken('R1')).resolves.toMatchObject({ + saasRefreshToken: 'R2', + }); + }); + }); + + describe('when fetching the environment id', () => { + it('should parse the numeric id from the Forest server response', async () => { + mockFetchOnce({ ok: true, json: async () => ({ data: { id: '42' } }) }); + + const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); + + await expect(client.fetchEnvironmentId()).resolves.toBe(42); + }); + + it('should throw when the response is not ok', async () => { + mockFetchOnce({ ok: false, status: 503 }); + + const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); + + await expect(client.fetchEnvironmentId()).rejects.toThrow(/Failed to fetch environment/); + }); + + it('should throw when the id is not an integer', async () => { + mockFetchOnce({ ok: true, json: async () => ({ data: { id: 'not-a-number' } }) }); + + const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); + + await expect(client.fetchEnvironmentId()).rejects.toThrow(/parse environment id/); + }); + }); + + describe('when decoding renderingId from an exchange token', () => { + it('should throw when the authorization_code access token has no renderingId', async () => { + const accessWithoutRendering = jsonwebtoken.sign( + { exp: Math.floor(Date.now() / 1000) + 3600 }, + 'irrelevant', + ); + mockFetchOnce({ + ok: true, + json: async () => ({ access_token: accessWithoutRendering, refresh_token: 'R1' }), + }); + + const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); + + await expect( + client.exchangeCode({ code: 'c', codeVerifier: 'v', redirectUri: 'r', clientId: 'x' }), + ).rejects.toThrow(/Failed to decode renderingId/); + }); + + it('should default renderingId to 0 on refresh when the token has none', async () => { + const accessWithoutRendering = jsonwebtoken.sign( + { exp: Math.floor(Date.now() / 1000) + 3600 }, + 'irrelevant', + ); + mockFetchOnce({ + ok: true, + json: async () => ({ access_token: accessWithoutRendering, refresh_token: 'R2' }), + }); + + const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); + + await expect(client.refreshServerToken('R1')).resolves.toMatchObject({ renderingId: 0 }); + }); + }); + + describe('when looking up a registered client', () => { + it('should return the client json on the ok path', async () => { + const registered = { client_id: 'client-1', redirect_uris: ['http://localhost/cb'] }; + mockFetchOnce({ ok: true, json: async () => registered }); + + const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); + + await expect(client.getRegisteredClient('client-1')).resolves.toEqual(registered); + }); + + it('should return undefined on 404 (unknown client)', async () => { + mockFetchOnce({ ok: false, status: 404 }); + + const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); + + await expect(client.getRegisteredClient('nope')).resolves.toBeUndefined(); + }); + + it('should throw on a non-404 error (outage), not masquerade as unknown client', async () => { + mockFetchOnce({ ok: false, status: 503 }); + + const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); + + await expect(client.getRegisteredClient('client-1')).rejects.toThrow(); + }); + + it('should percent-encode the clientId in the request path', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({}), + }); + global.fetch = fetchMock as unknown as typeof fetch; + + const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); + await client.getRegisteredClient('../../liana/environment'); + + expect(fetchMock.mock.calls[0][0]).toContain('%2F'); + expect(fetchMock.mock.calls[0][0]).not.toContain('/oauth/register/../'); + }); + }); + + describe('when resolving user info', () => { + it('should delegate to the forestadmin-client authService with the rendering id and token', async () => { + const userInfo = { id: 7, renderingId: 17 } as unknown as UserInfo; + getUserInfoSpy.mockResolvedValueOnce(userInfo); + + const client = new ForestServerClient({ forestServerUrl: SERVER_URL, envSecret: ENV_SECRET }); + const result = await client.getUserInfo(17, 'saas-token'); + + expect(getUserInfoSpy).toHaveBeenCalledWith(17, 'saas-token'); + expect(result).toBe(userInfo); + }); + }); +}); diff --git a/packages/agent-bff/test/oauth/oauth-error.test.ts b/packages/agent-bff/test/oauth/oauth-error.test.ts new file mode 100644 index 0000000000..5ea920a73e --- /dev/null +++ b/packages/agent-bff/test/oauth/oauth-error.test.ts @@ -0,0 +1,40 @@ +import { + OAuthRequestError, + forestIdentityNotAllowed, + invalidClient, + invalidGrant, + invalidRequest, + sessionExpired, + toErrorBody, +} from '../../src/oauth/oauth-error'; + +describe('oauth-error', () => { + describe('when serializing an error to the response body', () => { + it('should produce the nested { error: { type, status, message } } shape', () => { + const error = invalidRequest('Missing param'); + + expect(toErrorBody(error)).toEqual({ + error: { type: 'invalid_request', status: 400, message: 'Missing param' }, + }); + }); + }); + + describe('when building each contract error', () => { + it.each([ + ['invalidRequest', invalidRequest('x'), 'invalid_request', 400], + ['invalidClient', invalidClient('x'), 'invalid_client', 400], + ['invalidGrant', invalidGrant('x'), 'invalid_grant', 400], + [ + 'forestIdentityNotAllowed', + forestIdentityNotAllowed('x'), + 'forest_identity_not_allowed', + 403, + ], + ['sessionExpired', sessionExpired('x'), 'session_expired', 401], + ])('should set %s to type %s and status %d', (_label, error, type, status) => { + expect(error).toBeInstanceOf(OAuthRequestError); + expect(error.type).toBe(type); + expect(error.status).toBe(status); + }); + }); +}); diff --git a/packages/agent-bff/test/oauth/oauth-routes.test.ts b/packages/agent-bff/test/oauth/oauth-routes.test.ts new file mode 100644 index 0000000000..fb32479876 --- /dev/null +++ b/packages/agent-bff/test/oauth/oauth-routes.test.ts @@ -0,0 +1,556 @@ +import type ForestServerClient from '../../src/oauth/forest-server-client'; +import type { ServerTokens } from '../../src/oauth/forest-server-client'; +import type { LoggerLevel } from '../../src/ports/logger-port'; +import type { UserInfo } from '@forestadmin/forestadmin-client'; + +import { ForbiddenError } from '@forestadmin/forestadmin-client'; +import { bodyParser } from '@koa/bodyparser'; +import jsonwebtoken from 'jsonwebtoken'; +import Koa from 'koa'; +import request from 'supertest'; + +import { OAuthExchangeError } from '../../src/oauth/forest-server-client'; +import createOAuthRoutes from '../../src/oauth/oauth-routes'; +import createInMemorySessionStore from '../../src/oauth/session-store'; +import createTokenCipher from '../../src/oauth/token-cipher'; + +const AUTH_SECRET = 'auth-secret'; +const APP_URL = 'https://app.forestadmin.com'; +const CLIENT_ID = 'client-123'; +const REDIRECT_URI = 'http://localhost:4567/callback'; +const SENTINEL_ACCESS = 'SAAS-ACCESS-SENTINEL'; +const SENTINEL_REFRESH = 'SAAS-REFRESH-SENTINEL'; +const KEY = Buffer.alloc(32, 5).toString('base64'); + +const USER: UserInfo = { + id: 42, + email: 'ada@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + team: 'Support', + renderingId: 17, + role: 'Admin', + tags: {}, + permissionLevel: 'admin', +}; + +function serverTokens(expFromNow = 3600): ServerTokens { + return { + saasAccessToken: SENTINEL_ACCESS, + saasRefreshToken: SENTINEL_REFRESH, + renderingId: 17, + expiresAt: Math.floor(Date.now() / 1000) + expFromNow, + }; +} + +interface StubOverrides { + getRegisteredClient?: ForestServerClient['getRegisteredClient']; + exchangeCode?: ForestServerClient['exchangeCode']; + getUserInfo?: ForestServerClient['getUserInfo']; +} + +function stubServerClient(overrides: StubOverrides = {}): ForestServerClient { + return { + getRegisteredClient: + overrides.getRegisteredClient ?? + (async () => ({ client_id: CLIENT_ID, redirect_uris: [REDIRECT_URI] })), + exchangeCode: overrides.exchangeCode ?? (async () => serverTokens()), + getUserInfo: overrides.getUserInfo ?? (async () => USER), + } as unknown as ForestServerClient; +} + +interface LogLine { + level: LoggerLevel; + message: string; + context?: Record; +} + +function buildApp(serverClient: ForestServerClient) { + const logs: LogLine[] = []; + + const logger = (level: LoggerLevel, message: string, context?: Record) => { + logs.push({ level, message, context }); + }; + + const sessionStore = createInMemorySessionStore({ + cipher: createTokenCipher(KEY), + now: () => Date.now(), + sessionTtlSeconds: 3600, + }); + + const app = new Koa(); + app.use(bodyParser()); + app.use( + createOAuthRoutes({ + serverClient, + sessionStore, + forestAppUrl: APP_URL, + authSecret: AUTH_SECRET, + environmentId: 99, + logger, + }), + ); + + return { app, logs, sessionStore }; +} + +const TOKEN_BODY = { + grant_type: 'authorization_code', + code: 'auth-code-1', + client_id: CLIENT_ID, + code_verifier: 'a'.repeat(64), + redirect_uri: REDIRECT_URI, +}; + +const AUTHORIZE_QUERY = { + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: 'code', + code_challenge: 'challenge-abc', + code_challenge_method: 'S256', + state: 'state-xyz', +}; + +describe('oauth-routes GET /oauth/authorize', () => { + describe('when all required params are valid and the client is registered', () => { + it('should 302-redirect to the Forest server authorize url with the forwarded challenge', async () => { + const { app } = buildApp(stubServerClient()); + + const response = await request(app.callback()).get('/oauth/authorize').query(AUTHORIZE_QUERY); + + expect(response.status).toBe(302); + const location = new URL(response.headers.location); + expect(location.origin + location.pathname).toBe(`${APP_URL}/oauth/authorize`); + expect(location.searchParams.get('code_challenge')).toBe('challenge-abc'); + expect(location.searchParams.get('environmentId')).toBe('99'); + expect(location.searchParams.get('state')).toBe('state-xyz'); + }); + }); + + describe('when a required param is missing', () => { + it('should reject with nested invalid_request and not redirect', async () => { + const { app } = buildApp(stubServerClient()); + const incomplete = { ...AUTHORIZE_QUERY }; + delete (incomplete as Partial).code_challenge; + + const response = await request(app.callback()).get('/oauth/authorize').query(incomplete); + + expect(response.status).toBe(400); + expect(response.body.error.type).toBe('invalid_request'); + }); + }); + + describe('when the client_id is not registered', () => { + it('should reject with invalid_client', async () => { + const { app } = buildApp(stubServerClient({ getRegisteredClient: async () => undefined })); + + const response = await request(app.callback()).get('/oauth/authorize').query(AUTHORIZE_QUERY); + + expect(response.status).toBe(400); + expect(response.body.error.type).toBe('invalid_client'); + }); + }); + + describe('when code_challenge_method is not S256', () => { + it('should reject the PKCE downgrade with invalid_request', async () => { + const { app } = buildApp(stubServerClient()); + + const response = await request(app.callback()) + .get('/oauth/authorize') + .query({ ...AUTHORIZE_QUERY, code_challenge_method: 'plain' }); + + expect(response.status).toBe(400); + expect(response.body.error.type).toBe('invalid_request'); + }); + + it('should reject a missing code_challenge_method (S256 is mandatory, not defaulted)', async () => { + const { app } = buildApp(stubServerClient()); + const query = { ...AUTHORIZE_QUERY }; + delete (query as Partial).code_challenge_method; + + const response = await request(app.callback()).get('/oauth/authorize').query(query); + + expect(response.status).toBe(400); + expect(response.body.error.type).toBe('invalid_request'); + }); + + it('should reject an empty code_challenge (presence check rejects empty, not just missing)', async () => { + const { app } = buildApp(stubServerClient()); + + const response = await request(app.callback()) + .get('/oauth/authorize') + .query({ ...AUTHORIZE_QUERY, code_challenge: '' }); + + expect(response.status).toBe(400); + expect(response.body.error.type).toBe('invalid_request'); + }); + }); + + describe('when a query param is repeated (array value)', () => { + it('should reject with invalid_request and not redirect', async () => { + const { app } = buildApp(stubServerClient()); + + const response = await request(app.callback()) + .get('/oauth/authorize') + .query({ ...AUTHORIZE_QUERY, client_id: [CLIENT_ID, 'other'] }); + + expect(response.status).toBe(400); + expect(response.body.error.type).toBe('invalid_request'); + expect(response.headers.location).toBeUndefined(); + }); + }); + + describe('when the registered client has no redirect_uris', () => { + it('should fail closed with invalid_request instead of accepting any redirect_uri', async () => { + const { app } = buildApp( + stubServerClient({ getRegisteredClient: async () => ({ client_id: CLIENT_ID }) }), + ); + + const response = await request(app.callback()).get('/oauth/authorize').query(AUTHORIZE_QUERY); + + expect(response.status).toBe(400); + expect(response.body.error.type).toBe('invalid_request'); + }); + }); + + describe('when the redirect_uri does not match the registered client', () => { + it('should reject with a structured invalid_request and not redirect', async () => { + const { app } = buildApp( + stubServerClient({ + getRegisteredClient: async () => ({ + client_id: CLIENT_ID, + redirect_uris: ['http://localhost:9999/other'], + }), + }), + ); + + const response = await request(app.callback()).get('/oauth/authorize').query(AUTHORIZE_QUERY); + + expect(response.status).toBe(400); + expect(response.body.error.type).toBe('invalid_request'); + expect(response.headers.location).toBeUndefined(); + }); + }); +}); + +describe('oauth-routes POST /oauth/token', () => { + describe('when the authorization_code exchange succeeds', () => { + it('should return a complete pair (access JWT + opaque refresh) with no SaaS token leaked', async () => { + const { app } = buildApp(stubServerClient()); + + const response = await request(app.callback()).post('/oauth/token').send(TOKEN_BODY); + + expect(response.status).toBe(200); + expect(response.body.token_type).toBe('Bearer'); + expect(typeof response.body.refresh_token).toBe('string'); + expect(response.body.refresh_token.length).toBeGreaterThanOrEqual(32); + + const decoded = jsonwebtoken.verify(response.body.access_token, AUTH_SECRET) as Record< + string, + unknown + >; + expect(decoded.type).toBe('bff_access'); + + const serialized = JSON.stringify(response.body) + JSON.stringify(decoded); + expect(serialized).not.toContain(SENTINEL_ACCESS); + expect(serialized).not.toContain(SENTINEL_REFRESH); + }); + + it('should not leak any SaaS token value into the logs', async () => { + const { app, logs } = buildApp(stubServerClient()); + + await request(app.callback()).post('/oauth/token').send(TOKEN_BODY); + + expect(JSON.stringify(logs)).not.toContain(SENTINEL_ACCESS); + expect(JSON.stringify(logs)).not.toContain(SENTINEL_REFRESH); + }); + }); + + describe('when the redirect_uri does not match the registered client', () => { + it('should reject with invalid_grant (per the error contract) and not exchange', async () => { + const exchangeCode = jest.fn(async () => serverTokens()); + const { app } = buildApp( + stubServerClient({ + exchangeCode, + getRegisteredClient: async () => ({ + client_id: CLIENT_ID, + redirect_uris: ['http://localhost:9999/other'], + }), + }), + ); + + const response = await request(app.callback()).post('/oauth/token').send(TOKEN_BODY); + + expect(response.status).toBe(400); + expect(response.body.error.type).toBe('invalid_grant'); + expect(exchangeCode).not.toHaveBeenCalled(); + }); + }); + + describe('when local validation fails', () => { + it('should reject a missing field with nested invalid_request and call no SaaS', async () => { + const exchangeCode = jest.fn(async () => serverTokens()); + const { app } = buildApp(stubServerClient({ exchangeCode })); + const incomplete = { ...TOKEN_BODY }; + delete (incomplete as Partial).code_verifier; + + const response = await request(app.callback()).post('/oauth/token').send(incomplete); + + expect(response.status).toBe(400); + expect(response.body.error).toEqual({ + type: 'invalid_request', + status: 400, + message: expect.any(String), + }); + expect(exchangeCode).not.toHaveBeenCalled(); + }); + + it('should reject an unknown client with invalid_client and call no SaaS exchange', async () => { + const exchangeCode = jest.fn(async () => serverTokens()); + const { app } = buildApp( + stubServerClient({ exchangeCode, getRegisteredClient: async () => undefined }), + ); + + const response = await request(app.callback()).post('/oauth/token').send(TOKEN_BODY); + + expect(response.status).toBe(400); + expect(response.body.error.type).toBe('invalid_client'); + expect(exchangeCode).not.toHaveBeenCalled(); + }); + }); + + describe('when the same code is replayed', () => { + it('should reject the replay with invalid_grant and call the SaaS exchange exactly once', async () => { + const exchangeCode = jest.fn(async () => serverTokens()); + const { app } = buildApp(stubServerClient({ exchangeCode })); + + const first = await request(app.callback()).post('/oauth/token').send(TOKEN_BODY); + const second = await request(app.callback()).post('/oauth/token').send(TOKEN_BODY); + + expect(first.status).toBe(200); + expect(second.status).toBe(400); + expect(second.body.error.type).toBe('invalid_grant'); + expect(exchangeCode).toHaveBeenCalledTimes(1); + }); + }); + + describe('when a transient identity failure happens after the exchange', () => { + it('should release the code so the same request can be retried successfully', async () => { + let attempts = 0; + const getUserInfo = jest.fn(async () => { + attempts += 1; + if (attempts === 1) throw new Error('network blip'); + + return USER; + }); + const { app } = buildApp(stubServerClient({ getUserInfo })); + + const first = await request(app.callback()).post('/oauth/token').send(TOKEN_BODY); + const second = await request(app.callback()).post('/oauth/token').send(TOKEN_BODY); + + expect(first.status).toBe(502); + expect(first.body.error.type).toBe('identity_resolution_failed'); + expect(second.status).toBe(200); + expect(second.body.token_type).toBe('Bearer'); + }); + }); + + describe('when the resolved identity is not allowed for the rendering', () => { + it('should map a ForbiddenError to 403 forest_identity_not_allowed', async () => { + const { app } = buildApp( + stubServerClient({ + getUserInfo: async () => { + throw new ForbiddenError('nope'); + }, + }), + ); + + const response = await request(app.callback()).post('/oauth/token').send(TOKEN_BODY); + + expect(response.status).toBe(403); + expect(response.body.error.type).toBe('forest_identity_not_allowed'); + }); + }); + + describe('when the SaaS access token is already expired at exchange', () => { + it('should reject with invalid_grant rather than mint a born-dead token', async () => { + const { app } = buildApp(stubServerClient({ exchangeCode: async () => serverTokens(-10) })); + + const response = await request(app.callback()).post('/oauth/token').send(TOKEN_BODY); + + expect(response.status).toBe(400); + expect(response.body.error.type).toBe('invalid_grant'); + }); + }); + + describe('when the SaaS exchange itself fails', () => { + it('should map the safe exchange error to invalid_grant without relaying the description', async () => { + const { app } = buildApp( + stubServerClient({ + exchangeCode: async () => { + throw new OAuthExchangeError('invalid_grant', 'super-secret-saas-detail'); + }, + }), + ); + + const response = await request(app.callback()).post('/oauth/token').send(TOKEN_BODY); + + expect(response.status).toBe(400); + expect(response.body.error.type).toBe('invalid_grant'); + expect(JSON.stringify(response.body)).not.toContain('super-secret-saas-detail'); + }); + }); + + describe('when the SaaS exchange fails with an unsafe error code', () => { + it('should map it to 502 server_error without exposing the upstream code', async () => { + const { app } = buildApp( + stubServerClient({ + exchangeCode: async () => { + throw new OAuthExchangeError('some_unknown_upstream_error', 'super-secret-saas-detail'); + }, + }), + ); + + const response = await request(app.callback()).post('/oauth/token').send(TOKEN_BODY); + + expect(response.status).toBe(502); + expect(response.body.error.type).toBe('server_error'); + expect(JSON.stringify(response.body)).not.toContain('some_unknown_upstream_error'); + expect(JSON.stringify(response.body)).not.toContain('super-secret-saas-detail'); + }); + }); + + describe('when an unsupported grant_type is used', () => { + it('should reject refresh_token here (it is T5, not T4)', async () => { + const { app } = buildApp(stubServerClient()); + + const response = await request(app.callback()) + .post('/oauth/token') + .send({ grant_type: 'refresh_token', refresh_token: 'x' }); + + expect(response.status).toBe(400); + expect(response.body.error.type).toBe('unsupported_grant_type'); + }); + }); + + describe('when the code_verifier is malformed', () => { + it('should reject with invalid_request before exchanging', async () => { + const exchangeCode = jest.fn(async () => serverTokens()); + const { app } = buildApp(stubServerClient({ exchangeCode })); + + const response = await request(app.callback()) + .post('/oauth/token') + .send({ ...TOKEN_BODY, code_verifier: 'short' }); + + expect(response.status).toBe(400); + expect(response.body.error.type).toBe('invalid_request'); + expect(exchangeCode).not.toHaveBeenCalled(); + }); + }); + + describe('when the request body is missing (no parsed body)', () => { + it('should reject with invalid_request', async () => { + const sessionStore = createInMemorySessionStore({ + cipher: createTokenCipher(KEY), + now: () => Date.now(), + sessionTtlSeconds: 3600, + }); + const app = new Koa(); + app.use( + createOAuthRoutes({ + serverClient: stubServerClient(), + sessionStore, + forestAppUrl: APP_URL, + authSecret: AUTH_SECRET, + environmentId: 99, + logger: () => undefined, + }), + ); + + const response = await request(app.callback()).post('/oauth/token'); + + expect(response.status).toBe(400); + expect(response.body.error.type).toBe('invalid_request'); + }); + }); + + describe('when identity resolution throws a generic error', () => { + it('should map it to 502 identity_resolution_failed', async () => { + const { app } = buildApp( + stubServerClient({ + getUserInfo: async () => { + throw new Error('network blip'); + }, + }), + ); + + const response = await request(app.callback()).post('/oauth/token').send(TOKEN_BODY); + + expect(response.status).toBe(502); + expect(response.body.error.type).toBe('identity_resolution_failed'); + }); + }); + + describe('when the SaaS exchange throws a non-OAuthExchangeError', () => { + it('should surface a 500 server_error and not leak the cause', async () => { + const { app } = buildApp( + stubServerClient({ + exchangeCode: async () => { + throw new Error('super-secret-internal-detail'); + }, + }), + ); + + const response = await request(app.callback()).post('/oauth/token').send(TOKEN_BODY); + + expect(response.status).toBe(500); + expect(response.body.error.type).toBe('server_error'); + expect(JSON.stringify(response.body)).not.toContain('super-secret-internal-detail'); + }); + }); +}); + +describe('oauth-routes middleware', () => { + describe('when the response_type is not code', () => { + it('should reject with invalid_request', async () => { + const { app } = buildApp(stubServerClient()); + + const response = await request(app.callback()) + .get('/oauth/authorize') + .query({ ...AUTHORIZE_QUERY, response_type: 'token' }); + + expect(response.status).toBe(400); + expect(response.body.error.type).toBe('invalid_request'); + }); + }); + + describe('when a route handler throws a non-OAuth error', () => { + it('should write a 500 server_error', async () => { + const { app } = buildApp( + stubServerClient({ + getRegisteredClient: async () => { + throw new Error('plain boom'); + }, + }), + ); + + const response = await request(app.callback()).get('/oauth/authorize').query(AUTHORIZE_QUERY); + + expect(response.status).toBe(500); + expect(response.body.error.type).toBe('server_error'); + }); + }); + + describe('when the route is not an OAuth route', () => { + it('should pass through to the next middleware', async () => { + const { app } = buildApp(stubServerClient()); + app.use(async ctx => { + ctx.status = 204; + }); + + const response = await request(app.callback()).get('/something-else'); + + expect(response.status).toBe(204); + }); + }); +}); diff --git a/packages/agent-bff/test/oauth/pkce.test.ts b/packages/agent-bff/test/oauth/pkce.test.ts new file mode 100644 index 0000000000..22e8e866ae --- /dev/null +++ b/packages/agent-bff/test/oauth/pkce.test.ts @@ -0,0 +1,32 @@ +import crypto from 'crypto'; + +import { challengeFromVerifier, createPkcePair, createVerifier } from '../../src/oauth/pkce'; + +describe('pkce', () => { + describe('when creating a verifier', () => { + it('should produce a url-safe string with no padding', () => { + const verifier = createVerifier(); + + expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('should produce a different verifier on each call', () => { + expect(createVerifier()).not.toEqual(createVerifier()); + }); + }); + + describe('when deriving the challenge from a verifier', () => { + it('should equal base64url(sha256(verifier))', () => { + const verifier = createVerifier(); + const expected = crypto.createHash('sha256').update(verifier).digest('base64url'); + + expect(challengeFromVerifier(verifier)).toEqual(expected); + }); + + it('should round-trip a generated pair so the challenge matches the verifier', () => { + const pair = createPkcePair(); + + expect(challengeFromVerifier(pair.codeVerifier)).toEqual(pair.codeChallenge); + }); + }); +}); diff --git a/packages/agent-bff/test/oauth/session-lifecycle.test.ts b/packages/agent-bff/test/oauth/session-lifecycle.test.ts new file mode 100644 index 0000000000..c34c16781a --- /dev/null +++ b/packages/agent-bff/test/oauth/session-lifecycle.test.ts @@ -0,0 +1,215 @@ +import type ForestServerClient from '../../src/oauth/forest-server-client'; +import type { ServerTokens } from '../../src/oauth/forest-server-client'; +import type { SessionStore, StoredSession } from '../../src/oauth/session-store'; + +import jsonwebtoken from 'jsonwebtoken'; + +import { OAuthExchangeError } from '../../src/oauth/forest-server-client'; +import ensureFreshServerAccess from '../../src/oauth/session-lifecycle'; +import createInMemorySessionStore from '../../src/oauth/session-store'; +import createTokenCipher from '../../src/oauth/token-cipher'; + +const KEY = Buffer.alloc(32, 8).toString('base64'); + +function accessToken(expSecondsFromNow: number): string { + return jsonwebtoken.sign( + { meta: { renderingId: 17 }, exp: Math.floor(Date.now() / 1000) + expSecondsFromNow }, + 'irrelevant', + ); +} + +function buildStore() { + return createInMemorySessionStore({ + cipher: createTokenCipher(KEY), + now: () => Date.now(), + sessionTtlSeconds: 3600, + }); +} + +function rotatedTokens(): ServerTokens { + return { + saasAccessToken: accessToken(3600), + saasRefreshToken: 'NEW-REFRESH', + renderingId: 17, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }; +} + +describe('ensureFreshServerAccess', () => { + describe('when the stored SaaS access token is still valid', () => { + it('should return it without refreshing or writing to the store', async () => { + const store = buildStore(); + const validAccess = accessToken(3600); + const { sid } = store.create({ + saasAccessToken: validAccess, + saasRefreshToken: 'R1', + renderingId: 17, + userId: 42, + }); + const refreshServerToken = jest.fn(); + const client = { refreshServerToken } as unknown as ForestServerClient; + + const result = await ensureFreshServerAccess({ sid, store, serverClient: client }); + + expect(result).toBe(validAccess); + expect(refreshServerToken).not.toHaveBeenCalled(); + }); + }); + + describe('when the stored SaaS access token is expired', () => { + it('should refresh, persist the rotated tokens, and return the new access', async () => { + const store = buildStore(); + const { sid } = store.create({ + saasAccessToken: accessToken(-10), + saasRefreshToken: 'R1', + renderingId: 17, + userId: 42, + }); + const rotated = rotatedTokens(); + const client = { + refreshServerToken: jest.fn(async () => rotated), + } as unknown as ForestServerClient; + + const result = await ensureFreshServerAccess({ sid, store, serverClient: client }); + + expect(result).toBe(rotated.saasAccessToken); + expect(store.get(sid)?.saasAccessToken).toBe(rotated.saasAccessToken); + expect(store.getSaasRefreshToken(sid)).toBe('NEW-REFRESH'); + }); + }); + + describe('when two concurrent requests hit an expired access token', () => { + it('should refresh only once (single-flight per sid) and return the same new access to both', async () => { + const store = buildStore(); + const { sid } = store.create({ + saasAccessToken: accessToken(-10), + saasRefreshToken: 'R1', + renderingId: 17, + userId: 42, + }); + const rotated = rotatedTokens(); + const refreshServerToken = jest.fn(async () => { + await new Promise(resolve => { + setTimeout(resolve, 10); + }); + + return rotated; + }); + const client = { refreshServerToken } as unknown as ForestServerClient; + + const [a, b] = await Promise.all([ + ensureFreshServerAccess({ sid, store, serverClient: client }), + ensureFreshServerAccess({ sid, store, serverClient: client }), + ]); + + expect(refreshServerToken).toHaveBeenCalledTimes(1); + expect(a).toBe(rotated.saasAccessToken); + expect(b).toBe(rotated.saasAccessToken); + }); + }); + + describe('when the session does not exist', () => { + it('should throw a session_expired error without refreshing', async () => { + const store = buildStore(); + const refreshServerToken = jest.fn(); + const client = { refreshServerToken } as unknown as ForestServerClient; + + await expect( + ensureFreshServerAccess({ sid: 'unknown-sid', store, serverClient: client }), + ).rejects.toMatchObject({ type: 'session_expired' }); + expect(refreshServerToken).not.toHaveBeenCalled(); + }); + }); + + describe('when the session exists but its SaaS refresh token is gone', () => { + it('should throw a session_expired error', async () => { + const expiredAccess = accessToken(-10); + const store: SessionStore = { + get: () => ({ saasAccessToken: expiredAccess } as StoredSession), + getSaasRefreshToken: () => undefined, + create: jest.fn(), + updateSaasTokens: jest.fn(), + claimAuthorizationCode: jest.fn(), + releaseAuthorizationCode: jest.fn(), + pendingClaimCount: jest.fn(), + }; + const refreshServerToken = jest.fn(); + const client = { refreshServerToken } as unknown as ForestServerClient; + + await expect( + ensureFreshServerAccess({ sid: 'sid-1', store, serverClient: client }), + ).rejects.toMatchObject({ type: 'session_expired' }); + expect(refreshServerToken).not.toHaveBeenCalled(); + }); + }); + + describe('when the session disappears during the refresh', () => { + it('should throw session_expired instead of persisting into a gone session', async () => { + const expiredAccess = accessToken(-10); + const get = jest + .fn() + .mockReturnValueOnce({ saasAccessToken: expiredAccess } as StoredSession) + .mockReturnValue(undefined); + const updateSaasTokens = jest.fn(); + const store: SessionStore = { + get, + getSaasRefreshToken: () => 'R1', + create: jest.fn(), + updateSaasTokens, + claimAuthorizationCode: jest.fn(), + releaseAuthorizationCode: jest.fn(), + pendingClaimCount: jest.fn(), + }; + const client = { + refreshServerToken: jest.fn(async () => rotatedTokens()), + } as unknown as ForestServerClient; + + await expect( + ensureFreshServerAccess({ sid: 'sid-1', store, serverClient: client }), + ).rejects.toMatchObject({ type: 'session_expired' }); + expect(updateSaasTokens).not.toHaveBeenCalled(); + }); + }); + + describe('when the underlying SaaS refresh fails transiently', () => { + it('should throw a server_error (not session_expired) so the client can retry', async () => { + const store = buildStore(); + const { sid } = store.create({ + saasAccessToken: accessToken(-10), + saasRefreshToken: 'R1', + renderingId: 17, + userId: 42, + }); + const client = { + refreshServerToken: jest.fn(async () => { + throw new Error('saas down'); + }), + } as unknown as ForestServerClient; + + await expect( + ensureFreshServerAccess({ sid, store, serverClient: client }), + ).rejects.toMatchObject({ type: 'server_error' }); + }); + }); + + describe('when the Forest server rejects the refresh token', () => { + it('should throw session_expired so the client re-authenticates', async () => { + const store = buildStore(); + const { sid } = store.create({ + saasAccessToken: accessToken(-10), + saasRefreshToken: 'R1', + renderingId: 17, + userId: 42, + }); + const client = { + refreshServerToken: jest.fn(async () => { + throw new OAuthExchangeError('invalid_grant', 'refresh token revoked'); + }), + } as unknown as ForestServerClient; + + await expect( + ensureFreshServerAccess({ sid, store, serverClient: client }), + ).rejects.toMatchObject({ type: 'session_expired' }); + }); + }); +}); diff --git a/packages/agent-bff/test/oauth/session-store.test.ts b/packages/agent-bff/test/oauth/session-store.test.ts new file mode 100644 index 0000000000..e984c57726 --- /dev/null +++ b/packages/agent-bff/test/oauth/session-store.test.ts @@ -0,0 +1,192 @@ +import type { TokenCipher } from '../../src/oauth/token-cipher'; + +import createInMemorySessionStore from '../../src/oauth/session-store'; +import createTokenCipher from '../../src/oauth/token-cipher'; + +const KEY = Buffer.alloc(32, 3).toString('base64'); + +function buildStore(cipher: TokenCipher = createTokenCipher(KEY), now = () => 1_000_000) { + return createInMemorySessionStore({ cipher, now, sessionTtlSeconds: 3600 }); +} + +const SESSION_INPUT = { + saasAccessToken: 'SAAS-ACCESS-SENTINEL', + saasRefreshToken: 'SAAS-REFRESH-SENTINEL', + renderingId: 17, + userId: 42, +}; + +describe('session-store', () => { + describe('when creating a session', () => { + it('should return a sid and the raw opaque refresh token once', () => { + const store = buildStore(); + + const { sid, refreshToken } = store.create(SESSION_INPUT); + + expect(typeof sid).toBe('string'); + expect(refreshToken.length).toBeGreaterThanOrEqual(32); + expect(store.get(sid)?.userId).toBe(42); + }); + + it('should never persist the raw opaque refresh token (hash only)', () => { + const store = buildStore(); + + const { sid, refreshToken } = store.create(SESSION_INPUT); + const stored = store.get(sid); + + expect(JSON.stringify(stored)).not.toContain(refreshToken); + }); + + it('should encrypt the SaaS refresh token at rest (blob, not plaintext)', () => { + const store = buildStore(); + + const { sid } = store.create(SESSION_INPUT); + const stored = store.get(sid); + + expect(JSON.stringify(stored)).not.toContain('SAAS-REFRESH-SENTINEL'); + }); + + it('should expose the decrypted SaaS refresh token via getSaasRefreshToken', () => { + const store = buildStore(); + + const { sid } = store.create(SESSION_INPUT); + + expect(store.getSaasRefreshToken(sid)).toBe('SAAS-REFRESH-SENTINEL'); + }); + }); + + describe('when claiming an authorization code', () => { + it('should return true the first time and false on replay', () => { + const store = buildStore(); + + expect(store.claimAuthorizationCode('code-1')).toBe(true); + expect(store.claimAuthorizationCode('code-1')).toBe(false); + }); + + it('should claim distinct codes independently', () => { + const store = buildStore(); + + expect(store.claimAuthorizationCode('code-a')).toBe(true); + expect(store.claimAuthorizationCode('code-b')).toBe(true); + }); + + it('should reject new claims at the cap without evicting live entries (replay guard)', () => { + let clock = 1_000_000; + const store = createInMemorySessionStore({ + cipher: createTokenCipher(KEY), + now: () => clock, + sessionTtlSeconds: 3600, + authCodeTtlSeconds: 60, + maxPendingCodes: 2, + }); + + expect(store.claimAuthorizationCode('c1')).toBe(true); + clock += 1; + expect(store.claimAuthorizationCode('c2')).toBe(true); + clock += 1; + expect(store.claimAuthorizationCode('c3')).toBe(false); + + expect(store.claimAuthorizationCode('c1')).toBe(false); + expect(store.claimAuthorizationCode('c2')).toBe(false); + }); + + it('should reject every claim when maxPendingCodes is 0 (strict zero cap)', () => { + const store = createInMemorySessionStore({ + cipher: createTokenCipher(KEY), + now: () => 1_000_000, + sessionTtlSeconds: 3600, + maxPendingCodes: 0, + }); + + expect(store.claimAuthorizationCode('c1')).toBe(false); + expect(store.pendingClaimCount()).toBe(0); + }); + + it('should free a slot once a capped claim expires, accepting a new code', () => { + let clock = 1_000_000; + const store = createInMemorySessionStore({ + cipher: createTokenCipher(KEY), + now: () => clock, + sessionTtlSeconds: 3600, + authCodeTtlSeconds: 60, + maxPendingCodes: 2, + }); + + expect(store.claimAuthorizationCode('c1')).toBe(true); + expect(store.claimAuthorizationCode('c2')).toBe(true); + expect(store.claimAuthorizationCode('c3')).toBe(false); + + clock += 60 * 1000; + expect(store.claimAuthorizationCode('c3')).toBe(true); + }); + + it('should drop a claimed code after its TTL so the store does not grow unbounded', () => { + let clock = 1_000_000; + const store = createInMemorySessionStore({ + cipher: createTokenCipher(KEY), + now: () => clock, + sessionTtlSeconds: 3600, + authCodeTtlSeconds: 60, + }); + + expect(store.claimAuthorizationCode('code-x')).toBe(true); + expect(store.claimAuthorizationCode('code-x')).toBe(false); + + clock += 61 * 1000; + + expect(store.pendingClaimCount()).toBe(0); + }); + }); + + describe('when a session has a TTL', () => { + it('should not return an expired session', () => { + let clock = 1_000_000; + const store = createInMemorySessionStore({ + cipher: createTokenCipher(KEY), + now: () => clock, + sessionTtlSeconds: 60, + }); + + const { sid } = store.create(SESSION_INPUT); + expect(store.get(sid)).toBeDefined(); + + clock += 61 * 1000; + expect(store.get(sid)).toBeUndefined(); + }); + }); + + describe('when creating a session after older sessions have expired', () => { + it('should purge the expired session during create', () => { + let clock = 1_000_000; + const store = createInMemorySessionStore({ + cipher: createTokenCipher(KEY), + now: () => clock, + sessionTtlSeconds: 60, + }); + + const { sid: firstSid } = store.create(SESSION_INPUT); + + clock += 61 * 1000; + const { sid: secondSid } = store.create(SESSION_INPUT); + + expect(store.get(firstSid)).toBeUndefined(); + expect(store.get(secondSid)).toBeDefined(); + }); + }); + + describe('when updating the stored SaaS tokens after a refresh', () => { + it('should persist the rotated tokens and re-encrypt the new refresh', () => { + const store = buildStore(); + const { sid } = store.create(SESSION_INPUT); + + store.updateSaasTokens(sid, { + saasAccessToken: 'NEW-ACCESS', + saasRefreshToken: 'NEW-REFRESH', + }); + + expect(store.get(sid)?.saasAccessToken).toBe('NEW-ACCESS'); + expect(store.getSaasRefreshToken(sid)).toBe('NEW-REFRESH'); + expect(JSON.stringify(store.get(sid))).not.toContain('NEW-REFRESH'); + }); + }); +}); diff --git a/packages/agent-bff/test/oauth/token-cipher.test.ts b/packages/agent-bff/test/oauth/token-cipher.test.ts new file mode 100644 index 0000000000..45cd85c9b7 --- /dev/null +++ b/packages/agent-bff/test/oauth/token-cipher.test.ts @@ -0,0 +1,70 @@ +import createTokenCipher from '../../src/oauth/token-cipher'; + +const KEY = Buffer.alloc(32, 7).toString('base64'); + +describe('token-cipher', () => { + describe('when encrypting then decrypting', () => { + it('should round-trip the plaintext', () => { + const cipher = createTokenCipher(KEY); + const plaintext = 'SAAS-REFRESH-SENTINEL'; + + expect(cipher.decrypt(cipher.encrypt(plaintext))).toBe(plaintext); + }); + + it('should not expose the plaintext in the serialized output', () => { + const cipher = createTokenCipher(KEY); + const plaintext = 'SAAS-REFRESH-SENTINEL'; + + expect(JSON.stringify(cipher.encrypt(plaintext))).not.toContain(plaintext); + }); + + it('should produce { iv, authTag, ciphertext } as base64 fields', () => { + const cipher = createTokenCipher(KEY); + const blob = cipher.encrypt('x'); + + expect(blob).toEqual({ + iv: expect.any(String), + authTag: expect.any(String), + ciphertext: expect.any(String), + }); + expect(Buffer.from(blob.iv, 'base64').length).toBe(12); + }); + }); + + describe('when the same plaintext is encrypted twice', () => { + it('should use a fresh random IV so the ciphertexts differ', () => { + const cipher = createTokenCipher(KEY); + + expect(cipher.encrypt('same').ciphertext).not.toBe(cipher.encrypt('same').ciphertext); + }); + }); + + describe('when the blob is tampered with', () => { + it('should throw on a mutated authTag (GCM authentication)', () => { + const cipher = createTokenCipher(KEY); + const blob = cipher.encrypt('payload'); + const tampered = { ...blob, authTag: Buffer.alloc(16, 1).toString('base64') }; + + expect(() => cipher.decrypt(tampered)).toThrow(); + }); + + it('should throw on a mutated ciphertext', () => { + const cipher = createTokenCipher(KEY); + const blob = cipher.encrypt('payload'); + const flipped = Buffer.from(blob.ciphertext, 'base64'); + flipped[0] = (flipped[0] + 1) % 256; + const tampered = { ...blob, ciphertext: flipped.toString('base64') }; + + expect(() => cipher.decrypt(tampered)).toThrow(); + }); + }); + + describe('when a different key decrypts', () => { + it('should throw', () => { + const blob = createTokenCipher(KEY).encrypt('payload'); + const other = createTokenCipher(Buffer.alloc(32, 9).toString('base64')); + + expect(() => other.decrypt(blob)).toThrow(); + }); + }); +});