---
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 |
