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
9 changes: 9 additions & 0 deletions .changeset/expired-token-precheck.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 51 additions & 0 deletions packages/mcp-server/src/__tests__/ensure-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) =>
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();
Expand Down Expand Up @@ -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',
Expand Down
45 changes: 45 additions & 0 deletions packages/mcp-server/src/__tests__/token-expiry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { isJwtExpired } from '../token-expiry.js';

function makeJwt(payload: Record<string, unknown>): string {
const encode = (obj: Record<string, unknown>) =>
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);
});
});
37 changes: 26 additions & 11 deletions packages/mcp-server/src/ensure-auth.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,15 +19,22 @@ async function runAuthFlow(config: ResolvedFormioConfig): Promise<void> {
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
Expand All @@ -37,11 +45,18 @@ async function runAuthFlow(config: ResolvedFormioConfig): Promise<void> {
}

export async function ensureAuthenticated(config: ResolvedFormioConfig): Promise<void> {
// 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
Expand Down
43 changes: 43 additions & 0 deletions packages/mcp-server/src/token-expiry.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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<string, unknown>)
: 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;
}
Loading