Skip to content

Commit dbf583c

Browse files
committed
feat(lab-04): Traefik SSO — ForwardAuth middleware via Keycloak + oauth2-proxy
1 parent 5cdcc62 commit dbf583c

3 files changed

Lines changed: 234 additions & 76 deletions

File tree

.github/workflows/ci.yml

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,4 +174,37 @@ jobs:
174174

175175
- name: Cleanup
176176
if: always()
177-
run: docker compose -f docker/docker-compose.advanced.yml down -v
177+
run: docker compose -f docker/docker-compose.advanced.yml down -v
178+
179+
lab-04-smoke:
180+
name: Lab 04 — Traefik ForwardAuth SSO via Keycloak
181+
runs-on: ubuntu-latest
182+
needs: validate
183+
continue-on-error: true
184+
steps:
185+
- uses: actions/checkout@v4
186+
187+
- name: Install tools
188+
run: sudo apt-get install -y netcat-openbsd curl
189+
190+
- name: Start SSO stack (KC + Traefik + oauth2-proxy + whoami services)
191+
run: docker compose -f docker/docker-compose.sso.yml up -d
192+
193+
- name: Wait for Keycloak
194+
run: timeout 200 bash -c 'until curl -sf http://localhost:8080/health/ready | grep -q UP; do sleep 5; done'
195+
196+
- name: Wait for Traefik
197+
run: timeout 60 bash -c 'until curl -sf http://localhost:8080/api/version | grep -q Version; do sleep 3; done'
198+
199+
- name: Run Lab 18-04 test script
200+
env:
201+
KC_PASS: "Lab04Password!"
202+
run: bash tests/labs/test-lab-18-04.sh
203+
204+
- name: Collect logs on failure
205+
if: failure()
206+
run: docker compose -f docker/docker-compose.sso.yml logs
207+
208+
- name: Cleanup
209+
if: always()
210+
run: docker compose -f docker/docker-compose.sso.yml down -v

docker/docker-compose.sso.yml

Lines changed: 114 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,127 @@
1-
# Lab 04 — SSO Integration: traefik with Keycloak OIDC authentication
2-
---
31
services:
4-
traefik:
5-
image: traefik:v3.0
6-
container_name: it-stack-traefik
7-
restart: unless-stopped
8-
ports:
9-
- "80:$firstPort"
2+
3+
# ── Keycloak backing DB (internal) ────────────────────────────────
4+
kc-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=traefik
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 for SSO lab (replace with lab-id1 in real env)
20+
# ── Keycloak SSO provider ──────────────────────────────────────────
2021
keycloak:
21-
image: quay.io/keycloak/keycloak:24
22-
container_name: it-stack-traefik-keycloak
22+
image: quay.io/keycloak/keycloak:24.0
2323
command: start-dev
24+
depends_on:
25+
kc-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://kc-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+
ports:
38+
- "8080:8080"
39+
networks:
40+
- proxy-sso-net
41+
- kc-db-net
42+
healthcheck:
43+
test: ["CMD-SHELL", "curl -sf http://localhost:8080/health/ready || exit 1"]
44+
interval: 10s
45+
timeout: 5s
46+
retries: 30
47+
start_period: 60s
48+
49+
# ── oauth2-proxy: Traefik forwardAuth backend ──────────────────────
50+
oauth2-proxy:
51+
image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
52+
depends_on:
53+
keycloak:
54+
condition: service_healthy
55+
command:
56+
- --provider=oidc
57+
- --oidc-issuer-url=http://keycloak:8080/realms/it-stack
58+
- --client-id=oauth2-proxy
59+
- --client-secret=Lab04Password!
60+
- --cookie-secret=YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4
61+
- --email-domain=*
62+
- --upstream=static://202
63+
- --http-address=0.0.0.0:4180
64+
- --redirect-url=http://localhost:4180/oauth2/callback
65+
- --cookie-secure=false
66+
- --skip-jwt-bearer-tokens=true
67+
networks:
68+
- proxy-sso-net
69+
labels:
70+
- "traefik.enable=true"
71+
- "traefik.http.routers.oauth2.rule=PathPrefix(`/oauth2`)"
72+
- "traefik.http.routers.oauth2.entrypoints=web"
73+
- "traefik.http.services.oauth2.loadbalancer.server.port=4180"
74+
75+
# ── Traefik reverse proxy ──────────────────────────────────────────
76+
traefik:
77+
image: traefik:v3.0
78+
command:
79+
- --api.dashboard=true
80+
- --api.insecure=true
81+
- --entrypoints.web.address=:80
82+
- --providers.docker=true
83+
- --providers.docker.exposedbydefault=false
2784
ports:
85+
- "80:80"
2886
- "8080:8080"
87+
volumes:
88+
- /var/run/docker.sock:/var/run/docker.sock:ro
89+
networks:
90+
- proxy-sso-net
91+
depends_on:
92+
keycloak:
93+
condition: service_healthy
94+
95+
# ── Protected whoami (requires SSO) ──────────────────────────────
96+
whoami-protected:
97+
image: traefik/whoami:latest
2998
networks:
30-
- it-stack-net
99+
- proxy-sso-net
100+
labels:
101+
- "traefik.enable=true"
102+
- "traefik.http.routers.whoami-protected.rule=PathPrefix(`/protected`)"
103+
- "traefik.http.routers.whoami-protected.entrypoints=web"
104+
- "traefik.http.routers.whoami-protected.middlewares=forward-auth"
105+
- "traefik.http.middlewares.forward-auth.forwardauth.address=http://oauth2-proxy:4180"
106+
- "traefik.http.middlewares.forward-auth.forwardauth.trustForwardHeader=true"
107+
- "traefik.http.middlewares.forward-auth.forwardauth.authResponseHeaders=X-Auth-Request-User,X-Auth-Request-Email"
108+
109+
# ── Public whoami (no auth) ────────────────────────────────────────
110+
whoami-public:
111+
image: traefik/whoami:latest
112+
networks:
113+
- proxy-sso-net
114+
labels:
115+
- "traefik.enable=true"
116+
- "traefik.http.routers.whoami-public.rule=PathPrefix(`/public`)"
117+
- "traefik.http.routers.whoami-public.entrypoints=web"
31118

32119
networks:
33-
it-stack-net:
120+
proxy-sso-net:
34121
driver: bridge
122+
kc-db-net:
123+
driver: bridge
124+
internal: true
125+
126+
volumes:
127+
kc-db-sso:

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

Lines changed: 86 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,103 @@
11
#!/usr/bin/env bash
2-
# test-lab-18-04.sh — Lab 18-04: SSO Integration
3-
# Module 18: Traefik reverse proxy and load balancer
4-
# traefik with Keycloak OIDC/SAML authentication
2+
# test-lab-18-04.sh — Traefik Lab 04: ForwardAuth SSO via Keycloak OIDC
3+
# Tests: Keycloak setup, OIDC, oauth2-proxy ForwardAuth, public vs protected routes
54
set -euo pipefail
65

7-
LAB_ID="18-04"
8-
LAB_NAME="SSO Integration"
9-
MODULE="traefik"
10-
COMPOSE_FILE="docker/docker-compose.sso.yml"
11-
PASS=0
12-
FAIL=0
6+
PASS=0; FAIL=0
7+
KC_PASS="${KC_PASS:-Lab04Password!}"
8+
KC_URL="http://localhost:8080"
9+
REALM="it-stack"
10+
TRAEFIK_URL="http://localhost:80"
1311

14-
# ── Colors ────────────────────────────────────────────────────────────────────
15-
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
16-
CYAN='\033[0;36m'; NC='\033[0m'
12+
pass() { ((++PASS)); echo " [PASS] $1"; }
13+
fail() { ((++FAIL)); echo " [FAIL] $1"; }
14+
warn() { echo " [WARN] $1"; }
15+
header(){ echo; echo "=== $1 ==="; }
1716

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"; }
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+
}
2222

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 ""
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 Auth + Realm/Client/User Setup"
31+
TOKEN=$(kc_token)
32+
[[ -n "$TOKEN" ]] && pass "Admin token from master realm" || { fail "Admin auth failed"; exit 1; }
33+
curl -sf -X POST "$KC_URL/admin/realms" \
34+
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
35+
-d "{\"realm\":\"$REALM\",\"enabled\":true}" -o /dev/null && pass "Realm '$REALM' created" || warn "Realm may exist"
36+
TOKEN=$(kc_token)
37+
curl -sf -X POST "$KC_URL/admin/realms/$REALM/clients" \
38+
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
39+
-d "{\"clientId\":\"oauth2-proxy\",\"secret\":\"$KC_PASS\",\"publicClient\":false,
40+
\"serviceAccountsEnabled\":true,\"redirectUris\":[\"http://localhost:4180/*\"],
41+
\"enabled\":true}" -o /dev/null && pass "oauth2-proxy client created" || warn "Client may exist"
42+
TOKEN=$(kc_token)
43+
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$KC_URL/admin/realms/$REALM/users" \
44+
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
45+
-d "{\"username\":\"labuser\",\"enabled\":true,\"email\":\"labuser@lab.local\",
46+
\"emailVerified\":true,
47+
\"credentials\":[{\"type\":\"password\",\"value\":\"$KC_PASS\",\"temporary\":false}]}")
48+
[[ "$STATUS" =~ ^(201|409)$ ]] && pass "User 'labuser' ready" || fail "User creation failed (HTTP $STATUS)"
2849

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
50+
header "3. OIDC Discovery"
51+
DISCOVERY=$(curl -sf "$KC_URL/realms/$REALM/.well-known/openid-configuration")
52+
for field in token_endpoint authorization_endpoint jwks_uri; do
53+
echo "$DISCOVERY" | grep -q "\"$field\"" && pass "Discovery: $field present" || fail "Discovery missing $field"
54+
done
3455

35-
# ── PHASE 2: Health Checks ────────────────────────────────────────────────────
36-
info "Phase 2: Health Checks"
56+
header "4. Client Credentials Token"
57+
SA_TOKEN=$(curl -sf -X POST "$KC_URL/realms/$REALM/protocol/openid-connect/token" \
58+
-d "client_id=oauth2-proxy&client_secret=${KC_PASS}&grant_type=client_credentials" \
59+
| grep -o '"access_token":"[^"]*"' | cut -d'"' -f4)
60+
[[ -n "$SA_TOKEN" ]] && pass "Client credentials token obtained" || fail "Client credentials failed"
61+
IFS='.' read -ra P <<< "$SA_TOKEN"
62+
[[ "${#P[@]}" -eq 3 ]] && pass "JWT structure valid" || fail "Invalid JWT"
3763

38-
if docker compose -f "${COMPOSE_FILE}" ps | grep -q "running\|Up"; then
39-
pass "Container is running"
64+
header "5. Traefik Dashboard"
65+
if curl -sf http://localhost:8080/api/version | grep -q "Version"; then
66+
pass "Traefik dashboard API accessible"
4067
else
41-
fail "Container is not running"
68+
fail "Traefik dashboard not accessible"
4269
fi
4370

44-
# ── PHASE 3: Functional Tests ─────────────────────────────────────────────────
45-
info "Phase 3: Functional Tests (Lab 04 — SSO Integration)"
71+
header "6. Public Route (no auth required)"
72+
PUB=$(curl -s -o /dev/null -w "%{http_code}" "$TRAEFIK_URL/public")
73+
[[ "$PUB" -eq 200 ]] && pass "Public route /public → 200 OK" || fail "Public route failed (HTTP $PUB)"
4674

47-
# TODO: Add module-specific functional tests here
48-
# Example:
49-
# if curl -sf http://localhost:80/health > /dev/null 2>&1; then
50-
# pass "Health endpoint responds"
51-
# else
52-
# fail "Health endpoint not reachable"
53-
# fi
75+
header "7. Protected Route (ForwardAuth — expect SSO redirect)"
76+
PROT=$(curl -s -o /dev/null -w "%{http_code}" --max-redirect 0 "$TRAEFIK_URL/protected" 2>/dev/null || true)
77+
if [[ "$PROT" =~ ^(302|307|401)$ ]]; then
78+
pass "Protected route /protected → HTTP $PROT (ForwardAuth intercepted)"
79+
else
80+
fail "Protected route unexpected response: HTTP $PROT"
81+
fi
5482

55-
warn "Functional tests for Lab 18-04 pending implementation"
83+
header "8. oauth2-proxy /oauth2/callback Reachable via Traefik"
84+
OAUTH_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-redirect 0 "$TRAEFIK_URL/oauth2/callback" 2>/dev/null || true)
85+
[[ "$OAUTH_STATUS" =~ ^(200|302|400)$ ]] && pass "/oauth2/callback route accessible (HTTP $OAUTH_STATUS)" \
86+
|| fail "/oauth2/callback not accessible (HTTP $OAUTH_STATUS)"
5687

57-
# ── PHASE 4: Cleanup ──────────────────────────────────────────────────────────
58-
info "Phase 4: Cleanup"
59-
docker compose -f "${COMPOSE_FILE}" down -v --remove-orphans
60-
info "Cleanup complete"
88+
header "9. ForwardAuth Middleware in Traefik Config"
89+
ROUTERS=$(curl -sf http://localhost:8080/api/http/routers 2>/dev/null || echo "[]")
90+
echo "$ROUTERS" | grep -q "forward-auth\|forwardauth\|oauth2" \
91+
&& pass "ForwardAuth middleware referenced in router config" || warn "ForwardAuth middleware not visible in API (may use labels only)"
6192

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}"
93+
header "10. Traefik Router Count"
94+
ROUTER_COUNT=$(echo "$ROUTERS" | grep -o '"routerName"' | wc -l || echo "0")
95+
# Count routers differently
96+
ROUTER_COUNT=$(curl -sf http://localhost:8080/api/http/routers 2>/dev/null | grep -o '"provider"' | wc -l || echo "0")
97+
[[ "$ROUTER_COUNT" -ge 2 ]] && pass "Traefik has $ROUTER_COUNT routers (public + protected expected)" || warn "Router count: $ROUTER_COUNT"
6898

69-
if [ "${FAIL}" -gt 0 ]; then
70-
exit 1
71-
fi
99+
echo
100+
echo "═══════════════════════════════════════"
101+
echo " Lab 18-04 Results: $PASS passed, $FAIL failed"
102+
echo "═══════════════════════════════════════"
103+
[[ "$FAIL" -eq 0 ]]

0 commit comments

Comments
 (0)