diff --git a/Scripts/authentik-sync-tailscale-oidc.sh b/Scripts/authentik-sync-tailscale-oidc.sh new file mode 100755 index 0000000..54564ad --- /dev/null +++ b/Scripts/authentik-sync-tailscale-oidc.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +application_slug="${AUTHENTIK_TAILSCALE_APPLICATION_SLUG:-tailscale}" +application_name="${AUTHENTIK_TAILSCALE_APPLICATION_NAME:-Tailscale}" +provider_name="${AUTHENTIK_TAILSCALE_PROVIDER_NAME:-Tailscale}" +template_slug="${AUTHENTIK_TAILSCALE_TEMPLATE_SLUG:-ts}" +client_id="${AUTHENTIK_TAILSCALE_CLIENT_ID:-tailscale.burrow.net}" +client_secret="${AUTHENTIK_TAILSCALE_CLIENT_SECRET:-}" +launch_url="${AUTHENTIK_TAILSCALE_LAUNCH_URL:-https://login.tailscale.com/start/oidc}" +redirect_uris_json="${AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON:-[ + \"https://login.tailscale.com/a/oauth_response\" +]}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-tailscale-oidc.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + AUTHENTIK_TAILSCALE_CLIENT_SECRET + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_TAILSCALE_APPLICATION_SLUG + AUTHENTIK_TAILSCALE_APPLICATION_NAME + AUTHENTIK_TAILSCALE_PROVIDER_NAME + AUTHENTIK_TAILSCALE_TEMPLATE_SLUG + AUTHENTIK_TAILSCALE_CLIENT_ID + AUTHENTIK_TAILSCALE_LAUNCH_URL + AUTHENTIK_TAILSCALE_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 "Tailscale OIDC client secret is not configured; skipping Authentik Tailscale sync." >&2 + exit 0 +fi + +if ! printf '%s' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then + echo "error: AUTHENTIK_TAILSCALE_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 +} + +api_with_status() { + local method="$1" + local path="$2" + local data="${3:-}" + local response_file status + + response_file="$(mktemp)" + trap 'rm -f "$response_file"' RETURN + + if [[ -n "$data" ]]; then + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + )" + else + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + )" + fi + + printf '%s\n' "$status" + cat "$response_file" +} + +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 --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \ + | head -n1 +)" + +if [[ -z "$template_provider" ]]; then + echo "error: could not resolve the Authentik OAuth provider template ${template_slug}" >&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 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, + 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: Tailscale 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: true, + policy_engine_mode: "any" + }' +)" + +existing_application="$( + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \ + | head -n1 +)" + +if [[ -n "$existing_application" ]]; then + application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" +else + create_application_result="$( + api_with_status POST "/api/v3/core/applications/" "$application_payload" + )" + create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')" + create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')" + + if [[ "$create_application_status" =~ ^20[01]$ ]]; then + application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')" + elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e ' + (.slug // [] | index("Application with this slug already exists.")) != null + or (.provider // [] | index("Application with this provider already exists.")) != null + ' >/dev/null; then + application_pk="existing-duplicate" + else + printf '%s\n' "$create_application_body" >&2 + echo "error: could not reconcile Authentik application ${application_slug}" >&2 + exit 1 + fi +fi + +if [[ -z "${application_pk:-}" ]]; then + echo "error: Tailscale 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 Tailscale OIDC application ${application_slug} (${application_name})." + exit 0 + fi + sleep 2 +done + +echo "warning: Tailscale OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2 +echo "Synced Authentik Tailscale OIDC application ${application_slug} (${application_name})." diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 67c87ec..75b76d4 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -63,6 +63,12 @@ in group = "forgejo"; mode = "0440"; }; + age.secrets.burrowTailscaleOidcClientSecret = { + file = ../../../secrets/infra/tailscale-oidc-client-secret.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; age.secrets.burrowAuthentikGoogleClientId = { file = ../../../secrets/infra/authentik-google-client-id.age; owner = "root"; @@ -121,6 +127,7 @@ in envFile = config.age.secrets.burrowAuthentikEnv.path; forgejoClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; + tailscaleClientSecretFile = config.age.secrets.burrowTailscaleOidcClientSecret.path; googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; googleLoginMode = "redirect"; diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 478d0d9..6861f17 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -10,6 +10,7 @@ let dataVolume = "burrow-authentik-data:/data"; directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh; forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; + tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh; authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' @@ -131,6 +132,24 @@ in description = "Authentik application slug for Forgejo."; }; + tailscaleProviderSlug = lib.mkOption { + type = lib.types.str; + default = "tailscale"; + description = "Authentik application slug for Tailscale custom OIDC sign-in."; + }; + + tailscaleClientId = lib.mkOption { + type = lib.types.str; + default = "tailscale.burrow.net"; + description = "Client ID Authentik should present to Tailscale."; + }; + + tailscaleClientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Host-local file containing the Authentik Tailscale OIDC client secret."; + }; + forgejoClientId = lib.mkOption { type = lib.types.str; default = "git.burrow.net"; @@ -313,6 +332,13 @@ in fi ''} + ${lib.optionalString (cfg.tailscaleClientSecretFile != null) '' + if [ ! -s ${lib.escapeShellArg cfg.tailscaleClientSecretFile} ]; then + echo "Tailscale client secret missing: ${cfg.tailscaleClientSecretFile}" >&2 + exit 1 + fi + ''} + install -d -m 0750 -o root -g root ${runtimeDir} ${blueprintDir} install -m 0644 -o root -g root ${authentikBlueprint} ${blueprintFile} @@ -634,6 +660,53 @@ EOF ''; }; + systemd.services.burrow-authentik-tailscale-oidc = lib.mkIf (cfg.tailscaleClientSecretFile != null) { + description = "Reconcile the Burrow Authentik Tailscale OIDC application"; + after = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + tailscaleOidcSyncScript + cfg.envFile + cfg.tailscaleClientSecretFile + ]; + 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_TAILSCALE_APPLICATION_SLUG=${lib.escapeShellArg cfg.tailscaleProviderSlug} + export AUTHENTIK_TAILSCALE_APPLICATION_NAME=Tailscale + export AUTHENTIK_TAILSCALE_PROVIDER_NAME=Tailscale + export AUTHENTIK_TAILSCALE_TEMPLATE_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} + export AUTHENTIK_TAILSCALE_CLIENT_ID=${lib.escapeShellArg cfg.tailscaleClientId} + export AUTHENTIK_TAILSCALE_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.tailscaleClientSecretFile})" + export AUTHENTIK_TAILSCALE_LAUNCH_URL=https://login.tailscale.com/start/oidc + export AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON='["https://login.tailscale.com/a/oauth_response"]' + + ${pkgs.bash}/bin/bash ${tailscaleOidcSyncScript} + ''; + }; + 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 d74fc65..d733135 100644 --- a/nixos/modules/burrow-forge.nix +++ b/nixos/modules/burrow-forge.nix @@ -258,13 +258,13 @@ in "${cfg.siteDomain}".extraConfig = '' encode gzip zstd @oidcConfig path /.well-known/openid-configuration - redir @oidcConfig https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.forgejoProviderSlug}/.well-known/openid-configuration 308 + redir @oidcConfig https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.tailscaleProviderSlug}/.well-known/openid-configuration 308 @tailnetConfig path /.well-known/burrow-tailnet header @tailnetConfig Content-Type application/json respond @tailnetConfig "{\"domain\":\"${cfg.siteDomain}\",\"provider\":\"headscale\",\"authority\":\"https://${config.services.burrow.headscale.domain}\",\"oidc_issuer\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.headscaleProviderSlug}/\"}" 200 @webfinger path /.well-known/webfinger header @webfinger Content-Type application/jrd+json - respond @webfinger "{\"subject\":\"{query.resource}\",\"links\":[{\"rel\":\"http://openid.net/specs/connect/1.0/issuer\",\"href\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.forgejoProviderSlug}/\"},{\"rel\":\"https://burrow.net/rel/tailnet-control-server\",\"href\":\"https://${config.services.burrow.headscale.domain}\"}]}" 200 + respond @webfinger "{\"subject\":\"{query.resource}\",\"links\":[{\"rel\":\"http://openid.net/specs/connect/1.0/issuer\",\"href\":\"https://${config.services.burrow.authentik.domain}/application/o/${config.services.burrow.authentik.tailscaleProviderSlug}/\"},{\"rel\":\"https://burrow.net/rel/tailnet-control-server\",\"href\":\"https://${config.services.burrow.headscale.domain}\"}]}" 200 @root path / redir @root ${homeRepoUrl} 308 respond 404 diff --git a/secrets.nix b/secrets.nix index 5a3ac8c..c0b9b53 100644 --- a/secrets.nix +++ b/secrets.nix @@ -17,4 +17,5 @@ in "secrets/infra/authentik-ui-test-password.age".publicKeys = uiTestRecipients; "secrets/infra/forgejo-oidc-client-secret.age".publicKeys = burrowForgeRecipients; "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; + "secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; } diff --git a/secrets/infra/tailscale-oidc-client-secret.age b/secrets/infra/tailscale-oidc-client-secret.age new file mode 100644 index 0000000..e88c2d1 --- /dev/null +++ b/secrets/infra/tailscale-oidc-client-secret.age @@ -0,0 +1,10 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q KfvLMiH7JHE6v74Pp//SqzBP8WU1MNy1/EcqsONTTQQ +Y6SFXWe/5Pru6+3vU6e67bRZDWDkukdfgEX7uQjB4Uw +-> ssh-ed25519 IrZmAg AFn7BP4FktUYH9QvNJPVDdNcEpJjYqmOrisvX9XGV08 +Zho+KNtk1vUQZ55j1xUHdswAj0T0Soji/HC6p1tsVcA +-> X25519 sv50iZjBijWKfp6I+LfRlEJ2sqnj5/2m0hRWz5NqLTk +Hdfvo+87zemSCFWDSlzkpmvHLuvc0tjxEt0ociTPrCg +--- BkQd4O2m/i98rlBcNhczU6Wj0htoiNLQDn0W6yKn1/c + a "WL\#zDRq6.竂}#8²koyq>L\`wƔ>f/Ѵ^,# +hD<>]C \ No newline at end of file