Orca ORCA v0.174.2
Documentation navigation

Token Mappers

This article explains the Keycloak protocol/token mappers that end up in the tokens our applications consume — which mappers exist on which client, where each claim comes from, and why the same logical claim is sometimes delivered differently depending on the client.

The picture is genuinely a bit messy, so §8 collects the inconsistencies — they are the reason the platform is migrating to Keycloak Organizations. For the consumer side — which repos read which part of the token and how — see Token Consumers.

Important

This article describes the state being migrated away from. It stays accurate as a record of what is deployed today and of what must be unwound. For the target model see Platform Mappers Contract, and for the rollout see Organization Migration.

Note

The clients, dedicated scopes, and mappers described here are created programmatically via the Keycloak Admin API — by Corti Console (console.corti.app) and platform-service / platform-api when a customer creates an API client or a customer.

1. Overview

There are several distinct Keycloak clients, and a principal (a human user or a service account) logs into each one separately. There is no token brokering or exchange between them — e.g. an embedded client does not log into the assistant client on the user's behalf. Each client issues its own token, populated by its own dedicated mapper set.

Client / entity Who authenticates Where
Direct API client (client credentials) A service account (no human) API integrations
Embedded Assistant client A human user — their own or a company-provided username/password Customer's embedded integration
Studio showcase embedded client (*-default_embedded_client) An auto-created studio_user_<UUID>@users.corti.app Console embedded showcase
assistant client A human user, with username/password assistant.eu.corti.app

A user can log into the standard Assistant and an embedded Assistant client separately and use the Assistant application either way.

All custom claims land under a nested corti object in the token (the mapper's Token Claim Name is corti.<something>), e.g.:

{
  "sub": "…",
  "corti": {
    "console_project_id": "nfm-reseller",
    "console_project_uuid": "…",
    "retention_minutes": 43200,
    "metronome_customer_id": "<UUID>"
  }
}
flowchart TD
    SA["Service<br/>(client credentials)"] --> Direct["Direct API client"]
    U1["User<br/>(username / password)"] --> Assistant["assistant client<br/>(assistant.eu.corti.app)"]
    U2["User<br/>(own / company creds)"] --> Embedded["Embedded Assistant client"]
    Console["Console embedded showcase<br/>(console.corti.app)"] -->|auto-creates studio_user_&lt;UUID&gt;| Studio["*-default_embedded_client"]

    Direct --> T1["JWT<br/>(corti.* claims)"]
    Assistant --> T2["JWT<br/>(corti.* claims)"]
    Embedded --> T3["JWT<br/>(corti.* claims)"]
    Studio --> T4["JWT<br/>(corti.* claims)"]

Several of the same logical claims (console_project_uuid, openmeter_customer_id, stripe_customer_id, metronome_customer_id, retention_minutes, IsNFR) appear on multiple clients — each client carries its own copy, and the delivery type (Hardcoded claim vs User Attribute) varies per client.

2. Direct API client (client credentials)

Created in Corti Console via Create API client → Direct API access with the Client credentials authentication method.

In Keycloak this produces a confidential client with:

  • Client authentication ON
  • Only Service account roles enabled in the authentication flow (a pure client-credentials grant — there is no human user)

The client's dedicated scope carries 7 token mappers. Every one writes into the corti.* namespace. Note the Type column — most are Hardcoded claim (a fixed value baked onto the client), but two are User Attribute mappers:

Mapper Type Token Claim Name JSON type
console_project_uuid Hardcoded claim corti.console_project_uuid String
stripe_customer_id Hardcoded claim corti.stripe_customer_id String
retention_minutes User Attribute (retention_minutes) corti.retention_minutes int
console_project_id Hardcoded claim corti.console_project_id String
metronome_customer_id Hardcoded claim corti.metronome_customer_id String
IsNFR User Attribute corti.isNFR String
openmeter_customer_id Hardcoded claim corti.openmeter_customer_id String

The quirk worth flagging: even though a client-credentials client has no human user, retention_minutes and IsNFR are User Attribute mappers — they read from the client's service-account user attributes, while the rest are hardcoded onto the client itself.

Note

Default (auto-provisioned) client-credentials clients carry an extra auto_created mapper. When a project's client is auto-created by the platform (its dedicated scope name ends in default_client-dedicated), the scope has an 8th mapper — auto_created (Hardcoded claim, corti.auto_created = true, String) — that marks the client as system-provisioned rather than user-created. A regular Direct API client created by a customer does not have it. The studio showcase embedded client carries the same auto_created marker (see §4).

3. Embedded Assistant clients

Created in Corti Console via Create API client → Embedded Assistant. A human user authenticates directly against this client with their own (or a company-provided) username and password — there is no brokered login into the assistant client. It offers three authentication modes:

Mode Keycloak capability config
Auth code (with PKCE) Public client, Standard flow only, Client authentication OFF
Auth code (without PKCE) Frontend + backend; the client secret is held on the backend. Standard flow
ROPC (Resource Owner Password) Direct access grants enabled, Client authentication OFF

How the user logs in (per mode)

The modes differ mainly in where the user types their credentials and whether a backend secret is involved:

  • Auth code (with PKCE) — the user is redirected to Corti's Keycloak-hosted login page and authenticates there (never in the customer's app). The app generates a PKCE code_verifier/code_challenge, receives an authorization code on redirect back, and exchanges code + verifier for tokens. Public client, no secret — the PKCE verifier proves the exchange came from the same app. Best for SPAs / mobile / anything that can't safely hold a secret. This is the recommended mode.
  • Auth code (without PKCE) — the user logs in on the same Keycloak-hosted login page (identical experience). The difference is the code-for-token exchange is performed by a backend using the client secret, so this requires a frontend + backend (the secret must never reach the browser). Best for classic server-side web apps.
  • ROPC (Resource Owner Password) — no redirect. The user types their username/password directly into the customer's own application UI, which sends the raw credentials to Keycloak's token endpoint and gets tokens back in one call. Discouraged: the app sees the plaintext password and it can't do SSO/MFA/identity-provider login. Use only for legacy/constrained integrations.
Mode Where the user enters credentials Secret? Needs a backend?
Auth code + PKCE Keycloak hosted login page (redirect) No No
Auth code (no PKCE) Keycloak hosted login page (redirect) Yes (on backend) Yes
ROPC The customer's own app UI (no redirect) No No
Note

Only the redirect-based modes (the two auth-code flows) can support bring-your-own-SSO: the Keycloak login page can hand off to a customer's external identity provider. ROPC cannot, because there is no browser redirect for the IdP to participate in.

Because a real user logs in, the User Attribute mappers on this client resolve against that user's attributes — which always come from the user's customer group membership (see §6). A user with no customer group gets empty values for those claims.

4. Studio showcase embedded client (*-default_embedded_client)

Console (console.corti.app) hosts an embedded showcase to demo the embedded Assistant feature. In the showcase the user has not provided their own credentials yet, so Console auto-creates a user named studio_user_<UUID>@users.corti.app and logs that user into a per-project client named <project>-default_embedded_client.

That client carries 8 token mappers (note the two studio-specific ones, is_studio_client and auto_created):

Mapper Type
console_project_uuid Hardcoded claim
is_studio_client Hardcoded claim
openmeter_customer_id Hardcoded claim
stripe_customer_id Hardcoded claim
auto_created Hardcoded claim
metronome_customer_id Hardcoded claim
retention_minutes User Attribute
IsNFR User Attribute

As with every other client, the claims land under the corti.* namespace.

Important

The retention_minutes / IsNFR claims are absent for the studio user. These values are sourced from customer group attributes (see §6), and the auto-created studio_user_<UUID> has no attributes of its own and is not a member of any group — so there is nothing for these User Attribute mappers to emit.

5. The assistant client (assistant.eu.corti.app)

The standard Assistant. A human user logs in with a username and password directly against the assistant client. Its assistant-dedicated dedicated scope contains 3 User Attribute mappers:

Mapper Category Type
Metronome Token mapper User Attribute
IsNFR Token mapper User Attribute
Retention Minutes Token mapper User Attribute

These resolve against the logged-in user's attributes, but the source differs:

  • Metronome reads the user-level Metronome Customer ID attribute set directly on the user — so it works even for users who are not in any group/customer.
  • IsNFR and Retention Minutes read customer group attributes (is_nfr / retention_minutes) inherited via group membership. These claims are optional — they are only emitted when the group sets the attribute, and is_nfr is only set when true (see the conditions in §6).

6. The customer feature (groups → user attributes)

The User Attribute mappers above need somewhere to read their values from. That source is the customer feature.

A customer is a Keycloak group (e.g. platform-api/<project>/<customer>) that holds multiple users. The group carries attributes that every user in the group inherits. One example group's attributes:

Group attribute Example value
console_project_id <project>
is_nfr true
console_distributor_id corti
console_customer_id <customer>

The retention_minutes and IsNFR User Attribute mappers read from these inherited group attributes (retention_minutes / is_nfr) — they are not set directly on the user.

Important

Because these are User Attribute mappers, the corti.retention_minutes / corti.isNFR claims are only present when the backing attribute actually has a value. In practice that means all of the following must hold:

  1. A real user is logging in (an embedded / assistant user) — not a service-account client-credentials token.
  2. That user is a member of a group that sets the attribute.
  3. For is_nfr specifically: the attribute is set only when true. For a non-NFR user we don't set the group attribute at all, so corti.isNFR is absent from the token entirely (it is not emitted as false).

Consumers must therefore treat these claims as optional — a missing corti.isNFR means "not NFR", and a missing corti.retention_minutes means no group-configured retention. This is why the studio user (no group) gets neither (see §4).

Note

metronome_customer_id is the exception: it is a user-level attribute (Metronome Customer ID on the User), not a group attribute — see §7.

7. Special case — metronome_customer_id

The Metronome customer id is delivered differently depending on the client. The only client that reads it from the user is the assistant client; every other client carries it as a hardcoded claim, so whatever value is baked onto that client is what shows up in the token's corti.metronome_customer_id.

On the User entity there is a Metronome Customer ID attribute (e.g. user ffa+customer@corti.ai → <some-uuid>).

Client How metronome_customer_id is delivered
assistant client (a real user logs in directly) User attribute — the Metronome user-attribute mapper reads the user's Metronome Customer ID, so the claim follows the logged-in user
Embedded Assistant clients Hardcoded claim — baked onto each (separate) embedded client; that fixed value is what lands in corti.metronome_customer_id
Studio showcase embedded client (*-default_embedded_client) Hardcoded claim — same as above (see §4)
Direct API client (client credentials) Hardcoded claim — baked onto the client (the metronome_customer_id mapper in §2)

In other words, metronome_customer_id is a User Attribute mapper only on the assistant client; on every other client it is a Hardcoded claim fixed at client-creation time.

8. Known messiness

This area grew organically. These four problems are the case for the Organizations migration — each is listed with how the target model resolves it. See Organization Migration for the rollout.

  • Naming is inconsistent. console_project_id vs console_project_uuid; isNFR (mapper) vs IsNFR (display) vs is_nfr (group/user attribute key); retention_minutes vs "Retention Minutes"; Metronome (mapper) vs metronome_customer_id (claim). → Organization attribute keys are snake_case and defined once per organization, so there is no mapper display name to drift from the claim name. console_project_uuid is dropped outright.

  • The same claim is delivered two different ways (Hardcoded claim on client-credentials clients vs User Attribute on user-based clients). This is a drift risk — it's exactly what the audit script exists to catch. → Both client families become organization members (service accounts via their project's hidden organization), so one mechanism serves both and the dual mapping disappears.

  • Every client redefines the same recurring claims. console_project_uuid, openmeter_customer_id, stripe_customer_id, metronome_customer_id, retention_minutes, and IsNFR are copied onto each client's dedicated scope independently, so they can drift between clients. → Values live once on the organization and are emitted by Keycloak's own organization mappers. Only genuinely client-scoped values (a per-client retention override, auto_created, is_studio_client) stay on the client, and stripe_customer_id / openmeter_customer_id are dropped.

  • API-driven creation with no single source of truth. The mapper set each client/customer receives is defined in the Console / platform-service / platform-api code paths that call the Keycloak Admin API, not in versioned declarative config. → Partly resolved: the shared claims collapse onto one organization record, so there is nothing per-client left to drift, and the openmeter_customer_id audit script becomes unnecessary. Organization provisioning is still Admin-API-driven, so this problem is reduced rather than eliminated.