MAQPNADocs

Identity broker API

Every endpoint of the MAQPNA identity broker - token mint, verify, DPoP bind, proof nonces, RFC 8693 exchange and A2A delegation, JWKS, OIDC discovery, warm-pool bootstrap and probes - plus the session-token claims.

The identity broker (maqpna-identity) issues each session a short-lived, signed identity: a session token. This page is the reference for its HTTP API and for the claims inside the tokens it issues.

You rarely call the broker yourself. The operator mints tokens when it starts a session, the gateway fetches the broker's public keys (JWKS), and SDKs read the token from the file the operator mounts. You need this page when you debug a token, mint one by hand for a test, exchange a token for a cloud credential, implement A2A delegation without an SDK, or write a verifier. For the concepts, read Identity and delegation; for the design, Identity broker.

Source: cmd/maqpna-identity (broker.go, delegation.go, dpop.go, checks.go, bootstrap.go) and pkg/identity.

Endpoints at a glance#

Method Path Port Auth Purpose
POST /v1/token API (:8081) Broker token Mint a session token
POST /v1/token/verify API Broker token Verify a token and return its claims
POST /v1/token/bind API The session token + DPoP proof Bind a token to a DPoP key
POST /v1/proof/nonce API Broker token Single-use nonce for an identity-check proof
POST /v1/exchange API The subject token RFC 8693 token exchange; with actor_token, A2A delegation
GET /.well-known/jwks.json API None Public keys (JWKS)
GET /.well-known/openid-configuration API None OIDC discovery, only with cloud federation (-federation-key)
GET /.well-known/federation/jwks.json API None Federation-only keys, only with cloud federation
GET /healthz API and bootstrap None Liveness
GET /readyz API None Readiness
GET /version API None Build information
GET /metrics API None maqpna_build_info gauge
POST /v1/bootstrap Bootstrap (-bootstrap-listen, Helm default :8083) Projected ServiceAccount token Warm-pool late binding
sequenceDiagram
    autonumber
    participant OP as Operator
    participant IB as Identity broker
    participant AG as Agent A
    participant GW as Gateway
    participant B as Agent B
    OP->>IB: POST /v1/token (Bearer MAQPNA_BROKER_TOKEN)
    IB-->>OP: {token, expiresAt, spiffeId, jti, kid}
    OP->>AG: token file, re-minted at 80% of the lifetime
    GW->>IB: GET /.well-known/jwks.json
    AG->>GW: POST /a2a/team-a/reviewer (scope agents:reviewer)
    GW->>B: call + Txn-Token
    B->>IB: POST /v1/exchange<br/>subject_token = Txn-Token, actor_token = B's token
    IB-->>B: delegated token (act: A, then the user)

Authentication#

Caller How it authenticates
Operator, attestation service, tests Authorization: Bearer <broker token>: the value of MAQPNA_BROKER_TOKEN (or the file named by MAQPNA_BROKER_TOKEN_FILE). The broker refuses to start without it
Agents exchanging or delegating The tokens in the request body authenticate the request
Agents binding a DPoP key Authorization: Bearer <session token> plus a DPoP proof
Warm-pool pods A projected ServiceAccount token with audience maqpna-bootstrap, checked with a Kubernetes TokenReview

A wrong broker token returns 401 {"error":"unauthorized"} with WWW-Authenticate: Bearer realm="maqpna-identity". All JSON responses carry Cache-Control: no-store, except the key documents. Request bodies are limited to 64 KiB.

POST /v1/token#

Mint a session token. Unknown body fields are rejected.

Field Type Meaning
namespace, agent, session string The session identity; they form the SPIFFE ID
user string The user the agent acts for; becomes act.sub = "user:<user>"
scopes []string Granted scopes (tools:github, models:team-a.coder, agents:reviewer)
tier string Trust tier (tier-0, tier-1, tier-2)
ttlSeconds int Lifetime; 0 = -default-ttl (15 min); capped at -max-ttl (1 h); negative is refused
audience string Optional; default -audience (maqpna-gateway)
groups []string The user's verified groups (at most 32)
principalVerified string How the user was verified: requester, trustedCreator or idToken
parentSession, forkRoot, inheritedTaints string, string, []string For forked sessions
proof object Proof of where the session runs, checked by a configured identity check (see below)
dpopJkt string RFC 7638 thumbprint of the agent's DPoP key; binds the token (cnf.jkt)
curl -sS -X POST http://localhost:8081/v1/token \
  -H "Authorization: Bearer $MAQPNA_BROKER_TOKEN" -H "Content-Type: application/json" \
  -d '{"namespace":"team-a","agent":"coder","session":"fix-4821","user":"[email protected]","scopes":["tools:github","models:team-a.coder"],"tier":"tier-1","ttlSeconds":900}'
{
  "token": "eyJhbGciOiJFZERTQSIsImtpZCI6Ik56YkxzWGg4dURDY2QtNk1Od1hGNFdfN25vV1hGWkFmSGt4WnNSR0M5WHMifQ.…",
  "expiresAt": "2026-10-18T14:20:00Z",
  "spiffeId": "spiffe://maqpna.local/ns/team-a/agent/coder/session/fix-4821",
  "jti": "b3f1c2…",
  "kid": "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs",
  "tokenType": "Bearer"
}

tokenType is DPoP for a bound token. substrate is added when a proof was verified.

Status error
400 invalid JSON: …, ttlSeconds must be >= 0, invalid scope, invalid principalVerified, bad dpopJkt, unknown proof check "…"
401 unauthorized
403 The proof failed its check, or -require-proof is set and no proof was sent
503 Tenant signing key missing or mismatching (fail closed), or an identity check cannot reach its trust anchors

CLI: maqpna token mint.

Proofs and POST /v1/proof/nonce#

With identity checks configured (-identity-checks, -sandbox-check), a mint can carry a proof that the broker verifies against the substrate itself and records in the maqpna_substrate claim:

proof field Meaning
check Registered check name (for example kubernetes, spiffe, aws-iid, gcp-identity, azure-imds, tee, or a sandbox driver)
sandbox Sandbox ID or name
nonce A nonce from /v1/proof/nonce, for checks that bind one
token A signed JWT (projected ServiceAccount token, JWT-SVID, GCP identity token)
document, signature, format AWS instance identity document and its signature
x5c X.509-SVID chain, leaf first, base64 DER
evidence TEE evidence

POST /v1/proof/nonce with body {"check": "<name>"} returns {"check", "nonce", "expiresAt"} for a check that issues nonces; 400 for one that does not. See Identity checks.

POST /v1/token/verify#

Verify a token's signature, issuer, expiry, audience and tenant key binding.

Field Meaning
token Required
audience Audience to check; empty = the broker's default
curl -sS -X POST http://localhost:8081/v1/token/verify \
  -H "Authorization: Bearer $MAQPNA_BROKER_TOKEN" -H "Content-Type: application/json" \
  -d "{\"token\":\"$(cat token.jwt)\"}"

A valid token returns 200 {"valid": true, "claims": {...}}; an invalid one returns 200 {"valid": false, "error": "<why>"}. A body without token is 400. CLI: maqpna token verify (which checks against the JWKS) and maqpna token inspect (which decodes).

POST /v1/token/bind#

Exchange an unbound session token for the same token bound to a DPoP key (RFC 9449). Present the token as Authorization: Bearer <token> and a DPoP header with a proof over it (ath). A token's jti binds to one key only: the first bind wins.

{"token":"eyJ…","tokenType":"DPoP","expiresAt":"2026-10-18T14:20:00Z","jti":"b3f1c2…","jkt":"0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"}
Status error
400 invalid_request (token already bound), invalid_dpop_proof, invalid_grant
401 invalid_token
409 invalid_grant: the token is already bound to another key
503 Tenant key unavailable

Errors carry WWW-Authenticate: DPoP algs="EdDSA ES256", error="<code>". The bind record lives in the replica's memory, so with several broker replicas a token can be bound once per replica. See DPoP.

POST /v1/exchange#

RFC 8693 token exchange. The body is application/x-www-form-urlencoded (RFC 8693) or JSON with the same parameter names. Errors follow RFC 6749: {"error", "error_description"}.

Parameter Meaning
grant_type urn:ietf:params:oauth:grant-type:token-exchange (optional; any other value is refused)
subject_token The token to exchange
subject_token_type urn:ietf:params:oauth:token-type:jwt or …:access_token; for delegation also urn:ietf:params:oauth:token-type:txn_token
audience Target audience
scope Space-separated requested scopes; empty = every scope of the subject token
requested_ttl_seconds Shorter lifetime than the audience's maximum
actor_token, actor_token_type Present: this is an A2A delegation (below)
requested_token_type Accepted, not used

Exchange (no actor token)#

The subject token must be a MAQPNA session token (not itself an exchanged token). The audience must be on the -exchange-config allow-list, and the subject's namespace must match the audience's allowedNamespaces. The issued token holds only the requested scopes that are both allowed for the audience (allowedScopes) and granted by the subject; it lives at most maxTTLSeconds (default 300) and never longer than the subject; it carries maqpna_parent_jti. A provider on the audience turns the MAQPNA token into a cloud credential (aws-sts, gcp-sts, azure-entra); without one, the result is a down-scoped MAQPNA token.

-exchange-config file:

{
  "audiences": {
    "github-mcp": {"maxTTLSeconds": 300, "allowedScopes": ["tools:github"], "allowedNamespaces": ["team-a"]},
    "aws-prod": {"provider": "aws-sts", "params": {"roleArn": "arn:aws:iam::123456789012:role/agent"}, "allowedNamespaces": ["team-a"]}
  }
}

The params keys depend on the provider; see Cloud identity and keys.

curl -sS -X POST http://localhost:8081/v1/exchange \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d "subject_token=$(cat "$MAQPNA_TOKEN_FILE")" \
  -d subject_token_type=urn:ietf:params:oauth:token-type:jwt \
  -d audience=github-mcp \
  -d scope=tools:github
{"access_token":"eyJ…","issued_token_type":"urn:ietf:params:oauth:token-type:jwt","token_type":"Bearer","expires_in":300,"scope":"tools:github"}

A DPoP-bound subject token needs a proof by its key when the audience has a provider (cloud credentials are bearer); down-scoping it to a MAQPNA token needs none and keeps the binding.

Status error Cause
400 invalid_request Missing subject_token or audience, bad form, bad requested_ttl_seconds, unsupported token type
400 unsupported_grant_type Wrong grant_type
400 invalid_target Audience not allow-listed, or not for this namespace
400 invalid_grant Subject token invalid, or itself exchanged
400 invalid_scope No requested scope is both allowed and granted
400 invalid_dpop_proof Bound subject without a valid proof
502 server_error The cloud credential provider failed
503 temporarily_unavailable Tenant key unavailable

A2A delegation (with an actor token)#

Agent B, handling an A2A call from agent A, exchanges for a token that records the chain. actor_token is B's own session token (not exchanged, not delegated). subject_token is either:

  • the call's transaction token from the gateway (subject_token_type = urn:ietf:params:oauth:token-type:txn_token), which must have been issued for a call to B; or
  • A's session token (jwt / access_token / empty type), which must grant agents:<B> (agents:<ns>/<B> across namespaces) and must not be DPoP-bound.

audience, when set, must be the broker's default audience. The result is a token for B whose act chain is A and then the user, with maqpna_delegator_jti. It lives at most 15 minutes (or requested_ttl_seconds), the chain is capped at -max-delegation-depth (5), and a delegation never crosses a tenant. A DPoP-bound actor token needs a proof by its key.

curl -sS -X POST http://localhost:8081/v1/exchange \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d "subject_token=$TXN_TOKEN" \
  -d subject_token_type=urn:ietf:params:oauth:token-type:txn_token \
  -d "actor_token=$(cat "$MAQPNA_TOKEN_FILE")"

The response has the same shape as an exchange (issued_token_type urn:ietf:params:oauth:token-type:jwt). Errors: invalid_request, invalid_target, invalid_grant (bad actor or subject, wrong call target, different tenants, bound subject token), invalid_scope (subject_token does not grant agents:<name>), invalid_dpop_proof, temporarily_unavailable. See Agent-to-agent calls.

GET /.well-known/jwks.json#

The broker's public keys: the current signing key, retired keys still trusted (-previous-keys), every tenant key, and, when configured, the ML-DSA key and the federation key. Content-Type: application/jwk-set+json, Cache-Control: public, max-age=300.

curl -sS http://localhost:8081/.well-known/jwks.json
{"keys":[{"kty":"OKP","crv":"Ed25519","x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo","kid":"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs","use":"sig","alg":"EdDSA"}]}

MAQPNA verifiers use the OKP keys (and ML-DSA keys when identity.acceptMLDSA is set) and ignore others. The gateway refreshes this document every 300 s and at once on an unknown kid (at most every 10 s).

OIDC discovery for cloud federation#

With -federation-key (an ES256 or RS256 key) the broker signs federation JWTs that AWS STS, Google STS and Microsoft Entra accept, and serves two documents for the federation issuer (-federation-issuer, default -issuer):

GET /.well-known/openid-configuration:

{
  "issuer": "https://identity.example.eu",
  "jwks_uri": "https://identity.example.eu/.well-known/federation/jwks.json",
  "response_types_supported": ["id_token"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["ES256"],
  "claims_supported": ["sub", "aud", "iss", "iat", "exp", "nbf", "jti", "maqpna_ns", "maqpna_agent", "maqpna_session", "maqpna_tier", "scope"]
}

GET /.well-known/federation/jwks.json holds only the federation key. Both are cached for 300 s. The issuer URL must route to the broker, or you host these two documents statically. See Hyperscaler interop.

POST /v1/bootstrap#

Warm-pool late binding: a pre-started pod learns which session it serves. Served only on -bootstrap-listen, the one broker port agent namespaces may reach.

Input Meaning
Authorization: Bearer <projected ServiceAccount token> Audience -bootstrap-audience (maqpna-bootstrap), mounted at /var/run/maqpna/bootstrap/sa-token
?wait=<seconds> Long-poll until the pod is adopted and its session prepared; capped at 25 s. Clients use 20

The broker runs a TokenReview, checks the bound pod, follows pod → Sandbox → SandboxClaim → AgentSession with matching UIDs, binds the first pod, and returns the session:

{
  "namespace": "team-a",
  "session": "fix-4821",
  "agent": "coder",
  "attested": false,
  "token": "eyJ…",
  "expiresAt": "2026-10-18T14:20:00Z",
  "spiffeId": "spiffe://maqpna.local/ns/team-a/agent/coder/session/fix-4821",
  "jti": "b3f1c2…",
  "env": {"MAQPNA_GATEWAY_URL": "<gateway URL>", "MAQPNA_SESSION": "fix-4821", "…": "…"}
}

Attested sessions (tier-2) get no token: they redeem their release ID with attestation evidence. Call again with the rotated ServiceAccount token to refresh before expiresAt.

Status error Retry?
400 invalid_request (bad wait) No
401 invalid_token No
403 access_denied (pod, Sandbox, claim or session do not match), session_revoked No
409 already_bootstrapped (bound to another live pod), session_suspended No
410 session_gone (deleted, finished or expired) No
425 not_adopted, not_prepared Yes
503 temporarily_unavailable (too many concurrent bootstraps, Retry-After: 1; or a backend error) Yes

Probes, version and metrics#

Endpoint Answer
GET /healthz 200 {"status":"ok"}
GET /readyz 200 {"status":"ready"}; 503 {"status":"shutting down"} while draining after SIGTERM (-shutdown-delay)
GET /version {"component":"identity","version","commit","buildDate","goVersion","platform","tags"}
GET /metrics The maqpna_build_info gauge only

Token claims#

Session tokens are compact JWS. The header is {"alg":"EdDSA","kid":"<RFC 7638 thumbprint>"} (ML-DSA-* when -sign-alg selects it). Verifiers reject other algorithms, refuse tokens with typ: txntoken+jwt (a transaction token is never an identity token), and accept a token without kid only when exactly one key is trusted.

Claim Type Example Meaning
iss string maqpna-identity -issuer
sub string spiffe://maqpna.local/ns/team-a/agent/coder/session/fix-4821 SPIFFE-style session ID; the verifier requires it to match the maqpna_* claims
aud string or array maqpna-gateway -audience, or the exchange audience
iat, nbf, exp number 1792332000 Unix seconds
jti string b3f1c2… Token ID; revocations can target it
act object {"sub":"user:[email protected]"} The user the agent acts for. Delegation nests it: {"sub":"<caller SPIFFE ID>","act":{"sub":"user:[email protected]"}}
scope string tools:github models:team-a.coder Space-separated scopes
maqpna_ns string team-a Namespace
maqpna_agent string coder Agent
maqpna_session string fix-4821 Session
maqpna_tier string tier-1 Trust tier
maqpna_groups string platform support The user's verified groups, space-separated, at most 32
maqpna_principal_verified string requester How the user was bound: requester, trustedCreator or idToken
maqpna_parent_jti string Set on exchanged tokens: the subject token's jti
maqpna_delegator_jti string Set on delegated tokens: the delegating agent's jti, so revoking it revokes the chain
maqpna_parent_session, maqpna_fork_root string Set on forked sessions
maqpna_inherited_taints string web The parent's taint labels at fork time, space-separated
maqpna_substrate object {"check":"kosa8","sandbox":"sb-1","isolation":"microvm","attestation":"measured"} What a verified proof established (also egress, digest, platform, workload, jurisdiction)
cnf object {"jkt":"0ZcOCORZNY…"} DPoP key binding (RFC 7800)

Decode one locally:

maqpna token inspect "$(cat "$MAQPNA_TOKEN_FILE")"

Broker flags that shape the API#

Flag Default Effect
-listen :8081 API port
-bootstrap-listen off Bootstrap port
-trust-domain, -issuer, -audience maqpna.local, maqpna-identity, maqpna-gateway sub, iss, aud
-default-ttl, -max-ttl 15m, 1h Mint lifetimes
-exchange-config — Exchange allow-list
-max-delegation-depth 5 Delegation chain cap
-txn-token-keys, -txn-token-audience —, the trust domain Transaction tokens accepted as delegation subjects
-tenants-file, -tenant-keys-dir, -tenant-reload —, /var/run/maqpna/tenant-keys, 10s Per-tenant signing keys
-identity-checks, -sandbox-check, -require-proof — Mint proofs
-federation-key, -federation-issuer — Cloud federation and OIDC discovery
-public-url each request's scheme and host The htu of DPoP proofs
-tls-cert, -tls-key — Serve the API over TLS; clients may present an X.509-SVID

The complete list is maqpna-identity -h; environment variables are in Environment variables.

Next steps#