From 31e6b1d6d713b5ed6003f7a94691bf9973dac3ea Mon Sep 17 00:00:00 2001 From: sttlab Date: Thu, 7 May 2026 21:08:39 +0200 Subject: [PATCH] first commit --- .DS_Store | Bin 0 -> 8196 bytes OIDC.md | 320 ++++++++++++++++++++++ README.md | 65 +++++ compose/.DS_Store | Bin 0 -> 6148 bytes compose/.env.example | 9 + compose/.gitignore | 2 + compose/docker-compose.yml | 53 ++++ compose/keycloak-config/demo-realm.yaml | 99 +++++++ compose/keycloak-config/master-realm.yaml | 3 + 9 files changed, 551 insertions(+) create mode 100644 .DS_Store create mode 100644 OIDC.md create mode 100644 README.md create mode 100644 compose/.DS_Store create mode 100644 compose/.env.example create mode 100644 compose/.gitignore create mode 100644 compose/docker-compose.yml create mode 100644 compose/keycloak-config/demo-realm.yaml create mode 100644 compose/keycloak-config/master-realm.yaml diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..8e196906942454016dc5bb023bb288c234dc3d1b GIT binary patch literal 8196 zcmeHMTWl0n7(V~Buro}6Q!X;df-6-nwSlc5a#7fB7o^+@-IiV`l-Zq;PMDopcV>6N zimp#y0AEbdXbg!OiAH@emUxTuLJXSlKoG?k^~DDwKKMe^2jhRvnbxvxA4t?doRiG? z&pH49pUb!3KWG14LI@;_MiU_#Awi zS{&vD+n5J%pI9IhfvgD0U2#s4JwV_JL5cyw9rZD0jxrI*ijcycfN&=etPDYh0(^D! zi*a)T2_eHeL?A?9ECMWdkB}^8?m3bloxi&o)C^_vw&nTp_$82PY9~#OM3jgc*_$44 z%V}Tti(XPM>|wnQ*R?alZ9TKwGRysRR*UQSy5*P!?qC@vn)G#9j^UP*ZqYG3?wb}0 zMUj<$I(u+1-WZD?X^agvtz3!C)vMMVIikq3>zA)}hg9QP@43JBf=WNOu^o*sK7 z!^_$k+#++SI<0-6DV=q5c`-@D+HowrQcK2Wc+{*J#^c)n4_TR_s#Dd@-Cf;1z5SFH zJ+s}kb>AwPDcv`{emdPlcv`crv$J595SFg2YZr44YU;AOoh_oV*=*Zs>rr8yEx5M5 z)AcOha-E)T-#p|q9h>MnRd=uF7W@`IS+|Ar3#%DyjkY{<_I8^E52d2KJ8e$%&bu3y zuUxaf<)LjmFHWa*GiJ_G)m}v2@vVm~v(Q<#41b_U_p+vASk8g2lI2+$+w5@koJmh9 z%EcM9Zn`#CnVLsLT1)ADt;5oLt2Aj;FL$bm(3m0hG9;Rt2m{=RK z%!d~(*3=FUPet{>@Sa9h?Kp_cpdK#8_7`tbwN6{lm^L*O;db9DRqHB=L(#3l;M#Sn z){P%v4o~9ML1QqyA)#se*vi{<&u`0{4({qBT%Mm|fwoDOdziM_FieB5x4Bi*dc6U? zV78T!vGhW)*X=mYKSk8Axi%V>?-l~(F0Q1OQ?f7|5Ni~nN%B4!BB#k&a-Li!KaeZrS8|Q~Mt&!M zKn>KwWQYKTxv&V9z*1;{6|eyk&U_ zDXCRz7yFlnV{*^Pd3Zz0qNol$uY^Anvapign;@(H65Yk>QW;}~P8Qw9=rS3jlS;g+Hbqw`7=yG%bQ`1hD>4PqS*t#vU^LQN(cPdn zEBGFugmj@DjWXufQ8{3f_db;B6R!)9?X&2p=Q! z&%$~58oq;{s`$L)dOpLov3$ z{rIrL>?%TX7p#~G8|Udb%8@?|u^kI^i9l9_L=Bez{D**l?rU{;|A+Vg-)ZwLcK`nc Dw_&#T literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..615602260c5f1e3c04d3b9c7fd68af45d6af6b55 GIT binary patch literal 6148 zcmeHKK~BR!475v;T5+I9j{5-){lQX&7xV*=1PUT$DHj-q|>wY<9MaYa-&=V>>095YY%OI2dF2#N@ts&juDb1af&sM~N=!j!voRWqaT+ zDj;Wfj1#+}C!E>l`6ariYr3UH`_OcCN}as^j?`{3oi}N@(w&Z9cQcl$s_HJ)$i#0f zKfk=coqdPZ{(!Z**?hZzW!zi=SHKn6PX$o3#YRVpKDq*~fGe<8K)w$FE*K57V)=An zC@lbRh;T8?rI!#+FpP#-5qDs$RG?DXS`1b?;=%HwVOCT+u{9rTE3u$oshY{_bCZ*x*>1N;ImCV5$LO~Is; gV&qCGK7kiQJjetX4YML5F#jV^WbnZi_*Dfy0Cc}W;s5{u literal 0 HcmV?d00001 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