Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- .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/
185 lines
5.6 KiB
Markdown
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 |
|