Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80261e5224 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "a2amcp",
|
"name": "a2amcp",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "a2amcp",
|
"name": "a2amcp",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "a2amcp",
|
"name": "a2amcp",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"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.",
|
"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",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
109
src/oauth.ts
109
src/oauth.ts
@@ -1,5 +1,8 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
|
|
||||||
@@ -7,6 +10,7 @@ import { ControlPlaneClient } from "./api.js";
|
|||||||
import {
|
import {
|
||||||
DEFAULT_API_URL,
|
DEFAULT_API_URL,
|
||||||
type Credentials,
|
type Credentials,
|
||||||
|
loadCredentials,
|
||||||
saveCredentials,
|
saveCredentials,
|
||||||
} from "./credentials.js";
|
} from "./credentials.js";
|
||||||
|
|
||||||
@@ -15,6 +19,9 @@ export const DEFAULT_OAUTH_CLIENT_ID = "a2acloud-cli";
|
|||||||
export const DEFAULT_OAUTH_SCOPE = "openid email offline_access mcp:invoke agent:read";
|
export const DEFAULT_OAUTH_SCOPE = "openid email offline_access mcp:invoke agent:read";
|
||||||
export const DEFAULT_REDIRECT_PORT = 41873;
|
export const DEFAULT_REDIRECT_PORT = 41873;
|
||||||
const CALLBACK_PATH = "/callback";
|
const CALLBACK_PATH = "/callback";
|
||||||
|
const REFRESH_LOCK_WAIT_MS = 10_000;
|
||||||
|
const REFRESH_LOCK_STALE_MS = 30_000;
|
||||||
|
const REFRESH_LOCK_POLL_MS = 100;
|
||||||
|
|
||||||
type OpenIdConfiguration = {
|
type OpenIdConfiguration = {
|
||||||
authorization_endpoint: string;
|
authorization_endpoint: string;
|
||||||
@@ -116,20 +123,40 @@ export async function refreshCredentialsIfNeeded(
|
|||||||
minTtlSeconds = 60,
|
minTtlSeconds = 60,
|
||||||
): Promise<Credentials> {
|
): Promise<Credentials> {
|
||||||
if (!creds.refreshToken || !creds.issuer || !creds.clientId) return creds;
|
if (!creds.refreshToken || !creds.issuer || !creds.clientId) return creds;
|
||||||
if (creds.expiresAt && creds.expiresAt - nowSeconds() > minTtlSeconds) return creds;
|
if (credentialsFresh(creds, minTtlSeconds)) return creds;
|
||||||
|
|
||||||
const cfg = await discoverOpenIdConfiguration(creds.issuer);
|
return withRefreshLock(async () => {
|
||||||
|
const latest = await loadCredentials();
|
||||||
|
if (latest && sameOAuthClient(latest, creds)) {
|
||||||
|
if (credentialsFresh(latest, minTtlSeconds)) return latest;
|
||||||
|
if (latest.refreshToken && latest.issuer && latest.clientId) creds = latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshCredentials(creds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCredentials(creds: Credentials): Promise<Credentials> {
|
||||||
|
const cfg = await discoverOpenIdConfiguration(creds.issuer!);
|
||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
grant_type: "refresh_token",
|
grant_type: "refresh_token",
|
||||||
client_id: creds.clientId,
|
client_id: creds.clientId!,
|
||||||
refresh_token: creds.refreshToken,
|
refresh_token: creds.refreshToken!,
|
||||||
});
|
});
|
||||||
const resp = await fetch(cfg.token_endpoint, {
|
const resp = await fetch(cfg.token_endpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
const token = await parseTokenResponse(resp);
|
let token: TokenResponse;
|
||||||
|
try {
|
||||||
|
token = await parseTokenResponse(resp);
|
||||||
|
} catch (err) {
|
||||||
|
const detail = err instanceof Error ? err.message : String(err);
|
||||||
|
throw new Error(
|
||||||
|
`Login expired or revoked. Run \`a2amcp login\` and retry. (${detail})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const next: Credentials = {
|
const next: Credentials = {
|
||||||
...creds,
|
...creds,
|
||||||
token: token.access_token!,
|
token: token.access_token!,
|
||||||
@@ -141,6 +168,74 @@ export async function refreshCredentialsIfNeeded(
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function credentialsFresh(creds: Credentials, minTtlSeconds: number): boolean {
|
||||||
|
return Boolean(creds.expiresAt && creds.expiresAt - nowSeconds() > minTtlSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameOAuthClient(a: Credentials, b: Credentials): boolean {
|
||||||
|
return (
|
||||||
|
a.apiUrl === b.apiUrl &&
|
||||||
|
a.issuer === b.issuer &&
|
||||||
|
a.clientId === b.clientId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withRefreshLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
const release = await acquireRefreshLock();
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
await release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acquireRefreshLock(): Promise<() => Promise<void>> {
|
||||||
|
const file = refreshLockFile();
|
||||||
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||||
|
const started = Date.now();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const handle = await fs.open(file, "wx", 0o600);
|
||||||
|
try {
|
||||||
|
await handle.writeFile(
|
||||||
|
JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
return async () => {
|
||||||
|
try {
|
||||||
|
await fs.unlink(file);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code !== "ENOENT") throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code !== "EEXIST") throw err;
|
||||||
|
await removeStaleRefreshLock(file);
|
||||||
|
if (Date.now() - started > REFRESH_LOCK_WAIT_MS) {
|
||||||
|
throw new Error("timed out waiting for credential refresh lock");
|
||||||
|
}
|
||||||
|
await sleep(REFRESH_LOCK_POLL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeStaleRefreshLock(file: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(file);
|
||||||
|
if (Date.now() - stat.mtimeMs <= REFRESH_LOCK_STALE_MS) return;
|
||||||
|
await fs.unlink(file);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code !== "ENOENT") throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshLockFile(): string {
|
||||||
|
return path.join(os.homedir(), ".a2a", "credentials-refresh.lock");
|
||||||
|
}
|
||||||
|
|
||||||
export async function discoverOpenIdConfiguration(
|
export async function discoverOpenIdConfiguration(
|
||||||
issuer: string,
|
issuer: string,
|
||||||
): Promise<OpenIdConfiguration> {
|
): Promise<OpenIdConfiguration> {
|
||||||
@@ -338,6 +433,10 @@ function nowSeconds(): number {
|
|||||||
return Math.floor(Date.now() / 1000);
|
return Math.floor(Date.now() / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
function parseJsonObject(text: string): Record<string, unknown> {
|
function parseJsonObject(text: string): Record<string, unknown> {
|
||||||
if (!text.trim()) return {};
|
if (!text.trim()) return {};
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -93,3 +93,89 @@ test("refreshCredentialsIfNeeded refreshes expired Keycloak access token", async
|
|||||||
assert.equal((await loadCredentials())!.token, "fresh-token");
|
assert.equal((await loadCredentials())!.token, "fresh-token");
|
||||||
assert.ok(calls.some((call) => call.body?.includes("grant_type=refresh_token")));
|
assert.ok(calls.some((call) => call.body?.includes("grant_type=refresh_token")));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("refreshCredentialsIfNeeded serializes concurrent refreshes", async () => {
|
||||||
|
let tokenCalls = 0;
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tokenCalls += 1;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
access_token: `fresh-token-${tokenCalls}`,
|
||||||
|
refresh_token: `fresh-refresh-${tokenCalls}`,
|
||||||
|
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 stale = (await loadCredentials())!;
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
refreshCredentialsIfNeeded(stale),
|
||||||
|
refreshCredentialsIfNeeded({ ...stale }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(tokenCalls, 1);
|
||||||
|
assert.equal(first.token, "fresh-token-1");
|
||||||
|
assert.equal(second.token, "fresh-token-1");
|
||||||
|
assert.equal((await loadCredentials())!.refreshToken, "fresh-refresh-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refreshCredentialsIfNeeded reports stale Keycloak sessions clearly", async () => {
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
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({
|
||||||
|
error: "invalid_grant",
|
||||||
|
error_description: "Session doesn't have required client",
|
||||||
|
}),
|
||||||
|
{ status: 400, 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",
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
refreshCredentialsIfNeeded((await loadCredentials())!),
|
||||||
|
/Login expired or revoked\. Run `a2amcp login` and retry\. .*Session doesn't have required client/,
|
||||||
|
);
|
||||||
|
assert.equal((await loadCredentials())!.token, "old-token");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user