Skip to content

Token Exchange (Service-Principal On-Behalf-Of)

A confidential backend service authenticates as a service principal and exchanges proof of a staff user's identity for a short-lived, MG-issued token that acts as that staff user, with the service recorded as the actor. This is the on-behalf-of (OBO) delegation pattern the MCP authorization spec prescribes instead of token passthrough. The first consumer is the staff MCP server (see the staff-mcp spec); the model is built general so other trusted services (e.g. cron) can adopt the same principal/actor mechanism later.

Why

We're introducing the first class of client MG has never had: a trusted external program that needs to perform staff operations as a specific, accountable user, through the API's business logic (not raw DB access via scripts). The MCP server is that client. The required properties — per-user subject identity, the service distinctly recorded as actor, short token lifetime, full audit, and no passing the caller's upstream token through to the API — are exactly OBO/RFC 8693 delegation. MG has no mechanism for this today; CRON_SECRET is an identity-less shared secret, and impersonation is admin-only and human-oriented.

Constraints

  • MG cannot mint Supabase-compatible JWTs. The Supabase HS256 signing secret is not in the application environment, and supabase.auth.getClaims() falls back to a network call to Supabase for HS256 tokens (apps/api/src/lib/auth/supabase/server.ts). Any token MG signs itself would fail validation. → The delegated token must be an opaque, DB-backed credential validated by a new branch in validateAuthentication, exactly as x-impersonate already works. We do not issue a JWT.
  • No operation-level scopes exist, by design. ADR-006 (shipped March 2026, PRs #494/#497/#498) deliberately removed the permission grammar/wildcard/catalog (~1500 lines) in favor of in-memory role checks against JWT claims. Authorization is role-based: ADMIN / STAFF / COMMUNITY_MANAGER / USER / PARTNER, optionally location-scoped. → "Downscoping" here is not an app-level scope system (building one would re-add what ADR-006 deleted). It is: which user the service may act as, which role that user has, and which tools the service exposes.
  • No token passthrough. Per the MCP security spec, MG must not accept a token that wasn't issued to it. The caller's upstream identity proof (e.g. a Google ID token) is consumed only at the exchange endpoint to establish the subject — it never reaches downstream calls.
  • Reuse impersonation's role resolution. Delegated requests load the target user's roles fresh from user_roles via loadRoleClaimsFromDb (auth-validator.ts), never from a stale JWT.

Acceptance criteria

  1. A registered service principal can authenticate (confidential-client credentials) and call POST /api/auth/token-exchange.
  2. The endpoint establishes the subject (target staff user) per the principal's configured subject-verification mode (below), and rejects anything that doesn't resolve to an active, non-banned, staff-level user.
  3. It refuses to delegate as an ADMIN user — unless the principal is configured to allow it: either canDelegateAdmin (full admin claims) or delegatedRoleCap (claims capped to a lower role). The staff-mcp principal sets delegatedRoleCap: 'STAFF', so admins are valid subjects but every delegated call presents STAFF claims — never admin.
  4. It returns an opaque delegated-session token + expires_at. The token is short-lived (default 15 min) and revocable.
  5. Sending that token (new header) makes validateAuthentication resolve the principal as { userId: <staff>, actorPrincipalId: <service> } with the target's current roles from DB.
  6. Every mutation made under a delegated token writes an audit_logs row recording both the subject (user_id) and the acting service principal, distinguishable from a human impersonation.
  7. An expired, ended, or unknown delegated token is rejected (401), surfaced as a clean error.
  8. A non-MCP-shaped service principal (e.g. a future cron principal) can be created against the same model without schema change.

Data shapes (Prisma deltas)

General service-principal model. ServiceDelegation intentionally mirrors ImpersonationSession.

model ServicePrincipal {
  id              String   @id @default(uuid())
  name            String                       // "staff-mcp", later e.g. "cron:nudge-stub-users"
  clientId        String   @unique
  clientSecretHash String                      // argon2/bcrypt; secret shown once at creation. HTTP Basic at the endpoint.
  // How this principal is allowed to assert a subject:
  subjectMode     String                       // 'verified_oidc' | 'asserted' | 'none'. MCP principal = 'verified_oidc'.
  oidcIssuer      String?                      // for 'verified_oidc' (e.g. https://accounts.google.com)
  oidcEmailDomain String?                      // required domain on the verified subject (e.g. metrognome.com)
  canDelegateAdmin Boolean @default(false)      // allow delegating ADMIN subjects with FULL admin claims; MCP principal stays false
  delegatedRoleCap String?                       // cap every delegation's claims to this role (e.g. 'STAFF') regardless of subject's real role; lets admins act as staff. MCP principal = 'STAFF'
  active          Boolean  @default(true)
  createdAt       DateTime @default(now())
  deletedAt       DateTime?
  delegations     ServiceDelegation[]
}

model ServiceDelegation {
  id                 String   @id @default(uuid())   // the opaque delegated token
  servicePrincipalId String                          // the actor
  subjectUserId      String                          // the target staff user
  expiresAt          DateTime
  endedAt            DateTime?
  createdAt          DateTime @default(now())
  servicePrincipal   ServicePrincipal @relation(fields: [servicePrincipalId], references: [id])
}

Subject-verification modes (per principal, the key generality lever):

  • verified_oidc — the principal must present a subject_token (an OIDC ID token); MG verifies it against oidcIssuer's JWKS, requires oidcEmailDomain, and maps the email to an MG user. The MCP uses this (Google). Defense in depth: a stolen client secret alone cannot impersonate arbitrary staff without also a valid Google token for that staffer.
  • asserted — the principal directly asserts a subject_user_id; MG trusts it because the client is confidential. Simpler, weaker. Not used by the MCP.
  • none — no user subject (system actor). The path a future cron principal would use; it produces an actor-only context, not an act-as-user delegation.

API surface

POST /api/auth/token-exchange
  Auth:  confidential client — Authorization: Basic base64(clientId:clientSecret)
         (private_key_jwt is a future upgrade; not MVP)
  Body (verified_oidc): {
    subject_token: string,            // Google OIDC ID token for the staffer
    subject_token_type: "urn:ietf:params:oauth:token-type:id_token",
    audience: "mg-api"
  }
  200: { access_token: <delegation.id>, token_type: "MG-Delegated",
         expires_at: ISO8601, subject: { userId, email } }
  401: invalid client / invalid or untrusted subject_token
  403: subject is not an active staff user, or is an ADMIN the principal may not delegate (no canDelegateAdmin / delegatedRoleCap)

Delegated use on subsequent calls: new header x-service-delegation: <token>, checked in validateAuthentication before the Supabase path (sibling to x-impersonate). validateServiceDelegation looks up the live, unexpired ServiceDelegation, loads the subject's roles via loadRoleClaimsFromDb, and returns the principal with actorPrincipalId set.

Optional: DELETE /api/auth/token-exchange (or a revoke endpoint) to end a delegation early.

Flow

sequenceDiagram
  participant MCP as Service (MCP)
  participant MG as MG API /token-exchange
  participant G as Google JWKS
  participant API as MG API (tool route)

  MCP->>MG: POST /token-exchange (client creds + subject_token=Google ID token)
  MG->>MG: authenticate ServicePrincipal (clientId/secret)
  MG->>G: verify subject_token signature + claims
  MG->>MG: email→active staff user? not ADMIN?
  MG->>MG: create ServiceDelegation (subject, actor, 15m TTL)
  MG-->>MCP: { access_token: delegationId, expires_at }
  MCP->>API: tool call with x-service-delegation: delegationId
  API->>API: validateAuthentication → validateServiceDelegation → roles from DB
  API->>API: business logic runs as staff user; audit row = subject + actor

Audit

The Postgres audit_log_change() trigger reads audit.user_id, audit.impersonator_id, audit.source, audit.request_id from session vars set via applyAuditContext (apps/api/src/lib/database/audit-context.ts), populated from storeAuditContext in the auth validator.

  • The delegated branch must call storeAuditContext so the trigger captures the actor.
  • user_id = subject (staff user).
  • Actor: reuse impersonator_id to hold the ServicePrincipal.id. Verified safe — impersonator_id is a bare String? with no FK (schema.prisma:1547); nothing in the codebase joins it to users (only display-side readers). No migration.
  • source = 'service:<principalName>' (e.g. 'service:staff-mcp'), following cron's 'cron:*' convention. Set server-side in the delegated branch — no change to ALLOWED_AUDIT_SOURCES (that set only gates the client-supplied x-audit-source header on human paths).
  • Optional polish: the audit-log UI (AuditLogsClient.tsx) renders impersonator_id as "Impersonated by"; have it show service:* actors as "acting service" so it doesn't read as a human impersonation.

Security properties

  • Confidential-client auth (hashed secret, shown once).
  • verified_oidc mode → client-secret compromise alone ≠ impersonate-anyone (needs a valid staffer OIDC token too).
  • A capped principal (delegatedRoleCap) never yields ADMIN capabilities: staff-mcp delegations present STAFF claims for every subject (admins included), so even a leaked delegation token tops out at staff. Subjects must be active, non-banned, staff-or-above. An unrecognized cap value fails closed (no claims), never passing the subject's real claims through.
  • Short TTL (15 min default), revocable, every action audited with subject + actor.
  • No token passthrough; the Google token is consumed only at exchange.
  • Delegated tokens are opaque, non-deterministic UUIDs (no guessable session ids).

Out of scope

  • Operation-level scopes / a permission grammar (deleted by ADR-006; not reintroducing).
  • Migrating cron to service principals (the model supports it; wiring deferred).
  • The MCP server itself — tools, transport, OAuth-to-claude.ai (separate staff-mcp spec).
  • Replacing impersonation (orthogonal; human-facing, stays as-is).

Decisions (resolved)

  1. Actor audit field — reuse impersonator_id (no FK, no joins; verified). source = 'service:<name>' set server-side. No migration.
  2. Client auth — HTTP Basic client secret, hashed at rest. private_key_jwt is a future upgrade.
  3. Token TTL — 15 min, fixed, no refresh token; re-exchange on expiry (cheap — FastMCP auto-refreshes the upstream Google token).
  4. Namespace/api/auth/token-exchange.
  5. Subject establishmentverified_oidc: the MCP presents the staffer's Google credential and MG independently verifies it (JWKS for an id_token) and maps email → active, approved, staff-or-above user. MG never trusts a bare assertion from the MCP. (id_token only — the access_token/tokeninfo path was dropped in the build for being weaker.)
  6. Admin subjects via role-cap (added 2026-05-31) — the original "never delegate ADMIN" guard locked admins out of the staff MCP entirely, which is unworkable for an admin-operated team. Resolved with delegatedRoleCap: the staff-mcp principal delegates any verified staff-or-above subject (admins included) but presents STAFF claims on every delegated call (capClaims in role-check.ts, applied in validateServiceDelegation). Admins thus use the MCP acting as staff; the delegation never carries admin capabilities for anyone, so blast radius is bounded below RBAC (by the cap) and by the curated tool catalog. Chosen over canDelegateAdmin=true (which would grant full admin reach) precisely for least-privilege. An unrecognized cap fails closed.

Cross-system dependencies

  • Consumed by the staff MCP server (forthcoming features/staff-mcp/spec.md).
  • Admin-gated routes: because we never delegate as ADMIN, a staff-acting MCP cannot reach isAdmin-only routes (e.g. balance adjustment: POST /api/users/[id]/stripe-balance, PUT /api/user-credit-balances/[id]). Enabling those MCP tools requires deliberately downscoping those specific routes to staff-level — tracked in the MCP spec — not granting the MCP admin.
  • Auth domain: docs/engineering/domains/users.md, docs/engineering/architecture/auth/ (impersonation, roles, overview).
  • Authorization model fixed by ADR-006 — role-based, no scopes.