diff --git a/.changeset/expired-token-precheck.md b/.changeset/expired-token-precheck.md new file mode 100644 index 0000000..df096eb --- /dev/null +++ b/.changeset/expired-token-precheck.md @@ -0,0 +1,9 @@ +--- +'@formio/mcp': patch +'@formio/ai': patch +--- + +Check cached JWT expiry locally before use. The MCP server now decodes a cached +token's `exp` claim and clears expired tokens — both from the on-disk cache and +the in-process cache — before attempting any request, triggering re-auth instead +of thrashing on failing calls with a known-dead token. diff --git a/packages/mcp-server/src/__tests__/ensure-auth.test.ts b/packages/mcp-server/src/__tests__/ensure-auth.test.ts index f06f22e..bed0e0b 100644 --- a/packages/mcp-server/src/__tests__/ensure-auth.test.ts +++ b/packages/mcp-server/src/__tests__/ensure-auth.test.ts @@ -26,6 +26,13 @@ const mockClearToken = vi.mocked(clearToken); const mockValidateToken = vi.mocked(validateToken); const mockAuthenticate = vi.mocked(authenticate); +function makeJwt(expSecondsFromNow: number): string { + const encode = (obj: Record) => + Buffer.from(JSON.stringify(obj)).toString('base64url'); + const exp = Math.floor(Date.now() / 1000) + expSecondsFromNow; + return `${encode({ alg: 'HS256' })}.${encode({ exp })}.sig`; +} + describe('ensureAuthenticated', () => { beforeEach(() => { vi.resetAllMocks(); @@ -77,6 +84,50 @@ describe('ensureAuthenticated', () => { expect(config.jwt).toBe('fresh-jwt'); }); + it('clears an expired cached disk token without a network validation call, then re-auths', async () => { + const config: ResolvedFormioConfig = { + baseUrl: 'https://form.local', + projectUrl: 'https://form.local/example', + }; + mockReadToken.mockResolvedValue(makeJwt(-60)); + mockAuthenticate.mockResolvedValue('fresh-jwt'); + + await ensureAuthenticated(config); + + expect(mockValidateToken).not.toHaveBeenCalled(); + expect(mockClearToken).toHaveBeenCalledWith('https://form.local'); + expect(mockAuthenticate).toHaveBeenCalledOnce(); + expect(config.jwt).toBe('fresh-jwt'); + }); + + it('re-validates an in-process cached token and re-auths when it has expired mid-session', async () => { + const config: ResolvedFormioConfig = { + baseUrl: 'https://form.local', + projectUrl: 'https://form.local/example', + }; + // First call: a still-valid JWT gets cached in-process. + const validJwt = makeJwt(3600); + mockReadToken.mockResolvedValue(validJwt); + mockValidateToken.mockResolvedValue(true); + await ensureAuthenticated(config); + expect(config.jwt).toBe(validJwt); + + // Simulate the cached token having expired: reads now return an expired one. + const second: ResolvedFormioConfig = { + baseUrl: 'https://form.local', + projectUrl: 'https://form.local/example', + }; + // Seed the in-process cache with an expired token by going through a fresh flow. + resetAuthState(); + mockReadToken.mockResolvedValue(makeJwt(-60)); + mockAuthenticate.mockResolvedValue('re-authed-jwt'); + + await ensureAuthenticated(second); + + expect(mockAuthenticate).toHaveBeenCalledOnce(); + expect(second.jwt).toBe('re-authed-jwt'); + }); + it('is a no-op when an API key is configured — tool calls surface invalid keys via 401', async () => { const config: ResolvedFormioConfig = { baseUrl: 'https://form.local', diff --git a/packages/mcp-server/src/__tests__/token-expiry.test.ts b/packages/mcp-server/src/__tests__/token-expiry.test.ts new file mode 100644 index 0000000..b5dd0dc --- /dev/null +++ b/packages/mcp-server/src/__tests__/token-expiry.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { isJwtExpired } from '../token-expiry.js'; + +function makeJwt(payload: Record): string { + const encode = (obj: Record) => + Buffer.from(JSON.stringify(obj)).toString('base64url'); + return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`; +} + +describe('isJwtExpired', () => { + const now = 1_700_000_000_000; // fixed reference time in ms + + it('returns true when exp is in the past', () => { + const jwt = makeJwt({ exp: Math.floor(now / 1000) - 60 }); + expect(isJwtExpired(jwt, now)).toBe(true); + }); + + it('returns false when exp is comfortably in the future', () => { + const jwt = makeJwt({ exp: Math.floor(now / 1000) + 3600 }); + expect(isJwtExpired(jwt, now)).toBe(false); + }); + + it('treats a token expiring within the clock-skew window as expired', () => { + const jwt = makeJwt({ exp: Math.floor(now / 1000) + 5 }); + expect(isJwtExpired(jwt, now)).toBe(true); + }); + + it('returns false when the payload has no exp claim', () => { + const jwt = makeJwt({ sub: 'user-123' }); + expect(isJwtExpired(jwt, now)).toBe(false); + }); + + it('returns false for a non-JWT opaque string (cannot determine expiry)', () => { + expect(isJwtExpired('not-a-jwt', now)).toBe(false); + }); + + it('returns false when the payload segment is not valid base64/JSON', () => { + expect(isJwtExpired('a.!!!.c', now)).toBe(false); + }); + + it('returns false when exp is not a number', () => { + const jwt = makeJwt({ exp: 'soon' }); + expect(isJwtExpired(jwt, now)).toBe(false); + }); +}); diff --git a/packages/mcp-server/src/ensure-auth.ts b/packages/mcp-server/src/ensure-auth.ts index 39d56c4..a88ecde 100644 --- a/packages/mcp-server/src/ensure-auth.ts +++ b/packages/mcp-server/src/ensure-auth.ts @@ -1,6 +1,7 @@ import { ResolvedFormioConfig } from './config.js'; import { readToken, saveToken, clearToken } from './token-cache.js'; import { validateToken } from './token-validation.js'; +import { isJwtExpired } from './token-expiry.js'; import { authenticate } from './auth.js'; // Keyed by baseUrl: one JWT is valid for every project on the same Form.io @@ -18,15 +19,22 @@ async function runAuthFlow(config: ResolvedFormioConfig): Promise { const cachedToken = await readToken(config.baseUrl); if (cachedToken) { - config.jwt = cachedToken; - const valid = await validateToken(config); - if (valid) { - jwtCache.set(config.baseUrl, cachedToken); - return; + // Local expiry check first: a plainly-expired JWT is cleared without a + // wasted network round-trip, avoiding the thrash of firing requests with a + // token we already know is dead. + if (isJwtExpired(cachedToken)) { + await clearToken(config.baseUrl); + } else { + config.jwt = cachedToken; + const valid = await validateToken(config); + if (valid) { + jwtCache.set(config.baseUrl, cachedToken); + return; + } + // Rejected by the server (revoked, etc.) — clear and re-auth + await clearToken(config.baseUrl); + config.jwt = undefined; } - // Expired — clear and re-auth - await clearToken(config.baseUrl); - config.jwt = undefined; } // No valid token — login @@ -37,11 +45,18 @@ async function runAuthFlow(config: ResolvedFormioConfig): Promise { } export async function ensureAuthenticated(config: ResolvedFormioConfig): Promise { - // Short-circuit: already authenticated in this process + // Short-circuit: already authenticated in this process. Re-check expiry — a + // token validated at session start can expire mid-session, and we must not + // reuse it blindly. const cached = jwtCache.get(config.baseUrl); if (cached) { - config.jwt = cached; - return; + if (!isJwtExpired(cached)) { + config.jwt = cached; + return; + } + // Expired in-process — drop it and fall through to the full auth flow. + jwtCache.delete(config.baseUrl); + config.jwt = undefined; } // Single-flight per baseUrl: reuse an in-flight auth promise if one exists diff --git a/packages/mcp-server/src/token-expiry.ts b/packages/mcp-server/src/token-expiry.ts new file mode 100644 index 0000000..46f48ca --- /dev/null +++ b/packages/mcp-server/src/token-expiry.ts @@ -0,0 +1,43 @@ +// Decode a JWT's payload (without verifying its signature) and decide whether +// it is expired. We only need the `exp` claim to know whether a cached token is +// safe to reuse; signature verification requires the server's secret and is out +// of scope — the network never trusts our local decode, it re-validates. +// +// Conservative by design: when expiry cannot be determined (opaque string, +// malformed segment, missing/non-numeric `exp`), we report NOT expired and let +// the existing network validation / 401-retry path make the final call. That +// keeps behavior unchanged for non-JWT tokens while still catching the common +// case — a real, plainly-expired JWT — before it is ever sent. + +// Treat tokens that expire within this window as already expired, so we never +// hand off a token that dies mid-request. +const CLOCK_SKEW_MS = 30_000; + +function decodeJwtPayload(jwt: string): Record | null { + const segments = jwt.split('.'); + if (segments.length < 2) { + return null; + } + try { + const json = Buffer.from(segments[1], 'base64url').toString('utf-8'); + const parsed: unknown = JSON.parse(json); + return typeof parsed === 'object' && parsed !== null + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +export function isJwtExpired( + jwt: string, + nowMs: number = Date.now(), + skewMs: number = CLOCK_SKEW_MS +): boolean { + const payload = decodeJwtPayload(jwt); + if (!payload || typeof payload.exp !== 'number') { + return false; + } + const expMs = payload.exp * 1000; + return expMs - skewMs <= nowMs; +}