diff --git a/Scripts/authentik-sync-forgejo-oidc.sh b/Scripts/authentik-sync-forgejo-oidc.sh new file mode 100644 index 0000000..f354633 --- /dev/null +++ b/Scripts/authentik-sync-forgejo-oidc.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +application_slug="${AUTHENTIK_FORGEJO_APPLICATION_SLUG:-git}" +application_name="${AUTHENTIK_FORGEJO_APPLICATION_NAME:-burrow.net}" +provider_name="${AUTHENTIK_FORGEJO_PROVIDER_NAME:-burrow.net}" +client_id="${AUTHENTIK_FORGEJO_CLIENT_ID:-git.burrow.net}" +client_secret="${AUTHENTIK_FORGEJO_CLIENT_SECRET:-}" +launch_url="${AUTHENTIK_FORGEJO_LAUNCH_URL:-https://git.burrow.net/}" +redirect_uris_json="${AUTHENTIK_FORGEJO_REDIRECT_URIS_JSON:-[ + \"https://git.burrow.net/user/oauth2/burrow.net/callback\", + \"https://git.burrow.net/user/oauth2/authentik/callback\", + \"https://git.burrow.net/user/oauth2/GitHub/callback\" +]}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-forgejo-oidc.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + AUTHENTIK_FORGEJO_CLIENT_SECRET + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_FORGEJO_APPLICATION_SLUG + AUTHENTIK_FORGEJO_APPLICATION_NAME + AUTHENTIK_FORGEJO_PROVIDER_NAME + AUTHENTIK_FORGEJO_CLIENT_ID + AUTHENTIK_FORGEJO_LAUNCH_URL + AUTHENTIK_FORGEJO_REDIRECT_URIS_JSON +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 "$client_secret" || "$client_secret" == PENDING* ]]; then + echo "Forgejo OIDC client secret is not configured; skipping Authentik Forgejo sync." >&2 + exit 0 +fi + +if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then + echo "error: AUTHENTIK_FORGEJO_REDIRECT_URIS_JSON must be a non-empty JSON array" >&2 + exit 1 +fi + +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 +} + +wait_for_authentik + +template_provider="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -c '.results[]? | select(.assigned_application_slug == "ts")' \ + | head -n1 +)" + +if [[ -z "$template_provider" ]]; then + echo "error: could not resolve the Burrow Tailnet OAuth provider template" >&2 + exit 1 +fi + +authorization_flow="$(printf '%s\n' "$template_provider" | jq -r '.authorization_flow')" +invalidation_flow="$(printf '%s\n' "$template_provider" | jq -r '.invalidation_flow')" +property_mappings="$(printf '%s\n' "$template_provider" | jq -c '.property_mappings')" +signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')" + +provider_payload="$( + jq -n \ + --arg name "$provider_name" \ + --arg slug "$application_slug" \ + --arg authorization_flow "$authorization_flow" \ + --arg invalidation_flow "$invalidation_flow" \ + --arg client_id "$client_id" \ + --arg client_secret "$client_secret" \ + --arg signing_key "$signing_key" \ + --argjson property_mappings "$property_mappings" \ + --argjson redirect_uris "$redirect_uris_json" \ + '{ + name: $name, + slug: $slug, + authorization_flow: $authorization_flow, + invalidation_flow: $invalidation_flow, + client_type: "confidential", + client_id: $client_id, + client_secret: $client_secret, + include_claims_in_id_token: true, + redirect_uris: ($redirect_uris | map({matching_mode: "strict", url: .})), + property_mappings: $property_mappings, + signing_key: $signing_key, + issuer_mode: "per_provider", + sub_mode: "hashed_user_id" + }' +)" + +existing_provider="$( + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -c \ + --arg application_slug "$application_slug" \ + --arg provider_name "$provider_name" \ + '.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \ + | head -n1 +)" + +if [[ -n "$existing_provider" ]]; then + provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')" + api PATCH "/api/v3/providers/oauth2/${provider_pk}/" "$provider_payload" >/dev/null +else + provider_pk="$( + api POST "/api/v3/providers/oauth2/" "$provider_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "${provider_pk:-}" ]]; then + echo "error: Forgejo OIDC provider did not return a primary key" >&2 + exit 1 +fi + +application_payload="$( + jq -n \ + --arg name "$application_name" \ + --arg slug "$application_slug" \ + --arg provider "$provider_pk" \ + --arg launch_url "$launch_url" \ + '{ + name: $name, + slug: $slug, + provider: ($provider | tonumber), + meta_launch_url: $launch_url, + open_in_new_tab: false, + policy_engine_mode: "any" + }' +)" + +existing_application="$( + api GET "/api/v3/core/applications/?slug=${application_slug}" \ + | jq -c '.results[]? | select(.slug != null)' \ + | head -n1 +)" + +if [[ -n "$existing_application" ]]; then + application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" +else + application_pk="$( + api POST "/api/v3/core/applications/" "$application_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "${application_pk:-}" ]]; then + echo "error: Forgejo OIDC application did not return a primary key" >&2 + exit 1 +fi + +for _ in $(seq 1 30); do + if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then + echo "Synced Authentik Forgejo OIDC application ${application_slug} (${application_name})." + exit 0 + fi + sleep 2 +done + +echo "warning: Forgejo OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2 +echo "Synced Authentik Forgejo OIDC application ${application_slug} (${application_name})." diff --git a/nixos/README.md b/nixos/README.md index acae40f..07b421d 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`, `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/`. +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`, `secrets/infra/forgejo-oidc-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 6d4134c..314d6f1 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -33,6 +33,12 @@ group = "root"; mode = "0400"; }; + age.secrets.burrowForgejoOidcClientSecret = { + file = ../../../secrets/infra/forgejo-oidc-client-secret.age; + owner = "forgejo"; + group = "forgejo"; + mode = "0440"; + }; age.secrets.burrowAuthentikGoogleClientId = { file = ../../../secrets/infra/authentik-google-client-id.age; owner = "root"; @@ -54,6 +60,7 @@ services.burrow.forge = { enable = true; adminPasswordFile = "/var/lib/burrow/intake/forgejo_pass_contact_at_burrow_net.txt"; + oidcClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; authorizedKeys = [ (builtins.readFile ../../keys/contact_at_burrow_net.pub) (builtins.readFile ../../keys/agent_at_burrow_net.pub) @@ -80,6 +87,7 @@ services.burrow.authentik = { enable = true; envFile = config.age.secrets.burrowAuthentikEnv.path; + forgejoClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 9e6bf1f..78a305a 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"; + forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' version: 1 @@ -102,6 +103,30 @@ in description = "Authentik provider slug for Headscale."; }; + forgejoDomain = lib.mkOption { + type = lib.types.str; + default = "git.burrow.net"; + description = "Forgejo public domain used for the bundled OIDC client."; + }; + + forgejoProviderSlug = lib.mkOption { + type = lib.types.str; + default = "git"; + description = "Authentik application slug for Forgejo."; + }; + + forgejoClientId = lib.mkOption { + type = lib.types.str; + default = "git.burrow.net"; + description = "Client ID Authentik should present to Forgejo."; + }; + + forgejoClientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Host-local file containing the Authentik Forgejo OIDC client secret."; + }; + headscaleClientSecretFile = lib.mkOption { type = lib.types.str; default = "/var/lib/burrow/intake/authentik_headscale_client_secret.txt"; @@ -182,6 +207,13 @@ in exit 1 fi + ${lib.optionalString (cfg.forgejoClientSecretFile != null) '' + if [ ! -s ${lib.escapeShellArg cfg.forgejoClientSecretFile} ]; then + echo "Forgejo client secret missing: ${cfg.forgejoClientSecretFile}" >&2 + exit 1 + fi + ''} + install -d -m 0750 -o root -g root ${runtimeDir} ${blueprintDir} install -m 0644 -o root -g root ${authentikBlueprint} ${blueprintFile} @@ -208,6 +240,7 @@ AUTHENTIK_SECRET_KEY=$AUTHENTIK_SECRET_KEY AUTHENTIK_BOOTSTRAP_PASSWORD=$AUTHENTIK_BOOTSTRAP_PASSWORD AUTHENTIK_BOOTSTRAP_TOKEN=$AUTHENTIK_BOOTSTRAP_TOKEN AUTHENTIK_BURROW_TS_CLIENT_SECRET=$(read_secret ${lib.escapeShellArg cfg.headscaleClientSecretFile}) +${lib.optionalString (cfg.forgejoClientSecretFile != null) "AUTHENTIK_BURROW_FORGEJO_CLIENT_SECRET=$(read_secret ${lib.escapeShellArg cfg.forgejoClientSecretFile})"} EOF chown root:root ${envFile} chmod 0600 ${envFile} @@ -320,8 +353,6 @@ EOF Type = "oneshot"; User = "root"; Group = "root"; - Restart = "on-failure"; - RestartSec = 5; }; script = '' set -euo pipefail @@ -340,6 +371,52 @@ EOF ''; }; + systemd.services.burrow-authentik-forgejo-oidc = lib.mkIf (cfg.forgejoClientSecretFile != null) { + description = "Reconcile the Burrow Authentik Forgejo OIDC application"; + after = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + forgejoOidcSyncScript + cfg.envFile + cfg.forgejoClientSecretFile + ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + set -a + source ${lib.escapeShellArg cfg.envFile} + set +a + + export AUTHENTIK_URL=https://${cfg.domain} + export AUTHENTIK_FORGEJO_APPLICATION_SLUG=${lib.escapeShellArg cfg.forgejoProviderSlug} + export AUTHENTIK_FORGEJO_APPLICATION_NAME=burrow.net + export AUTHENTIK_FORGEJO_PROVIDER_NAME=burrow.net + export AUTHENTIK_FORGEJO_CLIENT_ID=${lib.escapeShellArg cfg.forgejoClientId} + export AUTHENTIK_FORGEJO_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.forgejoClientSecretFile})" + export AUTHENTIK_FORGEJO_LAUNCH_URL=https://${cfg.forgejoDomain}/ + export AUTHENTIK_FORGEJO_REDIRECT_URIS_JSON='["https://${cfg.forgejoDomain}/user/oauth2/burrow.net/callback","https://${cfg.forgejoDomain}/user/oauth2/authentik/callback","https://${cfg.forgejoDomain}/user/oauth2/GitHub/callback"]' + + ${pkgs.bash}/bin/bash ${forgejoOidcSyncScript} + ''; + }; + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd reverse_proxy 127.0.0.1:${toString cfg.port} diff --git a/nixos/modules/burrow-forge.nix b/nixos/modules/burrow-forge.nix index e02475f..edf5538 100644 --- a/nixos/modules/burrow-forge.nix +++ b/nixos/modules/burrow-forge.nix @@ -68,6 +68,30 @@ in description = "Host-local path to the plaintext bootstrap password file for the initial Forgejo admin."; }; + oidcDisplayName = lib.mkOption { + type = lib.types.str; + default = "burrow.net"; + description = "Login button label for the Forgejo OIDC provider."; + }; + + oidcClientId = lib.mkOption { + type = lib.types.str; + default = "git.burrow.net"; + description = "OIDC client ID that Forgejo should use against Authentik."; + }; + + oidcClientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Host-local path to the Forgejo OIDC client secret."; + }; + + oidcDiscoveryUrl = lib.mkOption { + type = lib.types.str; + default = "https://auth.burrow.net/application/o/git/.well-known/openid-configuration"; + description = "OpenID Connect discovery URL for the Forgejo login source."; + }; + authorizedKeys = lib.mkOption { type = with lib.types; listOf str; default = [ ]; @@ -243,5 +267,113 @@ in fi ''; }; + + systemd.services.burrow-forgejo-oidc-bootstrap = lib.mkIf (cfg.oidcClientSecretFile != null) { + description = "Seed the Burrow Forgejo OIDC login source"; + after = [ + "forgejo.service" + "postgresql.service" + ] ++ lib.optionals config.services.burrow.authentik.enable [ + "burrow-authentik-ready.service" + ]; + wants = lib.optionals config.services.burrow.authentik.enable [ + "burrow-authentik-ready.service" + ]; + requires = [ + "forgejo.service" + "postgresql.service" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + cfg.oidcClientSecretFile + ]; + path = [ + pkgs.coreutils + pkgs.gnugrep + pkgs.jq + pkgs.postgresql + ]; + serviceConfig = { + Type = "oneshot"; + User = forgejoCfg.user; + Group = forgejoCfg.group; + WorkingDirectory = forgejoCfg.stateDir; + }; + script = '' + set -euo pipefail + + if [ ! -s ${lib.escapeShellArg cfg.oidcClientSecretFile} ]; then + echo "Forgejo OIDC client secret missing: ${cfg.oidcClientSecretFile}" >&2 + exit 1 + fi + + ready=0 + for attempt in $(seq 1 60); do + if ${pkgs.postgresql}/bin/psql -h /run/postgresql -U forgejo forgejo -tAc \ + "SELECT 1 FROM pg_tables WHERE schemaname='public' AND tablename='login_source';" \ + | grep -q 1; then + ready=1 + break + fi + sleep 1 + done + + if [ "$ready" -ne 1 ]; then + echo "Forgejo login_source table did not become ready" >&2 + exit 1 + fi + + oidc_secret="$(${pkgs.coreutils}/bin/tr -d '\r\n' < ${lib.escapeShellArg cfg.oidcClientSecretFile})" + if [ -z "$oidc_secret" ]; then + echo "Forgejo OIDC client secret is empty" >&2 + exit 1 + fi + + cfg_json="$(${pkgs.jq}/bin/jq -nc \ + --arg client_id ${lib.escapeShellArg cfg.oidcClientId} \ + --arg client_secret "$oidc_secret" \ + --arg discovery_url ${lib.escapeShellArg cfg.oidcDiscoveryUrl} \ + '{ + Provider: "openidConnect", + ClientID: $client_id, + ClientSecret: $client_secret, + OpenIDConnectAutoDiscoveryURL: $discovery_url, + CustomURLMapping: null, + IconURL: "", + Scopes: ["openid", "profile", "email"], + AttributeSSHPublicKey: "", + RequiredClaimName: "", + RequiredClaimValue: "", + GroupClaimName: "", + AdminGroup: "", + GroupTeamMap: "", + GroupTeamMapRemoval: false, + RestrictedGroup: "" + }')" + + ${pkgs.postgresql}/bin/psql -v ON_ERROR_STOP=1 \ + -h /run/postgresql -U forgejo forgejo \ + -v oidc_name=${lib.escapeShellArg cfg.oidcDisplayName} \ + -v cfg_json="$cfg_json" <<'SQL' + INSERT INTO login_source ( + type, name, is_active, is_sync_enabled, cfg, created_unix, updated_unix + ) VALUES ( + 6, + :'oidc_name', + TRUE, + FALSE, + :'cfg_json', + EXTRACT(EPOCH FROM NOW())::BIGINT, + EXTRACT(EPOCH FROM NOW())::BIGINT + ) + ON CONFLICT (name) DO UPDATE SET + type = EXCLUDED.type, + is_active = TRUE, + is_sync_enabled = FALSE, + cfg = EXCLUDED.cfg, + updated_unix = EXCLUDED.updated_unix; + SQL + ''; + }; }; } diff --git a/secrets.nix b/secrets.nix index c63d898..909b929 100644 --- a/secrets.nix +++ b/secrets.nix @@ -12,5 +12,6 @@ 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/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; } diff --git a/secrets/infra/forgejo-oidc-client-secret.age b/secrets/infra/forgejo-oidc-client-secret.age new file mode 100644 index 0000000..ce6c440 --- /dev/null +++ b/secrets/infra/forgejo-oidc-client-secret.age @@ -0,0 +1,10 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q eaJ7I0AyitRWPLXnTbaazTiQ0qv2DRKOBNwx++QVrGk +1ScGy1EN80pr6QjJCToe/YRb0yHuFDR9pjoaWI/GlW8 +-> ssh-ed25519 IrZmAg AQIz2iWOSu+ewmasAa0nRFV17grA5/IRi4NEBinKaQ8 +8QIufDokWybbiRWV/OJle7kOdomyOnXSnxJeKF+5YI8 +-> X25519 9pO0rjF27QSQ6ZOgLiWAzbCBIP3MVZSapB+udiuz400 +74Ws3sCw4O3HvoCX96UhZd6b1SMptE82z9OIuEisOu8 +--- 8UR5iYLjAo6k1A3hpwiG+/mi2ZweMDvTbvi+XMWiimA +‹*€ †¦¥Z¨(Ñ”Q»Ä ^ˆÜ¯Ëu+.×ÇnhŸs=Ä0VŽŒãRF +É=GeÇç;z•ã‰m_ÃV…Maîr‡Çk4h«ÿ«óøÝ‘¸’±~Y<¿æûÅÏ#¶:öâ> \ No newline at end of file