diff --git a/README.md b/README.md index b16d640..5dd41e4 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Restart the client after adding or removing agents. ## Commands ```bash -a2amcp login # save control-plane credentials +a2amcp login # sign in through Keycloak and save OAuth tokens a2amcp whoami # show current account a2amcp agents # list agents visible to your account a2amcp add # expose one agent through this gateway @@ -57,6 +57,15 @@ a2amcp logout # clear local credentials Running `a2amcp` with no command starts the MCP gateway over stdio. +`a2amcp login` uses Keycloak Authorization Code + PKCE. It starts a local +loopback callback server, opens the browser, exchanges the code for a Keycloak +access token, and stores the token in `~/.a2a/credentials.json`. For headless +machines, pass an existing Keycloak access token: + +```bash +a2amcp login --token "$KEYCLOAK_ACCESS_TOKEN" +``` + ## Hosted OAuth Connectors ChatGPT and Claude-style hosted connectors should not run this stdio gateway. diff --git a/package-lock.json b/package-lock.json index bdbe91c..fe484be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "a2amcp", - "version": "0.1.7", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "a2amcp", - "version": "0.1.7", + "version": "0.1.8", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", diff --git a/package.json b/package.json index a602381..dbcae5a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "a2amcp", - "version": "0.1.7", + "version": "0.1.8", "description": "MCP gateway for a2acloud agents. Run as a local MCP server; expose any number of deployed A2A agents to Claude Code, Cursor, and other MCP clients.", "type": "module", "bin": { diff --git a/src/api.ts b/src/api.ts index 8700741..6cb4c21 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,7 +1,4 @@ -/** - * Thin control-plane client. Mirrors the relevant subset of - * a2a_pack.cli.api_client.ControlPlaneClient. - */ +/** Thin control-plane client used with Keycloak bearer tokens. */ export class ApiError extends Error { constructor(public status: number, message: string) { super(`API ${status}: ${message}`); @@ -52,20 +49,6 @@ export class ControlPlaneClient { return (await resp.json()) as T; } - login(email: string, password: string) { - return this.request<{ - access_token: string; - user: { id: number; email: string }; - }>("POST", "/v1/auth/login", { email, password }); - } - - signup(email: string, password: string) { - return this.request<{ - access_token: string; - user: { id: number; email: string }; - }>("POST", "/v1/auth/signup", { email, password }); - } - me() { return this.request<{ id: number; email: string }>("GET", "/v1/me"); } diff --git a/src/cli.ts b/src/cli.ts index 2e2ac65..01b8a46 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,9 +21,17 @@ import { clearCredentials, loadCredentials, resolveApiUrl, - saveCredentials, } from "./credentials.js"; import { runStdio } from "./gateway.js"; +import { + DEFAULT_OAUTH_CLIENT_ID, + DEFAULT_OAUTH_ISSUER, + DEFAULT_OAUTH_SCOPE, + DEFAULT_REDIRECT_PORT, + loginWithAccessToken, + loginWithBrowser, + refreshCredentialsIfNeeded, +} from "./oauth.js"; const program = new Command(); @@ -34,8 +42,9 @@ program async function client(apiOverride?: string): Promise { const creds = await loadCredentials(); + const refreshed = creds ? await refreshCredentialsIfNeeded(creds) : null; const apiUrl = resolveApiUrl(apiOverride ?? creds?.apiUrl); - return new ControlPlaneClient(apiUrl, creds?.token ?? null); + return new ControlPlaneClient(apiUrl, refreshed?.token ?? null); } function fail(msg: string, code = 1): never { @@ -106,25 +115,42 @@ program program .command("login") - .description("Authenticate against the control plane.") - .option("--email ") - .option("--password ") + .description("Authenticate with Keycloak and cache a bearer token.") .option("--api ", "Override control plane URL", DEFAULT_API_URL) - .action(async (opts: { email?: string; password?: string; api: string }) => { - const email = opts.email ?? (await prompt("email: ")); - const password = opts.password ?? (await prompt("password: ", { hidden: true })); + .option("--issuer ", "Keycloak realm issuer", DEFAULT_OAUTH_ISSUER) + .option("--client-id ", "Keycloak public client id", DEFAULT_OAUTH_CLIENT_ID) + .option("--scope ", "OAuth scopes to request", DEFAULT_OAUTH_SCOPE) + .option("--port ", "Loopback callback port", String(DEFAULT_REDIRECT_PORT)) + .option("--token ", "Save an already-issued Keycloak access token") + .option("--no-open", "Print the login URL without opening a browser") + .action(async (opts: { + api: string; + issuer: string; + clientId: string; + scope: string; + port: string; + token?: string; + open?: boolean; + }) => { const api = resolveApiUrl(opts.api); try { - const out = await new ControlPlaneClient(api, null).login(email, password); - const bucket = `user-${out.user.id}-files`; - await saveCredentials({ + const common = { apiUrl: api, - token: out.access_token, - email: out.user.email, - userId: out.user.id, - bucket, - }); - output.write(`logged in as ${out.user.email} @ ${api}\n`); + issuer: opts.issuer, + clientId: opts.clientId, + scope: opts.scope, + }; + const creds = opts.token + ? await loginWithAccessToken(opts.token, common) + : await loginWithBrowser({ + ...common, + port: Number.parseInt(opts.port, 10), + openBrowser: opts.open !== false, + onAuthorizationUrl: (url) => { + output.write(`Open this URL to sign in with Keycloak:\n${url}\n`); + }, + }); + output.write(`logged in as ${creds.email} @ ${api}\n`); } catch (err) { fail(err instanceof ApiError ? err.message : (err as Error).message); } @@ -202,8 +228,9 @@ program if (!url) { try { const creds = await loadCredentials(); + const refreshed = creds ? await refreshCredentialsIfNeeded(creds) : null; const apiUrl = resolveApiUrl(opts.api ?? creds?.apiUrl); - const row = await new ControlPlaneClient(apiUrl, creds?.token ?? null).getAgent(name); + const row = await new ControlPlaneClient(apiUrl, refreshed?.token ?? null).getAgent(name); const target = mcpTargetForAgent(row, apiUrl); url = target.url; mcpPath = target.mcpPath; @@ -242,6 +269,7 @@ program .action(async () => { const cfg = await loadConfig(); const creds = await loadCredentials(); + const refreshed = creds ? await refreshCredentialsIfNeeded(creds) : null; if (cfg.agents.length === 0) { output.write("(no agents enabled)\n"); return; @@ -252,7 +280,7 @@ program name: a.name, url: a.url, mcpPath: a.mcpPath, - token: creds?.token ?? null, + token: refreshed?.token ?? null, }); try { const tools = await u.listTools(); diff --git a/src/credentials.ts b/src/credentials.ts index 4e40a42..aa17285 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -1,8 +1,7 @@ /** - * Reader for ~/.a2a/credentials.json — same file the Python `a2a` CLI writes. - * - * We deliberately *only read* this file (and overwrite it on `a2amcp login`) - * so a single login flow is shared between the Python and Node tooling. + * Reader/writer for ~/.a2a/credentials.json — same file the Python `a2a` CLI + * writes. New logins store Keycloak access/refresh tokens; older token-only + * files are still readable. */ import { promises as fs } from "node:fs"; import os from "node:os"; @@ -16,6 +15,11 @@ export interface Credentials { email: string; userId?: number; bucket?: string; + refreshToken?: string; + expiresAt?: number; + issuer?: string; + clientId?: string; + scope?: string; } const credsDir = () => path.join(os.homedir(), ".a2a"); @@ -32,6 +36,12 @@ export async function loadCredentials(): Promise { email: data.email ?? "", userId: typeof data.user_id === "number" ? data.user_id : undefined, bucket: typeof data.bucket === "string" ? data.bucket : undefined, + refreshToken: + typeof data.refresh_token === "string" ? data.refresh_token : undefined, + expiresAt: typeof data.expires_at === "number" ? data.expires_at : undefined, + issuer: typeof data.oauth_issuer === "string" ? data.oauth_issuer : undefined, + clientId: typeof data.client_id === "string" ? data.client_id : undefined, + scope: typeof data.scope === "string" ? data.scope : undefined, }; } catch (err: any) { if (err?.code === "ENOENT") return null; @@ -48,6 +58,11 @@ export async function saveCredentials(c: Credentials): Promise { }; if (c.userId !== undefined) out.user_id = c.userId; if (c.bucket !== undefined) out.bucket = c.bucket; + if (c.refreshToken !== undefined) out.refresh_token = c.refreshToken; + if (c.expiresAt !== undefined) out.expires_at = c.expiresAt; + if (c.issuer !== undefined) out.oauth_issuer = c.issuer; + if (c.clientId !== undefined) out.client_id = c.clientId; + if (c.scope !== undefined) out.scope = c.scope; await fs.writeFile(credsFile(), JSON.stringify(out), { mode: 0o600 }); } diff --git a/src/gateway.ts b/src/gateway.ts index e515644..e091085 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -17,7 +17,8 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { loadConfig, type EnabledAgent } from "./config.js"; -import { loadCredentials } from "./credentials.js"; +import { loadCredentials, type Credentials } from "./credentials.js"; +import { refreshCredentialsIfNeeded } from "./oauth.js"; import { UpstreamAgent, UpstreamError, type UpstreamTool } from "./upstream.js"; const require = createRequire(import.meta.url); @@ -45,13 +46,18 @@ export interface GatewayHandle { export async function buildGateway(opts: GatewayOptions = {}): Promise { const cfg = opts.agents ?? (await loadConfig()).agents; - const creds = opts.token !== undefined ? null : await loadCredentials(); + let creds: Credentials | null = opts.token !== undefined ? null : await loadCredentials(); + async function refreshedToken(): Promise { + if (opts.token !== undefined) return opts.token; + if (!creds) return null; + creds = await refreshCredentialsIfNeeded(creds); + return creds.token; + } const token = opts.token !== undefined ? opts.token : creds?.token ?? null; // Forward CP credentials + bucket as params._meta so upstream agents // can act on behalf of the caller (mirrors the platform-orchestrator // call shape). When the user isn't logged in, _meta is omitted and // upstream sees the same unauthenticated context as before. - const cpJwt = creds?.token ?? null; const cpUrl = creds?.apiUrl ?? null; const bucket = creds?.bucket ?? null; @@ -61,7 +67,9 @@ export async function buildGateway(opts: GatewayOptions = {}): Promise void; +}; + +export async function loginWithBrowser( + opts: BrowserLoginOptions = {}, +): Promise { + const apiUrl = (opts.apiUrl || DEFAULT_API_URL).replace(/\/+$/, ""); + const issuer = (opts.issuer || DEFAULT_OAUTH_ISSUER).replace(/\/+$/, ""); + const clientId = opts.clientId || DEFAULT_OAUTH_CLIENT_ID; + const scope = opts.scope || DEFAULT_OAUTH_SCOPE; + const cfg = await discoverOpenIdConfiguration(issuer); + const pkce = createPkcePair(); + const state = randomUrlSafe(32); + const callback = await listenForCallback({ + port: opts.port ?? DEFAULT_REDIRECT_PORT, + state, + timeoutMs: opts.timeoutMs ?? 180_000, + }); + try { + const redirectUri = callback.redirectUri; + const authorizationUrl = buildAuthorizationUrl({ + authorizationEndpoint: cfg.authorization_endpoint, + clientId, + redirectUri, + scope, + state, + codeChallenge: pkce.challenge, + }); + opts.onAuthorizationUrl?.(authorizationUrl); + if (opts.openBrowser !== false) openBrowser(authorizationUrl); + const code = await callback.waitForCode; + const token = await exchangeAuthorizationCode({ + tokenEndpoint: cfg.token_endpoint, + clientId, + redirectUri, + code, + codeVerifier: pkce.verifier, + }); + const creds = await credentialsFromToken({ + apiUrl, + token, + issuer, + clientId, + scope, + }); + await saveCredentials(creds); + return creds; + } finally { + await callback.close(); + } +} + +export async function loginWithAccessToken( + token: string, + opts: { + apiUrl?: string; + issuer?: string; + clientId?: string; + scope?: string; + } = {}, +): Promise { + const apiUrl = (opts.apiUrl || DEFAULT_API_URL).replace(/\/+$/, ""); + const creds = await credentialsFromToken({ + apiUrl, + token: { access_token: token }, + issuer: opts.issuer, + clientId: opts.clientId, + scope: opts.scope, + }); + await saveCredentials(creds); + return creds; +} + +export async function refreshCredentialsIfNeeded( + creds: Credentials, + minTtlSeconds = 60, +): Promise { + if (!creds.refreshToken || !creds.issuer || !creds.clientId) return creds; + if (creds.expiresAt && creds.expiresAt - nowSeconds() > minTtlSeconds) return creds; + + const cfg = await discoverOpenIdConfiguration(creds.issuer); + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: creds.clientId, + refresh_token: creds.refreshToken, + }); + const resp = await fetch(cfg.token_endpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + const token = await parseTokenResponse(resp); + const next: Credentials = { + ...creds, + token: token.access_token!, + refreshToken: token.refresh_token || creds.refreshToken, + expiresAt: expiresAt(token.expires_in), + scope: token.scope || creds.scope, + }; + await saveCredentials(next); + return next; +} + +export async function discoverOpenIdConfiguration( + issuer: string, +): Promise { + const url = `${issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`; + const resp = await fetch(url, { headers: { accept: "application/json" } }); + if (!resp.ok) throw new Error(`OpenID discovery failed: ${resp.status}`); + const data = (await resp.json()) as Partial; + if (!data.authorization_endpoint || !data.token_endpoint) { + throw new Error("OpenID discovery document is missing authorization/token endpoints"); + } + return { + authorization_endpoint: data.authorization_endpoint, + token_endpoint: data.token_endpoint, + }; +} + +export function createPkcePair(): { verifier: string; challenge: string } { + const verifier = randomUrlSafe(64); + const challenge = crypto + .createHash("sha256") + .update(verifier) + .digest("base64url"); + return { verifier, challenge }; +} + +export function buildAuthorizationUrl(opts: { + authorizationEndpoint: string; + clientId: string; + redirectUri: string; + scope: string; + state: string; + codeChallenge: string; +}): string { + const url = new URL(opts.authorizationEndpoint); + url.searchParams.set("client_id", opts.clientId); + url.searchParams.set("redirect_uri", opts.redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", opts.scope); + url.searchParams.set("state", opts.state); + url.searchParams.set("code_challenge", opts.codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + return url.toString(); +} + +async function exchangeAuthorizationCode(opts: { + tokenEndpoint: string; + clientId: string; + redirectUri: string; + code: string; + codeVerifier: string; +}): Promise { + const body = new URLSearchParams({ + grant_type: "authorization_code", + client_id: opts.clientId, + redirect_uri: opts.redirectUri, + code: opts.code, + code_verifier: opts.codeVerifier, + }); + const resp = await fetch(opts.tokenEndpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + return parseTokenResponse(resp); +} + +async function parseTokenResponse(resp: Response): Promise { + const data = (await resp.json().catch(() => ({}))) as TokenResponse; + if (!resp.ok || !data.access_token) { + const message = data.error_description || data.error || resp.statusText; + throw new Error(`Keycloak token exchange failed: ${message}`); + } + return data; +} + +async function credentialsFromToken(opts: { + apiUrl: string; + token: TokenResponse; + issuer?: string; + clientId?: string; + scope?: string; +}): Promise { + const me = await new ControlPlaneClient(opts.apiUrl, opts.token.access_token!).me(); + return { + apiUrl: opts.apiUrl, + token: opts.token.access_token!, + email: me.email, + userId: me.id, + bucket: `user-${me.id}-files`, + refreshToken: opts.token.refresh_token, + expiresAt: expiresAt(opts.token.expires_in), + issuer: opts.issuer, + clientId: opts.clientId, + scope: opts.token.scope || opts.scope, + }; +} + +function listenForCallback(opts: { + port: number; + state: string; + timeoutMs: number; +}): Promise<{ + redirectUri: string; + waitForCode: Promise; + close: () => Promise; +}> { + let resolveCode!: (code: string) => void; + let rejectCode!: (err: Error) => void; + const waitForCode = new Promise((resolve, reject) => { + resolveCode = resolve; + rejectCode = reject; + }); + + const server = http.createServer((req, res) => { + const url = new URL(req.url || "/", `http://${req.headers.host}`); + if (url.pathname !== CALLBACK_PATH) { + res.statusCode = 404; + res.end("not found"); + return; + } + const error = url.searchParams.get("error"); + const errorDescription = url.searchParams.get("error_description"); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (error) { + rejectCode(new Error(errorDescription || error)); + finishBrowser(res, false); + return; + } + if (!code || state !== opts.state) { + rejectCode(new Error("invalid OAuth callback")); + finishBrowser(res, false); + return; + } + resolveCode(code); + finishBrowser(res, true); + }); + + const timer = setTimeout(() => { + rejectCode(new Error("timed out waiting for Keycloak login")); + }, opts.timeoutMs); + + return new Promise((resolve, reject) => { + server.on("error", reject); + server.listen(opts.port, "127.0.0.1", () => { + const { port } = server.address() as AddressInfo; + resolve({ + redirectUri: `http://127.0.0.1:${port}${CALLBACK_PATH}`, + waitForCode, + close: () => + new Promise((done) => { + clearTimeout(timer); + server.close(() => done()); + }), + }); + }); + }); +} + +function finishBrowser(res: http.ServerResponse, ok: boolean): void { + res.statusCode = ok ? 200 : 400; + res.setHeader("content-type", "text/html; charset=utf-8"); + res.end( + `

${ok ? "a2amcp is connected" : "a2amcp login failed"}

` + + "

You can close this tab and return to your terminal.

", + ); +} + +function openBrowser(url: string): void { + const cmd = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "cmd" + : "xdg-open"; + const args = + process.platform === "win32" + ? ["/c", "start", "", url] + : [url]; + const child = spawn(cmd, args, { detached: true, stdio: "ignore" }); + child.on("error", () => {}); + child.unref(); +} + +function randomUrlSafe(bytes: number): string { + return crypto.randomBytes(bytes).toString("base64url"); +} + +function expiresAt(expiresIn: number | undefined): number | undefined { + return typeof expiresIn === "number" ? nowSeconds() + expiresIn : undefined; +} + +function nowSeconds(): number { + return Math.floor(Date.now() / 1000); +} diff --git a/src/upstream.ts b/src/upstream.ts index 989d8dc..c06b430 100644 --- a/src/upstream.ts +++ b/src/upstream.ts @@ -15,12 +15,14 @@ export interface UpstreamConfig { url: string; mcpPath?: string; token: string | null; + tokenProvider?: () => Promise; requestTimeoutMs?: number; streamIdleTimeoutMs?: number; /** Forwarded to the upstream as ``params._meta.cp_jwt`` on tools/call. * Lets the upstream agent act on the caller's behalf (file/agent CRUD * on /v1/me/*) — same path the platform orchestrator uses. */ cpJwt?: string | null; + cpJwtProvider?: () => Promise; /** Paired with ``cpJwt``. The upstream agent uses this as the base URL * when calling back into /v1/me/*. */ cpUrl?: string | null; @@ -84,6 +86,16 @@ export class UpstreamAgent { return `${this.cfg.url}${this.cfg.mcpPath ?? "/mcp"}`; } + private async bearerToken(): Promise { + if (this.cfg.tokenProvider) return this.cfg.tokenProvider(); + return this.cfg.token ?? null; + } + + private async cpJwt(): Promise { + if (this.cfg.cpJwtProvider) return this.cfg.cpJwtProvider(); + return this.cfg.cpJwt ?? null; + } + private async rpc(method: string, params: unknown): Promise { const id = _nextId++; const timeoutMs = this.cfg.requestTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS; @@ -97,7 +109,8 @@ export class UpstreamAgent { "content-type": "application/json", accept: "application/json", }; - if (this.cfg.token) headers["authorization"] = `Bearer ${this.cfg.token}`; + const token = await this.bearerToken(); + if (token) headers["authorization"] = `Bearer ${token}`; try { const resp = await fetch(this.mcpUrl(), { @@ -153,7 +166,8 @@ export class UpstreamAgent { } = {}, ): Promise { const meta: Record = {}; - if (this.cfg.cpJwt) meta.cp_jwt = this.cfg.cpJwt; + const cpJwt = await this.cpJwt(); + if (cpJwt) meta.cp_jwt = cpJwt; if (this.cfg.cpUrl) meta.cp_url = this.cfg.cpUrl; if (this.cfg.bucket) meta.bucket = this.cfg.bucket; if (opts.progressToken !== undefined) meta.progressToken = opts.progressToken; @@ -202,7 +216,8 @@ export class UpstreamAgent { "content-type": "application/json", accept: "text/event-stream, application/json", }; - if (this.cfg.token) headers["authorization"] = `Bearer ${this.cfg.token}`; + const token = await this.bearerToken(); + if (token) headers["authorization"] = `Bearer ${token}`; let resp: Response; try { @@ -368,7 +383,8 @@ export class UpstreamAgent { accept: "application/json", "mcp-session-id": upstreamSessionId, }; - if (this.cfg.token) headers.authorization = `Bearer ${this.cfg.token}`; + const token = await this.bearerToken(); + if (token) headers.authorization = `Bearer ${token}`; const resp = await fetch(this.mcpUrl(), { method: "POST", headers, diff --git a/tests/config.test.ts b/tests/config.test.ts index 123886f..148969c 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { promises as fs } from "node:fs"; import { addAgent, loadConfig, removeAgent } from "../src/config.js"; +import { loadCredentials, saveCredentials } from "../src/credentials.js"; let originalHome: string | undefined; let tmpHome: string; @@ -44,3 +45,31 @@ test("removeAgent reports hit/miss", async () => { const cfg = await loadConfig(); assert.equal(cfg.agents.length, 0); }); + +test("credentials preserve Keycloak token metadata", async () => { + await saveCredentials({ + apiUrl: "https://api.example.test", + token: "access-token", + refreshToken: "refresh-token", + expiresAt: 12345, + email: "dev@example.test", + userId: 7, + bucket: "user-7-files", + issuer: "https://auth.example.test/realms/a2a", + clientId: "a2acloud-cli", + scope: "openid email mcp:invoke", + }); + + assert.deepEqual(await loadCredentials(), { + apiUrl: "https://api.example.test", + token: "access-token", + refreshToken: "refresh-token", + expiresAt: 12345, + email: "dev@example.test", + userId: 7, + bucket: "user-7-files", + issuer: "https://auth.example.test/realms/a2a", + clientId: "a2acloud-cli", + scope: "openid email mcp:invoke", + }); +}); diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts new file mode 100644 index 0000000..d33a0de --- /dev/null +++ b/tests/oauth.test.ts @@ -0,0 +1,95 @@ +import { test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { promises as fs } from "node:fs"; + +import { loadCredentials, saveCredentials } from "../src/credentials.js"; +import { + buildAuthorizationUrl, + createPkcePair, + refreshCredentialsIfNeeded, +} from "../src/oauth.js"; + +let originalHome: string | undefined; +let tmpHome: string; +const originalFetch = globalThis.fetch; + +beforeEach(async () => { + originalHome = process.env.HOME; + tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "a2a-mcp-oauth-")); + process.env.HOME = tmpHome; + globalThis.fetch = originalFetch; +}); + +afterEach(async () => { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + globalThis.fetch = originalFetch; + await fs.rm(tmpHome, { recursive: true, force: true }); +}); + +test("PKCE authorization URL requests Keycloak code flow", () => { + const pkce = createPkcePair(); + assert.ok(pkce.verifier.length > 40); + assert.ok(pkce.challenge.length > 40); + + const url = new URL( + buildAuthorizationUrl({ + authorizationEndpoint: "https://auth.example.test/auth", + clientId: "a2acloud-cli", + redirectUri: "http://127.0.0.1:41873/callback", + scope: "openid email mcp:invoke", + state: "state-123", + codeChallenge: pkce.challenge, + }), + ); + + assert.equal(url.searchParams.get("client_id"), "a2acloud-cli"); + assert.equal(url.searchParams.get("response_type"), "code"); + assert.equal(url.searchParams.get("code_challenge_method"), "S256"); + assert.equal(url.searchParams.get("scope"), "openid email mcp:invoke"); +}); + +test("refreshCredentialsIfNeeded refreshes expired Keycloak access token", async () => { + const calls: Array<{ url: string; body?: string }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + calls.push({ url, body: init?.body?.toString() }); + if (url.endsWith("/.well-known/openid-configuration")) { + return new Response( + JSON.stringify({ + authorization_endpoint: "https://auth.example.test/auth", + token_endpoint: "https://auth.example.test/token", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response( + JSON.stringify({ + access_token: "fresh-token", + refresh_token: "fresh-refresh", + expires_in: 3600, + scope: "openid email mcp:invoke", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as typeof fetch; + + await saveCredentials({ + apiUrl: "https://api.example.test", + token: "old-token", + refreshToken: "refresh-token", + expiresAt: 1, + email: "dev@example.test", + issuer: "https://auth.example.test", + clientId: "a2acloud-cli", + }); + + const refreshed = await refreshCredentialsIfNeeded((await loadCredentials())!); + + assert.equal(refreshed.token, "fresh-token"); + assert.equal(refreshed.refreshToken, "fresh-refresh"); + assert.equal((await loadCredentials())!.token, "fresh-token"); + assert.ok(calls.some((call) => call.body?.includes("grant_type=refresh_token"))); +});