Identity broker flows#
Every session acts under a short-lived, signed identity. The identity broker (maqpna-identity) mints it, publishes the public keys the gateway verifies it with (the broker's JWKS), and derives narrower tokens from it: downscoped tokens for other audiences, and delegation tokens when one agent acts for another over agent-to-agent (A2A) calls. No agent ever holds a long-lived credential: tokens last 15 minutes by default, at most 1 hour, and never longer than the session.
This page walks through the five identity flows in the code (cmd/maqpna-identity, pkg/identity): issuance, warm-pool bootstrap, exchange, delegation and key rotation.
The session token#
A session token is a compact JWS signed with Ed25519 (alg: EdDSA; any other algorithm is refused). Its kid is the RFC 7638 thumbprint of the signing key. A decoded payload (values illustrative):
{
"iss": "maqpna-identity",
"sub": "spiffe://maqpna.local/ns/team-a/agent/coder/session/fix-4821",
"aud": "maqpna-gateway",
"iat": 1790000000,
"nbf": 1790000000,
"exp": 1790000900,
"jti": "9b1f0c2e7a4d4c1f8e3b2a6d5c4f3e21",
"act": { "sub": "user:alice@acme.eu" },
"scope": "tools:github models:team-a.coder",
"maqpna_ns": "team-a",
"maqpna_agent": "coder",
"maqpna_session": "fix-4821",
"maqpna_tier": "tier-1-microvm",
"maqpna_groups": "eng oncall",
"maqpna_principal_verified": "requester"
}
| Claim | Meaning |
|---|---|
sub |
The session's SPIFFE-style ID, spiffe://<trust-domain>/ns/<ns>/agent/<agent>/session/<session>. The verifier checks it matches maqpna_ns, maqpna_agent and maqpna_session |
act.sub |
The person the agent acts for (RFC 8693 actor claim), user:<name> |
scope |
Space-separated scopes: tools:<server>, models:<route>, agents:<name> |
maqpna_groups |
The user's verified groups (at most 32) |
maqpna_principal_verified |
How the user was verified: requester, trustedCreator or idToken (absent means unverified) |
maqpna_parent_jti, maqpna_delegator_jti |
Set on exchanged and delegated tokens |
maqpna_parent_session, maqpna_fork_root, maqpna_inherited_taints |
Set on forked sessions |
Flow 1: token issuance#
sequenceDiagram
autonumber
participant Op as Operator
participant IB as Identity broker
participant K as Kubernetes API
participant SB as Sandbox pod
participant GW as Gateway
Op->>IB: POST /v1/token (Bearer MAQPNA_BROKER_TOKEN)<br/>namespace, agent, session, user, scopes, tier, ttlSeconds, groups, principalVerified
IB->>IB: TTL = ttlSeconds or -default-ttl (15m), capped at -max-ttl (1h)
IB-->>Op: token, expiresAt, spiffeId
Op->>K: Secret session-maqpna-token (owner-referenced)
K-->>SB: projected volume /var/run/maqpna/token (read-only)
SB->>GW: tool call, Authorization Bearer token
GW->>IB: GET /.well-known/jwks.json (cached, refreshed on unknown kid)
GW->>GW: verify EdDSA signature, iss, aud, exp, nbf, scope
Note over Op,IB: At 80% of the token lifetime the operator re-mints and updates the Secret
- The operator has already checked that the session's scopes are a subset of the agent's, clamped the session TTL and resolved the user's principal (see session lifecycle).
- It calls
POST /v1/tokenwith the broker's admin bearer token (MAQPNA_BROKER_TOKEN; the broker refuses to start without one). The requested TTL ismin(--token-ttl (15m), remaining session lifetime). - The broker builds the SPIFFE subject from its
-trust-domain(defaultmaqpna.local), setsact.sub = user:<user>, signs with its Ed25519 key and returns{token, expiresAt, spiffeId}. - The operator stores the token in the Secret
<session>-maqpna-token, which the pod mounts read-only at/var/run/maqpna/token(MAQPNA_TOKEN_FILE). The SDKs re-read the file, so a refreshed token takes effect without a restart. - The operator requeues the session at 80% of the token lifetime and mints a new token, until the session ends.
- The gateway verifies each call against the broker's JWKS. A token without
kidverifies only when exactly one key is trusted; a transaction token (typ: txntoken+jwt) is never accepted as an identity token.
Sessions on attested tiers never take this path: the attestation service mints the token only after the confidential VM proves itself. See attestation-gated secrets.
Flow 2: warm-pool bootstrap (late binding)#
Warm pools hold pre-started sandboxes. agent-sandbox only adopts a warm pod for a claim that carries no environment, so the session's identity reaches the pod after adoption, from the broker's separate bootstrap port (Helm: 8083, the only broker port agent namespaces may reach).
sequenceDiagram
autonumber
participant Op as Operator
participant AS as agent-sandbox
participant Pod as Warm pod (SDK or attest agent)
participant IB as Identity broker :8083
participant K as Kubernetes API
Pod->>IB: POST /v1/bootstrap?wait=20<br/>Bearer projected SA token (aud maqpna-bootstrap)
Op->>K: SandboxClaim (no env)<br/>AgentSession status.bootstrap.env
AS->>K: hand the warm Sandbox to the claim<br/>label pod agents.x-k8s.io/claim-uid
IB->>K: TokenReview, GET pod, follow owners<br/>pod to Sandbox to SandboxClaim to AgentSession
IB->>K: patch status.bootstrap.pod, podUID, bootstrappedAt
IB-->>Pod: namespace, session, agent, env, token, expiresAt, spiffeId
Note over Pod,IB: The bound pod repeats the call to refresh its token
- The pod starts with a projected ServiceAccount token (audience
maqpna-bootstrap, at most 1 hour) andautomountServiceAccountToken: false. It long-polls/v1/bootstrap?wait=20. - The broker runs a TokenReview and reads the bound pod's name and UID from it, then checks the pod (UID, ServiceAccount, not terminating).
- While the Sandbox still belongs to its warm pool, the broker waits for adoption (
425 not_adoptedif the wait runs out). - It follows controller owner references with matching UIDs: pod, Sandbox, SandboxClaim, AgentSession.
- The session must be live (not terminal, suspended, revoked or expired:
410 session_gone,409 session_suspended,session_revoked) and prepared by the operator (425 not_prepared). - The first pod is bound with an optimistic-lock status patch; another live pod gets
409 already_bootstrapped. - The broker answers with
status.bootstrap.envand a freshly minted token (TTLmin(-default-ttl, session expiry)). Attested sessions get no token here, only their release ID and nonce, which the attestation agent redeems.
No session token is ever stored in the claim, the pod spec or the session status.
Flow 3: token exchange (RFC 8693)#
A caller can exchange its token for a shorter-lived, narrower token for another audience. The gateway uses this for MCP servers with auth.type: tokenExchange, so the upstream gets a token for itself that still names the user.
- The caller sends
POST /v1/exchange(form or JSON) withgrant_type=urn:ietf:params:oauth:grant-type:token-exchange, the MAQPNAsubject_token, anaudienceand optionallyscopeandrequested_ttl_seconds. The subject token authenticates the call. - The audience must be listed in
-exchange-config, which sets itsmaxTTLSeconds(default 300),allowedScopesandprovider. - Requested scopes must be provably narrower than granted ones (a literal, or a literal prefix of a
<prefix>*grant). - A token that was already exchanged (it carries
maqpna_parent_jti) cannot be exchanged again. - The new token expires no later than its parent and links back with
maqpna_parent_jti. Revoking the parent'sjtialso revokes it. - The response follows RFC 8693:
access_token,issued_token_type,token_type,expires_in,scope. Errors use RFC 6749 codes (invalid_target,invalid_scope,invalid_grant, ...).
The default NoopExchanger returns the downscoped MAQPNA token. Exchangers for cloud providers (AWS STS, GCP STS, Azure) are an extension point described in pkg/identity/exchange.go; none is built in (Planned).
Flow 4: delegation across agents#
When agent A calls agent B over A2A, B may need to act for A (and so for A's user) when it calls its own tools.
sequenceDiagram
autonumber
participant A as Agent A (planner)
participant GW as Gateway
participant B as Agent B (coder)
participant IB as Identity broker
A->>GW: POST /a2a/team-a/coder (A's token, scope agents:coder)
GW->>GW: govern the call, mint Txn-Token (tctx.target team-a/coder)
GW->>B: forward with X-Maqpna-* headers and Txn-Token
B->>IB: POST /v1/exchange<br/>subject_token = Txn-Token, actor_token = B's session token
IB->>IB: checks: Txn-Token signer and audience, target equals B,<br/>actor is a session token, depth at most 5, no loop, same tenant
IB-->>B: token for B with act = {sub: A's SPIFFE ID, act: {sub: user:alice}}
B->>GW: tool call with the delegated token
GW->>GW: policy principals match alice (Claims.User walks the chain)<br/>audit ext.actChain
- The gateway mints a transaction token (
Txn-Tokenheader,typ: txntoken+jwt, 60 seconds by default, at most 300) for each governed call to a hosted A2A callee whentxnTokens.modeisaddorreplace. - B exchanges the Txn-Token (or A's own token, if it grants
agents:<B>) assubject_token, with its own session token asactor_token. - The broker accepts Txn-Tokens only when signed by its own keys or
-txn-token-keys, with audience-txn-token-audience(default the trust domain) andtctx.targetequal to B. - It refuses an actor token that is itself exchanged or delegated, a chain deeper than
-max-delegation-depth(default 5), self-delegation and tenant crossings. - The result keeps B as
sub, nests the chain inact(outermost is the immediate delegator), narrows scopes to B's, expires no later than either token, copies the user's groups and verification, and recordsmaqpna_delegator_jti. - Revoking any agent of the chain, or the delegator's
jti, revokes the delegated token at the gateway.
Flow 5: workload certificates (SVID binding)#
With SPIRE installed and the operator flag --spire-workload-registration, sandbox pods are labelled maqpna.com/spiffe=enabled and annotated maqpna.com/spiffe-id with the session's SPIFFE ID. A ClusterSPIFFEID makes SPIRE issue an X.509-SVID equal to the token subject. With tls.svidMode optional or required, the gateway requires the client certificate's SPIFFE ID to equal the token's sub (svid_mismatch, svid_required, svid_invalid, svid_trust_domain). A stolen token is then useless without the workload's private key. Warm-pool claim pods are not registered.
Tenant keys#
In a multi-tenant installation each Tenant has its own signing key (Secret maqpna-tenant-keys, <tenant>.pem) and trust domain. The broker signs tokens for a tenant's namespaces with that key and publishes every tenant key in its JWKS. A tenant whose key is missing or does not match the published keyId gets 503 (fail closed). The gateway refuses a token signed with a tenant key for any other namespace (tenant_mismatch). See tenants.
Key rotation#
The broker publishes its current key plus every previous-*.pem beside it in the JWKS, so tokens signed with the old key keep verifying during a rotation.
maqpna keys rotate identitygenerates a new key and keeps the old one as a previous key.- It waits until gateways have fetched the new JWKS (
--jwks-wait, default 5m30s) or restarts them (--restart-gateway). - After
--grace(default 1h, longer than any token),--finishdrops the old key.
Progress is recorded in the Secret annotation maqpna.com/key-rotation, so an interrupted rotation can be resumed.
What you see#
Inspect a token without verifying it, or verify it against a JWKS, with maqpna token inspect and maqpna token verify. The rotation plan prints with --dry-run:
maqpna keys rotate identity [-n NS] [--grace 1h] [--jwks-wait 5m30s | --restart-gateway] [--no-finish | --finish [--force]]
That synopsis is the command's usage line from cmd/maqpna/keys.go. See maqpna token inspect, maqpna token verify and maqpna keys rotate.
Failure modes#
| Failure | Effect |
|---|---|
| Broker unreachable when the operator mints | MintFailed event; the session stays Provisioning and is retried. Running sessions keep their current token until it expires |
| Broker unreachable at bootstrap | Warm pods keep long-polling; the session stays Provisioning |
| Gateway cannot fetch the JWKS | Tokens with known keys keep verifying; /readyz reports "no identity verification keys" when none is known |
| Tenant key missing | 503 for that tenant's tokens; other tenants are unaffected |
| Exchange or delegation refused | RFC 6749 error; nothing is minted |