#!/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" } backup_if_exists() { local _file="$1" [ -f "$_file" ] || return 0 cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null } 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) Export/import user data (backup accounts + 2FA before a reinstall," echo " or restore a previous export)" echo " 13) Make sure admins always have access to every site (old and new —" echo " safe to re-run any time)" echo " 14) Rename an outside-access group (e.g. \"customer1\" -> \"acme-corp\")" echo " 15) Show every group's sites and users (site groups + user groups overview)" echo " 16) Add/remove users from a group (pick the group, then toggle members —" echo " the reverse of option 4's per-user group toggle)" echo " 17) Bulk-assign several users to one group at once (e.g. \"1 4 5\" ->" echo " internal, then \"2 3 7\" -> a named group), with each user's" echo " current access shown alongside their name" echo " 0) Leave as-is / exit" echo "" local EXISTING_CHOICE="" prompt_text " Choice [1-17, 0 to exit]:" "0" 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 ;; 12) _authelia_export_import_users_menu return 0 ;; 13) _authelia_ensure_admin_access_everywhere return 0 ;; 14) _authelia_rename_group return 0 ;; 15) _authelia_report_groups return 0 ;; 16) _authelia_manage_group_membership return 0 ;; 17) _authelia_bulk_assign_group return 0 ;; 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 # Auto-correct the full domain being typed here by mistake (e.g. # "authelia.mydomain.com" instead of just "authelia") — concatenating # that with .${AUTHELIA_DOMAIN} below would otherwise silently produce # a doubled, broken domain like "authelia.mydomain.com.mydomain.com" # that never matches any real request. Confirmed live: this is exactly # what happened on a real box, and it explained a much bigger mystery # than the obviously-wrong hostname alone would suggest — every # forward_auth-gated site on the instance silently bypassed Authelia, # because Caddy had no site block matching the real portal hostname at # all, so the forward_auth subrequest never reached real policy # evaluation in the first place. if [[ "$AUTHELIA_PORTAL_SUBDOMAIN" == *".${AUTHELIA_DOMAIN}" ]]; then AUTHELIA_PORTAL_SUBDOMAIN="${AUTHELIA_PORTAL_SUBDOMAIN%.${AUTHELIA_DOMAIN}}" log_info "That already included the domain — using just '${AUTHELIA_PORTAL_SUBDOMAIN}' as the subdomain." elif [[ "$AUTHELIA_PORTAL_SUBDOMAIN" == "$AUTHELIA_DOMAIN" ]]; then log_warning "That's the apex domain itself, not a subdomain — the portal can't live at the bare apex (it would collide with the wildcard rule protecting everything else). Using 'auth' instead." AUTHELIA_PORTAL_SUBDOMAIN="auth" fi 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 ───────────────────────────────────────────────────────────────── backup_if_exists .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) ── backup_if_exists docker-compose.yml 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: # Admins always match first, on every domain this instance protects — this # rule must stay above any per-service scoping rule (old or new) below it, # or a group's "deny elsewhere" rule could accidentally catch an admin. # add_authelia_domain and _authelia_scope_access both preserve this # ordering automatically — see _authelia_ensure_admin_bypass. - domain: "*.${AUTHELIA_DOMAIN}" subject: "group:admins" policy: two_factor - 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 login portal (you'll be asked what subdomain to use — "auth" is just the suggested default) — 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 login portal (you'll pick" echo " the subdomain next — a separate login/session from your other domain(s))" echo " but shares this 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 # See install_authelia's identical guard on AUTHELIA_PORTAL_SUBDOMAIN # for why this matters — typing the full domain here instead of just # the subdomain silently produces a doubled, broken hostname that # never matches any real request. if [[ "$NEW_PORTAL_SUBDOMAIN" == *".${NEW_DOMAIN}" ]]; then NEW_PORTAL_SUBDOMAIN="${NEW_PORTAL_SUBDOMAIN%.${NEW_DOMAIN}}" log_info "That already included the domain — using just '${NEW_PORTAL_SUBDOMAIN}' as the subdomain." elif [[ "$NEW_PORTAL_SUBDOMAIN" == "$NEW_DOMAIN" ]]; then log_warning "That's the apex domain itself, not a subdomain — the portal can't live at the bare apex (it would collide with the wildcard rule protecting everything else). Using 'auth' instead." NEW_PORTAL_SUBDOMAIN="auth" fi NEW_PORTAL_DOMAIN="${NEW_PORTAL_SUBDOMAIN}.${NEW_DOMAIN}" # ── access_control.rules: insert right after "rules:" ──────────────────── # The admin-bypass rule goes first, this domain's own catch-all second — # both inserted together in one shot so a fresh domain never has a window # where the catch-all exists without the bypass above it. See # _authelia_ensure_admin_bypass for why this ordering has to hold. awk -v domain="$NEW_DOMAIN" ' { print } /^ rules:$/ && !done { print " - domain: \"*." domain "\"" print " subject: \"group:admins\"" print " policy: two_factor" 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 local -a apex_domains # Each apex domain now has two rules sharing this exact domain string — # its admin-bypass rule and its plain catch-all (see # _authelia_ensure_admin_bypass) — so dedupe or it'd list every domain twice. mapfile -t apex_domains < <(grep -oE '^ - domain: "\*\.[^"]+"' "$CONFIG_FILE" | sed -E 's/^ - domain: "\*\.(.+)"$/\1/' | awk '!seen[$0]++') echo "" if [ "${#apex_domains[@]}" -eq 0 ]; then log_info "No apex domains found on this instance." return 0 fi echo " Domains currently on this Authelia instance:" local i for i in "${!apex_domains[@]}"; do echo " $((i + 1))) ${apex_domains[$i]}" done echo " 0) Cancel" 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 login portal for that" echo " apex instead of adding it here as its own entry — option 10 (Protect an" echo " existing site) will find and use the right one automatically." echo "" local RM_CHOICE="" prompt_text " Number to remove, or 0 [0]:" "0" RM_CHOICE if ! [[ "$RM_CHOICE" =~ ^[0-9]+$ ]] || [ "$RM_CHOICE" -lt 1 ] || [ "$RM_CHOICE" -gt "${#apex_domains[@]}" ]; then log_info "Cancelled — nothing changed." return 0 fi local RM_DOMAIN="${apex_domains[$((RM_CHOICE - 1))]}" # 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 this domain's admin-bypass rule first ──── # (3 lines: domain/subject/policy — see _authelia_ensure_admin_bypass) — # has to run before the plain catch-all removal below, which only knows # how to strip a 2-line domain/policy pair and would otherwise leave this # rule's own "policy:" line orphaned, the exact corruption class this # instance was repaired from once already. awk -v domain="$RM_DOMAIN" ' BEGIN { skip=0 } skip > 0 { skip--; next } $0 == " - domain: \"*." domain "\"" { getline nxt if (nxt == " subject: \"group:admins\"") { skip=1; next } print; print nxt; next } { print } ' "$CONFIG_FILE" > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE" # ── 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 " 0 to cancel." echo "" local choice="" prompt_text " Number, domain, or 0 [0]:" "0" choice if [ -z "$choice" ] || [ "$choice" = "0" ]; then log_info "Cancelled — nothing changed." 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 " 0 to cancel." echo "" local choice="" prompt_text " Number, domain, or 0 [0]:" "0" choice if [ -z "$choice" ] || [ "$choice" = "0" ]; 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. # # Idempotent: makes sure a "subject: group:admins" rule exists for $domain's # access_control.rules, above every other rule for that domain, then returns # the 1-indexed line number its "policy:" line ended up on (via ADMIN_BYPASS_LINE, # not stdout — awk already uses stdout for the rewritten file). Callers that # insert a NEW rule for this domain (_authelia_scope_access's deny-elsewhere # rule in particular) must insert it AFTER that line, never at the literal # top of "rules:" — Authelia takes the first matching rule, so a deny rule # landing above the admin-bypass rule would catch an admin who's also (by # mistake, or by some future feature) a member of the group being denied. # install_authelia and add_authelia_domain both bake this rule in directly # at creation time instead of calling this — it's for retrofitting an apex # domain that predates this (see _authelia_ensure_admin_access_everywhere, # the menu-driven bulk version of this for an existing install) and for # _authelia_scope_access's own just-in-time safety net. _authelia_ensure_admin_bypass() { local config_file="$1" domain="$2" ADMIN_BYPASS_LINE="" ADMIN_BYPASS_ADDED="false" local result result="$(awk -v domain="$domain" ' { lines[NR] = $0 } END { for (i = 1; i < NR; i++) { if (lines[i] == " - domain: \"*." domain "\"" && lines[i+1] == " subject: \"group:admins\"") { print i + 2 exit } } } ' "$config_file")" if [ -n "$result" ]; then ADMIN_BYPASS_LINE="$result" return 0 fi ADMIN_BYPASS_ADDED="true" awk -v domain="$domain" ' { print } /^ rules:$/ && !done { print " - domain: \"*." domain "\"" print " subject: \"group:admins\"" print " policy: two_factor" done=1 } ' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file" chown 1000:1000 "$config_file" 2>/dev/null || true # The block we just inserted is always lines 2-4 counting from " rules:" # (which awk just placed the block right after) — but simplest and least # fragile is to just re-run the same lookup now that it exists. ADMIN_BYPASS_LINE="$(awk -v domain="$domain" ' { lines[NR] = $0 } END { for (i = 1; i < NR; i++) { if (lines[i] == " - domain: \"*." domain "\"" && lines[i+1] == " subject: \"group:admins\"") { print i + 2 exit } } } ' "$config_file")" } # Args: SERVICE_ID DOMAIN # # service_id is only ever used as the SUGGESTED group name when creating a # brand-new group — the actual group is whatever the user picks or types # below, so the same group (e.g. "customer1-only") can be attached to # several different sites over time instead of getting a fresh # "-only" group every call. The "-only" suffix itself is kept # internally (never shown to the user, who just sees "customer1") because # it's load-bearing elsewhere: _authelia_list_scoped_groups, # _authelia_report_access_scope, edit_authelia_user's per-user group # toggle, and _authelia_unprotect_site's cleanup all already key off that # exact suffix pattern to find "this is a site-scoping group, not some # other group a user happens to be in" — dropping it would mean touching # all four of those instead of just this one function. _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 echo "" echo " Who should be able to reach $domain via Authelia?" echo " 0) Internal — your own users, no extra restriction (default — same" echo " access as everything else)" echo " 1) Outside access — a named group of specific users only" local scope_choice="" prompt_text " Choice [0 for internal, 1 for outside access]:" "0" scope_choice [ "$scope_choice" = "1" ] || return 0 local -a existing_groups mapfile -t existing_groups < <(_authelia_list_scoped_groups "$users_file") local group="" is_new_group="true" i if [ "${#existing_groups[@]}" -gt 0 ]; then echo " Existing outside-access groups:" for i in "${!existing_groups[@]}"; do echo " $((i + 1))) ${existing_groups[$i]%-only}" done echo " Pick a number to add $domain to one of these, or type a new group" echo " name (e.g. \"customer1\") to create one." else echo " No outside-access groups exist yet — type a name to create one" echo " (e.g. \"customer1\")." fi local group_choice="" prompt_text " Group:" "${service_id}" group_choice if [ -z "$group_choice" ]; then log_warning "No group entered — leaving $domain open to all Authelia users." return 0 fi if [[ "$group_choice" =~ ^[0-9]+$ ]] && [ "$group_choice" -ge 1 ] && [ "$group_choice" -le "${#existing_groups[@]}" ]; then group="${existing_groups[$((group_choice - 1))]}" is_new_group="false" else local clean_name clean_name="$(echo "$group_choice" | tr -cs 'a-zA-Z0-9_-' '-' | sed 's/^-*//;s/-*$//')" if [ -z "$clean_name" ]; then log_warning "Invalid group name — leaving $domain open to all Authelia users." return 0 fi group="${clean_name}-only" for i in "${existing_groups[@]}"; do [ "$i" = "$group" ] && is_new_group="false" done fi if grep -A1 -F " - domain: \"${domain}\"" "$config_file" 2>/dev/null | grep -qF " subject: \"group:${group}\""; then log_info "$domain is already scoped to group '${group%-only}'." 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 local -a usernames if [ "$is_new_group" = "false" ]; then log_info "Reusing existing group '${group%-only}' — its current members already have access." local add_more="" prompt_yn " Add more users to '${group%-only}' now? (y/n):" "n" add_more if [[ ! "$add_more" =~ ^[Yy]$ ]]; then usernames=() fi fi if [ "$is_new_group" = "true" ] || [[ "${add_more:-}" =~ ^[Yy]$ ]]; then local -a existing_users mapfile -t existing_users < <(_authelia_list_usernames "$users_file") 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 read -ra raw_tokens <<< "$raw_users" if [ "${#raw_tokens[@]}" -eq 0 ] && [ "$is_new_group" = "true" ]; 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 fi 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%-only}'" 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")" # ...but BELOW the admin-bypass rule for this apex — otherwise the # deny-elsewhere rule below would outrank it, and an admin who ever ends # up in this group (by mistake, or a future feature) would be locked out # of every other domain on the instance. Ensures the bypass rule exists # first (retrofits it if this instance predates the feature), then # inserts right after it rather than at the literal top of "rules:". _authelia_ensure_admin_bypass "$config_file" "$authelia_domain" local anchor_line="$ADMIN_BYPASS_LINE" local scope_rules=" - domain: \"${domain}\" subject: \"group:${group}\" policy: two_factor - domain: \"*.${authelia_domain}\" subject: \"group:${group}\" policy: deny" awk -v block="$scope_rules" -v anchor="$anchor_line" ' { print } anchor != "" && NR == anchor + 0 { print block } anchor == "" && /^ rules:$/ && !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 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%-only}'." \ || log_warning "Restart failed — check: docker compose logs authelia" fi } # Renames an existing outside-access group everywhere it appears — every # "subject: group:" line in configuration.yml's access_control.rules, # and every member's "- " entry under their own groups: list in # users.yml. A plain find/replace on the "-only"-suffixed internal name; # the display name typed at the prompt (what _authelia_scope_access shows # without the suffix) is what the user actually renames. _authelia_rename_group() { local users_file="$DOCKER_DIR/authelia/config/users.yml" local config_file="$DOCKER_DIR/authelia/config/configuration.yml" [ -f "$config_file" ] || { log_warning "No configuration.yml found — install Authelia first."; return 1; } local -a existing_groups mapfile -t existing_groups < <(_authelia_list_scoped_groups "$users_file") if [ "${#existing_groups[@]}" -eq 0 ]; then log_info "No outside-access groups exist yet." return 0 fi echo "" echo " Outside-access groups:" local i for i in "${!existing_groups[@]}"; do echo " $((i + 1))) ${existing_groups[$i]%-only}" done echo " 0) Cancel" local choice="" prompt_text " Which group to rename? [0]:" "0" choice if ! [[ "$choice" =~ ^[0-9]+$ ]] || [ "$choice" -lt 1 ] || [ "$choice" -gt "${#existing_groups[@]}" ]; then log_info "Cancelled — nothing changed." return 0 fi local old_group="${existing_groups[$((choice - 1))]}" local new_name="" prompt_text " New name for '${old_group%-only}':" "" new_name local clean_name clean_name="$(echo "$new_name" | tr -cs 'a-zA-Z0-9_-' '-' | sed 's/^-*//;s/-*$//')" if [ -z "$clean_name" ]; then log_warning "No name entered — nothing changed." return 0 fi local new_group="${clean_name}-only" if [ "$new_group" = "$old_group" ]; then log_info "Same name — nothing changed." return 0 fi for i in "${existing_groups[@]}"; do if [ "$i" = "$new_group" ]; then log_warning "A group named '${clean_name}' already exists — pick a different name, or use that group directly instead of renaming into it." return 1 fi done sed -i "s/subject: \"group:${old_group}\"/subject: \"group:${new_group}\"/g" "$config_file" sed -i "s/^ - ${old_group}\$/ - ${new_group}/g" "$users_file" chown 1000:1000 "$config_file" "$users_file" 2>/dev/null || true log_success "Renamed '${old_group%-only}' to '${clean_name}' — updated every access rule and member using it." 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 } # For a given group, the domains it has an actual ALLOW rule for — i.e. its # "site membership" — as opposed to the deny-elsewhere rule # _authelia_scope_access also writes for the same group (same subject, but # a wildcard domain and policy: deny, which isn't a site the group can # reach and must be excluded). Rules are always domain-line, then # optionally a subject-line, then a policy-line, in that fixed order with # nothing else between them — this walks the file once matching that shape # rather than assuming fixed line-count blocks, so it works whether it's a # 2-line (no subject) or 3-line (subject present) rule. _authelia_group_domains() { local config_file="$1" group="$2" awk -v grp="group:${group}" ' /^ - domain:/ { d=$0; sub(/^ - domain: /,"",d); gsub(/"/,"",d); s=""; next } /^ subject:/ { s=$0; next } /^ policy:/ { if (s ~ grp && $0 !~ /deny/) print d; d=""; s=""; next } ' "$config_file" } # Read-only overview: every outside-access group, which sites it can reach, # and which users are in it — the "site groups" and "user groups" views # from the AD-style mental model, in one place, since a group's site # membership is otherwise only visible by grepping configuration.yml's # raw rules and its user membership only by grepping users.yml. _authelia_report_groups() { local users_file="$DOCKER_DIR/authelia/config/users.yml" local config_file="$DOCKER_DIR/authelia/config/configuration.yml" [ -f "$config_file" ] || { log_warning "No configuration.yml found — install Authelia first."; return 1; } local -a groups mapfile -t groups < <(_authelia_list_scoped_groups "$users_file") if [ "${#groups[@]}" -eq 0 ]; then log_info "No outside-access groups exist yet — every site is internal (open to any Authelia user, admins always included)." return 0 fi local -a all_users mapfile -t all_users < <(_authelia_list_usernames "$users_file") echo "" local g u start_end start end for g in "${groups[@]}"; do echo " ${g%-only}" echo " Sites:" local -a domains mapfile -t domains < <(_authelia_group_domains "$config_file" "$g") if [ "${#domains[@]}" -eq 0 ]; then echo " (none found — its access rule may be missing; try re-running site protection for it)" else printf ' - %s\n' "${domains[@]}" fi echo " Users:" local -a members=() for u in "${all_users[@]}"; do start_end="$(_authelia_user_line_range "$users_file" "$u")" start="${start_end% *}"; end="${start_end#* }" sed -n "${start},${end}p" "$users_file" | grep -qF " - ${g}" && members+=("$u") done if [ "${#members[@]}" -eq 0 ]; then echo " (none)" else printf ' - %s\n' "${members[@]}" fi echo "" done } # Group-first complement to _authelia_manage_one_user()'s option 6 (which is # user-first: pick a user, then toggle which groups they're in). This is the # other direction — pick a group, then toggle which users are in it — for # when you know which group you want to populate and don't want to visit # each user one at a time. Same _authelia_toggle_group() underneath either # way; this is purely a different entry point onto the same membership data. # # A group only exists here once it's been attached to at least one site # (via site protection's "Outside access" choice) — Authelia has no notion # of a group that isn't referenced by an access rule or a user's # membership, so there's no separate "create an empty group" step; naming # a new group during site protection is what creates it. _authelia_manage_group_membership() { local users_file="$DOCKER_DIR/authelia/config/users.yml" local config_file="$DOCKER_DIR/authelia/config/configuration.yml" [ -f "$users_file" ] || { log_warning "No users.yml found — install Authelia first."; return 1; } local -a groups mapfile -t groups < <(_authelia_list_scoped_groups "$users_file") if [ "${#groups[@]}" -eq 0 ]; then log_info "No outside-access groups exist yet. A group is created the first time you" log_info "protect a site (option 10, or a service's own \"Add Sign in with Authelia\"" log_info "offer) and choose \"Outside access\" instead of \"Internal\" — name it there" log_info "(e.g. \"customer1\"), and it'll show up here afterward to manage its members." return 0 fi echo "" echo " Outside-access groups:" local gi for gi in "${!groups[@]}"; do echo " $((gi + 1))) ${groups[$gi]%-only}" done echo " 0) Cancel" local GROUP_CHOICE="" prompt_text " Which group? [0]:" "0" GROUP_CHOICE if ! [[ "$GROUP_CHOICE" =~ ^[0-9]+$ ]] || [ "$GROUP_CHOICE" -lt 1 ] || [ "$GROUP_CHOICE" -gt "${#groups[@]}" ]; then log_info "Cancelled — nothing changed." return 0 fi local GROUP="${groups[$((GROUP_CHOICE - 1))]}" local -a all_users mapfile -t all_users < <(_authelia_list_usernames "$users_file") if [ "${#all_users[@]}" -eq 0 ]; then log_info "No users exist yet — add one first (this menu's \"Add a new user\")." return 0 fi echo "" echo " Members of '${GROUP%-only}' (* = currently a member):" local ui u start_end start end member for ui in "${!all_users[@]}"; do u="${all_users[$ui]}" start_end="$(_authelia_user_line_range "$users_file" "$u")" start="${start_end% *}"; end="${start_end#* }" member=" " sed -n "${start},${end}p" "$users_file" | grep -qF " - ${GROUP}" && member="*" echo " $((ui + 1))) [${member}] ${u}" done echo "" echo " Pick by number (space-separated) to toggle — a member gets removed, a" echo " non-member gets added. 0 (or blank) to leave unchanged." local TOGGLE_SEL="" prompt_text " Numbers [0]:" "0" TOGGLE_SEL local -a TOGGLE_TOKENS read -ra TOGGLE_TOKENS <<< "$TOGGLE_SEL" local tk tidx tu t_range t_start t_end is_member CHANGED=0 for tk in "${TOGGLE_TOKENS[@]}"; do [[ "$tk" =~ ^[0-9]+$ ]] || continue [ "$tk" -ge 1 ] && [ "$tk" -le "${#all_users[@]}" ] || continue tidx=$((tk - 1)) tu="${all_users[$tidx]}" # Re-resolve line range before every toggle — a prior toggle in this # same loop shifts every line after it (see the equivalent comment # in _authelia_manage_one_user's own group-toggle option). t_range="$(_authelia_user_line_range "$users_file" "$tu")" t_start="${t_range% *}"; t_end="${t_range#* }" is_member="false" sed -n "${t_start},${t_end}p" "$users_file" | grep -qF " - ${GROUP}" && is_member="true" if [ "$is_member" = "true" ]; then _authelia_toggle_group "$users_file" "$t_start" "$t_end" "$GROUP" "false" log_success "Removed ${tu} from '${GROUP%-only}'" else _authelia_toggle_group "$users_file" "$t_start" "$t_end" "$GROUP" "true" log_success "Added ${tu} to '${GROUP%-only}'" fi CHANGED=1 done if [ "$CHANGED" = "1" ]; then chown 1000:1000 "$users_file" 2>/dev/null || true 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 fi } # Bulk version spanning BOTH axes at once — several users, one target group, # in a single step (e.g. "1 4 5 6 -> internal", then "2 3 7 8 -> external1"), # repeatable for as many user/group batches as needed in one menu visit. # Complements the two single-axis tools above: option 6 (per-user menu) is # one user, many groups to toggle; option 16 is one group, many users to # toggle; this is many users, one group, picked together. "Internal" isn't # a real group — picking it clears every outside-access group membership # for the selected users, since internal access is the absence of a # restricting group, not a group of its own. _authelia_bulk_assign_group() { local users_file="$DOCKER_DIR/authelia/config/users.yml" [ -f "$users_file" ] || { log_warning "No users.yml found — install Authelia first."; return 1; } local KEEP_GOING="y" while [[ "$KEEP_GOING" =~ ^[Yy]$ ]]; do local -a all_users mapfile -t all_users < <(_authelia_list_usernames "$users_file") if [ "${#all_users[@]}" -eq 0 ]; then log_info "No users exist yet — add one first (this menu's \"Add a new user\")." return 0 fi echo "" echo " Existing users:" local i start_end start end for i in "${!all_users[@]}"; do start_end="$(_authelia_user_line_range "$users_file" "${all_users[$i]}")" start="${start_end% *}"; end="${start_end#* }" echo " $((i + 1))) ${all_users[$i]} [$(_authelia_describe_user_access "$users_file" "$start" "$end")]" done echo "" echo " Select one or more users by number (space-separated), or 0 to cancel." local USEL="" prompt_text " User number(s) [0]:" "0" USEL if [ -z "$USEL" ] || [ "$USEL" = "0" ]; then log_info "Cancelled." return 0 fi local -a usel_tokens targets=() read -ra usel_tokens <<< "$USEL" local tok for tok in "${usel_tokens[@]}"; do if [[ "$tok" =~ ^[0-9]+$ ]] && [ "$tok" -ge 1 ] && [ "$tok" -le "${#all_users[@]}" ]; then targets+=("${all_users[$((tok - 1))]}") else log_warning "Skipping invalid selection: $tok" fi done if [ "${#targets[@]}" -eq 0 ]; then log_warning "No valid users selected." prompt_yn " Try again? (y/n):" "n" KEEP_GOING continue fi local -a existing_groups mapfile -t existing_groups < <(_authelia_list_scoped_groups "$users_file") echo "" echo " Assign ${#targets[@]} user(s) to:" echo " 0) Internal — remove from every outside-access group" local gi for gi in "${!existing_groups[@]}"; do echo " $((gi + 1))) ${existing_groups[$gi]%-only}" done echo " Or type a new group name to create one." local GSEL="" prompt_text " Group [0 for internal]:" "0" GSEL if [ -z "$GSEL" ] || [ "$GSEL" = "0" ]; then local t t_start_end t_start t_end g for t in "${targets[@]}"; do t_start_end="$(_authelia_user_line_range "$users_file" "$t")" t_start="${t_start_end% *}"; t_end="${t_start_end#* }" for g in "${existing_groups[@]}"; do sed -n "${t_start},${t_end}p" "$users_file" | grep -qF " - ${g}" \ && _authelia_toggle_group "$users_file" "$t_start" "$t_end" "$g" "false" done log_success "${t} set to internal (removed from every outside-access group)" done else local group="" if [[ "$GSEL" =~ ^[0-9]+$ ]] && [ "$GSEL" -ge 1 ] && [ "$GSEL" -le "${#existing_groups[@]}" ]; then group="${existing_groups[$((GSEL - 1))]}" else local clean_name clean_name="$(echo "$GSEL" | tr -cs 'a-zA-Z0-9_-' '-' | sed 's/^-*//;s/-*$//')" if [ -z "$clean_name" ]; then log_warning "Invalid group name — nothing changed." prompt_yn " Try again? (y/n):" "n" KEEP_GOING continue fi group="${clean_name}-only" local is_new="true" eg for eg in "${existing_groups[@]}"; do [ "$eg" = "$group" ] && is_new="false"; done if [ "$is_new" = "true" ]; then log_warning "'$clean_name' isn't attached to any site yet — membership alone won't grant" log_warning "access to anything until a site is scoped to it (site protection's" log_warning "\"Outside access\" choice, or re-running a service's own SSO offer)." fi fi local t t_start_end2 t_start2 t_end2 for t in "${targets[@]}"; do t_start_end2="$(_authelia_user_line_range "$users_file" "$t")" t_start2="${t_start_end2% *}"; t_end2="${t_start_end2#* }" _authelia_toggle_group "$users_file" "$t_start2" "$t_end2" "$group" "true" log_success "Added ${t} to '${group%-only}'" done fi chown 1000:1000 "$users_file" 2>/dev/null || true 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 "" prompt_yn " Assign another batch (different users and/or a different group)? (y/n):" "n" KEEP_GOING done } # Menu-driven, idempotent bulk version of _authelia_ensure_admin_bypass — # backfills the admin-bypass rule for every apex domain currently on this # instance in one pass. install_authelia and add_authelia_domain bake the # rule in automatically for anything created from here on, and # _authelia_scope_access retrofits it just-in-time for whichever domain it's # scoping — this is for catching every OTHER domain an install already had # before this feature existed (or just double-checking one that's fine). # Safe to re-run any time; only touches domains actually missing the rule. _authelia_ensure_admin_access_everywhere() { local config_file="$DOCKER_DIR/authelia/config/configuration.yml" [ -f "$config_file" ] || { log_warning "No configuration.yml found — install Authelia first."; return 1; } local -a apex_domains mapfile -t apex_domains < <(grep -oE '^ - domain: "\*\.[^"]+"' "$config_file" | sed -E 's/^ - domain: "\*\.(.+)"$/\1/' | awk '!seen[$0]++') if [ "${#apex_domains[@]}" -eq 0 ]; then log_info "No apex domains found on this instance." return 0 fi echo "" echo " Checking the admin-bypass rule for every domain on this instance:" local d any_added="false" for d in "${apex_domains[@]}"; do _authelia_ensure_admin_bypass "$config_file" "$d" if [ "$ADMIN_BYPASS_ADDED" = "true" ]; then echo " ${d} — added" any_added="true" else echo " ${d} — already present" fi done if [ "$any_added" = "false" ]; then log_success "Every domain already had it — nothing to change." return 0 fi log_success "Members of the 'admins' group now always have access to every domain" log_success "listed above, regardless of any per-service scoping already in place —" log_success "or added later, since _authelia_scope_access checks for this automatically" log_success "from now on." log_warning "Make sure your admin account(s) are actually in the 'admins' group in" log_warning "config/users.yml — the default admin created at install time already is." 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 } # 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 " Internal (universal — every protected domain, no outside-access group):" 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 " Outside access (limited to a named group):" 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." } # Export/import accounts (+ optionally 2FA/session state) — for migrating to # a fresh instance or restoring after a reinstall without losing accounts or # forcing everyone to re-enroll 2FA. Passwords are never exported as # plaintext — Authelia only ever stores an argon2id hash — but that hash is # fully portable: dropping it into another instance's users.yml (same # hashing settings, which this repo's installer always uses) makes the # original password keep working, no reset required. # # TOTP secrets inside data/db.sqlite3 are AES-encrypted with this instance's # own storage encryption key (config/secrets/storage_secret) — NOT with # anything derived from the password. install_authelia's fresh-install path # generates a brand-new storage_secret every time (openssl rand -hex 32, # same as jwt_secret/session_secret), so a db.sqlite3 copied onto an # instance with a different storage_secret has 2FA data Authelia can't # decrypt. Export/import both carry storage_secret alongside db.sqlite3 so a # "remove and recreate" round-trip (export, reinstall, import) keeps 2FA # working — session_secret/jwt_secret don't need to match (only sign # cookies / password-reset links, safe to rotate) so those are left alone. _authelia_export_import_users_menu() { local authelia_dir="$DOCKER_DIR/authelia" [ -f "$authelia_dir/config/users.yml" ] || { log_warning "No users.yml found — install Authelia first."; return 1; } echo "" echo " Export/import user data" echo " 1) Export (users.yml + 2FA/session data) to a backup folder" echo " 2) Import from a previous export (overwrites current users)" echo " 0) Back" local choice="" prompt_text " Choice [1-2, 0 to go back]:" "0" choice case "$choice" in 1) _authelia_export_users ;; 2) _authelia_import_users ;; 0|*) return 0 ;; esac } _authelia_export_users() { local authelia_dir="$DOCKER_DIR/authelia" local users_file="$authelia_dir/config/users.yml" local db_file="$authelia_dir/data/db.sqlite3" local storage_secret_file="$authelia_dir/config/secrets/storage_secret" local default_dest="${ACTUAL_HOME:-$HOME}/authelia-export-$(date +%Y%m%d)" local dest="" prompt_text " Export to which directory? [${default_dest}]:" "$default_dest" dest [ -z "$dest" ] && dest="$default_dest" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would export $users_file, $db_file, and $storage_secret_file to $dest" return 0 fi mkdir -p "$dest" cp "$users_file" "$dest/users.yml" local exported_2fa="no" if [ -f "$db_file" ] && [ -f "$storage_secret_file" ]; then cp "$db_file" "$dest/db.sqlite3" cp "$storage_secret_file" "$dest/storage_secret" exported_2fa="yes" fi # Readable summary alongside the raw file — username / display name / # email / groups, no password hash — handy to eyeball or hand off # without pasting the full users.yml. awk ' /^ [a-zA-Z0-9_-]+:$/ { if (u) print u, "|", d, "|", e, "|", g; u=$1; sub(":","",u); d=""; e=""; g="" } /^ displayname:/ { d=$0; sub(/^ displayname: */,"",d) } /^ email:/ { e=$0; sub(/^ email: */,"",e) } /^ - / { line=$0; gsub(/^ - /,"",line); g = g line "," } END { if (u) print u, "|", d, "|", e, "|", g } ' "$users_file" > "$dest/users-summary.txt" chown -R "${ACTUAL_USER:-$(id -un)}:${ACTUAL_USER:-$(id -un)}" "$dest" 2>/dev/null || true chmod 600 "$dest/users.yml" "$dest/storage_secret" 2>/dev/null || true log_success "Exported to $dest" echo " users.yml — full account data incl. password hashes (portable, works as-is on import)" if [ "$exported_2fa" = "yes" ]; then echo " db.sqlite3 — 2FA/TOTP registrations + session storage" echo " storage_secret — required alongside db.sqlite3 to decrypt the 2FA data (keep this file private)" else log_warning " No data/db.sqlite3 or secrets/storage_secret found — 2FA registrations were NOT exported. Users will need to re-enroll 2FA after an import." fi echo " users-summary.txt — readable username/displayname/email/groups list, no password hash" } _authelia_import_users() { local authelia_dir="$DOCKER_DIR/authelia" local users_file="$authelia_dir/config/users.yml" local db_file="$authelia_dir/data/db.sqlite3" local storage_secret_file="$authelia_dir/config/secrets/storage_secret" local src="" prompt_text " Import from which directory (containing users.yml)?:" "" src [ -z "$src" ] && { log_info "Cancelled — nothing changed."; return 0; } src="${src%/}" if [ ! -f "$src/users.yml" ]; then log_warning "No users.yml found in $src — nothing to import." return 1 fi if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would replace $users_file with $src/users.yml" [ -f "$src/db.sqlite3" ] && echo "[DRY-RUN] Would replace $db_file and $storage_secret_file with the exported copies" return 0 fi local ts ts="$(date +%Y%m%d-%H%M%S)" [ -f "$users_file" ] && cp "$users_file" "$users_file.bak.$ts" cp "$src/users.yml" "$users_file" chown 1000:1000 "$users_file" log_success "Imported users.yml (previous version backed up to $(basename "$users_file").bak.$ts)" if [ -f "$src/db.sqlite3" ] && [ -f "$src/storage_secret" ]; then local import_db="" prompt_yn " Also import 2FA/session data (db.sqlite3 + storage_secret) — restores everyone's existing TOTP enrollment instead of forcing a re-scan? (y/n):" "y" import_db if [[ "$import_db" =~ ^[Yy]$ ]]; then [ -f "$db_file" ] && cp "$db_file" "$db_file.bak.$ts" [ -f "$storage_secret_file" ] && cp "$storage_secret_file" "$storage_secret_file.bak.$ts" cp "$src/db.sqlite3" "$db_file" cp "$src/storage_secret" "$storage_secret_file" chown 1000:1000 "$db_file" "$storage_secret_file" chmod 600 "$storage_secret_file" log_success "Imported db.sqlite3 + storage_secret (previous versions backed up alongside them)." log_warning "storage_secret must match what encrypted this db.sqlite3 — don't import one without the other, or 2FA data becomes undecryptable." fi elif [ -f "$src/db.sqlite3" ] || [ -f "$src/storage_secret" ]; then log_warning "Found only one of db.sqlite3 / storage_secret in $src — need both together to safely restore 2FA data, so skipping. Imported users will need to re-enroll 2FA on first login." else log_info "No 2FA/session export found in $src — imported users will need to re-enroll 2FA on first login." fi local restart_auth="" prompt_yn " Restart Authelia to apply? (y/n):" "y" restart_auth if [[ "$restart_auth" =~ ^[Yy]$ ]]; 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 } # 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 } # One-line access summary for a user, e.g. "admin" / "internal" / # "customer1, customer2" / "admin, customer1" — used by both # edit_authelia_user()'s listing and _authelia_bulk_assign_group() so a # user's current privileges are visible right where you're about to change # them, not something you have to cross-check against option 15 first. _authelia_describe_user_access() { local users_file="$1" start="$2" end="$3" local -a tags=() sed -n "${start},${end}p" "$users_file" | grep -q '^ - admins$' && tags+=("admin") local -a groups mapfile -t groups < <(sed -n "${start},${end}p" "$users_file" | grep -oE '^ - [a-zA-Z0-9_-]+-only$' | sed 's/^ - //; s/-only$//') tags+=("${groups[@]}") [ "${#tags[@]}" -eq 0 ] && tags=("internal") local IFS=", " echo "${tags[*]}" } # 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 u_start_end u_start u_end for i in "${!USERNAMES[@]}"; do u_start_end="$(_authelia_user_line_range "$USERS_FILE" "${USERNAMES[$i]}")" u_start="${u_start_end% *}"; u_end="${u_start_end#* }" echo " $((i + 1))) ${USERNAMES[$i]} [$(_authelia_describe_user_access "$USERS_FILE" "$u_start" "$u_end")]" done echo "" echo " Select one or more by number (space-separated, e.g. \"2 4\")," echo " or 0 to cancel." local SEL="" prompt_text " User number(s) [0]:" "0" SEL if [ -z "$SEL" ] || [ "$SEL" = "0" ]; 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 " 0) Done with this user" echo "" local ACTION="" prompt_text " Choice [1-7, 0 when done]:" "0" 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. 0 (or blank) to leave unchanged." local TOGGLE_SEL="" prompt_text " Numbers [0]:" "0" 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 ;; 0|*) ACTION="0" ;; 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) REQUIRE_PKCE(y/n) EXTRA_SCOPES [ ...] # EXTRA_SCOPES — space-separated scope names to add on top of the # always-included openid/profile/email (e.g. "groups" for an app whose own # OIDC settings request group membership, like Homebox). Pass "" when the # app only needs the three defaults — every existing caller before this # parameter was added does exactly that, so their registered client is # byte-for-byte unchanged. # 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" REQUIRE_PKCE="$5" EXTRA_SCOPES="$6"; shift 6 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'}" # PKCE lines are opt-in, not default — Authelia's own defaults for every # other field here (client_secret_basic auth method for a confidential # client, access_token_signed_response_alg: none) already match what # Audiobookshelf/Beszel's own Authelia integration docs specify, but # require_pkce defaults to false and has to be set explicitly for the # apps that need it. Checked against authelia.com's own per-client # integration pages for those two, not assumed — every existing caller # (Mealie/ActualBudget/Vaultwarden/Gitea/Immich) passes "n" here and # gets byte-for-byte the same client block as before this was added. local PKCE_YAML="" if [[ "$REQUIRE_PKCE" =~ ^[Yy]$ ]]; then PKCE_YAML=" require_pkce: true pkce_challenge_method: 'S256'" fi # EXTRA_SCOPES is space-separated (e.g. "groups") and additive to the # three always-included scopes below — Authelia rejects a callback # requesting any scope not in this exact per-client allowlist, even one # the server otherwise supports (confirmed live: Homebox's own # HBOX_OIDC_SCOPE=openid profile email groups was rejected with # invalid_scope until 'groups' was added here too). local EXTRA_SCOPES_YAML="" local _scope for _scope in $EXTRA_SCOPES; do EXTRA_SCOPES_YAML="${EXTRA_SCOPES_YAML} - '${_scope}'" done local CLIENT_BLOCK=" - client_id: '${CLIENT_ID}' client_name: '${APP_NAME}' client_secret: '${CLIENT_SECRET_HASH}' public: false authorization_policy: '${AUTH_POLICY}'${PKCE_YAML} redirect_uris: ${REDIRECT_URIS_YAML} scopes: - 'openid' - 'profile' - 'email'${EXTRA_SCOPES_YAML} 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 " 5) Audiobookshelf (needs PKCE — checked against its own Authelia" echo " integration docs, registered here automatically)" echo " 6) Beszel (PocketBase-based — also needs PKCE; its own side is" echo " configured in its Settings -> Auth providers page, not an API)" echo " 4) Other / custom app" echo " 0) Cancel" echo "" local APP_CHOICE="" prompt_text " Choice [1-6, 0 to cancel]:" "0" APP_CHOICE local APP_NAME="" CLIENT_ID="" REQUIRE_PKCE="n" 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") ;; 5) APP_NAME="Audiobookshelf"; CLIENT_ID="audiobookshelf"; REQUIRE_PKCE="y" REDIRECT_PATHS=("/auth/openid/callback" "/auth/openid/mobile-redirect") EXTRA_REDIRECT_URIS=("audiobookshelf://oauth") ;; 6) APP_NAME="Beszel"; CLIENT_ID="beszel"; REQUIRE_PKCE="y" REDIRECT_PATHS=("/api/oauth2-redirect") ;; 4) 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") ;; 0|*) log_info "Cancelled — nothing changed." return 0 ;; 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" "$REQUIRE_PKCE" "" "${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 "" ;; 5) echo " Audiobookshelf -> Settings -> Authentication -> enable OpenID Connect" echo " Authentication, then fill in (checked against audiobookshelf.org's own" echo " OIDC docs — it wants individual endpoints, not a discovery URL):" echo " Issuer URL: https://${AUTHELIA_PORTAL_DOMAIN}" echo " Authorize URL: https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/authorization" echo " Token URL: https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/token" echo " Userinfo URL: https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/userinfo" echo " JWKS URL: https://${AUTHELIA_PORTAL_DOMAIN}/jwks.json" echo " Client ID: ${CLIENT_ID}" echo " Client Secret: ${CLIENT_SECRET_PLAIN}" echo " Signing Algorithm: RS256" echo " Allowed Mobile Redirect URIs: audiobookshelf://oauth" echo "" ;; 6) echo " Beszel is PocketBase-based — its OAuth2 provider lives in PocketBase's" echo " own admin panel underneath the hub, not the hub's own Settings page and" echo " not an API this script can write (checked against beszel.dev's own docs):" echo " 1) https:///_/#/settings -> toggle OFF \"Hide" echo " collection create and edit controls\"" echo " 2) Collections -> edit the \"users\" collection" echo " 3) Options tab -> enable OAuth2 -> Add provider, fill in:" echo " Client ID: ${CLIENT_ID}" echo " Client Secret: ${CLIENT_SECRET_PLAIN}" echo " Auth URL: https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/authorization" echo " Token URL: https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/token" echo " User Info URL: https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/userinfo" echo " 4) Save, then toggle \"Hide collection create and edit controls\" back" echo " ON — leaving it off is its own exposure once you're done" echo " Register your first Beszel account with a password BEFORE touching" echo " DISABLE_PASSWORD_AUTH/USER_CREATION in its .env — flipping those before" echo " a working login exists risks locking the hub's UI out entirely." 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 " 0) Cancel" echo "" local choice="" prompt_text " Number to remove, or 0 [0]:" "0" choice if ! [[ "$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