# OIDC & OAuth2 — curl walkthrough Testing the full OIDC/OAuth2 flow against the local Keycloak stack. **Prerequisites:** `curl`, `jq`, stack running (`docker compose up -d`) --- ## Setup ```bash REALM=demo USER=demo-user PASS=demo REDIRECT=http://localhost:3000/callback KC=http://localhost:8080/realms/$REALM/protocol/openid-connect # Clients (see demo-realm.yaml for details) CLIENT=demo-app CLIENT_PKCE=demo-app-pkce CLIENT_BACKEND=demo-backend BACKEND_SECRET=demo-backend-secret ``` --- ## 1. Discovery ```bash curl -s http://localhost:8080/realms/$REALM/.well-known/openid-configuration | jq . ``` Key fields: `authorization_endpoint`, `token_endpoint`, `userinfo_endpoint`, `jwks_uri`. --- ## 2. Authorization Code Standard flow — the client never sees the user's password. Keycloak handles authentication and issues a short-lived code exchanged for tokens. **Step 1 — get the login form, submit credentials** Keycloak returns an HTML form with a session-specific `action` URL. Credentials must be posted to that URL, not to the auth endpoint directly. ```bash STATE=$(openssl rand -hex 16) LOGIN_URL=$(curl -s -c /tmp/kc-cookies \ "$KC/auth?response_type=code&client_id=$CLIENT&redirect_uri=$REDIRECT&scope=openid+profile+email&state=$STATE" \ | grep -oE 'action="[^"]+"' | head -1 | cut -d'"' -f2 | sed 's/&/\&/g') LOCATION=$(curl -s -b /tmp/kc-cookies \ -X POST "$LOGIN_URL" \ --data-urlencode "username=$USER" \ --data-urlencode "password=$PASS" \ -D - -o /dev/null \ | grep -i "^location:" | tr -d '\r' | cut -d' ' -f2) CODE=$(echo "$LOCATION" | grep -oE 'code=[^&]+' | cut -d= -f2) echo "Code: $CODE" ``` **Step 2 — exchange the code for tokens** ```bash RESPONSE=$(curl -s -X POST $KC/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code&client_id=$CLIENT&code=$CODE&redirect_uri=$REDIRECT") echo $RESPONSE | jq . ACCESS_TOKEN=$(echo $RESPONSE | jq -r .access_token) REFRESH_TOKEN=$(echo $RESPONSE | jq -r .refresh_token) ``` --- ## 3. Authorization Code + PKCE Same flow with PKCE (Proof Key for Code Exchange). Prevents authorization code interception attacks — mandatory for public clients in production. `demo-app-pkce` enforces `S256`. **Step 1 — generate verifier and challenge** ```bash # code_verifier: random URL-safe string (43-128 chars) CODE_VERIFIER=$(openssl rand -base64 96 | tr -d '=+/\n' | cut -c1-64) # code_challenge: BASE64URL(SHA256(code_verifier)) CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=') echo "Verifier: $CODE_VERIFIER" echo "Challenge: $CODE_CHALLENGE" ``` **Step 2 — get the login form with challenge, submit credentials** ```bash STATE=$(openssl rand -hex 16) LOGIN_URL=$(curl -s -c /tmp/kc-pkce-cookies \ "$KC/auth?response_type=code&client_id=$CLIENT_PKCE&redirect_uri=$REDIRECT&scope=openid+profile+email&state=$STATE&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256" \ | grep -oE 'action="[^"]+"' | head -1 | cut -d'"' -f2 | sed 's/&/\&/g') LOCATION=$(curl -s -b /tmp/kc-pkce-cookies \ -X POST "$LOGIN_URL" \ --data-urlencode "username=$USER" \ --data-urlencode "password=$PASS" \ -D - -o /dev/null \ | grep -i "^location:" | tr -d '\r' | cut -d' ' -f2) CODE=$(echo "$LOCATION" | grep -oE 'code=[^&]+' | cut -d= -f2) echo "Code: $CODE" ``` **Step 3 — exchange the code + verifier for tokens** ```bash RESPONSE=$(curl -s -X POST $KC/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code&client_id=$CLIENT_PKCE&code=$CODE&redirect_uri=$REDIRECT&code_verifier=$CODE_VERIFIER") echo $RESPONSE | jq . ACCESS_TOKEN=$(echo $RESPONSE | jq -r .access_token) REFRESH_TOKEN=$(echo $RESPONSE | jq -r .refresh_token) ``` > To verify PKCE is enforced: try the exchange without `code_verifier` — Keycloak returns `invalid_grant`. --- ## 4. Client Credentials Machine-to-machine — no user involved. The client authenticates with its own credentials and receives a token tied to its service account. ```bash RESPONSE=$(curl -s -X POST $KC/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -u "$CLIENT_BACKEND:$BACKEND_SECRET" \ -d "grant_type=client_credentials") echo $RESPONSE | jq . M2M_TOKEN=$(echo $RESPONSE | jq -r .access_token) ``` --- ## 5. Password Grant (ROPC — for debugging only) The client sends credentials directly to Keycloak. **Never use in production.** Useful for quick scripted tests only. ```bash RESPONSE=$(curl -s -X POST $KC/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=password&client_id=$CLIENT&username=$USER&password=$PASS") echo $RESPONSE | jq . ACCESS_TOKEN=$(echo $RESPONSE | jq -r .access_token) REFRESH_TOKEN=$(echo $RESPONSE | jq -r .refresh_token) ``` --- ## 6. Scope Scope controls **what the token allows** — the capabilities granted by the user or the authorization server. It is a contract between the client and the resource server. The demo realm defines two optional scopes on `demo-app`: `app:read` and `app:write`. Optional scopes are only included when explicitly requested. **Request a specific scope** ```bash RESPONSE=$(curl -s -X POST $KC/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=password&client_id=$CLIENT&username=$USER&password=$PASS&scope=openid+app:read") echo $RESPONSE | jq -r .scope ACCESS_TOKEN=$(echo $RESPONSE | jq -r .access_token) ``` **Inspect scope in the token** ```bash echo $ACCESS_TOKEN | cut -d. -f2 \ | tr -- '-_' '+/' \ | awk '{l=length($0)%4; if(l==2) print $0"=="; else if(l==3) print $0"="; else print $0}' \ | base64 -d | jq .scope ``` **Without the optional scope** — `app:read` is absent from the token: ```bash curl -s -X POST $KC/token \ -d "grant_type=password&client_id=$CLIENT&username=$USER&password=$PASS" \ | jq -r .scope ``` The resource server checks the `scope` claim before executing an operation. A token without `app:write` must be rejected on write endpoints regardless of who the user is. --- ## 7. Audience Audience controls **who can accept the token** — which resource servers are authorized to consume it. It is a contract between the token issuer and the downstream services. `demo-app` has an audience mapper configured for `demo-backend`. Every token issued to `demo-app` carries `aud=demo-backend`, regardless of scope. **Inspect audience in the token** ```bash RESPONSE=$(curl -s -X POST $KC/token \ -d "grant_type=password&client_id=$CLIENT&username=$USER&password=$PASS") ACCESS_TOKEN=$(echo $RESPONSE | jq -r .access_token) echo $ACCESS_TOKEN | cut -d. -f2 \ | tr -- '-_' '+/' \ | awk '{l=length($0)%4; if(l==2) print $0"=="; else if(l==3) print $0"="; else print $0}' \ | base64 -d | jq '{aud, scope}' ``` Expected: `"aud": ["demo-backend", "account"]` **What the resource server validates** When `demo-backend` receives a token, it must verify that its own client ID appears in `aud`. If not, it rejects the request even if the token is otherwise valid (valid signature, not expired, correct scope). ```bash # Introspection: demo-backend validates the token and checks aud internally curl -s -X POST $KC/token/introspect \ -u "$CLIENT_BACKEND:$BACKEND_SECRET" \ -d "token=$ACCESS_TOKEN" | jq '{active, aud, scope}' ``` **PKCE protects token issuance. Audience protects token usage.** A token intercepted after issuance can only be replayed against services listed in `aud` — nowhere else. --- ## 8. Decode the access token ```bash echo $ACCESS_TOKEN | cut -d. -f2 \ | tr -- '-_' '+/' \ | awk '{l=length($0)%4; if(l==2) print $0"=="; else if(l==3) print $0"="; else print $0}' \ | base64 -d | jq . ``` Notable claims: `sub`, `preferred_username`, `realm_access.roles`, `exp`, `iat`, `iss`. --- ## 9. Userinfo endpoint ```bash curl -s $KC/userinfo \ -H "Authorization: Bearer $ACCESS_TOKEN" | jq . ``` --- ## 10. Token introspection > Public clients cannot call this endpoint. Introspection is reserved for confidential clients — called by backend APIs to validate tokens they receive. ```bash curl -s -X POST $KC/token/introspect \ -H "Content-Type: application/x-www-form-urlencoded" \ -u "$CLIENT_BACKEND:$BACKEND_SECRET" \ -d "token=$ACCESS_TOKEN" | jq . ``` Check `"active": true` in the response. --- ## 11. Refresh the token ```bash RESPONSE=$(curl -s -X POST $KC/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=refresh_token&client_id=$CLIENT&refresh_token=$REFRESH_TOKEN") echo $RESPONSE | jq . ACCESS_TOKEN=$(echo $RESPONSE | jq -r .access_token) REFRESH_TOKEN=$(echo $RESPONSE | jq -r .refresh_token) ``` --- ## 12. Logout (token revocation) ```bash curl -s -X POST $KC/logout \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "client_id=$CLIENT&refresh_token=$REFRESH_TOKEN" ``` Verify the token is revoked by attempting a refresh — it should return `invalid_grant`. --- ## 13. JWKS — public keys ```bash curl -s http://localhost:8080/realms/$REALM/protocol/openid-connect/certs | jq . ``` --- ## 14. Admin API — list users ```bash ADMIN_TOKEN=$(curl -s -X POST \ http://localhost:8080/realms/master/protocol/openid-connect/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=password&client_id=admin-cli&username=admin&password=$KEYCLOAK_ADMIN_PASSWORD" \ | jq -r .access_token) curl -s http://localhost:8080/admin/realms/$REALM/users \ -H "Authorization: Bearer $ADMIN_TOKEN" | jq . ```