From ef2740543d050306af6d105f84c43a7d96db6a14 Mon Sep 17 00:00:00 2001 From: Sharath Rajasekar Date: Thu, 3 Sep 2026 17:45:35 -0700 Subject: [PATCH 1/2] feat: mount the admin API at the router root by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: DefaultAdminPathPrefix changes from "/api/v1" to "" — admin routes (identities, agents, credential-policies, signals, attestation, oauth/clients, delegations) now serve at the server root, matching the SaaS deployment shape and what the highflame SDKs expect from 0.3.23 (both the Python and TypeScript SDKs address admin routes at the root, so they 404ed against a stock deployment). Deployments that scripted against /api/v1 set server.admin_path_prefix: "/api/v1" (or ZEROID_ADMIN_PATH_PREFIX) to keep the old shape. - server: when the admin group shares the router root, register its huma instances speclessly — a spec-carrying instance there shadowed the canonical /openapi.json behind the agent-auth 401 middleware (TestOAuthFormCompatOpenAPIAdvertisesFormContentType caught it) - CI: bump SDK pins to 0.3.23 (python smoke, TS smoke, notebook check) - CLI: bump @highflame/sdk ^0.3.9 → ^0.3.23; tokens.issue → issueApiKey; CIBA admin-prefix default "" to match the server; test mocks moved to root paths and given a mint handler for the SDK's admin-token exchange (0.3.23 authenticates admin calls when constructed with an apiKey) - CLI tests: isolate profile reads/writes via ZID_CONFIG_DIR — init tests were writing profiles into the developer's real ~/.config/zeroid - quickstart: tokens.revoke now requires RFC 7009 client auth in 0.3.23; pass the OAuth client credentials (verified end-to-end via nbmake against a fresh compose build; ipynb re-normalized by nbformat) - docs: attestation.md / dpop-and-dcr.md examples use root admin paths Verified: go vet, 497 unit + 680 integration tests (race), 110 CLI tests, quickstart notebook executed against the rebuilt compose stack with highflame==0.3.23. Co-Authored-By: Claude Fable 5 --- .github/workflows/pr-check.yml | 6 +- cli/package-lock.json | 32 ++- cli/package.json | 2 +- cli/src/commands/ciba/api.ts | 2 +- cli/src/commands/ciba/approve.ts | 2 +- cli/src/commands/ciba/deny.ts | 2 +- cli/src/commands/token/issue.ts | 4 +- cli/src/lib/config.ts | 21 +- cli/tests/commands/agents.test.ts | 48 ++-- cli/tests/commands/ciba.test.ts | 4 +- cli/tests/commands/config.test.ts | 14 +- cli/tests/commands/creds.test.ts | 20 +- cli/tests/commands/init.test.ts | 29 +- cli/tests/commands/signal.test.ts | 18 +- cli/tests/helpers.ts | 33 +++ config.go | 21 +- docs/attestation.md | 8 +- docs/dpop-and-dcr.md | 4 +- examples/zeroid_quickstart.ipynb | 267 +++++++++++++++++- server.go | 19 +- tests/integration/helpers_test.go | 3 +- .../www_authenticate_compliance_test.go | 2 +- zeroid.yaml | 2 +- 23 files changed, 442 insertions(+), 121 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 3b013848..7a3008ef 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -189,7 +189,7 @@ jobs: shell: bash run: |- pip install --quiet "testcontainers[postgres]" "PyJWT>=2.8" cryptography pytest - pip install --quiet "highflame==0.3.17" + pip install --quiet "highflame==0.3.23" - name: Run Python SDK smoke tests run: pytest tests/sdk/test_sdk_smoke.py -v --tb=short -x @@ -200,7 +200,7 @@ jobs: run: |- npm init -y npm install --save-dev vitest typescript @testcontainers/postgresql - npm install @highflame/sdk@0.3.17 + npm install @highflame/sdk@0.3.23 - name: Run TypeScript SDK smoke tests working-directory: tests/sdk @@ -259,7 +259,7 @@ jobs: - name: Install notebook execution deps shell: bash - run: pip install --quiet pytest nbmake ipykernel "highflame==0.3.17" cryptography "PyJWT>=2.8" + run: pip install --quiet pytest nbmake ipykernel "highflame==0.3.23" cryptography "PyJWT>=2.8" - name: Execute the quickstart notebook against the live server shell: bash diff --git a/cli/package-lock.json b/cli/package-lock.json index 3cfd6567..4cbcd906 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -8,7 +8,7 @@ "name": "@highflame/zeroid", "version": "0.1.0", "dependencies": { - "@highflame/sdk": "^0.3.9", + "@highflame/sdk": "^0.3.23", "chalk": "^5.4.1", "cli-table3": "^0.6.5", "commander": "^13.1.0" @@ -628,18 +628,38 @@ } }, "node_modules/@highflame/sdk": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@highflame/sdk/-/sdk-0.3.9.tgz", - "integrity": "sha512-9rlYanCOgsHwXbWX/Eg5SCbUyTKm69i7DWH8WkjSnDl9DDRwp22wB8RNzFEGdX/xGmp4AYan7eI2jZBjLvOsHw==", + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/@highflame/sdk/-/sdk-0.3.23.tgz", + "integrity": "sha512-sb/MNK3Glk8S1eRzZnxqh0/5NzsU1NobU6kmKb2hZ40AXZXbNi8YEFQjfjzXiqXeOgTBlrWJ5+hb977iiVwn9Q==", "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { - "@opentelemetry/api": "^1.4.0" + "@aws/bedrock-ai-agents-strands": ">=1.0.0", + "@azure/ai-projects": ">=2.0.0", + "@langchain/core": ">=0.3.0", + "@langchain/langgraph": ">=1.0.0", + "@opentelemetry/api": "^1.4.0", + "@strands-agents/sdk": ">=1.0.0" }, "peerDependenciesMeta": { + "@aws/bedrock-ai-agents-strands": { + "optional": true + }, + "@azure/ai-projects": { + "optional": true + }, + "@langchain/core": { + "optional": true + }, + "@langchain/langgraph": { + "optional": true + }, "@opentelemetry/api": { "optional": true + }, + "@strands-agents/sdk": { + "optional": true } } }, diff --git a/cli/package.json b/cli/package.json index a7c6acb8..525fefa1 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "lint:fix": "eslint src tests --fix" }, "dependencies": { - "@highflame/sdk": "^0.3.9", + "@highflame/sdk": "^0.3.23", "chalk": "^5.4.1", "cli-table3": "^0.6.5", "commander": "^13.1.0" diff --git a/cli/src/commands/ciba/api.ts b/cli/src/commands/ciba/api.ts index 4c2b3174..2371dff8 100644 --- a/cli/src/commands/ciba/api.ts +++ b/cli/src/commands/ciba/api.ts @@ -116,7 +116,7 @@ export function resolveCibaAdminRequest( return { baseUrl: nonEmpty(opts.adminBaseUrl) ?? readEnv("ZID_ADMIN_BASE_URL") ?? context.base_url, - prefix: readOptionOrEnvAllowEmpty(opts.adminPrefix, "ZID_ADMIN_PREFIX") ?? "/api/v1", + prefix: readOptionOrEnvAllowEmpty(opts.adminPrefix, "ZID_ADMIN_PREFIX") ?? "", headers: cleanHeaders({ "X-Internal-Service": internalService, "X-Internal-Service-Secret": internalServiceSecret, diff --git a/cli/src/commands/ciba/approve.ts b/cli/src/commands/ciba/approve.ts index 44146752..b78171fe 100644 --- a/cli/src/commands/ciba/approve.ts +++ b/cli/src/commands/ciba/approve.ts @@ -22,7 +22,7 @@ export function registerCibaApprove(cibaCmd: Command): void { .option("--admin-base-url ", "Admin API base URL (defaults to profile base URL)") .option( "--admin-prefix ", - 'Admin route prefix before /oauth2/bc-authorize (default: /api/v1; use "" for AuthN)', + 'Admin route prefix before /oauth2/bc-authorize (default: none — admin routes at the server root; set /api/v1 for pre-flip deployments)', ) .option("--internal-service ", "Internal service name header for protected admin routes") .option("--internal-service-secret ", "Internal service secret header") diff --git a/cli/src/commands/ciba/deny.ts b/cli/src/commands/ciba/deny.ts index b16f68b4..397f970d 100644 --- a/cli/src/commands/ciba/deny.ts +++ b/cli/src/commands/ciba/deny.ts @@ -20,7 +20,7 @@ export function registerCibaDeny(cibaCmd: Command): void { .option("--admin-base-url ", "Admin API base URL (defaults to profile base URL)") .option( "--admin-prefix ", - 'Admin route prefix before /oauth2/bc-authorize (default: /api/v1; use "" for AuthN)', + 'Admin route prefix before /oauth2/bc-authorize (default: none — admin routes at the server root; set /api/v1 for pre-flip deployments)', ) .option("--internal-service ", "Internal service name header for protected admin routes") .option("--internal-service-secret ", "Internal service secret header") diff --git a/cli/src/commands/token/issue.ts b/cli/src/commands/token/issue.ts index 0c3ec923..a1e9084e 100644 --- a/cli/src/commands/token/issue.ts +++ b/cli/src/commands/token/issue.ts @@ -22,9 +22,7 @@ export function registerIssue(tokenCmd: Command): void { try { const profile = requireProfile(opts.profile as string | undefined); const client = makeClientFromProfile(profile); - const token = await client.tokens.issue({ - grant_type: "api_key", - api_key: profile.api_key, + const token = await client.tokens.issueApiKey(profile.api_key, { scope: (opts.scope as string).trim() || undefined, }); diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index e5cf16f0..00db3f75 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -31,15 +31,22 @@ interface ConfigFile { profiles: Record; } -const CONFIG_DIR = join(homedir(), ".config", "zeroid"); -const CONFIG_PATH = join(CONFIG_DIR, "config.json"); +// ZID_CONFIG_DIR overrides where profiles live (tests isolate through it; +// when set, the legacy path is not consulted). Resolved per call so a +// process can re-point it. +function configDir(): string { + return process.env.ZID_CONFIG_DIR || join(homedir(), ".config", "zeroid"); +} +function configPath(): string { + return join(configDir(), "config.json"); +} const LEGACY_CONFIG_PATH = join(homedir(), ".config", "zid", "config.json"); function _read(): ConfigFile { - if (existsSync(CONFIG_PATH)) { - return readConfigFile(CONFIG_PATH); + if (existsSync(configPath())) { + return readConfigFile(configPath()); } - if (existsSync(LEGACY_CONFIG_PATH)) { + if (!process.env.ZID_CONFIG_DIR && existsSync(LEGACY_CONFIG_PATH)) { return readConfigFile(LEGACY_CONFIG_PATH); } return { active_profile: "", profiles: {} }; @@ -56,8 +63,8 @@ function readConfigFile(path: string): ConfigFile { } function _write(cfg: ConfigFile): void { - mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); - writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); + mkdirSync(configDir(), { recursive: true, mode: 0o700 }); + writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); } export function getProfile(name?: string): Profile | undefined { diff --git a/cli/tests/commands/agents.test.ts b/cli/tests/commands/agents.test.ts index 1eb9b175..41e4fbcd 100644 --- a/cli/tests/commands/agents.test.ts +++ b/cli/tests/commands/agents.test.ts @@ -5,10 +5,10 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; -import { runCLI, BASE_URL } from "../helpers.js"; +import { runCLI, BASE_URL, tokenMintHandler } from "../helpers.js"; import type { AgentResponse, AgentRegistered, AgentListResponse } from "@highflame/sdk"; -const server = setupServer(); +const server = setupServer(tokenMintHandler()); beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); @@ -58,8 +58,8 @@ const ROTATED: AgentRegistered = { // --------------------------------------------------------------------------- describe("zeroid agents list", () => { - it("GET /api/v1/agents/registry and renders a table", async () => { - server.use(http.get(`${BASE_URL}/api/v1/agents/registry`, () => HttpResponse.json(AGENT_LIST))); + it("GET /agents/registry and renders a table", async () => { + server.use(http.get(`${BASE_URL}/agents/registry`, () => HttpResponse.json(AGENT_LIST))); const { stdout, exitCode } = await runCLI(["agents", "list"]); expect(exitCode).toBeUndefined(); const out = stdout.join("\n"); @@ -72,7 +72,7 @@ describe("zeroid agents list", () => { it("forwards --type as query param", async () => { let qs = ""; server.use( - http.get(`${BASE_URL}/api/v1/agents/registry`, ({ request }) => { + http.get(`${BASE_URL}/agents/registry`, ({ request }) => { qs = new URL(request.url).search; return HttpResponse.json(AGENT_LIST); }), @@ -82,7 +82,7 @@ describe("zeroid agents list", () => { }); it("outputs a JSON array with --json", async () => { - server.use(http.get(`${BASE_URL}/api/v1/agents/registry`, () => HttpResponse.json(AGENT_LIST))); + server.use(http.get(`${BASE_URL}/agents/registry`, () => HttpResponse.json(AGENT_LIST))); const { stdout } = await runCLI(["agents", "list", "--json"]); const parsed = JSON.parse(stdout.join("")) as AgentResponse[]; expect(Array.isArray(parsed)).toBe(true); @@ -91,7 +91,7 @@ describe("zeroid agents list", () => { it("prints 'No agents found' when list is empty", async () => { server.use( - http.get(`${BASE_URL}/api/v1/agents/registry`, () => + http.get(`${BASE_URL}/agents/registry`, () => HttpResponse.json({ agents: [], total: 0, limit: 50, offset: 0 }), ), ); @@ -110,7 +110,7 @@ describe("zeroid agents list", () => { it("exits 1 on API error", async () => { server.use( - http.get(`${BASE_URL}/api/v1/agents/registry`, () => + http.get(`${BASE_URL}/agents/registry`, () => HttpResponse.json({ title: "Unauthorized" }, { status: 401 }), ), ); @@ -124,9 +124,9 @@ describe("zeroid agents list", () => { // --------------------------------------------------------------------------- describe("zeroid agents get", () => { - it("GET /api/v1/agents/registry/:id and prints agent details", async () => { + it("GET /agents/registry/:id and prints agent details", async () => { server.use( - http.get(`${BASE_URL}/api/v1/agents/registry/agt_abc123`, () => HttpResponse.json(AGENT)), + http.get(`${BASE_URL}/agents/registry/agt_abc123`, () => HttpResponse.json(AGENT)), ); const { stdout, exitCode } = await runCLI(["agents", "get", "agt_abc123"]); expect(exitCode).toBeUndefined(); @@ -138,7 +138,7 @@ describe("zeroid agents get", () => { it("outputs raw JSON with --json", async () => { server.use( - http.get(`${BASE_URL}/api/v1/agents/registry/agt_abc123`, () => HttpResponse.json(AGENT)), + http.get(`${BASE_URL}/agents/registry/agt_abc123`, () => HttpResponse.json(AGENT)), ); const { stdout } = await runCLI(["agents", "get", "--json", "agt_abc123"]); const parsed = JSON.parse(stdout.join("")) as AgentResponse; @@ -148,7 +148,7 @@ describe("zeroid agents get", () => { it("exits 1 when agent not found", async () => { server.use( - http.get(`${BASE_URL}/api/v1/agents/registry/ghost`, () => + http.get(`${BASE_URL}/agents/registry/ghost`, () => HttpResponse.json({ title: "Not Found", detail: "identity not found" }, { status: 404 }), ), ); @@ -162,9 +162,9 @@ describe("zeroid agents get", () => { // --------------------------------------------------------------------------- describe("zeroid agents rotate-key", () => { - it("POST /api/v1/agents/registry/:id/rotate-key and prints new key", async () => { + it("POST /agents/registry/:id/rotate-key and prints new key", async () => { server.use( - http.post(`${BASE_URL}/api/v1/agents/registry/agt_abc123/rotate-key`, () => + http.post(`${BASE_URL}/agents/registry/agt_abc123/rotate-key`, () => HttpResponse.json(ROTATED), ), ); @@ -177,7 +177,7 @@ describe("zeroid agents rotate-key", () => { it("outputs raw JSON with --json", async () => { server.use( - http.post(`${BASE_URL}/api/v1/agents/registry/agt_abc123/rotate-key`, () => + http.post(`${BASE_URL}/agents/registry/agt_abc123/rotate-key`, () => HttpResponse.json(ROTATED), ), ); @@ -190,7 +190,7 @@ describe("zeroid agents rotate-key", () => { it("exits 1 when agent not found", async () => { server.use( - http.post(`${BASE_URL}/api/v1/agents/registry/ghost/rotate-key`, () => + http.post(`${BASE_URL}/agents/registry/ghost/rotate-key`, () => HttpResponse.json({ title: "Not Found", detail: "identity not found" }, { status: 404 }), ), ); @@ -204,10 +204,10 @@ describe("zeroid agents rotate-key", () => { // --------------------------------------------------------------------------- describe("zeroid agents deactivate", () => { - it("POST /api/v1/agents/registry/:id/deactivate", async () => { + it("POST /agents/registry/:id/deactivate", async () => { const deactivated = { ...AGENT, status: "deactivated" as const }; server.use( - http.post(`${BASE_URL}/api/v1/agents/registry/agt_abc123/deactivate`, () => + http.post(`${BASE_URL}/agents/registry/agt_abc123/deactivate`, () => HttpResponse.json(deactivated), ), ); @@ -219,7 +219,7 @@ describe("zeroid agents deactivate", () => { it("outputs raw JSON with --json", async () => { const deactivated = { ...AGENT, status: "deactivated" as const }; server.use( - http.post(`${BASE_URL}/api/v1/agents/registry/agt_abc123/deactivate`, () => + http.post(`${BASE_URL}/agents/registry/agt_abc123/deactivate`, () => HttpResponse.json(deactivated), ), ); @@ -232,7 +232,7 @@ describe("zeroid agents deactivate", () => { it("exits 1 on API error", async () => { server.use( - http.post(`${BASE_URL}/api/v1/agents/registry/ghost/deactivate`, () => + http.post(`${BASE_URL}/agents/registry/ghost/deactivate`, () => HttpResponse.json({ title: "Not Found" }, { status: 404 }), ), ); @@ -242,9 +242,9 @@ describe("zeroid agents deactivate", () => { }); describe("zeroid agents activate", () => { - it("POST /api/v1/agents/registry/:id/activate", async () => { + it("POST /agents/registry/:id/activate", async () => { server.use( - http.post(`${BASE_URL}/api/v1/agents/registry/agt_abc123/activate`, () => + http.post(`${BASE_URL}/agents/registry/agt_abc123/activate`, () => HttpResponse.json(AGENT), ), ); @@ -255,7 +255,7 @@ describe("zeroid agents activate", () => { it("outputs raw JSON with --json", async () => { server.use( - http.post(`${BASE_URL}/api/v1/agents/registry/agt_abc123/activate`, () => + http.post(`${BASE_URL}/agents/registry/agt_abc123/activate`, () => HttpResponse.json(AGENT), ), ); @@ -268,7 +268,7 @@ describe("zeroid agents activate", () => { it("exits 1 on API error", async () => { server.use( - http.post(`${BASE_URL}/api/v1/agents/registry/ghost/activate`, () => + http.post(`${BASE_URL}/agents/registry/ghost/activate`, () => HttpResponse.json({ title: "Not Found" }, { status: 404 }), ), ); diff --git a/cli/tests/commands/ciba.test.ts b/cli/tests/commands/ciba.test.ts index 6a850dae..fdb3851d 100644 --- a/cli/tests/commands/ciba.test.ts +++ b/cli/tests/commands/ciba.test.ts @@ -95,7 +95,7 @@ describe("zeroid ciba approve", () => { let projectHeader = ""; server.use( - http.post(`${BASE_URL}/api/v1/oauth2/bc-authorize/ari_test_123/approve`, async ({ request }) => { + http.post(`${BASE_URL}/oauth2/bc-authorize/ari_test_123/approve`, async ({ request }) => { captured = (await request.json()) as Record; accountHeader = request.headers.get("x-account-id") ?? ""; projectHeader = request.headers.get("x-project-id") ?? ""; @@ -172,7 +172,7 @@ describe("zeroid ciba deny", () => { let accountHeader = ""; server.use( - http.post(`${BASE_URL}/api/v1/oauth2/bc-authorize/ari_test_123/deny`, async ({ request }) => { + http.post(`${BASE_URL}/oauth2/bc-authorize/ari_test_123/deny`, async ({ request }) => { captured = (await request.json()) as Record; accountHeader = request.headers.get("x-account-id") ?? ""; return HttpResponse.json({ auth_req_id: "ari_test_123", status: "denied" }); diff --git a/cli/tests/commands/config.test.ts b/cli/tests/commands/config.test.ts index d708f17e..7d00d33b 100644 --- a/cli/tests/commands/config.test.ts +++ b/cli/tests/commands/config.test.ts @@ -24,6 +24,11 @@ vi.mock("node:os", async (importOriginal) => { // Import config functions after mock is in place so they use TEST_HOME. const { setProfile } = await import("../../src/lib/config.js"); +// runCLI injects a per-file ZID_CONFIG_DIR (see helpers.ts); this file isolates +// through the mocked homedir instead, so blank the override to keep the direct +// setProfile() writes above and the CLI reads on the same config file. +const HOME_CONFIG = { ZID_CONFIG_DIR: "" }; + beforeEach(() => { mkdirSync(join(TEST_HOME, ".config", "zeroid"), { recursive: true }); }); @@ -37,6 +42,7 @@ describe("zeroid config list-profiles", () => { const { stdout } = await runCLI(["config", "list-profiles"], { // Clear auth env so it doesn't mask the empty config ZID_API_KEY: "", + ...HOME_CONFIG, }); expect(stdout.join("")).toMatch(/no profiles/i); }); @@ -45,7 +51,7 @@ describe("zeroid config list-profiles", () => { setProfile("dev", { base_url: "http://dev", account_id: "a", project_id: "p", api_key: "k1" }); setProfile("prod", { base_url: "http://prod", account_id: "a", project_id: "p", api_key: "k2" }); - const { stdout } = await runCLI(["config", "list-profiles"]); + const { stdout } = await runCLI(["config", "list-profiles"], HOME_CONFIG); const out = stdout.join("\n"); expect(out).toContain("dev"); expect(out).toContain("prod"); @@ -59,17 +65,17 @@ describe("zeroid config use-profile", () => { setProfile("dev", { base_url: "http://dev", account_id: "a", project_id: "p", api_key: "k1" }); setProfile("prod", { base_url: "http://prod", account_id: "a", project_id: "p", api_key: "k2" }); - const { stdout, exitCode } = await runCLI(["config", "use-profile", "prod"]); + const { stdout, exitCode } = await runCLI(["config", "use-profile", "prod"], HOME_CONFIG); expect(exitCode).toBeUndefined(); expect(stdout.join("")).toContain("prod"); // Verify it's now active by listing - const { stdout: list } = await runCLI(["config", "list-profiles"]); + const { stdout: list } = await runCLI(["config", "list-profiles"], HOME_CONFIG); expect(list.join("\n")).toContain("* prod"); }); it("exits 1 when the profile does not exist", async () => { - const { exitCode, stderr } = await runCLI(["config", "use-profile", "ghost"]); + const { exitCode, stderr } = await runCLI(["config", "use-profile", "ghost"], HOME_CONFIG); expect(exitCode).toBe(1); expect(stderr.join("")).toMatch(/not found/i); }); diff --git a/cli/tests/commands/creds.test.ts b/cli/tests/commands/creds.test.ts index 2c23251d..599f613c 100644 --- a/cli/tests/commands/creds.test.ts +++ b/cli/tests/commands/creds.test.ts @@ -5,10 +5,10 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; -import { runCLI, BASE_URL } from "../helpers.js"; +import { runCLI, BASE_URL, tokenMintHandler } from "../helpers.js"; import type { CredentialListResponse, IssuedCredential } from "@highflame/sdk"; -const server = setupServer(); +const server = setupServer(tokenMintHandler()); beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); @@ -51,10 +51,10 @@ const LIST_RESPONSE: CredentialListResponse = { }; describe("zeroid creds list", () => { - it("GET /api/v1/credentials?identity_id= and renders table", async () => { + it("GET /credentials?identity_id= and renders table", async () => { let capturedUrl = ""; server.use( - http.get(`${BASE_URL}/api/v1/credentials`, ({ request }) => { + http.get(`${BASE_URL}/credentials`, ({ request }) => { capturedUrl = request.url; return HttpResponse.json(LIST_RESPONSE); }), @@ -73,7 +73,7 @@ describe("zeroid creds list", () => { it("--active filters out revoked credentials", async () => { server.use( - http.get(`${BASE_URL}/api/v1/credentials`, () => HttpResponse.json(LIST_RESPONSE)), + http.get(`${BASE_URL}/credentials`, () => HttpResponse.json(LIST_RESPONSE)), ); const { stdout } = await runCLI(["creds", "list", "--agent", "agt_abc123", "--active"]); @@ -85,7 +85,7 @@ describe("zeroid creds list", () => { it("shows scopes in the table", async () => { server.use( - http.get(`${BASE_URL}/api/v1/credentials`, () => HttpResponse.json(LIST_RESPONSE)), + http.get(`${BASE_URL}/credentials`, () => HttpResponse.json(LIST_RESPONSE)), ); const { stdout } = await runCLI(["creds", "list", "--agent", "agt_abc123"]); expect(stdout.join("\n")).toContain("repo:read pr:write"); @@ -93,7 +93,7 @@ describe("zeroid creds list", () => { it("outputs raw JSON with --json", async () => { server.use( - http.get(`${BASE_URL}/api/v1/credentials`, () => HttpResponse.json(LIST_RESPONSE)), + http.get(`${BASE_URL}/credentials`, () => HttpResponse.json(LIST_RESPONSE)), ); const { stdout } = await runCLI(["creds", "list", "--agent", "agt_abc123", "--json"]); const parsed = JSON.parse(stdout.join("")) as IssuedCredential[]; @@ -105,7 +105,7 @@ describe("zeroid creds list", () => { it("prints 'No credentials found' when list is empty", async () => { server.use( - http.get(`${BASE_URL}/api/v1/credentials`, () => + http.get(`${BASE_URL}/credentials`, () => HttpResponse.json({ credentials: [], total: 0 }), ), ); @@ -115,7 +115,7 @@ describe("zeroid creds list", () => { it("normalizes null credentials to an empty array", async () => { server.use( - http.get(`${BASE_URL}/api/v1/credentials`, () => + http.get(`${BASE_URL}/credentials`, () => HttpResponse.json({ credentials: null, total: 0 }), ), ); @@ -130,7 +130,7 @@ describe("zeroid creds list", () => { it("exits 1 on API error", async () => { server.use( - http.get(`${BASE_URL}/api/v1/credentials`, () => + http.get(`${BASE_URL}/credentials`, () => HttpResponse.json({ title: "Not Found" }, { status: 404 }), ), ); diff --git a/cli/tests/commands/init.test.ts b/cli/tests/commands/init.test.ts index d5f5a024..b11c6ced 100644 --- a/cli/tests/commands/init.test.ts +++ b/cli/tests/commands/init.test.ts @@ -7,10 +7,10 @@ import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import { rmSync } from "node:fs"; import { join } from "node:path"; -import { runCLI, BASE_URL } from "../helpers.js"; +import { runCLI, BASE_URL, tokenMintHandler, emptyConfigDir } from "../helpers.js"; import type { AgentRegistered, AgentResponse } from "@highflame/sdk"; -const server = setupServer(); +const server = setupServer(tokenMintHandler()); beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); @@ -49,10 +49,10 @@ const OWNER = "user_xyz"; afterEach(() => rmSync(join(process.cwd(), ".env.zeroid"), { force: true })); describe("zeroid init", () => { - it("POST /api/v1/agents/register with correct fields", async () => { + it("POST /agents/register with correct fields", async () => { let captured: Record = {}; server.use( - http.post(`${BASE_URL}/api/v1/agents/register`, async ({ request }) => { + http.post(`${BASE_URL}/agents/register`, async ({ request }) => { captured = (await request.json()) as Record; return HttpResponse.json(REGISTER_RESPONSE); }), @@ -69,7 +69,7 @@ describe("zeroid init", () => { it("uses --id as external_id when provided", async () => { let captured: Record = {}; server.use( - http.post(`${BASE_URL}/api/v1/agents/register`, async ({ request }) => { + http.post(`${BASE_URL}/agents/register`, async ({ request }) => { captured = (await request.json()) as Record; return HttpResponse.json(REGISTER_RESPONSE); }), @@ -84,7 +84,7 @@ describe("zeroid init", () => { it("includes framework and description when provided", async () => { let captured: Record = {}; server.use( - http.post(`${BASE_URL}/api/v1/agents/register`, async ({ request }) => { + http.post(`${BASE_URL}/agents/register`, async ({ request }) => { captured = (await request.json()) as Record; return HttpResponse.json(REGISTER_RESPONSE); }), @@ -103,7 +103,7 @@ describe("zeroid init", () => { it("prints WIMSE URI and API key on success", async () => { server.use( - http.post(`${BASE_URL}/api/v1/agents/register`, () => HttpResponse.json(REGISTER_RESPONSE)), + http.post(`${BASE_URL}/agents/register`, () => HttpResponse.json(REGISTER_RESPONSE)), ); const { stdout, stderr } = await runCLI(["init", "--name", "github-mcp-server", "--owner", OWNER]); @@ -116,7 +116,7 @@ describe("zeroid init", () => { it("writes .env.zeroid with the api_key", async () => { server.use( - http.post(`${BASE_URL}/api/v1/agents/register`, () => HttpResponse.json(REGISTER_RESPONSE)), + http.post(`${BASE_URL}/agents/register`, () => HttpResponse.json(REGISTER_RESPONSE)), ); await runCLI(["init", "--name", "github-mcp-server", "--owner", OWNER]); @@ -128,7 +128,7 @@ describe("zeroid init", () => { it("outputs raw JSON with --json", async () => { server.use( - http.post(`${BASE_URL}/api/v1/agents/register`, () => HttpResponse.json(REGISTER_RESPONSE)), + http.post(`${BASE_URL}/agents/register`, () => HttpResponse.json(REGISTER_RESPONSE)), ); const { stdout } = await runCLI(["init", "--name", "agent", "--owner", OWNER, "--json"]); @@ -140,7 +140,7 @@ describe("zeroid init", () => { it("still writes .env.zeroid when --json is given", async () => { server.use( - http.post(`${BASE_URL}/api/v1/agents/register`, () => HttpResponse.json(REGISTER_RESPONSE)), + http.post(`${BASE_URL}/agents/register`, () => HttpResponse.json(REGISTER_RESPONSE)), ); await runCLI(["init", "--name", "agent", "--owner", OWNER, "--json"]); @@ -153,7 +153,7 @@ describe("zeroid init", () => { it("bootstraps without ZID_API_KEY when tenant env vars are present", async () => { let authorization: string | null = null; server.use( - http.post(`${BASE_URL}/api/v1/agents/register`, ({ request }) => { + http.post(`${BASE_URL}/agents/register`, ({ request }) => { authorization = request.headers.get("authorization"); return HttpResponse.json(REGISTER_RESPONSE); }), @@ -161,7 +161,10 @@ describe("zeroid init", () => { const { exitCode } = await runCLI( ["init", "--name", "bootstrap-agent", "--owner", OWNER], - { ZID_API_KEY: "" }, + // Empty key AND a profile-free config dir: earlier tests in this file + // save profiles (whose api_key the SDK would otherwise mint a bearer + // from), and this test's point is credential-less bootstrap. + { ZID_API_KEY: "", ZID_CONFIG_DIR: emptyConfigDir() }, ); expect(exitCode).toBeUndefined(); @@ -170,7 +173,7 @@ describe("zeroid init", () => { it("exits 1 on conflict (agent already exists)", async () => { server.use( - http.post(`${BASE_URL}/api/v1/agents/register`, () => + http.post(`${BASE_URL}/agents/register`, () => HttpResponse.json({ title: "Conflict", detail: "agent already exists" }, { status: 409 }), ), ); diff --git a/cli/tests/commands/signal.test.ts b/cli/tests/commands/signal.test.ts index 22ce04ab..4f048892 100644 --- a/cli/tests/commands/signal.test.ts +++ b/cli/tests/commands/signal.test.ts @@ -5,10 +5,10 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; -import { runCLI, BASE_URL } from "../helpers.js"; +import { runCLI, BASE_URL, tokenMintHandler } from "../helpers.js"; import type { CAESignal, CreateSignalRequest } from "@highflame/sdk"; -const server = setupServer(); +const server = setupServer(tokenMintHandler()); beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); @@ -25,10 +25,10 @@ const SIGNAL_RESPONSE: CAESignal = { }; describe("zeroid signal", () => { - it("POST /api/v1/signals/ingest with all required fields", async () => { + it("POST /signals/ingest with all required fields", async () => { let captured: CreateSignalRequest = {} as CreateSignalRequest; server.use( - http.post(`${BASE_URL}/api/v1/signals/ingest`, async ({ request }) => { + http.post(`${BASE_URL}/signals/ingest`, async ({ request }) => { captured = (await request.json()) as CreateSignalRequest; return HttpResponse.json(SIGNAL_RESPONSE); }), @@ -51,7 +51,7 @@ describe("zeroid signal", () => { it("stores --reason in payload.reason", async () => { let captured: CreateSignalRequest = {} as CreateSignalRequest; server.use( - http.post(`${BASE_URL}/api/v1/signals/ingest`, async ({ request }) => { + http.post(`${BASE_URL}/signals/ingest`, async ({ request }) => { captured = (await request.json()) as CreateSignalRequest; return HttpResponse.json(SIGNAL_RESPONSE); }), @@ -72,7 +72,7 @@ describe("zeroid signal", () => { it("omits payload when --reason is not given", async () => { let captured: CreateSignalRequest = {} as CreateSignalRequest; server.use( - http.post(`${BASE_URL}/api/v1/signals/ingest`, async ({ request }) => { + http.post(`${BASE_URL}/signals/ingest`, async ({ request }) => { captured = (await request.json()) as CreateSignalRequest; return HttpResponse.json(SIGNAL_RESPONSE); }), @@ -91,7 +91,7 @@ describe("zeroid signal", () => { it("prints signal ID on success", async () => { server.use( - http.post(`${BASE_URL}/api/v1/signals/ingest`, () => HttpResponse.json(SIGNAL_RESPONSE)), + http.post(`${BASE_URL}/signals/ingest`, () => HttpResponse.json(SIGNAL_RESPONSE)), ); const { stdout, exitCode } = await runCLI([ "signal", @@ -106,7 +106,7 @@ describe("zeroid signal", () => { it("outputs raw JSON with --json", async () => { server.use( - http.post(`${BASE_URL}/api/v1/signals/ingest`, () => HttpResponse.json(SIGNAL_RESPONSE)), + http.post(`${BASE_URL}/signals/ingest`, () => HttpResponse.json(SIGNAL_RESPONSE)), ); const { stdout } = await runCLI([ "signal", @@ -143,7 +143,7 @@ describe("zeroid signal", () => { it("exits 1 on API error", async () => { server.use( - http.post(`${BASE_URL}/api/v1/signals/ingest`, () => + http.post(`${BASE_URL}/signals/ingest`, () => HttpResponse.json({ title: "Bad Request" }, { status: 400 }), ), ); diff --git a/cli/tests/helpers.ts b/cli/tests/helpers.ts index a7897b34..498a46d7 100644 --- a/cli/tests/helpers.ts +++ b/cli/tests/helpers.ts @@ -8,6 +8,10 @@ import { vi } from "vitest"; import { Command } from "commander"; +import { http, HttpResponse } from "msw"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { registerInit } from "../src/commands/init.js"; import { registerDecode } from "../src/commands/token/decode.js"; @@ -25,12 +29,25 @@ import { registerCiba } from "../src/commands/ciba/index.js"; export const BASE_URL = "http://zeroid.test"; +/** + * Per-test-file config dir so profile reads/writes never touch the real + * ~/.config/zeroid (init tests save profiles; without this they leak onto the + * developer's machine and into later tests). + */ +export const TEST_CONFIG_DIR = mkdtempSync(join(tmpdir(), "zeroid-cli-test-")); + +/** A fresh, empty config dir — for tests that must see no saved profile. */ +export function emptyConfigDir(): string { + return mkdtempSync(join(tmpdir(), "zeroid-cli-empty-")); +} + /** Env vars that satisfy requireProfile() without a config file. */ export const AUTH_ENV = { ZID_API_KEY: "zid_sk_test", ZID_ACCOUNT_ID: "acct_test", ZID_PROJECT_ID: "proj_test", ZID_BASE_URL: BASE_URL, + ZID_CONFIG_DIR: TEST_CONFIG_DIR, }; /** Result of running a CLI command in test. */ @@ -190,3 +207,19 @@ export function makeJWT(opts: FakeJWTOptions = {}): string { }; return `${b64url(header)}.${b64url(payload)}.fakesig`; } + +/** + * msw handler for the admin-token mint the SDK (≥0.3.23) performs before its + * first admin-plane call when constructed with an apiKey. Pass to + * setupServer() so it survives resetHandlers(); per-test server.use() + * handlers for /oauth2/token still take precedence. + */ +export function tokenMintHandler() { + return http.post(`${BASE_URL}/oauth2/token`, () => + HttpResponse.json({ + access_token: makeJWT({ scopes: ["nhi:manage"] }), + token_type: "Bearer", + expires_in: 900, + }), + ); +} diff --git a/config.go b/config.go index 518fca97..144ec6b3 100644 --- a/config.go +++ b/config.go @@ -18,9 +18,11 @@ import ( ) // DefaultAdminPathPrefix is the default URL prefix for admin API routes. -// Standalone ZeroID serves admin routes at /api/v1/*. Deployers can override -// this via ServerConfig.AdminPathPrefix. -const DefaultAdminPathPrefix = "/api/v1" +// Admin routes are served at the router root, matching the SaaS deployment +// shape and what the highflame SDKs (Python and TypeScript, ≥0.3.23) expect. +// Deployers can override this via ServerConfig.AdminPathPrefix (pre-flip +// deployments that scripted against /api/v1 set it back explicitly). +const DefaultAdminPathPrefix = "" // DefaultSigningJWKSName is the default suffix for the workload-attested // signing-credential verification JWKS, served at @@ -202,13 +204,12 @@ type ServerConfig struct { ShutdownTimeoutSeconds int `koanf:"shutdown_timeout_seconds"` // AdminPathPrefix is the URL prefix for admin API routes (identities, agents, - // credentials, etc.). Defaults to "/api/v1" for standalone deployments. + // credentials, etc.). Defaults to "" — admin routes at the router root. // // Deployers that mount ZeroID under their own path structure can override this. - // For example, highflame-authn sets this to "" and mounts the router at "/v1/auth" - // so admin routes become /v1/auth/identities/schema instead of /api/v1/identities/schema. - // - // Set to empty string ("") to register admin routes at the router root. + // For example, highflame-authn mounts the router at "/v1/auth" so admin routes + // become /v1/auth/identities/schema; a standalone deployment wanting the + // pre-flip shape sets "/api/v1". AdminPathPrefix *string `koanf:"admin_path_prefix"` // TrustForwardedHeaders tells the server to read X-Forwarded-Proto and @@ -220,8 +221,8 @@ type ServerConfig struct { TrustForwardedHeaders bool `koanf:"trust_forwarded_headers"` } -// GetAdminPathPrefix returns the admin route prefix. Defaults to "/api/v1" -// when not explicitly set. +// GetAdminPathPrefix returns the admin route prefix. Defaults to "" (router +// root) when not explicitly set. func (s *ServerConfig) GetAdminPathPrefix() string { if s.AdminPathPrefix != nil { return *s.AdminPathPrefix diff --git a/docs/attestation.md b/docs/attestation.md index 096ad533..883cfc3a 100644 --- a/docs/attestation.md +++ b/docs/attestation.md @@ -131,7 +131,7 @@ A policy ties a tenant + proof type to the rules a submitted proof must satisfy. ### Endpoint ``` -PUT /api/v1/attestation-policies +PUT /attestation-policies Content-Type: application/json { @@ -169,7 +169,7 @@ Content-Type: application/json A workflow in `myorg/myrepo` on `main` is allowed to attest. Anything else is rejected. ```bash -curl -X PUT https://zeroid.example.com/api/v1/attestation-policies \ +curl -X PUT https://zeroid.example.com/attestation-policies \ -H "Content-Type: application/json" \ -H "X-Account-ID: acct_123" -H "X-Project-ID: proj_456" \ -d '{ @@ -194,8 +194,8 @@ In the workflow: with: script: | const token = await core.getIDToken('https://github.com/myorg'); - // POST to /api/v1/attestation/submit with proof_type=oidc_token, proof_value=token - // then POST /api/v1/attestation/verify with the returned record id + // POST to /attestation/submit with proof_type=oidc_token, proof_value=token + // then POST /attestation/verify with the returned record id ``` ### Worked example: GCP Workload Identity Federation diff --git a/docs/dpop-and-dcr.md b/docs/dpop-and-dcr.md index d7b7604b..9dcf309a 100644 --- a/docs/dpop-and-dcr.md +++ b/docs/dpop-and-dcr.md @@ -50,7 +50,7 @@ The access token's claims include a `cnf` (confirmation) member with `jkt` = the Per RFC 9449 §7, the access token is presented with the `DPoP` (not `Bearer`) auth scheme, and a **new** proof JWT is signed for **this** call. The new proof carries an `ath` claim — `base64url(SHA-256(access_token))` — that binds the proof to this specific access token. ```http -POST /api/v1/transfer HTTP/1.1 +POST /transfer HTTP/1.1 Host: payments.example.com Authorization: DPoP eyJ0eXAi... DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7... (different jti, ath claim set) @@ -163,7 +163,7 @@ ZeroID has two registration paths; the right one depends on what you're register | Use case | Endpoint | Auth shape | |---|---|---| -| **Agent identity** participating in delegation chains (`token_exchange`, multi-hop `jwt-bearer`) | `POST /api/v1/agents/register` with `public_key_pem` | Keypair owned by the agent; public PEM uploaded to the broker at registration. Self-signed JWT assertions verify against the stored key. | +| **Agent identity** participating in delegation chains (`token_exchange`, multi-hop `jwt-bearer`) | `POST /agents/register` with `public_key_pem` | Keypair owned by the agent; public PEM uploaded to the broker at registration. Self-signed JWT assertions verify against the stored key. | | **Confidential OAuth client** — vendor MCP server, installer bootstrap, single-hop tool — that does not participate in delegation chains | `POST /oauth2/register` (this doc) with `client_secret_basic` / `client_secret_post`, optionally inline `jwks` for jwt-bearer assertions | Client secret minted at registration; assertion-signing keys (if used) held at the broker. | The split is deliberate. DCR-registered clients are **explicitly blocked from `token_exchange`** (enforced at the grant-type allow-list — see "What ZeroID enforces" below) because they have no `IdentityID` binding and cannot legitimately act as a delegation actor. Agents that need to participate in chains must register through the agent identity path. See the [agent registration walkthrough in the README](../README.md#1-register-an-agent) for that side of the API. diff --git a/examples/zeroid_quickstart.ipynb b/examples/zeroid_quickstart.ipynb index 8247e64c..0706eabd 100644 --- a/examples/zeroid_quickstart.ipynb +++ b/examples/zeroid_quickstart.ipynb @@ -70,7 +70,7 @@ "\n", "ZeroID exposes two logical surfaces on the same port:\n", "- **Public endpoints** (`/oauth2/*`, `/health`, `/.well-known/*`) — no auth required\n", - "- **Admin endpoints** (`/api/v1/*`) — tenant context via `X-Account-ID` + `X-Project-ID` headers" + "- **Admin endpoints** (identities, agents, policies, signals — served at the server root) — tenant context via `X-Account-ID` + `X-Project-ID` headers" ] }, { @@ -265,7 +265,37 @@ "id": "cell-s2-orch", "metadata": {}, "outputs": [], - "source": "from highflame.zeroid.errors import ConflictError\n\ntry:\n orchestrator = client.identities.create(\n external_id=\"billing-orchestrator\",\n name=\"Billing Orchestrator Agent\",\n identity_type=\"agent\",\n sub_type=\"orchestrator\",\n trust_level=\"first_party\",\n owner_user_id=\"user-yash\",\n allowed_scopes=[\"billing:read\", \"billing:write\", \"data:read\"],\n framework=\"langchain\",\n version=\"0.3.1\",\n description=\"Orchestrates billing workflows across sub-agents\",\n )\n print(\"Identity registered (new)\")\nexcept ConflictError:\n # Already exists — fetch it by listing and filtering\n identities = client.identities.list()\n orchestrator = next(i for i in identities if i.external_id == \"billing-orchestrator\")\n print(\"Identity already exists (reusing)\")\n\nprint(f\" ID: {orchestrator.id}\")\nprint(f\" External ID: {orchestrator.external_id}\")\nprint(f\" Type: {orchestrator.identity_type}/{orchestrator.sub_type}\")\nprint(f\" Trust Level: {orchestrator.trust_level}\")\nprint(f\" Status: {orchestrator.status}\")\nprint(f\" WIMSE URI: {orchestrator.wimse_uri}\")\nprint(f\" Scopes: {orchestrator.allowed_scopes}\")" + "source": [ + "from highflame.zeroid.errors import ConflictError\n", + "\n", + "try:\n", + " orchestrator = client.identities.create(\n", + " external_id=\"billing-orchestrator\",\n", + " name=\"Billing Orchestrator Agent\",\n", + " identity_type=\"agent\",\n", + " sub_type=\"orchestrator\",\n", + " trust_level=\"first_party\",\n", + " owner_user_id=\"user-yash\",\n", + " allowed_scopes=[\"billing:read\", \"billing:write\", \"data:read\"],\n", + " framework=\"langchain\",\n", + " version=\"0.3.1\",\n", + " description=\"Orchestrates billing workflows across sub-agents\",\n", + " )\n", + " print(\"Identity registered (new)\")\n", + "except ConflictError:\n", + " # Already exists — fetch it by listing and filtering\n", + " identities = client.identities.list()\n", + " orchestrator = next(i for i in identities if i.external_id == \"billing-orchestrator\")\n", + " print(\"Identity already exists (reusing)\")\n", + "\n", + "print(f\" ID: {orchestrator.id}\")\n", + "print(f\" External ID: {orchestrator.external_id}\")\n", + "print(f\" Type: {orchestrator.identity_type}/{orchestrator.sub_type}\")\n", + "print(f\" Trust Level: {orchestrator.trust_level}\")\n", + "print(f\" Status: {orchestrator.status}\")\n", + "print(f\" WIMSE URI: {orchestrator.wimse_uri}\")\n", + "print(f\" Scopes: {orchestrator.allowed_scopes}\")" + ] }, { "cell_type": "markdown", @@ -462,7 +492,40 @@ "id": "cell-s3-client", "metadata": {}, "outputs": [], - "source": "# The client_credentials grant resolves the agent identity by matching the\n# OAuth client_id to an identity's external_id within the tenant — so the\n# client_id here MUST equal the orchestrator's external_id (\"billing-orchestrator\").\ntry:\n oauth_result = client.oauth_clients.create(\n client_id=orchestrator.external_id, # must match the identity's external_id\n name=\"Billing Orchestrator Client\",\n confidential=True, # M2M: server returns a one-time client_secret\n identity_id=orchestrator.id, # bind the client to the orchestrator identity\n grant_types=[\n \"client_credentials\",\n \"urn:ietf:params:oauth:grant-type:token-exchange\",\n ],\n scopes=[\"billing:read\", \"billing:write\", \"data:read\"],\n )\n oauth_client = oauth_result.client\n client_secret = oauth_result.client_secret\n print(\"OAuth client created (new)\")\nexcept ConflictError:\n # Already exists — find it by client_id and rotate the secret to get a fresh one.\n clients = client.oauth_clients.list()\n existing = next(c for c in clients if c.client_id == orchestrator.external_id)\n rotated = client.oauth_clients.rotate_secret(existing.id)\n oauth_client = rotated.client\n client_secret = rotated.client_secret\n print(\"OAuth client already exists (secret rotated)\")\n\nprint(f\" Client ID: {oauth_client.client_id}\")\nprint(f\" Client Secret: {client_secret[:20]}... (save this!)\")\nprint(f\" Grant Types: {oauth_client.grant_types}\")\nprint(f\" Scopes: {oauth_client.scopes}\")\nprint(f\" Identity ID: {oauth_client.identity_id}\")" + "source": [ + "# The client_credentials grant resolves the agent identity by matching the\n", + "# OAuth client_id to an identity's external_id within the tenant — so the\n", + "# client_id here MUST equal the orchestrator's external_id (\"billing-orchestrator\").\n", + "try:\n", + " oauth_result = client.oauth_clients.create(\n", + " client_id=orchestrator.external_id, # must match the identity's external_id\n", + " name=\"Billing Orchestrator Client\",\n", + " confidential=True, # M2M: server returns a one-time client_secret\n", + " identity_id=orchestrator.id, # bind the client to the orchestrator identity\n", + " grant_types=[\n", + " \"client_credentials\",\n", + " \"urn:ietf:params:oauth:grant-type:token-exchange\",\n", + " ],\n", + " scopes=[\"billing:read\", \"billing:write\", \"data:read\"],\n", + " )\n", + " oauth_client = oauth_result.client\n", + " client_secret = oauth_result.client_secret\n", + " print(\"OAuth client created (new)\")\n", + "except ConflictError:\n", + " # Already exists — find it by client_id and rotate the secret to get a fresh one.\n", + " clients = client.oauth_clients.list()\n", + " existing = next(c for c in clients if c.client_id == orchestrator.external_id)\n", + " rotated = client.oauth_clients.rotate_secret(existing.id)\n", + " oauth_client = rotated.client\n", + " client_secret = rotated.client_secret\n", + " print(\"OAuth client already exists (secret rotated)\")\n", + "\n", + "print(f\" Client ID: {oauth_client.client_id}\")\n", + "print(f\" Client Secret: {client_secret[:20]}... (save this!)\")\n", + "print(f\" Grant Types: {oauth_client.grant_types}\")\n", + "print(f\" Scopes: {oauth_client.scopes}\")\n", + "print(f\" Identity ID: {oauth_client.identity_id}\")" + ] }, { "cell_type": "markdown", @@ -478,7 +541,22 @@ "id": "cell-s3-token", "metadata": {}, "outputs": [], - "source": "token_response = client.tokens.issue_client_credentials(\n client_id=oauth_client.client_id,\n client_secret=client_secret,\n scope=\"billing:read data:read\",\n)\n\norchestrator_token = token_response.access_token\n\nprint(f\"Token issued:\")\nprint(f\" Token Type: {token_response.token_type}\")\nprint(f\" Expires In: {token_response.expires_in}s\")\nprint(f\" Scope: {token_response.scope}\")\nprint(f\" JTI: {token_response.jti}\")\nprint(f\" Token: {orchestrator_token[:60]}...\")" + "source": [ + "token_response = client.tokens.issue_client_credentials(\n", + " client_id=oauth_client.client_id,\n", + " client_secret=client_secret,\n", + " scope=\"billing:read data:read\",\n", + ")\n", + "\n", + "orchestrator_token = token_response.access_token\n", + "\n", + "print(f\"Token issued:\")\n", + "print(f\" Token Type: {token_response.token_type}\")\n", + "print(f\" Expires In: {token_response.expires_in}s\")\n", + "print(f\" Scope: {token_response.scope}\")\n", + "print(f\" JTI: {token_response.jti}\")\n", + "print(f\" Token: {orchestrator_token[:60]}...\")" + ] }, { "cell_type": "markdown", @@ -592,7 +670,34 @@ "id": "cell-s4-assertion", "metadata": {}, "outputs": [], - "source": "import jwt as pyjwt\n\nnow = int(time.time())\nissuer_url = metadata[\"issuer\"] # from the well-known discovery response (dict)\n\n# The tool agent builds its assertion JWT using its private key.\n# This proves \"I am data-fetcher\" without any shared secret.\n# Per RFC 7523 §3 the assertion needs iss, sub, aud, and exp — for a\n# self-signed identity assertion, sub == iss == the agent's WIMSE URI.\nactor_assertion = pyjwt.encode(\n {\n \"iss\": tool_agent.wimse_uri, # who I am (WIMSE URI)\n \"sub\": tool_agent.wimse_uri, # self-signed: subject == issuer\n \"aud\": [issuer_url], # target: ZeroID\n \"iat\": now,\n \"exp\": now + 300, # 5 minute validity\n },\n tool_agent_private_key,\n algorithm=\"ES256\",\n)\n\nprint(f\"Actor assertion created:\")\nprint(f\" Algorithm: ES256\")\nprint(f\" Issuer: {tool_agent.wimse_uri}\")\nprint(f\" Audience: {issuer_url}\")\nprint(f\" JWT: {actor_assertion[:60]}...\")" + "source": [ + "import jwt as pyjwt\n", + "\n", + "now = int(time.time())\n", + "issuer_url = metadata[\"issuer\"] # from the well-known discovery response (dict)\n", + "\n", + "# The tool agent builds its assertion JWT using its private key.\n", + "# This proves \"I am data-fetcher\" without any shared secret.\n", + "# Per RFC 7523 §3 the assertion needs iss, sub, aud, and exp — for a\n", + "# self-signed identity assertion, sub == iss == the agent's WIMSE URI.\n", + "actor_assertion = pyjwt.encode(\n", + " {\n", + " \"iss\": tool_agent.wimse_uri, # who I am (WIMSE URI)\n", + " \"sub\": tool_agent.wimse_uri, # self-signed: subject == issuer\n", + " \"aud\": [issuer_url], # target: ZeroID\n", + " \"iat\": now,\n", + " \"exp\": now + 300, # 5 minute validity\n", + " },\n", + " tool_agent_private_key,\n", + " algorithm=\"ES256\",\n", + ")\n", + "\n", + "print(f\"Actor assertion created:\")\n", + "print(f\" Algorithm: ES256\")\n", + "print(f\" Issuer: {tool_agent.wimse_uri}\")\n", + "print(f\" Audience: {issuer_url}\")\n", + "print(f\" JWT: {actor_assertion[:60]}...\")" + ] }, { "cell_type": "markdown", @@ -608,7 +713,21 @@ "id": "cell-s4-exchange", "metadata": {}, "outputs": [], - "source": "delegated_response = client.tokens.issue_token_exchange(\n subject_token=orchestrator_token, # orchestrator's active token\n actor_token=actor_assertion, # tool agent's self-signed assertion\n scope=\"data:read\", # requested scope (must be subset of both)\n)\n\ndelegated_token = delegated_response.access_token\n\nprint(f\"Delegated token issued:\")\nprint(f\" Token Type: {delegated_response.token_type}\")\nprint(f\" Scope: {delegated_response.scope}\")\nprint(f\" Expires In: {delegated_response.expires_in}s\")\nprint(f\" Token: {delegated_token[:60]}...\")" + "source": [ + "delegated_response = client.tokens.issue_token_exchange(\n", + " subject_token=orchestrator_token, # orchestrator's active token\n", + " actor_token=actor_assertion, # tool agent's self-signed assertion\n", + " scope=\"data:read\", # requested scope (must be subset of both)\n", + ")\n", + "\n", + "delegated_token = delegated_response.access_token\n", + "\n", + "print(f\"Delegated token issued:\")\n", + "print(f\" Token Type: {delegated_response.token_type}\")\n", + "print(f\" Scope: {delegated_response.scope}\")\n", + "print(f\" Expires In: {delegated_response.expires_in}s\")\n", + "print(f\" Token: {delegated_token[:60]}...\")" + ] }, { "cell_type": "markdown", @@ -667,7 +786,9 @@ "- **Revoke** — \"Kill this token now.\" When an agent is compromised, misbehaving, or decommissioned,\n", " you revoke its credentials immediately — don't wait for expiry.\n", "\n", - "ZeroID implements RFC 7662 (introspection) and RFC 7009 (revocation)." + "ZeroID implements RFC 7662 (introspection) and RFC 7009 (revocation). Per RFC 7009\n", + "the revocation endpoint authenticates the **client** — an unauthenticated revoke is\n", + "refused with `invalid_client`." ] }, { @@ -684,7 +805,22 @@ "id": "cell-s5-introspect", "metadata": {}, "outputs": [], - "source": "introspection = client.tokens.introspect(delegated_token)\n\nprint(f\"Token introspection:\")\nprint(f\" active: {introspection.active}\")\nprint(f\" sub: {introspection.sub}\")\nprint(f\" iss: {introspection.iss}\")\nprint(f\" scope: {introspection.scope}\")\nprint(f\" trust_level: {introspection.trust_level}\")\nprint(f\" account_id: {introspection.account_id}\")\nprint(f\" project_id: {introspection.project_id}\")\n\n# act is a first-class field on TokenIntrospection (delegation chain).\nif introspection.act:\n print(f\" act.sub: {introspection.act.get('sub')}\")" + "source": [ + "introspection = client.tokens.introspect(delegated_token)\n", + "\n", + "print(f\"Token introspection:\")\n", + "print(f\" active: {introspection.active}\")\n", + "print(f\" sub: {introspection.sub}\")\n", + "print(f\" iss: {introspection.iss}\")\n", + "print(f\" scope: {introspection.scope}\")\n", + "print(f\" trust_level: {introspection.trust_level}\")\n", + "print(f\" account_id: {introspection.account_id}\")\n", + "print(f\" project_id: {introspection.project_id}\")\n", + "\n", + "# act is a first-class field on TokenIntrospection (delegation chain).\n", + "if introspection.act:\n", + " print(f\" act.sub: {introspection.act.get('sub')}\")" + ] }, { "cell_type": "markdown", @@ -701,7 +837,9 @@ "metadata": {}, "outputs": [], "source": [ - "client.tokens.revoke(delegated_token)\n", + "# RFC 7009 authenticates the *client*, not the token: revocation requires\n", + "# the OAuth client's credentials.\n", + "client.tokens.revoke(delegated_token, client_id=oauth_client.client_id, client_secret=client_secret)\n", "print(\"Token revocation request sent (RFC 7009 — always returns 200).\")" ] }, @@ -887,7 +1025,20 @@ "id": "cell-s7-setup", "metadata": {}, "outputs": [], - "source": "# Get a fresh token for the orchestrator\nfresh_token_response = client.tokens.issue_client_credentials(\n client_id=oauth_client.client_id,\n client_secret=client_secret,\n scope=\"billing:read\",\n)\nfresh_token = fresh_token_response.access_token\n\n# Confirm it is active\nresult = client.tokens.introspect(fresh_token)\nprint(f\"Fresh token active: {result.active}\")\nassert result.active is True" + "source": [ + "# Get a fresh token for the orchestrator\n", + "fresh_token_response = client.tokens.issue_client_credentials(\n", + " client_id=oauth_client.client_id,\n", + " client_secret=client_secret,\n", + " scope=\"billing:read\",\n", + ")\n", + "fresh_token = fresh_token_response.access_token\n", + "\n", + "# Confirm it is active\n", + "result = client.tokens.introspect(fresh_token)\n", + "print(f\"Fresh token active: {result.active}\")\n", + "assert result.active is True" + ] }, { "cell_type": "markdown", @@ -1054,13 +1205,103 @@ "scrolled": true }, "outputs": [], - "source": "# Get a fresh orchestrator token (previous was revoked by CAE signal)\nfresh_orch_token = client.tokens.issue_client_credentials(\n client_id=oauth_client.client_id,\n client_secret=client_secret,\n scope=\"billing:read\",\n).access_token\n\nprint(\"ZeroID JWTs carry rich identity context for downstream authorization.\")\nprint()\nprint(\"Any service that trusts ZeroID's JWKS can verify the token and use these claims:\")\nprint()\n\nclaims = decode_jwt_claims(fresh_orch_token)\n_table(\n [\"JWT Claim\", \"Value\", \"Authorization Use\"],\n [\n [\"sub\", claims.get(\"sub\", \"\")[:60] + \"...\", \"Who is this agent?\"],\n [\"trust_level\", claims.get(\"trust_level\", \"\"), \"How much do we trust it?\"],\n [\"identity_type\", claims.get(\"identity_type\", \"\"), \"What kind of principal?\"],\n [\"sub_type\", claims.get(\"sub_type\", \"\"), \"What role does it play?\"],\n [\"scopes\", str(claims.get(\"scopes\", [])), \"What is it allowed to do?\"],\n [\"delegation_depth\", str(claims.get(\"delegation_depth\", 0)), \"Is it acting on its own or delegated?\"],\n [\"framework\", claims.get(\"framework\", \"\"), \"Which agent framework?\"],\n ],\n)" + "source": [ + "# Get a fresh orchestrator token (previous was revoked by CAE signal)\n", + "fresh_orch_token = client.tokens.issue_client_credentials(\n", + " client_id=oauth_client.client_id,\n", + " client_secret=client_secret,\n", + " scope=\"billing:read\",\n", + ").access_token\n", + "\n", + "print(\"ZeroID JWTs carry rich identity context for downstream authorization.\")\n", + "print()\n", + "print(\"Any service that trusts ZeroID's JWKS can verify the token and use these claims:\")\n", + "print()\n", + "\n", + "claims = decode_jwt_claims(fresh_orch_token)\n", + "_table(\n", + " [\"JWT Claim\", \"Value\", \"Authorization Use\"],\n", + " [\n", + " [\"sub\", claims.get(\"sub\", \"\")[:60] + \"...\", \"Who is this agent?\"],\n", + " [\"trust_level\", claims.get(\"trust_level\", \"\"), \"How much do we trust it?\"],\n", + " [\"identity_type\", claims.get(\"identity_type\", \"\"), \"What kind of principal?\"],\n", + " [\"sub_type\", claims.get(\"sub_type\", \"\"), \"What role does it play?\"],\n", + " [\"scopes\", str(claims.get(\"scopes\", [])), \"What is it allowed to do?\"],\n", + " [\"delegation_depth\", str(claims.get(\"delegation_depth\", 0)), \"Is it acting on its own or delegated?\"],\n", + " [\"framework\", claims.get(\"framework\", \"\"), \"Which agent framework?\"],\n", + " ],\n", + ")" + ] }, { "cell_type": "markdown", "id": "cell-summary-md", "metadata": {}, - "source": "---\n\n## Quick Reference\n\n### SDK Client Methods\n\n| Method | Description |\n|--------|-------------|\n| `client.health()` | Health check |\n| `client.jwks()` | Fetch JWKS (public keys) |\n| `client.identities.create(...)` | Register an identity |\n| `client.identities.get(id)` | Get identity by UUID |\n| `client.identities.list()` | List identities |\n| `client.identities.update(id, ...)` | Update identity fields |\n| `client.identities.delete(id)` | Deactivate identity (soft delete) |\n| `client.oauth_clients.create(...)` | Register an OAuth2 client |\n| `client.oauth_clients.list()` | List OAuth2 clients |\n| `client.oauth_clients.rotate_secret(id)` | Rotate an OAuth2 client secret |\n| `client.tokens.issue_client_credentials(...)` | Issue a token via client credentials (RFC 6749 §4.4) |\n| `client.tokens.issue_token_exchange(...)` | Issue a delegated token (RFC 8693) |\n| `client.tokens.issue_authorization_code(...)` | Issue a token via authorization code (PKCE) |\n| `client.tokens.issue_refresh_token(...)` | Issue a token via refresh token |\n| `client.tokens.introspect(token)` | Introspect a token (RFC 7662) |\n| `client.tokens.revoke(token)` | Revoke a token (RFC 7009) |\n| `client.credential_policies.create(...)` | Create a credential policy |\n| `client.credential_policies.list()` | List policies |\n| `client.signals.ingest(...)` | Ingest a CAE signal |\n| `client.signals.list(limit=N)` | List recent signals |\n\n> Error types (`ConflictError`, `NotFoundError`, `ValidationError`, …) are importable from `highflame.zeroid.errors`.\n\n### OAuth2 Grant Types\n\n| Grant Type | Use Case | Auth Material |\n|-----------|----------|---------------|\n| `client_credentials` | Machine-to-machine (RFC 6749 §4.4) | `client_id` + `client_secret` |\n| `urn:ietf:params:oauth:grant-type:jwt-bearer` | Key-based (RFC 7523) | Self-signed JWT assertion |\n| `urn:ietf:params:oauth:grant-type:token-exchange` | Delegation (RFC 8693) | `subject_token` + `actor_token` |\n| `api_key` | SDK/CLI authentication | `zid_sk_*` API key |\n| `authorization_code` | PKCE flow (CLI, MCP clients) | Auth code + code verifier |\n| `refresh_token` | Token rotation | Refresh token |\n\n### JWT Claims\n\n| Claim | Description |\n|-------|-------------|\n| `iss` | Issuer (ZeroID server URL) |\n| `sub` | Subject (WIMSE URI of the identity) |\n| `jti` | Unique token ID |\n| `iat` / `exp` | Issued at / expires at |\n| `account_id` / `project_id` | Tenant scope |\n| `identity_type` / `sub_type` | Identity classification |\n| `trust_level` | Trust level at issuance time |\n| `scopes` | Granted scopes |\n| `grant_type` | How the token was issued |\n| `delegation_depth` | Depth in the delegation chain (0 = direct) |\n| `act.sub` | Who delegated (for token_exchange tokens) |\n| `framework` / `version` / `publisher` | Agent metadata |\n| `capabilities` | Agent capabilities (JSON) |\n\n---\n\nBuilt with [ZeroID](https://zeroid.dev) by [Highflame](https://highflame.com) — the control plane for autonomous AI." + "source": [ + "---\n", + "\n", + "## Quick Reference\n", + "\n", + "### SDK Client Methods\n", + "\n", + "| Method | Description |\n", + "|--------|-------------|\n", + "| `client.health()` | Health check |\n", + "| `client.jwks()` | Fetch JWKS (public keys) |\n", + "| `client.identities.create(...)` | Register an identity |\n", + "| `client.identities.get(id)` | Get identity by UUID |\n", + "| `client.identities.list()` | List identities |\n", + "| `client.identities.update(id, ...)` | Update identity fields |\n", + "| `client.identities.delete(id)` | Deactivate identity (soft delete) |\n", + "| `client.oauth_clients.create(...)` | Register an OAuth2 client |\n", + "| `client.oauth_clients.list()` | List OAuth2 clients |\n", + "| `client.oauth_clients.rotate_secret(id)` | Rotate an OAuth2 client secret |\n", + "| `client.tokens.issue_client_credentials(...)` | Issue a token via client credentials (RFC 6749 §4.4) |\n", + "| `client.tokens.issue_token_exchange(...)` | Issue a delegated token (RFC 8693) |\n", + "| `client.tokens.issue_authorization_code(...)` | Issue a token via authorization code (PKCE) |\n", + "| `client.tokens.issue_refresh_token(...)` | Issue a token via refresh token |\n", + "| `client.tokens.introspect(token)` | Introspect a token (RFC 7662) |\n", + "| `client.tokens.revoke(token)` | Revoke a token (RFC 7009) |\n", + "| `client.credential_policies.create(...)` | Create a credential policy |\n", + "| `client.credential_policies.list()` | List policies |\n", + "| `client.signals.ingest(...)` | Ingest a CAE signal |\n", + "| `client.signals.list(limit=N)` | List recent signals |\n", + "\n", + "> Error types (`ConflictError`, `NotFoundError`, `ValidationError`, …) are importable from `highflame.zeroid.errors`.\n", + "\n", + "### OAuth2 Grant Types\n", + "\n", + "| Grant Type | Use Case | Auth Material |\n", + "|-----------|----------|---------------|\n", + "| `client_credentials` | Machine-to-machine (RFC 6749 §4.4) | `client_id` + `client_secret` |\n", + "| `urn:ietf:params:oauth:grant-type:jwt-bearer` | Key-based (RFC 7523) | Self-signed JWT assertion |\n", + "| `urn:ietf:params:oauth:grant-type:token-exchange` | Delegation (RFC 8693) | `subject_token` + `actor_token` |\n", + "| `api_key` | SDK/CLI authentication | `zid_sk_*` API key |\n", + "| `authorization_code` | PKCE flow (CLI, MCP clients) | Auth code + code verifier |\n", + "| `refresh_token` | Token rotation | Refresh token |\n", + "\n", + "### JWT Claims\n", + "\n", + "| Claim | Description |\n", + "|-------|-------------|\n", + "| `iss` | Issuer (ZeroID server URL) |\n", + "| `sub` | Subject (WIMSE URI of the identity) |\n", + "| `jti` | Unique token ID |\n", + "| `iat` / `exp` | Issued at / expires at |\n", + "| `account_id` / `project_id` | Tenant scope |\n", + "| `identity_type` / `sub_type` | Identity classification |\n", + "| `trust_level` | Trust level at issuance time |\n", + "| `scopes` | Granted scopes |\n", + "| `grant_type` | How the token was issued |\n", + "| `delegation_depth` | Depth in the delegation chain (0 = direct) |\n", + "| `act.sub` | Who delegated (for token_exchange tokens) |\n", + "| `framework` / `version` / `publisher` | Agent metadata |\n", + "| `capabilities` | Agent capabilities (JSON) |\n", + "\n", + "---\n", + "\n", + "Built with [ZeroID](https://zeroid.dev) by [Highflame](https://highflame.com) — the control plane for autonomous AI." + ] } ], "metadata": { @@ -1079,7 +1320,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.5" + "version": "3.11.11" } }, "nbformat": 4, diff --git a/server.go b/server.go index 137a8fd2..a5f43700 100644 --- a/server.go +++ b/server.go @@ -116,7 +116,7 @@ func (h *middlewareHolder) chain() func(http.Handler) http.Handler { // - Public routes (/oauth2/*, /.well-known/*, /health, /ready): No authentication. // These are the token endpoints agents and SDKs call directly. // - Admin routes ({AdminPathPrefix}/*): Identity management, credential policies, -// attestation, signals. AdminPathPrefix defaults to "/api/v1" for standalone +// attestation, signals. AdminPathPrefix defaults to "" (router root) for standalone // deployments. No built-in auth by default — protect at the network layer // or use the AdminAuth hook. type Server struct { @@ -532,7 +532,7 @@ func NewServer(cfg Config, opts ...ServerOption) (*Server, error) { apiHandler.RegisterAgentSelfService(handler.NewHumaAPISpecless(r)) }) - // Admin routes — mounted under AdminPathPrefix (default "/api/v1"). + // Admin routes — mounted under AdminPathPrefix (default "": router root). // No built-in auth by default. Protected at the network layer or via AdminAuth hook. adminPrefix := cfg.Server.GetAdminPathPrefix() mountAdmin := func(r chi.Router) { @@ -551,7 +551,18 @@ func NewServer(cfg Config, opts ...ServerOption) (*Server, error) { // Tenant context extraction from X-Account-ID / X-Project-ID headers. r.Use(internalMiddleware.TenantContextMiddleware) - humaAdmin := handler.NewHumaAPI(r) + // With no prefix the admin group shares the router root with the + // public API, and a spec-carrying huma instance here would shadow the + // canonical /openapi.json (chi last-registration wins) — the agent-auth + // copy would additionally gate it behind its 401 middleware. Register + // speclessly at the root; prefixed mounts keep their own spec at + // {prefix}/openapi.json. + newHumaAPI := handler.NewHumaAPI + if adminPrefix == "" { + newHumaAPI = handler.NewHumaAPISpecless + } + + humaAdmin := newHumaAPI(r) apiHandler.RegisterAdmin(humaAdmin, r) // Agent-auth sub-group for proof generation (requires agent JWT). Reuses @@ -559,7 +570,7 @@ func NewServer(cfg Config, opts ...ServerOption) (*Server, error) { r.Group(func(r chi.Router) { r.Use(internalMiddleware.AgentAuthMiddleware(agentAuthCfg)) - humaAgentAuth := handler.NewHumaAPI(r) + humaAgentAuth := newHumaAPI(r) apiHandler.RegisterAgentAuth(humaAgentAuth) }) } diff --git a/tests/integration/helpers_test.go b/tests/integration/helpers_test.go index dc1393e9..00438649 100644 --- a/tests/integration/helpers_test.go +++ b/tests/integration/helpers_test.go @@ -59,7 +59,8 @@ const ( ) // adminPath prepends the default admin route prefix to a relative path. -// Usage: adminPath("/identities") → "/api/v1/identities" +// With the default prefix "" (admin at the router root) this is the identity +// function; it stays so tests keep working if a deployment prefix returns. func adminPath(path string) string { return zeroid.DefaultAdminPathPrefix + path } diff --git a/tests/integration/www_authenticate_compliance_test.go b/tests/integration/www_authenticate_compliance_test.go index ae248469..c1e5fba4 100644 --- a/tests/integration/www_authenticate_compliance_test.go +++ b/tests/integration/www_authenticate_compliance_test.go @@ -51,7 +51,7 @@ func extractResourceMetadata(wwwAuthenticate string) string { // agentAuthProtectedPath is a POST endpoint mounted inside the agent-auth // middleware sub-group — POSTing to it exercises the middleware's 401 // emission path. The agent-auth group lives under the admin path prefix -// (adminPath("...")) so the full URL is "/api/v1/proof/generate". +// (adminPath("..."), default "" so the full URL is "/proof/generate"). func agentAuthProtectedPath() string { return adminPath("/proof/generate") } func TestRFC9728_S5_1_AgentAuthMiddleware_EmitsBreadcrumbOnMissingAuth(t *testing.T) { diff --git a/zeroid.yaml b/zeroid.yaml index 4e46f55e..3d590053 100644 --- a/zeroid.yaml +++ b/zeroid.yaml @@ -76,7 +76,7 @@ token: wimse_domain: "highflame.ai" -# Admin routes (/api/v1/*) have no built-in auth. +# Admin routes (served at the router root; see server.admin_path_prefix) have no built-in auth. # In production, protect them at the network layer (reverse proxy, VPN, firewall) # or use the AdminAuth hook when embedding ZeroID as a library. From 17e0cfa663af64276d3ef81b390faf39a9a2a6a0 Mon Sep 17 00:00:00 2001 From: Sharath Rajasekar Date: Thu, 3 Sep 2026 18:15:42 -0700 Subject: [PATCH 2/2] fix: migrate SDK smoke tests to 0.3.23's RFC 7009 revoke contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sdk-integration.yml still pinned 0.3.17 (missed alongside pr-check.yml), and both smoke suites called tokens.revoke without client credentials, which 0.3.23 refuses client-side per RFC 7009. - token-lifecycle tests: deactivate the agent instead (the SDK's own guidance for clientless revocation) and assert introspection dies - Python delegation test: authenticate the revoke with the confidential client the flow already creates - TS delegation test: the TS SDK's tokens.revoke cannot carry client credentials yet (body is just {token}) — deactivate the subject identity instead; SDK gap noted inline Verified locally against a root-mount server build: Python smoke 8/8, TypeScript smoke 8/8 (testcontainers, SDK 0.3.23). Co-Authored-By: Claude Fable 5 --- .github/workflows/sdk-integration.yml | 4 ++-- tests/sdk/sdk-smoke.test.ts | 14 ++++++++++---- tests/sdk/test_sdk_smoke.py | 16 ++++++++++++---- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/.github/workflows/sdk-integration.yml b/.github/workflows/sdk-integration.yml index 85000dbc..aa5d83c6 100644 --- a/.github/workflows/sdk-integration.yml +++ b/.github/workflows/sdk-integration.yml @@ -58,7 +58,7 @@ jobs: shell: bash run: |- pip install --quiet "testcontainers[postgres]" "PyJWT>=2.8" cryptography pytest - pip install --quiet "highflame==0.3.17" + pip install --quiet "highflame==0.3.23" - name: Run Python SDK smoke tests run: pytest tests/sdk/test_sdk_smoke.py -v --tb=short -x @@ -101,7 +101,7 @@ jobs: run: |- npm init -y npm install --save-dev vitest typescript @testcontainers/postgresql - npm install @highflame/sdk@0.3.17 + npm install @highflame/sdk@0.3.23 - name: Run TypeScript SDK smoke tests working-directory: tests/sdk diff --git a/tests/sdk/sdk-smoke.test.ts b/tests/sdk/sdk-smoke.test.ts index f44e5886..4568910f 100644 --- a/tests/sdk/sdk-smoke.test.ts +++ b/tests/sdk/sdk-smoke.test.ts @@ -232,8 +232,11 @@ describe("Token lifecycle", () => { expect(session.active).toBe(true); expect(session.sub).toBeTruthy(); - // Revoke - await client.tokens.revoke(token.access_token); + // Revoke — RFC 7009 authenticates the *client*, so tokens.revoke needs + // OAuth client credentials this api_key agent doesn't have. Deactivating + // the agent collapses its tokens instead (the SDK's own guidance for + // clientless revocation). + await client.agents.deactivate(reg.identity.id); const revoked = await client.tokens.introspect(token.access_token); expect(revoked.active).toBe(false); } finally { @@ -398,8 +401,11 @@ describe("OAuth client credentials + delegation", () => { expect(introspection.sub).toBe(toolAgent.wimse_uri); expect(introspection.act?.sub).toBe(orchestrator.wimse_uri); - // 8. Revoke → inactive. - await client.tokens.revoke(delegated.access_token); + // 8. Revoke → inactive. RFC 7009 authenticates the client, but the TS + // SDK's tokens.revoke cannot carry client credentials yet (its body is + // just {token}, refused with 401 invalid_client) — so collapse the + // delegated token by deactivating the subject identity instead. + await client.agents.deactivate(toolAgent.id); const after = await client.tokens.introspect(delegated.access_token); expect(after.active).toBe(false); } finally { diff --git a/tests/sdk/test_sdk_smoke.py b/tests/sdk/test_sdk_smoke.py index ab5fd9d6..a22dbc0a 100644 --- a/tests/sdk/test_sdk_smoke.py +++ b/tests/sdk/test_sdk_smoke.py @@ -248,8 +248,11 @@ def test_issue_verify_introspect_revoke(self, client): assert session.active is True assert session.sub - # Revoke - client.tokens.revoke(token.access_token) + # Revoke — RFC 7009 authenticates the *client*, so tokens.revoke + # needs OAuth client credentials this api_key agent doesn't have. + # Deactivating the agent collapses its tokens instead (the SDK's + # own guidance for clientless revocation). + client.agents.deactivate(reg.identity.id) revoked = client.tokens.introspect(token.access_token) assert revoked.active is False finally: @@ -417,8 +420,13 @@ def test_client_credentials_and_delegation(self, client, zeroid_url): assert introspection.sub == tool_agent.wimse_uri assert introspection.act and introspection.act.get("sub") == orchestrator.wimse_uri - # 8. Revoke → inactive. - client.tokens.revoke(delegated.access_token) + # 8. Revoke → inactive. RFC 7009 authenticates the client, and this + # flow has one — the confidential client from step 3. + client.tokens.revoke( + delegated.access_token, + client_id=orch_external_id, + client_secret=created.client_secret, + ) assert client.tokens.introspect(delegated.access_token).active is False finally: if oauth_client_id: