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

5.6 KiB

name description
adding-keycloak-sso 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

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

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

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

- 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

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