Orca ORCA v0.174.2
Documentation navigation

Token Consumers

This is a map of who reads a Corti access token, which part of the token they read, and how — across the whole corticph GitHub estate. It is the consumer-side counterpart to Token Mappers (which client gets which mapper, i.e. how the token is produced) and the Platform Mappers Contract (the claim contract and the target organization model).

Use it for impact analysis: before a claim is renamed, moved, or dropped — and the Organizations migration does all three, moving project identity to organization.<org> and narrowing corti to client-level attributes (see the contract and Organization Migration) — this tells you exactly which repos and which lines break.

Note

Verified against the live corticph org via gh search code on 2026-06-10, building on the research in corticph/platform-api#283 (PLA-5670). It is a point-in-time snapshot — file paths and struct tags drift; re-verify before relying on a specific line.

1. Two client families, two ways to carry identity

A token's contents depend on which client family minted it. This is the single most important thing to know when reading the tables below — the same consumer behaves differently depending on the token it receives.

CC clients (client_credentials) Delegated clients (assistant, copilot-frontend)
Created by platform-api CreateClient platform-service realm bootstrap or platform-api CreateClient
corti.console_project_id? ✅ baked by a hardcoded-claim mapper ❌ no mapper at all
How project identity reaches the consumer the corti.console_project_id claim the groups claim path /platform-api/{projectID}/{customerID}, parsed by the consumer

Delegated clients most commonly back the Embedded Assistant flow, but that is not their only use — they can equally be configured for direct API access. The token mechanics in the table above apply regardless of which way the delegated client is used.

See Token Mappers for the producer-side mechanics.

Note

The Organizations migration removes this asymmetry. Both client families become organization members — delegated users through their customer organization, service accounts through their project's hidden organization — so both carry organization.<org>.console_project_id. The groups-path fallback that exists only to paper over the gap becomes dead code. See Organization Migration.

2. Master matrix — token field → consumers

Every token field, the repos that read it, and what they do with it.

Token field Consumed by How / for what
iss corti-core-api, authorization-service, traefik jwt-service-router, copilot-api, code-frontend, Assistant (frontend) split on realms/ → realm/tenant; drives JWKS lookup and tenant routing. Assistant uses it heavily — token recognition, realm/authority mapping, refresh + logout endpoints, log context, embedded re-auth
aud corti-core-api (verified), platform-api (verified) code-api does not verify it (verify_aud: False)
scope authorization-service (Casbin authz), traefik deny-jwt-scope, copilot-api (feature gating) deny-jwt-scope is a deny-list and fails open on a missing/malformed token
realm_access.roles authorization-service, corti-core-api, code-api, copilot-api, platform-api, common-service-library, Assistant (frontend) RBAC. Assistant populates session/JWT roles (incl. after refresh) and tRPC session roles
resource_access corti-core-api (realm-management.manage-users → superuser), copilot-api, agent-api, common-service-library per-resource roles
groups copilot-api, gocomo/maestro, platform-api, platform-service, corti-api-console, Assistant (frontend) parse /platform-api/{projectID}/{customerID} for project identity. Not in the shared lib, not read by corti-core-api / authorization-service. Assistant derives consoleProjectId from this path on delegated tokens, and also uses groups for session groups, group-specific legacy templates, GrowthBook flags, and analytics identity (Mixpanel/PostHog) — see §3
azp authorization-service (realm-role gate), gocomo, common-service-library, Assistant (frontend) client id. Assistant uses it as the client_id for refresh-token calls (falls back to AUTH_KEYCLOAK_ID) and on the session
sub corti-core-api, copilot-api, agent-api, voice-vault, Assistant (frontend) user id / attribution. Assistant requires it for token recognition, refresh/session user id, log context, and embedded user identity
preferred_username code-api, code-frontend, copilot-api, common-service-library display / identity
corti.* copilot-api, agent-api, gocomo/maestro, audio-bridge business claims — see §3

3. corti.* business-claim consumers

The custom business claims (console_project_id, the billing-provider IDs, is_nfr, retention_minutes, console_distributor_id) are read by four backends — though console_distributor_id has no token consumer today (see the note below). The shared claims struct does not include them, so each consumer defines its own struct. The table also lists Assistant for completeness: it consumes no corti.* claims but is a load-bearing consumer of project identity, deriving consoleProjectId from the groups path on delegated tokens.

Repo File(s) Claims read (tag/access) Project identity Notes
copilot-api pkg/auth/auth.go, pkg/auth/console_ids.go console_project_id, metronome_customer_id, retention_minutes (*int, not a string); stripe_id decoded but never read claim + groups fallback parses the group path when the claim is absent; works for both CC and delegated tokens. RetentionMinutes() (pkg/auth/auth.go:105) drives the document retention policy — read at pkg/documentation/router.go:371, pkg/new_document/controller.go:80, pkg/interaction/router.go:109
gocomo/maestro — a shared library, not a service maestro/token.go, maestro/model.go console_project_id, metronome_customer_id, is_nfr (json:"is_nfr,string,omitempty") claim + first platform-api/ group path billing → Metronome. BillingContextMiddleware is mounted by copilot-api, audio-bridge, agent-api (and corti-api-console), so those services parse the token twice — once here, once in their own auth package. Emits the groups claim as a billing dimension
agent-api pkg/auth/context.go console_project_id; stripe_id decoded but never read claim only — no groups fallback ProjectID() returns ErrMissingConsoleProjectID when the claim is absent, so it relies on a CC token carrying console_project_id
audio-bridge internal/server/auth.go console_project_id (runtime map access), metronome_customer_id (logged) claim, falls back to a default project id non-structured map access
code-frontend — iss only n/a decodes the issuer for env/tenant routing; not a corti.* consumer
Assistant (frontend) packages/flags/src/getGrowthbookAttributes.ts (extractConsoleProjectAndCustomer) none — does not read corti.* today groups path only parses /platform-api/{projectID}/{customerID} from the groups claim on delegated tokens to derive consoleProjectId; a load-bearing consumer of project identity via groups, not corti.*
Note

corti.console_distributor_id — produced but not consumed from the token. Console manages the distributor ID per project (super-admin panel; stored in Console's DB and pushed to the bound group/org attributes by platform-api). It is emitted onto tokens by the org mapper as corti.console_distributor_id (see Platform Mappers Contract and Token Mappers), but no backend or frontend reads it from the token today — the only struct references (platform-api models/user.go, models/group.go) are management-side request/response models, not token parsing. It feeds activation and finance reporting through analytics exports sourced from Console's database, not the JWT. Listed here so the field is documented alongside the other business claims; a future consumer that needs distributor identity at request time would read this claim.

Note

Assistant — full token surface. Assistant (corticph/assistant, next) is a broad consumer of the standard claims even though it reads no corti.*. Per a current-snapshot audit (deployments#19534):

  • iss — recognise Keycloak tokens, map authority/realm, build refresh & logout URLs, log context, embedded re-auth, legacy-template env (packages/auth/src/decodeToken.ts, packages/auth/src/auth.ts, packages/auth/src/server.ts, packages/api/src/trpc.ts, apps/web/src/utils/embeddedAPI/handlers/useHandleAuth.ts).
  • sub — token recognition, refresh/session user id, log context, embedded user identity.
  • realm_access.roles — session/JWT roles (incl. after refresh), tRPC roles.
  • groups — session groups, consoleProjectId/customer extraction (packages/flags/src/getGrowthbookAttributes.ts), group-specific legacy templates, GrowthBook flags, analytics identity (Mixpanel/PostHog), embedded same-user check.
  • azp — client_id for refresh-token calls (falls back to AUTH_KEYCLOAK_ID) and on the session.
  • Not used: aud, scope, resource_access, preferred_username, corti.*.
Note

Two struct tags diverge from the produced claim names: agent-api and copilot-api both decode stripe into json:"stripe_id", while platform-api bakes the claim as corti.stripe_customer_id. gocomo decodes is_nfr with json:"is_nfr,string" (string-to-bool). These are stated here as facts for impact analysis, not as a to-do list.

Important

Two consequences for the Organizations migration.

  • stripe_customer_id is safe to drop. Both stripe_id fields (copilot-api/pkg/auth/auth.go, agent-api/pkg/auth/context.go) are declared and never read — the only other references are "test-stripe-id" fixtures. Removing the claim needs no behavioural change, only deleting the dead fields.
  • Two consumers fail at decoding, not merely at reading the wrong location. Both need a type change, not just a path change:
    • gocomo's json:"is_nfr,string". Organization attributes are arrays, so a string-to-bool tag cannot decode ["false"]. Needs a custom unmarshal.
    • copilot-api's RetentionMinutes *int. corti.retention_minutes is emitted as a JSON int today (platform-api sets "jsonType.label": "int" in internal/infra/repositories/keycloak_realm_repo.go), and copilot-api's own test asserts that a string value fails with "cannot unmarshal string into Go struct field CortiAttrs.corti.retention_minutes of type int". Organization attributes are string arrays, so the organization half requires a custom unmarshal regardless; whether the surviving client-level corti.retention_minutes stays an int or becomes a string is still open — see Platform Mappers Contract §5.

4. Signature verification

The token is signed by Keycloak (RS256). Two downstream patterns exist: verify locally against the realm's JWKS, or trust the gateway and parse the payload without verifying.

Repo Pattern
corti-core-api verify locally via JWKS (core/lib/jwtvalidator; checks aud + iss)
code-api verify locally via Python PyJWKClient, RS256 — skips aud and selects the realm from an X-Customer header, not iss
platform-api verify locally via auth0 go-jwt-middleware + caching JWKS provider
authorization-service 2-pass: ParseUnverified to read iss → ParseWithClaims + keyFunc (full verify), then Casbin authz
copilot-api / agent-api / gocomo-maestro (library) / call-service / voice-vault ParseUnverified — trust the gateway, decode claims only (voice-vault uses a nil keyfunc and ignores ErrTokenUnverifiable)
traefik-plugins (deny-jwt-scope, jwt-service-router) base64-decode the payload only — no signature check; deny-jwt-scope fails open
Important

Where the cryptographic verification actually happens is the gateway, via Envoy SecurityPolicy JWT with remoteJWKS (RS256) — defined in the corticph/charts repo (charts/envoy-gateway/templates/ai-gateway-proxy/securityPolicyAuth.yaml, charts/inference-service/templates/securityPolicy.yaml), not in traefik-plugins. Services that ParseUnverified are safe only because they sit behind that gateway.

5. The shared claims structs

There are two shared Go token parsers, and they do not overlap.

corticph/common-service-library package jwtclaims defines the canonical Default struct (golang-jwt v5), used by corti-core-api, call-service, and others. Fields:

Azp, Acr, AllowedOrigins, RealmAccess.Roles, ResourceAccess, Scope, Sid, Email, EmailVerified, PreferredUsername, GivenName, FamilyName, Realm — plus the embedded RegisteredClaims (iss / aud / sub / exp).

It has no groups field and no corti.* fields — which is why the business claims appear in §3 rather than here.

corticph/gocomo/maestro is the other one, and it does parse the business claims — BillingContext embeds RegisteredClaims plus azp, client_id, email, groups, and a nested Corti struct. Because it ships as HTTP middleware (BillingContextMiddleware), copilot-api, audio-bridge and agent-api each parse the same token twice: once through maestro for billing, once through their own pkg/auth struct for request handling. Consolidating those two parsers is the obvious follow-up, and is why maestro is sequenced first in the migration workstreams.

6. Non-consumers

These repos surface in a code search for token fields but are not runtime consumers — they match only via vendored OpenAPI specs, generated SDK clients, indirect dependencies, debug scripts, or because they produce tokens:

  • api-specs — spec definitions only
  • assistant-ios — generated SDK, mirrors the spec
  • ml-service-llm-reasoning — extracts console_project_id for Opik trace metadata only (observability, not functional)
  • local-pilot — debug jq scripts that print claims for local testing
  • agent-memory — JWT is an indirect dependency

7. Using this when changing the token

When a claim is about to be renamed, moved, or removed:

  1. Find the readers — locate the claim in §2 / §3, then confirm in the repos with gh search code '<claim>' --owner corticph.
  2. Check the struct tag — the consumer's JSON tag must match the produced claim name exactly (see the §3 note on stripe_id and is_nfr,string).
  3. Confirm CC vs delegated presence — a claim present on CC tokens may be absent on delegated tokens (notably corti.console_project_id); a consumer without a groups fallback only works for the family that carries the claim.
  4. Mind the gateway — verification lives in the Envoy SecurityPolicy in the charts repo, and the traefik deny-jwt-scope plugin fails open; changes to iss / scope affect routing and the deny-list, not just business logic.
  5. Expect arrays, not scalars — anything moving to organization.<org> becomes an array (id excepted), and unset values arrive as the literal string "null" rather than a missing key. A consumer that only changes the claim path and keeps a scalar type will fail to decode. See the parsing contract in Platform Mappers Contract §6.