From be5b7d90dbd98a75ba19ae9356f61a2e3dc05169 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Tue, 31 Mar 2026 23:28:35 -0700 Subject: [PATCH] Enable Google Authentik login on forge --- Scripts/authentik-sync-google-source.sh | 284 ++++++++++++++++++ flake.lock | 4 +- nixos/README.md | 2 +- nixos/hosts/burrow-forge/default.nix | 14 + nixos/modules/burrow-authentik.nix | 77 +++++ secrets.nix | 2 + secrets/infra/authentik-google-client-id.age | Bin 0 -> 493 bytes .../infra/authentik-google-client-secret.age | 9 + 8 files changed, 389 insertions(+), 3 deletions(-) create mode 100755 Scripts/authentik-sync-google-source.sh create mode 100644 secrets/infra/authentik-google-client-id.age create mode 100644 secrets/infra/authentik-google-client-secret.age diff --git a/Scripts/authentik-sync-google-source.sh b/Scripts/authentik-sync-google-source.sh new file mode 100755 index 0000000..a4c9edb --- /dev/null +++ b/Scripts/authentik-sync-google-source.sh @@ -0,0 +1,284 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +google_client_id="${AUTHENTIK_GOOGLE_CLIENT_ID:-}" +google_client_secret="${AUTHENTIK_GOOGLE_CLIENT_SECRET:-}" +source_slug="${AUTHENTIK_GOOGLE_SOURCE_SLUG:-google}" +source_name="${AUTHENTIK_GOOGLE_SOURCE_NAME:-Google}" +identification_stage_name="${AUTHENTIK_GOOGLE_IDENTIFICATION_STAGE_NAME:-default-authentication-identification}" +authentication_flow_slug="${AUTHENTIK_GOOGLE_AUTHENTICATION_FLOW_SLUG:-default-source-authentication}" +enrollment_flow_slug="${AUTHENTIK_GOOGLE_ENROLLMENT_FLOW_SLUG:-default-source-enrollment}" +login_mode="${AUTHENTIK_GOOGLE_LOGIN_MODE:-redirect}" +user_matching_mode="${AUTHENTIK_GOOGLE_USER_MATCHING_MODE:-email_link}" +policy_engine_mode="${AUTHENTIK_GOOGLE_POLICY_ENGINE_MODE:-any}" +google_account_map_json="${AUTHENTIK_GOOGLE_ACCOUNT_MAP_JSON:-[]}" +property_mapping_name="${AUTHENTIK_GOOGLE_PROPERTY_MAPPING_NAME:-Burrow Google Account Map}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-google-source.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + AUTHENTIK_GOOGLE_CLIENT_ID + AUTHENTIK_GOOGLE_CLIENT_SECRET + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_GOOGLE_SOURCE_SLUG + AUTHENTIK_GOOGLE_SOURCE_NAME + AUTHENTIK_GOOGLE_IDENTIFICATION_STAGE_NAME + AUTHENTIK_GOOGLE_AUTHENTICATION_FLOW_SLUG + AUTHENTIK_GOOGLE_ENROLLMENT_FLOW_SLUG + AUTHENTIK_GOOGLE_LOGIN_MODE promoted|redirect + AUTHENTIK_GOOGLE_USER_MATCHING_MODE identifier|email_link|email_deny|username_link|username_deny + AUTHENTIK_GOOGLE_POLICY_ENGINE_MODE all|any + AUTHENTIK_GOOGLE_ACCOUNT_MAP_JSON JSON array of alias mappings + AUTHENTIK_GOOGLE_PROPERTY_MAPPING_NAME +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ -z "$bootstrap_token" ]]; then + echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 + exit 1 +fi + +if [[ -z "$google_client_id" || -z "$google_client_secret" || "$google_client_id" == PENDING* || "$google_client_secret" == PENDING* ]]; then + echo "Google OAuth credentials are not configured; skipping Authentik Google source sync." >&2 + echo "Set Authorized redirect URI in Google to ${authentik_url}/source/oauth/callback/${source_slug}/" >&2 + exit 0 +fi + +if ! printf '%s' "$google_account_map_json" | jq -e 'type == "array"' >/dev/null; then + echo "error: AUTHENTIK_GOOGLE_ACCOUNT_MAP_JSON must be a JSON array" >&2 + exit 1 +fi + +case "$login_mode" in + promoted|redirect) ;; + *) + echo "warning: unsupported AUTHENTIK_GOOGLE_LOGIN_MODE=$login_mode; falling back to redirect" >&2 + login_mode="redirect" + ;; +esac + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + + if [[ -n "$data" ]]; then + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + else + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + fi +} + +wait_for_authentik() { + for _ in $(seq 1 90); do + if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +lookup_single_result() { + local path="$1" + local jq_filter="$2" + + api GET "$path" | jq -r "$jq_filter" | head -n1 +} + +wait_for_authentik + +flow_pk="$( + lookup_single_result \ + "/api/v3/flows/instances/?slug=${authentication_flow_slug}" \ + '.results[] | select(.slug != null) | .pk // empty' +)" +if [[ -z "$flow_pk" ]]; then + echo "error: could not resolve Authentik authentication flow slug ${authentication_flow_slug}" >&2 + exit 1 +fi + +enrollment_flow_pk="$( + lookup_single_result \ + "/api/v3/flows/instances/?slug=${enrollment_flow_slug}" \ + '.results[] | select(.slug != null) | .pk // empty' +)" +if [[ -z "$enrollment_flow_pk" ]]; then + echo "error: could not resolve Authentik enrollment flow slug ${enrollment_flow_slug}" >&2 + exit 1 +fi + +identification_stage="$( + api GET "/api/v3/stages/identification/" \ + | jq -c --arg name "$identification_stage_name" '.results[] | select(.name == $name)' +)" +if [[ -z "$identification_stage" ]]; then + echo "error: could not resolve Authentik identification stage ${identification_stage_name}" >&2 + exit 1 +fi + +stage_pk="$(printf '%s\n' "$identification_stage" | jq -r '.pk')" + +property_mapping_payload='[]' +if [[ "$(printf '%s' "$google_account_map_json" | jq 'length')" -gt 0 ]]; then + alias_map_python="$( + printf '%s' "$google_account_map_json" \ + | jq -c ' + map({ + key: (.source_email | ascii_downcase), + value: { + username: .username, + email: .email, + name: .name + } + }) + | from_entries + ' + )" + + oauth_property_mapping_expression="$( + cat </dev/null + else + property_mapping_pk="$( + api POST "/api/v3/propertymappings/source/oauth/" "$oauth_property_mapping_payload" \ + | jq -r '.pk // empty' + )" + fi + + if [[ -z "${property_mapping_pk:-}" ]]; then + echo "error: Google OAuth property mapping did not return a primary key" >&2 + exit 1 + fi + + property_mapping_payload="$(jq -cn --arg property_mapping_pk "$property_mapping_pk" '[$property_mapping_pk]')" +fi + +oauth_source_payload="$( + jq -n \ + --arg name "$source_name" \ + --arg slug "$source_slug" \ + --arg authentication_flow "$flow_pk" \ + --arg enrollment_flow "$enrollment_flow_pk" \ + --arg user_matching_mode "$user_matching_mode" \ + --arg policy_engine_mode "$policy_engine_mode" \ + --argjson user_property_mappings "$property_mapping_payload" \ + --arg consumer_key "$google_client_id" \ + --arg consumer_secret "$google_client_secret" \ + '{ + name: $name, + slug: $slug, + enabled: true, + promoted: true, + authentication_flow: $authentication_flow, + enrollment_flow: $enrollment_flow, + user_property_mappings: $user_property_mappings, + group_property_mappings: [], + policy_engine_mode: $policy_engine_mode, + user_matching_mode: $user_matching_mode, + provider_type: "google", + consumer_key: $consumer_key, + consumer_secret: $consumer_secret + }' +)" + +existing_source="$( + api GET "/api/v3/sources/oauth/?slug=${source_slug}" \ + | jq -c '.results[]?' +)" + +if [[ -n "$existing_source" ]]; then + source_pk="$(printf '%s\n' "$existing_source" | jq -r '.pk')" + api PATCH "/api/v3/sources/oauth/${source_slug}/" "$oauth_source_payload" >/dev/null +else + source_pk="$( + api POST "/api/v3/sources/oauth/" "$oauth_source_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "$source_pk" ]]; then + echo "error: Google OAuth source did not return a primary key" >&2 + exit 1 +fi + +stage_patch="$( + printf '%s\n' "$identification_stage" \ + | jq -c \ + --arg source_pk "$source_pk" \ + --arg login_mode "$login_mode" ' + .sources = ( + if $login_mode == "redirect" then + [$source_pk] + else + ([ $source_pk ] + ((.sources // []) | map(select(. != $source_pk)))) + end + ) + | .show_source_labels = true + | if $login_mode == "redirect" then + .user_fields = [] + else + . + end + | { + sources, + show_source_labels, + user_fields + }' +)" + +api PATCH "/api/v3/stages/identification/${stage_pk}/" "$stage_patch" >/dev/null + +echo "Synced Authentik Google source ${source_slug} (${source_pk}) in ${login_mode} mode." diff --git a/flake.lock b/flake.lock index 599e193..1bafc37 100644 --- a/flake.lock +++ b/flake.lock @@ -52,8 +52,8 @@ ] }, "locked": { - "lastModified": 1773506317, - "narHash": "sha256-qWKbLUJpavIpvOdX1fhHYm0WGerytFHRoh9lVck6Bh0=", + "lastModified": 1773889306, + "narHash": "sha256-PAqwnsBSI9SVC2QugvQ3xeYCB0otOwCacB1ueQj2tgw=", "type": "tarball", "url": "https://codeload.github.com/nix-community/disko/tar.gz/master" }, diff --git a/nixos/README.md b/nixos/README.md index 7924944..acae40f 100644 --- a/nixos/README.md +++ b/nixos/README.md @@ -33,7 +33,7 @@ Mail hosting is intentionally not part of this NixOS host in the current plan. B 4. Let `burrow-forgejo-bootstrap.service` create or rotate the initial Forgejo admin account. 5. Let `burrow-forgejo-runner-bootstrap.service` register the self-hosted Forgejo runner and seed Git identity as `agent `. 6. Run `Scripts/provision-forgejo-nsc.sh` locally, then `Scripts/sync-forgejo-nsc-config.sh` to place the Namespace dispatcher/autoscaler runtime inputs under `/var/lib/burrow/intake/`. -7. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age` and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`. +7. Ensure `/var/lib/agenix/agenix.key` exists on the host, encrypt `secrets/infra/authentik.env.age`, `secrets/infra/authentik-google-client-id.age`, `secrets/infra/authentik-google-client-secret.age`, and `secrets/infra/headscale-oidc-client-secret.age`, and let agenix materialize them under `/run/agenix/`. 8. Use `Scripts/cloudflare-upsert-a-record.sh` to point `git.burrow.net`, `burrow.net`, `auth.burrow.net`, `ts.burrow.net`, and `nsc-autoscaler.burrow.net` at the host with Cloudflare proxying disabled for ACME. 9. Use `Scripts/forge-deploy.sh --allow-dirty` for subsequent remote `nixos-rebuild` runs from the live workspace. 10. Configure Forward Email custom S3 backups for `burrow.net` and `burrow.rs` out-of-band with `Tools/forwardemail-custom-s3.sh`. diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 43f65a3..6d4134c 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -33,6 +33,18 @@ group = "root"; mode = "0400"; }; + age.secrets.burrowAuthentikGoogleClientId = { + file = ../../../secrets/infra/authentik-google-client-id.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; + age.secrets.burrowAuthentikGoogleClientSecret = { + file = ../../../secrets/infra/authentik-google-client-secret.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; networking.extraHosts = '' 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net @@ -69,6 +81,8 @@ enable = true; envFile = config.age.secrets.burrowAuthentikEnv.path; headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; + googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; + googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; }; services.burrow.headscale = { diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 70ef2d7..9e6bf1f 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -8,6 +8,7 @@ let blueprintFile = "${blueprintDir}/burrow-authentik.yaml"; postgresVolume = "burrow-authentik-postgresql:/var/lib/postgresql/data"; dataVolume = "burrow-authentik-data:/data"; + googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' version: 1 metadata: @@ -106,6 +107,33 @@ in default = "/var/lib/burrow/intake/authentik_headscale_client_secret.txt"; description = "Host-local file containing the Authentik Headscale OIDC client secret."; }; + + googleClientIDFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Host-local file containing the Google OAuth client ID for the Authentik source."; + }; + + googleClientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Host-local file containing the Google OAuth client secret for the Authentik source."; + }; + + googleSourceSlug = lib.mkOption { + type = lib.types.str; + default = "google"; + description = "Authentik OAuth source slug used for Google login."; + }; + + googleLoginMode = lib.mkOption { + type = lib.types.enum [ + "promoted" + "redirect" + ]; + default = "redirect"; + description = "Identification-stage behavior for the Google Authentik source."; + }; }; config = lib.mkIf cfg.enable { @@ -263,6 +291,55 @@ EOF ''; }; + systemd.services.burrow-authentik-google-source = lib.mkIf ( + cfg.googleClientIDFile != null && cfg.googleClientSecretFile != null + ) { + description = "Reconcile the Burrow Authentik Google OAuth source"; + after = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + googleSourceSyncScript + cfg.envFile + cfg.googleClientIDFile + cfg.googleClientSecretFile + ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + Restart = "on-failure"; + RestartSec = 5; + }; + script = '' + set -euo pipefail + set -a + source ${lib.escapeShellArg cfg.envFile} + set +a + + export AUTHENTIK_URL=https://${cfg.domain} + export AUTHENTIK_GOOGLE_SOURCE_SLUG=${lib.escapeShellArg cfg.googleSourceSlug} + export AUTHENTIK_GOOGLE_LOGIN_MODE=${lib.escapeShellArg cfg.googleLoginMode} + export AUTHENTIK_GOOGLE_USER_MATCHING_MODE=email_link + export AUTHENTIK_GOOGLE_CLIENT_ID="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.googleClientIDFile})" + export AUTHENTIK_GOOGLE_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.googleClientSecretFile})" + + ${pkgs.bash}/bin/bash ${googleSourceSyncScript} + ''; + }; + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd reverse_proxy 127.0.0.1:${toString cfg.port} diff --git a/secrets.nix b/secrets.nix index 4382fd6..c63d898 100644 --- a/secrets.nix +++ b/secrets.nix @@ -10,5 +10,7 @@ let in { "secrets/infra/authentik.env.age".publicKeys = burrowForgeRecipients; + "secrets/infra/authentik-google-client-id.age".publicKeys = burrowForgeRecipients; + "secrets/infra/authentik-google-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; } diff --git a/secrets/infra/authentik-google-client-id.age b/secrets/infra/authentik-google-client-id.age new file mode 100644 index 0000000000000000000000000000000000000000..f295804f68781d005cf5d43f5fa1e4f1dd49d320 GIT binary patch literal 493 zcmYdHPt{G$OD?J`D9Oyv)5|YP*Do{V(zR14F3!+RO))YxHMCSHtuXPk2vo34D-H`N z4s_2mvMly@_H&EKH!caQFflR>Obqoe$Z#{(Hpt8IHVE={H{o&#^~+0j)z0$D&G0tP za1RSik1Q?CDm2jcjm$Lgj|$JobBhQsDR=cV@I|-HvnVRpFDAPGN%-1)? zJ;kgt-_YHsG$7rv%EKTlJ=NUB(Gz4_1jJiLj%LOssfLCwensUG&gG$jd4(osmgaPg*IIYT5fqcU?aQk>Z-TR0;T@>)0RXXn}51>-pTHJ SSx3#pe;XZ)NG{y;ZY}@@rLS24 literal 0 HcmV?d00001 diff --git a/secrets/infra/authentik-google-client-secret.age b/secrets/infra/authentik-google-client-secret.age new file mode 100644 index 0000000..43ecf0b --- /dev/null +++ b/secrets/infra/authentik-google-client-secret.age @@ -0,0 +1,9 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q 4uq5z93mRUUgcMOxP4+Yfe2Jq4tGYErwtzvtMHUvgi0 +J9DkDeSPkQbOjFM3QoV+1Kz3ZVLfR4PUxCT8Zxz+Wvk +-> ssh-ed25519 IrZmAg uLEVmJ+e9ZiLas5YooR4GfgyspWTsFdMB2WPvluU/VI +7vqqQ/BIDQaOp6VDVLa5ugoRxVZZsMj116cTHY6+8KM +-> X25519 9spF9eLz63UOaBfuG9vTIr6bCKwzFsWMjnaIj1PIR3Y +iGFELg2RQUT9rEal7pblQhfxtwYhxsZdXYxEhvjtHpw +--- 3TDrUnIN826N/n5gc+YY8ilMMc/6K8zGTh6FxzKC/JM +XH#IJGueֹf&1a2BJԎg=̿.*7Fb \ No newline at end of file