From 4af1cb397f232d4903f2fab48a2b7d1bba431d51 Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Thu, 22 Oct 2026 15:30:36 +0530 Subject: [PATCH 1/2] test: add isolated local client-credentials E2E coverage Refs #350. Implemented and reviewed with OpenAI Codex in ChatGPT. Signed-off-by: Divyam Talwar --- test/README.md | 26 +- test/e2e/lib/oauth-local.mjs | 644 +++++++++++++++++++++++ test/e2e/server/index-v2.ts | 6 +- test/e2e/server/index.ts | 6 +- test/e2e/server/oauth-local.ts | 192 +++++++ test/e2e/suites/auth/oauth-local.test.sh | 50 ++ 6 files changed, 920 insertions(+), 4 deletions(-) create mode 100644 test/e2e/lib/oauth-local.mjs create mode 100644 test/e2e/server/oauth-local.ts create mode 100644 test/e2e/suites/auth/oauth-local.test.sh diff --git a/test/README.md b/test/README.md index 0c0e372a..8c7e82b4 100644 --- a/test/README.md +++ b/test/README.md @@ -3,7 +3,8 @@ This directory contains the test suite for mcpc. There are two kinds of tests: unit and end-to-end (e2e). Unit tests are self-contained and don't need any configurations. -However, **e2e tests need a local setup** - see [E2E Setup](#e2e-setup) for details. +E2E tests use local fixtures. Only the optional remote OAuth smoke test needs +account setup - see [E2E Setup](#e2e-setup) for details. ## Quick start @@ -82,7 +83,28 @@ session management, bridge processes, and various transports. ### E2E setup -In order to test OAuth with real-world, remote MCP server, you need to run the following commands +`auth/oauth-local.test.sh` requires no accounts or external services. It runs the +real CLI and bridges against two strict local client-credentials identities, with +profile isolation, bearer rejection, bounded token reacquisition, restart/logout, +interruption during a held token response, and cleanup after a failure between +credential storage and metadata persistence. +It uses unique profiles and sessions, registers their exact credential identities +before login, and verifies deletion through mcpc's credential abstraction. An +isolated `MCPC_HOME_DIR` alone does not isolate the OS keychain. The suite reports +whether it exercised the OS keychain or the file fallback; both are useful lanes. +Cleanup refuses a changed backend and retains its private registry if deletion +cannot be verified. The suite bypasses proxies for loopback auth requests. +This covers the machine-to-machine grant, not browser authorization-code/PKCE or +refresh-token flows. + +```bash +./test/e2e/run.sh --no-build auth/oauth-local.test.sh +./test/e2e/run.sh --no-build --server-protocol modern auth/oauth-local.test.sh +./test/e2e/run.sh --no-build --runtime bun auth/oauth-local.test.sh +``` + +The existing `auth/oauth-remote.test.sh` remains an optional integration smoke +test. To test OAuth with a real-world, remote MCP server, run the following commands locally: - `mcpc mcp.apify.com login --profile e2e-test1` diff --git a/test/e2e/lib/oauth-local.mjs b/test/e2e/lib/oauth-local.mjs new file mode 100644 index 00000000..29d5f29d --- /dev/null +++ b/test/e2e/lib/oauth-local.mjs @@ -0,0 +1,644 @@ +// Use the same runtime as the CLI: macOS keychain ACLs are per executable. +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { appendFile, mkdir, readFile, readdir, rmdir, stat, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import * as keychain from '../../../dist/lib/auth/keychain.js'; + +const exec = promisify(execFile); +const cli = fileURLToPath(new URL('../../../dist/cli/index.js', import.meta.url)); +const [action, filenameArg, argument] = process.argv.slice(2); +const filename = resolve(filenameArg); +const profileKinds = ['OAuthClientInfo', 'OAuthTokenInfo', 'ClientCredentials', 'IdJagCredentials']; +const sessionKinds = ['SessionHeaders', 'SessionEnv', 'ProxyBearerToken']; +let activeChild; +let interrupted = 0; +let cleaning = false; +let cleanupFailed = false; +for (const [signal, code] of [ + ['SIGINT', 130], + ['SIGTERM', 143], +]) { + process.on(signal, () => { + interrupted = code; + if (!activeChild) return; + const child = activeChild; + child.kill('SIGTERM'); + const timer = setTimeout(() => child.kill('SIGKILL'), 1000); + timer.unref(); + child.once('close', () => clearTimeout(timer)); + }); +} + +async function save(manifest) { + await writeFile(filename, JSON.stringify(manifest), { mode: 0o600 }); +} + +async function inHome(home, operation) { + const previous = process.env.MCPC_HOME_DIR; + process.env.MCPC_HOME_DIR = home; + try { + return await operation(); + } finally { + if (previous === undefined) delete process.env.MCPC_HOME_DIR; + else process.env.MCPC_HOME_DIR = previous; + } +} + +function secrets(manifest) { + return [ + manifest.wrongSecret, + manifest.sentinelSecret, + ...Object.values(manifest.identities).flatMap((id) => [id.clientSecret, id.tokenPrefix]), + ]; +} + +function assertNoSecrets(manifest, text, description) { + assert.ok( + !secrets(manifest).some((secret) => text.includes(secret)), + `${description} leaked a fixture secret` + ); +} + +async function command(manifest, args, home = manifest.home) { + if (interrupted && !cleaning) throw new Error('Local OAuth test interrupted'); + let result; + const pending = exec(process.execPath, [cli, '--json', ...args], { + env: { ...process.env, MCPC_HOME_DIR: home }, + timeout: 20000, + killSignal: 'SIGKILL', + maxBuffer: 2 * 1024 * 1024, + }); + activeChild = pending.child; + try { + result = { + ...(await pending), + code: 0, + }; + } catch (error) { + // Timeouts/signals are failures, not acceptable authentication rejections. + if (typeof error.code !== 'number') + throw new Error('mcpc command did not finish within its bound'); + result = { stdout: error.stdout, stderr: error.stderr, code: error.code }; + } finally { + // execFile resolves/rejects only after the child's exit and stdio closure. + // Cleanup must never race a still-running login writing credential material. + activeChild = undefined; + } + if (interrupted && !cleaning) throw new Error('Local OAuth test interrupted'); + assertNoSecrets(manifest, result.stdout + result.stderr, 'CLI output'); + const safeArgs = args.map((arg) => (secrets(manifest).includes(arg) ? '' : arg)); + await appendFile( + join(dirname(filename), 'oauth-commands.log'), + `${JSON.stringify(safeArgs)}\nexit=${result.code}\n${result.stdout}${result.stderr}\n` + ); + return result; +} + +async function readCredential(profile) { + return inHome(profile.home, () => + keychain.readKeychainClientCredentials(profile.url, profile.name) + ); +} + +async function loginCredentials( + manifest, + identity, + profile, + secret = manifest.identities[identity].clientSecret +) { + return command( + manifest, + [ + 'login', + profile.url, + '--grant', + 'client-credentials', + '--client-id', + manifest.identities[identity].clientId, + '--client-secret', + secret, + '--scope', + 'mcp:tools', + '--profile', + profile.name, + ], + profile.home + ); +} + +async function verifyBackend(manifest) { + if (manifest.usesKeychain === undefined) return; // No credential writes yet. + assert.equal( + await keychain.isKeychainAvailable(), + manifest.usesKeychain, + 'Credential backend changed; retain the registry and retry cleanup with the original backend' + ); + if (!manifest.usesKeychain) return; + for (const home of new Set(Object.values(manifest.profiles).map((profile) => profile.home))) { + try { + assertNoSecrets( + manifest, + await readFile(join(home, 'credentials.json'), 'utf8'), + 'Unexpected fallback store while using the OS keychain' + ); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } +} + +async function assertAbsent(profile) { + await inHome(profile.home, async () => { + for (const kind of profileKinds) { + assert.ok( + (await keychain[`readKeychain${kind}`](profile.url, profile.name)) === undefined, + `Test-owned ${kind} credential remains after cleanup (${profile.name})` + ); + } + }); +} + +async function removeProfiles(profiles) { + const failures = []; + for (const profile of profiles) { + await inHome(profile.home, async () => { + for (const kind of profileKinds) { + try { + await keychain[`removeKeychain${kind}`](profile.url, profile.name); + } catch { + failures.push(`${profile.name}/${kind}`); + } + } + }); + try { + await assertAbsent(profile); + } catch { + failures.push(`${profile.name}/verification`); + } + } + assert.equal(failures.length, 0, `Exact credential cleanup failed: ${failures.join(', ')}`); +} + +async function cleanup(manifest) { + cleaning = true; + try { + await cleanupRegistered(manifest); + } catch (error) { + cleanupFailed = true; + throw error; + } finally { + cleaning = false; + } +} + +async function cleanupRegistered(manifest) { + if (!manifest.serverUrl) return; // Registration happens before any credential write. + const failures = []; + for (const session of Object.values(manifest.sessions)) { + try { + const result = await command(manifest, [session, 'close']); + assert.ok( + result.code === 0 || + (result.code === 1 && + JSON.parse(result.stderr).error === `Session not found: ${session}`), + 'A test-owned session could not be closed' + ); + } catch { + failures.push(`${session}/close`); + } + } + await verifyBackend(manifest); + try { + await removeProfiles(Object.values(manifest.profiles)); + } catch { + failures.push('profiles'); + } + for (const session of Object.values(manifest.sessions)) { + await inHome(manifest.home, async () => { + for (const kind of sessionKinds) { + try { + await keychain[`removeKeychain${kind}`](session); + assert.ok((await keychain[`readKeychain${kind}`](session)) === undefined); + } catch { + failures.push(`${session}/${kind}`); + } + } + }); + } + assert.equal(failures.length, 0, `Cleanup/verification failed: ${failures.join(', ')}`); +} + +async function scanFiles(manifest, directory) { + const excluded = [ + filename, + ...new Set( + Object.values(manifest.profiles).map((profile) => join(profile.home, 'credentials.json')) + ), + ]; // The legitimate fallback credential store. + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (excluded.includes(path)) continue; + if (entry.isDirectory()) await scanFiles(manifest, path); + else if (entry.isFile()) assertNoSecrets(manifest, await readFile(path, 'utf8'), path); + } +} + +async function bridgeArgs(manifest, pid) { + assert.ok(Number.isInteger(pid) && pid > 0, 'Expected a live bridge PID'); + const { stdout } = + process.platform === 'win32' + ? await exec( + 'powershell.exe', + [ + '-NoProfile', + '-Command', + `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine`, + ], + { timeout: 5000, killSignal: 'SIGKILL' } + ) + : await exec('ps', ['-p', String(pid), '-o', 'args='], { + timeout: 5000, + killSignal: 'SIGKILL', + }); + assert.ok(stdout.trim(), 'Could not inspect bridge arguments'); + assertNoSecrets(manifest, stdout, 'Bridge arguments'); +} + +async function run(manifest) { + const origin = new URL(argument); + assert.equal(origin.protocol, 'http:'); + assert.equal(origin.hostname, 'localhost'); + assert.ok(origin.port, 'The fixture must have an explicit local port'); + manifest.serverUrl = origin.origin; + const url = (identity) => `${origin.origin}/identity/${identity}`; + // Persist every exact identity before login can store a keychain item. The EXIT + // trap can use this registry even if this process is interrupted or fails. + manifest.profiles = Object.fromEntries( + ['alpha', 'beta', 'bad', 'partial', 'sentinel', 'interrupted'].map((name) => [ + name, + { + name: `${manifest.prefix}-${name}`, + url: url(name === 'beta' ? 'beta' : 'alpha'), + home: ['partial', 'sentinel'].includes(name) ? manifest.partialHome : manifest.home, + }, + ]) + ); + await save(manifest); + const { profiles, sessions, identities } = manifest; + manifest.usesKeychain = await keychain.isKeychainAvailable(); + await save(manifest); + const backend = manifest.usesKeychain ? 'OS keychain' : 'file fallback'; + console.log(`# Credential backend: ${backend}`); + + async function request(path, options) { + const response = await fetch(`${origin.origin}${path}`, { + ...options, + signal: AbortSignal.timeout(5000), + }); + return { status: response.status, body: await response.json() }; + } + async function stats() { + const response = await request('/oauth-test/stats'); + assert.equal(response.status, 200); + return response.body; + } + async function control(action, identity) { + assert.equal( + (await request(`/oauth-test/${action}/${identity}`, { method: 'POST' })).status, + 200 + ); + } + async function login(identity, profile, secret = identities[identity].clientSecret) { + return loginCredentials(manifest, identity, profile, secret); + } + async function success(args) { + const result = await command(manifest, args); + assert.equal(result.code, 0, `Command failed: ${args.join(' ')}; see oauth-commands.log`); + return result; + } + async function call(identity, message) { + const result = await success([sessions[identity], 'tools-call', 'echo', `message:=${message}`]); + assert.ok(result.stdout.includes(message), 'Expected the real MCP tool response'); + } + async function sessionRecords() { + return JSON.parse(await readFile(join(manifest.home, 'sessions.json'), 'utf8')).sessions; + } + + try { + assert.equal((await request('/identity/alpha', { method: 'POST' })).status, 401); + assert.equal( + ( + await request('/identity/alpha', { + method: 'POST', + headers: { Authorization: 'Bearer unissued-token' }, + }) + ).status, + 401 + ); + assert.equal((await login('alpha', profiles.bad, manifest.wrongSecret)).code, 4); + await assertAbsent(profiles.bad); + assert.equal((await stats()).invalidClients, 1); + console.log( + '# Passed: missing/unissued bearer tokens and wrong client credentials are refused' + ); + + for (const identity of ['alpha', 'beta']) { + assert.equal((await login(identity, profiles[identity])).code, 0); + const stored = await readCredential(profiles[identity]); + assert.ok( + stored?.clientId === identities[identity].clientId && + stored?.clientSecret === identities[identity].clientSecret, + 'Profile stored another identity' + ); + assert.equal((await stats()).identities[identity].issued, 1); + } + const metadata = JSON.parse(await readFile(join(manifest.home, 'profiles.json'), 'utf8')); + for (const identity of ['alpha', 'beta']) { + const profile = metadata.profiles[origin.host][profiles[identity].name]; + assert.equal(profile.oauthGrant, 'client_credentials'); + assert.equal(profile.oauthIssuer, origin.origin); + assert.equal(profile.serverUrl, url(identity)); + } + // An issued alpha token must not authorize beta, even though both are served + // by the same host and the same authorization server. + assert.equal( + ( + await request('/identity/beta', { + method: 'POST', + headers: { + Authorization: `Bearer ${identities.alpha.tokenPrefix}-1`, + }, + }) + ).status, + 403 + ); + for (const identity of ['alpha', 'beta']) { + await success([ + 'connect', + url(identity), + sessions[identity], + '--profile', + profiles[identity].name, + ]); + await call(identity, `own-${identity}`); + } + let records = await sessionRecords(); + for (const identity of ['alpha', 'beta']) { + assert.equal(records[sessions[identity]].profileName, profiles[identity].name); + assert.equal(records[sessions[identity]].server.url, url(identity)); + await bridgeArgs(manifest, records[sessions[identity]].pid); + } + assert.notEqual(records[sessions.alpha].pid, records[sessions.beta].pid); + const beforeCrossed = await stats(); + assert.equal( + ( + await command(manifest, [ + 'connect', + url('beta'), + sessions.cross, + '--profile', + profiles.alpha.name, + ]) + ).code, + 4 + ); + assert.equal( + (await command(manifest, ['connect', url('alpha'), sessions.anonymous, '--no-profile'])).code, + 4 + ); + const afterCrossed = await stats(); + for (const identity of ['alpha', 'beta']) { + assert.ok( + afterCrossed.identities[identity].rejected > beforeCrossed.identities[identity].rejected, + 'Rejected connections must reach the protected resource' + ); + } + await call('alpha', 'alpha-after-rejections'); + await call('beta', 'beta-after-rejections'); + console.log( + '# Passed: two profiles and bridges remain isolated; a crossed profile cannot connect' + ); + + let before = await stats(); + await control('revoke', 'alpha'); + await call('alpha', 'reacquired-alpha'); + let after = await stats(); + assert.equal( + after.identities.alpha.issued, + before.identities.alpha.issued + 1, + 'One invalidated access token must cause exactly one client-credentials exchange' + ); + assert.equal(after.identities.beta.issued, before.identities.beta.issued); + assert.equal(after.identities.alpha.tokenRequests, before.identities.alpha.tokenRequests + 1); + assert.equal(after.invalidClients, before.invalidClients); + assert.ok(after.identities.alpha.rejected > before.identities.alpha.rejected); + await call('beta', 'unaffected-beta'); + console.log('# Passed: token invalidation reacquires once without touching the other identity'); + + before = await stats(); + await control('reject', 'alpha'); + const rejected = await command(manifest, [ + sessions.alpha, + 'tools-call', + 'echo', + 'message:=denied', + ]); + // tools-call currently wraps this as a server error (code 2), while connect + // uses the authentication exit code. Pin refusal and its actual cause. + assert.notEqual(rejected.code, 0); + assert.match(JSON.parse(rejected.stderr).error, /401 after re-authentication/); + after = await stats(); + assert.ok( + after.identities.alpha.issued - before.identities.alpha.issued <= 1, + 'Persistent rejection must not loop token acquisition' + ); + assert.ok( + after.identities.alpha.rejected > before.identities.alpha.rejected && + after.identities.alpha.rejected - before.identities.alpha.rejected <= 2, + 'Persistent rejection must not loop protected requests' + ); + assert.ok(after.identities.alpha.tokenRequests - before.identities.alpha.tokenRequests <= 1); + assert.equal(after.invalidClients, before.invalidClients); + assert.equal(after.identities.beta.issued, before.identities.beta.issued); + await call('beta', 'beta-during-denial'); + const oldPid = records[sessions.alpha].pid; + await control('allow', 'alpha'); + await success([sessions.alpha, 'restart']); + await call('alpha', 'alpha-after-restart'); + records = await sessionRecords(); + assert.notEqual(records[sessions.alpha].pid, oldPid); + assert.equal(records[sessions.alpha].profileName, profiles.alpha.name); + console.log('# Passed: repeated rejection terminates; restart recovers the same profile'); + + await success([sessions.alpha, 'close']); + await success(['logout', url('alpha'), '--profile', profiles.alpha.name]); + await assertAbsent(profiles.alpha); + before = await stats(); + assert.notEqual( + ( + await command(manifest, [ + 'connect', + url('alpha'), + sessions.alpha, + '--profile', + profiles.alpha.name, + ]) + ).code, + 0 + ); + assert.equal((await stats()).identities.alpha.issued, before.identities.alpha.issued); + await success([sessions.beta, 'restart']); + await call('beta', 'beta-after-alpha-logout'); + assert.ok((await readCredential(profiles.beta))?.clientId === identities.beta.clientId); + await success([sessions.beta, 'close']); + await success(['logout', url('beta'), '--profile', profiles.beta.name]); + await assertAbsent(profiles.beta); + console.log( + '# Passed: logout deletes the exact profile credentials and preserves the other profile' + ); + + // Hold the real CLI's token response, then interrupt its helper. The helper + // must stop and await that child before verifying deletion, or releasing the + // response could cause a late credential write after supposedly safe cleanup. + before = await stats(); + await control('hold', 'alpha'); + const probe = exec( + process.execPath, + [fileURLToPath(import.meta.url), 'interrupt-login', filename], + { timeout: 10000, killSignal: 'SIGKILL' } + ); + activeChild = probe.child; + const outcome = probe.then( + (result) => ({ ...result, code: 0 }), + (error) => ({ stdout: error.stdout, stderr: error.stderr, code: error.code }) + ); + try { + const deadlineMillis = Date.now() + 5000; + while ((await stats()).identities.alpha.pending !== 1 && Date.now() < deadlineMillis) + await delay(50); + assert.equal((await stats()).identities.alpha.pending, 1, 'The real login must be in flight'); + // Windows force-kills on child.kill('SIGTERM'); use the same interrupt + // handler through stdin there, so the ordered teardown remains testable. + if (process.platform === 'win32') probe.child.stdin.end('interrupt'); + else probe.child.kill('SIGTERM'); + const result = await outcome; + assertNoSecrets(manifest, result.stdout + result.stderr, 'Interrupted helper output'); + assert.equal(result.code, 143); + assert.equal( + (await stats()).identities.alpha.pending, + 0, + 'The interrupted CLI still owns a token request' + ); + } finally { + probe.child.kill('SIGTERM'); + await outcome; + activeChild = undefined; + await control('release', 'alpha'); + } + assert.equal((await stats()).identities.alpha.issued, before.identities.alpha.issued); + await assertAbsent(profiles.interrupted); + console.log( + '# Passed: interruption stops the real login before cleanup, with no late token/credential write' + ); + + // A directory at the metadata filename makes the real login fail AFTER it + // stores credentials. This is deterministic even when running as root. + await mkdir(join(manifest.partialHome, 'profiles.json'), { recursive: true, mode: 0o700 }); + await inHome(manifest.partialHome, () => + keychain.storeKeychainClientCredentials(profiles.sentinel.url, profiles.sentinel.name, { + clientId: 'test-owned-sentinel', + clientSecret: manifest.sentinelSecret, + }) + ); + assert.notEqual((await login('alpha', profiles.partial)).code, 0); + assert.ok( + (await readCredential(profiles.partial))?.clientId === identities.alpha.clientId, + 'Failure injection must reach the credential write' + ); + assert.ok((await stat(join(manifest.partialHome, 'profiles.json'))).isDirectory()); + await rmdir(join(manifest.partialHome, 'profiles.json')); + assert.notEqual( + ( + await command( + manifest, + ['logout', url('alpha'), '--profile', profiles.partial.name], + manifest.partialHome + ) + ).code, + 0 + ); + // Logout cannot find metadata here. Prove that the absence check detects the + // orphan before exact registry cleanup removes it; a successful exit is not proof. + await assert.rejects(() => assertAbsent(profiles.partial), /credential remains/); + await removeProfiles([profiles.partial]); + assert.ok( + (await readCredential(profiles.sentinel))?.clientSecret === manifest.sentinelSecret, + 'Targeted cleanup removed an unrelated test-owned sentinel' + ); + console.log( + '# Passed: post-storage login failure is cleaned without metadata or broad deletion' + ); + + await scanFiles(manifest, manifest.home); + await scanFiles(manifest, dirname(dirname(filename))); + console.log( + '# Passed: CLI output, bridge arguments, metadata and logs contain no fixture secrets' + ); + } finally { + await cleanup(manifest); + } +} + +try { + if (action === 'init') { + const prefix = `oauth-local-${randomBytes(12).toString('hex')}`; + await save({ + prefix, + home: resolve(argument), + partialHome: join(dirname(filename), 'partial-home'), + wrongSecret: randomBytes(24).toString('hex'), + sentinelSecret: randomBytes(24).toString('hex'), + identities: Object.fromEntries( + ['alpha', 'beta'].map((name) => [ + name, + { + clientId: `${prefix}-${name}`, + clientSecret: randomBytes(24).toString('hex'), + tokenPrefix: randomBytes(24).toString('hex'), + }, + ]) + ), + sessions: Object.fromEntries( + ['alpha', 'beta', 'cross', 'anonymous'].map((name) => [name, `@${prefix}-${name}`]) + ), + }); + } else { + const manifest = JSON.parse(await readFile(filename, 'utf8')); + if (action === 'run') await run(manifest); + else if (action === 'cleanup') await cleanup(manifest); + else if (action === 'interrupt-login') { + if (process.platform === 'win32') { + process.stdin.once('data', () => process.emit('SIGTERM')); + } + try { + await loginCredentials(manifest, 'alpha', manifest.profiles.interrupted); + } finally { + await cleanup({ + ...manifest, + profiles: { interrupted: manifest.profiles.interrupted }, + sessions: {}, + }); + process.stdin.destroy(); + } + } else throw new Error('Unknown local OAuth test action'); + } +} catch (error) { + console.error(error.message); + process.exitCode = cleanupFailed ? 1 : interrupted || 1; +} +if (interrupted && !process.exitCode) process.exitCode = interrupted; diff --git a/test/e2e/server/index-v2.ts b/test/e2e/server/index-v2.ts index 12d99a19..223de388 100644 --- a/test/e2e/server/index-v2.ts +++ b/test/e2e/server/index-v2.ts @@ -49,6 +49,7 @@ import { } from '@modelcontextprotocol/server'; import { toNodeHandler } from '@modelcontextprotocol/node'; import http from 'http'; +import { createLocalOAuthFixture } from './oauth-local.js'; import { randomUUID } from 'crypto'; import { TOOLS, @@ -86,6 +87,7 @@ const WITH_OAUTH = process.env.WITH_OAUTH === 'true'; const OAUTH_CLIENT_ID = process.env.OAUTH_CLIENT_ID || 'test-client'; const OAUTH_CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || 's3cr3t'; const OAUTH_NO_METADATA = process.env.OAUTH_NO_METADATA === 'true'; +const localOAuth = createLocalOAuthFixture(process.env.OAUTH_IDENTITIES_FILE, PORT); const LEGACY_MODE = process.env.LEGACY_MODE === 'stateless' ? 'stateless' : 'reject'; // Control state (manipulated via /control/* endpoints). Module-level so it is @@ -661,6 +663,8 @@ async function main() { const httpServer = http.createServer(async (req, res) => { const url = new URL(req.url || '/', `http://localhost:${PORT}`); + if (localOAuth && (await localOAuth(req, res, url))) return; + // Health check if (url.pathname === '/health' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -814,7 +818,7 @@ async function main() { res.end('Not found'); }); - httpServer.listen(PORT, () => { + httpServer.listen(PORT, localOAuth ? '127.0.0.1' : undefined, () => { console.log(`E2E test server (2026-07-28) running on http://localhost:${PORT}`); console.log( ` Pagination: ${PAGINATION_SIZE > 0 ? `${PAGINATION_SIZE} items/page` : 'disabled'}` diff --git a/test/e2e/server/index.ts b/test/e2e/server/index.ts index 51a519dc..d9c95e0c 100644 --- a/test/e2e/server/index.ts +++ b/test/e2e/server/index.ts @@ -61,6 +61,7 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import { randomUUID } from 'crypto'; import http from 'http'; +import { createLocalOAuthFixture } from './oauth-local.js'; import { TOOLS, RESOURCES, @@ -99,6 +100,7 @@ const OAUTH_CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || 's3cr3t'; // When true, serve /token but NOT the .well-known metadata, so discovery fails and // only an explicit --token-endpoint can locate the token endpoint. const OAUTH_NO_METADATA = process.env.OAUTH_NO_METADATA === 'true'; +const localOAuth = createLocalOAuthFixture(process.env.OAUTH_IDENTITIES_FILE, PORT); // Control state (manipulated via /control/* endpoints) let failNextCount = 0; @@ -469,6 +471,8 @@ async function main() { const httpServer = http.createServer(async (req, res) => { const url = new URL(req.url || '/', `http://localhost:${PORT}`); + if (localOAuth && (await localOAuth(req, res, url))) return; + // Health check if (url.pathname === '/health' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -690,7 +694,7 @@ async function main() { res.end('Not found'); }); - httpServer.listen(PORT, () => { + httpServer.listen(PORT, localOAuth ? '127.0.0.1' : undefined, () => { console.log(`E2E test server running on http://localhost:${PORT}`); console.log( ` Pagination: ${PAGINATION_SIZE > 0 ? `${PAGINATION_SIZE} items/page` : 'disabled'}` diff --git a/test/e2e/server/oauth-local.ts b/test/e2e/server/oauth-local.ts new file mode 100644 index 00000000..0262001d --- /dev/null +++ b/test/e2e/server/oauth-local.ts @@ -0,0 +1,192 @@ +/** Strict, opt-in client-credentials fixture shared by both MCP protocol eras. */ +import { readFileSync } from 'node:fs'; +import type http from 'node:http'; + +interface Identity { + clientId: string; + clientSecret: string; + tokenPrefix: string; +} + +export function createLocalOAuthFixture(filename: string | undefined, port: number) { + if (!filename) return undefined; + const { identities } = JSON.parse(readFileSync(filename, 'utf8')) as { + identities: Record; + }; + const origin = `http://localhost:${port}`; + const state = Object.fromEntries( + Object.keys(identities).map((name) => [ + name, + { + issued: 0, + accepted: 0, + rejected: 0, + tokenRequests: 0, + deny: false, + hold: false, + pending: 0, + }, + ]) + ); + const tokens = new Map(); + const heldResponses = new Map void>>(); + let invalidClients = 0; + + function json(res: http.ServerResponse, status: number, body: unknown) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + } + + return async function handle( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL + ): Promise { + // This fixture contains local-only controls. Reject unexpected Host values as + // well as binding the listener to loopback in the server entry points. + if (req.headers.host !== `localhost:${port}`) { + json(res, 403, { error: 'Unexpected host' }); + return true; + } + + if (url.pathname.startsWith('/.well-known/oauth-protected-resource')) { + const resourcePath = url.pathname.slice('/.well-known/oauth-protected-resource'.length); + if (req.method === 'GET' && /^\/identity\/(alpha|beta)$/.test(resourcePath)) { + json(res, 200, { + resource: `${origin}${resourcePath}`, + authorization_servers: [origin], + scopes_supported: ['mcp:tools'], + }); + } else { + json(res, 404, { error: 'Unknown resource' }); + } + return true; + } + + if (url.pathname === '/.well-known/oauth-authorization-server') { + json(res, 200, { + issuer: origin, + authorization_endpoint: `${origin}/authorize`, + token_endpoint: `${origin}/token`, + response_types_supported: ['code'], + grant_types_supported: ['client_credentials'], + token_endpoint_auth_methods_supported: ['client_secret_basic'], + }); + return true; + } + + if (url.pathname === '/token' && req.method === 'POST') { + let body = ''; + for await (const chunk of req) body += String(chunk); + const params = new URLSearchParams(body); + if (params.get('grant_type') !== 'client_credentials') { + json(res, 400, { error: 'unsupported_grant_type' }); + return true; + } + let clientId = ''; + let clientSecret = ''; + try { + const auth = req.headers.authorization ?? ''; + if (auth.startsWith('Basic ')) { + const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8'); + const separator = decoded.indexOf(':'); + if (separator >= 0) { + clientId = decodeURIComponent(decoded.slice(0, separator)); + clientSecret = decodeURIComponent(decoded.slice(separator + 1)); + } + } + } catch { + // Malformed Basic credentials are an invalid client, never a bypass. + } + const entry = Object.entries(identities).find(([, id]) => id.clientId === clientId); + if (entry) state[entry[0]]!.tokenRequests++; + if (!entry || entry[1].clientSecret !== clientSecret) { + invalidClients++; + json(res, 401, { error: 'invalid_client' }); + return true; + } + const [name, identity] = entry; + if (state[name]!.hold) { + const waiting = heldResponses.get(name) ?? new Set<() => void>(); + heldResponses.set(name, waiting); + state[name]!.pending++; + const connected = await new Promise((resolve) => { + const release = () => finish(true); + const disconnected = () => finish(false); + const finish = (value: boolean) => { + waiting.delete(release); + res.off('close', disconnected); + resolve(value); + }; + waiting.add(release); + res.once('close', disconnected); + }); + state[name]!.pending--; + if (!connected) return true; + } + const generation = ++state[name]!.issued; + const token = `${identity.tokenPrefix}-${generation}`; + tokens.set(token, name); + json(res, 200, { + access_token: token, + token_type: 'Bearer', + expires_in: 3600, + scope: 'mcp:tools', + }); + return true; + } + + if (url.pathname === '/oauth-test/stats' && req.method === 'GET') { + json(res, 200, { identities: state, invalidClients }); + return true; + } + const control = /^\/oauth-test\/(revoke|reject|allow|hold|release)\/(alpha|beta)$/.exec( + url.pathname + ); + if (control && req.method === 'POST') { + const action = control[1]!; + const name = control[2]!; + if (action === 'revoke') { + for (const [token, owner] of tokens) if (owner === name) tokens.delete(token); + } else if (action === 'hold' || action === 'release') { + state[name]!.hold = action === 'hold'; + if (action === 'release') for (const release of heldResponses.get(name) ?? []) release(); + } else { + state[name]!.deny = action === 'reject'; + } + json(res, 200, { ok: true }); + return true; + } + + const resource = /^\/identity\/(alpha|beta)$/.exec(url.pathname); + if (resource) { + const name = resource[1]!; + const owner = tokens.get((req.headers.authorization ?? '').replace(/^Bearer /, '')); + if (!owner || state[name]!.deny) { + state[name]!.rejected++; + res.setHeader( + 'WWW-Authenticate', + `Bearer error="invalid_token", resource_metadata="${origin}/.well-known/oauth-protected-resource/identity/${name}"` + ); + json(res, 401, { error: 'invalid_token' }); + return true; + } + if (owner !== name || !req.headers.authorization?.startsWith('Bearer ')) { + state[name]!.rejected++; + json(res, 403, { error: 'Identity does not own this resource' }); + return true; + } + state[name]!.accepted++; + // The real MCP SDK still handles the authenticated request. Only route + // selection changes; tokens and identity are never passed to tool handlers. + url.pathname = '/mcp'; + req.url = '/mcp'; + return false; + } + if (url.pathname === '/' || url.pathname === '/mcp') { + json(res, 401, { error: 'Use an authenticated identity resource' }); + return true; + } + return false; + }; +} diff --git a/test/e2e/suites/auth/oauth-local.test.sh b/test/e2e/suites/auth/oauth-local.test.sh new file mode 100644 index 00000000..0e6bc611 --- /dev/null +++ b/test/e2e/suites/auth/oauth-local.test.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Deterministic client-credentials coverage. No accounts or remote services. +source "$(dirname "$0")/../../lib/framework.sh" +test_init "auth/oauth-local" --isolated +umask 077 +# Auth traffic must stay on loopback even when the developer configures a proxy. +export NO_PROXY="localhost,127.0.0.1,::1" +export no_proxy="$NO_PROXY" + +OAUTH_HELPER="$PROJECT_ROOT/test/e2e/lib/oauth-local.mjs" +OAUTH_MANIFEST="$TEST_TMP/oauth-identities.json" + +# The helper registers exact credential identities before login can write them. +# Run cleanup again from the shell trap in case the helper failed/interrupted. +# Never delete the isolated home before checking keychain/fallback-store absence. +cleanup_oauth_local() { + local result=$? + trap - EXIT INT TERM + set +e + local cleanup_result=0 + if [[ -f "$OAUTH_MANIFEST" ]]; then + "${E2E_RUNTIME:-node}" "$OAUTH_HELPER" cleanup "$OAUTH_MANIFEST" + cleanup_result=$? + fi + stop_test_server + if [[ $cleanup_result -eq 0 ]]; then + _test_cleanup + rm -f "$OAUTH_MANIFEST" + rm -rf "$TEST_TMP/partial-home" + else + echo "Exact OAuth credential cleanup failed; keeping the private registry for retry" >&2 + result=1 + fi + exit "$result" +} +trap cleanup_oauth_local EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +"${E2E_RUNTIME:-node}" "$OAUTH_HELPER" init "$OAUTH_MANIFEST" "$(to_native_path "$MCPC_HOME_DIR")" +start_test_server "OAUTH_IDENTITIES_FILE=$(to_native_path "$OAUTH_MANIFEST")" + +test_case "local client-credentials isolation, bounded recovery, logout and exact cleanup" +if "${E2E_RUNTIME:-node}" "$OAUTH_HELPER" run "$OAUTH_MANIFEST" "$TEST_SERVER_URL"; then + test_pass +else + test_fail "Local OAuth assertions failed (see oauth-commands.log)" + exit 1 +fi +test_done From b663f5c0bd62ad52b22172cdd29027592d13f446 Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Thu, 22 Oct 2026 20:12:25 +0530 Subject: [PATCH 2/2] test: cover local OAuth authorization code and refresh flows Refs #350. Implemented and reviewed with OpenAI Codex in ChatGPT. --- test/README.md | 14 + test/e2e/lib/oauth-browser.mjs | 70 +++++ test/e2e/lib/oauth-code.mjs | 401 ++++++++++++++++++++++++ test/e2e/lib/oauth-local.mjs | 50 ++- test/e2e/server/oauth-code.ts | 283 +++++++++++++++++ test/e2e/server/oauth-local.ts | 6 +- test/e2e/suites/auth/oauth-code.test.sh | 49 +++ 7 files changed, 860 insertions(+), 13 deletions(-) create mode 100644 test/e2e/lib/oauth-browser.mjs create mode 100644 test/e2e/lib/oauth-code.mjs create mode 100644 test/e2e/server/oauth-code.ts create mode 100644 test/e2e/suites/auth/oauth-code.test.sh diff --git a/test/README.md b/test/README.md index 8c7e82b4..a8377319 100644 --- a/test/README.md +++ b/test/README.md @@ -97,10 +97,24 @@ cannot be verified. The suite bypasses proxies for loopback auth requests. This covers the machine-to-machine grant, not browser authorization-code/PKCE or refresh-token flows. +`auth/oauth-code.test.sh` covers the authorization-code flow with a local +authorization server and a private `open`/`xdg-open` launcher on Linux and macOS. +It follows actual HTTP authorization and callback redirects through the real +CLI and SDK, including dynamic registration, S256 PKCE, callback Host/issuer +checks, two profiles, rotating refresh tokens, persisted refresh after restart, +refused refresh, logout, and cleanup when registration or token storage succeeds +but login fails. State is sent and echoed; mcpc's existing CSRF policy relies on +PKCE rather than comparing callback state. Windows reports an explicit skip +until it has an equivalent launcher driver. No production browser or auth checks +are bypassed, and the optional remote test remains separate. + ```bash ./test/e2e/run.sh --no-build auth/oauth-local.test.sh ./test/e2e/run.sh --no-build --server-protocol modern auth/oauth-local.test.sh ./test/e2e/run.sh --no-build --runtime bun auth/oauth-local.test.sh +./test/e2e/run.sh --no-build auth/oauth-code.test.sh +./test/e2e/run.sh --no-build --server-protocol modern auth/oauth-code.test.sh +./test/e2e/run.sh --no-build --runtime bun auth/oauth-code.test.sh ``` The existing `auth/oauth-remote.test.sh` remains an optional integration smoke diff --git a/test/e2e/lib/oauth-browser.mjs b/test/e2e/lib/oauth-browser.mjs new file mode 100644 index 00000000..d5e6e2e6 --- /dev/null +++ b/test/e2e/lib/oauth-browser.mjs @@ -0,0 +1,70 @@ +// Private launcher shim for this suite. It follows the real authorization and +// callback redirects; it does not replace mcpc's SDK, verifier, or callback server. +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { appendFile, readFile } from 'node:fs/promises'; +import { request } from 'node:http'; +import { dirname, join } from 'node:path'; + +try { + const filename = process.env.MCPC_E2E_BROWSER_REGISTRY; + assert.ok(filename, 'Missing private browser registry'); + const manifest = JSON.parse(await readFile(filename, 'utf8')); + const target = new URL(process.argv[2]); + const issuer = `${manifest.serverUrl}/authorization-server`; + const redirect = `http://127.0.0.1:${manifest.authorizationCode.callbackPort}/callback`; + assert.equal(target.origin, manifest.serverUrl, 'Browser target escaped the local fixture'); + assert.equal(target.pathname, '/authorization-server/authorize'); + assert.equal(target.searchParams.get('redirect_uri'), redirect); + assert.equal(target.searchParams.get('code_challenge_method'), 'S256'); + assert.ok(target.searchParams.get('state'), 'The real login must send OAuth state'); + const state = target.searchParams.get('state'); + const mode = manifest.browserMode; + if (mode === 'bad-pkce') { + target.searchParams.set( + 'code_challenge', + createHash('sha256').update(manifest.wrongSecret).digest('base64url') + ); + } + if (mode === 'deny') target.searchParams.set('test_decision', 'deny'); + + const authorization = await fetch(target, { + redirect: 'manual', + signal: AbortSignal.timeout(5000), + }); + assert.equal(authorization.status, 302, 'Authorization server rejected the real login request'); + const callback = new URL(authorization.headers.get('location')); + assert.equal(`${callback.origin}${callback.pathname}`, redirect); + assert.ok(callback.searchParams.get('state') === state, 'Authorization server changed state'); + assert.ok(callback.searchParams.get('iss') === issuer, 'Unexpected authorization issuer'); + if (mode === 'bad-issuer') callback.searchParams.set('iss', `${issuer}/wrong`); + + // A hostile Host must not complete or close the real callback listener. Send + // it before the valid callback and require that the valid login still works. + const rejectedHost = await new Promise((resolve, reject) => { + const req = request( + callback, + { headers: { Host: 'untrusted.invalid' }, timeout: 5000 }, + (response) => { + response.resume(); + response.once('end', () => resolve(response.statusCode)); + } + ); + req.once('timeout', () => req.destroy(new Error('Callback Host check timed out'))); + req.once('error', reject); + req.end(); + }); + assert.equal(rejectedHost, 403, 'Callback accepted an unexpected Host'); + const result = await fetch(callback, { signal: AbortSignal.timeout(5000) }); + assert.equal(result.status, mode === 'deny' ? 400 : 200); + await result.text(); + await appendFile( + join(dirname(filename), 'oauth-browser.log'), + `${mode}: S256 challenge, state echoed, Host rejected, callback delivered\n`, + { mode: 0o600 } + ); +} catch { + // Never print a URL, authorization code, token, or assertion's actual value. + console.error('Local OAuth browser/callback assertion failed'); + process.exitCode = 1; +} diff --git a/test/e2e/lib/oauth-code.mjs b/test/e2e/lib/oauth-code.mjs new file mode 100644 index 00000000..ca2e93b4 --- /dev/null +++ b/test/e2e/lib/oauth-code.mjs @@ -0,0 +1,401 @@ +import assert from 'node:assert/strict'; +import { createHash, randomBytes } from 'node:crypto'; +import { mkdir, readFile, rmdir, stat, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { delimiter, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export async function initializeAuthorizationCode(manifest, filename) { + const callbackPort = await new Promise((resolve, reject) => { + const listener = createServer(); + listener.once('error', reject); + listener.listen(0, '127.0.0.1', () => { + const port = listener.address().port; + listener.close((error) => (error ? reject(error) : resolve(port))); + }); + }); + manifest.authorizationCode = { callbackPort, codePrefix: randomBytes(24).toString('hex') }; + for (const identity of Object.values(manifest.identities)) { + identity.refreshPrefix = randomBytes(24).toString('hex'); + } + manifest.browserBin = join(dirname(filename), 'browser-bin'); + await mkdir(manifest.browserBin, { recursive: true, mode: 0o700 }); + const driver = fileURLToPath(new URL('./oauth-browser.mjs', import.meta.url)); + const quote = (text) => `'${text.replaceAll("'", "'\\''")}'`; + const launcher = `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(driver)} "$@"\n`; + for (const name of ['open', 'xdg-open']) { + await writeFile(join(manifest.browserBin, name), launcher, { mode: 0o700 }); + } +} + +export async function runAuthorizationCode(manifest, serverUrl, harness) { + const { + filename, + keychain, + save, + inHome, + command, + verifyBackend, + assertAbsent, + removeProfiles, + cleanup, + scanFiles, + bridgeArgs, + } = harness; + const origin = new URL(serverUrl); + assert.equal(origin.protocol, 'http:'); + assert.equal(origin.hostname, 'localhost'); + assert.ok(origin.port, 'The fixture must use a dedicated local port'); + manifest.serverUrl = origin.origin; + const issuer = `${origin.origin}/authorization-server`; + const resource = (identity) => `${origin.origin}/identity/${identity}`; + // Register all keys before DCR or login can write anything. Profile metadata + // is deliberately absent in several failure cases and cannot drive cleanup. + manifest.profiles = Object.fromEntries( + ['alpha', 'beta', 'bad-pkce', 'bad-issuer', 'denied', 'partial', 'sentinel'].map((name) => [ + name, + { + name: `${manifest.prefix}-${name}`, + url: resource(name === 'beta' ? 'beta' : 'alpha'), + home: ['partial', 'sentinel'].includes(name) ? manifest.partialHome : manifest.home, + }, + ]) + ); + await save(manifest); + manifest.usesKeychain = await keychain.isKeychainAvailable(); + await save(manifest); + console.log(`# Credential backend: ${manifest.usesKeychain ? 'OS keychain' : 'file fallback'}`); + const { profiles, sessions, identities } = manifest; + + async function request(path, options) { + const response = await fetch(`${origin.origin}${path}`, { + ...options, + signal: AbortSignal.timeout(5000), + }); + return { status: response.status, body: await response.json() }; + } + async function stats() { + const response = await request('/oauth-test/stats'); + assert.equal(response.status, 200); + return response.body; + } + async function control(action, identity) { + assert.equal( + (await request(`/oauth-test/${action}/${identity}`, { method: 'POST' })).status, + 200 + ); + } + async function tokens(profile) { + return inHome(profile.home, () => + keychain.readKeychainOAuthTokenInfo(profile.url, profile.name) + ); + } + async function client(profile) { + return inHome(profile.home, () => + keychain.readKeychainOAuthClientInfo(profile.url, profile.name) + ); + } + async function login(profile, mode = 'normal') { + manifest.browserMode = mode; + await save(manifest); + return command( + manifest, + [ + 'login', + profile.url, + '--profile', + profile.name, + '--scope', + 'mcp:tools', + '--callback-port', + String(manifest.authorizationCode.callbackPort), + ], + profile.home, + { + env: { + PATH: `${manifest.browserBin}${delimiter}${process.env.PATH}`, + MCPC_E2E_BROWSER_REGISTRY: filename, + }, + } + ); + } + async function success(args) { + const result = await command(manifest, args); + assert.equal(result.code, 0, `Command failed: ${args.join(' ')}; see oauth-commands.log`); + return result; + } + async function call(identity, message) { + const result = await success([sessions[identity], 'tools-call', 'echo', `message:=${message}`]); + assert.ok(result.stdout.includes(message), 'Expected the actual MCP tool response'); + } + async function refreshRequest(token, clientId, target) { + return request('/authorization-server/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: token, + client_id: clientId, + resource: target, + }), + }); + } + async function sentinelSurvives() { + assert.ok( + (await client(profiles.sentinel))?.clientSecret === manifest.sentinelSecret, + 'Targeted cleanup removed an unrelated test-owned credential' + ); + } + + try { + await inHome(manifest.partialHome, () => + keychain.storeKeychainOAuthClientInfo(profiles.sentinel.url, profiles.sentinel.name, { + clientId: 'test-owned-oauth-sentinel', + clientSecret: manifest.sentinelSecret, + }) + ); + assert.equal((await request('/identity/alpha', { method: 'POST' })).status, 401); + assert.equal( + ( + await request('/identity/alpha', { + method: 'POST', + headers: { Authorization: 'Bearer unissued-token' }, + }) + ).status, + 401 + ); + + let before = await stats(); + assert.notEqual((await login(profiles['bad-pkce'], 'bad-pkce')).code, 0); + let after = await stats(); + assert.equal(after.invalidPkce, before.invalidPkce + 1); + assert.equal(after.identities.alpha.codeExchanges, before.identities.alpha.codeExchanges); + assert.ok(await client(profiles['bad-pkce']), 'Failed exchange must follow actual DCR'); + assert.ok(!(await tokens(profiles['bad-pkce'])), 'Failed PKCE exchange stored tokens'); + + before = await stats(); + assert.notEqual((await login(profiles['bad-issuer'], 'bad-issuer')).code, 0); + after = await stats(); + assert.equal(after.authorizations, before.authorizations + 1); + assert.equal(after.tokenRequests, before.tokenRequests, 'Wrong issuer reached token exchange'); + assert.ok(!(await tokens(profiles['bad-issuer']))); + + before = await stats(); + assert.notEqual((await login(profiles.denied, 'deny')).code, 0); + assert.equal((await stats()).tokenRequests, before.tokenRequests); + assert.ok( + await client(profiles.denied), + 'Denied authorization must leave its registered client' + ); + assert.ok(!(await tokens(profiles.denied))); + // Logout has no metadata to find. Independently detect and remove only the + // registered keys, proving a failed login cannot escape test teardown. + await assert.rejects(() => assertAbsent(profiles.denied), /credential remains/); + await removeProfiles([profiles['bad-pkce'], profiles['bad-issuer'], profiles.denied]); + await sentinelSurvives(); + console.log( + '# Passed: real PKCE and issuer rejection, DCR cancellation and exact orphan cleanup' + ); + + for (const identity of ['alpha', 'beta']) { + assert.equal((await login(profiles[identity])).code, 0, 'Interactive login failed'); + const stored = await tokens(profiles[identity]); + assert.ok(stored?.accessToken === `${identities[identity].tokenPrefix}-1`); + assert.ok(stored?.refreshToken === `${identities[identity].refreshPrefix}-1`); + assert.equal((await stats()).identities[identity].codeExchanges, 1); + } + const alphaClient = await client(profiles.alpha); + const betaClient = await client(profiles.beta); + assert.notEqual(alphaClient.clientId, betaClient.clientId); + const metadata = JSON.parse(await readFile(join(manifest.home, 'profiles.json'), 'utf8')); + for (const identity of ['alpha', 'beta']) { + const profile = metadata.profiles[origin.host][profiles[identity].name]; + assert.equal(profile.oauthIssuer, issuer); + assert.equal(profile.oauthResource, resource(identity)); + assert.equal(profile.serverUrl, resource(identity)); + } + // The AS refuses omitted state rather than accepting any authorization + // request. mcpc itself intentionally relies on PKCE for CSRF protection; + // this suite verifies state is sent/echoed, not a nonexistent callback check. + const authorization = new URLSearchParams({ + client_id: alphaClient.clientId, + redirect_uri: `http://127.0.0.1:${manifest.authorizationCode.callbackPort}/callback`, + response_type: 'code', + resource: resource('alpha'), + scope: 'mcp:tools', + code_challenge_method: 'S256', + code_challenge: createHash('sha256').update(manifest.wrongSecret).digest('base64url'), + }); + assert.equal( + (await request(`/authorization-server/authorize?${authorization.toString()}`)).status, + 400 + ); + assert.equal( + ( + await request('/identity/beta', { + method: 'POST', + headers: { Authorization: `Bearer ${identities.alpha.tokenPrefix}-1` }, + }) + ).status, + 403 + ); + for (const identity of ['alpha', 'beta']) { + await success([ + 'connect', + resource(identity), + sessions[identity], + '--profile', + profiles[identity].name, + ]); + await success([sessions[identity], 'tools-list']); + await call(identity, `code-${identity}`); + } + const records = JSON.parse( + await readFile(join(manifest.home, 'sessions.json'), 'utf8') + ).sessions; + for (const identity of ['alpha', 'beta']) { + assert.equal(records[sessions[identity]].profileName, profiles[identity].name); + await bridgeArgs(manifest, records[sessions[identity]].pid); + } + assert.equal( + ( + await command(manifest, [ + 'connect', + resource('beta'), + sessions.cross, + '--profile', + profiles.alpha.name, + ]) + ).code, + 4 + ); + assert.equal( + (await command(manifest, ['connect', resource('alpha'), sessions.anonymous, '--no-profile'])) + .code, + 4 + ); + console.log( + '# Passed: DCR, real browser/callback exchange and two isolated authenticated sessions' + ); + + before = await stats(); + const initial = await tokens(profiles.alpha); + await control('revoke-access', 'alpha'); + await call('alpha', 'rotated-alpha'); + after = await stats(); + assert.equal( + after.identities.alpha.refreshRequests, + before.identities.alpha.refreshRequests + 1 + ); + assert.equal(after.identities.alpha.issued, before.identities.alpha.issued + 1); + assert.equal(after.identities.beta.issued, before.identities.beta.issued); + const rotated = await tokens(profiles.alpha); + assert.ok(rotated?.refreshToken !== initial.refreshToken, 'Refresh token did not rotate'); + assert.ok(rotated?.accessToken === `${identities.alpha.tokenPrefix}-2`); + assert.ok(rotated?.refreshToken === `${identities.alpha.refreshPrefix}-2`); + assert.equal( + (await refreshRequest(initial.refreshToken, alphaClient.clientId, resource('alpha'))).status, + 400, + 'A rotated refresh token could be replayed' + ); + assert.equal( + (await refreshRequest(rotated.refreshToken, alphaClient.clientId, resource('beta'))).status, + 400, + 'Refresh accepted the wrong resource' + ); + await success([sessions.alpha, 'restart']); + before = await stats(); + await control('revoke-access', 'alpha'); + await call('alpha', 'rotated-after-restart'); + after = await stats(); + assert.equal( + after.identities.alpha.refreshRequests, + before.identities.alpha.refreshRequests + 1 + ); + assert.ok( + (await tokens(profiles.alpha))?.refreshToken === `${identities.alpha.refreshPrefix}-3` + ); + await call('beta', 'beta-unaffected-by-rotation'); + console.log( + '# Passed: bounded refresh rotation persists and works after an actual bridge restart' + ); + + // Start a fresh manager before the rejection case. A manager that just + // refreshed deliberately deduplicates forced refreshes for five seconds; + // restarting avoids sleeps and makes this case exercise invalid_grant itself. + await success([sessions.alpha, 'restart']); + await control('revoke-refresh', 'alpha'); + await control('revoke-access', 'alpha'); + before = await stats(); + const rejected = await command(manifest, [ + sessions.alpha, + 'tools-call', + 'echo', + 'message:=denied', + ]); + assert.notEqual(rejected.code, 0); + assert.match(JSON.parse(rejected.stderr).error, /refresh|invalid_grant|401/i); + after = await stats(); + assert.equal( + after.identities.alpha.refreshRequests, + before.identities.alpha.refreshRequests + 1 + ); + assert.equal(after.identities.alpha.issued, before.identities.alpha.issued); + await call('beta', 'beta-after-alpha-refresh-rejection'); + assert.equal((await login(profiles.alpha)).code, 0); + await success([sessions.alpha, 'restart']); + await call('alpha', 'reauthorized-alpha'); + console.log( + '# Passed: unusable refresh is bounded, preserves beta, and recovers through real login' + ); + + await success([sessions.alpha, 'close']); + await success(['logout', resource('alpha'), '--profile', profiles.alpha.name]); + await assertAbsent(profiles.alpha); + assert.notEqual( + ( + await command(manifest, [ + 'connect', + resource('alpha'), + sessions.alpha, + '--profile', + profiles.alpha.name, + ]) + ).code, + 0 + ); + await success([sessions.beta, 'restart']); + await call('beta', 'beta-after-alpha-logout'); + await success([sessions.beta, 'close']); + await success(['logout', resource('beta'), '--profile', profiles.beta.name]); + await assertAbsent(profiles.beta); + + await mkdir(join(manifest.partialHome, 'profiles.json'), { recursive: true, mode: 0o700 }); + assert.notEqual((await login(profiles.partial)).code, 0); + assert.ok( + await client(profiles.partial), + 'Partial login did not persist its registered client' + ); + assert.ok( + await tokens(profiles.partial), + 'Partial login did not reach the token credential write' + ); + assert.ok((await stat(join(manifest.partialHome, 'profiles.json'))).isDirectory()); + await rmdir(join(manifest.partialHome, 'profiles.json')); + await assert.rejects(() => assertAbsent(profiles.partial), /credential remains/); + await removeProfiles([profiles.partial]); + await sentinelSurvives(); + await verifyBackend(manifest); + const browserEvidence = await readFile(join(dirname(filename), 'oauth-browser.log'), 'utf8'); + for (const mode of ['normal', 'bad-pkce', 'bad-issuer', 'deny']) { + assert.ok(browserEvidence.includes(`${mode}: S256 challenge, state echoed, Host rejected`)); + } + await scanFiles(manifest, manifest.home); + await scanFiles(manifest, dirname(dirname(filename))); + console.log( + '# Passed: logout, post-token metadata failure, exact cleanup and secret-free artifacts' + ); + } finally { + await cleanup(manifest); + } +} diff --git a/test/e2e/lib/oauth-local.mjs b/test/e2e/lib/oauth-local.mjs index 29d5f29d..a11576f5 100644 --- a/test/e2e/lib/oauth-local.mjs +++ b/test/e2e/lib/oauth-local.mjs @@ -8,6 +8,7 @@ import { setTimeout as delay } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import * as keychain from '../../../dist/lib/auth/keychain.js'; +import { initializeAuthorizationCode, runAuthorizationCode } from './oauth-code.mjs'; const exec = promisify(execFile); const cli = fileURLToPath(new URL('../../../dist/cli/index.js', import.meta.url)); @@ -53,8 +54,13 @@ function secrets(manifest) { return [ manifest.wrongSecret, manifest.sentinelSecret, - ...Object.values(manifest.identities).flatMap((id) => [id.clientSecret, id.tokenPrefix]), - ]; + manifest.authorizationCode?.codePrefix, + ...Object.values(manifest.identities).flatMap((id) => [ + id.clientSecret, + id.tokenPrefix, + id.refreshPrefix, + ]), + ].filter(Boolean); } function assertNoSecrets(manifest, text, description) { @@ -64,11 +70,11 @@ function assertNoSecrets(manifest, text, description) { ); } -async function command(manifest, args, home = manifest.home) { +async function command(manifest, args, home = manifest.home, options = {}) { if (interrupted && !cleaning) throw new Error('Local OAuth test interrupted'); let result; const pending = exec(process.execPath, [cli, '--json', ...args], { - env: { ...process.env, MCPC_HOME_DIR: home }, + env: { ...process.env, ...options.env, MCPC_HOME_DIR: home }, timeout: 20000, killSignal: 'SIGKILL', maxBuffer: 2 * 1024 * 1024, @@ -448,10 +454,14 @@ async function run(manifest) { 'echo', 'message:=denied', ]); - // tools-call currently wraps this as a server error (code 2), while connect - // uses the authentication exit code. Pin refusal and its actual cause. - assert.notEqual(rejected.code, 0); - assert.match(JSON.parse(rejected.stderr).error, /401 after re-authentication/); + // Depending on which legacy transport request sees the rejection first, + // tools-call surfaces the SDK error or the bridge's classified AuthError. + // Require an authentication cause; counters below still bound both retries. + assert.ok([2, 4].includes(rejected.code), 'Expected a classified authentication rejection'); + assert.match( + JSON.parse(rejected.stderr).error, + /401 after re-authentication|^Authentication required by server\./ + ); after = await stats(); assert.ok( after.identities.alpha.issued - before.identities.alpha.issued <= 1, @@ -595,9 +605,9 @@ async function run(manifest) { } try { - if (action === 'init') { + if (action === 'init' || action === 'init-code') { const prefix = `oauth-local-${randomBytes(12).toString('hex')}`; - await save({ + const manifest = { prefix, home: resolve(argument), partialHome: join(dirname(filename), 'partial-home'), @@ -616,11 +626,27 @@ try { sessions: Object.fromEntries( ['alpha', 'beta', 'cross', 'anonymous'].map((name) => [name, `@${prefix}-${name}`]) ), - }); + }; + if (action === 'init-code') await initializeAuthorizationCode(manifest, filename); + await save(manifest); } else { const manifest = JSON.parse(await readFile(filename, 'utf8')); if (action === 'run') await run(manifest); - else if (action === 'cleanup') await cleanup(manifest); + else if (action === 'run-code') { + await runAuthorizationCode(manifest, argument, { + filename, + keychain, + save, + inHome, + command, + verifyBackend, + assertAbsent, + removeProfiles, + cleanup, + scanFiles, + bridgeArgs, + }); + } else if (action === 'cleanup') await cleanup(manifest); else if (action === 'interrupt-login') { if (process.platform === 'win32') { process.stdin.once('data', () => process.emit('SIGTERM')); diff --git a/test/e2e/server/oauth-code.ts b/test/e2e/server/oauth-code.ts new file mode 100644 index 00000000..9e75c34c --- /dev/null +++ b/test/e2e/server/oauth-code.ts @@ -0,0 +1,283 @@ +/** Local authorization server: real code/PKCE exchange and single-use refresh tokens. */ +import { createHash } from 'node:crypto'; +import type http from 'node:http'; + +interface CodeIdentity { + tokenPrefix: string; + refreshPrefix?: string; +} + +interface Grant { + identity: string; + clientId: string; + resource: string; +} + +interface AuthorizationCode extends Grant { + redirectUri: string; + challenge: string; +} + +export interface AuthorizationCodeOptions { + callbackPort: number; + codePrefix: string; +} + +export function createAuthorizationCodeFixture( + identities: Record, + options: AuthorizationCodeOptions, + port: number +) { + for (const identity of Object.values(identities)) { + if (!identity.refreshPrefix) throw new Error('Missing local refresh-token prefix'); + } + const origin = `http://localhost:${port}`; + const issuer = `${origin}/authorization-server`; + const redirectUri = `http://127.0.0.1:${options.callbackPort}/callback`; + const clients = new Set(); + const codes = new Map(); + const accessTokens = new Map(); + const refreshTokens = new Map(); + const state = Object.fromEntries( + Object.keys(identities).map((identity) => [ + identity, + { issued: 0, codeExchanges: 0, refreshRequests: 0, accepted: 0, rejected: 0 }, + ]) + ); + const counters = { + registrations: 0, + authorizations: 0, + tokenRequests: 0, + invalidAuthorization: 0, + invalidGrant: 0, + invalidPkce: 0, + invalidResource: 0, + }; + + function json(res: http.ServerResponse, status: number, body: unknown) { + res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify(body)); + } + + async function body(req: http.IncomingMessage): Promise { + let result = ''; + for await (const chunk of req) { + result += String(chunk); + if (result.length > 16384) throw new Error('Oversized local OAuth request'); + } + return result; + } + + function issue(res: http.ServerResponse, grant: Grant) { + const identity = identities[grant.identity]!; + const generation = ++state[grant.identity]!.issued; + const accessToken = `${identity.tokenPrefix}-${generation}`; + const refreshToken = `${identity.refreshPrefix}-${generation}`; + accessTokens.set(accessToken, grant); + refreshTokens.set(refreshToken, grant); + json(res, 200, { + access_token: accessToken, + refresh_token: refreshToken, + token_type: 'Bearer', + expires_in: 3600, + scope: 'mcp:tools', + }); + } + + return async function handle( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL + ): Promise { + // Entry points also bind to loopback. Controls are never reachable through a + // developer-selected remote host, proxy, or public interface. + if (req.headers.host !== `localhost:${port}`) { + json(res, 403, { error: 'Unexpected host' }); + return true; + } + + const metadata = /^\/\.well-known\/oauth-protected-resource\/identity\/(alpha|beta)$/.exec( + url.pathname + ); + if (metadata && req.method === 'GET') { + json(res, 200, { + resource: `${origin}/identity/${metadata[1]}`, + authorization_servers: [issuer], + scopes_supported: ['mcp:tools'], + }); + return true; + } + if ( + req.method === 'GET' && + [ + '/.well-known/oauth-authorization-server/authorization-server', + '/authorization-server/.well-known/oauth-authorization-server', + ].includes(url.pathname) + ) { + json(res, 200, { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + registration_endpoint: `${issuer}/register`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['none'], + code_challenge_methods_supported: ['S256'], + scopes_supported: ['mcp:tools'], + authorization_response_iss_parameter_supported: true, + }); + return true; + } + if (url.pathname === '/authorization-server/register' && req.method === 'POST') { + const registration = JSON.parse(await body(req)) as Record; + if ( + !Array.isArray(registration.redirect_uris) || + registration.redirect_uris.length !== 1 || + registration.redirect_uris[0] !== redirectUri || + registration.token_endpoint_auth_method !== 'none' + ) { + json(res, 400, { error: 'invalid_client_metadata' }); + return true; + } + const clientId = `local-code-client-${++counters.registrations}`; + clients.add(clientId); + json(res, 201, { ...registration, client_id: clientId }); + return true; + } + if (url.pathname === '/authorization-server/authorize' && req.method === 'GET') { + const params = url.searchParams; + const resource = params.get('resource') ?? ''; + const identity = /^\/identity\/(alpha|beta)$/.exec(new URL(resource, origin).pathname)?.[1]; + const clientId = params.get('client_id') ?? ''; + const challenge = params.get('code_challenge') ?? ''; + if ( + !clients.has(clientId) || + !identity || + resource !== `${origin}/identity/${identity}` || + params.get('redirect_uri') !== redirectUri || + params.get('response_type') !== 'code' || + params.get('scope') !== 'mcp:tools' || + !params.get('state') || + params.get('code_challenge_method') !== 'S256' || + !/^[A-Za-z0-9_-]{43}$/.test(challenge) + ) { + counters.invalidAuthorization++; + json(res, 400, { error: 'invalid_request' }); + return true; + } + const callback = new URL(redirectUri); + callback.searchParams.set('state', params.get('state')!); + callback.searchParams.set('iss', issuer); + if (params.get('test_decision') === 'deny') { + callback.searchParams.set('error', 'access_denied'); + } else { + const code = `${options.codePrefix}-${++counters.authorizations}`; + codes.set(code, { identity, clientId, resource, redirectUri, challenge }); + callback.searchParams.set('code', code); + } + res.writeHead(302, { Location: callback.toString(), 'Cache-Control': 'no-store' }); + res.end(); + return true; + } + if (url.pathname === '/authorization-server/token' && req.method === 'POST') { + counters.tokenRequests++; + const params = new URLSearchParams(await body(req)); + const grantType = params.get('grant_type'); + if (grantType === 'authorization_code') { + const code = params.get('code') ?? ''; + const grant = codes.get(code); + // A code can be presented once, including a failed redemption attempt. + codes.delete(code); + if ( + !grant || + params.get('client_id') !== grant.clientId || + params.get('redirect_uri') !== grant.redirectUri + ) { + counters.invalidGrant++; + json(res, 400, { error: 'invalid_grant' }); + } else if (params.get('resource') !== grant.resource) { + counters.invalidResource++; + json(res, 400, { error: 'invalid_target' }); + } else if ( + !/^[A-Za-z0-9._~-]{43,128}$/.test(params.get('code_verifier') ?? '') || + createHash('sha256').update(params.get('code_verifier')!).digest('base64url') !== + grant.challenge + ) { + counters.invalidPkce++; + json(res, 400, { error: 'invalid_grant' }); + } else { + state[grant.identity]!.codeExchanges++; + issue(res, grant); + } + } else if (grantType === 'refresh_token') { + const token = params.get('refresh_token') ?? ''; + const grant = refreshTokens.get(token); + // Count attempted refreshes by their generated identity prefix even + // after revocation, so a retry loop cannot masquerade as one attempt. + const owner = Object.entries(identities).find(([, identity]) => + token.startsWith(`${identity.refreshPrefix}-`) + )?.[0]; + if (owner) state[owner]!.refreshRequests++; + if (!grant || params.get('client_id') !== grant.clientId) { + counters.invalidGrant++; + json(res, 400, { error: 'invalid_grant' }); + } else if (params.get('resource') !== grant.resource) { + counters.invalidResource++; + json(res, 400, { error: 'invalid_target' }); + } else { + refreshTokens.delete(token); + issue(res, grant); + } + } else { + json(res, 400, { error: 'unsupported_grant_type' }); + } + return true; + } + if (url.pathname === '/oauth-test/stats' && req.method === 'GET') { + json(res, 200, { ...counters, identities: state }); + return true; + } + const control = /^\/oauth-test\/(revoke-access|revoke-refresh)\/(alpha|beta)$/.exec( + url.pathname + ); + if (control && req.method === 'POST') { + const identity = control[2]!; + const store = control[1] === 'revoke-refresh' ? refreshTokens : accessTokens; + for (const [token, grant] of store) if (grant.identity === identity) store.delete(token); + json(res, 200, { ok: true }); + return true; + } + const resource = /^\/identity\/(alpha|beta)$/.exec(url.pathname); + if (resource) { + const identity = resource[1]!; + const authorization = req.headers.authorization ?? ''; + const grant = authorization.startsWith('Bearer ') + ? accessTokens.get(authorization.slice(7)) + : undefined; + if (!grant) { + state[identity]!.rejected++; + res.setHeader( + 'WWW-Authenticate', + `Bearer error="invalid_token", resource_metadata="${origin}/.well-known/oauth-protected-resource/identity/${identity}"` + ); + json(res, 401, { error: 'invalid_token' }); + return true; + } + if (grant.identity !== identity || grant.resource !== `${origin}/identity/${identity}`) { + state[identity]!.rejected++; + json(res, 403, { error: 'Identity does not own this resource' }); + return true; + } + state[identity]!.accepted++; + // Authentication is checked here; the selected era's real SDK serves MCP. + url.pathname = '/mcp'; + req.url = '/mcp'; + return false; + } + if (url.pathname === '/' || url.pathname === '/mcp') { + json(res, 401, { error: 'Use an authenticated identity resource' }); + return true; + } + return false; + }; +} diff --git a/test/e2e/server/oauth-local.ts b/test/e2e/server/oauth-local.ts index 0262001d..6663f86d 100644 --- a/test/e2e/server/oauth-local.ts +++ b/test/e2e/server/oauth-local.ts @@ -1,18 +1,22 @@ /** Strict, opt-in client-credentials fixture shared by both MCP protocol eras. */ import { readFileSync } from 'node:fs'; import type http from 'node:http'; +import { createAuthorizationCodeFixture, type AuthorizationCodeOptions } from './oauth-code.js'; interface Identity { clientId: string; clientSecret: string; tokenPrefix: string; + refreshPrefix?: string; } export function createLocalOAuthFixture(filename: string | undefined, port: number) { if (!filename) return undefined; - const { identities } = JSON.parse(readFileSync(filename, 'utf8')) as { + const { identities, authorizationCode } = JSON.parse(readFileSync(filename, 'utf8')) as { identities: Record; + authorizationCode?: AuthorizationCodeOptions; }; + if (authorizationCode) return createAuthorizationCodeFixture(identities, authorizationCode, port); const origin = `http://localhost:${port}`; const state = Object.fromEntries( Object.keys(identities).map((name) => [ diff --git a/test/e2e/suites/auth/oauth-code.test.sh b/test/e2e/suites/auth/oauth-code.test.sh new file mode 100644 index 00000000..5d2f73a4 --- /dev/null +++ b/test/e2e/suites/auth/oauth-code.test.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Local authorization-code/PKCE and refresh flow through the real CLI and SDK. +source "$(dirname "$0")/../../lib/framework.sh" +test_init "auth/oauth-code" --isolated +if [[ "${OS:-}" == "Windows_NT" ]]; then + skip_suite "The scoped open/xdg-open browser driver needs a POSIX launcher" +fi +umask 077 +export NO_PROXY="localhost,127.0.0.1,::1" +export no_proxy="$NO_PROXY" + +OAUTH_HELPER="$PROJECT_ROOT/test/e2e/lib/oauth-local.mjs" +OAUTH_MANIFEST="$TEST_TMP/oauth-identities.json" + +cleanup_oauth_code() { + local result=$? + trap - EXIT INT TERM + set +e + local cleanup_result=0 + if [[ -f "$OAUTH_MANIFEST" ]]; then + "${E2E_RUNTIME:-node}" "$OAUTH_HELPER" cleanup "$OAUTH_MANIFEST" + cleanup_result=$? + fi + stop_test_server + if [[ $cleanup_result -eq 0 ]]; then + _test_cleanup + rm -f "$OAUTH_MANIFEST" + rm -rf "$TEST_TMP/partial-home" "$TEST_TMP/browser-bin" + else + echo "Exact OAuth credential cleanup failed; keeping the private registry for retry" >&2 + result=1 + fi + exit "$result" +} +trap cleanup_oauth_code EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +"${E2E_RUNTIME:-node}" "$OAUTH_HELPER" init-code "$OAUTH_MANIFEST" "$(to_native_path "$MCPC_HOME_DIR")" +start_test_server "OAUTH_IDENTITIES_FILE=$(to_native_path "$OAUTH_MANIFEST")" + +test_case "authorization-code PKCE, rotating refresh, restart, logout and owned credential cleanup" +if "${E2E_RUNTIME:-node}" "$OAUTH_HELPER" run-code "$OAUTH_MANIFEST" "$TEST_SERVER_URL"; then + test_pass +else + test_fail "Local authorization-code assertions failed (see oauth-commands.log)" + exit 1 +fi +test_done