Architecture¶
Components¶
flowchart LR
subgraph spire[SPIRE]
SS[SPIRE server]
SA[SPIRE agent<br/>Workload API]
end
AG[agent<br/>mcp-svid-agent]
AZ[authz<br/>mcp-svid-authz]
A[notes-a<br/>mcp-svid-notes]
B[notes-b<br/>mcp-svid-notes]
P[(policy.yaml)]
SS --- SA
AG -- FetchJWTSVID --> SA
AZ -- FetchJWTBundles --> SA
AG -- "POST /token (JWT-SVID)" --> AZ
AZ --- P
AG -- "Bearer access token" --> A
AG -- "Bearer access token" --> B
A -- "GET /jwks.json" --> AZ
B -- "GET /jwks.json" --> AZ
| Module | Command | Role |
|---|---|---|
authz |
mcp-svid-authz |
Token endpoint. JWT-SVID client assertion in, audience-bound JWT access token out. Publishes JWKS and RFC 8414 metadata. |
mcp_server |
mcp-svid-notes |
MCP server over Streamable HTTP. Tools notes.search (notes:read) and notes.write (notes:write); with --upstream-resource also notes.search_upstream (notes:read), which calls another MCP server with the server's own token. |
agent_client |
mcp-svid-agent |
Discovers the authorization server, refuses it unless it is on the --trusted-issuer allowlist, fetches its SVID, gets a token, calls tools. Refuses non-https and non-public URLs unless relaxed for dev. --steal-token replays a token against another server. |
stdio_wrapper |
mcp-svid-stdio |
Starts a local stdio MCP server with a refreshed token file. See stdio wrapper. |
policy |
none | Allowlist of SPIFFE ID, resource and scopes. |
audit |
mcp-svid-audit-verify |
One hash-chained JSON line per decision, to a file or stderr, and the chain verifier. |
spiffe_keys |
none | JWT-SVID source and key resolver: Workload API, or a static JWKS for tests. |
deploy/ |
make demo |
SPIRE server and agent 1.15.3, registration entries, the services, eighteen asserted scenarios. |
Upstream calls without token passthrough¶
With --upstream-resource, a notes server gets the tool notes.search_upstream. It runs the same client flow as the agent (discovery, --trusted-issuer, URL checks, JWT-SVID client assertion) under the server's own SPIFFE ID, and caches that token until 30s before exp. The caller's token never leaves the server. The upstream audit line therefore names the relay (spiffe://example.org/mcp/notes-a), not the original caller; carrying the caller along needs a delegation chain (token exchange with an act claim), which is not implemented.
Endpoints¶
| Service | Path | Method | Content |
|---|---|---|---|
| authz | /.well-known/oauth-authorization-server |
GET | issuer, token_endpoint, jwks_uri, grant_types_supported = client_credentials, token_endpoint_auth_methods_supported = spiffe_jwt |
| authz | /token |
POST | Token request, form encoded |
| authz | /jwks.json |
GET | Public ES256 key for access tokens |
| MCP server | /.well-known/oauth-protected-resource<path> |
GET | resource, authorization_servers, scopes_supported, bearer_methods_supported = header |
| MCP server | resource path, e.g. /mcp |
POST | MCP Streamable HTTP, bearer token required |
Token flow¶
sequenceDiagram
participant SA as SPIRE agent<br/>(Workload API)
participant AG as agent/research
participant AZ as authz
participant A as notes-a (MCP)
participant B as notes-b (MCP)
AG->>A: GET /.well-known/oauth-protected-resource/mcp
A-->>AG: resource, authorization_servers
AG->>AZ: GET /.well-known/oauth-authorization-server
AZ-->>AG: issuer, token_endpoint
AG->>SA: FetchJWTSVID(aud = issuer)
SA-->>AG: JWT-SVID (60s demo, max 5 min)
AG->>AZ: POST /token client_credentials<br/>client_assertion = JWT-SVID<br/>resource = notes-a
AZ->>SA: FetchJWTBundles
AZ->>AZ: verify SVID, check policy.yaml
AZ-->>AG: access token (aud = notes-a, 5 min)
AG->>A: tools/call + Bearer token
A->>A: verify sig, iss, exp, aud, tool scope, write audit line
A-->>AG: result
AG-->>B: same token replayed
B-->>AG: 401 invalid_token (aud mismatch)
Steps on the agent side (agent_client.fetch_access_token):
- Before each request, check the URL and bind the default transport to a checked numeric address with original Host and TLS server-name verification:
httpsonly, and every address the host resolves to must be public (not private, loopback, link-local, shared or multicast). A failing URL is refused withurl_refusedand nothing is sent to it. This applies to the resource, the chosen issuer, thetoken_endpointfrom the issuer metadata, and the tool call. IPv6 addresses that embed an IPv4 address (IPv4-mapped, IPv4-compatible, NAT6464:ff9b::/96) are judged by the embedded address, and site-localfec0::/10is refused.--allow-httpand--allow-private-networkrelax the two checks for local container setups; either one emits aurl_policy_relaxedevent on stderr at startup. - Read Protected Resource Metadata. Fail if its
resourcediffers from the requested one. - Take the first entry of
authorization_serversthat is on the--trusted-issuerallowlist. Refuse withissuer_refusedif there is none. Comparison is exact after lowercasing scheme and host, dropping the default port and mapping an empty path to/(RFC 8414 section 2, RFC 9728 section 3). No prefix match;/tand/t/differ. - Read that authorization server's metadata. Fail if its
issuerdiffers. - Fetch a JWT-SVID with
aud= issuer. - Send a
client_credentialsgrant with the SVID as client assertion andresource= the server URI. - Call tools with the access token as a bearer token.
Token request¶
| Parameter | Value |
|---|---|
grant_type |
client_credentials |
client_assertion_type |
urn:ietf:params:oauth:client-assertion-type:jwt-spiffe |
client_assertion |
JWT-SVID, aud = authz issuer only, iat required, exp - iat at most 300s (--max-svid-lifetime) |
client_id |
the SPIFFE ID (optional, must match the SVID sub) |
resource |
canonical URI of the MCP server (required) |
scope |
required, single-space separated, must be a subset of the policy grant |
Repeated parameters are rejected with invalid_request. Error descriptions are fixed strings. Exception class names, not exception text, may appear in the audit log. Responses carry Cache-Control: no-store.
Access token¶
JWT, header typ at+jwt, alg ES256, kid of the authz signing key.
| Claim | Value |
|---|---|
iss |
authz issuer |
sub, client_id |
caller SPIFFE ID |
aud |
the requested resource, trailing slash removed |
scope |
granted scopes, sorted, space separated |
iat, exp |
issue time, issue time + 300s |
jti |
random hex |
The authz signing keys are P-256 keys generated in memory. Planned in-process rotation publishes the next public key before use and retains previous verification keys until their tokens expire. Keys do not survive a restart. See rotation and request budgets.
Audit log¶
Every allow and deny decision in authz and the MCP servers is one JSON line:
{"record_id":"5f0c9a53-2a8e-4d0b-9a57-0f3e2f6d1c44","parent_record_id":"b1d7e0a2-6c1f-4e89-8f0e-2d4c7a9b3e51","prev_hash":"3b9f0c2e8d7a41f6b5e0c9d8a7f6e5d4c3b2a1908f7e6d5c4b3a29180f7e6d5c","timestamp":"2026-09-26T18:07:11.742+00:00","component":"mcp_server:notes-b","spiffe_id":"spiffe://example.org/agent/research","tool":"notes.write","decision":"deny","reason":"insufficient_scope: needs notes:write"}
| Field | Content |
|---|---|
record_id |
random UUIDv4 |
parent_record_id |
record_id of the previous line in the same file, null for the first |
prev_hash |
SHA-256, lowercase hex, of the previous line in RFC 8785 (JCS) form, null for the first |
component |
authz or mcp_server:<name> |
spiffe_id |
caller, prefixed with unverified: when the decision was made before the signature check, null when unknown |
tool |
tool name for tools/call, else null |
decision |
allow, deny, or policy_reload (authz only) |
reason |
Fixed description or exception class name; hashed SVID ID for correlation |
Without --audit-log the lines go to stderr.
Hash chain¶
Each file is one chain, built as in draft-sharif-agent-audit-trail-05 (checked 2026-09-27): prev_hash(N) = hex(SHA-256(JCS(record N-1))), and parent_record_id links to the previous record_id. The field set is this project's own, not the full AAT record. A process that opens an existing file validates the whole chain before continuing it, and refuses every write while the chain is invalid or its last line lacks a trailing newline. One writer per file.
The verifier recomputes every link and reports edited, deleted, reordered or inserted lines and timestamps that go backwards. Exit code 0 means every chain is intact. Without --checkpoint, deleting lines from the end of a file (tail truncation) is not detected, because the links have no anchor for the last record.
The chain is tamper-evident, not tamper-proof: anyone who can write the file can rewrite the whole chain from the edited line on. A terminal checkpoint is implemented; storing it independently is the operator's responsibility. Signatures and managed external storage are not implemented. See Audit integrity.
Human sign-in with ZITADEL¶
ZITADEL is the optional human identity provider for the read-only Headlamp panel. SPIRE identifies workloads; ZITADEL signs in approved people. Human sign-in does not grant MCP tool permissions or delegate the user's authority to an agent.
flowchart TB
H["Approved person"] -->|"Passkey sign-in"| Z["ZITADEL"]
Z -->|"OIDC with PKCE"| G["Panel sign-in gate"]
G -->|"Short-lived local session"| U["Read-only Headlamp"]
U -->|"Gate's read-only credential"| K["Kubernetes state"]
Open the sign-in guide. The synthetic WebAuthn/OIDC checks passed. Real Google sign-in and physical passkey enrollment remain operator gates. Human-to-agent delegation is not implemented.
Optional personal-os integration¶
Proposed boundary, not an implemented integration. personal-os coordinates the task and selects an MCP tool. A workload identity lets its agent obtain a short-lived access token for that tool's MCP server. The server checks audience and scope independently of the planner.
Keep this adapter optional: local files and Beads do not require an OAuth service. ZITADEL authenticates a person; it does not turn their session into an agent credential. Human-to-agent delegation needs a separate bounded contract before an agent can act on a person's authority.
Local runtime ownership¶
The shared security lab runs in a dedicated CNCF Lima VM with k3s Kubernetes and containerd. The VM has 2 CPUs and 4 GiB memory, mounts only the three security repositories read-only, and is started explicitly. SPIRE uses projected-token node attestation and namespace/ServiceAccount workload selectors. The FHIR gateway loads mcp-spiffe-bridge and authenticates the peer over real mTLS before checking its resource-bound OAuth token. mcp-spiffe-bridge is not yet published, so the lab is not reproducible from public sources; the offline quickstart path needs only this repository. See shared lab.
The VM and cluster operator control this boundary. Workloads do not receive a container runtime API socket or Kubernetes API credentials. The legacy Podman compatibility path was retired on 2026-10-13.