commit 31e6b1d6d713b5ed6003f7a94691bf9973dac3ea Author: sttlab Date: Thu May 7 21:08:39 2026 +0200 first commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..8e19690 Binary files /dev/null and b/.DS_Store differ diff --git a/OIDC.md b/OIDC.md new file mode 100644 index 0000000..da3ccb3 --- /dev/null +++ b/OIDC.md @@ -0,0 +1,320 @@ +# 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 . +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..88cdb2a --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# pm-keycloak + +Keycloak deployment with declarative realm configuration via keycloak-config-cli. + +## Stack + +- **Keycloak 26.5.4** — identity and access management +- **PostgreSQL 16** — persistent storage +- **keycloak-config-cli 6.5.0** — declarative realm configuration + +## Installation + +### Prerequisites + +- Docker and Docker Compose + +### Setup + +```bash +cd compose +cp .env.example .env +``` + +Edit `.env` with your credentials: + +```env +COMPOSE_PROJECT_NAME=pm-keycloak +KC_DB_PASSWORD= +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD= +``` + +### Start + +```bash +docker compose up -d +``` + +Keycloak is available at http://localhost:8080. + +keycloak-config-cli runs once at startup, applies all realm configuration files, then exits. This is expected behavior. + +## Configuration + +Realm configuration files live in `compose/keycloak-config/`. Each `.yaml` file maps to one realm. + +### Apply configuration changes + +After editing a realm file: + +```bash +docker compose run --rm keycloak-config-cli +``` + +### File structure + +``` +compose/keycloak-config/ +├── master-realm.yaml # minimal patch of the master realm +└── demo-realm.yaml # example realm with roles and clients +``` + +### Managed mode + +`IMPORT_MANAGED_REALM: full` is set, meaning keycloak-config-cli is the source of truth for each realm it manages. Anything not declared in a YAML file will be removed from Keycloak on the next apply. diff --git a/compose/.DS_Store b/compose/.DS_Store new file mode 100644 index 0000000..6156022 Binary files /dev/null and b/compose/.DS_Store differ diff --git a/compose/.env.example b/compose/.env.example new file mode 100644 index 0000000..27583aa --- /dev/null +++ b/compose/.env.example @@ -0,0 +1,9 @@ +COMPOSE_PROJECT_NAME=pm-keycloak +KC_DB_PASSWORD=keycloak +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=admin + +# Client secrets (keycloak-config-cli) +DEMO_BACKEND_SECRET=change-me +DEMO_USER_PASSWORD=change-me + diff --git a/compose/.gitignore b/compose/.gitignore new file mode 100644 index 0000000..e408f6c --- /dev/null +++ b/compose/.gitignore @@ -0,0 +1,2 @@ +.env +postgres_data/ diff --git a/compose/docker-compose.yml b/compose/docker-compose.yml new file mode 100644 index 0000000..c5cad9f --- /dev/null +++ b/compose/docker-compose.yml @@ -0,0 +1,53 @@ +services: + keycloak: + image: quay.io/keycloak/keycloak:26.5.4 + command: start-dev + environment: + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: ${KC_DB_PASSWORD:-keycloak} + KC_HOSTNAME_STRICT: "false" + KC_HTTP_PORT: 8080 + KC_HEALTH_ENABLED: "true" + KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} + ports: + - "8080:8080" + networks: + - compose + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q 'UP'"] + interval: 15s + timeout: 10s + retries: 10 + start_period: 30s + + keycloak-config-cli: + image: public.ecr.aws/bitnami/keycloak-config-cli:latest + platform: linux/amd64 + environment: + KEYCLOAK_URL: http://keycloak:8080 + KEYCLOAK_USER: ${KEYCLOAK_ADMIN:-admin} + KEYCLOAK_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} + KEYCLOAK_AVAILABILITYCHECK_ENABLED: "true" + KEYCLOAK_AVAILABILITYCHECK_TIMEOUT: 120s + IMPORT_FILES_LOCATIONS: /config/* + IMPORT_MANAGED_REALM: full + DEMO_BACKEND_SECRET: ${DEMO_BACKEND_SECRET} + DEMO_USER_PASSWORD: ${DEMO_USER_PASSWORD} + BACKLOG_AGENT_SECRET: ${BACKLOG_AGENT_SECRET} + A2A_GATEWAY_SECRET: ${A2A_GATEWAY_SECRET} + LLM_GATEWAY_SECRET: ${LLM_GATEWAY_SECRET} + TOOLS_GATEWAY_SECRET: ${TOOLS_GATEWAY_SECRET} + volumes: + - ./keycloak-config:/config:ro + networks: + - compose + depends_on: + keycloak: + condition: service_healthy + +networks: + compose: + external: true diff --git a/compose/keycloak-config/demo-realm.yaml b/compose/keycloak-config/demo-realm.yaml new file mode 100644 index 0000000..b358e12 --- /dev/null +++ b/compose/keycloak-config/demo-realm.yaml @@ -0,0 +1,99 @@ +realm: demo +displayName: Demo +enabled: true +registrationAllowed: false +loginWithEmailAllowed: true +duplicateEmailsAllowed: false +resetPasswordAllowed: true +editUsernameAllowed: false +bruteForceProtected: true + +clientScopes: + - name: app:read + description: Read access to application resources + protocol: openid-connect + - name: app:write + description: Write access to application resources + protocol: openid-connect + +roles: + realm: + - name: app-user + description: Standard application user + - name: app-admin + description: Application administrator + +clients: + - clientId: demo-app + name: Demo Application + enabled: true + protocol: openid-connect + publicClient: true + standardFlowEnabled: true + directAccessGrantsEnabled: true + serviceAccountsEnabled: false + redirectUris: + - "http://localhost:3000/*" + webOrigins: + - "http://localhost:3000" + defaultClientScopes: + - web-origins + - acr + - profile + - roles + - email + optionalClientScopes: + - app:read + - app:write + protocolMappers: + - name: demo-backend-audience + protocol: openid-connect + protocolMapper: oidc-audience-mapper + config: + included.client.audience: demo-backend + access.token.claim: "true" + + - clientId: demo-app-pkce + name: Demo Application (PKCE) + enabled: true + protocol: openid-connect + publicClient: true + standardFlowEnabled: true + directAccessGrantsEnabled: false + serviceAccountsEnabled: false + attributes: + pkce.code.challenge.method: S256 + redirectUris: + - "http://localhost:3000/*" + webOrigins: + - "http://localhost:3000" + defaultClientScopes: + - web-origins + - acr + - profile + - roles + - email + + - clientId: demo-backend + name: Demo Backend + enabled: true + protocol: openid-connect + publicClient: false + standardFlowEnabled: false + directAccessGrantsEnabled: false + serviceAccountsEnabled: true + secret: $(env:DEMO_BACKEND_SECRET) + +users: + - username: demo-user + email: demo@example.com + firstName: Demo + lastName: User + enabled: true + emailVerified: true + credentials: + - type: password + value: $(env:DEMO_USER_PASSWORD) + temporary: false + realmRoles: + - app-user diff --git a/compose/keycloak-config/master-realm.yaml b/compose/keycloak-config/master-realm.yaml new file mode 100644 index 0000000..31b4afa --- /dev/null +++ b/compose/keycloak-config/master-realm.yaml @@ -0,0 +1,3 @@ +# Minimal master realm patch — do not remove critical built-in elements +realm: master +displayName: Master