/** * MCP Auth Storage Module * * Handles secure storage of OAuth credentials, tokens, client information, * and legacy PKCE state for MCP servers. * * Persistent OAuth entries are stored in the operating system credential store. * Legacy plaintext entries are imported from $MCP_OAUTH_DIR/sha256-/tokens.json * when set, otherwise /mcp-oauth/sha256-/tokens.json, * then the plaintext file is removed. */ import { createHash } from 'crypto'; import { createRequire } from 'module'; import { readFileSync, existsSync, rmSync } from 'fs'; import { join } from 'path'; import { getAgentPath } from './agent-dir.ts'; import { resolveConfiguredOAuthDir } from './config.ts'; const require = createRequire(import.meta.url); const AUTH_SECRET_SERVICE = 'pi-mcp-adapter.oauth'; const TEST_AUTH_STORE_ENV = 'PI_MCP_ADAPTER_TEST_AUTH_STORE'; /** OAuth token storage format */ export interface StoredTokens { accessToken: string; refreshToken?: string; expiresAt?: number; // Unix timestamp in seconds scope?: string; /** SEP-2352 authorization-server issuer stamp from the SDK */ issuer?: string; } /** OAuth client information from dynamic or static registration */ export interface StoredClientInfo { clientId: string; clientSecret?: string; clientIdIssuedAt?: number; clientSecretExpiresAt?: number; redirectUris?: string[]; /** SEP-2352 authorization-server issuer stamp from the SDK */ issuer?: string; /** * True when this entry is a secretless SEP-2352 issuer stub persisted for a * config-pre-registered client (written by the config-clientId path of * saveClientInformation). Such a stub is only usable when paired with the * config that supplies the client secret; it must never be served as * standalone client information. */ configPreRegistered?: boolean; } /** Complete auth entry for a server */ export interface AuthEntry { tokens?: StoredTokens; clientInfo?: StoredClientInfo; codeVerifier?: string; oauthState?: string; serverUrl?: string; // Track the URL these credentials are for } export interface AuthStorageOptions { /** Legacy plaintext import directory. Persistent secrets no longer use this as their store. */ baseDir?: string; } interface KeyringEntry { getPassword(): string | null; setPassword(password: string): void; deleteCredential(): boolean; } type KeyringEntryConstructor = new (service: string, account: string) => KeyringEntry; interface AuthSecretStore { read(account: string): string | undefined; write(account: string, payload: string): void; remove(account: string): void; } let KeyringEntryClass: KeyringEntryConstructor | undefined; const memoryAuthEntries = new Map(); const memoryAuthSecretStore: AuthSecretStore = { read(account) { return memoryAuthEntries.get(account); }, write(account, payload) { memoryAuthEntries.set(account, payload); }, remove(account) { memoryAuthEntries.delete(account); }, }; const keyringAuthSecretStore: AuthSecretStore = { read(account) { return getKeyringEntry(account).getPassword() ?? undefined; }, write(account, payload) { getKeyringEntry(account).setPassword(payload); }, remove(account) { getKeyringEntry(account).deleteCredential(); }, }; const unavailableAuthSecretStore: AuthSecretStore = { read() { throw new Error('simulated secure credential store unavailable'); }, write() { throw new Error('simulated secure credential store unavailable'); }, remove() { throw new Error('simulated secure credential store unavailable'); }, }; export function resetTestAuthSecretStore(): void { memoryAuthEntries.clear(); } function getAuthSecretStore(): AuthSecretStore { if (process.env[TEST_AUTH_STORE_ENV] === 'memory') return memoryAuthSecretStore; if (process.env[TEST_AUTH_STORE_ENV] === 'unavailable') return unavailableAuthSecretStore; return keyringAuthSecretStore; } function getKeyringEntry(account: string): KeyringEntry { try { KeyringEntryClass ??= (require('@napi-rs/keyring') as { Entry: KeyringEntryConstructor }).Entry; return new KeyringEntryClass(AUTH_SECRET_SERVICE, account); } catch (error) { throw new Error('OAuth secure credential storage is unavailable. Configure the OS credential store and retry authentication.', { cause: error }); } } export function getAuthStorageOptions(oauthDir: unknown, cwd = process.cwd()): AuthStorageOptions { const baseDir = resolveConfiguredOAuthDir(oauthDir, cwd); return baseDir ? { baseDir } : {}; } export function getAuthBaseDir(options: AuthStorageOptions = {}): string { const override = process.env.MCP_OAUTH_DIR?.trim(); if (override) return override; return options.baseDir ?? getAgentPath('mcp-oauth'); } /** * Get the legacy server-specific directory path. */ function getServerDir(serverName: string, options?: AuthStorageOptions): string { if (typeof serverName !== 'string') { throw new Error(`Invalid MCP server name: ${JSON.stringify(serverName)}`); } const storageKey = getAuthEntryAccount(serverName); return join(getAuthBaseDir(options), storageKey); } function getAuthEntryAccount(serverName: string): string { if (typeof serverName !== 'string') { throw new Error(`Invalid MCP server name: ${JSON.stringify(serverName)}`); } return `sha256-${createHash('sha256').update(serverName, 'utf8').digest('hex')}`; } /** * Get the legacy plaintext tokens file path for a server. */ export function getAuthEntryFilePath(serverName: string, options?: AuthStorageOptions): string { return join(getServerDir(serverName, options), 'tokens.json'); } function parseAuthEntryPayload(serverName: string, payload: string, source: string): AuthEntry { try { return JSON.parse(payload) as AuthEntry; } catch (error) { throw new Error(`Failed to parse OAuth credentials for ${serverName} from ${source}`, { cause: error }); } } function readLegacyAuthEntry(serverName: string, options?: AuthStorageOptions): AuthEntry | undefined { const filePath = getAuthEntryFilePath(serverName, options); if (!existsSync(filePath)) return undefined; const data = readFileSync(filePath, 'utf-8'); return parseAuthEntryPayload(serverName, data, filePath); } function removeLegacyAuthEntry(serverName: string, options?: AuthStorageOptions): void { const filePath = getAuthEntryFilePath(serverName, options); if (!existsSync(filePath)) return; try { rmSync(filePath, { force: true }); } catch (error) { throw new Error(`Failed to remove legacy plaintext OAuth credentials for ${serverName} at ${filePath}`, { cause: error }); } const dir = getServerDir(serverName, options); try { rmSync(dir, { recursive: true }); } catch { // Directory may contain future non-secret metadata; the plaintext file was already removed. } } function writeSecureAuthEntry(serverName: string, entry: AuthEntry): void { const account = getAuthEntryAccount(serverName); try { getAuthSecretStore().write(account, JSON.stringify(entry, null, 2)); } catch (error) { throw new Error(`Failed to write OAuth credentials for ${serverName} to the OS secure credential store`, { cause: error }); } } /** * Read the auth entry for a server from the OS secure store, importing and * deleting a legacy plaintext entry when present. */ function readAuthEntry(serverName: string, options?: AuthStorageOptions): AuthEntry | undefined { const account = getAuthEntryAccount(serverName); let payload: string | undefined; try { payload = getAuthSecretStore().read(account); } catch (error) { throw new Error(`Failed to read OAuth credentials for ${serverName} from the OS secure credential store`, { cause: error }); } if (payload !== undefined) { const entry = parseAuthEntryPayload(serverName, payload, 'OS secure credential store'); removeLegacyAuthEntry(serverName, options); return entry; } const legacyEntry = readLegacyAuthEntry(serverName, options); if (!legacyEntry) return undefined; writeSecureAuthEntry(serverName, legacyEntry); removeLegacyAuthEntry(serverName, options); return legacyEntry; } /** * Get auth entry for a server. */ export function getAuthEntry(serverName: string, options?: AuthStorageOptions): AuthEntry | undefined { return readAuthEntry(serverName, options); } /** * Get auth entry and validate it's for the correct URL. * Returns undefined if URL has changed (credentials are invalid). */ export function getAuthForUrl(serverName: string, serverUrl: string, options?: AuthStorageOptions): AuthEntry | undefined { const entry = getAuthEntry(serverName, options); if (!entry) return undefined; // If no serverUrl is stored, this is from an old version - consider it invalid if (!entry.serverUrl) return undefined; // If URL has changed, credentials are invalid if (entry.serverUrl !== serverUrl) return undefined; return entry; } /** * Save auth entry for a server. */ export function saveAuthEntry(serverName: string, entry: AuthEntry, serverUrl?: string, options?: AuthStorageOptions): void { // Always update serverUrl if provided if (serverUrl) { entry.serverUrl = serverUrl; } writeSecureAuthEntry(serverName, entry); removeLegacyAuthEntry(serverName, options); } /** * Remove auth entry for a server. */ export function removeAuthEntry(serverName: string, options?: AuthStorageOptions): void { const account = getAuthEntryAccount(serverName); try { getAuthSecretStore().remove(account); } catch (error) { throw new Error(`Failed to remove OAuth credentials for ${serverName} from the OS secure credential store`, { cause: error }); } removeLegacyAuthEntry(serverName, options); } /** * Update tokens for a server. */ export function updateTokens( serverName: string, tokens: StoredTokens, serverUrl?: string, options?: AuthStorageOptions ): void { const entry = getAuthEntry(serverName, options) ?? {}; if (serverUrl && entry.serverUrl !== serverUrl) { delete entry.clientInfo; delete entry.codeVerifier; delete entry.oauthState; } entry.tokens = tokens; saveAuthEntry(serverName, entry, serverUrl, options); } /** * Update client info for a server. */ export function updateClientInfo( serverName: string, clientInfo: StoredClientInfo, serverUrl?: string, options?: AuthStorageOptions ): void { const entry = getAuthEntry(serverName, options) ?? {}; if (serverUrl && entry.serverUrl !== serverUrl) { delete entry.tokens; delete entry.codeVerifier; delete entry.oauthState; } entry.clientInfo = clientInfo; saveAuthEntry(serverName, entry, serverUrl, options); } /** * Update code verifier for a server. */ export function updateCodeVerifier(serverName: string, codeVerifier: string, serverUrl?: string, options?: AuthStorageOptions): void { const entry = getAuthEntry(serverName, options) ?? {}; if (serverUrl && entry.serverUrl !== serverUrl) { delete entry.tokens; delete entry.clientInfo; delete entry.oauthState; } entry.codeVerifier = codeVerifier; saveAuthEntry(serverName, entry, serverUrl, options); } /** * Clear code verifier for a server. */ export function clearCodeVerifier(serverName: string, options?: AuthStorageOptions): void { const entry = getAuthEntry(serverName, options); if (entry) { delete entry.codeVerifier; saveAuthEntry(serverName, entry, undefined, options); } } /** * Update OAuth state for a server. */ export function updateOAuthState(serverName: string, state: string, serverUrl?: string, options?: AuthStorageOptions): void { const entry = getAuthEntry(serverName, options) ?? {}; if (serverUrl && entry.serverUrl !== serverUrl) { delete entry.tokens; delete entry.clientInfo; delete entry.codeVerifier; } entry.oauthState = state; saveAuthEntry(serverName, entry, serverUrl, options); } /** * Get OAuth state for a server. */ export function getOAuthState(serverName: string, options?: AuthStorageOptions): string | undefined { const entry = getAuthEntry(serverName, options); return entry?.oauthState; } /** * Clear OAuth state for a server. */ export function clearOAuthState(serverName: string, options?: AuthStorageOptions): void { const entry = getAuthEntry(serverName, options); if (entry) { delete entry.oauthState; saveAuthEntry(serverName, entry, undefined, options); } } /** * Check if stored tokens are expired. * Returns null if no tokens exist, false if no expiry or not expired, true if expired. */ export function isTokenExpired(serverName: string, options?: AuthStorageOptions): boolean | null { const entry = getAuthEntry(serverName, options); if (!entry?.tokens) return null; if (!entry.tokens.expiresAt) return false; return entry.tokens.expiresAt < Date.now() / 1000; } /** * Check if a server has stored tokens. */ export function hasStoredTokens(serverName: string, options?: AuthStorageOptions): boolean { const entry = getAuthEntry(serverName, options); return !!entry?.tokens; } /** * Clear all credentials for a server. */ export function clearAllCredentials(serverName: string, options?: AuthStorageOptions): void { removeAuthEntry(serverName, options); } /** * Clear only client info for a server. */ export function clearClientInfo(serverName: string, options?: AuthStorageOptions): void { const entry = getAuthEntry(serverName, options); if (entry) { delete entry.clientInfo; saveAuthEntry(serverName, entry, undefined, options); } } /** * Clear only tokens for a server. */ export function clearTokens(serverName: string, options?: AuthStorageOptions): void { const entry = getAuthEntry(serverName, options); if (entry) { delete entry.tokens; saveAuthEntry(serverName, entry, undefined, options); } }