diff --git a/config/traefik/dynamic/middlewares.yml b/config/traefik/dynamic/middlewares.yml index 2d1367ee..b38aaa07 100644 --- a/config/traefik/dynamic/middlewares.yml +++ b/config/traefik/dynamic/middlewares.yml @@ -1,32 +1,9 @@ # ============================================================================= -# Traefik — Dynamic Middleware Configuration -# All middlewares defined here are available project-wide via @file provider. -# -# Usage in docker-compose labels: -# traefik.http.routers..middlewares=authentik@file,security-headers@file +# Traefik Dynamic Configuration — ForwardAuth Middleware +# Protects services that don't natively support OIDC via Authentik # ============================================================================= - http: middlewares: - - # ------------------------------------------------------------------------- - # BasicAuth — Traefik Dashboard protection - # Generate hash: echo $(htpasswd -nb admin PASSWORD) | sed -e s/\$/\$\$/g - # Then set TRAEFIK_DASHBOARD_PASSWORD_HASH in .env - # ------------------------------------------------------------------------- - traefik-auth: - basicAuth: - usersFile: /dynamic/.htpasswd - removeHeader: true # Strip Authorization header from upstream request - - # ------------------------------------------------------------------------- - # Authentik ForwardAuth - # Protects any service — redirects unauthenticated requests to SSO login. - # Requires: SSO stack running (stacks/sso/docker-compose.yml) - # - # Usage: add to any router: - # traefik.http.routers..middlewares=authentik@file - # ------------------------------------------------------------------------- authentik: forwardAuth: address: "http://authentik-server:9000/outpost.goauthentik.io/auth/traefik" @@ -35,73 +12,8 @@ http: - X-authentik-username - X-authentik-groups - X-authentik-email - - X-authentik-name - - X-authentik-uid - - X-authentik-jwt - - X-authentik-meta-jwks - - X-authentik-meta-outpost - - X-authentik-meta-provider - - X-authentik-meta-app - - X-authentik-meta-version - - # ------------------------------------------------------------------------- - # Security Headers — applied to all public-facing services - # Reference: https://securityheaders.com - # ------------------------------------------------------------------------- - security-headers: - headers: - # HSTS: force HTTPS for 1 year, include subdomains - stsSeconds: 31536000 - stsIncludeSubdomains: true - stsPreload: true - # Prevent clickjacking - frameDeny: false # false = allow iframes from same origin (Portainer embeds) - customFrameOptionsValue: "SAMEORIGIN" - # XSS protection - browserXssFilter: true - contentTypeNosniff: true - # Referrer policy - referrerPolicy: "strict-origin-when-cross-origin" - # Remove identifying headers - customResponseHeaders: - X-Powered-By: "" - Server: "" - # Content Security Policy (permissive default — tighten per-service) - contentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;" - - # ------------------------------------------------------------------------- - # Rate Limiting — protect against brute force / DDoS - # ------------------------------------------------------------------------- - rate-limit: - rateLimit: - average: 100 # requests per second (average) - burst: 50 # burst allowance - - # ------------------------------------------------------------------------- - # IP Whitelist — restrict access to local network only - # Use for internal-only services (Portainer, Traefik dashboard, etc.) - # ------------------------------------------------------------------------- - local-only: - ipAllowList: - sourceRange: - - "127.0.0.1/32" - - "10.0.0.0/8" - - "172.16.0.0/12" - - "192.168.0.0/16" - - # ------------------------------------------------------------------------- - # Compress responses - # ------------------------------------------------------------------------- - compress: - compress: - excludedContentTypes: - - "text/event-stream" - # ------------------------------------------------------------------------- - # Redirect www → non-www - # ------------------------------------------------------------------------- - www-redirect: - redirectRegex: - regex: "^https://www\\.(.*)" - replacement: "https://${1}" - permanent: true + authentik-strip-prefix: + chain: + middlewares: + - authentik diff --git a/scripts/authentik-setup.sh b/scripts/authentik-setup.sh new file mode 100644 index 00000000..a037c32d --- /dev/null +++ b/scripts/authentik-setup.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# ============================================================================= +# HomeLab Stack — Authentik SSO Setup Script +# Creates OIDC providers for Grafana, Gitea, Nextcloud, Outline, Open WebUI, Portainer +# Requires: curl, jq +# Usage: ./scripts/authentik-setup.sh [--dry-run] +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +ROOT_DIR=$(dirname "$SCRIPT_DIR") + +# Load .env +if [ -f "$ROOT_DIR/.env" ]; then + set -a; source "$ROOT_DIR/.env"; set +a +fi + +DRY_RUN=false +[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' +log_info() { echo -e "${GREEN}[OK]${RESET} $*"; } +log_warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } +log_error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } +log_step() { echo; echo -e "${BOLD}${CYAN}==> $*${RESET}"; } + +AUTHENTIK_URL="https://${AUTHENTIK_DOMAIN:-auth.${DOMAIN}}" +API_URL="$AUTHENTIK_URL/api/v3" +TOKEN="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" + +if [ -z "$TOKEN" ]; then + log_error "AUTHENTIK_BOOTSTRAP_TOKEN is not set in .env" + exit 1 +fi + +AUTH_HEADER="Authorization: Bearer $TOKEN" + +get_default_flow() { + local designation="$1" + curl -sf "$API_URL/flows/instances/?designation=${designation}&ordering=slug" \ + -H "$AUTH_HEADER" | jq -r '.results[0].pk' +} + +get_signing_key() { + curl -sf "$API_URL/crypto/certificatekeypairs/?has_key=true&ordering=name" \ + -H "$AUTH_HEADER" | jq -r '.results[0].pk' +} + +create_oidc_provider() { + local name="$1" + local redirect_uri="$2" + local client_id_var="$3" + local client_secret_var="$4" + + log_step "Creating OIDC provider: $name" + + if $DRY_RUN; then + log_warn "[DRY RUN] Would create provider: $name" + log_info " Redirect URI: $redirect_uri" + return + fi + + local flow_pk signing_key + flow_pk=$(get_default_flow authorize) + signing_key=$(get_signing_key) + local slug + slug=$(echo "$name" | tr '[:upper:]' '[:lower:]') + + local payload + payload=$(jq -n \ + --arg name "${name} Provider" \ + --arg flow "$flow_pk" \ + --arg uri "$redirect_uri" \ + --arg key "$signing_key" \ + '{ + name: $name, + authorization_flow: $flow, + client_type: "confidential", + redirect_uris: $uri, + sub_mode: "hashed_user_id", + include_claims_in_id_token: true, + signing_key: $key + }') + + local response + response=$(curl -sf -X POST "$API_URL/providers/oauth2/" \ + -H "$AUTH_HEADER" \ + -H "Content-Type: application/json" \ + -d "$payload") + + local provider_pk client_id client_secret + provider_pk=$(echo "$response" | jq -r '.pk') + client_id=$(echo "$response" | jq -r '.client_id') + client_secret=$(echo "$response" | jq -r '.client_secret') + + log_info "Created provider: $name" + log_info " Client ID: $client_id" + log_info " Client Secret: $client_secret" + log_info " Redirect URI: $redirect_uri" + + sed -i "s|^${client_id_var}=.*|${client_id_var}=${client_id}|" "$ROOT_DIR/.env" + sed -i "s|^${client_secret_var}=.*|${client_secret_var}=${client_secret}|" "$ROOT_DIR/.env" + + local app_payload + app_payload=$(jq -n \ + --arg name "$name" \ + --arg slug "$slug" \ + --argjson pk "$provider_pk" \ + '{name: $name, slug: $slug, provider: $pk}') + + curl -sf -X POST "$API_URL/core/applications/" \ + -H "$AUTH_HEADER" \ + -H "Content-Type: application/json" \ + -d "$app_payload" > /dev/null + + log_info " Application created: $name" +} + +create_groups() { + log_step "Creating user groups" + for group in "homelab-admins" "homelab-users" "media-users"; do + if $DRY_RUN; then + log_warn "[DRY RUN] Would create group: $group" + continue + fi + curl -sf -X POST "$API_URL/core/groups/" \ + -H "$AUTH_HEADER" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"$group\"}" > /dev/null 2>&1 || true + log_info "Group: $group" + done +} + +# ------------------------------------------------------------------ +# Wait for Authentik to be ready +# ------------------------------------------------------------------ +log_step "Waiting for Authentik API..." +for i in $(seq 1 30); do + if curl -sf "$AUTHENTIK_URL/-/health/ready/" -o /dev/null 2>/dev/null; then + log_info "Authentik is ready" + break + fi + if [ "$i" -eq 30 ]; then + log_error "Authentik did not become ready in 150s" + exit 1 + fi + echo -n "." + sleep 5 +done + +# ------------------------------------------------------------------ +# Create groups +# ------------------------------------------------------------------ +create_groups + +# ------------------------------------------------------------------ +# Create providers +# ------------------------------------------------------------------ +create_oidc_provider \ + "Grafana" \ + "https://grafana.${DOMAIN}/login/generic_oauth" \ + "GRAFANA_OAUTH_CLIENT_ID" \ + "GRAFANA_OAUTH_CLIENT_SECRET" + +create_oidc_provider \ + "Gitea" \ + "https://git.${DOMAIN}/user/oauth2/Authentik/callback" \ + "GITEA_OAUTH_CLIENT_ID" \ + "GITEA_OAUTH_CLIENT_SECRET" + +create_oidc_provider \ + "Nextcloud" \ + "https://cloud.${DOMAIN}/apps/sociallogin/custom_oidc/callback" \ + "NEXTCLOUD_OAUTH_CLIENT_ID" \ + "NEXTCLOUD_OAUTH_CLIENT_SECRET" + +create_oidc_provider \ + "Outline" \ + "https://outline.${DOMAIN}/auth/oidc.callback" \ + "OUTLINE_OAUTH_CLIENT_ID" \ + "OUTLINE_OAUTH_CLIENT_SECRET" + +create_oidc_provider \ + "Open WebUI" \ + "https://ai.${DOMAIN}/oauth/oidc/callback" \ + "OPENWEBUI_OAUTH_CLIENT_ID" \ + "OPENWEBUI_OAUTH_CLIENT_SECRET" + +create_oidc_provider \ + "Portainer" \ + "https://portainer.${DOMAIN}/" \ + "PORTAINER_OAUTH_CLIENT_ID" \ + "PORTAINER_OAUTH_CLIENT_SECRET" + +log_step "All providers created. Credentials written to .env" +log_info "Authentik OIDC issuer: $AUTHENTIK_URL/application/o//" diff --git a/scripts/nextcloud-oidc-setup.sh b/scripts/nextcloud-oidc-setup.sh new file mode 100644 index 00000000..2626927a --- /dev/null +++ b/scripts/nextcloud-oidc-setup.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# ============================================================================= +# Nextcloud OIDC Setup via Social Login App +# Configures Nextcloud to use Authentik as OIDC provider +# Usage: ./scripts/nextcloud-oidc-setup.sh +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +ROOT_DIR=$(dirname "$SCRIPT_DIR") + +if [ -f "$ROOT_DIR/.env" ]; then + set -a; source "$ROOT_DIR/.env"; set +a +fi + +RED='\033[0;31m'; GREEN='\033[0;32m'; RESET='\033[0m' +log_info() { echo -e "${GREEN}[OK]${RESET} $*"; } +log_error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } + +NEXTCLOUD_CONTAINER="nextcloud" + +if ! docker ps --format '{{.Names}}' | grep -q "^${NEXTCLOUD_CONTAINER}$"; then + log_error "Nextcloud container is not running" + exit 1 +fi + +log_info "Installing Social Login app..." +docker exec -u www-data "$NEXTCLOUD_CONTAINER" php occ app:install sociallogin 2>/dev/null || true + +log_info "Configuring OIDC provider..." +docker exec -u www-data "$NEXTCLOUD_CONTAINER" php occ config:app:set sociallogin custom_providers --value="$(cat <