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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/agent-bff/src/api-key/agent-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { ResolvedApiKeyIdentity } from './api-key-client';

import jsonwebtoken from 'jsonwebtoken';

export const AGENT_TOKEN_EXPIRES_IN = '5m';

export interface IssueAgentTokenParams {
identity: ResolvedApiKeyIdentity;
authSecret: string;
}

function tagsToRecord(tags: { key: string; value: string }[]): Record<string, string> {
return tags.reduce((memo, { key, value }) => ({ ...memo, [key]: value }), {});
}

export function issueAgentToken({ identity, authSecret }: IssueAgentTokenParams): string {
const { user, renderingId } = identity;
const firstName = user.firstName ?? '';
const lastName = user.lastName ?? '';
const tags = tagsToRecord(user.tags);

// snake_case aliases: Ruby/Python agents splat JWT claims into Caller (snake_case kwargs).
return jsonwebtoken.sign(
{
id: user.id,
email: user.email,
Comment thread
Tonours marked this conversation as resolved.
firstName,
lastName,
team: user.team,
renderingId,
tags,
permissionLevel: user.permissionLevel,
first_name: firstName,
last_name: lastName,
rendering_id: renderingId,
permission_level: user.permissionLevel,
},
authSecret,
{ algorithm: 'HS256', expiresIn: AGENT_TOKEN_EXPIRES_IN },
);
}
95 changes: 95 additions & 0 deletions packages/agent-bff/src/api-key/api-key-authenticator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type ApiKeyClient from './api-key-client';
import type { ResolvedApiKeyIdentity } from './api-key-client';
import type { ApiKeyError } from './api-key-error';
import type { ResolveCache } from './resolve-cache';

import { issueAgentToken } from './agent-token';
import { hashApiKey, parseApiKey } from './api-key';
import { ApiKeyResolveError } from './api-key-client';
import {
forestIdentityNotAllowed,
invalidApiKey,
invalidRequest,
keyResolutionUnavailable,
} from './api-key-error';

const UNAVAILABLE_RETRY_AFTER_SECONDS = 5;
const NEGATIVE_CACHE_STATUSES = new Set([401, 403]);

export interface ApiKeyAuthenticatorOptions {
client: ApiKeyClient;
cache: ResolveCache;
authSecret: string;
}

export interface AuthenticatedApiKey {
agentToken: string;
identity: ResolvedApiKeyIdentity;
}

export interface ApiKeyAuthenticator {
authenticate(rawKey: string): Promise<AuthenticatedApiKey>;
}

function mapResolveError(error: ApiKeyResolveError): ApiKeyError {
if (error.unreachable) return keyResolutionUnavailable(UNAVAILABLE_RETRY_AFTER_SECONDS);

switch (error.status) {
case 401:
return invalidApiKey();
case 403:
return forestIdentityNotAllowed();
case 400:
return invalidRequest();
case 429:
return keyResolutionUnavailable(error.retryAfter ?? UNAVAILABLE_RETRY_AFTER_SECONDS);
default:
return keyResolutionUnavailable(UNAVAILABLE_RETRY_AFTER_SECONDS);
}
}

export default function createApiKeyAuthenticator({
client,
cache,
authSecret,
}: ApiKeyAuthenticatorOptions): ApiKeyAuthenticator {
function mint(identity: ResolvedApiKeyIdentity): AuthenticatedApiKey {
return { agentToken: issueAgentToken({ identity, authSecret }), identity };
}

return {
async authenticate(rawKey) {
const parsed = parseApiKey(rawKey);

if (!parsed) throw invalidApiKey();

const hash = hashApiKey(parsed.keyId, parsed.secret);

const cachedIdentity = cache.getPositive(hash);
if (cachedIdentity) return mint(cachedIdentity);

const cachedError = cache.getNegative(hash);
if (cachedError) throw cachedError;

let identity: ResolvedApiKeyIdentity;

try {
identity = await client.resolveApiKey(parsed);
} catch (error) {
if (error instanceof ApiKeyResolveError) {
const mapped = mapResolveError(error);
if (NEGATIVE_CACHE_STATUSES.has(mapped.status)) cache.setNegative(hash, mapped);

throw mapped;
}

throw error;
}

const authenticated = mint(identity);
cache.setPositive(hash, identity);
Comment thread
Tonours marked this conversation as resolved.

return authenticated;
},
};
}
131 changes: 131 additions & 0 deletions packages/agent-bff/src/api-key/api-key-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { ApiKeyResolveError } from './api-key-resolve-error';

export { ApiKeyResolveError } from './api-key-resolve-error';
export type { ApiKeyResolveErrorParams } from './api-key-resolve-error';

export interface ApiKeyIdentityUser {
id: number;
email: string;
firstName: string | null;
lastName: string | null;
team: string;
tags: { key: string; value: string }[];
permissionLevel: string;
}

export interface ResolvedApiKeyIdentity {
user: ApiKeyIdentityUser;
renderingId: number;
allowedOrigins: string[];
}

export interface ApiKeyClientOptions {
forestServerUrl: string;
envSecret: string;
}

const DEFAULT_HEADERS = { 'Content-Type': 'application/json' } as const;
const REQUEST_TIMEOUT_MS = 10_000;
const RESOLVE_PATH = '/liana/v1/bff-api-keys/resolve';

interface SaasErrorBody {
errors?: { name?: string; meta?: { code?: string } }[];
}

export default class ApiKeyClient {
Comment thread
Tonours marked this conversation as resolved.
private readonly forestServerUrl: string;
private readonly envSecret: string;

constructor({ forestServerUrl, envSecret }: ApiKeyClientOptions) {
this.forestServerUrl = forestServerUrl;
this.envSecret = envSecret;
}

async resolveApiKey(parsedKey: {
keyId: string;
secret: string;
}): Promise<ResolvedApiKeyIdentity> {
let response: Response;

try {
response = await fetch(this.url(RESOLVE_PATH), {
method: 'POST',
headers: { ...DEFAULT_HEADERS, 'forest-secret-key': this.envSecret },
body: JSON.stringify({ keyId: parsedKey.keyId, secret: parsedKey.secret }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch {
throw new ApiKeyResolveError({ unreachable: true });
}

if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as SaasErrorBody;
const firstError = body.errors?.[0];

throw new ApiKeyResolveError({
status: response.status,
code: firstError?.meta?.code,
name: firstError?.name,
retryAfter: ApiKeyClient.parseRetryAfter(response.headers.get('retry-after')),
});
}

let body: unknown;

try {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
body = await response.json();
} catch {
throw new ApiKeyResolveError({ unreachable: true });
}

// A well-formed HTTP 200 with an incomplete body is an unusable resolution, not a caller
// error: surface it as unavailable rather than letting a later `user` deref throw a 500.
if (!ApiKeyClient.isResolvedIdentity(body)) {
throw new ApiKeyResolveError({ unreachable: true });
}

return body;
}

private static isResolvedIdentity(body: unknown): body is ResolvedApiKeyIdentity {
Comment thread
Tonours marked this conversation as resolved.
if (typeof body !== 'object' || body === null) return false;

const candidate = body as { user?: unknown; renderingId?: unknown; allowedOrigins?: unknown };

return (
typeof candidate.renderingId === 'number' &&
Array.isArray(candidate.allowedOrigins) &&
ApiKeyClient.isIdentityUser(candidate.user)
);
}

// Every field the agent-token mint reads must be present, so a partial body fails here
// (mapped to unavailable) instead of minting a token with undefined claims later.
private static isIdentityUser(user: unknown): user is ApiKeyIdentityUser {
if (typeof user !== 'object' || user === null) return false;

const candidate = user as Record<string, unknown>;

return (
typeof candidate.id === 'number' &&
typeof candidate.email === 'string' &&
typeof candidate.team === 'string' &&
typeof candidate.permissionLevel === 'string' &&
Array.isArray(candidate.tags)
);
}

// Retry-After may be an HTTP-date rather than delay-seconds; keep only a positive integer so a
// date/negative/zero value falls back to the caller's default instead of a useless backoff hint.
private static parseRetryAfter(header: string | null): number | undefined {
if (header === null) return undefined;

const seconds = Number(header);
Comment thread
Tonours marked this conversation as resolved.

return Number.isInteger(seconds) && seconds > 0 ? seconds : undefined;
}

private url(path: string): string {
return new URL(path, this.forestServerUrl).toString();
}
}
50 changes: 50 additions & 0 deletions packages/agent-bff/src/api-key/api-key-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export type ApiKeyErrorType =
| 'invalid_api_key'
| 'forest_identity_not_allowed'
| 'invalid_request'
| 'key_resolution_unavailable';

export class ApiKeyError extends Error {
readonly status: number;
readonly type: ApiKeyErrorType;
readonly retryAfter?: number;

constructor(status: number, type: ApiKeyErrorType, message: string, retryAfter?: number) {
super(message);
this.name = 'ApiKeyError';
this.status = status;
this.type = type;
this.retryAfter = retryAfter;
}
}

export interface ApiKeyErrorBody {
error: {
type: ApiKeyErrorType;
status: number;
message: string;
};
}

export function toErrorBody(error: ApiKeyError): ApiKeyErrorBody {
return { error: { type: error.type, status: error.status, message: error.message } };
}

export function invalidApiKey(message = 'Invalid API key'): ApiKeyError {
return new ApiKeyError(401, 'invalid_api_key', message);
}

export function forestIdentityNotAllowed(message = 'Forest identity not allowed'): ApiKeyError {
return new ApiKeyError(403, 'forest_identity_not_allowed', message);
}

export function invalidRequest(message = 'Invalid request'): ApiKeyError {
return new ApiKeyError(400, 'invalid_request', message);
}

export function keyResolutionUnavailable(
retryAfter: number,
message = 'Key resolution unavailable',
): ApiKeyError {
return new ApiKeyError(503, 'key_resolution_unavailable', message, retryAfter);
}
69 changes: 69 additions & 0 deletions packages/agent-bff/src/api-key/api-key-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { ApiKeyAuthenticator, AuthenticatedApiKey } from './api-key-authenticator';
import type { Logger } from '../ports/logger-port';
import type { Context, Middleware } from 'koa';

import { fingerprintApiKey } from './api-key';
import { ApiKeyError, toErrorBody } from './api-key-error';

export const BFF_KEY_HEADER = 'X-Forest-Bff-Key';

export interface ApiKeyMiddlewareOptions {
authenticator: ApiKeyAuthenticator;
logger: Logger;
}

function writeError(ctx: Context, error: unknown, rawKey: string, logger: Logger): void {
if (error instanceof ApiKeyError) {
if (error.retryAfter !== undefined) ctx.set('Retry-After', String(error.retryAfter));
ctx.status = error.status;
ctx.body = toErrorBody(error);
logger('Warn', 'BFF API key rejected', {
keyHash: fingerprintApiKey(rawKey),
type: error.type,
});

return;
}

logger('Error', 'BFF API key middleware failure', {
keyHash: fingerprintApiKey(rawKey),
cause: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
});
ctx.status = 500;
ctx.body = { error: { type: 'server_error', status: 500, message: 'API key processing failed' } };
}

export default function createApiKeyMiddleware({
authenticator,
logger,
}: ApiKeyMiddlewareOptions): Middleware {
return async function apiKeyMiddleware(ctx, next) {
const rawKey = ctx.get(BFF_KEY_HEADER);

if (!rawKey) {
await next();

return;
}

let authenticated: AuthenticatedApiKey;

try {
authenticated = await authenticator.authenticate(rawKey);
} catch (error) {
writeError(ctx, error, rawKey, logger);

return;
}

ctx.state.agentToken = authenticated.agentToken;
ctx.state.apiKeyIdentity = authenticated.identity;
ctx.set('Cache-Control', 'no-store');
logger('Info', 'Resolved BFF API key', {
keyHash: fingerprintApiKey(rawKey),
renderingId: authenticated.identity.renderingId,
});

await next();
};
}
Loading
Loading