diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index a8e3b2397d..6e7d852483 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -18,7 +18,7 @@ import type { } from '@forestadmin/datasource-customizer'; import type { DataSource, DataSourceFactory } from '@forestadmin/datasource-toolkit'; import type { ForestSchema } from '@forestadmin/forestadmin-client'; -import type { ToolName } from '@forestadmin/mcp-server'; +import type { TokenTtlOptions, ToolName } from '@forestadmin/mcp-server'; import { DataSourceCustomizer } from '@forestadmin/datasource-customizer'; import bodyParser from '@koa/bodyparser'; @@ -57,6 +57,7 @@ export default class Agent extends FrameworkMounter private mcpEnabled = false; private mcpEnabledTools?: ToolName[]; private mcpBasePath?: string; + private mcpTokenTtl?: TokenTtlOptions; /** In-process workflow executor, created only when addWorkflowExecutor() is called. */ private embeddedExecutor: EmbeddedWorkflowExecutor | null = null; @@ -257,11 +258,20 @@ export default class Agent extends FrameworkMounter * // OAuth discovery metadata stays at the origin root (prefix-suffixed), so root `.well-known` * // traffic must still reach the agent. * agent.mountAiMcpServer({ basePath: '/ai' }); + * // Example: shorten the OAuth token lifetimes. `accessTokenSeconds` cannot exceed the 1h Forest + * // grants; `refreshTokenSeconds` bounds the time between two interactive logins, which is + * // otherwise unbounded since Forest re-grants its refresh lifetime on every refresh. + * agent.mountAiMcpServer({ tokenTtl: { accessTokenSeconds: 900, refreshTokenSeconds: 86400 } }); */ - mountAiMcpServer(options?: { enabledTools?: ToolName[]; basePath?: string }): this { + mountAiMcpServer(options?: { + enabledTools?: ToolName[]; + basePath?: string; + tokenTtl?: TokenTtlOptions; + }): this { this.mcpEnabled = true; this.mcpEnabledTools = options?.enabledTools; this.mcpBasePath = options?.basePath; + this.mcpTokenTtl = options?.tokenTtl; return this; } @@ -429,6 +439,7 @@ export default class Agent extends FrameworkMounter forestServerClient, enabledTools: this.mcpEnabledTools, basePath: this.mcpBasePath, + tokenTtl: this.mcpTokenTtl, agentDispatcher: this.getInProcessDispatcher(), }); diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index ffe113ff7b..64fcafc67d 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -591,6 +591,20 @@ describe('Agent', () => { expect(mcpServerSpy).toHaveBeenCalledWith(expect.objectContaining({ basePath: '/mcp' })); }); + test('should pass tokenTtl to ForestMCPServer', async () => { + const options = factories.forestAdminHttpDriverOptions.build(); + const agent = new Agent(options); + + agent.mountAiMcpServer({ tokenTtl: { accessTokenSeconds: 900, refreshTokenSeconds: 86400 } }); + await agent.start(); + + expect(mcpServerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + tokenTtl: { accessTokenSeconds: 900, refreshTokenSeconds: 86400 }, + }), + ); + }); + test('passes an in-process agentDispatcher to ForestMCPServer', async () => { const options = factories.forestAdminHttpDriverOptions.build(); const agent = new Agent(options); diff --git a/packages/mcp-server/CLAUDE.md b/packages/mcp-server/CLAUDE.md index 552db3aa83..518e2cf714 100644 --- a/packages/mcp-server/CLAUDE.md +++ b/packages/mcp-server/CLAUDE.md @@ -13,7 +13,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Key flows that only make sense across files: - **Per-request MCP server.** `handleMcpRequest` builds a *fresh* `McpServer` + `StreamableHTTPServerTransport` per request (`sessionIdGenerator: undefined`, stateless) and closes the transport on response end. Tool registration in `createMcpServer()` therefore re-runs on every call. -- **OAuth = pass-through to Forest Admin.** `ForestOAuthProvider` (`src/forest-oauth-provider.ts`) does not store tokens. `/oauth/authorize` redirects to the Forest app; `exchange*` calls relay to the Forest server's `/oauth/token`, then `generateAccessToken` re-signs a JWT with `authSecret` carrying the Forest token under the `serverToken` claim. `verifyAccessToken` verifies that JWT and builds `AuthInfo.extra` with `forestServerToken: decoded.serverToken` plus `environmentApiEndpoint` — the latter is **not** in the JWT, it's read from the provider's own `this.environmentApiEndpoint` field (set during env discovery). +- **OAuth = pass-through to Forest Admin.** `ForestOAuthProvider` (`src/forest-oauth-provider.ts`) does not store tokens. `/oauth/authorize` redirects to the Forest app; `exchange*` calls relay to the Forest server's `/oauth/token`, then `generateAccessToken` re-signs a JWT with `authSecret` carrying the Forest token under the `serverToken` claim. `verifyAccessToken` verifies that JWT and builds `AuthInfo.extra` with `forestServerToken: decoded.serverToken` plus `environmentApiEndpoint` — the latter is **not** in the JWT, it's read from the provider's own `this.environmentApiEndpoint` field (set during env discovery). Both lifetimes mirror the Forest token's own `exp`, optionally shortened by the `tokenTtl` option (`src/utils/token-ttl.ts`) — always a `min`, never a `max`, and capped at the point the TTL is computed so the advertised `expires_in` matches the signed JWT (except on the already-expired-upstream branch, which advertises 3600). The refresh cap is anchored on a `sessionStartedAt` claim carried across refreshes and never re-stamped: Forest grants a full refresh lifetime on every refresh, so a per-refresh cap would slide forever and never force a re-login. - **Tools call the live agent, not this server.** Each tool in `src/tools/*` is a `declareXxxTool(mcpServer, forestServerClient, logger, collectionNames)` factory. At call time `buildClient(extra)` (`src/utils/agent-caller.ts`) reads `extra.authInfo` (`forestServerToken` + `environmentApiEndpoint` from `AuthInfo.extra`) and builds a `createRemoteAgentClient` from `@forestadmin/agent-client` — i.e. the tool RPCs into the user's actual running agent. `forestServerClient` (`src/http-client`, wrapping `@forestadmin/forestadmin-client`'s `SchemaService`/`ActivityLogsService`) is used only for schema fetch and activity logging, not data. - **Two cross-cutting wrappers, always used together.** `registerToolWithLogging` (`src/utils/tool-with-logging.ts`) registers the tool and converts thrown errors into `{ isError: true }` results (per MCP spec) instead of protocol errors. Inside the handler, `withActivityLog` (`src/utils/with-activity-log.ts`) brackets the operation with a pending→succeeded/failed Forest activity log and runs `parseAgentError` + optional `errorEnhancer` (e.g. `list` appends sortable field names on "Invalid sort"). - **`collectionNames` → `z.enum`.** `fetchCollectionNames()` populates the schema's collection list; tools turn it into a `z.enum` for `collectionName` so the LLM gets autocomplete/validation. If schema fetch fails the server logs a warning and runs "degraded" with `z.string()`. diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index e2a2faf975..a8ad563e7c 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -64,6 +64,8 @@ yarn start:dev # Development (loads .env file automatically) | `MCP_SERVER_PORT` | No | `3931` | Port for the HTTP server | | `FOREST_MCP_ENABLED_TOOLS` | No | - | Comma-separated list of tools to enable (allowlist) | | `FOREST_AGENT_URL` | No | your environment's back-end URL | URL the MCP server uses to reach the back-end's data layer. Set it when the server runs next to a self-hosted back-end at an internal address (e.g. `http://localhost:3310`), instead of the public URL registered in Forest | +| `FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS` | No | `3600` (1 hour) | Maximum lifetime of the OAuth access tokens the server issues (`tokenTtl.accessTokenSeconds`). Minimum `60` | +| `FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS` | No | unbounded | Maximum time between two interactive logins (`tokenTtl.refreshTokenSeconds`). Unset, a client that keeps refreshing never signs in again. Minimum `60` | #### Example Configuration @@ -109,6 +111,33 @@ When `enabledTools` is not set, all tools are enabled by default. See [Available Tools](#available-tools) for the full list. `describeCollection` is always enabled as it is required for the MCP server to function properly. +## Shorten Token Lifetimes + +Forest grants **1 hour** (3600s) for an access token and **8 days** (691200s) for a refresh token — but it re-grants those 8 days on *every* refresh, so without `refreshTokenSeconds` a client that keeps working is never asked to sign in again. + +**Both values are upper bounds: they can only shorten that, never extend it.** For `accessTokenSeconds` a value above 3600 therefore has no effect. `refreshTokenSeconds` bounds the whole session, which Forest otherwise re-extends on every refresh, so any value shortens it however large it is. + +```typescript +// With Forest Agent +agent.mountAiMcpServer({ + tokenTtl: { accessTokenSeconds: 900, refreshTokenSeconds: 86400 }, +}); +``` + +```bash +# Standalone +export FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS=900 +export FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS=86400 +npx forest-mcp-server +``` + +The two settings differ in what the user notices: + +- `accessTokenSeconds` shortens how long a leaked access token can drive **this server**: the MCP path closes, its scopes stop applying and its calls stop being audited. It does **not** shorten the Forest token carried inside that JWT — the JWT is signed, not encrypted, so treat a leak as a Forest token leak and revoke at the source. It is transparent to users — the assistant silently obtains a new one. +- `refreshTokenSeconds` bounds the time between two **interactive logins**: once it elapses, the assistant can no longer refresh and the user signs in through the browser again. It is measured from the login itself, so an active assistant cannot keep extending its session. Refresh tokens issued before you enabled the option carry no login timestamp, so their window is measured from their last refresh instead — one longer session each, then bounded. + +The minimum for either value is 60 seconds; anything lower is raised to it. An invalid value (zero, negative, fractional) fails at startup rather than silently leaving the tokens uncapped. + ## API Endpoints Once running, the MCP server exposes the following endpoints: diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index 16dc12e1dd..ec5d4832ff 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -3,6 +3,8 @@ import ForestMCPServer from './server'; import parseToolList from './utils/parse-tool-list'; +const toSeconds = (value?: string) => (value === undefined ? undefined : Number(value)); + // Start the server when run directly as CLI const server = new ForestMCPServer({ forestServerUrl: process.env.FOREST_SERVER_URL || 'https://api.forestadmin.com', @@ -11,6 +13,11 @@ const server = new ForestMCPServer({ authSecret: process.env.FOREST_AUTH_SECRET, enabledTools: parseToolList(process.env.FOREST_MCP_ENABLED_TOOLS), agentUrl: process.env.FOREST_AGENT_URL, + // normalizeTokenTtl rejects NaN and non-positive values, so a bad variable fails at startup. + tokenTtl: { + accessTokenSeconds: toSeconds(process.env.FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS), + refreshTokenSeconds: toSeconds(process.env.FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS), + }, }); server.run().catch(error => { diff --git a/packages/mcp-server/src/forest-oauth-provider.ts b/packages/mcp-server/src/forest-oauth-provider.ts index a351cd8811..a76e2d6edf 100644 --- a/packages/mcp-server/src/forest-oauth-provider.ts +++ b/packages/mcp-server/src/forest-oauth-provider.ts @@ -1,4 +1,5 @@ import type { Logger } from './server'; +import type { TokenTtlOptions } from './utils/token-ttl'; import type { ForestAdminClient } from '@forestadmin/forestadmin-client'; import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js'; import type { @@ -20,9 +21,29 @@ import { InvalidGrantError, InvalidRequestError, InvalidTokenError, + OAuthError, UnsupportedTokenTypeError, } from '@modelcontextprotocol/sdk/server/auth/errors.js'; import jsonwebtoken from 'jsonwebtoken'; +import { z } from 'zod'; + +import { sessionRemainingSeconds } from './utils/token-ttl'; + +const DecodedRefreshTokenSchema = z.object({ + type: z.literal('refresh'), + clientId: z.string(), + userId: z.number(), + renderingId: z.number(), + serverRefreshToken: z.string(), + sessionStartedAt: z.number().optional(), + iat: z.number().optional(), +}); + +// `sessionStartedAt` is required on the write side so dropping or renaming it — in the schema or in +// the signed payload — is a compile error, not a silent fallback to `iat`, which is re-stamped on +// every rotation and would slide the window forever. +type RefreshTokenClaims = Omit, 'iat'> & + Required, 'sessionStartedAt'>>; export interface ForestOAuthProviderOptions { forestServerUrl: string; @@ -32,6 +53,7 @@ export interface ForestOAuthProviderOptions { logger: Logger; // Pre-normalized by the caller (ForestMCPServer); not re-validated here. agentUrl?: string; + tokenTtl?: TokenTtlOptions; } /** @@ -46,6 +68,8 @@ export default class ForestOAuthProvider implements OAuthServerProvider { private environmentId?: number; private environmentApiEndpoint?: string; private agentUrl?: string; + private tokenTtl?: TokenTtlOptions; + private warnedAccessCapInert = false; private logger: Logger; constructor({ @@ -55,6 +79,7 @@ export default class ForestOAuthProvider implements OAuthServerProvider { authSecret, logger, agentUrl, + tokenTtl, }: ForestOAuthProviderOptions) { this.forestServerUrl = forestServerUrl; this.forestAppUrl = forestAppUrl; @@ -62,6 +87,7 @@ export default class ForestOAuthProvider implements OAuthServerProvider { this.authSecret = authSecret; this.logger = logger; this.agentUrl = agentUrl; + this.tokenTtl = tokenTtl; this.forestClient = createForestAdminClient({ forestServerUrl: this.forestServerUrl, envSecret: this.envSecret, @@ -204,15 +230,23 @@ export default class ForestOAuthProvider implements OAuthServerProvider { codeVerifier?: string, redirectUri?: string, ): Promise { + const sessionStartedAt = Math.floor(Date.now() / 1000); + try { - return await this.generateAccessToken(client, { - grant_type: 'authorization_code', - code: authorizationCode, - redirect_uri: redirectUri, - client_id: client.client_id, - code_verifier: codeVerifier, - }); + return await this.generateAccessToken( + client, + { + grant_type: 'authorization_code', + code: authorizationCode, + redirect_uri: redirectUri, + client_id: client.client_id, + code_verifier: codeVerifier, + }, + sessionStartedAt, + ); } catch (error) { + if (error instanceof OAuthError) throw error; + const message = error instanceof Error ? error.message : String(error); throw new InvalidRequestError(`Failed to exchange authorization code: ${message}`); } @@ -224,16 +258,10 @@ export default class ForestOAuthProvider implements OAuthServerProvider { scopes?: string[], ): Promise { // Verify and decode the refresh token - let decoded: { - type: string; - clientId: string; - userId: number; - renderingId: number; - serverRefreshToken: string; - }; + let verified: unknown; try { - decoded = jsonwebtoken.verify(refreshToken, this.authSecret) as typeof decoded; + verified = jsonwebtoken.verify(refreshToken, this.authSecret); } catch (error) { if (error instanceof jsonwebtoken.TokenExpiredError) { this.logger( @@ -253,25 +281,56 @@ export default class ForestOAuthProvider implements OAuthServerProvider { throw new InvalidGrantError('Invalid refresh token'); } - // Validate token type - if (decoded.type !== 'refresh') { + const parsed = DecodedRefreshTokenSchema.safeParse(verified); + + if (!parsed.success) { + this.logger( + 'Error', + `[ForestOAuthProvider] Unexpected refresh token payload: ${parsed.error.message}`, + ); throw new UnsupportedTokenTypeError('Invalid token type'); } + const decoded = parsed.data; + // Validate client_id matches if (decoded.clientId !== client.client_id) { throw new InvalidClientError('Token was not issued to this client'); } + const nowInSeconds = Math.floor(Date.now() / 1000); + const sessionStartedAt = decoded.sessionStartedAt ?? decoded.iat ?? nowInSeconds; + const { refreshTokenSeconds } = this.tokenTtl ?? {}; + + // Checked again after the Forest round trips; this one only spares them when already expired. + if (sessionRemainingSeconds({ refreshTokenSeconds, sessionStartedAt, nowInSeconds }) <= 0) { + // Info, not Error: a session reaching its limit is this option working. + this.logger( + 'Info', + `[ForestOAuthProvider] Session reached tokenTtl.refreshTokenSeconds=${refreshTokenSeconds}, ` + + `re-authentication required: sessionStartedAt=${sessionStartedAt}, ` + + `clientId=${client.client_id}, userId=${decoded.userId}`, + ); + throw new InvalidGrantError('Refresh token has expired'); + } + // Exchange the Forest refresh token for new tokens try { - return await this.generateAccessToken(client, { - grant_type: 'refresh_token', - refresh_token: decoded.serverRefreshToken, - client_id: client.client_id, - scopes, - }); + return await this.generateAccessToken( + client, + { + grant_type: 'refresh_token', + refresh_token: decoded.serverRefreshToken, + client_id: client.client_id, + scopes, + }, + sessionStartedAt, + ); } catch (error) { + // Preserve the OAuth code: the client only re-authenticates on invalid_grant (and + // invalid_client), and invalid_request is the one code it treats as unrecoverable. + if (error instanceof OAuthError) throw error; + const message = error instanceof Error ? error.message : String(error); const errorName = error instanceof Error ? error.name : 'Unknown'; this.logger( @@ -283,10 +342,25 @@ export default class ForestOAuthProvider implements OAuthServerProvider { } } + private warnIfAccessCapIsInert(capSeconds: number | undefined, upstreamTtlSeconds: number): void { + if (capSeconds === undefined || upstreamTtlSeconds <= 0) return; + if (capSeconds <= upstreamTtlSeconds || this.warnedAccessCapInert) return; + + this.warnedAccessCapInert = true; + this.logger( + 'Warn', + `[ForestOAuthProvider] tokenTtl.accessTokenSeconds=${capSeconds} exceeds the ` + + `${upstreamTtlSeconds}s lifetime granted by Forest and has no effect: this option can ` + + `only shorten a token lifetime, never extend it.`, + ); + } + private async generateAccessToken( client: OAuthClientInformationFull, tokenPayload: Record, + sessionStartedAt: number, ): Promise { + const requestStartedAt = Math.floor(Date.now() / 1000); const response = await fetch(`${this.forestServerUrl}/oauth/token`, { method: 'POST', headers: { @@ -314,15 +388,16 @@ export default class ForestOAuthProvider implements OAuthServerProvider { }; // Get updated user info + // Cast, not parsed: malformed shapes already fail loudly (missing `meta` throws here, missing + // `exp` makes sign() reject the NaN), and a stricter schema risks refusing a valid token. const decodedAccessToken = jsonwebtoken.decode(forestServerAccessToken) as { meta: { renderingId: number }; exp: number; - iat: number; scope: string; } | null; if (!decodedAccessToken) { - throw new Error('Failed to decode access token from Forest Admin server'); + throw new Error('Failed to decode access token from Forest'); } const { @@ -333,11 +408,10 @@ export default class ForestOAuthProvider implements OAuthServerProvider { const decodedRefreshToken = jsonwebtoken.decode(forestServerRefreshToken) as { exp: number; - iat: number; } | null; if (!decodedRefreshToken) { - throw new Error('Failed to decode refresh token from Forest Admin server'); + throw new Error('Failed to decode refresh token from Forest'); } const { exp: refreshTokenExpirationDate } = decodedRefreshToken; @@ -346,8 +420,36 @@ export default class ForestOAuthProvider implements OAuthServerProvider { forestServerAccessToken, ); - // Create new access token - const expiresIn = expirationDate - Math.floor(Date.now() / 1000); + const nowInSeconds = Math.floor(Date.now() / 1000); + // Only the access cap can be inert. `refreshTokenSeconds` bounds the whole session, which Forest + // otherwise re-extends on every refresh, so any value binds however large it is. + this.warnIfAccessCapIsInert(this.tokenTtl?.accessTokenSeconds, expirationDate - nowInSeconds); + + // Re-read after the Forest round trips, which can outlast the session. Bounds the access token + // too, which would otherwise extend the session by its own lifetime. + const sessionRemaining = sessionRemainingSeconds({ + refreshTokenSeconds: this.tokenTtl?.refreshTokenSeconds, + sessionStartedAt, + nowInSeconds, + }); + + if (sessionRemaining <= 0) { + // The pre-flight check passed, so the round trips consumed the last of the window. + this.logger( + 'Warn', + `[ForestOAuthProvider] Session lapsed while calling Forest: ` + + `sessionStartedAt=${sessionStartedAt}, ` + + `roundTripSeconds=${nowInSeconds - requestStartedAt}`, + ); + throw new InvalidGrantError('Refresh token has expired'); + } + + // Computed before signing so the `expires_in` returned below reports the same value. + const expiresIn = Math.min( + expirationDate - nowInSeconds, + sessionRemaining, + this.tokenTtl?.accessTokenSeconds ?? Infinity, + ); const tokenScopes = scope ? scope.split(' ') : ['mcp:read', 'mcp:write', 'mcp:action']; const accessToken = jsonwebtoken.sign( { @@ -363,21 +465,22 @@ export default class ForestOAuthProvider implements OAuthServerProvider { ); // Create new refresh token (token rotation for security) - const refreshToken = jsonwebtoken.sign( - { - type: 'refresh', - clientId: client.client_id, - userId: user.id, - renderingId, - serverRefreshToken: forestServerRefreshToken, - }, - this.authSecret, - { expiresIn: refreshTokenExpirationDate - Math.floor(Date.now() / 1000) }, - ); + const refreshClaims: RefreshTokenClaims = { + type: 'refresh', + clientId: client.client_id, + userId: user.id, + renderingId, + serverRefreshToken: forestServerRefreshToken, + sessionStartedAt, + }; + const refreshToken = jsonwebtoken.sign(refreshClaims, this.authSecret, { + expiresIn: Math.min(refreshTokenExpirationDate - nowInSeconds, sessionRemaining), + }); return { access_token: accessToken, token_type: 'Bearer', + // Only reachable when the Forest token is already expired; a cap cannot reach zero. expires_in: expiresIn > 0 ? expiresIn : 3600, refresh_token: refreshToken, scope: scope || client.scope, diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 15af70f4b8..164da65311 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -1,6 +1,7 @@ // Library exports only - no side effects export { default as ForestMCPServer } from './server'; export type { ForestMCPServerOptions, HttpCallback, ToolName } from './server'; +export type { TokenTtlOptions } from './utils/token-ttl'; export type { InProcessAgentDispatcher, InProcessDispatchRequest, diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 449ba3a9c3..7510bda4d4 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -5,6 +5,7 @@ import './polyfills'; import type { ForestServerClient } from './http-client'; import type { InProcessAgentDispatcher } from './in-process-agent-dispatcher'; import type { ToolContext } from './tool-context'; +import type { TokenTtlOptions } from './utils/token-ttl'; import type { Express } from 'express'; import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize.js'; @@ -38,6 +39,7 @@ import declareUpdateTool from './tools/update'; import normalizeAgentUrl from './utils/normalize-agent-url'; import { fetchForestSchema, getCollectionNames } from './utils/schema-fetcher'; import interceptResponseForErrorLogging from './utils/sse-error-logger'; +import normalizeTokenTtl from './utils/token-ttl'; import { NAME, VERSION } from './version'; export const LOGO_URL = 'https://forest-assets.s3.us-east-1.amazonaws.com/logo-green.png'; @@ -139,6 +141,13 @@ export interface ForestMCPServerOptions { * instead of reaching its public `api_endpoint` over HTTP. */ agentDispatcher?: InProcessAgentDispatcher; + /** + * Upper bounds on the OAuth token lifetimes this server issues. Forest grants 1 hour + * (3600s) for an access token and 8 days (691200s) for a refresh token; each value can only + * shorten that, never extend it. `refreshTokenSeconds` bounds the time between two interactive + * logins. Minimum 60s for either. + */ + tokenTtl?: TokenTtlOptions; } /** @@ -163,6 +172,7 @@ export default class ForestMCPServer { private basePath: string; private agentUrl?: string; private agentDispatcher?: InProcessAgentDispatcher; + private tokenTtl?: TokenTtlOptions; constructor(options?: ForestMCPServerOptions) { this.forestServerUrl = options?.forestServerUrl || 'https://api.forestadmin.com'; @@ -174,6 +184,7 @@ export default class ForestMCPServer { this.basePath = normalizeMountPath(options?.basePath); this.agentUrl = normalizeAgentUrl(options?.agentUrl); this.agentDispatcher = options?.agentDispatcher; + this.tokenTtl = normalizeTokenTtl(options?.tokenTtl, this.logger); // Use injected forestServerClient or create default this.forestServerClient = options?.forestServerClient ?? this.createDefaultForestServerClient(); @@ -430,6 +441,7 @@ export default class ForestMCPServer { authSecret, logger: this.logger, agentUrl: this.agentUrl, + tokenTtl: this.tokenTtl, }); await oauthProvider.initialize(); diff --git a/packages/mcp-server/src/utils/token-ttl.ts b/packages/mcp-server/src/utils/token-ttl.ts new file mode 100644 index 0000000000..528a01fa59 --- /dev/null +++ b/packages/mcp-server/src/utils/token-ttl.ts @@ -0,0 +1,87 @@ +import type { Logger } from '../server'; + +// The MCP SDK rate-limits /oauth/token to 50 requests per 15 minutes (one per 18s) per IP and +// server.ts does not override it. 60s leaves margin for clients sharing an egress IP. +export const MIN_TOKEN_TTL_SECONDS = 60; + +export type TokenTtlOptions = { + /** Caps the access token lifetime. Forest grants 3600s (1 hour). */ + accessTokenSeconds?: number; + /** Caps the time between two interactive logins. Forest grants 691200s (8 days). */ + refreshTokenSeconds?: number; +}; + +function normalizeSeconds( + field: keyof TokenTtlOptions, + value: number | undefined, + logger: Logger, +): number | undefined { + if (value === undefined) return undefined; + + if (!Number.isInteger(value) || value <= 0) { + throw new Error( + `Invalid tokenTtl.${field} "${value}": it must be a positive integer number of seconds.`, + ); + } + + if (value < MIN_TOKEN_TTL_SECONDS) { + logger( + 'Warn', + `raising tokenTtl.${field} from ${value} to the ${MIN_TOKEN_TTL_SECONDS}s minimum`, + ); + + return MIN_TOKEN_TTL_SECONDS; + } + + return value; +} + +export default function normalizeTokenTtl( + tokenTtl: TokenTtlOptions | undefined, + logger: Logger, +): TokenTtlOptions | undefined { + const accessTokenSeconds = normalizeSeconds( + 'accessTokenSeconds', + tokenTtl?.accessTokenSeconds, + logger, + ); + const refreshTokenSeconds = normalizeSeconds( + 'refreshTokenSeconds', + tokenTtl?.refreshTokenSeconds, + logger, + ); + + if (accessTokenSeconds === undefined && refreshTokenSeconds === undefined) return undefined; + + // Legal — the session bound wins — but almost always the two values swapped. + if ( + accessTokenSeconds !== undefined && + refreshTokenSeconds !== undefined && + refreshTokenSeconds < accessTokenSeconds + ) { + logger( + 'Warn', + `tokenTtl.refreshTokenSeconds=${refreshTokenSeconds} is shorter than ` + + `tokenTtl.accessTokenSeconds=${accessTokenSeconds}: users will re-authenticate every ` + + `${refreshTokenSeconds}s. Did you swap the two values?`, + ); + } + + return { accessTokenSeconds, refreshTokenSeconds }; +} + +// Anchored on the interactive login, not on the current refresh: Forest grants a full refresh +// lifetime on every refresh, so a per-refresh bound would slide forever and never force a re-login. +export function sessionRemainingSeconds({ + refreshTokenSeconds, + sessionStartedAt, + nowInSeconds, +}: { + refreshTokenSeconds?: number; + sessionStartedAt: number; + nowInSeconds: number; +}): number { + if (refreshTokenSeconds === undefined) return Number.POSITIVE_INFINITY; + + return sessionStartedAt + refreshTokenSeconds - nowInSeconds; +} diff --git a/packages/mcp-server/test/forest-oauth-provider.test.ts b/packages/mcp-server/test/forest-oauth-provider.test.ts index cd11f7cdfb..ed0bb58ea9 100644 --- a/packages/mcp-server/test/forest-oauth-provider.test.ts +++ b/packages/mcp-server/test/forest-oauth-provider.test.ts @@ -1,3 +1,5 @@ +import type { Logger } from '../src/server'; +import type { TokenTtlOptions } from '../src/utils/token-ttl'; import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth'; import type { Response } from 'express'; @@ -20,14 +22,20 @@ const TEST_ENV_SECRET = 'test-env-secret'; const TEST_AUTH_SECRET = 'test-auth-secret'; const TEST_FOREST_APP_URL = 'https://app.forestadmin.com'; -function createProvider(forestServerUrl = 'https://api.forestadmin.com', agentUrl?: string) { +function createProvider( + forestServerUrl = 'https://api.forestadmin.com', + agentUrl?: string, + tokenTtl?: TokenTtlOptions, + logger: Logger = console.info, +) { return new ForestOAuthProvider({ forestServerUrl, forestAppUrl: TEST_FOREST_APP_URL, envSecret: TEST_ENV_SECRET, authSecret: TEST_AUTH_SECRET, - logger: console.info, + logger, agentUrl, + tokenTtl, }); } @@ -786,7 +794,7 @@ describe('ForestOAuthProvider', () => { ).rejects.toThrow('Token was not issued to this client'); }); - it('should throw error when Forest Admin refresh fails', async () => { + it('propagates the OAuth code when Forest rejects the refresh', async () => { (jsonwebtoken.verify as jest.Mock).mockReturnValue({ type: 'refresh', clientId: 'test-client-id', @@ -802,7 +810,7 @@ describe('ForestOAuthProvider', () => { await expect( provider.exchangeRefreshToken(mockClient, 'valid-refresh-token'), - ).rejects.toThrow('Failed to refresh token'); + ).rejects.toMatchObject({ errorCode: 'invalid_grant' }); }); }); @@ -955,4 +963,357 @@ describe('ForestOAuthProvider', () => { }); }); }); + + describe('tokenTtl', () => { + const NOW_MS = Date.UTC(2026, 0, 1); + const nowInSeconds = Math.floor(NOW_MS / 1000); + const FOREST_ACCESS_TTL = 3600; + const FOREST_REFRESH_TTL = 604800; + + let mockClient: OAuthClientInformationFull; + + function primeForestTokens() { + mockJwtDecode + .mockReturnValueOnce({ + meta: { renderingId: 456 }, + exp: nowInSeconds + FOREST_ACCESS_TTL, + iat: nowInSeconds, + scope: 'mcp:read', + }) + .mockReturnValueOnce({ + exp: nowInSeconds + FOREST_REFRESH_TTL, + iat: nowInSeconds, + }); + mockJwtSign.mockReturnValue('mocked-jwt-token'); + + mockServer + .get('/liana/environment', { + data: { id: '12345', attributes: { api_endpoint: 'https://api.example.com' } }, + }) + .post('/oauth/token', { + access_token: 'forest-access-token', + refresh_token: 'forest-refresh-token', + expires_in: FOREST_ACCESS_TTL, + token_type: 'Bearer', + scope: 'mcp:read', + }); + global.fetch = mockServer.fetch; + } + + const signedTtl = (nthCall: number) => mockJwtSign.mock.calls[nthCall - 1][2].expiresIn; + const signedPayload = (nthCall: number) => mockJwtSign.mock.calls[nthCall - 1][0]; + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(NOW_MS); + + mockClient = { + client_id: 'test-client-id', + redirect_uris: ['https://example.com/callback'], + scope: 'mcp:read mcp:write', + } as OAuthClientInformationFull; + + mockCreateForestAdminClient.mockReturnValue({ + authService: { + getUserInfo: jest.fn().mockResolvedValue({ + id: 123, + email: 'user@example.com', + firstName: 'Test', + lastName: 'User', + team: 'Operations', + role: 'Admin', + tags: {}, + renderingId: 456, + permissionLevel: 'admin', + }), + }, + } as unknown as ReturnType); + + primeForestTokens(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('shortens both token lifetimes to the configured caps', async () => { + const provider = createProvider(undefined, undefined, { + accessTokenSeconds: 900, + refreshTokenSeconds: 86400, + }); + + const result = await provider.exchangeAuthorizationCode(mockClient, 'auth-code-123'); + + expect(signedTtl(1)).toBe(900); + expect(signedTtl(2)).toBe(86400); + expect(result.expires_in).toBe(900); + }); + + it('never extends beyond the lifetimes Forest granted', async () => { + const provider = createProvider(undefined, undefined, { + accessTokenSeconds: 7200, + refreshTokenSeconds: 30 * 24 * 3600, + }); + + const result = await provider.exchangeAuthorizationCode(mockClient, 'auth-code-123'); + + expect(signedTtl(1)).toBe(FOREST_ACCESS_TTL); + expect(signedTtl(2)).toBe(FOREST_REFRESH_TTL); + expect(result.expires_in).toBe(FOREST_ACCESS_TTL); + }); + + it('warns when the access cap exceeds the lifetime Forest granted', async () => { + const logger = jest.fn(); + const provider = createProvider(undefined, undefined, { accessTokenSeconds: 7200 }, logger); + + await provider.exchangeAuthorizationCode(mockClient, 'auth-code-123'); + + expect(logger).toHaveBeenCalledWith( + 'Warn', + expect.stringContaining(`tokenTtl.accessTokenSeconds=7200 exceeds the 3600s`), + ); + }); + + it('stays silent when the refresh cap exceeds one Forest refresh lifetime', async () => { + const logger = jest.fn(); + const provider = createProvider( + undefined, + undefined, + { refreshTokenSeconds: 30 * 24 * 3600 }, + logger, + ); + + await provider.exchangeAuthorizationCode(mockClient, 'auth-code-123'); + + expect(logger).not.toHaveBeenCalled(); + }); + + it('warns once per field instead of on every token issuance', async () => { + const logger = jest.fn(); + const provider = createProvider(undefined, undefined, { accessTokenSeconds: 7200 }, logger); + + await provider.exchangeAuthorizationCode(mockClient, 'auth-code-123'); + primeForestTokens(); + await provider.exchangeAuthorizationCode(mockClient, 'auth-code-456'); + + expect(logger).toHaveBeenCalledTimes(1); + }); + + it('stays silent when the caps are effective', async () => { + const logger = jest.fn(); + const provider = createProvider( + undefined, + undefined, + { accessTokenSeconds: 900, refreshTokenSeconds: 86400 }, + logger, + ); + + await provider.exchangeAuthorizationCode(mockClient, 'auth-code-123'); + + expect(logger).not.toHaveBeenCalled(); + }); + + it('stays silent when the Forest token is already expired', async () => { + const logger = jest.fn(); + mockJwtDecode.mockReset(); + mockJwtDecode + .mockReturnValueOnce({ + meta: { renderingId: 456 }, + exp: nowInSeconds - 10, + iat: nowInSeconds - 3610, + scope: 'mcp:read', + }) + .mockReturnValueOnce({ exp: nowInSeconds + FOREST_REFRESH_TTL, iat: nowInSeconds }); + const provider = createProvider(undefined, undefined, { accessTokenSeconds: 7200 }, logger); + + await provider.exchangeAuthorizationCode(mockClient, 'auth-code-123'); + + expect(logger).not.toHaveBeenCalled(); + }); + + it('leaves both lifetimes untouched when no cap is configured', async () => { + const provider = createProvider(); + + const result = await provider.exchangeAuthorizationCode(mockClient, 'auth-code-123'); + + expect(signedTtl(1)).toBe(FOREST_ACCESS_TTL); + expect(signedTtl(2)).toBe(FOREST_REFRESH_TTL); + expect(result.expires_in).toBe(FOREST_ACCESS_TTL); + }); + + it('stamps the session start on the refresh token issued at login', async () => { + const provider = createProvider(undefined, undefined, { refreshTokenSeconds: 86400 }); + + await provider.exchangeAuthorizationCode(mockClient, 'auth-code-123'); + + expect(signedPayload(2)).toEqual( + expect.objectContaining({ type: 'refresh', sessionStartedAt: nowInSeconds }), + ); + }); + + it('anchors on the stamped session start, not on the refresh token issue date', async () => { + (jsonwebtoken.verify as jest.Mock).mockReturnValue({ + type: 'refresh', + clientId: 'test-client-id', + userId: 123, + renderingId: 456, + serverRefreshToken: 'forest-refresh-token', + sessionStartedAt: nowInSeconds - 80000, + iat: nowInSeconds - 300, + }); + const provider = createProvider(undefined, undefined, { refreshTokenSeconds: 86400 }); + + await provider.exchangeRefreshToken(mockClient, 'our-refresh-token'); + + expect(signedTtl(2)).toBe(6400); + expect(signedPayload(2)).toEqual( + expect.objectContaining({ sessionStartedAt: nowInSeconds - 80000 }), + ); + }); + + it('keeps the access token inside the session window', async () => { + (jsonwebtoken.verify as jest.Mock).mockReturnValue({ + type: 'refresh', + clientId: 'test-client-id', + userId: 123, + renderingId: 456, + serverRefreshToken: 'forest-refresh-token', + sessionStartedAt: nowInSeconds - 86340, + }); + const provider = createProvider(undefined, undefined, { refreshTokenSeconds: 86400 }); + + const result = await provider.exchangeRefreshToken(mockClient, 'our-refresh-token'); + + expect(signedTtl(1)).toBe(60); + expect(result.expires_in).toBe(60); + }); + + it('rejects when the session elapses while talking to Forest', async () => { + (jsonwebtoken.verify as jest.Mock).mockReturnValue({ + type: 'refresh', + clientId: 'test-client-id', + userId: 123, + renderingId: 456, + serverRefreshToken: 'forest-refresh-token', + sessionStartedAt: nowInSeconds - 86399, + }); + const provider = createProvider(undefined, undefined, { refreshTokenSeconds: 86400 }); + + const pending = provider.exchangeRefreshToken(mockClient, 'our-refresh-token'); + jest.setSystemTime(NOW_MS + 5000); + + await expect(pending).rejects.toMatchObject({ errorCode: 'invalid_grant' }); + expect(mockJwtSign).not.toHaveBeenCalled(); + }); + + it('rejects a refresh once the session window has elapsed, without calling Forest', async () => { + (jsonwebtoken.verify as jest.Mock).mockReturnValue({ + type: 'refresh', + clientId: 'test-client-id', + userId: 123, + renderingId: 456, + serverRefreshToken: 'forest-refresh-token', + sessionStartedAt: nowInSeconds - 90000, + }); + const provider = createProvider(undefined, undefined, { refreshTokenSeconds: 86400 }); + + await expect( + provider.exchangeRefreshToken(mockClient, 'our-refresh-token'), + ).rejects.toMatchObject({ errorCode: 'invalid_grant' }); + expect(mockServer.fetch).not.toHaveBeenCalledWith( + expect.stringContaining('/oauth/token'), + expect.anything(), + ); + }); + + it('falls back to the issue date for a refresh token minted before the anchor existed', async () => { + (jsonwebtoken.verify as jest.Mock).mockReturnValue({ + type: 'refresh', + clientId: 'test-client-id', + userId: 123, + renderingId: 456, + serverRefreshToken: 'forest-refresh-token', + iat: nowInSeconds - 1000, + }); + const provider = createProvider(undefined, undefined, { refreshTokenSeconds: 86400 }); + + await provider.exchangeRefreshToken(mockClient, 'our-refresh-token'); + + expect(signedTtl(2)).toBe(85400); + }); + + it('accepts a Forest access token whose renderingId is a string', async () => { + mockJwtDecode.mockReset(); + mockJwtDecode + .mockReturnValueOnce({ + meta: { renderingId: '456' }, + exp: nowInSeconds + FOREST_ACCESS_TTL, + scope: 'mcp:read', + }) + .mockReturnValueOnce({ exp: nowInSeconds + FOREST_REFRESH_TTL }); + const provider = createProvider(); + + const result = await provider.exchangeAuthorizationCode(mockClient, 'auth-code-123'); + + expect(result.expires_in).toBe(FOREST_ACCESS_TTL); + expect(signedPayload(1)).toEqual(expect.objectContaining({ rendering_id: '456' })); + }); + + it.each([ + ['access', [null, { exp: 1_000_000 }], /Failed to decode access token from Forest/], + [ + 'refresh', + [{ meta: { renderingId: 456 }, exp: 1_000_000, scope: 'mcp:read' }, null], + /Failed to decode refresh token from Forest/, + ], + ])( + 'throws when the Forest %s token is not a decodable JWT', + async (_label, decoded, message) => { + mockJwtDecode.mockReset(); + mockJwtDecode.mockReturnValueOnce(decoded[0]).mockReturnValueOnce(decoded[1]); + const provider = createProvider(); + + await expect( + provider.exchangeAuthorizationCode(mockClient, 'auth-code-123'), + ).rejects.toThrow(message); + }, + ); + + it('rejects our refresh token when the payload shape is unusable', async () => { + (jsonwebtoken.verify as jest.Mock).mockReturnValue({ + type: 'refresh', + clientId: 'test-client-id', + userId: 'not-a-number', + renderingId: 456, + serverRefreshToken: 'forest-refresh-token', + }); + const provider = createProvider(); + + await expect(provider.exchangeRefreshToken(mockClient, 'our-refresh-token')).rejects.toThrow( + 'Invalid token type', + ); + expect(mockServer.fetch).not.toHaveBeenCalledWith( + expect.stringContaining('/oauth/token'), + expect.anything(), + ); + }); + + it('still advertises the 3600 fallback when the Forest token is already expired', async () => { + mockJwtDecode.mockReset(); + mockJwtDecode + .mockReturnValueOnce({ + meta: { renderingId: 456 }, + exp: nowInSeconds - 10, + iat: nowInSeconds - 3610, + scope: 'mcp:read', + }) + .mockReturnValueOnce({ exp: nowInSeconds + FOREST_REFRESH_TTL, iat: nowInSeconds }); + const provider = createProvider(); + + const result = await provider.exchangeAuthorizationCode(mockClient, 'auth-code-123'); + + expect(signedTtl(1)).toBe(-10); + expect(result.expires_in).toBe(3600); + }); + }); }); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 73e96e6798..e21a4d7e8d 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -742,9 +742,6 @@ describe('ForestMCPServer Instance', () => { .post('/oauth/token', errorResponse, statusCode); }; - // Note: The implementation wraps all OAuth errors in InvalidRequestError, - // so the error code is always 'invalid_request' with the original error in the description - it('should return error when authorization code is invalid', async () => { setupErrorMock( { @@ -763,8 +760,10 @@ describe('ForestMCPServer Instance', () => { }); expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_request'); - expect(response.body.error_description).toContain('Failed to exchange authorization code'); + expect(response.body.error).toBe('invalid_grant'); + expect(response.body.error_description).toContain( + 'The authorization code has expired or is invalid', + ); }); it('should return error when client authentication fails', async () => { @@ -785,8 +784,8 @@ describe('ForestMCPServer Instance', () => { }); expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_request'); - expect(response.body.error_description).toContain('Failed to exchange authorization code'); + expect(response.body.error).toBe('invalid_client'); + expect(response.body.error_description).toContain('Client authentication failed'); }); it('should return error when requested scope is invalid', async () => { @@ -807,8 +806,10 @@ describe('ForestMCPServer Instance', () => { }); expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_request'); - expect(response.body.error_description).toContain('Failed to exchange authorization code'); + expect(response.body.error).toBe('invalid_scope'); + expect(response.body.error_description).toContain( + 'The requested scope is invalid or unknown', + ); }); it('should return error when client is not authorized', async () => { @@ -829,8 +830,10 @@ describe('ForestMCPServer Instance', () => { }); expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_request'); - expect(response.body.error_description).toContain('Failed to exchange authorization code'); + expect(response.body.error).toBe('unauthorized_client'); + expect(response.body.error_description).toContain( + 'The client is not authorized to use this grant type', + ); }); it('should return error when Forest Admin server has internal error', async () => { @@ -851,8 +854,10 @@ describe('ForestMCPServer Instance', () => { }); expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_request'); - expect(response.body.error_description).toContain('Failed to exchange authorization code'); + expect(response.body.error).toBe('server_error'); + expect(response.body.error_description).toContain( + 'An unexpected error occurred on the server', + ); }); it('should use default error description when not provided by Forest server', async () => { @@ -883,7 +888,7 @@ describe('ForestMCPServer Instance', () => { }); expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_request'); + expect(response.body.error).toBe('server_error'); expect(response.body.error_description).toContain('Failed to exchange authorization code'); }); }); @@ -2825,6 +2830,95 @@ describe('agentUrl option', () => { }); }); +describe('tokenTtl option', () => { + const originalFetch = global.fetch; + const FOREST_SECRET = 'forest-signing-secret'; + let mockFetchServer: MockServer; + + afterEach(() => { + global.fetch = originalFetch; + mockFetchServer?.restoreSuperagent(); + }); + + function stubForestServer() { + mockFetchServer = new MockServer(); + mockFetchServer + .get('/liana/environment', { + data: { id: '1', attributes: { api_endpoint: 'https://api.example.com' } }, + }) + .get(/\/oauth\/register\//, { + client_id: 'test-client', + redirect_uris: ['https://example.com/callback'], + }) + .post('/oauth/token', { + access_token: jsonwebtoken.sign( + { meta: { renderingId: 456 }, scope: 'mcp:read' }, + FOREST_SECRET, + { expiresIn: 3600 }, + ), + refresh_token: jsonwebtoken.sign({}, FOREST_SECRET, { expiresIn: '7d' }), + expires_in: 3600, + token_type: 'Bearer', + scope: 'mcp:read', + }) + .get(/\/liana\/v2\/renderings\/\d+\/authorization/, { + data: { + id: '123', + attributes: { + email: 'user@example.com', + first_name: 'Test', + last_name: 'User', + teams: ['Operations'], + role: 'Admin', + permission_level: 'admin', + tags: [], + }, + }, + }); + global.fetch = mockFetchServer.fetch; + mockFetchServer.setupSuperagentMock(); + } + + it('shortens the issued access token down to the configured cap', async () => { + stubForestServer(); + const server = new ForestMCPServer({ + envSecret: 'ENV_SECRET', + authSecret: 'AUTH_SECRET', + forestServerClient: createMockForestServerClient(), + tokenTtl: { accessTokenSeconds: 120 }, + }); + const app = await server.buildExpressApp(new URL('http://localhost:3000')); + + const response = await request(app).post('/oauth/token').type('form').send({ + grant_type: 'authorization_code', + code: 'auth-code', + code_verifier: 'code-verifier', + client_id: 'test-client', + }); + + expect(response.status).toBe(200); + expect(response.body.expires_in).toBe(120); + + const { exp, iat } = jsonwebtoken.decode(response.body.access_token) as { + exp: number; + iat: number; + }; + expect(exp - iat).toBe(120); + }); + + it('rejects an invalid value at construction rather than issuing uncapped tokens', () => { + expect( + () => + new ForestMCPServer({ + envSecret: 'ENV_SECRET', + authSecret: 'AUTH_SECRET', + forestServerClient: createMockForestServerClient(), + tokenTtl: { accessTokenSeconds: 0 }, + }), + ).toThrow(/Invalid tokenTtl\.accessTokenSeconds/); + }); +}); + describe('handleMcpRequest cleanup', () => { const originalFetch = global.fetch; let cleanupServer: ForestMCPServer; diff --git a/packages/mcp-server/test/utils/token-ttl.test.ts b/packages/mcp-server/test/utils/token-ttl.test.ts new file mode 100644 index 0000000000..39069c8013 --- /dev/null +++ b/packages/mcp-server/test/utils/token-ttl.test.ts @@ -0,0 +1,119 @@ +import normalizeTokenTtl, { + MIN_TOKEN_TTL_SECONDS, + sessionRemainingSeconds, +} from '../../src/utils/token-ttl'; + +describe('normalizeTokenTtl', () => { + let logger: jest.Mock; + + beforeEach(() => { + logger = jest.fn(); + }); + + it.each([undefined, {}])('returns undefined for %p so nothing is capped', input => { + expect(normalizeTokenTtl(input, logger)).toBeUndefined(); + expect(logger).not.toHaveBeenCalled(); + }); + + it('returns valid values unchanged', () => { + expect( + normalizeTokenTtl({ accessTokenSeconds: 900, refreshTokenSeconds: 86400 }, logger), + ).toEqual({ accessTokenSeconds: 900, refreshTokenSeconds: 86400 }); + expect(logger).not.toHaveBeenCalled(); + }); + + it('accepts a value far above the Forest lifetime, which the cap neutralizes', () => { + expect(normalizeTokenTtl({ accessTokenSeconds: 1e12 }, logger)).toEqual({ + accessTokenSeconds: 1e12, + refreshTokenSeconds: undefined, + }); + expect(logger).not.toHaveBeenCalled(); + }); + + it.each([0, -1, -3600, 3600.5, NaN, Infinity, -Infinity, '900' as unknown as number, null])( + 'throws for accessTokenSeconds %p', + value => { + expect(() => normalizeTokenTtl({ accessTokenSeconds: value }, logger)).toThrow( + /Invalid tokenTtl\.accessTokenSeconds/, + ); + }, + ); + + it.each([0, -1, -3600, 3600.5, NaN, Infinity, -Infinity, '900' as unknown as number, null])( + 'throws for refreshTokenSeconds %p', + value => { + expect(() => normalizeTokenTtl({ refreshTokenSeconds: value }, logger)).toThrow( + /Invalid tokenTtl\.refreshTokenSeconds/, + ); + }, + ); + + it('clamps a value below the minimum and warns', () => { + expect(normalizeTokenTtl({ accessTokenSeconds: 30 }, logger)).toEqual({ + accessTokenSeconds: MIN_TOKEN_TTL_SECONDS, + refreshTokenSeconds: undefined, + }); + expect(logger).toHaveBeenCalledWith( + 'Warn', + `raising tokenTtl.accessTokenSeconds from 30 to the ${MIN_TOKEN_TTL_SECONDS}s minimum`, + ); + }); + + it('warns when the session is shorter than one access token, the classic swap', () => { + expect( + normalizeTokenTtl({ accessTokenSeconds: 3600, refreshTokenSeconds: 60 }, logger), + ).toEqual({ accessTokenSeconds: 3600, refreshTokenSeconds: 60 }); + expect(logger).toHaveBeenCalledWith('Warn', expect.stringContaining('Did you swap the two')); + }); + + it('stays silent when the session is longer than the access token', () => { + normalizeTokenTtl({ accessTokenSeconds: 900, refreshTokenSeconds: 86400 }, logger); + expect(logger).not.toHaveBeenCalled(); + }); + + it('clamps at the boundary but leaves the minimum itself alone', () => { + expect(normalizeTokenTtl({ accessTokenSeconds: 59 }, logger)?.accessTokenSeconds).toBe(60); + expect(normalizeTokenTtl({ accessTokenSeconds: 60 }, logger)?.accessTokenSeconds).toBe(60); + expect(logger).toHaveBeenCalledTimes(1); + }); +}); + +describe('sessionRemainingSeconds', () => { + const nowInSeconds = 1_000_000; + + it('is unbounded when no refresh cap is configured', () => { + expect(sessionRemainingSeconds({ sessionStartedAt: nowInSeconds, nowInSeconds })).toBe( + Infinity, + ); + }); + + it('is the full cap at the start of the session', () => { + expect( + sessionRemainingSeconds({ + refreshTokenSeconds: 86400, + sessionStartedAt: nowInSeconds, + nowInSeconds, + }), + ).toBe(86400); + }); + + it('shrinks as the session ages instead of sliding', () => { + expect( + sessionRemainingSeconds({ + refreshTokenSeconds: 86400, + sessionStartedAt: nowInSeconds - 80000, + nowInSeconds, + }), + ).toBe(6400); + }); + + it('goes non-positive once the session is over', () => { + expect( + sessionRemainingSeconds({ + refreshTokenSeconds: 86400, + sessionStartedAt: nowInSeconds - 90000, + nowInSeconds, + }), + ).toBe(-3600); + }); +});