From 6801342711d5e509332920788e990e45a65ee586 Mon Sep 17 00:00:00 2001 From: Jan Curn Date: Wed, 4 Mar 2026 18:25:22 +0100 Subject: [PATCH 1/4] Keychain access fallback to file-based credential storage --- README.md | 36 ++--- src/lib/auth/keychain.ts | 293 +++++++++++++++++++-------------------- 2 files changed, 162 insertions(+), 167 deletions(-) diff --git a/README.md b/README.md index e26bba8f..476ba6ca 100644 --- a/README.md +++ b/README.md @@ -48,29 +48,29 @@ npm install -g @apify/mcpc ``` **Linux users:** `mcpc` uses the OS keychain for secure credential storage via the -[Secret Service API](https://specifications.freedesktop.org/secret-service/). Two things are required: +[Secret Service API](https://specifications.freedesktop.org/secret-service/). +On desktop systems (GNOME, KDE) this works out of the box. On headless/server/CI environments +without a keyring daemon, `mcpc` automatically falls back to a file-based credential store +(`~/.mcpc/credentials`, mode `0600`). -1. **`libsecret`** — the shared library (client side): - ```bash - # Debian/Ubuntu - sudo apt-get install libsecret-1-0 +To use the OS keychain on a headless system, install `libsecret` and a secret service daemon: - # Fedora/RHEL/CentOS - sudo dnf install libsecret +```bash +# Debian/Ubuntu +sudo apt-get install libsecret-1-0 gnome-keyring + +# Fedora/RHEL/CentOS +sudo dnf install libsecret gnome-keyring - # Arch Linux - sudo pacman -S libsecret - ``` +# Arch Linux +sudo pacman -S libsecret gnome-keyring +``` -2. **A running secret service daemon** — on desktop systems (GNOME, KDE) this is already provided - by gnome-keyring or KWallet. On headless/server/CI environments you need to install and start one: - ```bash - # Debian/Ubuntu - sudo apt-get install gnome-keyring +And then run `mcpc` as follows: - # Then start it (e.g. in CI): - dbus-run-session -- bash -c "echo -n 'password' | gnome-keyring-daemon --unlock && your-command" - ``` +``` +dbus-run-session -- bash -c "echo -n 'password' | gnome-keyring-daemon --unlock && mcpc ..." +``` ## Quickstart diff --git a/src/lib/auth/keychain.ts b/src/lib/auth/keychain.ts index 20ca937e..93366bfe 100644 --- a/src/lib/auth/keychain.ts +++ b/src/lib/auth/keychain.ts @@ -1,28 +1,122 @@ /** * OS Keychain integration for secure credential storage - * Uses @napi-rs/keyring package for cross-platform keychain access + * Uses @napi-rs/keyring for cross-platform keychain access. + * Falls back to ~/.mcpc/credentials.json (mode 0600) when the OS keychain + * is unavailable (e.g. headless servers, containers). */ import { Entry } from '@napi-rs/keyring'; +import { readFile, writeFile } from 'fs/promises'; +import { join } from 'path'; import { createLogger } from '../logger.js'; -import { getServerHost } from '../utils.js'; +import { getServerHost, getMcpcHome } from '../utils.js'; +import { withFileLock } from '../file-lock.js'; const logger = createLogger('keychain'); - -// Service name for all mcpc credentials in the keychain const SERVICE_NAME = 'mcpc'; -/** - * OAuth client information (from dynamic registration) - */ +// ============================================================================= +// File-based fallback store +// ============================================================================= + +const credentialsPath = (): string => join(getMcpcHome(), 'credentials.json'); + +async function fileGet(account: string): Promise { + try { + const data = JSON.parse(await readFile(credentialsPath(), 'utf8')) as Record; + return data[account] ?? null; + } catch { + return null; + } +} + +async function fileSet(account: string, value: string): Promise { + await withFileLock(credentialsPath(), async () => { + const raw = await readFile(credentialsPath(), 'utf8').catch(() => '{}'); + const data = { ...(JSON.parse(raw) as Record), [account]: value }; + await writeFile(credentialsPath(), JSON.stringify(data), { mode: 0o600 }); + }); +} + +async function fileDelete(account: string): Promise { + return withFileLock(credentialsPath(), async () => { + const raw = await readFile(credentialsPath(), 'utf8').catch(() => '{}'); + const data = JSON.parse(raw) as Record; + if (!(account in data)) return false; + delete data[account]; + await writeFile(credentialsPath(), JSON.stringify(data), { mode: 0o600 }); + return true; + }); +} + +// ============================================================================= +// Keychain wrappers with automatic file fallback +// ============================================================================= + +let keychainAvailable: boolean | null = null; // null = untested + +async function keychainSet(account: string, value: string): Promise { + if (keychainAvailable === false) return fileSet(account, value); + try { + new Entry(SERVICE_NAME, account).setPassword(value); + keychainAvailable = true; + } catch (error) { + if (keychainAvailable === null) { + logger.warn( + `OS keychain unavailable (${(error as Error).message}), ` + + `falling back to file-based credential storage (${credentialsPath()}). ` + + `Install a keyring daemon (e.g. gnome-keyring or kwallet) for better security.` + ); + } + keychainAvailable = false; + await fileSet(account, value); + } +} + +async function keychainGet(account: string): Promise { + if (keychainAvailable === false) return fileGet(account); + try { + const result = new Entry(SERVICE_NAME, account).getPassword(); + keychainAvailable = true; + return result ?? null; + } catch { + keychainAvailable = false; + return fileGet(account); + } +} + +async function keychainDelete(account: string): Promise { + if (keychainAvailable === false) return fileDelete(account); + try { + const result = new Entry(SERVICE_NAME, account).deletePassword(); + keychainAvailable = true; + return result; + } catch { + keychainAvailable = false; + return fileDelete(account); + } +} + +async function keychainGetParsed(account: string, label: string): Promise { + const raw = await keychainGet(account); + if (!raw) return undefined; + try { + return JSON.parse(raw) as T; + } catch (error) { + logger.error(`Failed to parse ${label}: ${(error as Error).message}`); + return undefined; + } +} + +// ============================================================================= +// Types +// ============================================================================= + export interface OAuthClientInfo { clientId: string; clientSecret?: string; } -/** - * OAuth tokens - */ export interface OAuthTokenInfo { accessToken: string; refreshToken?: string; @@ -32,223 +126,124 @@ export interface OAuthTokenInfo { scope?: string; } -/** - * Get a keychain account name for OAuth client info - * Uses getServerHost() to normalize the server URL to a canonical host - */ -function buildOAuthClientAccountName(serverUrl: string, profileName: string): string { - const host = getServerHost(serverUrl); - return `auth-profile:${host}:${profileName}:client`; -} +// ============================================================================= +// Account name builders +// ============================================================================= -/** - * Get a keychain account name for OAuth tokens - * Uses getServerHost() to normalize the server URL to a canonical host - */ -function buildOAuthTokensAccountName(serverUrl: string, profileName: string): string { - const host = getServerHost(serverUrl); - return `auth-profile:${host}:${profileName}:tokens`; -} +const oauthClientAccount = (serverUrl: string, profileName: string): string => + `auth-profile:${getServerHost(serverUrl)}:${profileName}:client`; -/** - * Get a keychain account name for session headers - */ -function buildSessionAccountName(sessionName: string): string { - return `session:${sessionName}:headers`; -} +const oauthTokensAccount = (serverUrl: string, profileName: string): string => + `auth-profile:${getServerHost(serverUrl)}:${profileName}:tokens`; -/** - * Get a keychain account name for proxy bearer token - */ -function buildProxyBearerTokenAccountName(sessionName: string): string { - return `session:${sessionName}:proxy-bearer-token`; -} +const sessionHeadersAccount = (sessionName: string): string => + `session:${sessionName}:headers`; -/** - * Store OAuth client info in keychain - */ +const proxyBearerTokenAccount = (sessionName: string): string => + `session:${sessionName}:proxy-bearer-token`; + +// ============================================================================= +// Public API +// ============================================================================= + +/** Store OAuth client registration info for an auth profile. */ export async function storeKeychainOAuthClientInfo( serverUrl: string, profileName: string, client: OAuthClientInfo ): Promise { - const account = buildOAuthClientAccountName(serverUrl, profileName); - const value = JSON.stringify(client); - logger.debug(`Storing OAuth client info for ${profileName} @ ${serverUrl}`); - new Entry(SERVICE_NAME, account).setPassword(value); + await keychainSet(oauthClientAccount(serverUrl, profileName), JSON.stringify(client)); } -/** - * Get OAuth client info from keychain - */ +/** Read OAuth client registration info for an auth profile. */ export async function readKeychainOAuthClientInfo( serverUrl: string, profileName: string ): Promise { - const account = buildOAuthClientAccountName(serverUrl, profileName); - logger.debug(`Retrieving OAuth client info for ${profileName} @ ${serverUrl}`); - const value = new Entry(SERVICE_NAME, account).getPassword(); - - if (!value) { - return undefined; - } - - try { - return JSON.parse(value) as OAuthClientInfo; - } catch (error) { - logger.error(`Failed to parse OAuth client info from keychain: ${(error as Error).message}`); - return undefined; - } + return keychainGetParsed(oauthClientAccount(serverUrl, profileName), 'OAuth client info'); } -/** - * Delete OAuth client info from keychain - */ +/** Delete OAuth client registration info for an auth profile. */ export async function removeKeychainOAuthClientInfo( serverUrl: string, profileName: string ): Promise { - const account = buildOAuthClientAccountName(serverUrl, profileName); - logger.debug(`Deleting OAuth client info for ${profileName} @ ${serverUrl}`); - return new Entry(SERVICE_NAME, account).deletePassword(); + return keychainDelete(oauthClientAccount(serverUrl, profileName)); } -/** - * Store OAuth tokens in keychain - * TODO: The operations on Keychain should be done under profiles file lock, to ensure atomocity... - */ +/** Store OAuth tokens for an auth profile. */ export async function storeKeychainOAuthTokenInfo( serverUrl: string, profileName: string, tokens: OAuthTokenInfo ): Promise { - const account = buildOAuthTokensAccountName(serverUrl, profileName); - const value = JSON.stringify(tokens); - logger.debug(`Storing OAuth tokens for ${profileName} @ ${serverUrl}`); - new Entry(SERVICE_NAME, account).setPassword(value); + await keychainSet(oauthTokensAccount(serverUrl, profileName), JSON.stringify(tokens)); } -/** - * Get OAuth tokens from keychain - */ +/** Read OAuth tokens for an auth profile. */ export async function readKeychainOAuthTokenInfo( serverUrl: string, profileName: string ): Promise { - const account = buildOAuthTokensAccountName(serverUrl, profileName); - logger.debug(`Retrieving OAuth tokens for ${profileName} @ ${serverUrl}`); - const value = new Entry(SERVICE_NAME, account).getPassword(); - - if (!value) { - return undefined; - } - - try { - return JSON.parse(value) as OAuthTokenInfo; - } catch (error) { - logger.error(`Failed to parse OAuth tokens from keychain: ${(error as Error).message}`); - return undefined; - } + return keychainGetParsed(oauthTokensAccount(serverUrl, profileName), 'OAuth tokens'); } -/** - * Delete OAuth tokens from keychain - */ +/** Delete OAuth tokens for an auth profile. */ export async function removeKeychainOAuthTokenInfo( serverUrl: string, profileName: string ): Promise { - const account = buildOAuthTokensAccountName(serverUrl, profileName); - logger.debug(`Deleting OAuth tokens for ${profileName} @ ${serverUrl}`); - return new Entry(SERVICE_NAME, account).deletePassword(); + return keychainDelete(oauthTokensAccount(serverUrl, profileName)); } -/** - * Store HTTP headers for a session in keychain - * All headers from --header flags are treated as potentially sensitive - */ +/** Store custom HTTP headers for a session. */ export async function storeKeychainSessionHeaders( sessionName: string, headers: Record ): Promise { - const account = buildSessionAccountName(sessionName); - const value = JSON.stringify(headers); - logger.debug(`Storing headers for session ${sessionName}`); - new Entry(SERVICE_NAME, account).setPassword(value); + await keychainSet(sessionHeadersAccount(sessionName), JSON.stringify(headers)); } -/** - * Retrieve HTTP headers for a session from keychain - */ +/** Read custom HTTP headers for a session. */ export async function readKeychainSessionHeaders( sessionName: string ): Promise | undefined> { - const account = buildSessionAccountName(sessionName); - logger.debug(`Retrieving headers for session ${sessionName}`); - const value = new Entry(SERVICE_NAME, account).getPassword(); - - if (!value) { - return undefined; - } - - try { - return JSON.parse(value) as Record; - } catch (error) { - logger.error(`Failed to parse headers from keychain: ${(error as Error).message}`); - return undefined; - } + return keychainGetParsed>(sessionHeadersAccount(sessionName), 'session headers'); } -/** - * Delete HTTP headers for a session from keychain - */ +/** Delete custom HTTP headers for a session. */ export async function removeKeychainSessionHeaders(sessionName: string): Promise { - const account = buildSessionAccountName(sessionName); - logger.debug(`Deleting headers for session ${sessionName}`); - return new Entry(SERVICE_NAME, account).deletePassword(); + return keychainDelete(sessionHeadersAccount(sessionName)); } -/** - * Store proxy bearer token for a session in keychain - * Used to secure the proxy MCP server with authentication - */ +/** Store the bearer token used to authenticate requests to the proxy server. */ export async function storeKeychainProxyBearerToken( sessionName: string, token: string ): Promise { - const account = buildProxyBearerTokenAccountName(sessionName); - logger.debug(`Storing proxy bearer token for session ${sessionName}`); - new Entry(SERVICE_NAME, account).setPassword(token); + await keychainSet(proxyBearerTokenAccount(sessionName), token); } -/** - * Retrieve proxy bearer token for a session from keychain - */ +/** Read the bearer token used to authenticate requests to the proxy server. */ export async function readKeychainProxyBearerToken( sessionName: string ): Promise { - const account = buildProxyBearerTokenAccountName(sessionName); - logger.debug(`Retrieving proxy bearer token for session ${sessionName}`); - return new Entry(SERVICE_NAME, account).getPassword() ?? undefined; + return (await keychainGet(proxyBearerTokenAccount(sessionName))) ?? undefined; } -/** - * Delete proxy bearer token for a session from keychain - */ +/** Delete the bearer token used to authenticate requests to the proxy server. */ export async function removeKeychainProxyBearerToken(sessionName: string): Promise { - const account = buildProxyBearerTokenAccountName(sessionName); - logger.debug(`Deleting proxy bearer token for session ${sessionName}`); - return new Entry(SERVICE_NAME, account).deletePassword(); + return keychainDelete(proxyBearerTokenAccount(sessionName)); } From fd0d3ed12f1168d6f7b75a9dbcee3be78c63e043 Mon Sep 17 00:00:00 2001 From: Jan Curn Date: Thu, 5 Mar 2026 00:23:15 +0100 Subject: [PATCH 2/4] Better code --- src/lib/auth/keychain.ts | 37 +++++++++++++------------------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/src/lib/auth/keychain.ts b/src/lib/auth/keychain.ts index 93366bfe..83371a38 100644 --- a/src/lib/auth/keychain.ts +++ b/src/lib/auth/keychain.ts @@ -55,11 +55,12 @@ async function fileDelete(account: string): Promise { let keychainAvailable: boolean | null = null; // null = untested -async function keychainSet(account: string, value: string): Promise { - if (keychainAvailable === false) return fileSet(account, value); +function withKeychain(keychainOp: () => T, fallback: () => Promise): Promise { + if (keychainAvailable === false) return fallback(); try { - new Entry(SERVICE_NAME, account).setPassword(value); + const result = keychainOp(); keychainAvailable = true; + return Promise.resolve(result); } catch (error) { if (keychainAvailable === null) { logger.warn( @@ -69,32 +70,20 @@ async function keychainSet(account: string, value: string): Promise { ); } keychainAvailable = false; - await fileSet(account, value); + return fallback(); } } -async function keychainGet(account: string): Promise { - if (keychainAvailable === false) return fileGet(account); - try { - const result = new Entry(SERVICE_NAME, account).getPassword(); - keychainAvailable = true; - return result ?? null; - } catch { - keychainAvailable = false; - return fileGet(account); - } +function keychainSet(account: string, value: string): Promise { + return withKeychain(() => { new Entry(SERVICE_NAME, account).setPassword(value); }, () => fileSet(account, value)); } -async function keychainDelete(account: string): Promise { - if (keychainAvailable === false) return fileDelete(account); - try { - const result = new Entry(SERVICE_NAME, account).deletePassword(); - keychainAvailable = true; - return result; - } catch { - keychainAvailable = false; - return fileDelete(account); - } +function keychainGet(account: string): Promise { + return withKeychain(() => new Entry(SERVICE_NAME, account).getPassword() ?? null, () => fileGet(account)); +} + +function keychainDelete(account: string): Promise { + return withKeychain(() => new Entry(SERVICE_NAME, account).deletePassword(), () => fileDelete(account)); } async function keychainGetParsed(account: string, label: string): Promise { From fc26de5625b29d2520a1fb6b72250e91175c94fd Mon Sep 17 00:00:00 2001 From: Jan Curn Date: Thu, 5 Mar 2026 00:34:34 +0100 Subject: [PATCH 3/4] Lint --- src/lib/auth/keychain.ts | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/lib/auth/keychain.ts b/src/lib/auth/keychain.ts index 83371a38..8fde65eb 100644 --- a/src/lib/auth/keychain.ts +++ b/src/lib/auth/keychain.ts @@ -75,15 +75,26 @@ function withKeychain(keychainOp: () => T, fallback: () => Promise): Promi } function keychainSet(account: string, value: string): Promise { - return withKeychain(() => { new Entry(SERVICE_NAME, account).setPassword(value); }, () => fileSet(account, value)); + return withKeychain( + () => { + new Entry(SERVICE_NAME, account).setPassword(value); + }, + () => fileSet(account, value) + ); } function keychainGet(account: string): Promise { - return withKeychain(() => new Entry(SERVICE_NAME, account).getPassword() ?? null, () => fileGet(account)); + return withKeychain( + () => new Entry(SERVICE_NAME, account).getPassword() ?? null, + () => fileGet(account) + ); } function keychainDelete(account: string): Promise { - return withKeychain(() => new Entry(SERVICE_NAME, account).deletePassword(), () => fileDelete(account)); + return withKeychain( + () => new Entry(SERVICE_NAME, account).deletePassword(), + () => fileDelete(account) + ); } async function keychainGetParsed(account: string, label: string): Promise { @@ -125,8 +136,7 @@ const oauthClientAccount = (serverUrl: string, profileName: string): string => const oauthTokensAccount = (serverUrl: string, profileName: string): string => `auth-profile:${getServerHost(serverUrl)}:${profileName}:tokens`; -const sessionHeadersAccount = (sessionName: string): string => - `session:${sessionName}:headers`; +const sessionHeadersAccount = (sessionName: string): string => `session:${sessionName}:headers`; const proxyBearerTokenAccount = (sessionName: string): string => `session:${sessionName}:proxy-bearer-token`; @@ -151,7 +161,10 @@ export async function readKeychainOAuthClientInfo( profileName: string ): Promise { logger.debug(`Retrieving OAuth client info for ${profileName} @ ${serverUrl}`); - return keychainGetParsed(oauthClientAccount(serverUrl, profileName), 'OAuth client info'); + return keychainGetParsed( + oauthClientAccount(serverUrl, profileName), + 'OAuth client info' + ); } /** Delete OAuth client registration info for an auth profile. */ @@ -179,7 +192,10 @@ export async function readKeychainOAuthTokenInfo( profileName: string ): Promise { logger.debug(`Retrieving OAuth tokens for ${profileName} @ ${serverUrl}`); - return keychainGetParsed(oauthTokensAccount(serverUrl, profileName), 'OAuth tokens'); + return keychainGetParsed( + oauthTokensAccount(serverUrl, profileName), + 'OAuth tokens' + ); } /** Delete OAuth tokens for an auth profile. */ @@ -205,7 +221,10 @@ export async function readKeychainSessionHeaders( sessionName: string ): Promise | undefined> { logger.debug(`Retrieving headers for session ${sessionName}`); - return keychainGetParsed>(sessionHeadersAccount(sessionName), 'session headers'); + return keychainGetParsed>( + sessionHeadersAccount(sessionName), + 'session headers' + ); } /** Delete custom HTTP headers for a session. */ From 6fa2e0ed3a1accc46696ff7aa654ed8437498dfe Mon Sep 17 00:00:00 2001 From: Jan Curn Date: Thu, 5 Mar 2026 01:04:17 +0100 Subject: [PATCH 4/4] Fixes --- CHANGELOG.md | 3 +++ src/lib/auth/keychain.ts | 4 ++-- test/e2e/suites/basic/auth-errors.test.sh | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5ae1734..27132a34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- OS keychain now falls back to `~/.mcpc/credentials.json` (mode 0600) when no keyring daemon is available (e.g. headless Linux servers, containers) + ## [0.1.10] - 2026-03-01 ### Added diff --git a/src/lib/auth/keychain.ts b/src/lib/auth/keychain.ts index 8fde65eb..24d8f649 100644 --- a/src/lib/auth/keychain.ts +++ b/src/lib/auth/keychain.ts @@ -8,7 +8,7 @@ import { Entry } from '@napi-rs/keyring'; import { readFile, writeFile } from 'fs/promises'; import { join } from 'path'; -import { createLogger } from '../logger.js'; +import { createLogger, getJsonMode } from '../logger.js'; import { getServerHost, getMcpcHome } from '../utils.js'; import { withFileLock } from '../file-lock.js'; @@ -62,7 +62,7 @@ function withKeychain(keychainOp: () => T, fallback: () => Promise): Promi keychainAvailable = true; return Promise.resolve(result); } catch (error) { - if (keychainAvailable === null) { + if (keychainAvailable === null && !getJsonMode()) { logger.warn( `OS keychain unavailable (${(error as Error).message}), ` + `falling back to file-based credential storage (${credentialsPath()}). ` + diff --git a/test/e2e/suites/basic/auth-errors.test.sh b/test/e2e/suites/basic/auth-errors.test.sh index 79e1e83c..21ac7586 100755 --- a/test/e2e/suites/basic/auth-errors.test.sh +++ b/test/e2e/suites/basic/auth-errors.test.sh @@ -58,8 +58,8 @@ test_pass # Test: OAuth-enabled remote server without profile hints at login # ============================================================================= -# Use mcp.sentry.dev which requires OAuth authentication -OAUTH_SERVER="https://mcp.sentry.dev/mcp" +# Use mcp.slack.com which requires OAuth authentication +OAUTH_SERVER="https://mcp.slack.com/mcp" test_case "OAuth server without profile shows login hint" run_mcpc "$OAUTH_SERVER" tools-list