TT Lab
Get started
Learn Learning paths Courses

Sessions and Tokens — From the Browser to the Mesh

Client credentials and token exchange (build your own STS)

Continue in TT Lab

Goal

Get and check a service token from Keycloak with client credentials, stand up by yourself as a local STS the standard token exchange (RFC 8693) that Keycloak 26.0.7 does not have, swap a user token for a delegation token only for downstream, and then prove with real requests that downstream checks aud and act and that forged tokens are rejected.

Why it matters

When a service calls another service on behalf of a user and passes the received user token straight through, downstream has to turn off the aud check to accept it, does not know who called on behalf, and the privileges do not shrink either. A token that leaks from one place becomes the key to the whole chain. If you use only the service's own token (client credentials), then this time it disappears whom the request is for. Token exchange solves both together by continuing the user identity, narrowing the audience to downstream, and leaving the actor in act. If the STS does not verify the subject_token here, it becomes a machine that launders a forged token with its own signature, and if downstream does not look at aud and act, there was no point in doing the exchange. Keycloak officially supports standard exchange from 26.2, so this lab also shows what you have to do yourself when the version is too old.

Steps

  1. If Keycloak is off, turn it on with lab-start-keycloak, wait up to 240 seconds with /root/st/exchange/wait.sh until http://127.0.0.1:8080/realms/labhub becomes 200, and then write ready_seconds=<정수> (the placeholder is an integer) into /root/st/exchange/ready.txt.
  2. Get a client credentials token with the confidential client api-svc, save it in /root/st/exchange/svc_token.txt, and after verifying the signature, write azp=, aud=, username=, roles=, and refresh_token= into /root/st/exchange/svc_claims.txt.
  3. Write the result of requesting Keycloak with the token-exchange grant into /root/st/exchange/kc_exchange.txt as kc_exchange_error= and token_exchange_advertised=, and bring up the local STS /root/st/exchange/sts.py at 127.0.0.1:8308 (/health, /jwks, signing key /root/st/exchange/sts_key.pem).
  4. Take an exchange with audience=orders-api using the dev1 token as the subject_token and the api-svc token as the actor_token, put it in /root/st/exchange/exchanged_token.txt, and write the verified values into /root/st/exchange/exchanged.txt. You make the helper as /root/st/exchange/tokens.sh.
  5. Bring up the downstream service /root/st/exchange/downstream.py at 127.0.0.1:8309 and write the status codes of calling GET /orders with four tokens into /root/st/exchange/downstream.txt.
  6. Send a forged, tampered, and unsigned subject_token, a missing actor_token, an unknown audience, and a normal control to the STS and write the results into /root/st/exchange/reject.txt.
  7. Test the whole flow with /root/st/exchange/e2e.sh and leave seven lines in /root/st/exchange/e2e.out.

Notes

Wait until Keycloak is ready

If Keycloak is off, turn it on with lab-start-keycloak, wait up to 240 seconds with /root/st/exchange/wait.sh until http://127.0.0.1:8080/realms/labhub becomes 200, and then write the time it took into /root/st/exchange/ready.txt as ready_seconds=<정수> (the placeholder is an integer).

Keycloak is a JVM, so it takes 40–90 seconds. Instead of a fixed sleep, use a loop that polls the status code. You can see what is running with lab-status.

Get a service token with client credentials

Get a grant_type=client_credentials token with the confidential client api-svc and save it on the first line of /root/st/exchange/svc_token.txt, and after verifying the signature with the realm JWKS, write azp=, aud= (joined with commas), username=, roles=, and refresh_token=present|absent into /root/st/exchange/svc_claims.txt. Read the client secret from KC_CONFIDENTIAL_SECRET in /opt/fixtures/kc/realm-info.env and do not copy it into a file.

client credentials is a token you get in the service's own name with no user (RFC 6749 4.4). The token endpoint is KC_TOKEN_URL in realm-info.env. Whose token it is, azp and preferred_username tell you. Also check whether the response JSON had a refresh_token.

Check Keycloak's limit and bring up the local STS

Request Keycloak with grant_type=urn:ietf:params:oauth:grant-type:token-exchange and write the error value that came back as kc_exchange_error=, and whether that grant is in the discovery document's grant_types_supported as token_exchange_advertised=yes|no, into /root/st/exchange/kc_exchange.txt. Then bring up the local STS /root/st/exchange/sts.py at 127.0.0.1:8308. GET /health gives {"ok":true}, GET /jwks gives an RSA public key with a kid, and you keep the signing private key at /root/st/exchange/sts_key.pem. POST /token handles an RFC 8693 exchange (graded in steps 4 and 6).

The Keycloak in this Pod is 26.0.7, and standard token exchange (V2) starts from 26.2. The discovery address is /.well-known/openid-configuration after the issuer. The STS must verify the subject_token with the Keycloak JWKS (PyJWT's PyJWKClient), and you read the form body with parse_qs.

Exchange the user token for an orders-api-only token

Use dev1's user token (web-app, password grant) as the subject_token and api-svc's client credentials token as the actor_token, and POST /token (audience=orders-api) to the STS to get an exchanged token. The new token must carry over the original user's sub, hold the actor's sub and client_id in act, hold in roles only those of the original user's roles that the downstream needs, and have a lifetime shorter than the original token's (900 seconds). Put the token in /root/st/exchange/exchanged_token.txt, verify it with the STS public key, and write issued_token_type=, iss=, aud=, sub_matches=yes|no, act_client_id=, roles=, and ttl= into /root/st/exchange/exchanged.txt. You make the issuing and exchange helper as /root/st/exchange/tokens.sh and use it in later steps.

In RFC 8693, subject_token and subject_token_type are required, and the response must have issued_token_type. The token type identifier is urn:ietf:params:oauth:token-type:access_token. Pass the values with curl's --data-urlencode.

Make downstream check aud and act

Bring up the downstream service /root/st/exchange/downstream.py at 127.0.0.1:8309. GET /orders gives 200 and {"user":..., "actor":...} only to a token that the STS signed, has aud=orders-api, and has an act.client_id of api-svc, and gives 401 to all the rest. Write the status codes of four requests into /root/st/exchange/downstream.txt as exchanged= (the normal exchanged token), keycloak_direct= (Keycloak's original user token), other_aud= (an exchanged token for billing-api), and no_act= (a token signed with the STS key but without act).

If you give audience and issuer to PyJWT's jwt.decode, it does the aud and iss checks together. act is a claim PyJWT does not know, so you have to check it yourself. RFC 8693 says to use only the outermost act for access control.

Reject a forged subject_token and an unknown audience

Test whether the STS rejects requests that fail verification and write the results into /root/st/exchange/reject.txt. For tampered= (a token with only the sub changed and the signature left as is), forged_key= (a token signed with a different RSA key that only imitates Keycloak's kid), alg_none= (an unsigned token), and no_actor= (without an actor_token), write the status code; for bad_audience=, write the error value when audience=admin-api; and for control=, write the status code of the normal request.

RFC 8693 says to use invalid_request if the subject_token is invalid and invalid_target if it cannot issue for the requested audience. An STS that only decodes without signature verification and an STS that does not pin algorithms are exposed here. The grader also sends an expired token signed with the realm key and a token with a different aud.

Prove the whole flow at once

Test the whole flow at once with /root/st/exchange/e2e.sh and leave seven lines in /root/st/exchange/e2e.out: keycloak_exchange= (the error of Keycloak's exchange rejection), exchange=, act=, downstream=, direct_keycloak=, forged_subject= (the status code of a subject_token with a broken signature), and bad_audience=.

You can chain the earlier steps with the tokens.sh helper. Do not write any value into the file by hand; get them from request results. Along with the file, the grader checks the exchange-to-downstream flow once more itself.