Files
2026-05-07 21:08:39 +02:00

9.4 KiB

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

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

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.

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

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

# 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

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

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.

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.

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

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

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 scopeapp:read is absent from the token:

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

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).

# 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

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

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.

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

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)

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

curl -s http://localhost:8080/realms/$REALM/protocol/openid-connect/certs | jq .

14. Admin API — list users

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 .