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 invalidateAuthentication, exactly asx-impersonatealready 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_rolesvialoadRoleClaimsFromDb(auth-validator.ts), never from a stale JWT.
Acceptance criteria
- A registered service principal can authenticate (confidential-client credentials) and call
POST /api/auth/token-exchange. - 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.
- It refuses to delegate as an ADMIN user — unless the principal is configured to allow it: either
canDelegateAdmin(full admin claims) ordelegatedRoleCap(claims capped to a lower role). Thestaff-mcpprincipal setsdelegatedRoleCap: 'STAFF', so admins are valid subjects but every delegated call presents STAFF claims — never admin. - It returns an opaque delegated-session token +
expires_at. The token is short-lived (default 15 min) and revocable. - Sending that token (new header) makes
validateAuthenticationresolve the principal as{ userId: <staff>, actorPrincipalId: <service> }with the target's current roles from DB. - Every mutation made under a delegated token writes an
audit_logsrow recording both the subject (user_id) and the acting service principal, distinguishable from a human impersonation. - An expired, ended, or unknown delegated token is rejected (401), surfaced as a clean error.
- 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 asubject_token(an OIDC ID token); MG verifies it againstoidcIssuer's JWKS, requiresoidcEmailDomain, 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 asubject_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
storeAuditContextso the trigger captures the actor. user_id= subject (staff user).- Actor: reuse
impersonator_idto hold theServicePrincipal.id. Verified safe —impersonator_idis a bareString?with no FK (schema.prisma:1547); nothing in the codebase joins it tousers(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 toALLOWED_AUDIT_SOURCES(that set only gates the client-suppliedx-audit-sourceheader on human paths).- Optional polish: the audit-log UI (
AuditLogsClient.tsx) rendersimpersonator_idas "Impersonated by"; have it showservice:*actors as "acting service" so it doesn't read as a human impersonation.
Security properties
- Confidential-client auth (hashed secret, shown once).
verified_oidcmode → client-secret compromise alone ≠ impersonate-anyone (needs a valid staffer OIDC token too).- A capped principal (
delegatedRoleCap) never yields ADMIN capabilities:staff-mcpdelegations 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-mcpspec). - Replacing impersonation (orthogonal; human-facing, stays as-is).
Decisions (resolved)
- Actor audit field — reuse
impersonator_id(no FK, no joins; verified).source = 'service:<name>'set server-side. No migration. - Client auth — HTTP Basic client secret, hashed at rest.
private_key_jwtis a future upgrade. - Token TTL — 15 min, fixed, no refresh token; re-exchange on expiry (cheap — FastMCP auto-refreshes the upstream Google token).
- Namespace —
/api/auth/token-exchange. - Subject establishment —
verified_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.) - 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: thestaff-mcpprincipal delegates any verified staff-or-above subject (admins included) but presents STAFF claims on every delegated call (capClaimsinrole-check.ts, applied invalidateServiceDelegation). 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 overcanDelegateAdmin=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.