Platform Token Issuer (MCP trust root)
Issue #1954 · resolves the #1864 trust-root gap · ADR-0022 · milestone 24.
What it is
The platform-owned MCP token issuer is UpsQuad's OWN authorization server for the
governed-MCP path. It lets us mint the short-TTL, agent-scoped bearer that a
worker presents at /mcp/team/{unit} and have the gateway validate it against a
key set we control — closing the gap where the dev stack could only validate
tokens from fake-jwt-issuer / Clerk, neither of which we can mint with.
Two PUBLIC routes on the gateway (cmd/context-engine, HTTP mux):
| Route | Purpose |
|---|---|
GET /.well-known/jwks.json | Public half of the platform signing key (JWK Set, RS256). |
GET /.well-known/oauth-authorization-server | RFC 8414 AS metadata (issuer, jwks_uri, token_endpoint). |
POST /oauth/token | Client-credentials token endpoint for external / BYO agents (PR-4, #1957). |
token_endpoint (/oauth/token) was advertised from PR-1 and is now
IMPLEMENTED in PR-4 (#1957); PR-2 (#1955) wires the orchestrator minter and
repoints MCP_GATEWAY_JWKS_URL at /.well-known/jwks.json.
Single-key custody (finding 2 — the one decision the founder signed off)
There is exactly ONE platform signing key. Two processes load it from the same secret-backend source:
- Gateway (
context-engine) loads it to SERVE the public half (this PR). - Orchestrator (
agent-orchestrator) loads it to MINT tokens (PR-2, #1955).
Env contract (identical in both processes):
| Var | Meaning |
|---|---|
PLATFORM_SIGNING_KEY_FILE | Path to the PEM (secret-backend mount). |
PLATFORM_SIGNING_KEY | Inline PEM alternative (tests/CI). |
PLATFORM_SIGNING_KEY_ID | JWS kid — must match between served JWKS and minted token. |
PLATFORM_ISSUER | iss claim / AS issuer identifier. |
There is deliberately no second custody path: a worker only ever RECEIVES a finished token and can never re-sign it.
Fail-safe
No key material ⇒ LoadSigningKey returns (nil, nil), the issuer routes are
not mounted, and mint stays OFF. The trust root is wholly present or wholly
absent — never half-open. Material present but malformed / missing kid/issuer is
a fatal startup error (loud, never silent).
Dev stack
bash scripts/dev-secrets/gen-platform-signing-key.sh # seeds .env + PEM
docker compose -f docker-compose.dev.yml up -d context-engine
curl -s http://localhost:8080/.well-known/jwks.json | jq
curl -s http://localhost:8080/.well-known/oauth-authorization-server | jq
The generated PEM lives OUTSIDE the repo (/opt/upsquad-secrets/platform-issuer/
by default), mode 0600 — a dev-only key, never used in GCP.
GCP
Same env contract, satisfied by a mounted K8s secret / Workload-Identity-fronted
secret. Key rotation is a redeploy: publish the new public key in the JWKS
(advertise both kids during overlap), then flip the minter to the new kid.
POST /oauth/token — external / BYO agent client credentials (PR-4, #1957)
A registered external agent (external_agent_clients, migration 155;
RegisterExternalAgent RPC, #1956) presents its client credential here and
receives the same agent-scoped gateway token an internal orchestrator mint
produces: agent_id / org_id / unit_id claims, aud = <base>/mcp/team/{unit},
~10m TTL, azp = agent-runtime (finding 1), plus runtime = external provenance
(finding 3/5). It reuses RS256AgentTokenMinter.Mint UNCHANGED (finding 3), so the
terminator needs zero changes (finding 5). grant_type=client_credentials only;
no refresh tokens — the agent re-grants to refresh.
Two client-authentication methods (the two the AS metadata advertises):
private_key_jwt(RFC 7523, PREFERRED) — the customer holds the private key; we store only the public JWK. The client signs a short-lived assertion (iss = sub = client_id,aud =this token endpoint,jti,exp ≤ 5m); we verify the signature against the stored public key, the audience, expiry, and ajtisingle-use replay guard.client_secret_post(FALLBACK) — bcrypt compare againsthashed_secret.
Enable gate (Decision-D, #1864)
The endpoint mirrors the orchestrator's env gate. It is NOT mounted (404s) until armed, and even when mounted refuses a resolved org that is not enabled:
| Var | Meaning |
|---|---|
MCP_GATEWAY_ENABLED | Global on/off (default false). |
MCP_GATEWAY_TENANTS | CSV of org_ids allowed even when global is off. |
Set the SAME values on context-engine (issue side) as on agent-orchestrator
(mint side) so both flip together.
Governed-MCP enablement checklist (#1981)
Governed MCP for internal agents is inert unless the session-snapshot freeze
derives the agent's MCP set from the v2.4 inheritance resolver. That path is
gated by orgv24.multiparent_resolver — so this flag sits at the same tier
as the signing key and the MCP_GATEWAY_ENABLED gate. Miss it and the gateway
edge is correct but every agent runs on an EMPTY snapshot and "has no tool to
see" (#1981 root cause).
To turn governed MCP on for an environment, ALL of the following must hold:
| Requirement | Where | Note |
|---|---|---|
PLATFORM_SIGNING_KEY_FILE (or _KEY) + _ID armed | orchestrator + context-engine | Mints/verifies the agent-gateway token (#1955). |
MCP_GATEWAY_ENABLED=true or org in MCP_GATEWAY_TENANTS | orchestrator + context-engine | Decision-D enable gate (#1864). |
orgv24.multiparent_resolver = true | platform_feature_flags row (global) | Gates the snapshot MCP freeze (#1981). Seeded ON by migration 161; compile default is also ON post-GA. Set the row to false to kill-switch back to v2.3. |
Fail-loud coupling: the orchestrator now REFUSES TO START (startup fatal,
not a WARN) when the MCP trust root is armed (signing key + enable gate) but
orgv24.multiparent_resolver resolves OFF — the contradictory state that
silently produced empty snapshots is now impossible to boot into
(cmd/agent-orchestrator/main.go, guard tagged #1981).
Persistence: migration 161 seeds orgv24.multiparent_resolver=true via
ON CONFLICT DO UPDATE, so a DB reset that re-runs migrations restores the ON
posture — an environment (e.g. beta-dev) no longer silently reverts to a
hand-inserted SQL row. The .down.sql restores the false kill-switch posture.
Hardening (public trust-root surface)
- Uniform failure: unknown client, revoked client, wrong secret/signature,
expired/replayed assertion, wrong audience, and a disabled tenant ALL return the
identical
401 invalid_clientbody — no client-enumeration oracle. The secret path always runs a bcrypt compare (dummy hash for unknown clients) so timing does not distinguish unknown from known-wrong. - Rate limit + lockout: per-IP and per-client_id token buckets plus a
consecutive-failure exponential backoff (
429 slow_down), enforced before any DB work. - Audit: every issuance (
external_token_issued) and every failed attempt (external_token_denied) writes an audit row (migration 157). The assertion, the secret, and the minted token are NEVER logged or placed in a detail. - Bootstrap resolve: the org is unknown until the credential resolves, so the
lookup goes through the
resolve_external_agent_clientSECURITY DEFINER function (migration 156) — the one sanctioned cross-tenant read, production-safe under the NOBYPASSRLSapp_rwrole (#1908). A miss returns zero rows.
External-agent usage
# 1. private_key_jwt — build a client assertion (aud = the token endpoint):
# claims: {iss, sub = client_id, aud = "<BASE>/oauth/token", jti, exp}
# signed with the client's PRIVATE key (RS256/ES256/PS256).
ASSERTION=$(build_client_assertion) # your runtime signs this
curl -s -X POST "$BASE/oauth/token" \
-d grant_type=client_credentials \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
-d "client_assertion=$ASSERTION"
# => {"access_token":"<jwt>","token_type":"Bearer","expires_in":600}
# 2. client_secret_post fallback:
curl -s -X POST "$BASE/oauth/token" \
-d grant_type=client_credentials \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET"
# 3. Use the token at the governed team gateway (same as an internal agent):
curl -s -X POST "$BASE/mcp/team/$UNIT" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'