Skip to main content

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):

RoutePurpose
GET /.well-known/jwks.jsonPublic half of the platform signing key (JWK Set, RS256).
GET /.well-known/oauth-authorization-serverRFC 8414 AS metadata (issuer, jwks_uri, token_endpoint).
POST /oauth/tokenClient-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):

VarMeaning
PLATFORM_SIGNING_KEY_FILEPath to the PEM (secret-backend mount).
PLATFORM_SIGNING_KEYInline PEM alternative (tests/CI).
PLATFORM_SIGNING_KEY_IDJWS kid — must match between served JWKS and minted token.
PLATFORM_ISSUERiss 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 a jti single-use replay guard.
  • client_secret_post (FALLBACK) — bcrypt compare against hashed_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:

VarMeaning
MCP_GATEWAY_ENABLEDGlobal on/off (default false).
MCP_GATEWAY_TENANTSCSV 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:

RequirementWhereNote
PLATFORM_SIGNING_KEY_FILE (or _KEY) + _ID armedorchestrator + context-engineMints/verifies the agent-gateway token (#1955).
MCP_GATEWAY_ENABLED=true or org in MCP_GATEWAY_TENANTSorchestrator + context-engineDecision-D enable gate (#1864).
orgv24.multiparent_resolver = trueplatform_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_client body — 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_client SECURITY DEFINER function (migration 156) — the one sanctioned cross-tenant read, production-safe under the NOBYPASSRLS app_rw role (#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":{}}'