Last active
June 24, 2026 10:42
-
-
Save thomasdarimont/05ff048dbc6f066f8e1aa7c0aba74a59 to your computer and use it in GitHub Desktop.
Example script for creating an idjag token with Keycloak
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env zsh | |
| # Manual end-to-end test for the Identity Assertion JWT (ID-JAG) token-exchange flow. | |
| # | |
| # Prereq: start Keycloak with the experimental feature enabled, e.g. | |
| # bin/kc.sh start-dev --features=identity-assertion-jwt,token-exchange | |
| # | |
| # Requires: curl, jq, openssl (all standard on macOS). | |
| set -u | |
| BASE=${BASE:-http://localhost:8081} | |
| REALM=${REALM:-master} | |
| ADMIN_USER=${ADMIN_USER:-admin} | |
| ADMIN_PASS=${ADMIN_PASS:-admin} | |
| CLIENT_ID=mcp-client | |
| CLIENT_SECRET=secret | |
| # --- helpers --------------------------------------------------------------- | |
| # Portable base64url decode (macOS/BSD openssl, no GNU-only flags). | |
| b64url_decode() { | |
| local data="${1//-/+}"; data="${data//_//}" | |
| local pad=$(( ${#data} % 4 )) | |
| (( pad )) && data="${data}$(printf '=%.0s' {1..$((4 - pad))})" | |
| printf '%s' "$data" | openssl base64 -d -A | |
| } | |
| # Decode a JWT segment: jwt_decode <jwt> <1=header|2=payload> | |
| jwt_decode() { | |
| b64url_decode "$(printf '%s' "$1" | cut -d. -f"$2")" | jq . | |
| } | |
| die() { print -u2 "ERROR: $*"; exit 1; } | |
| # --- admin token (cached) -------------------------------------------------- | |
| print "== getting admin token ==" | |
| ADMIN_TOKEN=$(curl -s "$BASE/realms/master/protocol/openid-connect/token" \ | |
| -d grant_type=password -d client_id=admin-cli \ | |
| -d username="$ADMIN_USER" -d password="$ADMIN_PASS" | jq -r '.access_token // empty') | |
| [[ -n "$ADMIN_TOKEN" ]] || die "could not get admin token (check $ADMIN_USER/$ADMIN_PASS creds)" | |
| print "admin token OK" | |
| AUTH=(-H "Authorization: Bearer $ADMIN_TOKEN") | |
| # --- (re)create the mcp-client --------------------------------------------- | |
| print "\n== (re)creating client '$CLIENT_ID' ==" | |
| CID=$(curl -s "${AUTH[@]}" \ | |
| "$BASE/admin/realms/$REALM/clients?clientId=$CLIENT_ID" | jq -r '.[0].id // empty') | |
| if [[ -n "$CID" ]]; then | |
| curl -s -X DELETE "${AUTH[@]}" "$BASE/admin/realms/$REALM/clients/$CID" >/dev/null | |
| print "deleted existing client ($CID)" | |
| fi | |
| HTTP=$(curl -s -o /dev/null -w '%{http_code}' \ | |
| -X POST "$BASE/admin/realms/$REALM/clients" \ | |
| "${AUTH[@]}" -H "Content-Type: application/json" -d @- <<JSON | |
| { | |
| "clientId": "$CLIENT_ID", | |
| "enabled": true, | |
| "protocol": "openid-connect", | |
| "publicClient": false, | |
| "secret": "$CLIENT_SECRET", | |
| "directAccessGrantsEnabled": true, | |
| "standardFlowEnabled": false, | |
| "attributes": { | |
| "id.jag.issuance.enabled": "true", | |
| "id.jag.allowed.audiences": "https://auth.chat.example", | |
| "id.jag.client.id": "mcp-client-at-resource-as", | |
| "id.jag.allowed.scopes": "chat.read##chat.history" | |
| } | |
| } | |
| JSON | |
| ) | |
| [[ "$HTTP" == "201" ]] || die "create client failed (HTTP $HTTP)" | |
| print "create client: 201" | |
| # Resolve the actual secret (in case the server overrode it). | |
| CID=$(curl -s "${AUTH[@]}" \ | |
| "$BASE/admin/realms/$REALM/clients?clientId=$CLIENT_ID" | jq -r '.[0].id') | |
| SECRET=$(curl -s "${AUTH[@]}" \ | |
| "$BASE/admin/realms/$REALM/clients/$CID/client-secret" | jq -r '.value') | |
| print "client secret: $SECRET" | |
| # --- get an ID token for the user ------------------------------------------ | |
| print "\n== getting ID token ==" | |
| ID_TOKEN=$(curl -s "$BASE/realms/$REALM/protocol/openid-connect/token" \ | |
| -d grant_type=password -d username="$ADMIN_USER" -d password="$ADMIN_PASS" \ | |
| -d client_id="$CLIENT_ID" -d client_secret="$SECRET" \ | |
| -d scope="openid email" | jq -r '.id_token // empty') | |
| [[ -n "$ID_TOKEN" ]] || die "login failed (no id_token; is 'openid' scope mapped?)" | |
| print "ID token OK" | |
| # --- happy path: exchange ID token -> ID-JAG ------------------------------- | |
| print "\n== exchanging ID token for an ID-JAG ==" | |
| RESP=$(curl -s -u "$CLIENT_ID:$SECRET" "$BASE/realms/$REALM/protocol/openid-connect/token" \ | |
| -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ | |
| -d requested_token_type=urn:ietf:params:oauth:token-type:id-jag \ | |
| -d subject_token="$ID_TOKEN" \ | |
| -d subject_token_type=urn:ietf:params:oauth:token-type:id_token \ | |
| -d audience=https://auth.chat.example \ | |
| -d resource=https://mcp.chat.example/mcp \ | |
| -d scope="chat.read chat.history") | |
| print "$RESP" | jq . | |
| JAG=$(print "$RESP" | jq -r '.access_token // empty') | |
| [[ -n "$JAG" ]] || die "no ID-JAG returned (is the 'identity-assertion-jwt' feature enabled?)" | |
| print "\n-- ID-JAG header --" | |
| jwt_decode "$JAG" 1 | |
| print -- "-- ID-JAG payload --" | |
| jwt_decode "$JAG" 2 | |
| print "\n== advertised grant profiles ==" | |
| curl -s "$BASE/realms/$REALM/.well-known/openid-configuration" \ | |
| | jq '.authorization_grant_profiles_supported' | |
| # --- negative: audience not allow-listed -> 403 ---------------------------- | |
| print "\n== negative: non-allow-listed audience (expect 403) ==" | |
| curl -s -o /dev/null -w 'not-allowed audience: %{http_code}\n' \ | |
| -u "$CLIENT_ID:$SECRET" "$BASE/realms/$REALM/protocol/openid-connect/token" \ | |
| -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ | |
| -d requested_token_type=urn:ietf:params:oauth:token-type:id-jag \ | |
| -d subject_token="$ID_TOKEN" \ | |
| -d subject_token_type=urn:ietf:params:oauth:token-type:id_token \ | |
| -d audience=evil.example | |
| # --- negative: wrong subject_token_type -> 400 invalid_request ------------- | |
| print "\n== negative: wrong subject_token_type (expect 400 invalid_request) ==" | |
| curl -s -u "$CLIENT_ID:$SECRET" "$BASE/realms/$REALM/protocol/openid-connect/token" \ | |
| -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ | |
| -d requested_token_type=urn:ietf:params:oauth:token-type:id-jag \ | |
| -d subject_token="$ID_TOKEN" \ | |
| -d subject_token_type=urn:ietf:params:oauth:token-type:access_token \ | |
| -d audience=auth.chat.example | jq '{error, error_description}' | |
| # --- negative: scope outside the allow-list -> 400 invalid_scope ----------- | |
| print "\n== negative: non-allow-listed scope (expect 400 invalid_scope) ==" | |
| curl -s -u "$CLIENT_ID:$SECRET" "$BASE/realms/$REALM/protocol/openid-connect/token" \ | |
| -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ | |
| -d requested_token_type=urn:ietf:params:oauth:token-type:id-jag \ | |
| -d subject_token="$ID_TOKEN" \ | |
| -d subject_token_type=urn:ietf:params:oauth:token-type:id_token \ | |
| -d audience=auth.chat.example \ | |
| -d scope="chat.read chat.delete" | jq '{error, error_description}' | |
| print "\nDone." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment