From c72382e4b4616664dbb519db8a903122e23029ec Mon Sep 17 00:00:00 2001 From: Emilio Amaya Date: Wed, 28 Jan 2026 12:55:47 -0500 Subject: [PATCH] feat(cli): cache OAuth tokens between CLI invocations Add FileOAuthDb class that persists OAuth tokens to disk at ~/.atxp/oauth-cache.json. This eliminates the need for a fresh OAuth flow on every paas command. - Create FileOAuthDb implementing OAuthDb interface - Store tokens with 0o600 permissions (owner read/write only) - Check token expiration on retrieval - Add unit tests for token persistence and expiration Fixes ATXP-1427 Co-Authored-By: Claude Opus 4.5 --- packages/atxp/src/call-tool.ts | 10 ++ packages/atxp/src/file-oauth-db.test.ts | 199 ++++++++++++++++++++++++ packages/atxp/src/file-oauth-db.ts | 100 ++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 packages/atxp/src/file-oauth-db.test.ts create mode 100644 packages/atxp/src/file-oauth-db.ts diff --git a/packages/atxp/src/call-tool.ts b/packages/atxp/src/call-tool.ts index c23dd72..52119df 100644 --- a/packages/atxp/src/call-tool.ts +++ b/packages/atxp/src/call-tool.ts @@ -1,6 +1,15 @@ import { atxpClient, ATXPAccount } from '@atxp/client'; import chalk from 'chalk'; import { getConnection } from './config.js'; +import { FileOAuthDb } from './file-oauth-db.js'; + +let oAuthDb: FileOAuthDb | null = null; +function getOAuthDb(): FileOAuthDb { + if (!oAuthDb) { + oAuthDb = new FileOAuthDb(); + } + return oAuthDb; +} export interface ToolResult { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; @@ -23,6 +32,7 @@ export async function callTool( const client = await atxpClient({ mcpServer: `https://${server}`, account: new ATXPAccount(connection), + oAuthDb: getOAuthDb(), }); const result = (await client.callTool({ diff --git a/packages/atxp/src/file-oauth-db.test.ts b/packages/atxp/src/file-oauth-db.test.ts new file mode 100644 index 0000000..6131678 --- /dev/null +++ b/packages/atxp/src/file-oauth-db.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { FileOAuthDb } from './file-oauth-db.js'; +import type { ClientCredentials, PKCEValues, AccessToken } from '@atxp/common'; + +describe('FileOAuthDb', () => { + let testDir: string; + let testCacheFile: string; + let db: FileOAuthDb; + + beforeEach(() => { + // Create a unique test directory for each test + testDir = path.join(os.tmpdir(), `atxp-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + testCacheFile = path.join(testDir, 'oauth-cache.json'); + db = new FileOAuthDb(testCacheFile); + }); + + afterEach(() => { + // Clean up test directory + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true }); + } + }); + + describe('file creation', () => { + it('should create cache file with correct permissions (0o600)', async () => { + const credentials: ClientCredentials = { + clientId: 'test-client', + clientSecret: 'test-secret', + redirectUri: 'http://localhost/callback', + }; + + await db.saveClientCredentials('https://example.com', credentials); + + expect(fs.existsSync(testCacheFile)).toBe(true); + + const stats = fs.statSync(testCacheFile); + // Check permissions (0o600 = owner read/write only) + const permissions = stats.mode & 0o777; + expect(permissions).toBe(0o600); + }); + + it('should create directory if it does not exist', async () => { + expect(fs.existsSync(testDir)).toBe(false); + + await db.saveClientCredentials('https://example.com', { + clientId: 'test', + clientSecret: 'secret', + redirectUri: 'http://localhost', + }); + + expect(fs.existsSync(testDir)).toBe(true); + expect(fs.existsSync(testCacheFile)).toBe(true); + }); + }); + + describe('client credentials', () => { + it('should store and retrieve client credentials', async () => { + const serverUrl = 'https://api.example.com'; + const credentials: ClientCredentials = { + clientId: 'my-client-id', + clientSecret: 'my-client-secret', + redirectUri: 'http://localhost:3000/callback', + }; + + await db.saveClientCredentials(serverUrl, credentials); + const retrieved = await db.getClientCredentials(serverUrl); + + expect(retrieved).toEqual(credentials); + }); + + it('should return null for non-existent credentials', async () => { + const result = await db.getClientCredentials('https://unknown.com'); + expect(result).toBeNull(); + }); + }); + + describe('PKCE values', () => { + it('should store and retrieve PKCE values', async () => { + const userId = 'user-123'; + const state = 'random-state'; + const values: PKCEValues = { + codeVerifier: 'code-verifier-xyz', + codeChallenge: 'code-challenge-abc', + resourceUrl: 'https://resource.example.com', + url: 'https://auth.example.com/authorize', + }; + + await db.savePKCEValues(userId, state, values); + const retrieved = await db.getPKCEValues(userId, state); + + expect(retrieved).toEqual(values); + }); + + it('should return null for non-existent PKCE values', async () => { + const result = await db.getPKCEValues('unknown-user', 'unknown-state'); + expect(result).toBeNull(); + }); + }); + + describe('access tokens', () => { + it('should store and retrieve valid access tokens', async () => { + const userId = 'user-456'; + const url = 'https://api.example.com'; + const token: AccessToken = { + accessToken: 'access-token-xyz', + refreshToken: 'refresh-token-abc', + expiresAt: Date.now() + 3600000, // Expires in 1 hour + resourceUrl: url, + }; + + await db.saveAccessToken(userId, url, token); + const retrieved = await db.getAccessToken(userId, url); + + expect(retrieved).toEqual(token); + }); + + it('should return null for non-existent tokens', async () => { + const result = await db.getAccessToken('unknown-user', 'https://unknown.com'); + expect(result).toBeNull(); + }); + + it('should return null for expired tokens', async () => { + const userId = 'user-789'; + const url = 'https://api.example.com'; + const expiredToken: AccessToken = { + accessToken: 'expired-token', + expiresAt: Date.now() - 1000, // Expired 1 second ago + resourceUrl: url, + }; + + await db.saveAccessToken(userId, url, expiredToken); + const retrieved = await db.getAccessToken(userId, url); + + expect(retrieved).toBeNull(); + }); + + it('should remove expired token from cache', async () => { + const userId = 'user-expired'; + const url = 'https://api.example.com'; + const expiredToken: AccessToken = { + accessToken: 'expired-token', + expiresAt: Date.now() - 1000, + resourceUrl: url, + }; + + await db.saveAccessToken(userId, url, expiredToken); + + // First retrieval returns null and removes from cache + await db.getAccessToken(userId, url); + + // Verify token is removed from the file + const cacheContent = JSON.parse(fs.readFileSync(testCacheFile, 'utf-8')); + expect(cacheContent.accessTokens[`${userId}:${url}`]).toBeUndefined(); + }); + + it('should return tokens without expiresAt as valid', async () => { + const userId = 'user-no-expiry'; + const url = 'https://api.example.com'; + const tokenNoExpiry: AccessToken = { + accessToken: 'no-expiry-token', + resourceUrl: url, + }; + + await db.saveAccessToken(userId, url, tokenNoExpiry); + const retrieved = await db.getAccessToken(userId, url); + + expect(retrieved).toEqual(tokenNoExpiry); + }); + }); + + describe('cache persistence', () => { + it('should persist data across instances', async () => { + const serverUrl = 'https://persist-test.com'; + const credentials: ClientCredentials = { + clientId: 'persist-client', + clientSecret: 'persist-secret', + redirectUri: 'http://localhost', + }; + + // Save with first instance + await db.saveClientCredentials(serverUrl, credentials); + + // Create new instance with same cache file + const db2 = new FileOAuthDb(testCacheFile); + const retrieved = await db2.getClientCredentials(serverUrl); + + expect(retrieved).toEqual(credentials); + }); + }); + + describe('close', () => { + it('should close without error', async () => { + await expect(db.close()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/atxp/src/file-oauth-db.ts b/packages/atxp/src/file-oauth-db.ts new file mode 100644 index 0000000..df71646 --- /dev/null +++ b/packages/atxp/src/file-oauth-db.ts @@ -0,0 +1,100 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import type { OAuthDb, ClientCredentials, PKCEValues, AccessToken } from '@atxp/common'; +import { CONFIG_DIR } from './config.js'; + +export const OAUTH_CACHE_FILE = path.join(CONFIG_DIR, 'oauth-cache.json'); + +interface OAuthCache { + clientCredentials: Record; + pkceValues: Record; + accessTokens: Record; +} + +export class FileOAuthDb implements OAuthDb { + private cacheFile: string; + + constructor(cacheFile: string = OAUTH_CACHE_FILE) { + this.cacheFile = cacheFile; + } + + private loadCache(): OAuthCache { + try { + if (fs.existsSync(this.cacheFile)) { + const content = fs.readFileSync(this.cacheFile, 'utf-8'); + return JSON.parse(content) as OAuthCache; + } + } catch { + // Ignore read/parse errors, start fresh + } + return { + clientCredentials: {}, + pkceValues: {}, + accessTokens: {}, + }; + } + + private saveCache(cache: OAuthCache): void { + const dir = path.dirname(this.cacheFile); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(this.cacheFile, JSON.stringify(cache, null, 2), { mode: 0o600 }); + } + + async getClientCredentials(serverUrl: string): Promise { + const cache = this.loadCache(); + return cache.clientCredentials[serverUrl] || null; + } + + async saveClientCredentials(serverUrl: string, credentials: ClientCredentials): Promise { + const cache = this.loadCache(); + cache.clientCredentials[serverUrl] = credentials; + this.saveCache(cache); + } + + async getPKCEValues(userId: string, state: string): Promise { + const key = `${userId}:${state}`; + const cache = this.loadCache(); + return cache.pkceValues[key] || null; + } + + async savePKCEValues(userId: string, state: string, values: PKCEValues): Promise { + const key = `${userId}:${state}`; + const cache = this.loadCache(); + cache.pkceValues[key] = values; + this.saveCache(cache); + } + + async getAccessToken(userId: string, url: string): Promise { + const key = `${userId}:${url}`; + const cache = this.loadCache(); + const token = cache.accessTokens[key]; + + if (!token) { + return null; + } + + // Check if token has expired + if (token.expiresAt && token.expiresAt < Date.now()) { + // Remove expired token from cache + delete cache.accessTokens[key]; + this.saveCache(cache); + return null; + } + + return token; + } + + async saveAccessToken(userId: string, url: string, token: AccessToken): Promise { + const key = `${userId}:${url}`; + const cache = this.loadCache(); + cache.accessTokens[key] = token; + this.saveCache(cache); + } + + async close(): Promise { + // Nothing to close for file-based storage + } +}