diff --git a/Scripts/authentik-sync-google-wif-oidc.sh b/Scripts/authentik-sync-google-wif-oidc.sh index bd150d0..c8bae10 100755 --- a/Scripts/authentik-sync-google-wif-oidc.sh +++ b/Scripts/authentik-sync-google-wif-oidc.sh @@ -11,7 +11,7 @@ client_id="${AUTHENTIK_GOOGLE_WIF_CLIENT_ID:-google-wif.burrow.net}" client_secret="${AUTHENTIK_GOOGLE_WIF_CLIENT_SECRET:-}" launch_url="${AUTHENTIK_GOOGLE_WIF_LAUNCH_URL:-https://console.cloud.google.com/}" redirect_uris_json="${AUTHENTIK_GOOGLE_WIF_REDIRECT_URIS_JSON:-[]}" -access_group="${AUTHENTIK_GOOGLE_WIF_ACCESS_GROUP:-burrow-automation}" +access_group="${AUTHENTIK_GOOGLE_WIF_ACCESS_GROUP:-}" usage() { cat <<'EOF' @@ -33,7 +33,11 @@ Optional environment: AUTHENTIK_GOOGLE_WIF_CLIENT_ID AUTHENTIK_GOOGLE_WIF_LAUNCH_URL AUTHENTIK_GOOGLE_WIF_REDIRECT_URIS_JSON - AUTHENTIK_GOOGLE_WIF_ACCESS_GROUP + AUTHENTIK_GOOGLE_WIF_ACCESS_GROUP (optional; leave empty for client credentials) + +AUTHENTIK_GOOGLE_WIF_CLIENT_SECRET is the Authentik client-credentials secret +used by Forgejo runners. For service-account authentication, store +base64(":"). EOF } @@ -214,9 +218,18 @@ existing_application="$( | head -n1 )" +if [[ -z "$existing_application" ]]; then + direct_application_result="$(api_with_status GET "/api/v3/core/applications/${application_slug}/")" + direct_application_status="$(printf '%s\n' "$direct_application_result" | sed -n '1p')" + direct_application_body="$(printf '%s\n' "$direct_application_result" | sed '1d')" + if [[ "$direct_application_status" == "200" ]]; then + existing_application="$direct_application_body" + fi +fi + if [[ -n "$existing_application" ]]; then application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" - api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null + api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null else create_application_result="$( api_with_status POST "/api/v3/core/applications/" "$application_payload" @@ -235,6 +248,14 @@ else | jq -r --arg slug "$application_slug" '.results[]? | select(.slug == $slug) | .pk // empty' \ | head -n1 )" + if [[ -z "$application_pk" ]]; then + direct_application_result="$(api_with_status GET "/api/v3/core/applications/${application_slug}/")" + direct_application_status="$(printf '%s\n' "$direct_application_result" | sed -n '1p')" + direct_application_body="$(printf '%s\n' "$direct_application_result" | sed '1d')" + if [[ "$direct_application_status" == "200" ]]; then + application_pk="$(printf '%s\n' "$direct_application_body" | jq -r '.pk // empty')" + fi + fi else printf '%s\n' "$create_application_body" >&2 echo "error: could not reconcile Authentik application ${application_slug}" >&2 @@ -247,6 +268,32 @@ if [[ -z "${application_pk:-}" ]]; then exit 1 fi +delete_binding() { + local binding_pk="$1" + api DELETE "/api/v3/policies/bindings/${binding_pk}/" >/dev/null || true +} + +delete_application_group_bindings_except() { + local keep_group_pk="${1:-}" + local binding_pks + + binding_pks="$( + api GET "/api/v3/policies/bindings/?page_size=500" \ + | jq -r \ + --arg target "$application_pk" \ + --arg keep_group "$keep_group_pk" \ + '.results[]? + | select((.target | tostring) == $target and .group != null) + | select(($keep_group == "") or ((.group | tostring) != $keep_group)) + | .pk' + )" + + while IFS= read -r binding_pk; do + [[ -n "$binding_pk" ]] || continue + delete_binding "$binding_pk" + done <<< "$binding_pks" +} + if [[ -n "$access_group" ]]; then group_pk="$( api GET "/api/v3/core/groups/?page_size=200" \ @@ -256,21 +303,36 @@ if [[ -n "$access_group" ]]; then if [[ -z "$group_pk" ]]; then echo "warning: Authentik Google WIF access group ${access_group} was not found; application policy left unchanged." >&2 else - api POST "/api/v3/policies/bindings/" "$( - jq -n \ - --arg target "$application_pk" \ - --arg group "$group_pk" \ - '{ - target: $target, - group: $group, - negate: false, - order: 0, - enabled: true, - timeout: 30, - failure_result: false - }' - )" >/dev/null || true + delete_application_group_bindings_except "$group_pk" + existing_binding="$( + api GET "/api/v3/policies/bindings/?page_size=500" \ + | jq -r \ + --arg target "$application_pk" \ + --arg group "$group_pk" \ + '.results[]? + | select((.target | tostring) == $target and (.group | tostring) == $group) + | .pk' \ + | head -n1 + )" + if [[ -z "$existing_binding" ]]; then + api POST "/api/v3/policies/bindings/" "$( + jq -n \ + --arg target "$application_pk" \ + --arg group "$group_pk" \ + '{ + target: $target, + group: $group, + negate: false, + order: 0, + enabled: true, + timeout: 30, + failure_result: false + }' + )" >/dev/null || true + fi fi +else + delete_application_group_bindings_except "" fi for _ in $(seq 1 30); do diff --git a/Scripts/ci/google-wif-auth.sh b/Scripts/ci/google-wif-auth.sh index 67cd03c..af38f9e 100755 --- a/Scripts/ci/google-wif-auth.sh +++ b/Scripts/ci/google-wif-auth.sh @@ -115,12 +115,13 @@ else fi token_endpoint="$(resolve_token_endpoint)" curl -fsS \ - -u "${authentik_client_id}:${authentik_client_secret}" \ -d grant_type=client_credentials \ + -d client_id="$authentik_client_id" \ + -d client_secret="$authentik_client_secret" \ -d scope="openid profile email groups" \ -d audience="$authentik_audience" \ "$token_endpoint" \ - | jq -r '.id_token // empty' > "$token_path" + | jq -r '.id_token // .access_token // empty' > "$token_path" fi chmod 0600 "$token_path" diff --git a/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md b/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md index 6c06146..51b55e7 100644 --- a/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md +++ b/evolution/proposals/BEP-0009-release-infrastructure-and-store-upload-pipeline.md @@ -85,6 +85,14 @@ The same change also makes the forge itself the release authority: release tags token endpoint, not a provider-slug child path. The helper should prefer an explicit `BURROW_AUTHENTIK_WIF_TOKEN_ENDPOINT`, then OIDC discovery, then the Authentik `/application/o/token/` fallback derived from the configured issuer. +- The Google WIF Authentik application is a machine-to-machine + client-credentials provider. It must not default to a human group policy + binding, because the token exchange has no interactive user and Authentik will + reject the grant before Google WIF can validate the issued JWT. +- Forgejo should store the Authentik WIF credential as the client-credentials + `client_secret` form value. For service-account authentication, that value is + `base64(":")`; the helper must + send it as form data, not HTTP Basic auth. - Produce unsigned Apple validation artifacts when signing is absent, but require signing for an App Store requested iOS release and for a Sparkle tester release. Uploadable artifacts are produced only after provisioning profiles can be synced from App Store Connect credentials and the relevant Apple certificate is proven to match the selected Google KMS key. - Resolve release credentials through age/agenix before signed Apple lanes run. Forgejo secrets should carry only the runner age identity fallback and the Authentik WIF client secret until OpenBao can mint the runner token directly. App Store Connect keys and signing certificates live as sealed files under `secrets/`. - Persist the forge runner SSH key as `/var/lib/forgejo-runner-agent/age_keystore` and export `BURROW_RUNNER_AGE_IDENTITY_PATH` so jobs can resolve agenix identities without embedding long-lived material in every workflow. diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index d423e94..633a0f5 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -302,7 +302,6 @@ in googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; googleAccountMapFile = config.age.secrets.burrowAuthentikGoogleAccountMap.path; googleWifClientSecretFile = config.age.secrets.burrowAuthentikGoogleWifClientSecret.path; - googleWifAccessGroupName = contributors.groups.automation; googleLoginMode = "redirect"; userGroupName = contributors.groups.users; adminGroupName = contributors.groups.admins; diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index b702c34..a96ece8 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -183,13 +183,13 @@ in googleWifClientSecretFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; - description = "Host-local file containing the Authentik Google WIF OIDC client secret."; + description = "Host-local file containing the Authentik Google WIF client-credentials secret."; }; googleWifAccessGroupName = lib.mkOption { - type = lib.types.str; - default = "burrow-automation"; - description = "Authentik group allowed to mint Google WIF tokens."; + type = lib.types.nullOr lib.types.str; + default = null; + description = "Optional Authentik group allowed to launch the Google WIF application. Leave null for client-credentials token exchange."; }; tailscaleProviderSlug = lib.mkOption { @@ -979,7 +979,9 @@ EOF export AUTHENTIK_GOOGLE_WIF_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.googleWifClientSecretFile})" export AUTHENTIK_GOOGLE_WIF_LAUNCH_URL=https://console.cloud.google.com/ export AUTHENTIK_GOOGLE_WIF_REDIRECT_URIS_JSON='[]' - export AUTHENTIK_GOOGLE_WIF_ACCESS_GROUP=${lib.escapeShellArg cfg.googleWifAccessGroupName} + ${lib.optionalString (cfg.googleWifAccessGroupName != null) '' + export AUTHENTIK_GOOGLE_WIF_ACCESS_GROUP=${lib.escapeShellArg cfg.googleWifAccessGroupName} + ''} ${pkgs.bash}/bin/bash ${googleWifOidcSyncScript} ''; diff --git a/secrets/infra/authentik-google-wif-client-secret.age b/secrets/infra/authentik-google-wif-client-secret.age index 1edee6c..427dbba 100644 --- a/secrets/infra/authentik-google-wif-client-secret.age +++ b/secrets/infra/authentik-google-wif-client-secret.age @@ -1,11 +1,11 @@ age-encryption.org/v1 --> ssh-ed25519 ux4N8Q tBglVPqKvCyAssCU7wVjFHHKlWCsbmar9sWJOThjPTY -4GKg+66JVi6ruPTlZg2SgmLPgMlGMDMtGUu8KmlxjRQ --> ssh-ed25519 IrZmAg C7CT5hJ2/TIVWBfG9v7CCeDTclDt9S5ejzLeE86EO0o -lSSn+DgRhJGCIQRRvpA3ErRD0OZtwSVgF/bu9Wj6OyU --> ssh-ed25519 6PdzAw 3ZN3lfIb13sJL7ZJhYdP27RxLfZQlY0TDFM+5zOdnwE -A3ccTSOvg4RNQRBOFsqdEIsO6EfXHoE8gIm1/rwVUAk --> X25519 M3bXNlL5lplqUSYeDi/MO7HhEnToTsDA1Xfhx9rI8TU -SlWPhylIEC5fpzj+gn9/tHmOEVoom1pyjlmxY+XhSPk ---- Op4e1VN3vK0xqjhndSOCTQcKwKGK76Y3gr6+B5P96tQ -Jh}cdJ] ,BN{0o nںN*`H- r \ No newline at end of file +-> ssh-ed25519 ux4N8Q dmNzqS+jc7JGI31mzsRRxSr7vAxRw3uxkbs4vzG20gk +sJWziiiMhKGzEiAeIxPfeqUYIu4CzZRw+eMXle5zMRs +-> ssh-ed25519 IrZmAg McLHhYu/3wDQYB1bJ/rXsoTv2UgpVTRm4AqsCTLkhCc +Rrl0vy0mhdXR0pSHckRI39Eg0bxB/Km3lCXXxl9t9Io +-> ssh-ed25519 0kWPgQ UQoa9fP3OPYtesWmMiWL1q8MqPxsXy//hj6805JQBRg +UKt/WM0t90OPDH9A+RVgrW60vlI2FmRewGsXGD5FCkU +-> X25519 48f0e9+P3ihg3mJIQxvHvq1P/4JEfS1t7DO73/TRGwg +k70uLkBQt5F1ovKhemYRhyKuPQXfT5XSXMzaQczPC8Q +--- L9B029nBJvUvCKhU2Avmmdmw5sce7bvhFpiPm0p+TVE +nG$\_$s&ΪALlZɪ N!ZA%LVb$(`aK