CodamAIDocs
Topicdone

Token exchange

Why CIAS exchanges every user token for one for its own client, how long the result is cached, and what happens with a misconfigured realm.

Variants
Exchange with cache hitExchange without cacheToken without jti (no cache)Realm misconfigured → 401Keycloak unreachable → 503

What this is about

A token is always issued for a specific client. When someone logs in to the hub, the BFF gets a token for the hub’s client. It contains what the hub client may see, and not necessarily everything CIAS needs: the roles of the backend client, the organizations, the attributes for the tenant.

That is why CIAS exchanges every incoming token at Keycloak for one for its own client, such as cias-backend or cdms-backend. This procedure is called token exchange and is an OAuth standard.

Keycloak only exchanges when the incoming token names the CIAS client in its audience (aud). The one exception: the client exchanges its own token. CIAS checks exactly this rule already when validating the token, so a token for another client never reaches the exchange. The exchanged token has to name the CIAS client as azp, or CIAS does not accept it.

The benefit: the frontend does not need to know which roles and claims the backend needs. It sends its token, and CIAS gets the rest.

The flow

sequenceDiagram
    participant C as Client
    participant S as Filter chain (CIAS)
    participant M as Cache
    participant K as Keycloak
    C->>S: Request with token (jti = abc)
    S->>M: Is there an exchanged token for abc?
    alt Hit
        M-->>S: exchanged token
    else No hit
        S->>K: POST /token<br/>grant_type=token-exchange<br/>subject_token=…
        K-->>S: Token for the CIAS client
        S->>M: remember under abc
    end
    S->>S: Read identity from the exchanged token

jti is the unique ID of a token, a claim that Keycloak writes into every token. It is the key of the cache.

The request to Keycloak

FieldValue
grant_typeurn:ietf:params:oauth:grant-type:token-exchange
client_id, client_secretthe client of CIAS (cias_client, cias_client_secret)
subject_tokenthe token the client sent
subject_token_typeurn:ietf:params:oauth:token-type:access_token
audienceonly if restrict-audience: true, then its own client

It is the same client whose roles CIAS then reads from resource_access.<client>.roles. This way the exchanged token and the roles read always match.

The variants

Four cases during the exchange

When: The same token (same jti) came in a short while ago.

CIAS takes the remembered exchanged token. Keycloak is not asked.

Result: The fast normal case, because a client sends the same token for many requests in a row.

When: A new token, for example right after a refresh.

CIAS exchanges at Keycloak and remembers the result. It stays in the cache for at most 5 minutes, and never longer than until 10 seconds before the exchanged token expires.

Result: One call to Keycloak, then hits again.

When: The token has no jti.

Without a key, CIAS cannot remember anything. Every request with this token is exchanged at Keycloak again.

Result: Works, but costs a call to Keycloak on every request.

When: Keycloak refuses the exchange, for example because the exchange is not switched on at the client.

  1. 1
    CIAS→Keycloak
    asks for the exchange
  2. 2
    Keycloak→CIAS
    400 invalid_request: Standard token exchange is not enabled…
  3. 3
    CIAS
    writes an error with the status and response from Keycloak to the log
  4. 4
    CIAS
    ends the request with 401 cias.authentication.token-rejected

Result: The request does not reach the application. It never continues without an identity.

When: Keycloak does not answer, or answers with a server error (5xx).

CIAS writes the reason to the log and responds with 503 cias.authentication.identity-provider-unavailable. It is not the token's fault.

Result: The request may succeed later.

Settings

application.yml
codamai:
  cias:
    token-exchange:
      ttl: 5m                 # CIAS_TOKEN_EXCHANGE_TTL
      expiry-margin: 10s      # CIAS_TOKEN_EXCHANGE_MARGIN
      max-entries: 10000      # CIAS_TOKEN_EXCHANGE_MAX_ENTRIES
      restrict-audience: false
SettingDefaultMeaning
ttl5 minuteshow long an exchanged token is remembered at most
expiry-margin10 secondstime before the exchanged token expires from which it no longer comes from the cache
max-entries10,000ceiling of the cache. When it is full, expired entries go first, otherwise it is cleared
restrict-audiencefalsewhether audience is sent

Next

Sources in the code and the knowledge base
  • CIAS/cias-authentication – TokenExchangeService (exchangeToken, cache, form fields, error handling), TokenExchangeProperties, TokenCacheItem, TokenParser
  • CIAS/cias-authentication/docs/adr – ADR-030, ADR-049
  • CIAS/cias-authentication – CiasTokenValidation (exchanged), IdentityProviderUnavailableException, RequestAdmission
  • CIAS/cias-runtime – application.yml (codamai.cias.token-exchange), deploy/keycloak/import/codamai-realm.json (standard.token.exchange.enabled, self-audience)
Search