#!/bin/bash # services/authelia.sh — Authelia SSO + 2FA portal (forward-auth for Caddy). # Part of the modular post-install system (sourced by setup.sh). # # Can also be run standalone on any machine: # sudo bash authelia.sh # (Docker must already be installed when run standalone) # # Ported from the authelia-setup repo / the monolith's working block. # ── Standalone bootstrap ────────────────────────────────────────────────────── # Detected when the script is executed directly rather than sourced by setup.sh. # Sets up helpers and globals, then defers execution until after the function # definition at the bottom of this file. if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" _COMMON="$_SELF_DIR/../lib/common.sh" if [[ -f "$_COMMON" ]]; then # Full repo present — use the real helpers (picks up ~/docker/.config too) # shellcheck source=../lib/common.sh source "$_COMMON" else # One-off copy — inline minimal stubs so the script works without the repo log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } require_docker() { command -v docker &>/dev/null || { log_error "Docker not found. Install it first:" log_error " curl -fsSL https://get.docker.com | sudo sh" return 1 } docker compose version &>/dev/null || { log_error "Docker Compose plugin missing:" log_error " sudo apt-get install -y docker-compose-plugin" return 1 } } ensure_docker_dir_ownership() { chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } port_in_use() { local _port="$1" _proto="${2:-tcp}" local _flag="-tlnH" [ "$_proto" = "udp" ] && _flag="-ulnH" ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . } find_free_port() { local _varname="$1" _port="$2" _proto="${3:-tcp}" while port_in_use "$_port" "$_proto"; do _port=$((_port + 1)) done eval "$_varname='$_port'" } # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } read -r -p " $_q " _r eval "$_var='${_r:-$_def}'" } prompt_yn() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } read -r -p " $_q " _r eval "$_var='${_r:-$_def}'" } configure_caddy_for_service() { local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" local _display_port="${_upstream##*:}" # Determine mode: local Caddy, remote Caddy, or none local _mode="none" [[ -d "$_caddy_dir" ]] && _mode="local" [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" [[ "$_mode" == "none" ]] && { log_info "Access $_name directly on port $_display_port." return 0 } echo "" local _do_caddy="" if [[ "$_mode" == "remote" ]]; then log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." log_info "A snippet file will be saved to ~/docker/caddy-snippets/." fi read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy [[ "${_do_caddy,,}" == "y" ]] || { log_info "Skipping — access at: http://localhost:$_display_port" return 0 } # Domain prompt — pre-fill from SITE_DOMAIN when available local _default_domain="" if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then _default_domain="${_subdomain}.${SITE_DOMAIN}" log_info "Default: $_default_domain" fi local _domain="" read -r -p " Domain [${_default_domain:-required}]: " _domain _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } # Build upstream — remote Caddy uses host IP:port, not container name local _block_upstream="$_upstream" if [[ "$_mode" == "remote" ]]; then _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi local _site_block _site_block="$(cat << CBLOCK # $_name ${_domain} { reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "SAMEORIGIN" Referrer-Policy "strict-origin-when-cross-origin" } log { output file /var/log/caddy/${_domain}.log format json } ${_extra} } CBLOCK )" if [[ "$_mode" == "local" ]]; then if [[ -f "$_caddyfile" ]]; then local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" cp "$_caddyfile" "$_bk" log_info "Backed up Caddyfile to $(basename "$_bk")" else touch "$_caddyfile" fi if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then log_warning "$_domain already in Caddyfile" local _ow="" read -r -p " Overwrite? [y/N]: " _ow [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } sed -i "/^${_domain}/,/^}/d" "$_caddyfile" fi printf '%s\n' "$_site_block" >> "$_caddyfile" log_success "Added $_domain to Caddyfile" docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then log_success "$_name accessible at: https://$_domain" else log_warning "Reload failed — check: docker logs caddy" log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" fi else local _snippet_dir="$DOCKER_DIR/caddy-snippets" local _snippet_file="$_snippet_dir/${_subdomain}.caddy" mkdir -p "$_snippet_dir" printf '%s\n' "$_site_block" > "$_snippet_file" chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true log_success "Snippet saved: $_snippet_file" log_info "Copy to Caddy machine:" log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } write_readme() { local _dir="$1"; shift mkdir -p "$_dir" cat > "$_dir/README.md" } fi # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # ($HOME under sudo is /root, not the real user's home) ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" DRY_RUN="${DRY_RUN:-false}" UNATTENDED="${UNATTENDED:-false}" SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 fi # ───────────────────────────────────────────────────────────────────────────── register_service authelia homelab "SSO + 2FA auth portal (Authelia)" 9091 install_authelia() { require_docker || return 1 local AUTHELIA_DIR="$DOCKER_DIR/authelia" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would set up Authelia:" echo " • Create $AUTHELIA_DIR (config/secrets, data)" echo " • Generate jwt/session/storage secrets + admin password hash" echo " • Write docker-compose.yml, configuration.yml, users.yml, README.md" echo " • Create the caddy_net network and add the forward-auth snippet to the Caddyfile" return 0 fi # Don't clobber an existing install (it would regenerate secrets and break sessions). if [ -f "$AUTHELIA_DIR/docker-compose.yml" ]; then echo " ⚠ Authelia already exists at $AUTHELIA_DIR." echo "" echo " 1) Add another protected domain to this instance (non-destructive —" echo " one Authelia+Redis, multiple independent apex domains/logins)" echo " 2) Remove a protected domain added this way (undoes option 1 for one" echo " domain — other services still pointed at it will stop authenticating)" echo " 3) Add a new user (creates a users.yml entry + password hash)" echo " 4) Manage an existing user (email, password reset, 2FA reset/exempt," echo " promote/demote admin, per-service access, delete)" echo " 5) Register an app to log in VIA Authelia (OIDC/SSO — e.g. ActualBudget," echo " Vaultwarden, or any other app with its own \"Enable OpenID\" setting)" echo " 6) Remove a registered OIDC app (undoes option 5 for one app — its own" echo " separate password login, if it has one, is untouched)" echo " 7) Reconfigure from scratch (regenerates secrets/users — breaks" echo " existing sessions for every domain already on this instance)" echo " 8) Show who has universal vs. service-scoped access" echo " 9) Change \"Remember me\" session duration (stay logged in longer)" echo " 10) Protect an existing site with this instance (pick a local Caddy site," echo " or type one on a different box — gates it with a login, same as any" echo " other service already protected this way)" echo " 11) Un-protect a site (undoes option 10 for one site)" echo " 12) Leave as-is" echo "" local EXISTING_CHOICE="" prompt_text " Choice [1/2/3/4/5/6/7/8/9/10/11/12]:" "12" EXISTING_CHOICE case "$EXISTING_CHOICE" in 1) add_authelia_domain return 0 ;; 2) remove_authelia_domain return 0 ;; 3) add_authelia_user return 0 ;; 4) edit_authelia_user return 0 ;; 5) _authelia_add_oidc_client return 0 ;; 6) _authelia_remove_oidc_client_menu return 0 ;; 7) : # fall through to the full reinstall flow below ;; 8) _authelia_report_access_scope return 0 ;; 9) _authelia_set_remember_me return 0 ;; 10) _authelia_protect_site return 0 ;; 11) _authelia_unprotect_site return 0 ;; *) echo " Keeping existing Authelia. (Edit config/users.yml then: cd $AUTHELIA_DIR && docker compose restart authelia)" return 0 ;; esac fi log_info "Installing Authelia..." mkdir -p "$AUTHELIA_DIR/config/secrets" "$AUTHELIA_DIR/data" # ── Collect configuration ──────────────────────────────────────────────── echo "" echo " Authelia needs a few details to configure." echo "" local CADDY_NET="${SITE_CADDY_NET:-caddy_net}" local AUTHELIA_DOMAIN AUTHELIA_PORTAL_SUBDOMAIN AUTHELIA_PORTAL_DOMAIN AUTHELIA_ADMIN_USER AUTHELIA_ADMIN_DISPLAY AUTHELIA_ADMIN_EMAIL local AUTHELIA_SMTP_HOST AUTHELIA_SMTP_PORT AUTHELIA_SMTP_USER AUTHELIA_SMTP_PASS AUTHELIA_TZ prompt_text " Your domain (e.g., example.com):" "${SITE_DOMAIN:-example.com}" AUTHELIA_DOMAIN # Subdomain the login portal itself lives on — "auth" is just the # default, not a fixed convention. Every later function that needs this # domain's portal (add_authelia_domain for a DIFFERENT domain's own # portal, remove_authelia_domain, OIDC client registration, etc.) reads # it back from configuration.yml's session.cookies authelia_url instead # of assuming "auth." — see those functions for why. prompt_text " Subdomain for the login portal (e.g. 'auth' -> auth.${AUTHELIA_DOMAIN}):" "auth" AUTHELIA_PORTAL_SUBDOMAIN AUTHELIA_PORTAL_DOMAIN="${AUTHELIA_PORTAL_SUBDOMAIN}.${AUTHELIA_DOMAIN}" prompt_text " Admin username:" "admin" AUTHELIA_ADMIN_USER prompt_text " Admin display name:" "Administrator" AUTHELIA_ADMIN_DISPLAY prompt_text " Admin email:" "admin@${AUTHELIA_DOMAIN}" AUTHELIA_ADMIN_EMAIL prompt_text " SMTP server (e.g., smtp.migadu.com):" "smtp.migadu.com" AUTHELIA_SMTP_HOST prompt_text " SMTP port:" "587" AUTHELIA_SMTP_PORT prompt_text " SMTP username (full email):" "authelia@${AUTHELIA_DOMAIN}" AUTHELIA_SMTP_USER prompt_text " SMTP password:" "" AUTHELIA_SMTP_PASS prompt_text " Timezone (e.g., America/New_York):" "${SITE_TZ:-America/New_York}" AUTHELIA_TZ # ── Secrets ────────────────────────────────────────────────────────────── echo "" echo " Generating secrets..." echo "$(openssl rand -hex 32)" > "$AUTHELIA_DIR/config/secrets/jwt_secret" echo "$(openssl rand -hex 32)" > "$AUTHELIA_DIR/config/secrets/session_secret" echo "$(openssl rand -hex 32)" > "$AUTHELIA_DIR/config/secrets/storage_secret" echo "$AUTHELIA_SMTP_PASS" > "$AUTHELIA_DIR/config/secrets/smtp_password" chmod 600 "$AUTHELIA_DIR/config/secrets/"* echo " ✓ Secrets generated" # ── Admin password hash ────────────────────────────────────────────────── echo "" local AUTHELIA_TEMP_PASS AUTHELIA_HASH prompt_text " Temporary password for admin (users reset via email):" "TempPass2026!" AUTHELIA_TEMP_PASS echo " Generating password hash..." AUTHELIA_HASH=$(docker run --rm authelia/authelia:4.39.20 \ authelia crypto hash generate argon2 --password "$AUTHELIA_TEMP_PASS" 2>/dev/null \ | grep -oP '(?<=Digest: ).*' || echo "REPLACE_WITH_HASH") if [ "$AUTHELIA_HASH" = "REPLACE_WITH_HASH" ]; then log_warning "Could not generate hash automatically. After install run:" echo " docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'yourpassword'" echo " then update $AUTHELIA_DIR/config/users.yml" else echo " ✓ Password hash generated" fi ensure_docker_dir_ownership "$AUTHELIA_DIR" cd "$AUTHELIA_DIR" || return 1 # ── .env ───────────────────────────────────────────────────────────────── cat > .env << AUTHELIA_ENV MY_DOMAIN=${AUTHELIA_DOMAIN} SMTP_USER=${AUTHELIA_SMTP_USER} DOCKER_MY_NETWORK=${CADDY_NET} TZ=${AUTHELIA_TZ} AUTHELIA_ENV # ── docker-compose.yml (quoted heredoc: ${SMTP_USER} resolved by compose/.env) ── cat > docker-compose.yml << 'AUTHELIA_COMPOSE' name: authelia services: authelia: image: authelia/authelia:4.39.20 pull_policy: missing container_name: authelia user: "1000:1000" volumes: - ./config:/config - ./data:/data environment: - AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/config/secrets/jwt_secret - AUTHELIA_SESSION_SECRET_FILE=/config/secrets/session_secret - AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/config/secrets/storage_secret - AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/config/secrets/smtp_password - AUTHELIA_NOTIFIER_SMTP_USERNAME=${SMTP_USER} - AUTHELIA_NOTIFIER_SMTP_SENDER=Authelia <${SMTP_USER}> expose: - 9091 restart: unless-stopped networks: - caddy_net networks: caddy_net: external: true AUTHELIA_COMPOSE [ "$CADDY_NET" != "caddy_net" ] && sed -i "s/caddy_net/${CADDY_NET}/g" docker-compose.yml # ── configuration.yml ──────────────────────────────────────────────────── cat > config/configuration.yml << AUTHELIA_CONFIG --- # Authelia configuration. Secrets injected via AUTHELIA_* env vars in compose. theme: dark server: address: tcp://0.0.0.0:9091 log: level: info file_path: /data/authelia.log totp: period: 30 skew: 1 authentication_backend: file: path: /config/users.yml password: algorithm: argon2 argon2: variant: argon2id iterations: 3 memory: 65536 parallelism: 4 key_length: 32 salt_length: 16 access_control: default_policy: deny rules: - domain: "*.${AUTHELIA_DOMAIN}" policy: two_factor session: name: authelia_session expiration: 12h inactivity: 2h remember_me: 7d cookies: - domain: ${AUTHELIA_DOMAIN} authelia_url: https://${AUTHELIA_PORTAL_DOMAIN} default_redirection_url: https://${AUTHELIA_DOMAIN} storage: local: path: /data/db.sqlite3 notifier: disable_startup_check: false smtp: address: smtp://${AUTHELIA_SMTP_HOST}:${AUTHELIA_SMTP_PORT} timeout: 10s identifier: localhost subject: "[Authelia] {title}" startup_check_address: ${AUTHELIA_SMTP_USER} disable_require_tls: false disable_starttls: false AUTHELIA_CONFIG # ── users.yml ──────────────────────────────────────────────────────────── cat > config/users.yml << AUTHELIA_USERS --- # Authelia users database # Add users: copy a block, change username/email/displayname, restart authelia. # Generate a hash: docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'thepassword' # Login with username (not email). Use "Forgot Password" to set a real password. users: ${AUTHELIA_ADMIN_USER}: displayname: "${AUTHELIA_ADMIN_DISPLAY}" email: ${AUTHELIA_ADMIN_EMAIL} password: "${AUTHELIA_HASH}" groups: - admins - users AUTHELIA_USERS chown -R 1000:1000 "$AUTHELIA_DIR/config" "$AUTHELIA_DIR/data" log_success "Authelia configured at $AUTHELIA_DIR" # $CADDY_NET already exists at this point — require_docker (called at the # top of this function) creates it via ensure_caddy_network in lib/common.sh. # ── Caddyfile forward-auth snippet + portal block ──────────────────────── local CADDY_FILE="$DOCKER_DIR/caddy/Caddyfile" if [ -f "$CADDY_FILE" ]; then echo " Configuring Caddy for Authelia..." # Anchored to an actual, uncommented snippet definition — a bare # `grep -q "(authelia)"` also matches the commented-out example # block caddy.sh's starter Caddyfile ships ("# (authelia) {" as # documentation). Confirmed live: that false match made this skip # writing the real snippet entirely, leaving any later `import # authelia` reference elsewhere in the file dangling — Caddy then # refuses to start at all ("File to import not found: authelia"), # taking down every site it fronts, not just the Authelia-protected # one. if ! grep -qE '^\(authelia\)[[:space:]]*\{' "$CADDY_FILE"; then cp "$CADDY_FILE" "$CADDY_FILE.backup.$(date +%Y%m%d-%H%M%S)" { cat << 'SNIPPET_EOF' # ── Authelia forward auth snippet ───────────────────────────────────────────── (authelia) { forward_auth authelia:9091 { uri /api/authz/forward-auth copy_headers Remote-User Remote-Groups Remote-Name Remote-Email } } SNIPPET_EOF cat "$CADDY_FILE"; } > "$CADDY_FILE.tmp" && mv "$CADDY_FILE.tmp" "$CADDY_FILE" echo " ✓ Authelia snippet added to Caddyfile" fi if ! grep -q "${AUTHELIA_PORTAL_DOMAIN}" "$CADDY_FILE"; then cat >> "$CADDY_FILE" << CADDY_AUTH_BLOCK # ── Authelia login portal ────────────────────────────────────────────────────── ${AUTHELIA_PORTAL_DOMAIN} { # header_up pins X-Forwarded-Host to whatever the client actually sent. # Without it, Caddy's reverse_proxy recomputes X-Forwarded-Host from its # own incoming request (always ${AUTHELIA_PORTAL_DOMAIN} itself) and # overwrites the value a forward_auth caller (e.g. a remote site's # "forward_auth https://${AUTHELIA_PORTAL_DOMAIN}" block, see # services/asterisk.sh's droplet-mode Caddy block) set for its own domain. Confirmed # live: every forward-auth check evaluated as if it were for # ${AUTHELIA_PORTAL_DOMAIN} itself (which has policy: bypass in # access_control.rules so its own login portal isn't gated behind # itself), so every domain behind it silently passed through with no # 2FA prompt regardless of that domain's own policy. reverse_proxy authelia:9091 { header_up X-Forwarded-Host {http.request.header.X-Forwarded-Host} } log { output file /var/log/caddy/${AUTHELIA_PORTAL_DOMAIN}.log } } CADDY_AUTH_BLOCK echo " ✓ Authelia portal block added for ${AUTHELIA_PORTAL_DOMAIN}" fi docker ps --format '{{.Names}}' | grep -q "^caddy$" && \ { docker exec -w /etc/caddy caddy caddy reload 2>/dev/null && echo " ✓ Caddy reloaded" || echo " ⚠ Reload manually after checking the Caddyfile"; } else echo " ℹ Caddy not installed yet — add the (authelia) snippet + ${AUTHELIA_PORTAL_DOMAIN} block to your Caddyfile later (see README)." fi # ── README for the service folder ──────────────────────────────────────── write_readme "$AUTHELIA_DIR" << README_MD # Authelia — SSO + 2FA portal Single login (with TOTP two-factor) that protects any Caddy subdomain via forward-auth. Portal: **https://${AUTHELIA_PORTAL_DOMAIN}** ## Layout \`\`\` $AUTHELIA_DIR/ ├── docker-compose.yml ├── .env ├── config/ │ ├── configuration.yml │ ├── users.yml │ └── secrets/ # jwt/session/storage/smtp — never commit └── data/ # sqlite db + log \`\`\` ## Protect a service with Authelia In that service's Caddy site block, add \`import authelia\`: \`\`\` myservice.${AUTHELIA_DOMAIN} { import authelia reverse_proxy localhost:PORT } \`\`\` The \`(authelia)\` snippet and the \`auth.${AUTHELIA_DOMAIN}\` portal block were added to \`$DOCKER_DIR/caddy/Caddyfile\` automatically. ## Protecting a second (or third) apex domain Re-run this installer (\`sudo ./setup.sh authelia\` or \`sudo bash authelia.sh\`) and choose **"Add another protected domain to this instance"** when it detects the existing install. That domain gets its own \`session.cookies\` entry and its own \`auth.\` portal — a separate login/session from ${AUTHELIA_DOMAIN}, so no accidental cross-domain SSO — but it's still one shared Authelia + Redis container and one shared user database, not a second full stack. Cheaper than standing up an entirely separate instance, and the right way to protect multiple unrelated domains from the same box. ## Letting other apps log in via Authelia (OIDC/SSO) Different from \`import authelia\` above: that gates a whole site behind a login page before the request reaches it. This is for an app with its OWN "Enable OpenID"/SSO setting (ActualBudget, Vaultwarden, etc.) that should delegate ITS login to Authelia instead of a separate app-specific password. Re-run this installer and choose **"Register an app to log in VIA Authelia"** when it detects the existing install. Presets exist for ActualBudget and Vaultwarden (their exact redirect URI is filled in automatically); anything else works too via "Other/custom" — check that app's own OIDC/SSO docs for its redirect URI path first. First time this runs it also enables Authelia's OIDC provider itself (generates a signing key + HMAC secret, one-time, automatic). Each registered app gets its own Client ID/Secret under \`identity_providers.oidc.clients\` in \`config/configuration.yml\` — the secret is shown once at registration time and only the hash is kept. Endpoints (needed if an app asks for them instead of a discovery URL): - Discovery: \`https://${AUTHELIA_PORTAL_DOMAIN}/.well-known/openid-configuration\` - Authorization: \`https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/authorization\` - Token: \`https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/token\` - UserInfo: \`https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/userinfo\` ## Manage \`\`\` cd $AUTHELIA_DIR docker compose up -d # start docker compose restart authelia docker compose logs -f authelia docker compose down # stop \`\`\` ## Users - Login with the **username** (not email). Admin user: \`${AUTHELIA_ADMIN_USER}\`. - Both self-service paths need working SMTP: **Forgot Password** on the login screen emails a reset link, and even the in-portal **Settings → Change Password** page (for an already-logged-in user) sends a one-time code to their email to confirm the change — confirmed live, it is not a no-email path despite Authelia describing it as an in-session action. If SMTP isn't working yet, use the admin-side reset instead (next line), which never touches email. - **Add a user:** re-run this installer (\`sudo ./setup.sh authelia\` or \`sudo bash authelia.sh\`) and choose **"Add a new user"** from the menu — it prompts for username/email/display name, generates the password hash, writes the \`users.yml\` block, and restarts Authelia for you. - To add one by hand instead: copy a block in \`config/users.yml\`, change username/email/displayname, generate a hash, then \`docker compose restart authelia\`: \`\`\` docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'thepassword' \`\`\` - Any user added this way can log into every OIDC app already registered on this instance (see "Letting other apps log in via Authelia" above) — access isn't scoped per-app by default, it's shared across the whole instance. ## Notes - Authelia listens on 9091 **internally only** (no published port) and is reached through Caddy on the shared \`caddy_net\` docker network. - Two-factor is **required** (\`default_policy: deny\`, rule \`two_factor\` for \`*.${AUTHELIA_DOMAIN}\`). README_MD local START_AUTHELIA="" prompt_yn "Start Authelia now? (y/n):" "y" START_AUTHELIA if [ "$START_AUTHELIA" = "y" ] || [ "$START_AUTHELIA" = "Y" ]; then docker compose up -d 2>/dev/null && log_success "Authelia started" || log_warning "Failed to start Authelia" fi echo "" echo " Auth portal: https://${AUTHELIA_PORTAL_DOMAIN}" echo " Admin login: ${AUTHELIA_ADMIN_USER} (use Forgot Password to set a real password)" echo " README: $AUTHELIA_DIR/README.md" echo "" } # Adds a second (or third, etc.) independent apex domain to an EXISTING Authelia # instance instead of standing up a whole separate Authelia+Redis stack for it. # Authelia natively supports this: session.cookies and access_control.rules are # both lists, so one instance can hold a distinct cookie scope + login portal per # domain, each with its own session (no cross-domain SSO, but also no collision — # see the "Running more than one Authelia instance" note in CLAUDE.md for why two # domains can't just share one session.cookies entry). Far cheaper on RAM than a # second full instance, which matters most on a small droplet. add_authelia_domain() { local AUTHELIA_DIR="$DOCKER_DIR/authelia" local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml" local CADDY_FILE="$DOCKER_DIR/caddy/Caddyfile" if [ ! -f "$CONFIG_FILE" ]; then log_warning "No configuration.yml found at $CONFIG_FILE — install Authelia first." return 1 fi echo "" echo " Add another apex domain to this Authelia instance." echo " It gets its own session-cookie scope and its own auth. portal —" echo " a separate login/session from your other domain(s) — but shares this" echo " same Authelia + Redis container, not a second full stack." echo "" local NEW_DOMAIN="" prompt_text " New domain (e.g., example.com):" "" NEW_DOMAIN if [ -z "$NEW_DOMAIN" ]; then log_warning "No domain entered — nothing to do." return 0 fi if grep -qF "\"*.${NEW_DOMAIN}\"" "$CONFIG_FILE" 2>/dev/null; then log_warning "$NEW_DOMAIN is already configured in $CONFIG_FILE — nothing to do." return 0 fi local NEW_PORTAL_SUBDOMAIN NEW_PORTAL_DOMAIN prompt_text " Subdomain for this domain's own login portal (e.g. 'auth' -> auth.${NEW_DOMAIN}):" "auth" NEW_PORTAL_SUBDOMAIN NEW_PORTAL_DOMAIN="${NEW_PORTAL_SUBDOMAIN}.${NEW_DOMAIN}" # ── access_control.rules: insert right after "rules:" ──────────────────── awk -v domain="$NEW_DOMAIN" ' { print } /^ rules:$/ && !done { print " - domain: \"*." domain "\"" print " policy: two_factor" done=1 } ' "$CONFIG_FILE" > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE" # ── session.cookies: insert right after "cookies:" ──────────────────────── awk -v domain="$NEW_DOMAIN" -v portal="$NEW_PORTAL_DOMAIN" ' { print } /^ cookies:$/ && !done { print " - domain: " domain print " authelia_url: https://" portal print " default_redirection_url: https://" domain done=1 } ' "$CONFIG_FILE" > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE" chown 1000:1000 "$CONFIG_FILE" 2>/dev/null || true log_success "Added $NEW_DOMAIN to $CONFIG_FILE (access_control rule + session cookie scope)" # ── Caddy portal block for the new domain ───────────────────────────────── if [ -f "$CADDY_FILE" ]; then if ! grep -qx "${NEW_PORTAL_DOMAIN} {" "$CADDY_FILE"; then cat >> "$CADDY_FILE" << CADDY_AUTH_BLOCK2 # ── Authelia login portal (${NEW_DOMAIN}) ───────────────────────────────────── ${NEW_PORTAL_DOMAIN} { # See this instance's other portal block(s) above for why header_up # X-Forwarded-Host is required here, not optional. reverse_proxy authelia:9091 { header_up X-Forwarded-Host {http.request.header.X-Forwarded-Host} } log { output file /var/log/caddy/${NEW_PORTAL_DOMAIN}.log } } CADDY_AUTH_BLOCK2 echo " ✓ Authelia portal block added for ${NEW_PORTAL_DOMAIN}" docker ps --format '{{.Names}}' | grep -q "^caddy$" && \ { docker exec -w /etc/caddy caddy caddy reload 2>/dev/null && echo " ✓ Caddy reloaded" || echo " ⚠ Reload manually: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"; } else echo " ✓ ${NEW_PORTAL_DOMAIN} portal block already exists in the Caddyfile" fi else echo " ℹ Caddy not installed — add a ${NEW_PORTAL_DOMAIN} portal block manually later (see README)." fi # ── Restart Authelia to pick up the new config ──────────────────────────── local RESTART_AUTH="" prompt_yn " Restart Authelia to apply the new domain? (y/n):" "y" RESTART_AUTH if [ "$RESTART_AUTH" = "y" ] || [ "$RESTART_AUTH" = "Y" ]; then (cd "$AUTHELIA_DIR" && docker compose restart authelia 2>/dev/null) \ && log_success "Authelia restarted" \ || log_warning "Restart failed — check: docker compose logs authelia" fi echo "" echo " Auth portal for $NEW_DOMAIN: https://${NEW_PORTAL_DOMAIN}" echo " Protect a service under this domain the same way as any other:" echo " myservice.${NEW_DOMAIN} {" echo " import authelia" echo " reverse_proxy localhost:PORT" echo " }" echo " Same users/passwords work across every domain on this instance —" echo " it's one shared user database, just separate sessions per domain." echo "" } # Removes a login-portal Caddy block add_authelia_domain() writes — same # bounded-block technique used elsewhere in this repo for Caddy site blocks # (find the opening " {" line, walk forward to the matching # unindented "}"). Takes the portal's own FULL domain, not the apex it # belongs to and an assumed "auth." prefix — the portal subdomain is # user-chosen at the time it's added (see add_authelia_domain), so it can't # be reconstructed from the apex alone. Callers read it back from that # domain's own session.cookies authelia_url entry before removing it. _authelia_remove_caddy_portal_block() { local portal_domain="$1" local caddy_file="$DOCKER_DIR/caddy/Caddyfile" [ -f "$caddy_file" ] || return 0 local domain_line end_line start_line domain_line="$(grep -nx "${portal_domain} {" "$caddy_file" | head -1 | cut -d: -f1)" [ -z "$domain_line" ] && return 0 start_line="$domain_line" if [ "$domain_line" -gt 1 ] && sed -n "$((domain_line - 1))p" "$caddy_file" | grep -qE '^# '; then start_line=$((domain_line - 1)) fi end_line="$(tail -n "+$domain_line" "$caddy_file" | grep -nx '}' | head -1 | cut -d: -f1)" if [ -z "$end_line" ]; then log_warning "Could not find the end of ${portal_domain}'s Caddy block — leaving it as-is." return 1 fi end_line=$((domain_line + end_line - 1)) sed -i "${start_line},${end_line}d" "$caddy_file" log_info "Removed the ${portal_domain} Caddy portal block." docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^caddy$" && \ { docker exec -w /etc/caddy caddy caddy reload 2>/dev/null && log_success "Caddy reloaded" \ || log_warning "Reload manually: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"; } } # Reverse of add_authelia_domain() — removes one apex domain's # access_control.rules entry, session.cookies entry, and its auth. # Caddy portal block from this instance. Undoes a domain added by mistake # (wrong value entered, or a domain that turned out to already be covered by # an existing apex's wildcard rule — see the menu's own warning text). Does # NOT touch any other domain already on this instance, and does NOT find or # fix whatever individual services still point "import authelia"/forward_auth # at this instance for the removed domain — those start failing to # authenticate (no session-cookie scope left to complete a login against) # the moment this runs, so this is for cleaning up a domain that's not # actually in use this way, not a way to quietly de-protect a live service. remove_authelia_domain() { local AUTHELIA_DIR="$DOCKER_DIR/authelia" local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml" if [ ! -f "$CONFIG_FILE" ]; then log_warning "No configuration.yml found at $CONFIG_FILE — install Authelia first." return 1 fi echo "" echo " Domains currently on this Authelia instance:" grep -oE '^ - domain: "\*\.[^"]+"' "$CONFIG_FILE" | sed -E 's/^ - domain: "\*\.(.+)"$/ - \1/' echo "" echo " Note: this removes a whole apex domain entry added via 'Add another" echo " protected domain' — if you meant to protect a SUBDOMAIN of an apex" echo " already listed above, you don't need this at all: it's already covered" echo " by that apex's wildcard rule and session-cookie scope. Just point that" echo " subdomain's Caddy block at this instance's existing auth. portal" echo " instead of adding it here as its own entry." echo "" local RM_DOMAIN="" prompt_text " Domain to remove (as shown above, e.g. example.com):" "" RM_DOMAIN if [ -z "$RM_DOMAIN" ]; then log_warning "No domain entered — nothing to do." return 0 fi if ! grep -qF "\"*.${RM_DOMAIN}\"" "$CONFIG_FILE" 2>/dev/null; then log_warning "$RM_DOMAIN isn't configured on this instance — nothing to do." return 0 fi # Read the portal's own domain back from this apex's session.cookies # entry — it's whatever subdomain was chosen when this domain was added # (see add_authelia_domain), not necessarily "auth.", so it # can't be assumed. Must happen before the removal below, which deletes # this exact entry. local RM_PORTAL_DOMAIN RM_PORTAL_DOMAIN="$(awk -v domain="$RM_DOMAIN" ' $0 == " - domain: " domain { f=1; next } f && /authelia_url:/ { print $2; exit } ' "$CONFIG_FILE" | sed -E 's#^https?://##')" [ -z "$RM_PORTAL_DOMAIN" ] && RM_PORTAL_DOMAIN="auth.${RM_DOMAIN}" echo "" log_warning "This removes ${RM_DOMAIN}'s access rule, session-cookie scope, and its" log_warning "${RM_PORTAL_DOMAIN} login portal from THIS Authelia instance." log_warning "Any service still using 'import authelia' or forward_auth pointed at" log_warning "${RM_DOMAIN} will start failing to authenticate — reconfigure or remove" log_warning "those first if they're still live." local CONFIRM_RM="" prompt_yn " Continue? (y/n):" "n" CONFIRM_RM [[ "$CONFIRM_RM" =~ ^[Yy]$ ]] || { log_info "Cancelled — nothing changed."; return 0; } # ── access_control.rules: remove the "- domain: "*.X"" + "policy: ..." pair ── awk -v domain="$RM_DOMAIN" ' BEGIN { skip=0 } skip == 1 { skip=0; next } $0 == " - domain: \"*." domain "\"" { skip=1; next } { print } ' "$CONFIG_FILE" > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE" # ── session.cookies: remove the "- domain: X" + 2 following lines ───────── awk -v domain="$RM_DOMAIN" ' BEGIN { skip=0 } skip > 0 { skip--; next } $0 == " - domain: " domain { skip=2; next } { print } ' "$CONFIG_FILE" > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE" chown 1000:1000 "$CONFIG_FILE" 2>/dev/null || true log_success "Removed ${RM_DOMAIN} from $CONFIG_FILE" _authelia_remove_caddy_portal_block "$RM_PORTAL_DOMAIN" local RESTART_AUTH="" prompt_yn " Restart Authelia to apply? (y/n):" "y" RESTART_AUTH if [ "$RESTART_AUTH" = "y" ] || [ "$RESTART_AUTH" = "Y" ]; then (cd "$AUTHELIA_DIR" && docker compose restart authelia 2>/dev/null) \ && log_success "Authelia restarted" \ || log_warning "Restart failed — check: docker compose logs authelia" fi } # Lists this box's local Caddy sites by number for convenience, then lets # the caller pick one OR just type a domain directly — including one not on # this box's Caddy at all, since this only ever offers the list, never # requires picking from it. Shared by every prompt in this file that needs # "a domain, ideally from Caddy" (protecting/un-protecting a site, and the # OIDC "what domain is this app on" prompt) so they behave the same way # instead of each re-implementing their own version of "type it out fully." # Echoes the chosen domain on stdout (empty if nothing entered); the # listing itself goes to stderr so it never ends up captured by a caller # using $(...) to grab the echoed domain. _authelia_pick_domain() { local prompt_label="$1" local caddy_file="$DOCKER_DIR/caddy/Caddyfile" local -a site_domains [ -f "$caddy_file" ] && mapfile -t site_domains < <(grep -oE '^[A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,} \{$' "$caddy_file" | sed 's/ {$//') if [ "${#site_domains[@]}" -gt 0 ]; then echo " Local Caddy sites on this box:" >&2 local i for i in "${!site_domains[@]}"; do echo " $((i + 1))) ${site_domains[$i]}" >&2 done echo " Or type a domain directly — including one on a different box's Caddy." >&2 fi local choice="" prompt_text " ${prompt_label}:" "" choice if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#site_domains[@]}" ]; then echo "${site_domains[$((choice - 1))]}" else echo "$choice" fi } # Generalizes the "Protect X with Authelia SSO?" prompt individual services # (magicmirror, wolf-pair, security-dashboard, etc.) each offer on their own # install into one menu action here: pick any existing LOCAL Caddy site by # number, or type a domain that's on a DIFFERENT box's Caddy entirely (this # box only runs Authelia, not that site) — e.g. this repo's own case of a # DigitalOcean droplet's site protected by an Authelia instance on a # separate IONOS box. # # Local site: inserts "import authelia" as the very first line inside its # existing block — must come before reverse_proxy, since Caddy runs # directives in the order they're written and an auth check placed after # reverse_proxy is dead code that never runs (full bypass, not an error; # see lib/common.sh's configure_caddy_for_service for the fuller version of # this warning). Idempotent: an already-protected site is flagged in the # list and skips re-inserting a duplicate import, going straight to access # scoping below. # # Remote site: this box can't edit a file on another machine, so it prints # (and saves to caddy-snippets/, same convention as every other remote- # Authelia caller in this repo) the forward_auth block that box's OWN # Caddyfile needs instead — the remote-hop-safe form with a literal # X-Forwarded-Host, not the {host} placeholder, for the header-rewrite # reasons documented at length in CLAUDE.md and services/asterisk.sh. # # Either way, finishes by offering _authelia_scope_access for the domain — # identical either way, since it only cares about the domain, not which box # is actually enforcing the gate. _authelia_protect_site() { local authelia_dir="$DOCKER_DIR/authelia" local config_file="$authelia_dir/config/configuration.yml" local caddy_file="$DOCKER_DIR/caddy/Caddyfile" if [ ! -f "$config_file" ]; then log_warning "No configuration.yml found at $config_file — install Authelia first." return 1 fi local -a site_domains if [ -f "$caddy_file" ]; then mapfile -t site_domains < <(grep -oE '^[A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,} \{$' "$caddy_file" | sed 's/ {$//') fi echo "" echo " Protect a site with this Authelia instance." if [ "${#site_domains[@]}" -gt 0 ]; then echo " Local Caddy sites on this box:" local i d marker for i in "${!site_domains[@]}"; do d="${site_domains[$i]}" marker="" sed -n "/^${d} {\$/,/^}/p" "$caddy_file" | grep -qE 'import authelia|forward_auth' && marker=" (already protected)" echo " $((i + 1))) ${d}${marker}" done else echo " No local Caddy sites found." fi echo " Or type a domain directly — including one on a DIFFERENT box's Caddy" echo " entirely (this box only needs to run Authelia itself for that to work)." echo "" local choice="" prompt_text " Number or domain:" "" choice if [ -z "$choice" ]; then log_warning "Nothing entered — nothing to do." return 0 fi local target_domain="" is_local=false if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#site_domains[@]}" ]; then target_domain="${site_domains[$((choice - 1))]}" is_local=true else target_domain="$choice" [ -f "$caddy_file" ] && grep -qx "${target_domain} {" "$caddy_file" 2>/dev/null && is_local=true fi if [ "$is_local" = true ]; then if sed -n "/^${target_domain} {\$/,/^}/p" "$caddy_file" | grep -qE 'import authelia|forward_auth'; then log_info "${target_domain} is already protected — moving on to access scoping." else cp "$caddy_file" "$caddy_file.backup.$(date +%Y%m%d-%H%M%S)" sed -i "/^${target_domain} {\$/a\\ import authelia" "$caddy_file" log_success "Inserted 'import authelia' into ${target_domain}'s Caddy block." docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^caddy$"; then if docker exec -w /etc/caddy caddy caddy reload 2>/dev/null; then log_success "Caddy reloaded" elif docker restart caddy &>/dev/null; then log_success "Caddy restarted (reload API is disabled by default)" else log_warning "Reload/restart failed — check: docker logs caddy" fi fi fi else # This instance's own portal — read back from the primary apex's # session.cookies entry (the first one; same read-back pattern # every other caller in this file uses). A domain on a different # box isn't "added" to this instance the way add_authelia_domain's # apex domains are — it's just gated by THIS instance's existing # portal, same as any local site above, so there's no per-domain # cookie entry of its own to read from. local portal_domain portal_domain="$(tr -d '\r' < "$config_file" | awk '/^ cookies:$/{f=1; next} f && /authelia_url:/{print $2; exit}' | sed -E 's#^https?://##')" if [ -z "$portal_domain" ]; then log_warning "Couldn't determine this instance's own portal domain from $config_file — aborting." return 1 fi echo "" log_info "${target_domain} isn't on this box's own Caddy — add this to the OTHER box's" log_info "Caddyfile instead (the one that actually serves ${target_domain}), BEFORE reverse_proxy:" echo "" echo " forward_auth https://${portal_domain} {" echo " uri /api/authz/forward-auth" echo " copy_headers Remote-User Remote-Groups Remote-Name Remote-Email" echo " header_up X-Forwarded-Method {method}" echo " header_up X-Forwarded-Proto {scheme}" echo " header_up X-Forwarded-Host ${target_domain}" echo " header_up X-Forwarded-Uri {uri}" echo " }" echo "" log_warning "Must come BEFORE reverse_proxy in that block, not after — Caddy runs" log_warning "directives in the order they're written, and an auth check placed after" log_warning "reverse_proxy never runs at all (full bypass, not an error)." local snippet_dir="$DOCKER_DIR/caddy-snippets" mkdir -p "$snippet_dir" cat > "$snippet_dir/${target_domain}-authelia.caddy" << SNIPPET forward_auth https://${portal_domain} { uri /api/authz/forward-auth copy_headers Remote-User Remote-Groups Remote-Name Remote-Email header_up X-Forwarded-Method {method} header_up X-Forwarded-Proto {scheme} header_up X-Forwarded-Host ${target_domain} header_up X-Forwarded-Uri {uri} } SNIPPET chown "$ACTUAL_USER:$ACTUAL_USER" "$snippet_dir/${target_domain}-authelia.caddy" 2>/dev/null || true log_success "Also saved: $snippet_dir/${target_domain}-authelia.caddy" fi _authelia_scope_access "$(echo "$target_domain" | tr '.' '-')" "$target_domain" } # Reverse of _authelia_protect_site — removes a local site's "import # authelia" line from its own Caddy block (reloading Caddy), and the two # access_control.rules entries _authelia_scope_access may have added for # it, if any (found by the same "-only" group # name _authelia_protect_site used). Each scoped rule is a 3-line # "- domain: ...\n subject: ...\n policy: ..." block — bounded removal by # buffering exactly 3 lines at a time from each " - domain:" line and # only dropping the buffer if the group's subject line is inside it, so an # unrelated rule sharing the same "*." domain line for a DIFFERENT # group is untouched. # # Does NOT remove the user group itself from users.yml (a user's ["x-only"] # membership with no matching rule left is inert, not a live grant) or # touch a domain on a different box's Caddy (nothing here can edit that # file) — for a remote site, this only cleans up the access rules on this # side; removing the forward_auth block itself is a manual edit on the box # that actually serves it. _authelia_unprotect_site() { local authelia_dir="$DOCKER_DIR/authelia" local config_file="$authelia_dir/config/configuration.yml" local caddy_file="$DOCKER_DIR/caddy/Caddyfile" if [ ! -f "$config_file" ]; then log_warning "No configuration.yml found at $config_file — install Authelia first." return 1 fi local -a protected_domains if [ -f "$caddy_file" ]; then mapfile -t protected_domains < <( grep -oE '^[A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,} \{$' "$caddy_file" | sed 's/ {$//' | while read -r d; do sed -n "/^${d} {\$/,/^}/p" "$caddy_file" | grep -qE 'import authelia|forward_auth' && echo "$d" done ) fi echo "" echo " Un-protect a site (remove its Authelia gate)." if [ "${#protected_domains[@]}" -gt 0 ]; then echo " Currently-protected local Caddy sites:" local i for i in "${!protected_domains[@]}"; do echo " $((i + 1))) ${protected_domains[$i]}" done else echo " No locally-gated Caddy sites found." fi echo " Or type a domain directly — including one on a different box's Caddy, to" echo " clean up its access-scoping rules here even though the gate itself lives" echo " elsewhere and needs removing there by hand." echo "" local choice="" prompt_text " Number or domain (blank to cancel):" "" choice if [ -z "$choice" ]; then log_info "Cancelled — nothing changed." return 0 fi local target_domain="" is_local=false if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#protected_domains[@]}" ]; then target_domain="${protected_domains[$((choice - 1))]}" is_local=true else target_domain="$choice" [ -f "$caddy_file" ] && grep -qx "${target_domain} {" "$caddy_file" 2>/dev/null && is_local=true fi if [ "$is_local" = true ]; then if sed -n "/^${target_domain} {\$/,/^}/p" "$caddy_file" | grep -qE 'import authelia|forward_auth'; then cp "$caddy_file" "$caddy_file.backup.$(date +%Y%m%d-%H%M%S)" sed -i "/^${target_domain} {\$/,/^}/{/^ *import authelia\$/d; /^ *forward_auth /,/^ *}\$/d}" "$caddy_file" log_success "Removed the Authelia gate from ${target_domain}'s Caddy block." docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^caddy$"; then if docker exec -w /etc/caddy caddy caddy reload 2>/dev/null; then log_success "Caddy reloaded" elif docker restart caddy &>/dev/null; then log_success "Caddy restarted (reload API is disabled by default)" else log_warning "Reload/restart failed — check: docker logs caddy" fi fi else log_info "${target_domain} isn't currently gated — nothing to remove there." fi else log_info "${target_domain} isn't on this box's own Caddy — only cleaning up its access" log_info "rules here. Remove the forward_auth block from the box that actually serves" log_info "it yourself (see $DOCKER_DIR/caddy-snippets/ if it was added via option 9)." fi local group="$(echo "$target_domain" | tr '.' '-')-only" if grep -qF "subject: \"group:${group}\"" "$config_file" 2>/dev/null; then awk -v grp="\"group:${group}\"" ' BEGIN { buf=""; n=0; hit=0 } /^ - domain:/ { if (n > 0) { if (!hit) printf "%s", buf; buf=""; n=0; hit=0 } buf = $0 "\n"; n=1 if ($0 ~ grp) hit=1 next } n > 0 && n < 3 { buf = buf $0 "\n"; n++ if ($0 ~ grp) hit=1 if (n == 3) { if (!hit) printf "%s", buf; buf=""; n=0; hit=0 } next } { if (n > 0) { if (!hit) printf "%s", buf; buf=""; n=0; hit=0 } print } END { if (n > 0 && !hit) printf "%s", buf } ' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file" chown 1000:1000 "$config_file" 2>/dev/null || true log_success "Removed the '${group}' access-scoping rules — ${target_domain} is open to any" log_success "Authelia user again (users' membership in '${group}' is left as harmless" log_success "unused metadata — remove it by hand in users.yml if you want it fully gone)." fi local RESTART_AUTH="" prompt_yn " Restart Authelia to apply? (y/n):" "y" RESTART_AUTH if [ "$RESTART_AUTH" = "y" ] || [ "$RESTART_AUTH" = "Y" ]; then (cd "$authelia_dir" && docker compose restart authelia 2>/dev/null) \ && log_success "Authelia restarted" \ || log_warning "Restart failed — check: docker compose logs authelia" fi } # Picks "count" random characters from "charset" using an unbiased-enough # per-byte modulo draw from /dev/urandom. Not part of lib/common.sh's shared # generate_password (that one is deliberately alphanumeric-only — see its # paired validate_password, which rejects special characters outright, since # plenty of other services embed its output directly into .env/YAML/URLs # without escaping). This one is scoped to add_authelia_user()'s temp # password only, which is never written to disk in plaintext, so the wider # character set is safe here without becoming a repo-wide convention change. _authelia_rand_chars() { local charset="$1" count="$2" out="" idx byte clen clen=${#charset} while [ "${#out}" -lt "$count" ]; do byte=$(od -An -N1 -tu1 /dev/urandom | tr -d ' ') idx=$(( byte % clen )) out+="${charset:idx:1}" done printf '%s' "$out" } # 30 chars, at least 5 each of uppercase/digit/special, rest a random mix — # then shuffled so the guaranteed characters aren't clustered at the front. _authelia_gen_temp_password() { local length=30 min_upper=5 min_digit=5 min_special=5 local upper_set="ABCDEFGHIJKLMNOPQRSTUVWXYZ" local digit_set="0123456789" local special_set='!@#%^&*()_+=-[]{}:,.?~' local mixed_set="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789${special_set}" local part_upper part_digit part_special part_rest part_upper="$(_authelia_rand_chars "$upper_set" "$min_upper")" part_digit="$(_authelia_rand_chars "$digit_set" "$min_digit")" part_special="$(_authelia_rand_chars "$special_set" "$min_special")" local rest_len=$(( length - min_upper - min_digit - min_special )) part_rest="$(_authelia_rand_chars "$mixed_set" "$rest_len")" printf '%s%s%s%s' "$part_upper" "$part_digit" "$part_special" "$part_rest" \ | fold -w1 | shuf | tr -d '\n' } # Adds a new user to an EXISTING Authelia instance's users.yml — the scripted # version of the manual "generate a hash, paste a users.yml block, restart" # steps this file's own generated README already documents. Non-destructive: # only inserts a new block under the existing "users:" key, never touches any # other user already there. Any user added here can authenticate against # every OIDC client already registered on this instance (see # _authelia_add_oidc_client below) — Authelia's authorization_policy controls # required auth strength (1FA/2FA), not which users may use a given client, # so there's no separate "grant access to this app" step needed. add_authelia_user() { local AUTHELIA_DIR="$DOCKER_DIR/authelia" local USERS_FILE="$AUTHELIA_DIR/config/users.yml" if [ ! -f "$USERS_FILE" ]; then log_warning "No users.yml found at $USERS_FILE — install Authelia first." return 1 fi echo "" echo " Add a new user to this Authelia instance." echo " They log in with their username (not email). A temporary password" echo " is generated below — hand it to them directly. \"Forgot Password\"" echo " and Authelia's own Settings → Change Password both require working" echo " SMTP (both email a one-time code), so until that's fixed, use this" echo " menu's \"Edit an existing user\" → \"Reset password\" for future resets." echo "" local NEW_USERNAME="" NEW_DISPLAY="" NEW_EMAIL="" NEW_ADMIN="" prompt_text " Username (lowercase, no spaces):" "" NEW_USERNAME NEW_USERNAME="$(echo "$NEW_USERNAME" | tr -cs 'a-zA-Z0-9_-' '-' | sed 's/^-*//;s/-*$//')" if [ -z "$NEW_USERNAME" ]; then log_warning "No username entered — nothing to do." return 0 fi if grep -qE "^ ${NEW_USERNAME}:$" "$USERS_FILE" 2>/dev/null; then log_warning "A user named '$NEW_USERNAME' already exists in $USERS_FILE — pick another username, or edit that entry by hand." return 0 fi prompt_text " Display name:" "$NEW_USERNAME" NEW_DISPLAY prompt_text " Email:" "${NEW_USERNAME}@${SITE_DOMAIN:-example.com}" NEW_EMAIL local NEW_ADMIN_YN="" prompt_yn " Grant admin group membership too? (y/n):" "n" NEW_ADMIN_YN log_info "Generating temporary password + hash..." local TEMP_PASS NEW_HASH TEMP_PASS="$(_authelia_gen_temp_password)" NEW_HASH=$(docker run --rm authelia/authelia:4.39.20 \ authelia crypto hash generate argon2 --password "$TEMP_PASS" 2>/dev/null \ | grep -oP '(?<=Digest: ).*') if [ -z "$NEW_HASH" ]; then log_warning "Couldn't generate the password hash automatically. Run manually, then add the" log_warning "user to $USERS_FILE by hand:" echo " docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'temporary-password'" return 1 fi local GROUPS_BLOCK=" - users" [[ "$NEW_ADMIN_YN" =~ ^[Yy]$ ]] && GROUPS_BLOCK=" - admins - users" local USER_BLOCK=" ${NEW_USERNAME}: displayname: \"${NEW_DISPLAY}\" email: ${NEW_EMAIL} password: \"${NEW_HASH}\" groups: ${GROUPS_BLOCK}" awk -v block="$USER_BLOCK" ' { print } /^users:$/ && !done { print block; done=1 } ' "$USERS_FILE" > "$USERS_FILE.tmp" && mv "$USERS_FILE.tmp" "$USERS_FILE" chown 1000:1000 "$USERS_FILE" 2>/dev/null || true log_success "Added user '$NEW_USERNAME' to $USERS_FILE" local RESTART_AUTH="" prompt_yn " Restart Authelia to apply the new user? (y/n):" "y" RESTART_AUTH if [ "$RESTART_AUTH" = "y" ] || [ "$RESTART_AUTH" = "Y" ]; then (cd "$AUTHELIA_DIR" && docker compose restart authelia 2>/dev/null) \ && log_success "Authelia restarted" \ || log_warning "Restart failed — check: docker compose logs authelia" fi echo "" echo " New user: ${NEW_USERNAME}" echo " Temp password: ${TEMP_PASS}" echo " Give this to them directly (it's shown once, nothing stores it in" echo " plaintext). They can log in with it as-is and keep using it, or" echo " change it themselves from Authelia's Settings page — but that page" echo " emails a one-time code to confirm the change, so it needs working" echo " SMTP. Without SMTP, use this menu's \"Edit an existing user\" →" echo " \"Reset password\" instead — that one never touches email." echo "" } # ── edit_authelia_user() helpers ────────────────────────────────────────────── # All of these operate on a caller-supplied line range or file, never scan the # whole file themselves, so an edit to one user's block can't bleed into a # neighboring user (or, for the 2FA-exempt helpers, one user's exemption rule # can't be mistaken for another's — verified against multi-user/multi-domain # fixtures before this shipped, since a bad access_control edit here would # break every protected domain on the instance, not just this one user). _authelia_list_usernames() { local users_file="$1" awk '/^users:$/{f=1; next} f && /^ [A-Za-z0-9_-]+:$/{gsub(/^ /,""); gsub(/:$/,""); print}' "$users_file" } # Prints " " (1-indexed, inclusive) spanning just the # given user's block in users.yml. _authelia_user_line_range() { local users_file="$1" username="$2" awk -v user="$username" ' BEGIN{start=0; end=0} /^ [A-Za-z0-9_-]+:$/ { if (start>0 && end==0) { end=NR-1 } if ($0 ~ "^ "user":$") { start=NR } } END { if (start>0 && end==0) { end=NR } print start, end } ' "$users_file" } # Replaces the first " : ..." line found within [start,end] with # "newline" verbatim (caller supplies correct quoting for that field). _authelia_set_user_field() { local users_file="$1" start="$2" end="$3" field="$4" newline="$5" awk -v s="$start" -v e="$end" -v field="$field" -v newline="$newline" ' NR>=s && NR<=e && $0 ~ "^ "field":" { print newline; next } { print } ' "$users_file" > "$users_file.tmp" && mv "$users_file.tmp" "$users_file" } # enable=true adds "- admins" under this user's groups: (no-op if already # present); enable=false removes it. Scoped to [start,end] so it can't touch # another user's groups list. _authelia_toggle_admin() { local users_file="$1" start="$2" end="$3" enable="$4" if [ "$enable" = "true" ]; then if ! sed -n "${start},${end}p" "$users_file" | grep -q '^ - admins$'; then awk -v s="$start" -v e="$end" ' { print } NR>=s && NR<=e && /^ groups:$/ { print " - admins" } ' "$users_file" > "$users_file.tmp" && mv "$users_file.tmp" "$users_file" fi else awk -v s="$start" -v e="$end" ' NR>=s && NR<=e && /^ - admins$/ { next } { print } ' "$users_file" > "$users_file.tmp" && mv "$users_file.tmp" "$users_file" fi } # Deletes a user's whole block (their [start,end] line range, as returned by # _authelia_user_line_range) from users.yml. Doesn't touch access_control.rules # or any "-only" group definition elsewhere — deleting the user's own # block is enough, since group membership only ever lived inside it. _authelia_delete_user_block() { local users_file="$1" start="$2" end="$3" awk -v s="$start" -v e="$end" 'NRe' "$users_file" > "$users_file.tmp" && mv "$users_file.tmp" "$users_file" } # Every "-only" group that exists anywhere in users.yml, deduplicated — # i.e. every service someone has already scoped access to via # _authelia_scope_access. Used to offer a numbered pick-list instead of asking # for a group name to be typed. _authelia_list_scoped_groups() { local users_file="$1" grep -oE '^ - [a-zA-Z0-9_-]+-only$' "$users_file" 2>/dev/null | sed 's/^ - //' | sort -u } # Same shape as _authelia_toggle_admin but for an arbitrary group name — # used to scope a user's access to a single service (see # _authelia_scope_access below) rather than the fixed "admins" group. _authelia_toggle_group() { local users_file="$1" start="$2" end="$3" group="$4" enable="$5" if [ "$enable" = "true" ]; then if ! sed -n "${start},${end}p" "$users_file" | grep -qF " - ${group}"; then awk -v s="$start" -v e="$end" -v grp=" - ${group}" ' { print } NR>=s && NR<=e && /^ groups:$/ { print grp } ' "$users_file" > "$users_file.tmp" && mv "$users_file.tmp" "$users_file" fi else awk -v s="$start" -v e="$end" -v grpline=" - ${group}" ' NR>=s && NR<=e && $0==grpline { next } { print } ' "$users_file" > "$users_file.tmp" && mv "$users_file.tmp" "$users_file" fi } # Non-interactive core of add_authelia_user() below — no prompts, takes # everything as args, generates a temp password + hash, and writes the user # block directly into an arbitrary extra group (not just "users"). Used by # _authelia_scope_access() to create users on the fly when someone lists a # username that doesn't exist yet. Deliberately a separate function rather # than a refactor of add_authelia_user() itself — that one's already in # regular use via the interactive menu and this repo's convention is to # extract a non-interactive core only when a second caller actually needs # it (see _authelia_provision_oidc_client for the same reasoning), which # keeps this addition low-risk to the existing, working function. # # Args: USERNAME DISPLAY EMAIL GROUP # Out-param (not `local`): AUTHELIA_NEW_USER_TEMP_PASSWORD # Returns 1 if the user already exists or hash generation fails. _authelia_create_user_noninteractive() { local username="$1" display="$2" email="$3" group="$4" local users_file="$DOCKER_DIR/authelia/config/users.yml" AUTHELIA_NEW_USER_TEMP_PASSWORD="" if grep -qE "^ ${username}:$" "$users_file" 2>/dev/null; then log_warning "'$username' already exists in $users_file." return 1 fi local temp_pass new_hash temp_pass="$(_authelia_gen_temp_password)" new_hash=$(docker run --rm authelia/authelia:4.39.20 \ authelia crypto hash generate argon2 --password "$temp_pass" 2>/dev/null \ | grep -oP '(?<=Digest: ).*') if [ -z "$new_hash" ]; then log_warning "Couldn't generate a password hash for '$username' automatically." return 1 fi local user_block=" ${username}: displayname: \"${display}\" email: ${email} password: \"${new_hash}\" groups: - ${group}" awk -v block="$user_block" ' { print } /^users:$/ && !done { print block; done=1 } ' "$users_file" > "$users_file.tmp" && mv "$users_file.tmp" "$users_file" chown 1000:1000 "$users_file" 2>/dev/null || true AUTHELIA_NEW_USER_TEMP_PASSWORD="$temp_pass" log_success "Created user '$username' (group: $group)" return 0 } # Reusable by ANY service, after it's already been protected by Authelia — # forward_auth gate or native OIDC alike, since this only cares about the # domain, not the gating mechanism. Asks whether access to $DOMAIN should be # open to any Authelia user (today's only behavior, before this existed) or # scoped to a specific list. If scoped: creates a dedicated group named # "-only", adds every listed username to it (creating any that # don't exist yet via _authelia_create_user_noninteractive), and inserts two # access_control rules ABOVE the general catch-all — allow this group on # $DOMAIN, deny this group on every other protected domain on the instance — # so members can reach ONLY this one domain. Idempotent: reruns against a # domain that's already scoped just report the existing group instead of # duplicating rules. # # Args: SERVICE_ID DOMAIN _authelia_scope_access() { local service_id="$1" domain="$2" local authelia_dir="$DOCKER_DIR/authelia" local config_file="$authelia_dir/config/configuration.yml" local users_file="$authelia_dir/config/users.yml" [ -f "$config_file" ] || return 0 local group="${service_id}-only" if grep -qF "subject: \"group:${group}\"" "$config_file" 2>/dev/null; then log_info "Access to $domain is already scoped to group '$group'." log_info "Manage its members via this menu's \"Edit an existing user\" (toggle their groups by hand in users.yml), or the universal-access report below." return 0 fi echo "" echo " Who should be able to reach $domain via Authelia?" echo " 1) Any Authelia user (default — same access as everything else)" echo " 2) Specific users only" local scope_choice="" prompt_text " Choice [1/2]:" "1" scope_choice [ "$scope_choice" = "2" ] || return 0 local -a existing_users mapfile -t existing_users < <(_authelia_list_usernames "$users_file") local i if [ "${#existing_users[@]}" -gt 0 ]; then echo " Existing Authelia users:" for i in "${!existing_users[@]}"; do echo " $((i + 1))) ${existing_users[$i]}" done echo " Pick by number (space-separated), and/or type new usernames directly" echo " to create them — mix freely, e.g. \"1 3 newperson\"." else echo " No existing Authelia users yet — type usernames below to create them fresh." fi echo " Anyone typed (not picked by number) who doesn't already have an" echo " Authelia account gets one created — you'll get their temporary" echo " password to hand over." local raw_users="" prompt_text " Usernames/numbers:" "" raw_users local -a raw_tokens usernames read -ra raw_tokens <<< "$raw_users" if [ "${#raw_tokens[@]}" -eq 0 ]; then log_warning "No usernames entered — leaving $domain open to all Authelia users." return 0 fi local t for t in "${raw_tokens[@]}"; do if [[ "$t" =~ ^[0-9]+$ ]] && [ "$t" -ge 1 ] && [ "$t" -le "${#existing_users[@]}" ]; then usernames+=("${existing_users[$((t - 1))]}") else usernames+=("$t") fi done local u start_end start end for u in "${usernames[@]}"; do u="$(echo "$u" | tr -cs 'a-zA-Z0-9_-' '-' | sed 's/^-*//;s/-*$//')" [ -z "$u" ] && continue if grep -qE "^ ${u}:$" "$users_file" 2>/dev/null; then start_end="$(_authelia_user_line_range "$users_file" "$u")" start="${start_end% *}"; end="${start_end#* }" _authelia_toggle_group "$users_file" "$start" "$end" "$group" "true" log_success "Added '$u' to group '$group'" else local email_default="${u}@${SITE_DOMAIN:-example.com}" if _authelia_create_user_noninteractive "$u" "$u" "$email_default" "$group"; then echo " Temp password for '$u': $AUTHELIA_NEW_USER_TEMP_PASSWORD" fi fi done # Two rules, both above the general catch-all: allow this group on the # target domain, deny this group on every other protected domain. Order # matters — Authelia takes the first matching rule, so both must land # before access_control's existing "*.${AUTHELIA_DOMAIN}" catch-all. local authelia_domain authelia_domain="$(awk '/^ cookies:$/{f=1; next} f && /domain:/{print $3; exit}' "$config_file")" local scope_rules=" - domain: \"${domain}\" subject: \"group:${group}\" policy: two_factor - domain: \"*.${authelia_domain}\" subject: \"group:${group}\" policy: deny" awk -v block="$scope_rules" ' /^ rules:$/ && !done { print; print block; done=1; next } { print } ' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file" chown 1000:1000 "$config_file" 2>/dev/null || true local restart_auth="" prompt_yn " Restart Authelia to apply this scoping? (y/n):" "y" restart_auth if [[ "$restart_auth" =~ ^[Yy]$ ]]; then (cd "$authelia_dir" && docker compose restart authelia 2>/dev/null) \ && log_success "Authelia restarted — $domain is now restricted to group '$group'." \ || log_warning "Restart failed — check: docker compose logs authelia" fi } # Reporting/management: lists which users have "universal" access (every # protected domain — anyone not locked into a "-only" group) versus # which are scoped to specific services, then offers to promote a scoped # user to universal by removing them from all their "-only" groups. Doesn't # touch access_control.rules at all — universal access is just the absence # of a restricting group, so "promoting" someone is purely a users.yml edit. _authelia_report_access_scope() { local users_file="$DOCKER_DIR/authelia/config/users.yml" [ -f "$users_file" ] || { log_warning "No users.yml found — install Authelia first."; return 1; } local -a all_users mapfile -t all_users < <(_authelia_list_usernames "$users_file") if [ "${#all_users[@]}" -eq 0 ]; then log_warning "No users found in $users_file." return 0 fi echo "" echo " Universal access (every protected domain):" local -a universal=() restricted=() local u start_end start end groups_in_range for u in "${all_users[@]}"; do start_end="$(_authelia_user_line_range "$users_file" "$u")" start="${start_end% *}"; end="${start_end#* }" groups_in_range="$(sed -n "${start},${end}p" "$users_file" | grep -oE '\- [a-z0-9_-]+-only$' | sed 's/^- //')" if [ -z "$groups_in_range" ]; then universal+=("$u") echo " - $u" else restricted+=("$u ($(echo "$groups_in_range" | tr '\n' ',' | sed 's/,$//'))") fi done [ "${#universal[@]}" -eq 0 ] && echo " (none)" echo "" echo " Scoped to specific services only:" if [ "${#restricted[@]}" -eq 0 ]; then echo " (none)" else printf ' - %s\n' "${restricted[@]}" fi echo "" local promote="" prompt_yn " Promote a scoped user to universal access? (y/n):" "n" promote [[ "$promote" =~ ^[Yy]$ ]] || return 0 local target="" prompt_text " Username to promote:" "" target [ -z "$target" ] && return 0 if ! grep -qE "^ ${target}:$" "$users_file" 2>/dev/null; then log_warning "'$target' not found in $users_file." return 0 fi start_end="$(_authelia_user_line_range "$users_file" "$target")" start="${start_end% *}"; end="${start_end#* }" local -a target_groups mapfile -t target_groups < <(sed -n "${start},${end}p" "$users_file" | grep -oE '\- [a-z0-9_-]+-only$' | sed 's/^- //') if [ "${#target_groups[@]}" -eq 0 ]; then log_info "'$target' already has universal access." return 0 fi local g for g in "${target_groups[@]}"; do _authelia_toggle_group "$users_file" "$start" "$end" "$g" "false" done log_success "'$target' removed from: ${target_groups[*]} — now has universal access." local restart_auth="" prompt_yn " Restart Authelia to apply? (y/n):" "y" restart_auth if [[ "$restart_auth" =~ ^[Yy]$ ]]; then (cd "$DOCKER_DIR/authelia" && docker compose restart authelia 2>/dev/null) \ && log_success "Authelia restarted" \ || log_warning "Restart failed — check: docker compose logs authelia" fi } # Changes how long an Authelia session lasts when a user checks "Remember # me" at login — the actual mechanism behind "log in once, don't get asked # again for a long time" for every domain this instance protects. # # The config key is `remember_me` (plain, under session:), NOT # `remember_me_duration` — that name was retired in Authelia 4.38, this # repo pins 4.39.20. Confirmed against Authelia's own docs/changelog # before writing this; an easy mistake since older guidance (including an # earlier version of this very file's own README section) uses the old # name, which Authelia would just silently ignore rather than error on. # # This only controls AUTHELIA's own session — it does not touch how long # a native-OIDC app's (Gitea/Mealie/ActualBudget) own session/token lasts # after logging in via Authelia. A long remember_me makes re-authenticating # to Authelia itself instant/silent whenever one of those apps' own # session expires and sends you back through the OIDC flow, but doesn't # stop that app's own session from expiring on its own separate schedule. _authelia_set_remember_me() { local config_file="$DOCKER_DIR/authelia/config/configuration.yml" [ -f "$config_file" ] || { log_warning "No configuration.yml found — install Authelia first."; return 1; } local current current="$(grep -E '^ remember_me:' "$config_file" | awk '{print $2}' | tr -d "'\"")" echo "" echo " Current \"remember me\" duration: ${current:-not set}" echo " How long a session lasts when someone checks \"Remember me\" at login —" echo " applies to every domain this Authelia instance protects." echo " Examples: 12h, 7d, 1M (month), 1y. Set to -1 to disable Remember Me entirely." local new_duration="" prompt_text " New duration [${current:-7d}]:" "${current:-7d}" new_duration if [ -z "$new_duration" ] || [ "$new_duration" = "$current" ]; then log_info "No change made." return 0 fi if grep -qE '^ remember_me:' "$config_file"; then sed -i "s/^ remember_me:.*/ remember_me: '${new_duration}'/" "$config_file" else sed -i "/^session:\$/a\\ remember_me: '${new_duration}'" "$config_file" fi chown 1000:1000 "$config_file" 2>/dev/null || true log_success "\"Remember me\" duration set to ${new_duration}." local restart_auth="" prompt_yn " Restart Authelia to apply? (y/n):" "y" restart_auth if [[ "$restart_auth" =~ ^[Yy]$ ]]; then (cd "$DOCKER_DIR/authelia" && docker compose restart authelia 2>/dev/null) \ && log_success "Authelia restarted" \ || log_warning "Restart failed — check: docker compose logs authelia" fi echo "" log_info "Takes effect for NEW logins where \"Remember me\" is checked at Authelia's" log_info "login page — existing sessions keep whatever expiration they already had." log_info "The checkbox itself is already on the login form by default; this only" log_info "changes how long checking it actually keeps you signed in." } # action="exempt": inserts a "policy: one_factor / subject: user:" rule # immediately before EVERY plain "policy: two_factor" catch-all domain rule in # configuration.yml (handles multi-domain instances from add_authelia_domain # automatically). action="restore": removes only this user's own such rules, # leaving any other user's exemptions and the catch-all rules untouched. # Caller is responsible for the idempotency check (only offer "exempt" in the # menu when not already exempt, and vice versa) — this helper doesn't dedupe. _authelia_set_2fa_exempt() { local config_file="$1" username="$2" action="$3" if [ "$action" = "exempt" ]; then awk -v user="$username" ' { lines[NR]=$0 } END { for (i=1; i<=NR; i++) { if (lines[i] ~ /^ - domain:/ && lines[i+1] ~ /policy: two_factor/) { domain = lines[i] sub(/^ - domain: /, "", domain) print " - domain: " domain print " policy: one_factor" print " subject: \"user:" user "\"" } print lines[i] } } ' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file" else awk -v user="$username" ' { lines[NR]=$0 } END { for (i=1; i<=NR; i++) { if (lines[i] ~ /^ - domain:/ && lines[i+1] ~ /policy: one_factor/ && lines[i+2] ~ ("subject: \"user:" user "\"")) { i += 2 continue } print lines[i] } } ' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file" fi chown 1000:1000 "$config_file" 2>/dev/null || true } # Interactive: pick an existing user from users.yml, then act on them — # edit email/display name, force a password reset, reset their 2FA device, # toggle whether they need 2FA at all, or toggle admin group membership. # Loops so multiple actions can be applied to the same user in one pass. edit_authelia_user() { local AUTHELIA_DIR="$DOCKER_DIR/authelia" local USERS_FILE="$AUTHELIA_DIR/config/users.yml" local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml" if [ ! -f "$USERS_FILE" ]; then log_warning "No users.yml found at $USERS_FILE — install Authelia first." return 1 fi # Outer loop: pick one or more users by number, act on each in turn (via # _authelia_manage_one_user below), then ask whether to go again — so # deleting/editing several users doesn't require re-running the whole # script and re-navigating this menu from scratch for every single one. local KEEP_GOING="y" while [[ "$KEEP_GOING" =~ ^[Yy]$ ]]; do local -a USERNAMES mapfile -t USERNAMES < <(_authelia_list_usernames "$USERS_FILE") if [ "${#USERNAMES[@]}" -eq 0 ]; then log_warning "No users left in $USERS_FILE." return 0 fi echo "" echo " Existing users:" local i for i in "${!USERNAMES[@]}"; do echo " $((i + 1))) ${USERNAMES[$i]}" done echo "" echo " Select one or more by number (space-separated, e.g. \"2 4\")," echo " blank to cancel." local SEL="" prompt_text " User number(s):" "" SEL if [ -z "$SEL" ]; then log_info "Cancelled." return 0 fi local -a SEL_TOKENS TARGETS=() read -ra SEL_TOKENS <<< "$SEL" local tok for tok in "${SEL_TOKENS[@]}"; do if [[ "$tok" =~ ^[0-9]+$ ]] && [ "$tok" -ge 1 ] && [ "$tok" -le "${#USERNAMES[@]}" ]; then TARGETS+=("${USERNAMES[$((tok - 1))]}") else log_warning "Skipping invalid selection: $tok" fi done local TARGET for TARGET in "${TARGETS[@]}"; do # A user picked earlier in this same batch may have just been # deleted (or this number was picked twice) — re-check before # acting instead of operating on a now-stale line range. grep -qE "^ ${TARGET}:$" "$USERS_FILE" 2>/dev/null || { log_info "'$TARGET' no longer exists — skipping."; continue; } _authelia_manage_one_user "$TARGET" "$USERS_FILE" "$CONFIG_FILE" "$AUTHELIA_DIR" done echo "" prompt_yn " Manage more users? (y/n):" "n" KEEP_GOING done } # Per-user action menu (edit/reset-password/2FA/admin/service-access/delete), # extracted out of edit_authelia_user() so its caller can drive it once per # selected user across a multi-user batch instead of only ever handling one # user per script invocation. _authelia_manage_one_user() { local TARGET="$1" USERS_FILE="$2" CONFIG_FILE="$3" AUTHELIA_DIR="$4" local CONTINUE="y" while [[ "$CONTINUE" =~ ^[Yy]$ ]]; do local RANGE START END DELETED=0 RANGE="$(_authelia_user_line_range "$USERS_FILE" "$TARGET")" START="${RANGE% *}"; END="${RANGE#* }" local IS_ADMIN="no" sed -n "${START},${END}p" "$USERS_FILE" | grep -q '^ - admins$' && IS_ADMIN="yes" local IS_EXEMPT="no" [ -f "$CONFIG_FILE" ] && grep -qF "subject: \"user:${TARGET}\"" "$CONFIG_FILE" && IS_EXEMPT="yes" echo "" echo " Editing user: $TARGET (admin: $IS_ADMIN, 2FA-exempt: $IS_EXEMPT)" echo " 1) Edit email / display name" echo " 2) Reset password" echo " 3) Reset 2FA device (they register a new one on next login)" if [ "$IS_EXEMPT" = "yes" ]; then echo " 4) Restore the 2FA requirement for this user" else echo " 4) Exempt this user from 2FA (one_factor only — weakens their account)" fi if [ "$IS_ADMIN" = "yes" ]; then echo " 5) Demote from admin" else echo " 5) Promote to admin" fi echo " 6) Promote to (or remove from) a specific service's access group" echo " 7) Delete this user" echo " 8) Done with this user" echo "" local ACTION="" prompt_text " Choice [1-8]:" "8" ACTION case "$ACTION" in 1) local CUR_EMAIL CUR_DISPLAY NEW_EMAIL NEW_DISPLAY CUR_EMAIL="$(sed -n "${START},${END}p" "$USERS_FILE" | grep '^ email:' | sed 's/^ email: *//')" CUR_DISPLAY="$(sed -n "${START},${END}p" "$USERS_FILE" | grep '^ displayname:' | sed 's/^ displayname: *//; s/^"//; s/"$//')" prompt_text " New email [$CUR_EMAIL]:" "$CUR_EMAIL" NEW_EMAIL prompt_text " New display name [$CUR_DISPLAY]:" "$CUR_DISPLAY" NEW_DISPLAY _authelia_set_user_field "$USERS_FILE" "$START" "$END" "email" " email: ${NEW_EMAIL}" _authelia_set_user_field "$USERS_FILE" "$START" "$END" "displayname" " displayname: \"${NEW_DISPLAY}\"" chown 1000:1000 "$USERS_FILE" 2>/dev/null || true log_success "Updated $TARGET's email/display name." ;; 2) log_info "Generating a new temporary password + hash..." local NEW_TEMP_PASS NEW_HASH NEW_TEMP_PASS="$(_authelia_gen_temp_password)" NEW_HASH=$(docker run --rm authelia/authelia:4.39.20 \ authelia crypto hash generate argon2 --password "$NEW_TEMP_PASS" 2>/dev/null \ | grep -oP '(?<=Digest: ).*') if [ -z "$NEW_HASH" ]; then log_warning "Couldn't generate the password hash automatically — nothing changed. Try again." else _authelia_set_user_field "$USERS_FILE" "$START" "$END" "password" " password: \"${NEW_HASH}\"" chown 1000:1000 "$USERS_FILE" 2>/dev/null || true log_success "Password reset for $TARGET." echo " New password: ${NEW_TEMP_PASS}" echo " Give this to them directly — shown once, not stored in plaintext anywhere." fi ;; 3) if docker ps --format '{{.Names}}' | grep -q '^authelia$'; then if docker exec authelia authelia storage user totp delete "$TARGET" --config /config/configuration.yml 2>/dev/null; then log_success "TOTP device reset for $TARGET — they'll register a new one on next login." else log_warning "No TOTP device found for $TARGET (or the delete failed) — check: docker compose logs authelia" fi echo " WebAuthn devices (if any) aren't covered by this option — reset those manually with:" echo " docker exec authelia authelia storage user webauthn delete --username $TARGET --config /config/configuration.yml" else log_warning "Authelia isn't running — start it first: cd $AUTHELIA_DIR && docker compose up -d" fi ;; 4) if [ "$IS_EXEMPT" = "yes" ]; then _authelia_set_2fa_exempt "$CONFIG_FILE" "$TARGET" "restore" log_success "Restored the two_factor requirement for $TARGET." else local CONFIRM_EXEMPT="" prompt_yn " $TARGET will be able to log in with just a password (no 2FA) on every domain this instance protects. Continue? (y/n):" "n" CONFIRM_EXEMPT if [[ "$CONFIRM_EXEMPT" =~ ^[Yy]$ ]]; then _authelia_set_2fa_exempt "$CONFIG_FILE" "$TARGET" "exempt" log_success "$TARGET no longer needs 2FA (one_factor only)." else log_info "Left as-is." fi fi ;; 5) if [ "$IS_ADMIN" = "yes" ]; then _authelia_toggle_admin "$USERS_FILE" "$START" "$END" "false" chown 1000:1000 "$USERS_FILE" 2>/dev/null || true log_success "$TARGET demoted from admin." else _authelia_toggle_admin "$USERS_FILE" "$START" "$END" "true" chown 1000:1000 "$USERS_FILE" 2>/dev/null || true log_success "$TARGET promoted to admin." fi ;; 6) local -a SCOPED_GROUPS mapfile -t SCOPED_GROUPS < <(_authelia_list_scoped_groups "$USERS_FILE") if [ "${#SCOPED_GROUPS[@]}" -eq 0 ]; then log_info "No service-scoped access groups exist yet — every protected domain is currently open to any Authelia user. A service gets a scoped group when it's first protected with Authelia SSO and \"Specific users only\" is chosen." ACTION="" else echo "" echo " Service-scoped access groups (* = $TARGET is currently a member):" local gi grp member for gi in "${!SCOPED_GROUPS[@]}"; do grp="${SCOPED_GROUPS[$gi]}" member=" " sed -n "${START},${END}p" "$USERS_FILE" | grep -qF " - ${grp}" && member="*" echo " $((gi + 1))) [${member}] ${grp%-only}" done echo "" echo " Pick by number (space-separated) to toggle — a member gets removed," echo " a non-member gets added. Blank to leave unchanged." local TOGGLE_SEL="" prompt_text " Numbers:" "" TOGGLE_SEL local -a TOGGLE_TOKENS read -ra TOGGLE_TOKENS <<< "$TOGGLE_SEL" local tk tidx tgrp t_start t_end t_range is_member for tk in "${TOGGLE_TOKENS[@]}"; do [[ "$tk" =~ ^[0-9]+$ ]] || continue [ "$tk" -ge 1 ] && [ "$tk" -le "${#SCOPED_GROUPS[@]}" ] || continue tidx=$((tk - 1)) tgrp="${SCOPED_GROUPS[$tidx]}" # Re-resolve the user's line range before every toggle — a prior # toggle in this same loop shifts every line after it, so reusing # the outer START/END here would drift after the first change. t_range="$(_authelia_user_line_range "$USERS_FILE" "$TARGET")" t_start="${t_range% *}"; t_end="${t_range#* }" is_member="false" sed -n "${t_start},${t_end}p" "$USERS_FILE" | grep -qF " - ${tgrp}" && is_member="true" if [ "$is_member" = "true" ]; then _authelia_toggle_group "$USERS_FILE" "$t_start" "$t_end" "$tgrp" "false" log_success "Removed $TARGET from '${tgrp}' (${tgrp%-only})" else _authelia_toggle_group "$USERS_FILE" "$t_start" "$t_end" "$tgrp" "true" log_success "Added $TARGET to '${tgrp}' (${tgrp%-only})" fi done chown 1000:1000 "$USERS_FILE" 2>/dev/null || true fi ;; 7) echo "" log_warning "This permanently removes '$TARGET' from $USERS_FILE — they won't be able to log in again until re-added." local CONFIRM_DELETE="" prompt_yn " Delete user '$TARGET'? (y/n):" "n" CONFIRM_DELETE if [[ "$CONFIRM_DELETE" =~ ^[Yy]$ ]]; then _authelia_delete_user_block "$USERS_FILE" "$START" "$END" chown 1000:1000 "$USERS_FILE" 2>/dev/null || true log_success "Deleted user '$TARGET'." DELETED=1 else log_info "Left as-is." fi ;; *) ACTION="8" ;; esac if [ "$DELETED" = "1" ]; then local RESTART_AUTH="" prompt_yn " Restart Authelia to apply this change? (y/n):" "y" RESTART_AUTH if [ "$RESTART_AUTH" = "y" ] || [ "$RESTART_AUTH" = "Y" ]; then (cd "$AUTHELIA_DIR" && docker compose restart authelia 2>/dev/null) \ && log_success "Authelia restarted" \ || log_warning "Restart failed — check: docker compose logs authelia" fi CONTINUE="n" elif [[ "$ACTION" =~ ^[12456]$ ]]; then local RESTART_AUTH="" prompt_yn " Restart Authelia to apply this change? (y/n):" "y" RESTART_AUTH if [ "$RESTART_AUTH" = "y" ] || [ "$RESTART_AUTH" = "Y" ]; then (cd "$AUTHELIA_DIR" && docker compose restart authelia 2>/dev/null) \ && log_success "Authelia restarted" \ || log_warning "Restart failed — check: docker compose logs authelia" fi echo "" prompt_yn " Do something else with $TARGET? (y/n):" "n" CONTINUE else CONTINUE="n" fi done } # Enables Authelia's OIDC PROVIDER feature — a distinct thing from the # forward_auth (proxy-auth) setup install_authelia() already does. forward_auth # gates a whole Caddy site behind an Authelia login page before the request # ever reaches the app; OIDC provider mode is the opposite direction — an app # with its OWN "Enable OpenID"/SSO setting (ActualBudget, Vaultwarden, etc.) # delegates ITS login to Authelia instead of asking a user for a # service-specific password. Neither replaces the other; a service can use # either, both, or neither. # # One-time, idempotent (checked via the identity_providers: key already being # present) — every _authelia_add_oidc_client() call runs this first so OIDC # just works the first time an app is registered, no separate "enable OIDC" # step to remember. _authelia_ensure_oidc_provider() { local AUTHELIA_DIR="$1" local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml" local SECRETS_DIR="$AUTHELIA_DIR/config/secrets" grep -q '^identity_providers:' "$CONFIG_FILE" 2>/dev/null && return 0 log_info "Enabling Authelia's OIDC provider (one-time — lets other apps log in via Authelia)..." # hmac_secret: injected via a _FILE env var in docker-compose.yml, same # convention as jwt/session/storage secrets above — configuration.yml # itself never holds this one as a raw string. "Random Value: " # is the exact (and only) line this subcommand prints — confirmed # against Authelia's own CLI source, not assumed. local _rand_out _rand_out="$(docker run --rm authelia/authelia:4.39.20 \ authelia crypto rand --length 64 --charset alphanumeric 2>/dev/null)" echo "${_rand_out#Random Value: }" > "$SECRETS_DIR/oidc_hmac_secret" if [ ! -s "$SECRETS_DIR/oidc_hmac_secret" ]; then log_warning "Couldn't generate the OIDC HMAC secret — skipping OIDC provider setup. Re-run to try again." return 1 fi chmod 600 "$SECRETS_DIR/oidc_hmac_secret" # RSA keypair for signing OIDC tokens (jwks). Authelia's schema requires # the private key inlined as PEM directly in configuration.yml — no # file-path or _FILE-env-var option for this specific nested field # (confirmed against the current identity_providers.oidc.jwks schema) — # so this generates into config/secrets/ for safe permissions, then reads # it back in below. "private.pem"/"public.pem" are the CLI's own default # output filenames (confirmed against Authelia's CLI reference), not # guessed. docker run --rm -u "$(id -u):$(id -g)" -v "$SECRETS_DIR":/keys \ authelia/authelia:4.39.20 authelia crypto pair rsa generate --directory /keys >/dev/null 2>&1 if [ ! -f "$SECRETS_DIR/private.pem" ]; then log_warning "Couldn't generate the OIDC signing key — skipping OIDC provider setup. Re-run to try again." return 1 fi chmod 600 "$SECRETS_DIR/private.pem" "$SECRETS_DIR/public.pem" 2>/dev/null { echo "" echo "identity_providers:" echo " oidc:" echo " jwks:" echo " - key_id: 'main'" echo " algorithm: 'RS256'" echo " use: 'sig'" echo " key: |" sed 's/^/ /' "$SECRETS_DIR/private.pem" echo " clients: []" } >> "$CONFIG_FILE" if ! grep -q 'AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE' "$AUTHELIA_DIR/docker-compose.yml"; then sed -i '/AUTHELIA_NOTIFIER_SMTP_SENDER/a\ - AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE=/config/secrets/oidc_hmac_secret' \ "$AUTHELIA_DIR/docker-compose.yml" fi chown -R 1000:1000 "$AUTHELIA_DIR/config" chmod 600 "$CONFIG_FILE" log_success "OIDC provider enabled (signing key + HMAC secret generated)" } # Deletes one OIDC client block (matched by client_id) from # identity_providers.oidc.clients in configuration.yml. Used by # _authelia_provision_oidc_client below to make re-registering a client_id # idempotent instead of a dead end — see that function's own comment on # why a stale registration is safe to just replace. A client block starts # at its own " - client_id: ''" line (6-space indent) and runs # until either the next such line or a line indented less than 6 spaces # (end of the clients list) — deleting stops exactly there so a sibling # client's block, or whatever config section follows, is untouched. _authelia_remove_oidc_client() { local config_file="$1" client_id="$2" awk -v target="'${client_id}'" ' { if ($0 ~ /^ - client_id: /) { skip = ($0 ~ target) ? 1 : 0 } else if (skip && $0 !~ /^ /) { skip = 0 } if (!skip) print } ' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file" chown 1000:1000 "$config_file" 2>/dev/null || true } # Non-interactive core of _authelia_add_oidc_client() below — generates a # client secret, patches it into identity_providers.oidc.clients, and # (optionally) restarts Authelia. Fully self-contained (re-validates # everything itself rather than trusting a caller's state) so other # services can call it directly to register themselves as an OIDC client # without walking a human through this file's own menu — see # services/gitea.sh's "Sign in with Authelia" step for the reference caller. # Guard every cross-file call with `declare -F` per this repo's chaining # convention (services/gitea.sh does). # # Args: APP_NAME CLIENT_ID AUTH_POLICY RESTART_AUTH(y/n) [ ...] # Out-params (not `local` — read them after the call returns): # OIDC_CLIENT_SECRET_PLAIN the plaintext secret. Shown once — Authelia's # config only ever stores the hash — so the # caller must capture and use/display it now. # OIDC_AUTHELIA_DOMAIN this Authelia instance's apex domain, for # building discovery/authorization/token URLs. # OIDC_AUTHELIA_PORTAL_URL the actual login-portal base URL (e.g. # https://auth.example.com) — read back from this # instance's own config rather than assumed, # since the portal subdomain isn't always "auth." # (install_authelia()/add_authelia_domain() both # default to it, but it's plain text in # configuration.yml and gets hand-edited on some # boxes — e.g. a dedicated VPS instance renamed # to "authelia." to avoid colliding with another # instance's "auth." on a different machine). # Use this, not a hardcoded "https://auth.$domain", # when building a discovery/redirect URL for a # native-OIDC app. # Returns 1 on failure (Authelia not installed, domain undeterminable, # secret generation failed) with the reason already logged. A client_id # that's already registered is NOT a failure — it gets replaced (see the # comment at that check below). _authelia_provision_oidc_client() { local APP_NAME="$1" CLIENT_ID="$2" AUTH_POLICY="$3" RESTART_AUTH="$4"; shift 4 local -a REDIRECT_URIS=("$@") OIDC_CLIENT_SECRET_PLAIN="" OIDC_AUTHELIA_DOMAIN="" OIDC_AUTHELIA_PORTAL_URL="" local AUTHELIA_DIR="$DOCKER_DIR/authelia" local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml" if [ ! -f "$CONFIG_FILE" ]; then log_warning "No configuration.yml found at $CONFIG_FILE — install Authelia first." return 1 fi _authelia_ensure_oidc_provider "$AUTHELIA_DIR" || return 1 # The apex domain this Authelia instance already serves — read back from # its own session.cookies (same structure install_authelia()/ # add_authelia_domain() write), rather than asking again or assuming a # variable set earlier in this run is still in scope (this flow can be # reached standalone from the "already exists" menu, or from another # service entirely, with none of install_authelia()'s own locals ever # having run this session). # tr -d '\r' first, not after — a CRLF-tainted config (e.g. a line # hand-edited by something that saves Windows line endings) makes every # line-anchored awk pattern below fail to match at all, not just leave a # stray \r in the captured value: " cookies:\r" doesn't match # /^ cookies:$/ since $ anchors end-of-string and the \r is still part # of it. Also strip a leading/trailing quote character: these fields are # unquoted in every value this repo's own scripts write, but YAML makes # quoting optional and a hand-edited config can add single or double # quotes around the value. awk's `print $2`/`print $3` is a naive # whitespace-split token grab that doesn't know about YAML quoting, so a # quoted value comes back WITH the literal quote characters still # attached. Confirmed live: this is what actually caused a "line 12: # unexpected character '/' in variable name" failure in Mealie's # .env — an authelia_url value hand-edited to # `authelia_url: 'https://authelia.example.com'` got captured as the # literal string including both single quotes, so the generated # discovery URL came out `'https://authelia.example.com'/.well-known/...` # — Docker Compose's env parser closed the quoted value at that # embedded closing quote and choked on everything after it as a new, # invalid token. (The earlier \r-stripping guards a different, # also-real failure mode — a CRLF-tainted line failing to match these # anchored patterns at all — not this one; both are needed.) OIDC_AUTHELIA_DOMAIN="$(tr -d '\r' < "$CONFIG_FILE" | awk '/^ cookies:$/{f=1; next} f && /domain:/{print $3; exit}' | sed "s/^[\"']//; s/[\"']\$//")" if [ -z "$OIDC_AUTHELIA_DOMAIN" ]; then log_warning "Couldn't determine this Authelia instance's domain from $CONFIG_FILE — aborting." return 1 fi # Read the real portal URL back from config instead of assuming the # "auth." prefix — see the OIDC_AUTHELIA_PORTAL_URL out-param comment # above for why this can't be hardcoded. Falls back to the "auth." # default only if parsing somehow comes up empty. OIDC_AUTHELIA_PORTAL_URL="$(tr -d '\r' < "$CONFIG_FILE" | awk '/^ cookies:$/{f=1; next} f && /authelia_url:/{print $2; exit}' | sed "s/^[\"']//; s/[\"']\$//")" [ -z "$OIDC_AUTHELIA_PORTAL_URL" ] && OIDC_AUTHELIA_PORTAL_URL="https://auth.${OIDC_AUTHELIA_DOMAIN}" # A stale registration (e.g. from the interactive "Register an app" menu # run previously without ever finishing — its plaintext secret was shown # once and is gone, so the registration is dead weight either way) would # otherwise permanently block this exact service's automated SSO offer # with nothing but a warning. Confirmed live: this is what happened to # ActualBudget the first time its own offer ran, against a client_id the # menu had already registered in an earlier session. Safe to just # replace — every automated caller here uses a fixed, service-specific # client_id, so a collision means "this same service, already # registered" rather than someone else's app using the same ID. if grep -qF "client_id: '${CLIENT_ID}'" "$CONFIG_FILE" 2>/dev/null; then log_warning "A client with ID '$CLIENT_ID' is already registered — replacing it with a fresh one (its old secret was never recoverable anyway)." _authelia_remove_oidc_client "$CONFIG_FILE" "$CLIENT_ID" fi log_info "Generating client secret..." local _hash_out CLIENT_SECRET_HASH _hash_out="$(docker run --rm authelia/authelia:4.39.20 \ authelia crypto hash generate pbkdf2 --variant sha512 --random \ --random.length 72 --random.charset rfc3986 2>/dev/null)" OIDC_CLIENT_SECRET_PLAIN="$(echo "$_hash_out" | sed -n 's/^Random Password: //p')" CLIENT_SECRET_HASH="$(echo "$_hash_out" | sed -n 's/^Digest: //p')" if [ -z "$OIDC_CLIENT_SECRET_PLAIN" ] || [ -z "$CLIENT_SECRET_HASH" ]; then log_warning "Couldn't generate the client secret automatically. Run manually, then add the" log_warning "client to $CONFIG_FILE's identity_providers.oidc.clients by hand:" echo " docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72 --random.charset rfc3986" OIDC_CLIENT_SECRET_PLAIN="" return 1 fi grep -q '^ clients: \[\]$' "$CONFIG_FILE" && sed -i 's/^ clients: \[\]$/ clients:/' "$CONFIG_FILE" local REDIRECT_URIS_YAML REDIRECT_URIS_YAML="$(printf " - '%s'\n" "${REDIRECT_URIS[@]}")" REDIRECT_URIS_YAML="${REDIRECT_URIS_YAML%$'\n'}" local CLIENT_BLOCK=" - client_id: '${CLIENT_ID}' client_name: '${APP_NAME}' client_secret: '${CLIENT_SECRET_HASH}' public: false authorization_policy: '${AUTH_POLICY}' redirect_uris: ${REDIRECT_URIS_YAML} scopes: - 'openid' - 'profile' - 'email' grant_types: - 'authorization_code' response_types: - 'code' response_modes: - 'query' userinfo_signed_response_alg: 'none'" awk -v block="$CLIENT_BLOCK" ' { print } /^ clients:$/ && !done { print block; done=1 } ' "$CONFIG_FILE" > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE" chown 1000:1000 "$CONFIG_FILE" 2>/dev/null || true if [[ "$RESTART_AUTH" =~ ^[Yy]$ ]]; then (cd "$AUTHELIA_DIR" && docker compose restart authelia 2>/dev/null) \ && log_success "Authelia restarted" \ || log_warning "Authelia restart failed — check: docker compose logs authelia" fi return 0 } # Registers an OIDC client for another app to log in via Authelia — the # "Other" provider option in an app's own "Enable OpenID"/SSO dialog. Presets # below hand back the app's own known redirect URI path and the exact fields # to paste where; "Other/custom" covers anything not listed (the app's own # OIDC/SSO docs will say what redirect URI it expects). Interactive wrapper # around _authelia_provision_oidc_client() above, which does the actual work. _authelia_add_oidc_client() { local AUTHELIA_DIR="$DOCKER_DIR/authelia" local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml" if [ ! -f "$CONFIG_FILE" ]; then log_warning "No configuration.yml found at $CONFIG_FILE — install Authelia first." return 1 fi # The apex domain this Authelia instance already serves — read back from # its own session.cookies (same structure install_authelia()/ # add_authelia_domain() write), rather than asking again or assuming a # variable set earlier in this run is still in scope (this flow can be # reached standalone from the "already exists" menu with none of # install_authelia()'s own locals ever having run this session). Used # below only to suggest a domain default — _authelia_provision_oidc_client # re-derives its own copy independently. local AUTHELIA_DOMAIN AUTHELIA_DOMAIN="$(tr -d '\r' < "$CONFIG_FILE" | awk '/^ cookies:$/{f=1; next} f && /domain:/{print $3; exit}' | sed "s/^[\"']//; s/[\"']\$//")" if [ -z "$AUTHELIA_DOMAIN" ]; then log_warning "Couldn't determine this Authelia instance's domain from $CONFIG_FILE — aborting." return 1 fi # This domain's own portal — whatever subdomain was actually chosen at # install time (see install_authelia's own AUTHELIA_PORTAL_SUBDOMAIN # prompt), not necessarily "auth.". Read back the same way # AUTHELIA_DOMAIN itself is, from this entry's own authelia_url. local AUTHELIA_PORTAL_DOMAIN AUTHELIA_PORTAL_DOMAIN="$(tr -d '\r' < "$CONFIG_FILE" | awk -v domain="$AUTHELIA_DOMAIN" ' $0 == " - domain: " domain { f=1; next } f && /authelia_url:/ { print $2; exit } ' | sed -E 's#^https?://##')" [ -z "$AUTHELIA_PORTAL_DOMAIN" ] && AUTHELIA_PORTAL_DOMAIN="auth.${AUTHELIA_DOMAIN}" echo "" echo " Register another app to log in via Authelia (OIDC/SSO)." echo "" echo " 1) ActualBudget" echo " 2) Vaultwarden" echo " 3) Immich (needs multiple redirect URIs — web login, account-linking," echo " and the mobile app's custom-scheme callback — all registered here)" echo " 4) Other / custom app" echo "" local APP_CHOICE="" prompt_text " Choice [1/2/3/4]:" "4" APP_CHOICE local APP_NAME="" CLIENT_ID="" local -a REDIRECT_PATHS=() EXTRA_REDIRECT_URIS=() case "$APP_CHOICE" in 1) APP_NAME="ActualBudget"; CLIENT_ID="actualbudget"; REDIRECT_PATHS=("/openid/callback") ;; 2) APP_NAME="Vaultwarden"; CLIENT_ID="vaultwarden"; REDIRECT_PATHS=("/identity/connect/oidc-signin") ;; 3) APP_NAME="Immich"; CLIENT_ID="immich" REDIRECT_PATHS=("/auth/login" "/user-settings") EXTRA_REDIRECT_URIS=("app.immich:///oauth-callback") ;; *) prompt_text " App name (for your reference):" "" APP_NAME [ -z "$APP_NAME" ] && { log_warning "No app name entered — nothing to do."; return 0; } CLIENT_ID="$(echo "$APP_NAME" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//;s/-*$//')" prompt_text " Client ID [${CLIENT_ID}]:" "$CLIENT_ID" CLIENT_ID echo " Check ${APP_NAME}'s own OIDC/SSO docs for its exact redirect URI path" echo " (often something like /oauth/callback, /auth/callback, /sso/callback)." local _redirect_path="" prompt_text " Redirect URI path (starting with /):" "" _redirect_path [ -n "$_redirect_path" ] && REDIRECT_PATHS=("$_redirect_path") ;; esac if [ -z "$CLIENT_ID" ] || { [ "${#REDIRECT_PATHS[@]}" -eq 0 ] && [ "${#EXTRA_REDIRECT_URIS[@]}" -eq 0 ]; }; then log_warning "Missing client ID or redirect path — nothing to do." return 0 fi # No duplicate-ID check here — _authelia_provision_oidc_client below # already handles that safely by replacing the stale registration (its # old secret was shown once and is unrecoverable either way, so there's # nothing to lose). An earlier version of this function dead-ended here # instead ("pick a different app, or edit by hand") before ever # reaching that safe path — confirmed live: this blocked re-registering # ActualBudget after nothing more than a first attempt, with no way # through except hand-editing configuration.yml. local APP_DOMAIN APP_DOMAIN="$(_authelia_pick_domain "Domain ${APP_NAME} is reachable at (number or domain)")" if [ -z "$APP_DOMAIN" ]; then log_warning "No domain entered — nothing to do." return 0 fi # Domain-relative paths (web login, account-linking, ...) plus any # already-complete URIs that aren't domain-based (Immich's mobile app # custom-scheme callback isn't reached over https at all). local -a REDIRECT_URIS=() local _p for _p in "${REDIRECT_PATHS[@]}"; do REDIRECT_URIS+=("https://${APP_DOMAIN}${_p}") done for _p in "${EXTRA_REDIRECT_URIS[@]}"; do REDIRECT_URIS+=("$_p") done local _2fa="" AUTH_POLICY="two_factor" prompt_yn " Require two-factor for ${APP_NAME} logins too? (y/n):" "y" _2fa [[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor" local RESTART_AUTH="" prompt_yn " Restart Authelia to apply? (y/n):" "y" RESTART_AUTH _authelia_provision_oidc_client "$APP_NAME" "$CLIENT_ID" "$AUTH_POLICY" "$RESTART_AUTH" "${REDIRECT_URIS[@]}" \ || return 1 local CLIENT_SECRET_PLAIN="$OIDC_CLIENT_SECRET_PLAIN" echo "" echo " ${APP_NAME} is registered. Paste these into its OpenID/SSO settings" echo " (choose \"Other\" as the provider if it's not listed by name):" echo "" echo " Client ID: ${CLIENT_ID}" echo " Client Secret: ${CLIENT_SECRET_PLAIN}" echo " Discovery URL: https://${AUTHELIA_PORTAL_DOMAIN}/.well-known/openid-configuration" echo "" echo " If it asks for individual endpoints instead of a discovery URL:" echo " Authorization: https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/authorization" echo " Token: https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/token" echo " UserInfo: https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/userinfo" echo " Scopes: openid profile email" echo "" case "$APP_CHOICE" in 1) echo " ActualBudget's \"Enable OpenID\" dialog → provider \"Other\": paste the" echo " Discovery URL, Client ID, and Client Secret above." echo " First OIDC login becomes the ActualBudget server owner." echo "" ;; 2) echo " Add these to Vaultwarden's .env, then: cd \$VAULTWARDEN_DIR && docker compose up -d" echo " SSO_ENABLED=true" echo " SSO_AUTHORITY=https://${AUTHELIA_PORTAL_DOMAIN}" echo " SSO_CLIENT_ID=${CLIENT_ID}" echo " SSO_CLIENT_SECRET=${CLIENT_SECRET_PLAIN}" echo " SSO_SCOPES=profile email" echo " Enabling SSO changes Vaultwarden's login flow for everyone on this" echo " instance — see Vaultwarden's own SSO docs before turning this on for" echo " a vault other people already use." echo "" ;; 3) echo " Immich → Administration → Settings → OAuth Authentication:" echo " Issuer URL: https://${AUTHELIA_PORTAL_DOMAIN}" echo " (Immich appends /.well-known/openid-configuration itself — paste" echo " just the base URL above, not the full Discovery URL from earlier.)" echo " Client ID: ${CLIENT_ID}" echo " Client Secret: ${CLIENT_SECRET_PLAIN}" echo " Scope: openid email profile" echo " Enable OAuth login on that same settings page, then check its other" echo " toggles there (auto-register new accounts, storage label claim, etc.)" echo " — those are Immich-side choices this script doesn't set for you." echo " Three redirect URIs were registered above: the web login, the" echo " account-linking page, and the mobile app's callback — all needed" echo " for OAuth to work in both the browser and the Immich mobile app." echo "" ;; esac log_warning "The Client Secret above is shown once — it isn't stored in plaintext anywhere. Save it now." } # Interactive wrapper around _authelia_remove_oidc_client (the internal # helper _authelia_provision_oidc_client already uses to replace a stale # registration) — exposes it as its own menu action so removing an app's # OIDC client doesn't require hand-editing configuration.yml either. Lists # every registered client's ID and display name, numbered; removing one # doesn't affect that app's own separate password login (if it has one) or # any other client on this instance. _authelia_remove_oidc_client_menu() { local AUTHELIA_DIR="$DOCKER_DIR/authelia" local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml" if [ ! -f "$CONFIG_FILE" ]; then log_warning "No configuration.yml found at $CONFIG_FILE — install Authelia first." return 1 fi local -a client_ids client_names mapfile -t client_ids < <(grep -oP "(?<=- client_id: ')[^']+" "$CONFIG_FILE") mapfile -t client_names < <(grep -oP "(?<=client_name: ')[^']+" "$CONFIG_FILE") if [ "${#client_ids[@]}" -eq 0 ]; then log_info "No OIDC clients registered on this instance." return 0 fi echo "" echo " Registered OIDC clients:" local i for i in "${!client_ids[@]}"; do echo " $((i + 1))) ${client_ids[$i]} (${client_names[$i]:-unnamed})" done echo "" local choice="" prompt_text " Number to remove (blank to cancel):" "" choice if [ -z "$choice" ] || ! [[ "$choice" =~ ^[0-9]+$ ]] || [ "$choice" -lt 1 ] || [ "$choice" -gt "${#client_ids[@]}" ]; then log_info "Cancelled — nothing changed." return 0 fi local target_id="${client_ids[$((choice - 1))]}" log_warning "This removes the OIDC client '${target_id}' — anyone using it to log into that app" log_warning "via Authelia will no longer be able to until it's re-registered (option 5). It does" log_warning "NOT touch that app's own separate password login, if it has one." local confirm="" prompt_yn " Continue? (y/n):" "n" confirm [[ "$confirm" =~ ^[Yy]$ ]] || { log_info "Cancelled — nothing changed."; return 0; } _authelia_remove_oidc_client "$CONFIG_FILE" "$target_id" log_success "Removed OIDC client '${target_id}'." local RESTART_AUTH="" prompt_yn " Restart Authelia to apply? (y/n):" "y" RESTART_AUTH if [ "$RESTART_AUTH" = "y" ] || [ "$RESTART_AUTH" = "Y" ]; then (cd "$AUTHELIA_DIR" && docker compose restart authelia 2>/dev/null) \ && log_success "Authelia restarted" \ || log_warning "Restart failed — check: docker compose logs authelia" fi } # Run immediately when executed directly (deferred until after function definition) [[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_authelia