From 87388712f087fe243f9f789db87a59d976db1f9e Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Thu, 27 Aug 2026 17:01:46 -0500 Subject: [PATCH 1/2] feat(gitea): wire native Keycloak OIDC SSO (break-glass admin retained) Wires the bootstrapping-critical Gitea git host to the Keycloak `homelab` realm as a native confidential OIDC client, moving it off the local-admin-only login while keeping the local admin as the break-glass path (docs/15 availability caveat; R-IDP-02/06). Realm (source of truth): add the `gitea` confidential client to platform/keycloak/keycloak-realm-import.yaml (modeled on grafana) + its GITEA_CLIENT_SECRET placeholder sourced from the keycloak-realm-clients Secret. NOTE: a KeycloakRealmImport no-ops on an already-existing realm, so the live client was ALSO created via kcadm (id 70a6ca60-317a-42ce-8331-a60e380838ee); the YAML keeps git authoritative for a rebuild. Secrets (SOPS/age, never plaintext): add gitea-client-secret to platform/keycloak/keycloak-realm-clients.sops.yaml (keycloak ns) and mirror the same value as oidc-client-secret into apps/wave-0/gitea-bootstrap-secret.sops.yaml (gitea ns) so the app can read it in-namespace. Both == the live client secret. App config (catalog source + re-render): catalog/gitea.yaml gains the GITEA_OIDC_CLIENT_SECRET env (from the mirrored Secret), [oauth2_client] auto-registration/account-linking app.ini env, and an idempotent postStart step that registers the "keycloak" OAuth2 login source (auto-discovery; groups claim; platform-admins -> Gitea admin). apps/wave-0/gitea.yaml re-rendered via `render.py --no-image-automation`. Verified: realm import Done=True/HasErrors=False and platform-config Ready=True/Healthy=True (unchanged); edited realm YAML passes server dry-run; static gate (lint/l0/l1) green. Browser SSO login still needs a human check. Co-Authored-By: Claude Opus 4.8 --- apps/wave-0/gitea-bootstrap-secret.sops.yaml | 5 +- apps/wave-0/gitea.yaml | 13 ++- catalog/gitea.yaml | 79 +++++++++++++++---- .../keycloak/keycloak-realm-clients.sops.yaml | 5 +- platform/keycloak/keycloak-realm-import.yaml | 37 +++++++++ 5 files changed, 118 insertions(+), 21 deletions(-) diff --git a/apps/wave-0/gitea-bootstrap-secret.sops.yaml b/apps/wave-0/gitea-bootstrap-secret.sops.yaml index 60182ce..bbda041 100644 --- a/apps/wave-0/gitea-bootstrap-secret.sops.yaml +++ b/apps/wave-0/gitea-bootstrap-secret.sops.yaml @@ -14,6 +14,7 @@ stringData: secret-key: ENC[AES256_GCM,data:N4wgSipwTYNgkwujPzGs8Bo63IzUip1TwfVq9fG+Z2nECrgIV9PYkEyL4ic3Ej9f5udOE4fHwsbgcyuiJw==,iv:p33bef3z2fDbaAi6beGF+CWmeTeS7Vn46ce1/2zQH4g=,tag:CLJ+kZbmz0tiPtOsI3+dIg==,type:str] admin-username: ENC[AES256_GCM,data:hyWYQ3UC8c9H2Os=,iv:bgn72bsILLxChY9QAsAD3tYWvsWS2CBe0FlneCCSr+E=,tag:7qP/P8TjDmRFsuXjJF8A2g==,type:str] admin-password: ENC[AES256_GCM,data:GPz1vracB1U6YurrV1AEhX9VEGx3lwCOXqOmKAg6+/6NjGPmTv+r2uosawVYtwie,iv:L/QKFqo7RpSg4eQGie/+R5BDBgTiTzwDdWuofJeebFE=,tag:cD6AU9tahLskTaaU/A7BOA==,type:str] + oidc-client-secret: ENC[AES256_GCM,data:T0CoSmS2yn0LBWdZ/AgkoMfNNoSTX+2EWzBMB3AV1ARxOP4mxP0IqElWvIH2xmseDJo7/o1fD0FU/E9BpgkSuw==,iv:xJoBSuBA6ATFd1XNVlI+tqj+hks1HvtAet3TGHtkxU8=,tag:h1P9pqubdvLk5znFLo/Slw==,type:str] sops: kms: [] gcp_kms: [] @@ -29,8 +30,8 @@ sops: Nys4V2NBZWF6U2lER05HV3FNZ1pOQ1kKEVHBTPTAJfiQBzkP/QezTzOfS96nYzxg Ad1m50j67vyFaBCVAb0/0toEUAm+qEV4wzOglaNmZuScavY4ntwCIw== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-08-25T23:06:33Z" - mac: ENC[AES256_GCM,data:ik735BfSMdlB4IUceScEfY4aNNNo61po7W7x/6iod1VvsiMo0+7nUrVqD2uiL8p066k6g/hV+BNg5BwnYRVjvkVm8tU60ctcjEDfinxtgnqRNBVK81RROn5GtsRCzuBxiJ5J8ulgb45+jG9ND6Xuo9XuTZ35PWs/4/Ebdl/jpPM=,iv:Nt3QtDz3S37AnrtG2nd1TIHgFFemgxXrKrHfXTVo6S4=,tag:ScMtwQAF8a4heyhN+iXt/A==,type:str] + lastmodified: "2026-08-27T21:58:13Z" + mac: ENC[AES256_GCM,data:/4DR4Ot6ZBeCoe9zJQoAV/WqmAx1TvsNbYbwpDhqDETGJkatIkbo5VivwljeocZsuWVKklefNZE95ycFzN+fAVsZoY03n4B7fdp4KpjoSFRs9IhhJYM57vamWDP0ppjhGzOV5RoT8kynZiIvBCXJjhs072ilwpQ8qLdTCmMMgbU=,iv:m9M2vJIqlTS4vWBEzLwtr7G8IrTFSHQuh5eAjdyUx8s=,tag:VF9jnwHWemowIC2pWS2AwQ==,type:str] pgp: [] encrypted_regex: ^(data|stringData)$ version: 3.9.4 diff --git a/apps/wave-0/gitea.yaml b/apps/wave-0/gitea.yaml index d85a72b..c3665f8 100644 --- a/apps/wave-0/gitea.yaml +++ b/apps/wave-0/gitea.yaml @@ -107,6 +107,17 @@ spec: secretKeyRef: name: gitea-bootstrap key: admin-password + - name: GITEA_OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: gitea-bootstrap + key: oidc-client-secret + - name: GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION + value: 'true' + - name: GITEA__oauth2_client__ACCOUNT_LINKING + value: auto + - name: GITEA__oauth2_client__USERNAME + value: preferred_username volumeMounts: - name: data mountPath: /data @@ -116,7 +127,7 @@ spec: command: - /bin/sh - -c - - "# Idempotent break-glass admin bootstrap. Runs alongside the gitea entrypoint\n# (which, with INSTALL_LOCK=true, headless-installs + migrates the DB on first\n# boot). Waits for the DB, then creates the admin only if it does not exist.\nset -u\nexport HOME=/data/git\ni=0\nuntil su-exec git gitea admin user list >/dev/null 2>&1; do\n i=$((i + 1))\n [ \"$i\" -ge 150 ] && { echo \"[gitea-bootstrap] DB not ready after wait; skipping\" >&2; exit 0; }\n sleep 2\ndone\nif su-exec git gitea admin user list --admin 2>/dev/null | awk 'NR>1{print $2}' | grep -qx \"$GITEA_ADMIN_USERNAME\"; then\n echo \"[gitea-bootstrap] admin '$GITEA_ADMIN_USERNAME' already present\"\n exit 0\nfi\nn=0\nuntil su-exec git gitea admin user create \\\n --username \"$GITEA_ADMIN_USERNAME\" \\\n --password \"$GITEA_ADMIN_PASSWORD\" \\\n --email \"$GITEA_ADMIN_USERNAME@home.local\" \\\n --admin --must-change-password=false; do\n n=$((n + 1))\n [ \"$n\" -ge 10 ] && { echo \"[gitea-bootstrap] admin create failed after retries\" >&2; exit 0; }\n sleep 3\ndone\necho \"[gitea-bootstrap] created break-glass admin '$GITEA_ADMIN_USERNAME'\"\n" + - "# Idempotent break-glass admin bootstrap. Runs alongside the gitea entrypoint\n# (which, with INSTALL_LOCK=true, headless-installs + migrates the DB on first\n# boot). Waits for the DB, then creates the admin only if it does not exist.\nset -u\nexport HOME=/data/git\ni=0\nuntil su-exec git gitea admin user list >/dev/null 2>&1; do\n i=$((i + 1))\n [ \"$i\" -ge 150 ] && { echo \"[gitea-bootstrap] DB not ready after wait; skipping\" >&2; exit 0; }\n sleep 2\ndone\nif su-exec git gitea admin user list --admin 2>/dev/null | awk 'NR>1{print $2}' | grep -qx \"$GITEA_ADMIN_USERNAME\"; then\n echo \"[gitea-bootstrap] admin '$GITEA_ADMIN_USERNAME' already present\"\nelse\n n=0\n until su-exec git gitea admin user create \\\n --username \"$GITEA_ADMIN_USERNAME\" \\\n --password \"$GITEA_ADMIN_PASSWORD\" \\\n --email \"$GITEA_ADMIN_USERNAME@home.local\" \\\n --admin --must-change-password=false; do\n n=$((n + 1))\n [ \"$n\" -ge 10 ] && { echo \"[gitea-bootstrap] admin create failed after retries\" >&2; break; }\n sleep 3\n done\n echo \"[gitea-bootstrap] created break-glass admin '$GITEA_ADMIN_USERNAME'\"\nfi\n# --- Keycloak OIDC login source (idempotent add-or-update; R-IDP-02/06) ---\n# Registers the confidential `gitea` client as the OAuth2 login source \"keycloak\" against the\n# homelab realm's discovery doc. The `groups` claim maps the platform-admins group to Gitea\n# admin (--admin-group). Auto-discovery pulls auth/token/userinfo/jwks endpoints. Runs on every\n# start: updates the existing source if present, else creates it. Missing secret => skip (the\n# local break-glass admin above still works), so an unfilled secret never crashes the pod.\nif [ -n \"${GITEA_OIDC_CLIENT_SECRET:-}\" ]; then\n oidc_id=$(su-exec git gitea admin auth list 2>/dev/null | awk '$2==\"keycloak\"{print $1; exit}')\n if [ -n \"$oidc_id\" ]; then\n su-exec git gitea admin auth update-oauth --id \"$oidc_id\" \\\n --name keycloak --provider openidConnect --key gitea \\\n --secret \"$GITEA_OIDC_CLIENT_SECRET\" \\\n --auto-discover-url https://keycloak.home.keithjackson.dev/realms/homelab/.well-known/openid-configuration \\\n --scopes \"openid email profile\" --group-claim-name groups --admin-group platform-admins \\\n && echo \"[gitea-oidc] updated login source 'keycloak' (id $oidc_id)\" \\\n || echo \"[gitea-oidc] update-oauth failed\" >&2\n else\n su-exec git gitea admin auth add-oauth \\\n --name keycloak --provider openidConnect --key gitea \\\n --secret \"$GITEA_OIDC_CLIENT_SECRET\" \\\n --auto-discover-url https://keycloak.home.keithjackson.dev/realms/homelab/.well-known/openid-configuration \\\n --scopes \"openid email profile\" --group-claim-name groups --admin-group platform-admins \\\n && echo \"[gitea-oidc] added login source 'keycloak'\" \\\n || echo \"[gitea-oidc] add-oauth failed\" >&2\n fi\nelse\n echo \"[gitea-oidc] GITEA_OIDC_CLIENT_SECRET unset; skipping OIDC source (break-glass admin only)\"\nfi\n" volumes: - name: data persistentVolumeClaim: diff --git a/catalog/gitea.yaml b/catalog/gitea.yaml index df9132b..7fd7382 100644 --- a/catalog/gitea.yaml +++ b/catalog/gitea.yaml @@ -41,6 +41,23 @@ env: secretKeyRef: name: gitea-bootstrap key: admin-password + # --- Keycloak native OIDC SSO (auth.mode oidc; docs/15 "Native OIDC"; R-IDP-02/06). The confidential + # `gitea` realm client's secret is mirrored into this namespace's gitea-bootstrap Secret (the same + # value SOPS-held in keycloak-realm-clients); the postStart hook registers it as the "keycloak" + # OAuth2 login source. The LOCAL admin above stays as the break-glass path (docs/15). --- + - name: GITEA_OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: gitea-bootstrap + key: oidc-client-secret + # app.ini [oauth2_client]: auto-provision a Gitea account on first Keycloak login and link it to an + # existing local account by verified email, so SSO users don't need a pre-created local user. + - name: GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION + value: "true" + - name: GITEA__oauth2_client__ACCOUNT_LINKING + value: auto + - name: GITEA__oauth2_client__USERNAME + value: preferred_username lifecycle: postStart: exec: @@ -61,19 +78,47 @@ lifecycle: done if su-exec git gitea admin user list --admin 2>/dev/null | awk 'NR>1{print $2}' | grep -qx "$GITEA_ADMIN_USERNAME"; then echo "[gitea-bootstrap] admin '$GITEA_ADMIN_USERNAME' already present" - exit 0 + else + n=0 + until su-exec git gitea admin user create \ + --username "$GITEA_ADMIN_USERNAME" \ + --password "$GITEA_ADMIN_PASSWORD" \ + --email "$GITEA_ADMIN_USERNAME@home.local" \ + --admin --must-change-password=false; do + n=$((n + 1)) + [ "$n" -ge 10 ] && { echo "[gitea-bootstrap] admin create failed after retries" >&2; break; } + sleep 3 + done + echo "[gitea-bootstrap] created break-glass admin '$GITEA_ADMIN_USERNAME'" + fi + # --- Keycloak OIDC login source (idempotent add-or-update; R-IDP-02/06) --- + # Registers the confidential `gitea` client as the OAuth2 login source "keycloak" against the + # homelab realm's discovery doc. The `groups` claim maps the platform-admins group to Gitea + # admin (--admin-group). Auto-discovery pulls auth/token/userinfo/jwks endpoints. Runs on every + # start: updates the existing source if present, else creates it. Missing secret => skip (the + # local break-glass admin above still works), so an unfilled secret never crashes the pod. + if [ -n "${GITEA_OIDC_CLIENT_SECRET:-}" ]; then + oidc_id=$(su-exec git gitea admin auth list 2>/dev/null | awk '$2=="keycloak"{print $1; exit}') + if [ -n "$oidc_id" ]; then + su-exec git gitea admin auth update-oauth --id "$oidc_id" \ + --name keycloak --provider openidConnect --key gitea \ + --secret "$GITEA_OIDC_CLIENT_SECRET" \ + --auto-discover-url https://keycloak.home.keithjackson.dev/realms/homelab/.well-known/openid-configuration \ + --scopes "openid email profile" --group-claim-name groups --admin-group platform-admins \ + && echo "[gitea-oidc] updated login source 'keycloak' (id $oidc_id)" \ + || echo "[gitea-oidc] update-oauth failed" >&2 + else + su-exec git gitea admin auth add-oauth \ + --name keycloak --provider openidConnect --key gitea \ + --secret "$GITEA_OIDC_CLIENT_SECRET" \ + --auto-discover-url https://keycloak.home.keithjackson.dev/realms/homelab/.well-known/openid-configuration \ + --scopes "openid email profile" --group-claim-name groups --admin-group platform-admins \ + && echo "[gitea-oidc] added login source 'keycloak'" \ + || echo "[gitea-oidc] add-oauth failed" >&2 + fi + else + echo "[gitea-oidc] GITEA_OIDC_CLIENT_SECRET unset; skipping OIDC source (break-glass admin only)" fi - n=0 - until su-exec git gitea admin user create \ - --username "$GITEA_ADMIN_USERNAME" \ - --password "$GITEA_ADMIN_PASSWORD" \ - --email "$GITEA_ADMIN_USERNAME@home.local" \ - --admin --must-change-password=false; do - n=$((n + 1)) - [ "$n" -ge 10 ] && { echo "[gitea-bootstrap] admin create failed after retries" >&2; exit 0; } - sleep 3 - done - echo "[gitea-bootstrap] created break-glass admin '$GITEA_ADMIN_USERNAME'" exposure: public # edge-forwarded git host at gitea.home.keithjackson.dev (docs/09; R-DNS/R-NET) egress: internet # normal WAN egress (docs/03; R-NET-05) storage: @@ -90,10 +135,12 @@ auth: mode: oidc # native Keycloak OIDC client (docs/13 SSO row, docs/15 "Native OIDC" set; R-IDP-02) groups: - platform-admins # access scoped to an authorized group (R-IDP-06) - # NOTE (OIDC follow-up + break-glass): Gitea keeps a LOCAL admin as the break-glass path so an IdP - # outage never locks the operator out of recovery tools (docs/15 "Availability caveat"). At initial - # deploy Gitea comes up on that local admin; the Keycloak OIDC client (+ SOPS client secret) is wired - # once Keycloak is live. This declaration records the target integration; it does not embed a secret. + # WIRED (native OIDC): the Keycloak `gitea` confidential client (platform/keycloak/keycloak-realm- + # import.yaml + live kcadm) is registered as Gitea's "keycloak" OAuth2 login source by the postStart + # hook, using the client secret mirrored into the gitea-bootstrap Secret (oidc-client-secret; same + # value SOPS-held in keycloak-realm-clients). platform-admins (groups claim) -> Gitea admin. Gitea + # KEEPS a LOCAL admin as the break-glass path so an IdP outage never locks the operator out of + # recovery tools (docs/15 "Availability caveat"). No secret is embedded here. update: channel: stable # R-UPD-01 range: ">=1.22.0 <2.0.0" # stay on the 1.x stable line diff --git a/platform/keycloak/keycloak-realm-clients.sops.yaml b/platform/keycloak/keycloak-realm-clients.sops.yaml index 5dce5df..aed9d13 100644 --- a/platform/keycloak/keycloak-realm-clients.sops.yaml +++ b/platform/keycloak/keycloak-realm-clients.sops.yaml @@ -7,6 +7,7 @@ type: Opaque stringData: grafana-client-secret: ENC[AES256_GCM,data:J6grYrkJVubGNS+z1WrcUuvjKu2Z/fETWNXsKkjQnk/RlitaMjDqkazglKgy8PkEymNLNuVao48gksnuNLQ6aA==,iv:9sw5IdW+7GuwsRjn4NSi2JVZh9mTE2hiWJQYQS7w2bA=,tag:2hOUFeFqcQJsPeC8e6U4RA==,type:str] oauth2-proxy-client-secret: ENC[AES256_GCM,data:hE9mN6A6uTL3GpqOCGb3T0qVR9bcY4wcqNlgcrs9+uezSeci/Vs9uiXeFzfD57klHe8nEqxF1bCftD0GVFadhQ==,iv:rP2CbbGG4LWmGyV0adrlsmn2GtQLH8hSlqbioOAD824=,tag:p28vfuSZxYMs7XnAjslBWg==,type:str] + gitea-client-secret: ENC[AES256_GCM,data:ObU5sWSq2xEZKgqwccFo/kzs/nfhOVJ0NceBlgxgy6a6iOWeUN079iQpJ0ADoJyjsoQk1IQediFD06rwVSvNIQ==,iv:Bc4L8MngA6YKNRQRwEQojnUo5ixdS2t1AocvGMwE0oQ=,tag:YsozaKtL6CR68sPmGiL5wA==,type:str] sops: kms: [] gcp_kms: [] @@ -22,8 +23,8 @@ sops: YXJGUUJiTnFndFhDZDU1bmxXTENCbmsKuhb3RQ3LZmuTPAXEck8b6iuZ5vxLb9fV +fQELmU4kOTbZrPSxy+kpttLG5Cy5d6X2ieZXBf6opzpLTUhlBOkHA== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-08-25T03:06:30Z" - mac: ENC[AES256_GCM,data:wJ6Mf7yFXnnnuo/IhfGdDYcgNUzzNv2bFGN6wPwmVRr1To4rt3DGb1JZO19OxvloWYrGAyJaeXzC5NCLGDZOzS99TBmMy1TIeEYUqd6zEmFofEla0mZyLFGLsU/JUIVL3d1l4ECPmcHpUnEjJf4o4kE8BxkWAyHSMWktB69a1cc=,iv:7E7T7cFpAcT42ryTfqbDUoWr0d23a9rCI3n//LgBFS8=,tag:zVXOh/hjQNousg85TZw/Zg==,type:str] + lastmodified: "2026-08-27T21:58:13Z" + mac: ENC[AES256_GCM,data:+QNCSmJMoAAUibsQZ7by/Neno6VfxLoxCNUoRwHAW+T3SDrgQI7hkrVcWr39bpCne7AAGnlyVS7Zd4m3lk/ZGs90LbSx5xdFyGPQ7vpI774aaIMQJe+84pBPLGw1NH8bVmDOS99P0rlIG0lGBixl4UqrQ3BWhgnd63tAvnKnCBU=,iv:K4Wi+LvKhszj3bnRBuheHdbS2UwhlN6hBJP/H3nEo9M=,tag:P4+14EaB88QQNzDvCUlnJA==,type:str] pgp: [] encrypted_regex: ^(data|stringData)$ version: 3.9.4 diff --git a/platform/keycloak/keycloak-realm-import.yaml b/platform/keycloak/keycloak-realm-import.yaml index 2b55896..96a4192 100644 --- a/platform/keycloak/keycloak-realm-import.yaml +++ b/platform/keycloak/keycloak-realm-import.yaml @@ -23,6 +23,10 @@ spec: secret: name: keycloak-realm-clients key: oauth2-proxy-client-secret + GITEA_CLIENT_SECRET: + secret: + name: keycloak-realm-clients + key: gitea-client-secret realm: realm: homelab enabled: true @@ -145,6 +149,39 @@ spec: "access.token.claim": "true" "userinfo.token.claim": "true" "claim.name": groups + # gitea — native OIDC SSO for the bootstrapping-critical git host (catalog/gitea.yaml auth.mode + # oidc; docs/13 SSO row, docs/15 "Native OIDC"; R-IDP-02). Confidential auth-code client modeled on + # grafana. Redirect URI is Gitea's OAuth2 callback for the auth source named `keycloak` + # (/user/oauth2//callback). The `groups` claim drives authorization: Gitea maps the + # platform-admins group -> Gitea admin (auth source --admin-group; R-IDP-06). The LOCAL Gitea admin + # remains the break-glass path (docs/15 availability caveat). Secret injected from the SOPS + # keycloak-realm-clients Secret via the GITEA_CLIENT_SECRET placeholder (R-IDP-04). + - clientId: gitea + name: Gitea + protocol: openid-connect + enabled: true + publicClient: false + standardFlowEnabled: true + directAccessGrantsEnabled: false + secret: "$(env:GITEA_CLIENT_SECRET)" + redirectUris: + - https://gitea.home.keithjackson.dev/user/oauth2/keycloak/callback + webOrigins: + - https://gitea.home.keithjackson.dev + defaultClientScopes: + - email + - profile + - roles + protocolMappers: + - name: groups + protocol: openid-connect + protocolMapper: oidc-group-membership-mapper + config: + "full.path": "false" + "id.token.claim": "true" + "access.token.claim": "true" + "userinfo.token.claim": "true" + "claim.name": groups # --- MFA for platform-admins (R-IDP-04): a browser flow whose OTP step is CONDITIONAL on the # platform-admin role. Non-admins log in without OTP; platform-admins must enroll/present TOTP. # Set as the realm browserFlow below. VERIFY at bring-up (see runbook): a malformed flow fails From f1630269547509d0602a60b3a331e5b467b64b95 Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Thu, 27 Aug 2026 17:17:13 -0500 Subject: [PATCH 2/2] feat(nextcloud,semaphore): wire native Keycloak OIDC SSO (break-glass admins retained) Extends the SSO work to the two remaining `auth: oidc` apps still on local-admin logins, each as a native confidential client of the Keycloak `homelab` realm with its local admin kept as break-glass (docs/15 availability caveat; R-IDP-02/06). Realm (source of truth): add the `nextcloud` and `semaphore` confidential clients to platform/keycloak/keycloak-realm-import.yaml (modeled on grafana/gitea) + their NEXTCLOUD_CLIENT_SECRET / SEMAPHORE_CLIENT_SECRET placeholders. As before, a KeycloakRealmImport no-ops on an already-existing realm, so both live clients were ALSO created via kcadm (nextcloud e95049ed-ca10-4085-97d9-4bbef495fe61, semaphore 1835bfe9-b125-4026-8f69-33557519cf4a); the YAML keeps git authoritative. Secrets (SOPS/age): add nextcloud-client-secret + semaphore-client-secret to keycloak-realm-clients.sops.yaml (keycloak ns). Mirror the nextcloud secret as oidc-client-secret into nextcloud-bootstrap (nextcloud ns). For semaphore, the whole SEMAPHORE_OIDC_PROVIDERS JSON (which embeds the client secret) is held encrypted as oidc-providers-json in semaphore-bootstrap (semaphore ns). All values verified == the live clients; existing keys untouched; no plaintext in git. App config (catalog source + re-render): - nextcloud: add NEXTCLOUD_OIDC_CLIENT_SECRET env + an idempotent postStart hook that installs/enables user_oidc and runs `occ user_oidc:provider keycloak` (create-or-update; secret via --clientsecret-env so it never hits argv; occ as www-data; group provisioning on the `groups` claim). Syntax verified against the running user_oidc 8.11.0 on Nextcloud 31.0.14; su-to-www-data env passthrough verified. Missing-secret/not-installed guards prevent crashloops. - semaphore: add SEMAPHORE_OIDC_PROVIDERS env sourced from the encrypted oidc-providers-json (provider_url=realm issuer/auto-discovery, redirect /api/auth/oidc/keycloak/redirect). Mechanism verified against semaphore v2.19.10 source (util ConfigType oidc_providers env tag + OidcProvider struct + router redirect path); no kubelet service-link env collision. apps/wave-1/{nextcloud,semaphore}.yaml re-rendered via `render.py --no-image-automation` (diffs are env/lifecycle-only). Verified: realm import Done=True/HasErrors=False and platform-config Ready=True/Healthy=True (unchanged); edited realm YAML passes server dry-run; live homelab clients now include gitea/nextcloud/semaphore; static gate (lint/l0/l1) green. Browser SSO login for each still needs a human check. Co-Authored-By: Claude Opus 4.8 --- .../nextcloud-bootstrap-secret.sops.yaml | 5 +- apps/wave-1/nextcloud.yaml | 12 ++++ .../semaphore-bootstrap-secret.sops.yaml | 5 +- apps/wave-1/semaphore.yaml | 5 ++ catalog/nextcloud.yaml | 70 ++++++++++++++++--- catalog/semaphore.yaml | 28 +++++--- .../keycloak/keycloak-realm-clients.sops.yaml | 6 +- platform/keycloak/keycloak-realm-import.yaml | 70 +++++++++++++++++++ 8 files changed, 179 insertions(+), 22 deletions(-) diff --git a/apps/wave-1/nextcloud-bootstrap-secret.sops.yaml b/apps/wave-1/nextcloud-bootstrap-secret.sops.yaml index 11e4d07..3e5f4a2 100644 --- a/apps/wave-1/nextcloud-bootstrap-secret.sops.yaml +++ b/apps/wave-1/nextcloud-bootstrap-secret.sops.yaml @@ -14,6 +14,7 @@ stringData: admin-username: ENC[AES256_GCM,data:GVvF2Q6ymg==,iv:z0aNezBuAit4nZU0o8C5vd0V0eqFV27Ly6BX6EHA/QQ=,tag:mX17Bc5uC/HMH1d4uA3WGw==,type:str] admin-password: ENC[AES256_GCM,data:kdP3lOEMKTOrefDNYY0m9b2JsVEWF0cwYHhpizFRvN0tA8nYv0blgvYmS16KvuUI,iv:9bbntbUewcayR3RgdeieThaFxt0UwFYHIqQoD1VSHHo=,tag:E1DHYb5TuNLl/WVFRuDCtw==,type:str] db-password: ENC[AES256_GCM,data:Xh62RMSTEv9uS4UYhL63qclbRWFhHtUZn9/YCEbBHwkYtImYNSgKltt9d5ntUeKk,iv:ITCJE3J1eSdIzBEnWh3q2BGW3O82fqqRZ+GnWodFh78=,tag:f9MsOPPTec3dGCXweFUetQ==,type:str] + oidc-client-secret: ENC[AES256_GCM,data:5eFxTbW6MsrynLBXb8jNljcwDvG5XYFp9z6TtfVXvIaJPGBLlBc21f+0PSZ7mCaGUq9d6pyhqco3LFdJFSg77w==,iv:no50G222ahfHr1J+OYORpgj0olin26FjAE58n2Z1e9o=,tag:4qh1cqnziUIGJtEcJLVX3w==,type:str] sops: kms: [] gcp_kms: [] @@ -29,8 +30,8 @@ sops: SDdoWXhVRi9DR0RzL0dlM0dtcTlFQlEKNVBukqVCZ7ZjREYAl2xK/YxuJVKEgESh ToKuzCgydEaxW3NoKWMvZxJUM5vnGI1rPxSZdGgXDjcpI4Q4iTYmZA== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-08-26T06:30:08Z" - mac: ENC[AES256_GCM,data:INbmOvfiZFll97zz3Dfq4xFpbBWpNUoW4LOERUSQ2jBy2UBGO8wSNf0r8F328mlO1GqFXFs0a0LD11pDgC2wAhF/bQWYsPFAYNEQFgU3FpxCNnLNPUN9nS58CE9C1sMIs5WaAaOzFBudRXEYulQNRKuLPGOF7UKytxkzmgIggT4=,iv:JuzQ7scb+CQg0kOtwzTqNe0D8V90sQSpiNA/2lMsJr8=,tag:1ICb7zPIqvonmEgEfYdGqA==,type:str] + lastmodified: "2026-08-27T22:12:32Z" + mac: ENC[AES256_GCM,data:vnwIPefz9V/s6lbkoCvnylEzaPO788eSS5grFOaBJbjT5p0yYCVVpEoh19Z1k9BnQiMKwRL2btMYGwxgN2/BdsmWE8QdpQd25ma5Fso2/EnNxTZ87aM8loA0f8JFaSvU9iyFioqGWqLxXhm7pFzJYUn/vn6cZNB/mcuPcvMI3VY=,iv:HjPfmg77anshGGv+WIOS/vsI5ctsS4JLzh4WJe/xmTk=,tag:QcMgV68/iclfAfLCWK+siQ==,type:str] pgp: [] encrypted_regex: ^(data|stringData)$ version: 3.9.4 diff --git a/apps/wave-1/nextcloud.yaml b/apps/wave-1/nextcloud.yaml index 345e81d..8efc9b9 100644 --- a/apps/wave-1/nextcloud.yaml +++ b/apps/wave-1/nextcloud.yaml @@ -111,9 +111,21 @@ spec: value: https - name: OVERWRITECLIURL value: https://nextcloud.home.keithjackson.dev + - name: NEXTCLOUD_OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: nextcloud-bootstrap + key: oidc-client-secret volumeMounts: - name: data mountPath: /var/www/html + lifecycle: + postStart: + exec: + command: + - /bin/sh + - -c + - "# Idempotent Keycloak OIDC wiring (R-IDP-02/06). Runs alongside the nextcloud entrypoint\n# (which installs/upgrades on first boot). Installs+enables the user_oidc app and creates OR\n# updates the \"keycloak\" login provider (occ user_oidc:provider is create-or-update by\n# identifier). Auto-discovery pulls the realm's endpoints; the `groups` claim drives Nextcloud\n# group provisioning. occ runs as www-data (owns config.php); the client secret is passed via\n# --clientsecret-env so it never lands on a command line. Safe to re-run every start; a missing\n# secret or not-yet-installed instance is skipped so the pod never crashloops (the local\n# break-glass admin still works).\nset -u\nOCC() { su -s /bin/sh www-data -c \"php /var/www/html/occ $*\"; }\ni=0\nuntil OCC status 2>/dev/null | grep -q \"installed: true\"; do\n i=$((i + 1))\n [ \"$i\" -ge 300 ] && { echo \"[nextcloud-oidc] occ not ready after wait; skipping\" >&2; exit 0; }\n sleep 2\ndone\nif [ -z \"${NEXTCLOUD_OIDC_CLIENT_SECRET:-}\" ]; then\n echo \"[nextcloud-oidc] client secret unset; skipping OIDC (break-glass admin only)\"\n exit 0\nfi\nif ! OCC app:list 2>/dev/null | grep -q \"user_oidc\"; then\n OCC app:install user_oidc || echo \"[nextcloud-oidc] app:install user_oidc failed\" >&2\nfi\nOCC app:enable user_oidc >/dev/null 2>&1 || true\nif OCC user_oidc:provider keycloak \\\n --clientid=nextcloud \\\n --clientsecret-env=NEXTCLOUD_OIDC_CLIENT_SECRET \\\n --discoveryuri=https://keycloak.home.keithjackson.dev/realms/homelab/.well-known/openid-configuration \\\n --scope=\"openid email profile\" \\\n --unique-uid=0 \\\n --mapping-uid=preferred_username \\\n --mapping-email=email \\\n --mapping-display-name=name \\\n --group-provisioning=1 \\\n --mapping-groups=groups; then\n echo \"[nextcloud-oidc] provider 'keycloak' configured\"\nelse\n echo \"[nextcloud-oidc] provider config failed\" >&2\nfi\n" volumes: - name: data persistentVolumeClaim: diff --git a/apps/wave-1/semaphore-bootstrap-secret.sops.yaml b/apps/wave-1/semaphore-bootstrap-secret.sops.yaml index 18095d2..490de04 100644 --- a/apps/wave-1/semaphore-bootstrap-secret.sops.yaml +++ b/apps/wave-1/semaphore-bootstrap-secret.sops.yaml @@ -19,6 +19,7 @@ stringData: admin-password: ENC[AES256_GCM,data:544nWY1UmBnlsdPJAQZJMXRumu4W9X7UXZvZ6vHN/ArjRgLXKTArOe5JkT/nNrNw,iv:AC1DDbZj2HfpU19EjzA7T+WpIaR4nZc3PNN61y8bAzU=,tag:r28LFi5w6ZeCCaRaX2jRdg==,type:str] db-password: ENC[AES256_GCM,data:ySkdvaGRCW9fMQqChs5l8dY4zCG19i4HOEinRC48mgLuh9WOrjd16T6jwFgiwB55,iv:+1gztJ22HbwgQJiolZMMWOcKIsxrBJpnruJdtxdooOU=,tag:8CfJGwyPTjDGyr0CvalXMQ==,type:str] access-key-encryption: ENC[AES256_GCM,data:1YAMLo1nVkpJWZc+KBZ7B4ia+Y8P+xMyOhHMcEGGJOXd3o+WC5FqirLBVh4=,iv:j28ozeaDhGWDjoaxFOhn2t9YbYE7L3ah/Mfi+i2DWwY=,tag:czG9Wxc/7Nsuh1W5iATOww==,type:str] + oidc-providers-json: ENC[AES256_GCM,data:90yt5mIVWLVI2KrlJWpxhL/BMDpEgRhv7+BL+mBRt79diRvU7pN9vtzhZprAKzcU1sEywlT4EwFlsvJpYyo5Qbkp+ZVknbyhAlGvHhJOBHukOPWgf4LignLerO2GzhXjOOzFqWfZ4EyUxNMWTbSe/xc0zIVHDDNQiYplGVRK+E/ukiI7sD/Tn2RrbrdvpC5GyK0NXWRsXn9ffhE3fCoiRHXagj9i/q+uUrQI5EKpPobrDUAGqvKoBXTl/wJ4phnWg9IqtCTE/c3yA7fLLz4j5dCtPZNSVPT4TKDx8Mdv8CfCgziVicjG2s+yHuWpsQn8lf6nvdJmpYPvIqTtLWdxFWuD8Vfo6Qbq2mqPbKR+KmDOGUnjl4gbZdL1SkE9JiqBUobz7UXeLXwYzxMIsinOfYZLcCk7alrD5Yndj9JDxDJZQ54sMMBfVbxDl94W1VHy03RxAR46KKAbPaxOP5wH4Q4z3SDceNwmuGCDRGChRXiHhN5X2X0GqYCbc8E6GdkIMcpmD80e26ydEmAjw7F89mCF4F/yMzJNiG82qaZIn8HeqgGA8urous9r9bYJNlitYTYQ,iv:jbfVdrrZ9fGiOxhQeoTJfFg1QwiOMkoQyaadiVfxX/w=,tag:JkepcRs34EuEgNb4buTTDQ==,type:str] sops: kms: [] gcp_kms: [] @@ -34,8 +35,8 @@ sops: elp0SWxHcjg0RWF3aW9mWTJHZ0lhcUEKc0rU5VilAVDleCwqq4SOF+Y9sniu1Pka w/MGDA9THSB0b+YgWRWiX/KiimFqp0ZFODOJ9cHVwFGY71+VadPwOg== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-08-26T06:45:19Z" - mac: ENC[AES256_GCM,data:5XhhTZ9BGQTIdHPNrFUxwgPRm0hOqyzdsjc2ACVSJofBho+3j7XiExGTXTyblSmT5Kv+BEym0rgOjxgKKF59CAnjEMhIoZ/3mYWegQGF8HZhVqDdPgjHmxFF93D6tLK5mQGimXILmDAYcAMRAvQoJGF16BDo4v5YeVZ1bAJZ4Ug=,iv:33cmv3YaskhbUQ+K+dcJQI9LYHKZDCtDjd2NgR5yvts=,tag:CRh/ZsPggCmqyePQe5Q0gw==,type:str] + lastmodified: "2026-08-27T22:12:32Z" + mac: ENC[AES256_GCM,data:nHftJTGnTr0O5h8yGXwgz3eflggXJfIXM3m8n3bzDJE/P4nvSpZzFeMb+sc2on+AL+t+SeKrIl8blzm33XsQ1lkaY85Zm+bc5cnED1g04BMDVmmK3brh/8w50Bp2wPEVzb4I01MDzhhyIbOYg7pFvZAH3/VuqvM1t6XmfF1NrOA=,iv:4KBa9PH/dfdGwLC16RenZIVB6BtSoGo6x3qt3h1R43I=,tag:KULiA1klP/RZMNigkloGDA==,type:str] pgp: [] encrypted_regex: ^(data|stringData)$ version: 3.9.4 diff --git a/apps/wave-1/semaphore.yaml b/apps/wave-1/semaphore.yaml index a0a2e57..3b522e7 100644 --- a/apps/wave-1/semaphore.yaml +++ b/apps/wave-1/semaphore.yaml @@ -118,6 +118,11 @@ spec: key: access-key-encryption - name: SEMAPHORE_PORT value: :3000 + - name: SEMAPHORE_OIDC_PROVIDERS + valueFrom: + secretKeyRef: + name: semaphore-bootstrap + key: oidc-providers-json volumeMounts: - name: config mountPath: /etc/semaphore diff --git a/catalog/nextcloud.yaml b/catalog/nextcloud.yaml index e60bcf8..5dee17a 100644 --- a/catalog/nextcloud.yaml +++ b/catalog/nextcloud.yaml @@ -57,6 +57,62 @@ env: value: https - name: OVERWRITECLIURL value: https://nextcloud.home.keithjackson.dev + # --- Keycloak native OIDC SSO (auth.mode oidc; docs/15 "Native OIDC"; R-IDP-02/06). The confidential + # `nextcloud` realm client's secret is mirrored into this namespace's nextcloud-bootstrap Secret + # (oidc-client-secret; same value SOPS-held in keycloak-realm-clients). The postStart hook reads it + # via user_oidc's --clientsecret-env (never on argv) to register the "keycloak" provider. The LOCAL + # admin above stays as the break-glass path (docs/15). --- + - name: NEXTCLOUD_OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: nextcloud-bootstrap + key: oidc-client-secret +lifecycle: + postStart: + exec: + command: + - /bin/sh + - -c + - | + # Idempotent Keycloak OIDC wiring (R-IDP-02/06). Runs alongside the nextcloud entrypoint + # (which installs/upgrades on first boot). Installs+enables the user_oidc app and creates OR + # updates the "keycloak" login provider (occ user_oidc:provider is create-or-update by + # identifier). Auto-discovery pulls the realm's endpoints; the `groups` claim drives Nextcloud + # group provisioning. occ runs as www-data (owns config.php); the client secret is passed via + # --clientsecret-env so it never lands on a command line. Safe to re-run every start; a missing + # secret or not-yet-installed instance is skipped so the pod never crashloops (the local + # break-glass admin still works). + set -u + OCC() { su -s /bin/sh www-data -c "php /var/www/html/occ $*"; } + i=0 + until OCC status 2>/dev/null | grep -q "installed: true"; do + i=$((i + 1)) + [ "$i" -ge 300 ] && { echo "[nextcloud-oidc] occ not ready after wait; skipping" >&2; exit 0; } + sleep 2 + done + if [ -z "${NEXTCLOUD_OIDC_CLIENT_SECRET:-}" ]; then + echo "[nextcloud-oidc] client secret unset; skipping OIDC (break-glass admin only)" + exit 0 + fi + if ! OCC app:list 2>/dev/null | grep -q "user_oidc"; then + OCC app:install user_oidc || echo "[nextcloud-oidc] app:install user_oidc failed" >&2 + fi + OCC app:enable user_oidc >/dev/null 2>&1 || true + if OCC user_oidc:provider keycloak \ + --clientid=nextcloud \ + --clientsecret-env=NEXTCLOUD_OIDC_CLIENT_SECRET \ + --discoveryuri=https://keycloak.home.keithjackson.dev/realms/homelab/.well-known/openid-configuration \ + --scope="openid email profile" \ + --unique-uid=0 \ + --mapping-uid=preferred_username \ + --mapping-email=email \ + --mapping-display-name=name \ + --group-provisioning=1 \ + --mapping-groups=groups; then + echo "[nextcloud-oidc] provider 'keycloak' configured" + else + echo "[nextcloud-oidc] provider config failed" >&2 + fi exposure: public # edge port-forward + public DNS (docs/09; R-DNS/R-NET) egress: internet # normal WAN egress (docs/03; R-NET-05) — app store, integrity checks storage: @@ -77,14 +133,12 @@ auth: mode: oidc # native Keycloak OIDC client (docs/13 SSO row, docs/15 "Native OIDC"; R-IDP-02) groups: - platform-admins # access scoped to an authorized group (R-IDP-06) - # NOTE (OIDC follow-up + break-glass): like Gitea (docs/15 "Availability caveat"), Nextcloud keeps a - # LOCAL admin as the break-glass path so an IdP outage never locks the operator out. At initial deploy - # it comes up INSTALLED on that local admin (env above). The Keycloak `nextcloud` realm client + its - # user_oidc app config is a DEFERRED follow-up: wiring it means editing the reconciled realm import in - # platform-config (a malformed edit fails the whole platform-config apply / can leave the realm import - # PENDING) AND post-install `occ` steps the image has no first-class env for — deferred rather than - # risking the running Keycloak realm / Flux source (WS-APPS step 3 escape hatch). This records the - # target integration; it embeds no secret. + # WIRED (native OIDC): the Keycloak `nextcloud` confidential client (platform/keycloak/keycloak-realm- + # import.yaml + live kcadm) is registered as a user_oidc login provider ("keycloak") by the postStart + # hook (occ user_oidc:provider), using the client secret mirrored into nextcloud-bootstrap + # (oidc-client-secret; same value SOPS-held in keycloak-realm-clients). The `groups` claim drives + # user_oidc group provisioning (R-IDP-06). Nextcloud KEEPS its LOCAL admin as the break-glass path so + # an IdP outage never locks the operator out (docs/15 "Availability caveat"). No secret is embedded here. update: channel: stable # R-UPD-01 range: ">=31.0.0 <32.0.0" # stay on the 31.x stable line diff --git a/catalog/semaphore.yaml b/catalog/semaphore.yaml index 736f79e..fa4ac02 100644 --- a/catalog/semaphore.yaml +++ b/catalog/semaphore.yaml @@ -73,6 +73,21 @@ env: # service links), pinning the listen port back to :3000 (matches containerPort/Service). - name: SEMAPHORE_PORT value: ":3000" + # --- Keycloak native OIDC SSO (auth.mode oidc; docs/15 "Native OIDC"; R-IDP-02/06). Semaphore + # v2.19.10 reads its OIDC providers from the SEMAPHORE_OIDC_PROVIDERS env as a JSON map + # (util ConfigType `oidc_providers`, env-tagged SEMAPHORE_OIDC_PROVIDERS). The value is a single + # JSON blob for the `keycloak` provider — provider_url is the homelab realm issuer (auto-discovery), + # client_id=semaphore, redirect_url=/api/auth/oidc/keycloak/redirect, claims map to the realm's + # token. It CONTAINS the confidential client secret, so the whole blob is held SOPS-encrypted in the + # semaphore-bootstrap Secret (key oidc-providers-json; same secret value SOPS-held in + # keycloak-realm-clients) and injected here by reference — never in cleartext. The name is NOT a + # kubelet service-link var (those are SEMAPHORE_SERVICE_HOST / SEMAPHORE_PORT_*), so no collision. + # The LOCAL SEMAPHORE_ADMIN above stays as the break-glass path (docs/15). --- + - name: SEMAPHORE_OIDC_PROVIDERS + valueFrom: + secretKeyRef: + name: semaphore-bootstrap + key: oidc-providers-json # The semaphoreui/semaphore image runs as the non-root user UID 1001 / GID 0 (tini -> server-wrapper), # and on boot the wrapper writes its generated config.json into the config dir. A mounted Longhorn PVC # is root:root, so UID 1001 cannot write it -> the pod panics ("permission denied"). fsGroup=1001 makes @@ -101,14 +116,11 @@ auth: mode: oidc # native Keycloak OIDC client (docs/13 SSO row, docs/15 "Native OIDC"; R-IDP-02) groups: - platform-admins # access scoped to an authorized group (R-IDP-06) - # NOTE (OIDC follow-up + break-glass): like Gitea/Nextcloud (docs/15 "Availability caveat"), - # Semaphore keeps a LOCAL admin as the break-glass path so an IdP outage never locks the operator - # out. At initial deploy it comes up on that local admin (SEMAPHORE_ADMIN* env above). The Keycloak - # `semaphore` realm client + Semaphore's OIDC provider config (config.json `oidc_providers`) is a - # DEFERRED follow-up: wiring it means editing the reconciled realm import in platform-config (a - # malformed edit fails the whole platform-config apply / can leave the realm import PENDING) — so it - # is deferred rather than risking the running Keycloak realm / Flux source. This records the target - # integration; it embeds no secret. + # WIRED (native OIDC): the Keycloak `semaphore` confidential client (platform/keycloak/keycloak-realm- + # import.yaml + live kcadm) is consumed via the SEMAPHORE_OIDC_PROVIDERS env (JSON blob held encrypted + # in the semaphore-bootstrap Secret, oidc-providers-json). The `groups` claim is exposed for + # authorization (R-IDP-06). Semaphore KEEPS its LOCAL admin as the break-glass path so an IdP outage + # never locks the operator out (docs/15 "Availability caveat"). No secret is embedded here. update: channel: stable # R-UPD-01 range: ">=2.19.0 <3.0.0" # stay on the 2.x stable line diff --git a/platform/keycloak/keycloak-realm-clients.sops.yaml b/platform/keycloak/keycloak-realm-clients.sops.yaml index aed9d13..d25b4e1 100644 --- a/platform/keycloak/keycloak-realm-clients.sops.yaml +++ b/platform/keycloak/keycloak-realm-clients.sops.yaml @@ -8,6 +8,8 @@ stringData: grafana-client-secret: ENC[AES256_GCM,data:J6grYrkJVubGNS+z1WrcUuvjKu2Z/fETWNXsKkjQnk/RlitaMjDqkazglKgy8PkEymNLNuVao48gksnuNLQ6aA==,iv:9sw5IdW+7GuwsRjn4NSi2JVZh9mTE2hiWJQYQS7w2bA=,tag:2hOUFeFqcQJsPeC8e6U4RA==,type:str] oauth2-proxy-client-secret: ENC[AES256_GCM,data:hE9mN6A6uTL3GpqOCGb3T0qVR9bcY4wcqNlgcrs9+uezSeci/Vs9uiXeFzfD57klHe8nEqxF1bCftD0GVFadhQ==,iv:rP2CbbGG4LWmGyV0adrlsmn2GtQLH8hSlqbioOAD824=,tag:p28vfuSZxYMs7XnAjslBWg==,type:str] gitea-client-secret: ENC[AES256_GCM,data:ObU5sWSq2xEZKgqwccFo/kzs/nfhOVJ0NceBlgxgy6a6iOWeUN079iQpJ0ADoJyjsoQk1IQediFD06rwVSvNIQ==,iv:Bc4L8MngA6YKNRQRwEQojnUo5ixdS2t1AocvGMwE0oQ=,tag:YsozaKtL6CR68sPmGiL5wA==,type:str] + nextcloud-client-secret: ENC[AES256_GCM,data:Uh3+EqIr88CNNt3KfDYP8lbNIhcB9iQrxJvbaxek40hD+JYAJmUP31WQypyDY3BiSvkjE6urr9PJmYoDgz7tkw==,iv:Tg4WAgyydIUBB1mqdOZPt0HsCkWSGNPvbI4WtFKj9V8=,tag:1LgZYdDPVEM+9UROanBllw==,type:str] + semaphore-client-secret: ENC[AES256_GCM,data:BX5lXhFNX6vQh67G2+XM2vt9wymhL8Psx+NqQJneFup7gdZRjhk8paEU5dOfugZNwooieara7UFyAFm5+1BJFg==,iv:kmmz0ZiCZgsSowns3hjDTQzoD2lPCr9Oz1iS5WgbhEE=,tag:BbesQX6lxAhaGiqPqMQ9vA==,type:str] sops: kms: [] gcp_kms: [] @@ -23,8 +25,8 @@ sops: YXJGUUJiTnFndFhDZDU1bmxXTENCbmsKuhb3RQ3LZmuTPAXEck8b6iuZ5vxLb9fV +fQELmU4kOTbZrPSxy+kpttLG5Cy5d6X2ieZXBf6opzpLTUhlBOkHA== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-08-27T21:58:13Z" - mac: ENC[AES256_GCM,data:+QNCSmJMoAAUibsQZ7by/Neno6VfxLoxCNUoRwHAW+T3SDrgQI7hkrVcWr39bpCne7AAGnlyVS7Zd4m3lk/ZGs90LbSx5xdFyGPQ7vpI774aaIMQJe+84pBPLGw1NH8bVmDOS99P0rlIG0lGBixl4UqrQ3BWhgnd63tAvnKnCBU=,iv:K4Wi+LvKhszj3bnRBuheHdbS2UwhlN6hBJP/H3nEo9M=,tag:P4+14EaB88QQNzDvCUlnJA==,type:str] + lastmodified: "2026-08-27T22:12:32Z" + mac: ENC[AES256_GCM,data:nruWApjP/BK694O9/BtSsQExmjtWTBNCfq+FyXDtockjRdDHRmyxuePVdfvm2F/OO7AuxI2EPYhr+a1SiB+RYGWfz7GCtbKW1iDPPmgiAnGxFH/HiuEGpa7S4+CVkNaD1j/z6jBkYe6oPko2IO/ezVtcTG/x7Wzst8vRDeid5Tw=,iv:wzuEl5cnycPMpoBGWrx1F2YoIgFufZ9se0yzVcQznEQ=,tag:B5pSL2IEC9T5maa7UZhYJw==,type:str] pgp: [] encrypted_regex: ^(data|stringData)$ version: 3.9.4 diff --git a/platform/keycloak/keycloak-realm-import.yaml b/platform/keycloak/keycloak-realm-import.yaml index 96a4192..763cf2f 100644 --- a/platform/keycloak/keycloak-realm-import.yaml +++ b/platform/keycloak/keycloak-realm-import.yaml @@ -27,6 +27,14 @@ spec: secret: name: keycloak-realm-clients key: gitea-client-secret + NEXTCLOUD_CLIENT_SECRET: + secret: + name: keycloak-realm-clients + key: nextcloud-client-secret + SEMAPHORE_CLIENT_SECRET: + secret: + name: keycloak-realm-clients + key: semaphore-client-secret realm: realm: homelab enabled: true @@ -182,6 +190,68 @@ spec: "access.token.claim": "true" "userinfo.token.claim": "true" "claim.name": groups + # nextcloud — native OIDC SSO for the file-sync host (catalog/nextcloud.yaml auth.mode oidc; docs/13 + # SSO row, docs/15; R-IDP-02). Confidential auth-code client modeled on grafana. Redirect URI is the + # user_oidc app's callback (/apps/user_oidc/code). The `groups` claim drives group provisioning in + # Nextcloud (user_oidc --group-provisioning; R-IDP-06). The LOCAL Nextcloud admin stays break-glass + # (docs/15). Secret injected from the SOPS keycloak-realm-clients Secret via NEXTCLOUD_CLIENT_SECRET. + - clientId: nextcloud + name: Nextcloud + protocol: openid-connect + enabled: true + publicClient: false + standardFlowEnabled: true + directAccessGrantsEnabled: false + secret: "$(env:NEXTCLOUD_CLIENT_SECRET)" + redirectUris: + - https://nextcloud.home.keithjackson.dev/apps/user_oidc/code + webOrigins: + - https://nextcloud.home.keithjackson.dev + defaultClientScopes: + - email + - profile + - roles + protocolMappers: + - name: groups + protocol: openid-connect + protocolMapper: oidc-group-membership-mapper + config: + "full.path": "false" + "id.token.claim": "true" + "access.token.claim": "true" + "userinfo.token.claim": "true" + "claim.name": groups + # semaphore — native OIDC SSO for the Ansible UI (catalog/semaphore.yaml auth.mode oidc; docs/13 SSO + # row, docs/15; R-IDP-02). Confidential auth-code client modeled on grafana. Redirect URI is + # Semaphore's OIDC callback (/api/auth/oidc//redirect for provider `keycloak`). The + # `groups` claim is exposed for authorization (R-IDP-06). The LOCAL Semaphore admin stays break-glass + # (docs/15). Secret injected from the SOPS keycloak-realm-clients Secret via SEMAPHORE_CLIENT_SECRET. + - clientId: semaphore + name: Semaphore + protocol: openid-connect + enabled: true + publicClient: false + standardFlowEnabled: true + directAccessGrantsEnabled: false + secret: "$(env:SEMAPHORE_CLIENT_SECRET)" + redirectUris: + - https://semaphore.home.keithjackson.dev/api/auth/oidc/keycloak/redirect + webOrigins: + - https://semaphore.home.keithjackson.dev + defaultClientScopes: + - email + - profile + - roles + protocolMappers: + - name: groups + protocol: openid-connect + protocolMapper: oidc-group-membership-mapper + config: + "full.path": "false" + "id.token.claim": "true" + "access.token.claim": "true" + "userinfo.token.claim": "true" + "claim.name": groups # --- MFA for platform-admins (R-IDP-04): a browser flow whose OTP step is CONDITIONAL on the # platform-admin role. Non-admins log in without OTP; platform-admins must enroll/present TOTP. # Set as the realm browserFlow below. VERIFY at bring-up (see runbook): a malformed flow fails