chore(oauth): centralize credentials
This commit is contained in:
parent
53a32e2429
commit
e1bdcda28e
@ -2,7 +2,8 @@
|
||||
|
||||
## [Unreleased]
|
||||
### CLI
|
||||
- Nothing yet.
|
||||
- Centralized OAuth credentials in a shared vault (`~/.mcporter/.credentials.json`) while still honoring per-server `tokenCacheDir` when present; legacy per-server caches are migrated automatically.
|
||||
- `mcporter auth --reset` now clears the vault and legacy caches without crashing on corrupted credential files, making re-auth reliable for servers like Gmail.
|
||||
|
||||
## [0.6.6] - 2025-11-28
|
||||
### CLI
|
||||
|
||||
11
src/cli.ts
11
src/cli.ts
@ -25,6 +25,7 @@ import { DaemonClient } from './daemon/client.js';
|
||||
import { createKeepAliveRuntime } from './daemon/runtime-wrapper.js';
|
||||
import { analyzeConnectionError } from './error-classifier.js';
|
||||
import { isKeepAliveServer } from './lifecycle.js';
|
||||
import { clearOAuthCaches } from './oauth-persistence.js';
|
||||
import { createRuntime, MCPORTER_VERSION } from './runtime.js';
|
||||
|
||||
export { parseCallArguments } from './cli/call-arguments.js';
|
||||
@ -471,14 +472,8 @@ export async function handleAuth(runtime: Awaited<ReturnType<typeof createRuntim
|
||||
|
||||
const definition = runtime.getDefinition(target);
|
||||
if (shouldReset) {
|
||||
const tokenDir = definition.tokenCacheDir;
|
||||
if (tokenDir) {
|
||||
// Drop the cached credentials so the next auth run starts cleanly.
|
||||
await fsPromises.rm(tokenDir, { recursive: true, force: true });
|
||||
logInfo(`Cleared cached credentials for '${target}' at ${tokenDir}`);
|
||||
} else {
|
||||
logWarn(`Server '${target}' does not expose a token cache path.`);
|
||||
}
|
||||
await clearOAuthCaches(definition);
|
||||
logInfo(`Cleared cached credentials for '${target}'.`);
|
||||
}
|
||||
|
||||
// Kick off the interactive OAuth flow without blocking list output. We retry once if the
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { loadServerDefinitions } from '../../config.js';
|
||||
import { CliUsageError } from '../errors.js';
|
||||
import { resolveServerDefinition } from './shared.js';
|
||||
import type { ConfigCliOptions } from './types.js';
|
||||
import { clearOAuthCaches } from '../../oauth-persistence.js';
|
||||
|
||||
export async function handleLoginCommand(options: ConfigCliOptions, args: string[]): Promise<void> {
|
||||
if (args.length === 0) {
|
||||
@ -18,10 +18,6 @@ export async function handleLogoutCommand(options: ConfigCliOptions, args: strin
|
||||
}
|
||||
const servers = await loadServerDefinitions(options.loadOptions);
|
||||
const target = resolveServerDefinition(name, servers);
|
||||
if (!target.tokenCacheDir) {
|
||||
console.log(`Server '${name}' does not expose a token cache directory.`);
|
||||
return;
|
||||
}
|
||||
await fs.rm(target.tokenCacheDir, { recursive: true, force: true });
|
||||
console.log(`Cleared cached credentials for '${target.name}' (${target.tokenCacheDir})`);
|
||||
await clearOAuthCaches(target);
|
||||
console.log(`Cleared cached credentials for '${target.name}'`);
|
||||
}
|
||||
|
||||
@ -15,9 +15,6 @@ export async function handleDoctorCommand(options: ConfigCliOptions, _args: stri
|
||||
if (server.command.kind === 'stdio' && !path.isAbsolute(server.command.cwd)) {
|
||||
issues.push(`Server '${server.name}' has a non-absolute working directory.`);
|
||||
}
|
||||
if (server.auth === 'oauth' && !server.tokenCacheDir) {
|
||||
issues.push(`Server '${server.name}' enables OAuth but lacks a token cache directory.`);
|
||||
}
|
||||
}
|
||||
if (issues.length === 0) {
|
||||
console.log('Config looks good.');
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { CommandSpec, RawEntry, ServerDefinition, ServerLoggingOptions, ServerSource } from './config-schema.js';
|
||||
import { expandHome } from './env.js';
|
||||
import { resolveLifecycle } from './lifecycle.js';
|
||||
@ -41,9 +39,6 @@ export function normalizeServerEntry(
|
||||
throw new Error(`Server '${name}' is missing a baseUrl/url or command definition in mcporter.json`);
|
||||
}
|
||||
|
||||
const resolvedTokenCacheDir =
|
||||
auth === 'oauth' ? (tokenCacheDir ?? path.join(os.homedir(), '.mcporter', name)) : (tokenCacheDir ?? undefined);
|
||||
|
||||
const lifecycle = resolveLifecycle(name, raw.lifecycle, command);
|
||||
const logging = normalizeLogging(raw.logging);
|
||||
|
||||
@ -53,7 +48,7 @@ export function normalizeServerEntry(
|
||||
command,
|
||||
env,
|
||||
auth,
|
||||
tokenCacheDir: resolvedTokenCacheDir,
|
||||
tokenCacheDir,
|
||||
clientName,
|
||||
oauthRedirectUrl,
|
||||
source,
|
||||
|
||||
267
src/oauth-persistence.ts
Normal file
267
src/oauth-persistence.ts
Normal file
@ -0,0 +1,267 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { OAuthClientInformationMixed, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import type { ServerDefinition } from './config.js';
|
||||
import { readJsonFile, writeJsonFile } from './fs-json.js';
|
||||
import type { Logger } from './logging.js';
|
||||
import { clearVaultEntry, loadVaultEntry, saveVaultEntry } from './oauth-vault.js';
|
||||
|
||||
export type OAuthClearScope = 'all' | 'client' | 'tokens' | 'verifier' | 'state';
|
||||
|
||||
export interface OAuthPersistence {
|
||||
describe(): string;
|
||||
readTokens(): Promise<OAuthTokens | undefined>;
|
||||
saveTokens(tokens: OAuthTokens): Promise<void>;
|
||||
readClientInfo(): Promise<OAuthClientInformationMixed | undefined>;
|
||||
saveClientInfo(info: OAuthClientInformationMixed): Promise<void>;
|
||||
readCodeVerifier(): Promise<string | undefined>;
|
||||
saveCodeVerifier(value: string): Promise<void>;
|
||||
readState(): Promise<string | undefined>;
|
||||
saveState(value: string): Promise<void>;
|
||||
clear(scope: OAuthClearScope): Promise<void>;
|
||||
}
|
||||
|
||||
class DirectoryPersistence implements OAuthPersistence {
|
||||
private readonly tokenPath: string;
|
||||
private readonly clientInfoPath: string;
|
||||
private readonly codeVerifierPath: string;
|
||||
private readonly statePath: string;
|
||||
|
||||
constructor(private readonly root: string, private readonly logger?: Logger) {
|
||||
this.tokenPath = path.join(root, 'tokens.json');
|
||||
this.clientInfoPath = path.join(root, 'client.json');
|
||||
this.codeVerifierPath = path.join(root, 'code_verifier.txt');
|
||||
this.statePath = path.join(root, 'state.txt');
|
||||
}
|
||||
|
||||
describe(): string {
|
||||
return this.root;
|
||||
}
|
||||
|
||||
private async ensureDir() {
|
||||
await fs.mkdir(this.root, { recursive: true });
|
||||
}
|
||||
|
||||
async readTokens(): Promise<OAuthTokens | undefined> {
|
||||
return readJsonFile<OAuthTokens>(this.tokenPath);
|
||||
}
|
||||
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
await this.ensureDir();
|
||||
await writeJsonFile(this.tokenPath, tokens);
|
||||
this.logger?.debug?.(`Saved tokens to ${this.tokenPath}`);
|
||||
}
|
||||
|
||||
async readClientInfo(): Promise<OAuthClientInformationMixed | undefined> {
|
||||
return readJsonFile<OAuthClientInformationMixed>(this.clientInfoPath);
|
||||
}
|
||||
|
||||
async saveClientInfo(info: OAuthClientInformationMixed): Promise<void> {
|
||||
await this.ensureDir();
|
||||
await writeJsonFile(this.clientInfoPath, info);
|
||||
}
|
||||
|
||||
async readCodeVerifier(): Promise<string | undefined> {
|
||||
try {
|
||||
return (await fs.readFile(this.codeVerifierPath, 'utf8')).trim();
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async saveCodeVerifier(value: string): Promise<void> {
|
||||
await this.ensureDir();
|
||||
await fs.writeFile(this.codeVerifierPath, value, 'utf8');
|
||||
}
|
||||
|
||||
async readState(): Promise<string | undefined> {
|
||||
return readJsonFile<string>(this.statePath);
|
||||
}
|
||||
|
||||
async saveState(value: string): Promise<void> {
|
||||
await this.ensureDir();
|
||||
await writeJsonFile(this.statePath, value);
|
||||
}
|
||||
|
||||
async clear(scope: OAuthClearScope): Promise<void> {
|
||||
const files: string[] = [];
|
||||
if (scope === 'all' || scope === 'tokens') files.push(this.tokenPath);
|
||||
if (scope === 'all' || scope === 'client') files.push(this.clientInfoPath);
|
||||
if (scope === 'all' || scope === 'verifier') files.push(this.codeVerifierPath);
|
||||
if (scope === 'all' || scope === 'state') files.push(this.statePath);
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
try {
|
||||
await fs.unlink(file);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class VaultPersistence implements OAuthPersistence {
|
||||
constructor(private readonly definition: ServerDefinition) {}
|
||||
|
||||
describe(): string {
|
||||
return '~/.mcporter/.credentials.json (vault)';
|
||||
}
|
||||
|
||||
async readTokens(): Promise<OAuthTokens | undefined> {
|
||||
return (await loadVaultEntry(this.definition))?.tokens;
|
||||
}
|
||||
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
await saveVaultEntry(this.definition, { tokens });
|
||||
}
|
||||
|
||||
async readClientInfo(): Promise<OAuthClientInformationMixed | undefined> {
|
||||
return (await loadVaultEntry(this.definition))?.clientInfo;
|
||||
}
|
||||
|
||||
async saveClientInfo(info: OAuthClientInformationMixed): Promise<void> {
|
||||
await saveVaultEntry(this.definition, { clientInfo: info });
|
||||
}
|
||||
|
||||
async readCodeVerifier(): Promise<string | undefined> {
|
||||
return (await loadVaultEntry(this.definition))?.codeVerifier;
|
||||
}
|
||||
|
||||
async saveCodeVerifier(value: string): Promise<void> {
|
||||
await saveVaultEntry(this.definition, { codeVerifier: value });
|
||||
}
|
||||
|
||||
async readState(): Promise<string | undefined> {
|
||||
return (await loadVaultEntry(this.definition))?.state;
|
||||
}
|
||||
|
||||
async saveState(value: string): Promise<void> {
|
||||
await saveVaultEntry(this.definition, { state: value });
|
||||
}
|
||||
|
||||
async clear(scope: OAuthClearScope): Promise<void> {
|
||||
await clearVaultEntry(this.definition, scope);
|
||||
}
|
||||
}
|
||||
|
||||
class CompositePersistence implements OAuthPersistence {
|
||||
constructor(private readonly stores: OAuthPersistence[]) {}
|
||||
|
||||
describe(): string {
|
||||
return this.stores.map((store) => store.describe()).join(' + ');
|
||||
}
|
||||
|
||||
async readTokens(): Promise<OAuthTokens | undefined> {
|
||||
for (const store of this.stores) {
|
||||
const result = await store.readTokens();
|
||||
if (result) return result;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
await Promise.all(this.stores.map((store) => store.saveTokens(tokens)));
|
||||
}
|
||||
|
||||
async readClientInfo(): Promise<OAuthClientInformationMixed | undefined> {
|
||||
for (const store of this.stores) {
|
||||
const result = await store.readClientInfo();
|
||||
if (result) return result;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async saveClientInfo(info: OAuthClientInformationMixed): Promise<void> {
|
||||
await Promise.all(this.stores.map((store) => store.saveClientInfo(info)));
|
||||
}
|
||||
|
||||
async readCodeVerifier(): Promise<string | undefined> {
|
||||
for (const store of this.stores) {
|
||||
const result = await store.readCodeVerifier();
|
||||
if (result) return result;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async saveCodeVerifier(value: string): Promise<void> {
|
||||
await Promise.all(this.stores.map((store) => store.saveCodeVerifier(value)));
|
||||
}
|
||||
|
||||
async readState(): Promise<string | undefined> {
|
||||
for (const store of this.stores) {
|
||||
const result = await store.readState();
|
||||
if (result) return result;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async saveState(value: string): Promise<void> {
|
||||
await Promise.all(this.stores.map((store) => store.saveState(value)));
|
||||
}
|
||||
|
||||
async clear(scope: OAuthClearScope): Promise<void> {
|
||||
await Promise.all(this.stores.map((store) => store.clear(scope)));
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildOAuthPersistence(
|
||||
definition: ServerDefinition,
|
||||
logger?: Logger
|
||||
): Promise<OAuthPersistence> {
|
||||
const vault = new VaultPersistence(definition);
|
||||
const stores: OAuthPersistence[] = [vault];
|
||||
|
||||
if (definition.tokenCacheDir) {
|
||||
stores.unshift(new DirectoryPersistence(definition.tokenCacheDir, logger));
|
||||
}
|
||||
|
||||
// Migrate legacy default per-server cache (~/.mcporter/<name>) into the vault if present.
|
||||
const legacyDir = path.join(os.homedir(), '.mcporter', definition.name);
|
||||
if (!definition.tokenCacheDir && legacyDir) {
|
||||
const legacy = new DirectoryPersistence(legacyDir, logger);
|
||||
const legacyTokens = await legacy.readTokens();
|
||||
const legacyClient = await legacy.readClientInfo();
|
||||
const legacyVerifier = await legacy.readCodeVerifier();
|
||||
const legacyState = await legacy.readState();
|
||||
if (legacyTokens || legacyClient || legacyVerifier || legacyState) {
|
||||
if (legacyTokens) {
|
||||
await vault.saveTokens(legacyTokens);
|
||||
}
|
||||
if (legacyClient) await vault.saveClientInfo(legacyClient);
|
||||
if (legacyVerifier) await vault.saveCodeVerifier(legacyVerifier);
|
||||
if (legacyState) await vault.saveState(legacyState);
|
||||
logger?.info?.(`Migrated legacy OAuth cache for '${definition.name}' into vault.`);
|
||||
}
|
||||
}
|
||||
|
||||
return stores.length === 1 ? vault : new CompositePersistence(stores);
|
||||
}
|
||||
|
||||
export async function clearOAuthCaches(
|
||||
definition: ServerDefinition,
|
||||
logger?: Logger,
|
||||
scope: OAuthClearScope = 'all'
|
||||
): Promise<void> {
|
||||
const persistence = await buildOAuthPersistence(definition, logger);
|
||||
await persistence.clear(scope);
|
||||
|
||||
const legacyDir = path.join(os.homedir(), '.mcporter', definition.name);
|
||||
if (legacyDir && (!definition.tokenCacheDir || legacyDir !== definition.tokenCacheDir)) {
|
||||
const legacy = new DirectoryPersistence(legacyDir, logger);
|
||||
await legacy.clear(scope);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readCachedAccessToken(definition: ServerDefinition, logger?: Logger): Promise<string | undefined> {
|
||||
const persistence = await buildOAuthPersistence(definition, logger);
|
||||
const tokens = await persistence.readTokens();
|
||||
if (tokens && typeof tokens.access_token === 'string' && tokens.access_token.trim().length > 0) {
|
||||
return tokens.access_token;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
99
src/oauth-vault.ts
Normal file
99
src/oauth-vault.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { OAuthClientInformationMixed, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import type { ServerDefinition } from './config.js';
|
||||
import { readJsonFile, writeJsonFile } from './fs-json.js';
|
||||
|
||||
const VAULT_PATH = path.join(os.homedir(), '.mcporter', '.credentials.json');
|
||||
|
||||
type VaultKey = string;
|
||||
|
||||
export interface VaultEntry {
|
||||
serverName: string;
|
||||
serverUrl?: string;
|
||||
tokens?: OAuthTokens;
|
||||
clientInfo?: OAuthClientInformationMixed;
|
||||
codeVerifier?: string;
|
||||
state?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface VaultFile {
|
||||
version: 1;
|
||||
entries: Record<VaultKey, VaultEntry>;
|
||||
}
|
||||
|
||||
async function readVault(): Promise<VaultFile> {
|
||||
try {
|
||||
const existing = await readJsonFile<VaultFile>(VAULT_PATH);
|
||||
if (existing && existing.version === 1 && existing.entries && typeof existing.entries === 'object') {
|
||||
return existing;
|
||||
}
|
||||
} catch {
|
||||
// Corrupt or unreadable vault; reset to empty.
|
||||
}
|
||||
return { version: 1, entries: {} };
|
||||
}
|
||||
|
||||
async function writeVault(contents: VaultFile): Promise<void> {
|
||||
const dir = path.dirname(VAULT_PATH);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await writeJsonFile(VAULT_PATH, contents);
|
||||
}
|
||||
|
||||
export function vaultKeyForDefinition(definition: ServerDefinition): VaultKey {
|
||||
const descriptor = {
|
||||
name: definition.name,
|
||||
url: definition.command.kind === 'http' ? definition.command.url.toString() : null,
|
||||
command:
|
||||
definition.command.kind === 'stdio'
|
||||
? { command: definition.command.command, args: definition.command.args ?? [] }
|
||||
: null,
|
||||
};
|
||||
const hash = crypto.createHash('sha256').update(JSON.stringify(descriptor)).digest('hex').slice(0, 16);
|
||||
return `${definition.name}|${hash}`;
|
||||
}
|
||||
|
||||
export async function loadVaultEntry(definition: ServerDefinition): Promise<VaultEntry | undefined> {
|
||||
const vault = await readVault();
|
||||
return vault.entries[vaultKeyForDefinition(definition)];
|
||||
}
|
||||
|
||||
export async function saveVaultEntry(definition: ServerDefinition, patch: Partial<VaultEntry>): Promise<void> {
|
||||
const vault = await readVault();
|
||||
const key = vaultKeyForDefinition(definition);
|
||||
const current = vault.entries[key] ?? {
|
||||
serverName: definition.name,
|
||||
serverUrl: definition.command.kind === 'http' ? definition.command.url.toString() : undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
vault.entries[key] = {
|
||||
...current,
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeVault(vault);
|
||||
}
|
||||
|
||||
export async function clearVaultEntry(definition: ServerDefinition, scope: 'all' | 'tokens' | 'client' | 'verifier' | 'state'): Promise<void> {
|
||||
const vault = await readVault();
|
||||
const key = vaultKeyForDefinition(definition);
|
||||
const existing = vault.entries[key];
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
if (scope === 'all') {
|
||||
delete vault.entries[key];
|
||||
} else {
|
||||
const updated: VaultEntry = { ...existing };
|
||||
if (scope === 'tokens') delete updated.tokens;
|
||||
if (scope === 'client') delete updated.clientInfo;
|
||||
if (scope === 'verifier') delete updated.codeVerifier;
|
||||
if (scope === 'state') delete updated.state;
|
||||
updated.updatedAt = new Date().toISOString();
|
||||
vault.entries[key] = updated;
|
||||
}
|
||||
await writeVault(vault);
|
||||
}
|
||||
79
src/oauth.ts
79
src/oauth.ts
@ -1,9 +1,6 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { URL } from 'node:url';
|
||||
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||
import type {
|
||||
@ -12,7 +9,8 @@ import type {
|
||||
OAuthTokens,
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import type { ServerDefinition } from './config.js';
|
||||
import { readJsonFile, writeJsonFile } from './fs-json.js';
|
||||
import type { OAuthPersistence } from './oauth-persistence.js';
|
||||
import { buildOAuthPersistence } from './oauth-persistence.js';
|
||||
|
||||
const CALLBACK_HOST = '127.0.0.1';
|
||||
const CALLBACK_PATH = '/callback';
|
||||
@ -57,35 +55,24 @@ function openExternal(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ensureDirectory guarantees a directory exists before writing JSON blobs.
|
||||
async function ensureDirectory(dir: string) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// FileOAuthClientProvider persists OAuth session artifacts to disk and captures callback redirects.
|
||||
class FileOAuthClientProvider implements OAuthClientProvider {
|
||||
private readonly tokenPath: string;
|
||||
private readonly clientInfoPath: string;
|
||||
private readonly codeVerifierPath: string;
|
||||
private readonly statePath: string;
|
||||
// PersistentOAuthClientProvider persists OAuth session artifacts to disk and captures callback redirects.
|
||||
class PersistentOAuthClientProvider implements OAuthClientProvider {
|
||||
private readonly metadata: OAuthClientMetadata;
|
||||
private readonly logger: OAuthLogger;
|
||||
private readonly persistence: OAuthPersistence;
|
||||
private redirectUrlValue: URL;
|
||||
private authorizationDeferred: Deferred<string> | null = null;
|
||||
private server?: http.Server;
|
||||
|
||||
private constructor(
|
||||
private readonly definition: ServerDefinition,
|
||||
tokenCacheDir: string,
|
||||
persistence: OAuthPersistence,
|
||||
redirectUrl: URL,
|
||||
logger: OAuthLogger
|
||||
) {
|
||||
this.tokenPath = path.join(tokenCacheDir, 'tokens.json');
|
||||
this.clientInfoPath = path.join(tokenCacheDir, 'client.json');
|
||||
this.codeVerifierPath = path.join(tokenCacheDir, 'code_verifier.txt');
|
||||
this.statePath = path.join(tokenCacheDir, 'state.txt');
|
||||
this.redirectUrlValue = redirectUrl;
|
||||
this.logger = logger;
|
||||
this.persistence = persistence;
|
||||
this.metadata = {
|
||||
client_name: definition.clientName ?? `mcporter (${definition.name})`,
|
||||
redirect_uris: [this.redirectUrlValue.toString()],
|
||||
@ -100,11 +87,10 @@ class FileOAuthClientProvider implements OAuthClientProvider {
|
||||
definition: ServerDefinition,
|
||||
logger: OAuthLogger
|
||||
): Promise<{
|
||||
provider: FileOAuthClientProvider;
|
||||
provider: PersistentOAuthClientProvider;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const tokenDir = definition.tokenCacheDir ?? path.join(os.homedir(), '.mcporter', definition.name);
|
||||
await ensureDirectory(tokenDir);
|
||||
const persistence = await buildOAuthPersistence(definition, logger);
|
||||
|
||||
const server = http.createServer();
|
||||
const overrideRedirect = definition.oauthRedirectUrl ? new URL(definition.oauthRedirectUrl) : null;
|
||||
@ -134,7 +120,7 @@ class FileOAuthClientProvider implements OAuthClientProvider {
|
||||
redirectUrl.pathname = callbackPath;
|
||||
}
|
||||
|
||||
const provider = new FileOAuthClientProvider(definition, tokenDir, redirectUrl, logger);
|
||||
const provider = new PersistentOAuthClientProvider(definition, persistence, redirectUrl, logger);
|
||||
provider.attachServer(server);
|
||||
return {
|
||||
provider,
|
||||
@ -193,30 +179,30 @@ class FileOAuthClientProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
async state(): Promise<string> {
|
||||
const existing = await readJsonFile<string>(this.statePath);
|
||||
const existing = await this.persistence.readState();
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const state = randomUUID();
|
||||
await writeJsonFile(this.statePath, state);
|
||||
await this.persistence.saveState(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
|
||||
return readJsonFile<OAuthClientInformationMixed>(this.clientInfoPath);
|
||||
return this.persistence.readClientInfo();
|
||||
}
|
||||
|
||||
async saveClientInformation(clientInformation: OAuthClientInformationMixed): Promise<void> {
|
||||
await writeJsonFile(this.clientInfoPath, clientInformation);
|
||||
await this.persistence.saveClientInfo(clientInformation);
|
||||
}
|
||||
|
||||
async tokens(): Promise<OAuthTokens | undefined> {
|
||||
return readJsonFile<OAuthTokens>(this.tokenPath);
|
||||
return this.persistence.readTokens();
|
||||
}
|
||||
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
await writeJsonFile(this.tokenPath, tokens);
|
||||
this.logger.info(`Saved OAuth tokens for ${this.definition.name} to ${this.tokenPath}`);
|
||||
await this.persistence.saveTokens(tokens);
|
||||
this.logger.info(`Saved OAuth tokens for ${this.definition.name} (${this.persistence.describe()})`);
|
||||
}
|
||||
|
||||
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
||||
@ -227,37 +213,20 @@ class FileOAuthClientProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
||||
await fs.writeFile(this.codeVerifierPath, codeVerifier, 'utf8');
|
||||
await this.persistence.saveCodeVerifier(codeVerifier);
|
||||
}
|
||||
|
||||
async codeVerifier(): Promise<string> {
|
||||
const value = await fs.readFile(this.codeVerifierPath, 'utf8');
|
||||
const value = await this.persistence.readCodeVerifier();
|
||||
if (!value) {
|
||||
throw new Error(`Missing PKCE code verifier for ${this.definition.name}`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
// invalidateCredentials removes cached files to force the next OAuth flow.
|
||||
async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier'): Promise<void> {
|
||||
const removals: string[] = [];
|
||||
if (scope === 'all' || scope === 'tokens') {
|
||||
removals.push(this.tokenPath);
|
||||
}
|
||||
if (scope === 'all' || scope === 'client') {
|
||||
removals.push(this.clientInfoPath);
|
||||
}
|
||||
if (scope === 'all' || scope === 'verifier') {
|
||||
removals.push(this.codeVerifierPath);
|
||||
}
|
||||
await Promise.all(
|
||||
removals.map(async (file) => {
|
||||
try {
|
||||
await fs.unlink(file);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
await this.persistence.clear(scope);
|
||||
}
|
||||
|
||||
// waitForAuthorizationCode resolves once the local callback server captures a redirect.
|
||||
@ -295,7 +264,7 @@ export interface OAuthSession {
|
||||
|
||||
// createOAuthSession spins up a file-backed OAuth provider and callback server for the target definition.
|
||||
export async function createOAuthSession(definition: ServerDefinition, logger: OAuthLogger): Promise<OAuthSession> {
|
||||
const { provider, close } = await FileOAuthClientProvider.create(definition, logger);
|
||||
const { provider, close } = await PersistentOAuthClientProvider.create(definition, logger);
|
||||
const waitForAuthorizationCode = () => provider.waitForAuthorizationCode();
|
||||
return {
|
||||
provider,
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { ServerDefinition } from './config.js';
|
||||
import { analyzeConnectionError } from './error-classifier.js';
|
||||
import type { Logger } from './logging.js';
|
||||
@ -15,12 +13,10 @@ export function maybeEnableOAuth(definition: ServerDefinition, logger: Logger):
|
||||
if (!isAdHocSource) {
|
||||
return undefined;
|
||||
}
|
||||
const tokenCacheDir = definition.tokenCacheDir ?? path.join(os.homedir(), '.mcporter', definition.name);
|
||||
logger.info(`Detected OAuth requirement for '${definition.name}'. Launching browser flow...`);
|
||||
return {
|
||||
...definition,
|
||||
auth: 'oauth',
|
||||
tokenCacheDir,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,3 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
@ -14,6 +11,7 @@ import { materializeHeaders } from '../runtime-header-utils.js';
|
||||
import { isUnauthorizedError, maybeEnableOAuth } from '../runtime-oauth-support.js';
|
||||
import { closeTransportAndWait } from '../runtime-process-utils.js';
|
||||
import { connectWithAuth, OAuthTimeoutError } from './oauth.js';
|
||||
import { readCachedAccessToken } from '../oauth-persistence.js';
|
||||
import { resolveCommandArgument, resolveCommandArguments } from './utils.js';
|
||||
|
||||
const STDIO_TRACE_ENABLED = process.env.MCPORTER_STDIO_TRACE === '1';
|
||||
@ -23,25 +21,6 @@ function attachStdioTraceLogging(_transport: StdioClientTransport, _label?: stri
|
||||
// so runtime callers can opt-in without sprinkling conditional checks everywhere.
|
||||
}
|
||||
|
||||
async function loadCachedAccessToken(definition: ServerDefinition): Promise<string | undefined> {
|
||||
const tokenDir = definition.tokenCacheDir ?? path.join(os.homedir(), '.mcporter', definition.name);
|
||||
const tokensPath = path.join(tokenDir, 'tokens.json');
|
||||
try {
|
||||
const buffer = await fs.readFile(tokensPath, 'utf8');
|
||||
const parsed = JSON.parse(buffer);
|
||||
const token = parsed?.access_token;
|
||||
if (typeof token === 'string' && token.trim().length > 0) {
|
||||
return token;
|
||||
}
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClientContext {
|
||||
readonly client: Client;
|
||||
readonly transport: Transport & { close(): Promise<void> };
|
||||
@ -67,7 +46,7 @@ export async function createClientContext(
|
||||
|
||||
if (options.allowCachedAuth && activeDefinition.auth === 'oauth' && activeDefinition.command.kind === 'http') {
|
||||
try {
|
||||
const cached = await loadCachedAccessToken(activeDefinition);
|
||||
const cached = await readCachedAccessToken(activeDefinition, logger);
|
||||
if (cached) {
|
||||
const existingHeaders = activeDefinition.command.headers ?? {};
|
||||
if (!('Authorization' in existingHeaders)) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user