Zeroid

data-ai MCP Server

ZeroID: Autonomous Agent Identity Management System (AAIMS)

Verified
data-aidata-ai
5 views146 stars15 forksApache-2.0

Why This Matters

Discovered via github-topic:mcp and last synced 3mo ago.

Verified
Source
github-topic:mcp
Stars
146
Last synced
3mo ago
Install
Check source

Install

Install instructions not detected yet

Check the source repository for the latest setup steps.

View source instructions
29
Tools
0
Resources
0
Prompts
Standard I/O
Transport

Available Tools (29)

refresh_token

No

full-autonomy

`tools:read tools:write tools:execute tools:network tools:agent tools:vcs`

Method

Path

autonomous

tool_agent

client_credentials

No

verified_third_party

first_party created_by="[email protected]", # stored as owner claim in every token ) print(agent.identity.wimse_uri) # spiffe://auth.highflame.ai/acme/prod/agent/orchestrator-1 print(agent.api_key) # zid_sk_... ← save this securely, shown once ``` </details> <details> <summary>TypeScript</summary> ```typescript const agent = await client.agents.register({ name: "Task Orchestrator", external_id: "orchestrator-1", sub_type: "orchestrator", trust_level: "first_party", created_by: "[email protected]", }); // agent.identity.wimse_uri → "spiffe://..." (persistent identity) // agent.api_key → "zid_sk_..." (save securely, shown once) ``` </details> *Under the hood, the SDK exchanges your API key for a short-lived JWT and refreshes it automatically.* ### 2. Delegate to a Sub-Agent When an orchestrator needs a specialized agent to handle part of a task, it delegates a **subset of its own permissions** — it cannot grant more than it has. The sub-agent gets its own token with its own identity, but the full chain of who authorized what is preserved cryptographically. This is the key difference from sharing credentials: the sub-agent has its own registered identity and its own keypair. It proves it holds that keypair by signing a short-lived JWT assertion (`actor_token`). ZeroID verifies both tokens and issues a delegated token that carries both identities. The SDK does not currently ship a JWT-assertion helper, so the snippets below define `generate_ec_keypair` and `build_jwt_assertion` inline. A production-grade Python reference (with the same DER → IEEE P1363 signature conversion required by ES256) lives at [`examples/openclaw/agent-identity-sidecar.py:450`](./examples/openclaw/agent-identity-sidecar.py). <details> <summary>Python</summary> ```python # ── One-time helpers (define once, reuse across delegate calls) ────────── import base64, json, time, uuid from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature ZEROID_ISSUER = "http://localhost:8899" # set to your ZeroID base URL def _b64url(b: bytes) -> str: return base64.urlsafe_b64encode(b).decode().rstrip("=") def generate_ec_keypair() -> tuple[str, str]: """Generate an ES256 (P-256) keypair. Returns (private_pem, public_pem).""" key = ec.generate_private_key(ec.SECP256R1()) private_pem = key.private_bytes( serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption(), ).decode() public_pem = key.public_key().public_bytes( serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo, ).decode() return private_pem, public_pem def build_jwt_assertion(private_pem: str, wimse_uri: str) -> str: """Sign a 5-minute ES256 JWT assertion proving the holder of private_pem.""" key = serialization.load_pem_private_key(private_pem.encode(), password=None) now = int(time.time()) header = _b64url(json.dumps({"alg": "ES256", "typ": "JWT"}).encode()) payload = _b64url(json.dumps({ "iss": wimse_uri, "sub": wimse_uri, "aud": ZEROID_ISSUER, "iat": now, "exp": now + 300, "jti": uuid.uuid4().hex, }).encode()) der = key.sign(f"{header}.{payload}".encode(), ec.ECDSA(hashes.SHA256())) r, s = decode_dss_signature(der) sig = _b64url(r.to_bytes(32, "big") + s.to_bytes(32, "big")) return f"{header}.{payload}.{sig}" # ── Tutorial flow ──────────────────────────────────────────────────────── # Generate the sub-agent's keypair. Register the public PEM with ZeroID; # keep the private PEM in your secret store — it never leaves the agent. sub_agent_private_key, sub_agent_public_key = generate_ec_keypair() sub_agent = client.agents.register( name="Data Fetcher", external_id="data-fetcher", sub_type="tool_agent", trust_level="first_party", public_key_pem=sub_agent_public_key, # required for token_exchange ) # The sub-agent proves it holds its private key by signing a JWT assertion. actor_token = build_jwt_assertion(sub_agent_private_key, sub_agent.identity.wimse_uri) # Delegate data:read to the sub-agent. # ZeroID enforces scope intersection — the sub-agent can only receive scopes # the orchestrator already holds. delegated = client.tokens.delegate( actor_token=actor_token, scope="data:read", ) # The delegated token carries the full chain: # sub: spiffe://.../agent/data-fetcher ← who is acting # owner: [email protected] ← who provisioned this agent # act.sub: spiffe://.../agent/orchestrator-1 ← which agent delegated (RFC 8693) # scope: data:read ← capped by intersection # delegation_depth: 1 ``` </details> <details> <summary>TypeScript</summary> ```typescript import { ZeroIDClient } from "@highflame/sdk"; import { SignJWT, generateKeyPair, exportJWK, importJWK } from "jose"; import { randomUUID, createPublicKey } from "node:crypto"; const ZEROID_ISSUER = "http://localhost:8899"; // set to your ZeroID base URL // ── One-time helpers ──────────────────────────────────────────────────── async function generateEcKeypair(): Promise<{ privateJwk: object; publicPem: string }> { const { privateKey, publicKey } = await generateKeyPair("ES256", { extractable: true }); const privateJwk = await exportJWK(privateKey); const publicJwk = await exportJWK(publicKey); const publicPem = createPublicKey({ key: publicJwk as never, format: "jwk" }).export({ type: "spki", format: "pem", }) as string; return { privateJwk, publicPem }; } async function buildJwtAssertion(privateJwk: object, wimseUri: string): Promise<string> { const key = await importJWK(privateJwk as never, "ES256"); return new SignJWT({}) .setProtectedHeader({ alg: "ES256", typ: "JWT" }) .setIssuer(wimseUri) .setSubject(wimseUri) .setAudience(ZEROID_ISSUER) .setIssuedAt() .setExpirationTime("5m") .setJti(randomUUID()) .sign(key); } // ── Tutorial flow ────────────────────────────────────────────────────── const { privateJwk: subAgentPrivateKey, publicPem: subAgentPublicKey } = await generateEcKeypair(); const subAgent = await client.agents.register({ name: "Data Fetcher", external_id: "data-fetcher", sub_type: "tool_agent", trust_level: "first_party", public_key_pem: subAgentPublicKey, // required for token_exchange }); const actorToken = await buildJwtAssertion(subAgentPrivateKey, subAgent.identity.wimse_uri); const delegated = await client.tokens.delegate({ actor_token: actorToken, scope: "data:read", }); // delegated.access_token carries sub=data-fetcher, act.sub=orchestrator-1, // scope=data:read, delegation_depth=1 ``` </details> ### 3. Verify — Confirm the Token and Read Its Identity There are two paths depending on your latency and revocation requirements: **`session()` — network path with typed helpers (recommended for most cases).** Calls `POST /oauth2/token/introspect` on every request and wraps the result in an `AgentSession` with `require_scope()`, `require_trust()`, `is_delegated()`, and `delegated_by()` helpers. Use `session_from_request()` to extract the Bearer token from request headers automatically. **`verify()` — local path (preferred for high-throughput services).** Validates the JWT signature against the cached JWKS (fetched once, cached 5 minutes). No network call on the hot path. Returns a typed `ZeroIDIdentity` with the same helper interface. <details> <summary>Python</summary> ```python # From a token string, or directly from request headers session = client.tokens.session(delegated.access_token) session = client.tokens.session_from_request(request.headers) # extracts Bearer automatically session.require_scope("data:read") # raises ZeroIDError if scope missing session.require_trust("verified_third_party") # raises ZeroIDError if trust too low print(session.sub) # spiffe://auth.highflame.ai/acme/prod/agent/data-fetcher print(session.delegation_depth) # 1 # Async session = await client.tokens.asession_from_request(request.headers) ``` </details> <details> <summary>TypeScript</summary> ```typescript // From a token string, or from request headers (extracts Bearer automatically) const session = await client.tokens.sessionFromRequest(request.headers); session.requireScope("data:read"); // throws ZeroIDError if scope missing session.requireTrust("verified_third_party"); // throws ZeroIDError if trust too low // session.sub → "spiffe://..." // session.act?.sub → orchestrator's WIMSE URI ``` </details> For high-throughput services where you want no network call on the hot path, use `verify()` / `verifyBearer()` instead — it validates the JWT signature locally against the cached JWKS and returns a `ZeroIDIdentity` with the same helper interface. Note that local verification does not check real-time revocation. ### 4. Revoke Revocation is immediate and cascades. Revoke any token in the chain and everything downstream of it becomes invalid — no need to wait for expiry. <details> <summary>Python</summary> ```python # Revoke a specific delegated token client.tokens.revoke(delegated.access_token) # → delegated token now returns active: false on introspect # Revoke the orchestrator's token and the entire downstream chain collapses. # This is how you respond to a compromise: one call, full containment. client.tokens.revoke(orchestrator_token) ``` </details> Full interactive API docs: `GET http://localhost:8899/docs` --- ## Real-World Patterns ### Pattern 1: High-velocity autonomous agent with policy-based controls **Scenario:** A marketing optimization agent receives a single instruction — *"Reallocate budget to maximize click-through rate"* — and immediately begins making hundreds of API calls: pausing underperforming campaigns, adjusting bids, transferring budget across ad groups. It operates at machine speed, completing in seconds what would take a human hours. **The problem without ZeroID:** The agent uses a shared service account with broad CRM and ad-platform access. There is no record of which agent took which action, no limit on what it can touch, and no way to stop it if it starts behaving unexpectedly — short of revoking the shared account and breaking every other service using it. **With ZeroID:** Define the agent's exact operational envelope once at registration time using a `CredentialPolicy`. The agent can only obtain tokens for the scopes the policy allows. It cannot delegate further. Every action carries its identity in the token `sub`, and `owner` traces it back to the team that provisioned it. ```python # Operations team defines the envelope once — before the agent runs policy = client.credential_policies.create( name="budget-optimizer-policy", allowed_scopes=["campaigns:read", "campaigns:write", "budget:reallocate"], max_ttl_seconds=3600, # tokens expire hourly — no long-lived access required_trust_level="first_party", max_delegation_depth=0, # this agent cannot spawn sub-agents ) agent = client.agents.register( name="Budget Optimizer", external_id="budget-optimizer-v1", sub_type="autonomous", trust_level="first_party", created_by="[email protected]", # owner claim in every token ) # Agent runs autonomously. Per-action approvals are replaced by the policy envelope. # If the agent tries to request billing:write or customer:delete — ZeroID rejects the token request. # No runtime intervention needed; the policy is the control. token = client.tokens.issue_api_key( agent.api_key, scope="campaigns:read campaigns:write budget:reallocate", ) ``` **If something goes wrong:** One call revokes the agent's token. Every downstream API using that token immediately sees `active: false` on introspection. The shared service account is untouched — other services keep running. --- ### Pattern 2: Human authorizes once, agent runs autonomously **Scenario:** A developer connects their coding agent to their GitHub account. From that point on, the agent opens PRs, reviews diffs, and pushes commits — entirely on its own, without the developer re-authorizing each action. But when something goes wrong in production, the security team needs to know: *who is responsible for this commit?* **The problem without ZeroID:** The agent authenticates as the developer (using their OAuth token or SSH key). There is no way to distinguish agent-authored commits from human-authored ones. If the agent is compromised, you revoke the developer's access — which also locks them out. **With ZeroID:** The agent has its own identity. The developer's identity is captured in `owner` at registration and appears in every token the agent issues — but the agent authenticates as itself, not as the developer. The audit trail is unambiguous. ```python # Developer registers their coding agent once — this is the authorization event agent = client.agents.register( name="Code Agent", external_id="code-agent-alice", sub_type="code_agent", trust_level="first_party", created_by="[email protected]", # becomes owner in every token ) # From here the agent runs autonomously — Alice isn't involved in any individual commit or push. # The agent client auto-manages its own token; no explicit issue() call needed. agent_client = ZeroIDClient(base_url="...", api_key=agent.api_key, scope="repo:read repo:write") # Any downstream system (GitHub, CI pipeline, audit log) that receives the agent's requests # can verify the token and answer: "Which agent did this, and who is responsible?" session = downstream_client.tokens.session_from_request(incoming_request.headers) # session.sub → "spiffe://.../agent/code-agent-alice" ← the agent acted # session.owner_user_id → "[email protected]" ← alice provisioned it ``` **If Alice leaves the company:** Deactivate her agent. Its credential is revoked. The shared GitHub OAuth token Alice used before is unaffected — but the agent's identity is cleanly terminated. ```python client.agents.deactivate(agent.identity.id) # All tokens issued to code-agent-alice immediately return active: false ``` --- ### Pattern 3: Orchestrator delegates to a sub-agent chain **Scenario:** A security operations agent detects an anomaly in network traffic. It cannot remediate on its own — remediation requires a separate, more privileged agent with write access to firewall rules. The orchestrator needs to hand off the investigation and response while maintaining a complete audit trail of who authorized what at each step. **The problem without ZeroID:** The orchestrator passes its own credentials to the sub-agent, or the sub-agent has its own broad credentials. Either way, there is no record of the delegation. If the remediation agent makes a mistake, the audit trail stops at "the remediation agent did this" with no connection to the orchestrator that authorized it or the policy that invoked the chain. **With ZeroID:** Each agent in the chain has its own registered identity. The orchestrator delegates an explicit, attenuated subset of its permissions to the investigator via RFC 8693 token exchange. The investigator does the same for the remediator. Scope cannot expand at any hop. Delegation depth is enforced by policy. The full chain is cryptographically embedded in every token. ```python # Policy caps the chain — no agent beyond depth 2 can act policy = client.credential_policies.create( name="sec-ops-policy", max_delegation_depth=2, allowed_scopes=["alerts:read", "logs:read", "logs:query", "firewall:write"], ) # Three agents registered with separate identities monitor = client.agents.register(name="Security Monitor", external_id="sec-monitor", sub_type="orchestrator", trust_level="first_party", created_by="[email protected]") investigator = client.agents.register(name="Log Investigator", external_id="log-investigator", sub_type="autonomous", trust_level="first_party", created_by="[email protected]") remediator = client.agents.register(name="Firewall Agent", external_id="fw-remediator", sub_type="tool_agent", trust_level="first_party", created_by="[email protected]") # Each agent runs with its own client, initialized with its own api_key. # delegate() uses the client's internally managed token as the subject. # `build_jwt_assertion` and `generate_ec_keypair` are defined in §2 above. monitor_client = ZeroIDClient(base_url="...", api_key=monitor.api_key) investigator_client = ZeroIDClient(base_url="...", api_key=investigator.api_key) # Each sub-agent has its own keypair; the public PEM was registered when the # identity was created (omitted above for brevity — pass public_key_pem=...). investigator_private_key, _ = generate_ec_keypair() remediator_private_key, _ = generate_ec_keypair() # Monitor detects anomaly → delegates log investigation (depth 1) # Scope is attenuated — investigator gets read access only, not firewall:write investigator_token = monitor_client.tokens.delegate( actor_token=build_jwt_assertion(investigator_private_key, investigator.identity.wimse_uri), scope="logs:read logs:query", ) # investigator_token: sub=log-investigator, act.sub=sec-monitor, depth=1 # Investigator confirms breach → delegates remediation (depth 2, at the cap) remediator_token = investigator_client.tokens.delegate( actor_token=build_jwt_assertion(remediator_private_key, remediator.identity.wimse_uri), scope="firewall:write", ) # remediator_token: sub=fw-remediator, act.sub=log-investigator, depth=2 # Incident resolved. Deactivate the monitor — the entire downstream chain is invalidated. client.agents.deactivate(monitor.identity.id) ``` --- ### Pattern 4: User delegates a task to an agent at runtime **Scenario:** An enterprise user asks their AI assistant to *"book travel for my upcoming conference."* The assistant needs to query the user's calendar, check company travel policy, book flights, and submit an expense pre-approval — all on the user's behalf. The downstream travel and HR systems need to know this action came from Alice, not from a generic agent. **Without ZeroID:** The agent logs into travel and HR systems as Alice (using her OAuth token). There's no distinction between Alice booking travel herself and the agent doing it on her behalf. Alice can't revoke just the agent's access — she'd have to revoke her own session. **With ZeroID:** Alice authenticates via the authorization_code flow and delegates to her assistant. The agent's token carries its own identity in `sub`, Alice's identity in `act.sub`, and the agent's owner (ops team) in `owner`. Downstream systems can see the full picture. Alice can revoke the agent's delegated token without affecting her own session. ```python # Alice authenticates via authorization_code + PKCE # Her user token is exchanged so the assistant can act on her behalf # `build_jwt_assertion` and `generate_ec_keypair` are defined in §2 above. # The public PEM was registered when the identity was created (pass public_key_pem=...). assistant_private_key, _ = generate_ec_keypair() assistant_token = client.tokens.issue_token_exchange( subject_token=alice_user_token, # alice's authorization_code-issued token actor_token=build_jwt_assertion(assistant_private_key, assistant.identity.wimse_uri), scope="calendar:read travel:book expenses:submit", ) # assistant_token: sub=travel-assistant, [email protected], depth=1 # Travel and HR systems see act.sub → book in Alice's name, charge her cost center # Alice decides she wants to handle this herself — revoke just the agent's token. # Her own session is untouched. client.tokens.revoke(assistant_token.access_token) ``` --- ### Pattern 5: MCP server enforcing identity at the tool boundary **Scenario:** An MCP server exposes tools that can read files, execute queries, and write to databases. It needs to verify that any agent calling it has a legitimate identity, appropriate permissions, and hasn't exceeded its authorized delegation depth — before any tool executes. **The problem without ZeroID:** MCP servers today typically accept any bearer token and trust the caller. There's no standard way to verify who the agent is, who authorized it, or whether it's still authorized (a token issued an hour ago may have been revoked since). **With ZeroID:** The MCP server calls `session_from_request()` before any tool executes. The returned `AgentSession` carries the full identity context with typed helpers; the server enforces its own access policy based on trust level, delegation depth, scope, and allowed tools, without implementing any identity logic of its own. ```python from highflame.zeroid import ZeroIDClient client = ZeroIDClient(base_url="https://auth.highflame.ai", api_key="zid_sk_...") def handle_query_database(request_headers: dict, query: str) -> dict: session = client.tokens.session_from_request(request_headers) session.require_scope("database:read") # raises ZeroIDError if scope missing or token revoked log_audit(agent=session.sub, delegated_by=session.delegated_by()) return execute_query(query) ``` **Why this matters:** The MCP server doesn't implement any identity logic of its own — it delegates all trust decisions to ZeroID. `session_from_request()` checks real-time revocation on every call. For high-throughput services where a network call per request is too expensive, swap it for `verify_bearer()` — it validates the JWT signature locally (no network call after the initial JWKS fetch) and returns a `ZeroIDIdentity` with the same `has_scope()`, `is_delegated()`, and `delegated_by()` interface. --- ### Pattern 6: Agent pauses for out-of-band user approval (CIBA) **Scenario:** An autonomous agent needs to perform a high-trust action — read a user's email, transfer money, deploy to production — and must obtain real-time consent from the human owner before proceeding. The agent and the human are not in the same session; the human may not even be online when the agent starts. **The problem without ZeroID:** There is no standard way for a backend agent to request "ask the user" without standing up a custom approval queue, push-notification pipeline, and reconciliation logic. Most teams hand-roll this and end up with no audit trail. **With ZeroID:** OpenID CIBA Core 1.0 is the standard for this. The agent posts to `/oauth2/bc-authorize` with a `binding_message` describing the action. The deployer's `BackchannelNotifier` delivers the prompt out-of-band (email, Slack, mobile push). When the user approves, the agent retrieves a scoped, audit-trailed token via poll, ping callback, or push delivery. ```bash # 1. Agent initiates the request — supplies the user identifier (login_hint), # requested scope, and a human-readable binding_message the user will see # in the approval prompt. curl -s -X POST https://auth.highflame.ai/oauth2/bc-authorize \ -d 'client_id=alice-agent' \ -d '[email protected]' \ -d 'scope=gmail:read' \ -d 'binding_message=alice-agent wants to read your unread Gmail' # → {"auth_req_id":"…","expires_in":300,"interval":5} # 2. Deployer's BackchannelNotifier (configured via Server.SetBackchannelNotifier) # pushes an approval prompt to Alice — typically email or Slack with # one-click Approve / Deny buttons. # 3. Agent polls /oauth2/token until the user resolves the request. Poll # returns one of: authorization_pending, slow_down, access_denied, # expired_token, or — on approval — the access token. curl -s -X POST https://auth.highflame.ai/oauth2/token \ -d 'grant_type=urn:openid:params:grant-type:ciba' \ -d 'auth_req_id=<auth_req_id>' \ -d 'client_id=alice-agent' # After Alice approves: # { # "access_token": "...", ← sub = [email protected], backchannel_client_id = alice-agent # "token_type": "Bearer", # "expires_in": 900 # } ``` **Ping mode** delivers a callback to the client's registered `client_notification_endpoint` the moment the user resolves — agents that don't want to poll can wait for the ping and then call `/oauth2/token` once. **Push mode** delivers the full access token to the callback directly. Both modes require `backchannel_token_delivery_mode` set on the client at registration; the callback endpoint is SSRF-guarded. **Why this matters:** Every CIBA approval produces a token whose `sub` is the approving user and whose `backchannel_client_id` claim identifies the agent that asked. Downstream systems get a real, attributable consent trail — "alice-agent acted with Alice's explicit approval at 14:32, with binding message X" — without you building approval infrastructure. --- ### Pattern 7: Sender-constrained tokens for high-risk agents (DPoP) **Scenario:** A finance-team agent issues high-value payment instructions. The bearer access token it carries is, by default, a portable credential — anyone who steals it gets full impersonation until expiry or revocation. For a 1-hour TTL on a budget-transferring NHI, that's an unacceptable blast radius. **The problem without ZeroID:** Mitigations like network-bound MTLS, IP allowlists, or short token TTLs are coarse and operationally painful. They either constrain where the agent can run (defeating workload portability) or add a refresh storm that hits the token endpoint every few minutes. **With ZeroID:** RFC 9449 DPoP — Demonstrating Proof of Possession. The agent generates an asymmetric key in its own process memory, signs a fresh DPoP proof for each request to `/oauth2/token`, and ZeroID binds the issued token to that key by embedding `cnf.jkt` (the JWK thumbprint) inside the JWT. The same proof mechanism is replayed at every resource-server call: a stolen access token is useless without the private key that signed the proof. ```bash # 1. Agent (or its SDK) generates an ephemeral ES256 keypair and signs a DPoP proof # over { typ=dpop+jwt, htm=POST, htu=https://auth.example/oauth2/token, iat, jti }. # 2. Request a token with the proof in the DPoP header. curl -s -X POST https://auth.example/oauth2/token \ -H "DPoP: <proof-jwt>" \ -d 'grant_type=client_credentials' \ -d 'client_id=finance-bot' \ -d 'client_secret=...' \ -d 'account_id=acme' -d 'project_id=prod' \ -d 'scope=payments:write' # → # { # "access_token": "...", ← carries cnf.jkt = thumbprint of agent's key # "token_type": "DPoP", ← signals to the agent that proofs are required downstream # "expires_in": 3600 # } # 3. Calling a resource server: include the access token + a *new* DPoP proof # whose ath claim hashes the access token and whose htu/htm match this call. curl -s -X POST https://payments.example/transfer \ -H "Authorization: DPoP <access_token>" \ -H "DPoP: <new-proof-jwt-with-ath>" \ -d '...' # 4. The resource server validates the per-request proof against the cnf.jkt # it pulls from ZeroID's introspection response. Stolen token without the # key → invalid_dpop_proof, instantly. ``` **Why this matters:** Every grant type ZeroID issues — `client_credentials`, `jwt_bearer`, `token_exchange`, `api_key`, `authorization_code`, `refresh_token`, even CIBA — produces a DPoP-bound token when the caller attaches a proof. The agent's key never leaves its process; ZeroID never stores it; rotation is a no-op (next request, new key, new cnf). For agents whose tokens cross orchestrator → sub-agent → tool-agent hops, the binding survives the entire `token_exchange` chain because the new proof's key gets bound at each step. Replay defence is atomic: each proof's `jti` is INSERT-or-fail on a primary-key column. No pre-check race window — the second request collapses to `invalid_dpop_proof` at the database level. Full reference: [`docs/dpop-and-dcr.md`](docs/dpop-and-dcr.md). --- ### Pattern 8: Self-service agent registration (Dynamic Client Registration) **Scenario:** An MCP server, an SDK, or a per-deployment AI agent needs an OAuth client to talk to ZeroID — but you can't ship the platform's admin API surface to every tenant who installs your tool. Hand-rolling a sign-up flow adds an operator burden and a security hole the moment the form is exposed. **The problem without ZeroID:** Most OAuth servers force you to provision every client through their admin console, which makes any "install this tool and it works" experience impossible. The teams that try to automate it usually expose their internal admin API to the public internet behind a thin shim, then patch CVEs in that shim for the next decade. **With ZeroID:** RFC 7591 dynamic client registration with RFC 7592 management — gated by an initial access token (an ordinary ZeroID-issued JWT with the reserved `client:register` scope). The platform mints initial access tokens to authorised registrants (your installer, your CLI's first-run bootstrap, your MCP server's onboarding flow); each one is single-issuer / single-audience / scope-restricted, so the surface is the same shape as any other OAuth call. ```bash # 1. Platform mints an initial access token (ordinary client_credentials grant # against a confidential client whose allowed_scopes includes client:register). IAT=$(curl -s -X POST https://auth.example/oauth2/token \ -d 'grant_type=client_credentials' \ -d "client_id=$BOOTSTRAP_CLIENT" -d "client_secret=$BOOTSTRAP_SECRET" \ -d 'account_id=acme' -d 'project_id=prod' \ -d 'scope=client:register'

Scope

Covers

ES256

1 hour

PATCH

`/api/v1/credential-policies/{id}`

Standard

RFC / Spec

Partial

✅

Persona

Scopes

RS256

15 min

Flow

Algorithm

api_key

No

read-only-reviewer

`tools:read`

code-editor

`tools:read tools:write tools:vcs`

test-runner

`tools:read tools:execute`

Algorithm

Default TTL

POST

`/oauth2/bc-authorize/{auth_req_id}/deny`

GET

`/api/v1/delegations/chains`

DELETE

`/api/v1/attestation-policies/{id}`

PUT

`/api/v1/attestation-policies`

PKCE

RFC 7636

CAEP

OpenID CAEP

No

Sub-agent delegates further to a tool agent (depth 2), and so on. `delegation_depth` increments at each hop. `CredentialPolicy.max_delegation_depth` caps how far the chain can go. The full `act` claim chain is preserved at every level.

CIBA

OpenID CIBA Core 1.0

Subject

User Claims

DPoP

RFC 9449