Skip to content

Commit e382c73

Browse files
committed
feat(lab-04): Keycloak SSO hub — OIDC/SAML clients, ROPC, refresh, introspection, MailHog
1 parent 6528614 commit e382c73

3 files changed

Lines changed: 230 additions & 87 deletions

File tree

.github/workflows/ci.yml

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,4 +181,34 @@ jobs:
181181

182182
- name: Cleanup
183183
if: always()
184-
run: docker compose -f docker/docker-compose.advanced.yml down -v
184+
run: docker compose -f docker/docker-compose.advanced.yml down -v
185+
186+
lab-04-smoke:
187+
name: Lab 04 — Full OIDC/SAML Hub (ROPC, refresh, introspection, SAML)
188+
runs-on: ubuntu-latest
189+
needs: validate
190+
continue-on-error: true
191+
steps:
192+
- uses: actions/checkout@v4
193+
194+
- name: Install tools
195+
run: sudo apt-get install -y netcat-openbsd curl
196+
197+
- name: Start SSO stack (keycloak + db + oidc-test-app + mailhog)
198+
run: docker compose -f docker/docker-compose.sso.yml up -d
199+
200+
- name: Wait for Keycloak
201+
run: timeout 200 bash -c 'until curl -sf http://localhost:8080/health/ready | grep -q UP; do sleep 5; done'
202+
203+
- name: Run Lab 02-04 test script
204+
env:
205+
KC_PASS: "Lab04Password!"
206+
run: bash tests/labs/test-lab-02-04.sh
207+
208+
- name: Collect logs on failure
209+
if: failure()
210+
run: docker compose -f docker/docker-compose.sso.yml logs
211+
212+
- name: Cleanup
213+
if: always()
214+
run: docker compose -f docker/docker-compose.sso.yml down -v

docker/docker-compose.sso.yml

Lines changed: 64 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,76 @@
1-
# Lab 04 — SSO Integration: keycloak with Keycloak OIDC authentication
2-
---
31
services:
4-
keycloak:
5-
image: quay.io/keycloak/keycloak:24
6-
container_name: it-stack-keycloak
7-
restart: unless-stopped
8-
ports:
9-
- "8080:$firstPort"
2+
3+
# ── Keycloak DB (internal) ─────────────────────────────────────────
4+
keycloak-db:
5+
image: postgres:16-alpine
106
environment:
11-
- IT_STACK_ENV=lab-04-sso
12-
- KEYCLOAK_URL=
13-
- KEYCLOAK_REALM=
14-
- KEYCLOAK_CLIENT_ID=keycloak
15-
- KEYCLOAK_CLIENT_SECRET=
7+
POSTGRES_DB: keycloak
8+
POSTGRES_USER: kcadmin
9+
POSTGRES_PASSWORD: Lab04Password!
1610
networks:
17-
- it-stack-net
11+
- kc-db-net
12+
volumes:
13+
- kc-db-sso:/var/lib/postgresql/data
14+
healthcheck:
15+
test: ["CMD-SHELL", "pg_isready -U kcadmin -d keycloak"]
16+
interval: 5s
17+
timeout: 3s
18+
retries: 20
1819

19-
# Local Keycloak reference instance for SSO lab (replace with lab-id1 in real env)
20-
keycloak-sso:
21-
image: quay.io/keycloak/keycloak:24
22-
container_name: it-stack-keycloak-sso
20+
# ── Keycloak SSO hub ───────────────────────────────────────────────
21+
keycloak:
22+
image: quay.io/keycloak/keycloak:24.0
2323
command: start-dev
24+
depends_on:
25+
keycloak-db:
26+
condition: service_healthy
2427
environment:
25-
KEYCLOAK_ADMIN: admin
26-
KEYCLOAK_ADMIN_PASSWORD: admin
28+
KC_BOOTSTRAP_ADMIN_USERNAME: admin
29+
KC_BOOTSTRAP_ADMIN_PASSWORD: Lab04Password!
30+
KC_DB: postgres
31+
KC_DB_URL: "jdbc:postgresql://keycloak-db:5432/keycloak"
32+
KC_DB_USERNAME: kcadmin
33+
KC_DB_PASSWORD: Lab04Password!
34+
KC_HTTP_PORT: "8080"
35+
KC_HOSTNAME_STRICT: "false"
36+
KC_PROXY: edge
37+
# Enable all required features for lab
38+
KC_FEATURES: token-exchange,admin-fine-grained-authz
2739
ports:
2840
- "8080:8080"
2941
networks:
30-
- it-stack-net
42+
- kc-app-net
43+
- kc-db-net
44+
healthcheck:
45+
test: ["CMD-SHELL", "curl -sf http://localhost:8080/health/ready || exit 1"]
46+
interval: 10s
47+
timeout: 5s
48+
retries: 30
49+
start_period: 60s
50+
51+
# ── Test OIDC client app (simulates resource server) ──────────────
52+
oidc-test-app:
53+
image: traefik/whoami:latest
54+
networks:
55+
- kc-app-net
56+
ports:
57+
- "9000:80"
58+
59+
# ── MailHog for email verification flows ──────────────────────────
60+
mailhog:
61+
image: mailhog/mailhog:latest
62+
ports:
63+
- "1025:1025"
64+
- "8025:8025"
65+
networks:
66+
- kc-app-net
3167

3268
networks:
33-
it-stack-net:
69+
kc-app-net:
70+
driver: bridge
71+
kc-db-net:
3472
driver: bridge
73+
internal: true
74+
75+
volumes:
76+
kc-db-sso:

tests/labs/test-lab-02-04.sh

Lines changed: 135 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,142 @@
11
#!/usr/bin/env bash
2-
# test-lab-02-04.sh — Lab 02-04: SSO Integration
3-
# Module 02: Keycloak OAuth2/OIDC/SAML SSO provider
4-
# keycloak with Keycloak OIDC/SAML authentication
2+
# test-lab-02-04.sh — Keycloak Lab 04: Full OIDC/SAML SSO Hub
3+
# Tests: realm config, OIDC client flows, SAML metadata, ROPC grant,
4+
# refresh tokens, JWT decode, token introspection, MailHog email
55
set -euo pipefail
66

7-
LAB_ID="02-04"
8-
LAB_NAME="SSO Integration"
9-
MODULE="keycloak"
10-
COMPOSE_FILE="docker/docker-compose.sso.yml"
11-
PASS=0
12-
FAIL=0
13-
14-
# ── Colors ────────────────────────────────────────────────────────────────────
15-
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
16-
CYAN='\033[0;36m'; NC='\033[0m'
17-
18-
pass() { echo -e "${GREEN}[PASS]${NC} $1"; ((PASS++)); }
19-
fail() { echo -e "${RED}[FAIL]${NC} $1"; ((FAIL++)); }
20-
info() { echo -e "${CYAN}[INFO]${NC} $1"; }
21-
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
22-
23-
echo -e "${CYAN}======================================${NC}"
24-
echo -e "${CYAN} Lab ${LAB_ID}: ${LAB_NAME}${NC}"
25-
echo -e "${CYAN} Module: ${MODULE}${NC}"
26-
echo -e "${CYAN}======================================${NC}"
27-
echo ""
28-
29-
# ── PHASE 1: Setup ────────────────────────────────────────────────────────────
30-
info "Phase 1: Setup"
31-
docker compose -f "${COMPOSE_FILE}" up -d
32-
info "Waiting 30s for ${MODULE} to initialize..."
33-
sleep 30
34-
35-
# ── PHASE 2: Health Checks ────────────────────────────────────────────────────
36-
info "Phase 2: Health Checks"
37-
38-
if docker compose -f "${COMPOSE_FILE}" ps | grep -q "running\|Up"; then
39-
pass "Container is running"
7+
PASS=0; FAIL=0
8+
KC_PASS="${KC_PASS:-Lab04Password!}"
9+
KC_URL="http://localhost:8080"
10+
REALM="it-stack"
11+
12+
pass() { ((++PASS)); echo " [PASS] $1"; }
13+
fail() { ((++FAIL)); echo " [FAIL] $1"; }
14+
warn() { echo " [WARN] $1"; }
15+
header(){ echo; echo "=== $1 ==="; }
16+
17+
kc_token() {
18+
curl -sf -X POST "$KC_URL/realms/master/protocol/openid-connect/token" \
19+
-d "client_id=admin-cli&grant_type=password&username=admin&password=${KC_PASS}" \
20+
| grep -o '"access_token":"[^"]*"' | cut -d'"' -f4
21+
}
22+
23+
header "1. Keycloak Health"
24+
if curl -sf "$KC_URL/health/ready" | grep -q '"status":"UP"'; then
25+
pass "Keycloak /health/ready UP"
26+
else
27+
fail "Keycloak not ready"; exit 1
28+
fi
29+
30+
header "2. Admin Authentication"
31+
TOKEN=$(kc_token)
32+
[[ -n "$TOKEN" ]] && pass "Admin token from master realm" || { fail "Admin auth failed"; exit 1; }
33+
34+
header "3. Realm Creation + Config"
35+
curl -sf -X POST "$KC_URL/admin/realms" \
36+
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
37+
-d "{\"realm\":\"$REALM\",\"enabled\":true,\"displayName\":\"IT-Stack\",
38+
\"bruteForceProtected\":true,\"ssoSessionMaxLifespan\":86400}" \
39+
-o /dev/null && pass "Realm '$REALM' created" || warn "Realm may exist"
40+
41+
TOKEN=$(kc_token)
42+
REALM_INFO=$(curl -sf "$KC_URL/admin/realms/$REALM" -H "Authorization: Bearer $TOKEN")
43+
echo "$REALM_INFO" | grep -q '"enabled":true' && pass "Realm is enabled" || fail "Realm not enabled"
44+
echo "$REALM_INFO" | grep -q '"bruteForceProtected":true' && pass "Brute force protection enabled" || fail "Brute force not enabled"
45+
46+
header "4. OIDC Client (confidential + service account + ROPC)"
47+
TOKEN=$(kc_token)
48+
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$KC_URL/admin/realms/$REALM/clients" \
49+
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
50+
-d "{\"clientId\":\"oidc-client\",\"secret\":\"$KC_PASS\",\"publicClient\":false,
51+
\"serviceAccountsEnabled\":true,\"directAccessGrantsEnabled\":true,
52+
\"redirectUris\":[\"http://localhost:9000/*\"],\"enabled\":true}")
53+
[[ "$STATUS" =~ ^(201|409)$ ]] && pass "OIDC client 'oidc-client' ready (HTTP $STATUS)" || fail "OIDC client failed (HTTP $STATUS)"
54+
55+
header "5. SAML Client Registration"
56+
TOKEN=$(kc_token)
57+
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$KC_URL/admin/realms/$REALM/clients" \
58+
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
59+
-d "{\"clientId\":\"saml-client\",\"protocol\":\"saml\",
60+
\"redirectUris\":[\"http://localhost:9001/*\"],\"enabled\":true}")
61+
[[ "$STATUS" =~ ^(201|409)$ ]] && pass "SAML client 'saml-client' ready (HTTP $STATUS)" || fail "SAML client failed (HTTP $STATUS)"
62+
63+
header "6. Test User Creation"
64+
TOKEN=$(kc_token)
65+
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$KC_URL/admin/realms/$REALM/users" \
66+
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
67+
-d "{\"username\":\"labuser\",\"enabled\":true,\"email\":\"labuser@lab.local\",
68+
\"emailVerified\":true,\"firstName\":\"Lab\",\"lastName\":\"User\",
69+
\"credentials\":[{\"type\":\"password\",\"value\":\"$KC_PASS\",\"temporary\":false}]}")
70+
[[ "$STATUS" =~ ^(201|409)$ ]] && pass "User 'labuser' ready (HTTP $STATUS)" || fail "User creation failed (HTTP $STATUS)"
71+
72+
header "7. Client Credentials Grant (OAuth2 M2M)"
73+
SA_TOKEN=$(curl -sf -X POST "$KC_URL/realms/$REALM/protocol/openid-connect/token" \
74+
-d "client_id=oidc-client&client_secret=${KC_PASS}&grant_type=client_credentials" \
75+
| grep -o '"access_token":"[^"]*"' | cut -d'"' -f4)
76+
[[ -n "$SA_TOKEN" ]] && pass "Client credentials grant: access token obtained" || fail "Client credentials grant failed"
77+
78+
header "8. JWT Structure + Claims"
79+
IFS='.' read -ra P <<< "$SA_TOKEN"
80+
[[ "${#P[@]}" -eq 3 ]] && pass "JWT has 3 parts (header.payload.sig)" || fail "Invalid JWT structure"
81+
if [[ "${#P[@]}" -eq 3 ]]; then
82+
PAD=$(( 4 - ${#P[1]} % 4 )); [[ "$PAD" -lt 4 ]] && P[1]+=$(printf '%0.s=' $(seq 1 $PAD))
83+
PAYLOAD=$(echo "${P[1]}" | base64 -d 2>/dev/null || true)
84+
for claim in iss exp iat; do
85+
echo "$PAYLOAD" | grep -q "\"$claim\"" && pass "JWT claim '$claim' present" || fail "JWT missing '$claim'"
86+
done
87+
fi
88+
89+
header "9. Resource Owner Password Credentials (user login)"
90+
ROPC=$(curl -sf -X POST "$KC_URL/realms/$REALM/protocol/openid-connect/token" \
91+
-d "client_id=oidc-client&client_secret=${KC_PASS}&grant_type=password&username=labuser&password=${KC_PASS}" \
92+
2>/dev/null || echo "{}")
93+
echo "$ROPC" | grep -q '"access_token"' && pass "ROPC grant: user token obtained" || fail "ROPC grant failed"
94+
REFRESH=$(echo "$ROPC" | grep -o '"refresh_token":"[^"]*"' | cut -d'"' -f4 || true)
95+
96+
header "10. Token Refresh"
97+
if [[ -n "$REFRESH" ]]; then
98+
REFRESHED=$(curl -sf -X POST "$KC_URL/realms/$REALM/protocol/openid-connect/token" \
99+
-d "client_id=oidc-client&client_secret=${KC_PASS}&grant_type=refresh_token&refresh_token=${REFRESH}" \
100+
| grep -o '"access_token":"[^"]*"' | cut -d'"' -f4 || true)
101+
[[ -n "$REFRESHED" ]] && pass "Token refresh succeeded" || fail "Token refresh failed"
40102
else
41-
fail "Container is not running"
103+
fail "No refresh token to test"
42104
fi
43105

44-
# ── PHASE 3: Functional Tests ─────────────────────────────────────────────────
45-
info "Phase 3: Functional Tests (Lab 04 — SSO Integration)"
46-
47-
# TODO: Add module-specific functional tests here
48-
# Example:
49-
# if curl -sf http://localhost:8080/health > /dev/null 2>&1; then
50-
# pass "Health endpoint responds"
51-
# else
52-
# fail "Health endpoint not reachable"
53-
# fi
54-
55-
warn "Functional tests for Lab 02-04 pending implementation"
56-
57-
# ── PHASE 4: Cleanup ──────────────────────────────────────────────────────────
58-
info "Phase 4: Cleanup"
59-
docker compose -f "${COMPOSE_FILE}" down -v --remove-orphans
60-
info "Cleanup complete"
61-
62-
# ── Results ───────────────────────────────────────────────────────────────────
63-
echo ""
64-
echo -e "${CYAN}======================================${NC}"
65-
echo -e " Lab ${LAB_ID} Complete"
66-
echo -e " ${GREEN}PASS: ${PASS}${NC} | ${RED}FAIL: ${FAIL}${NC}"
67-
echo -e "${CYAN}======================================${NC}"
68-
69-
if [ "${FAIL}" -gt 0 ]; then
70-
exit 1
106+
header "11. Token Introspection"
107+
TOKEN=$(kc_token)
108+
INTRO=$(curl -sf -X POST "$KC_URL/realms/$REALM/protocol/openid-connect/token/introspect" \
109+
-u "oidc-client:${KC_PASS}" -d "token=${SA_TOKEN}" | grep -o '"active":[a-z]*')
110+
echo "$INTRO" | grep -q '"active":true' && pass "Token introspection: active=true" || fail "Token not active"
111+
112+
header "12. OIDC Discovery"
113+
DISC=$(curl -sf "$KC_URL/realms/$REALM/.well-known/openid-configuration")
114+
for f in token_endpoint authorization_endpoint jwks_uri userinfo_endpoint introspection_endpoint; do
115+
echo "$DISC" | grep -q "\"$f\"" && pass "Discovery: $f present" || fail "Discovery missing $f"
116+
done
117+
118+
header "13. SAML Metadata Endpoint"
119+
SAML_META=$(curl -sf "$KC_URL/realms/$REALM/protocol/saml/descriptor")
120+
echo "$SAML_META" | grep -q "EntityDescriptor\|IDPSSODescriptor" \
121+
&& pass "SAML metadata XML returned" || fail "SAML metadata not available"
122+
123+
header "14. Client List (verify both OIDC + SAML present)"
124+
TOKEN=$(kc_token)
125+
CLIENTS=$(curl -sf "$KC_URL/admin/realms/$REALM/clients" -H "Authorization: Bearer $TOKEN")
126+
echo "$CLIENTS" | grep -q '"oidc-client"' && pass "OIDC client visible in realm" || fail "OIDC client not found"
127+
echo "$CLIENTS" | grep -q '"saml-client"' && pass "SAML client visible in realm" || fail "SAML client not found"
128+
129+
header "15. MailHog Email Sink"
130+
if curl -sf http://localhost:8025/ -o /dev/null; then
131+
pass "MailHog UI accessible (:8025)"
132+
curl -sf http://localhost:8025/api/v2/messages | grep -q '"total"' \
133+
&& pass "MailHog API v2 messages endpoint works" || fail "MailHog API failed"
134+
else
135+
fail "MailHog not accessible"
71136
fi
137+
138+
echo
139+
echo "═══════════════════════════════════════"
140+
echo " Lab 02-04 Results: $PASS passed, $FAIL failed"
141+
echo "═══════════════════════════════════════"
142+
[[ "$FAIL" -eq 0 ]]

0 commit comments

Comments
 (0)