autojanet/skills/adding-keycloak-sso/SKILL.md
Zoë cc74ad0bd0
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix: use library/ Harbor project, add skills, fix pipeline secrets
- .woodpecker.yaml: image paths -> library/autojanet-{agent,dispatcher}
- .woodpecker.yaml: secret names RS_HARBOR_USER / RS_HARBOR_PASS (global)
- container/Dockerfile: restore COPY skills/, skills/ populated from opencode config
- skills/: 84 opencode skills bundled into image
- k8s/manifests: update image refs to library/
2026-05-30 15:43:14 -07:00

185 lines
5.6 KiB
Markdown

---
name: adding-keycloak-sso
description: Use when adding Keycloak SSO authentication to a service on the homelab cluster at ctz.fyi, whether via oauth2-proxy sidecar or native OIDC configuration.
---
# Adding Keycloak SSO
## Overview
Two patterns depending on whether the app supports OIDC natively. Both use Keycloak at `sso.ctz.fyi`, realm `ctz`, with secrets stored in OpenBao.
## Pattern Selection
| App type | Pattern |
|----------|---------|
| No auth or basic auth only | **A: oauth2-proxy sidecar** |
| Native OIDC/OAuth2 support (Grafana, Jellyfin, Open WebUI) | **B: Native OIDC** |
| SPA (React/Vue/etc) | **B: Public PKCE client** (`publicClient: true`, no secret) |
**Gotcha:** If an app already uses keycloak-js internally, do NOT also add oauth2-proxy — you'll get double-auth. Pick one.
---
## Step 1: Create Keycloak Client
```bash
# Port-forward Keycloak
kubectl port-forward -n keycloak svc/keycloak 8080:80 &
# Get admin password from OpenBao
bao kv get secret/production/keycloak/keycloak-admin
# Get admin token
TOKEN=$(curl -s http://localhost:8080/realms/master/protocol/openid-connect/token \
-d "client_id=admin-cli&grant_type=password&username=admin&password=<PASSWORD>" \
| jq -r .access_token)
# Create client
curl -s -X POST http://localhost:8080/admin/realms/ctz/clients \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"clientId": "<service-name>",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"redirectUris": ["https://<hostname>/oauth2/callback", "https://<hostname>/*"],
"webOrigins": ["https://<hostname>"],
"baseUrl": "https://<hostname>"
}'
# Get client UUID, then fetch secret
CLIENT_ID=$(curl -s http://localhost:8080/admin/realms/ctz/clients \
-H "Authorization: Bearer $TOKEN" | jq -r '.[] | select(.clientId=="<service-name>") | .id')
CLIENT_SECRET=$(curl -s http://localhost:8080/admin/realms/ctz/clients/$CLIENT_ID/client-secret \
-H "Authorization: Bearer $TOKEN" | jq -r .value)
kill %1 # Kill port-forward
```
**Redirect URI must include BOTH** `/oauth2/callback` AND `/*` wildcard — missing wildcard causes `redirect_uri_mismatch` for SPAs using keycloak-js.
---
## Step 2: Write Secrets to OpenBao
**Pattern A only — generate cookie secret first:**
```bash
COOKIE_SECRET=$(python3 -c "import os,base64; print(base64.urlsafe_b64encode(os.urandom(32)).decode())")
bao kv put secret/production/<namespace>/<name>-oauth2proxy-secret \
client-secret="$CLIENT_SECRET" \
cookie-secret="$COOKIE_SECRET"
```
**Pattern B:** Store whatever the app needs (client secret, etc.) under an appropriate path.
---
## Step 3: Pattern A — oauth2-proxy Sidecar
### ExternalSecret
```yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: <name>-oauth2proxy-secret
annotations:
argocd.argoproj.io/sync-wave: "-1"
spec:
refreshInterval: 1h
secretStoreRef:
name: openbao
kind: ClusterSecretStore
target:
name: <name>-oauth2proxy-secret
creationPolicy: Owner
data:
- secretKey: client-secret
remoteRef:
key: secret/production/<namespace>/<name>-oauth2proxy-secret
property: client-secret
- secretKey: cookie-secret
remoteRef:
key: secret/production/<namespace>/<name>-oauth2proxy-secret
property: cookie-secret
```
### Deployment sidecar container
```yaml
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1
args:
- --provider=oidc
- --oidc-issuer-url=https://sso.ctz.fyi/realms/ctz
- --client-id=<service-name>
- --redirect-url=https://<hostname>/oauth2/callback
- --email-domain=*
- --upstream=http://localhost:<app-port>
- --cookie-secure=true
- --cookie-samesite=lax
- --skip-provider-button=true
- --pass-authorization-header=true
- --pass-access-token=true
- --set-xauthrequest=true
- --http-address=0.0.0.0:4180
env:
- name: OAUTH2_PROXY_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: <name>-oauth2proxy-secret
key: client-secret
- name: OAUTH2_PROXY_COOKIE_SECRET
valueFrom:
secretKeyRef:
name: <name>-oauth2proxy-secret
key: cookie-secret
ports:
- containerPort: 4180
```
### IngressRoute
Update the service port to `4180`. The app's own port no longer needs to be exposed externally.
---
## Step 4: Pattern B — Native OIDC
Configure the app using:
- **Issuer URL:** `https://sso.ctz.fyi/realms/ctz`
- **Client ID:** `<service-name>`
- **Client secret:** from OpenBao (via ExternalSecret or however the app ingests it)
- **Callback/redirect URL:** whatever the app expects (configure in Keycloak `redirectUris`)
For SPAs: set `"publicClient": true` in client creation, omit secret entirely.
---
## Step 5: Deploy and Verify
```bash
git add -A && git commit -m "feat(<service>): add Keycloak SSO"
git push
# Watch ArgoCD sync
```
Test the login flow manually. Check that:
- Unauthenticated requests redirect to Keycloak
- Successful login lands back on the app
- No double-auth prompts
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Missing `/*` wildcard in redirectUris | Add `"https://<hostname>/*"` alongside the callback URI |
| Cookie secret wrong length | Must be exactly 32 bytes → use the `python3` command above |
| Double-auth on apps with built-in keycloak-js | Remove app's internal auth OR remove oauth2-proxy, not both |
| IngressRoute still pointing at app port | Update to port `4180` for Pattern A |
| `directAccessGrantsEnabled: true` | Set to `false` — resource owner password grant is not needed |