Use Keycloak OAuth for a2amcp login
All checks were successful
build / test (push) Successful in 19s
publish / npm (push) Successful in 22s

This commit is contained in:
2026-05-31 20:46:17 -03:00
parent 1e034412ca
commit 2814112ab3
12 changed files with 584 additions and 53 deletions

View File

@@ -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 <name> # 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.

4
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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");
}

View File

@@ -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<ControlPlaneClient> {
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 <email>")
.option("--password <password>")
.description("Authenticate with Keycloak and cache a bearer token.")
.option("--api <url>", "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 <url>", "Keycloak realm issuer", DEFAULT_OAUTH_ISSUER)
.option("--client-id <id>", "Keycloak public client id", DEFAULT_OAUTH_CLIENT_ID)
.option("--scope <scope>", "OAuth scopes to request", DEFAULT_OAUTH_SCOPE)
.option("--port <port>", "Loopback callback port", String(DEFAULT_REDIRECT_PORT))
.option("--token <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,
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 ${out.user.email} @ ${api}\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();

View File

@@ -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<Credentials | null> {
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<void> {
};
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 });
}

View File

@@ -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<GatewayHandle> {
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<string | null> {
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<GatewayHa
a.name,
new UpstreamAgent({
name: a.name, url: a.url, mcpPath: a.mcpPath, token,
cpJwt, cpUrl, bucket,
tokenProvider: refreshedToken,
cpJwtProvider: refreshedToken,
cpUrl, bucket,
requestTimeoutMs: opts.upstreamRequestTimeoutMs,
streamIdleTimeoutMs: opts.upstreamStreamIdleTimeoutMs,
}),

View File

@@ -14,3 +14,13 @@ export {
saveCredentials,
} from "./credentials.js";
export type { Credentials } from "./credentials.js";
export {
DEFAULT_OAUTH_CLIENT_ID,
DEFAULT_OAUTH_ISSUER,
DEFAULT_OAUTH_SCOPE,
DEFAULT_REDIRECT_PORT,
createPkcePair,
loginWithAccessToken,
loginWithBrowser,
refreshCredentialsIfNeeded,
} from "./oauth.js";

338
src/oauth.ts Normal file
View File

@@ -0,0 +1,338 @@
import crypto from "node:crypto";
import http from "node:http";
import { spawn } from "node:child_process";
import type { AddressInfo } from "node:net";
import { ControlPlaneClient } from "./api.js";
import {
DEFAULT_API_URL,
type Credentials,
saveCredentials,
} from "./credentials.js";
export const DEFAULT_OAUTH_ISSUER = "https://auth.a2acloud.io/realms/a2acloud";
export const DEFAULT_OAUTH_CLIENT_ID = "a2acloud-cli";
export const DEFAULT_OAUTH_SCOPE = "openid email offline_access mcp:invoke agent:read";
export const DEFAULT_REDIRECT_PORT = 41873;
const CALLBACK_PATH = "/callback";
type OpenIdConfiguration = {
authorization_endpoint: string;
token_endpoint: string;
};
type TokenResponse = {
access_token?: string;
refresh_token?: string;
expires_in?: number;
scope?: string;
error?: string;
error_description?: string;
};
export type BrowserLoginOptions = {
apiUrl?: string;
issuer?: string;
clientId?: string;
scope?: string;
port?: number;
openBrowser?: boolean;
timeoutMs?: number;
onAuthorizationUrl?: (url: string) => void;
};
export async function loginWithBrowser(
opts: BrowserLoginOptions = {},
): Promise<Credentials> {
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<Credentials> {
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<Credentials> {
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<OpenIdConfiguration> {
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<OpenIdConfiguration>;
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<TokenResponse> {
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<TokenResponse> {
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<Credentials> {
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<string>;
close: () => Promise<void>;
}> {
let resolveCode!: (code: string) => void;
let rejectCode!: (err: Error) => void;
const waitForCode = new Promise<string>((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(
`<html><body><h1>${ok ? "a2amcp is connected" : "a2amcp login failed"}</h1>` +
"<p>You can close this tab and return to your terminal.</p></body></html>",
);
}
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);
}

View File

@@ -15,12 +15,14 @@ export interface UpstreamConfig {
url: string;
mcpPath?: string;
token: string | null;
tokenProvider?: () => Promise<string | null>;
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<string | null>;
/** 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<string | null> {
if (this.cfg.tokenProvider) return this.cfg.tokenProvider();
return this.cfg.token ?? null;
}
private async cpJwt(): Promise<string | null> {
if (this.cfg.cpJwtProvider) return this.cfg.cpJwtProvider();
return this.cfg.cpJwt ?? null;
}
private async rpc<T>(method: string, params: unknown): Promise<T> {
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<UpstreamCallResult> {
const meta: Record<string, unknown> = {};
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,

View File

@@ -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",
});
});

95
tests/oauth.test.ts Normal file
View File

@@ -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")));
});