#!/bin/bash # services/authelia.sh — Authelia SSO + 2FA portal (forward-auth for Caddy). # Part of the modular post-install system (sourced by setup.sh). # # Can also be run standalone on any machine: # sudo bash authelia.sh # (Docker must already be installed when run standalone) # # Ported from the authelia-setup repo / the monolith's working block. # ── Standalone bootstrap ────────────────────────────────────────────────────── # Detected when the script is executed directly rather than sourced by setup.sh. # Sets up helpers and globals, then defers execution until after the function # definition at the bottom of this file. if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" _COMMON="$_SELF_DIR/../lib/common.sh" if [[ -f "$_COMMON" ]]; then # Full repo present — use the real helpers (picks up ~/docker/.config too) # shellcheck source=../lib/common.sh source "$_COMMON" else # One-off copy — inline minimal stubs so the script works without the repo log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } require_docker() { command -v docker &>/dev/null || { log_error "Docker not found. Install it first:" log_error " curl -fsSL https://get.docker.com | sudo sh" return 1 } docker compose version &>/dev/null || { log_error "Docker Compose plugin missing:" log_error " sudo apt-get install -y docker-compose-plugin" return 1 } } ensure_docker_dir_ownership() { chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } port_in_use() { local _port="$1" _proto="${2:-tcp}" local _flag="-tlnH" [ "$_proto" = "udp" ] && _flag="-ulnH" ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q . } find_free_port() { local _varname="$1" _port="$2" _proto="${3:-tcp}" while port_in_use "$_port" "$_proto"; do _port=$((_port + 1)) done eval "$_varname='$_port'" } # Match common.sh's eval-based pattern so local vars in install_* are set correctly prompt_text() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } read -r -p " $_q " _r eval "$_var='${_r:-$_def}'" } prompt_yn() { local _q="$1" _def="$2" _var="$3" _r [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } read -r -p " $_q " _r eval "$_var='${_r:-$_def}'" } configure_caddy_for_service() { local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" local _display_port="${_upstream##*:}" # Determine mode: local Caddy, remote Caddy, or none local _mode="none" [[ -d "$_caddy_dir" ]] && _mode="local" [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" [[ "$_mode" == "none" ]] && { log_info "Access $_name directly on port $_display_port." return 0 } echo "" local _do_caddy="" if [[ "$_mode" == "remote" ]]; then log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." log_info "A snippet file will be saved to ~/docker/caddy-snippets/." fi read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy [[ "${_do_caddy,,}" == "y" ]] || { log_info "Skipping — access at: http://localhost:$_display_port" return 0 } # Domain prompt — pre-fill from SITE_DOMAIN when available local _default_domain="" if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then _default_domain="${_subdomain}.${SITE_DOMAIN}" log_info "Default: $_default_domain" fi local _domain="" read -r -p " Domain [${_default_domain:-required}]: " _domain _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } # Build upstream — remote Caddy uses host IP:port, not container name local _block_upstream="$_upstream" if [[ "$_mode" == "remote" ]]; then _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi local _site_block _site_block="$(cat << CBLOCK # $_name ${_domain} { reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "SAMEORIGIN" Referrer-Policy "strict-origin-when-cross-origin" } log { output file /var/log/caddy/${_domain}.log format json } ${_extra} } CBLOCK )" if [[ "$_mode" == "local" ]]; then if [[ -f "$_caddyfile" ]]; then local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" cp "$_caddyfile" "$_bk" log_info "Backed up Caddyfile to $(basename "$_bk")" else touch "$_caddyfile" fi if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then log_warning "$_domain already in Caddyfile" local _ow="" read -r -p " Overwrite? [y/N]: " _ow [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } sed -i "/^${_domain}/,/^}/d" "$_caddyfile" fi printf '%s\n' "$_site_block" >> "$_caddyfile" log_success "Added $_domain to Caddyfile" docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then log_success "$_name accessible at: https://$_domain" else log_warning "Reload failed — check: docker logs caddy" log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" fi else local _snippet_dir="$DOCKER_DIR/caddy-snippets" local _snippet_file="$_snippet_dir/${_subdomain}.caddy" mkdir -p "$_snippet_dir" printf '%s\n' "$_site_block" > "$_snippet_file" chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true log_success "Snippet saved: $_snippet_file" log_info "Copy to Caddy machine:" log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } write_readme() { local _dir="$1"; shift mkdir -p "$_dir" cat > "$_dir/README.md" } fi # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # ($HOME under sudo is /root, not the real user's home) ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" DRY_RUN="${DRY_RUN:-false}" UNATTENDED="${UNATTENDED:-false}" SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 fi # ───────────────────────────────────────────────────────────────────────────── register_service authelia homelab "SSO + 2FA auth portal (Authelia)" 9091 install_authelia() { require_docker || return 1 local AUTHELIA_DIR="$DOCKER_DIR/authelia" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would set up Authelia:" echo " • Create $AUTHELIA_DIR (config/secrets, data)" echo " • Generate jwt/session/storage secrets + admin password hash" echo " • Write docker-compose.yml, configuration.yml, users.yml, README.md" echo " • Create the caddy_net network and add the forward-auth snippet to the Caddyfile" return 0 fi # Don't clobber an existing install (it would regenerate secrets and break sessions). if [ -f "$AUTHELIA_DIR/docker-compose.yml" ]; then echo " ⚠ Authelia already exists at $AUTHELIA_DIR." echo "" echo " 1) Add another protected domain to this instance (non-destructive —" echo " one Authelia+Redis, multiple independent apex domains/logins)" echo " 2) Add a new user (creates a users.yml entry + password hash)" echo " 3) Edit an existing user (email, password reset, 2FA reset/exempt," echo " promote/demote admin)" echo " 4) 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 " 5) Reconfigure from scratch (regenerates secrets/users — breaks" echo " existing sessions for every domain already on this instance)" echo " 6) Show who has universal vs. service-scoped access" echo " 7) Change \"Remember me\" session duration (stay logged in longer)" echo " 8) Leave as-is" echo "" local EXISTING_CHOICE="" prompt_text " Choice [1/2/3/4/5/6/7/8]:" "8" EXISTING_CHOICE case "$EXISTING_CHOICE" in 1) add_authelia_domain return 0 ;; 2) add_authelia_user return 0 ;; 3) edit_authelia_user return 0 ;; 4) _authelia_add_oidc_client return 0 ;; 5) : # fall through to the full reinstall flow below ;; 6) _authelia_report_access_scope return 0 ;; 7) _authelia_set_remember_me return 0 ;; *) echo " Keeping existing Authelia. (Edit config/users.yml then: cd $AUTHELIA_DIR && docker compose restart authelia)" return 0 ;; esac fi log_info "Installing Authelia..." mkdir -p "$AUTHELIA_DIR/config/secrets" "$AUTHELIA_DIR/data" # ── Collect configuration ──────────────────────────────────────────────── echo "" echo " Authelia needs a few details to configure." echo "" local CADDY_NET="${SITE_CADDY_NET:-caddy_net}" local AUTHELIA_DOMAIN AUTHELIA_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 prompt_text " Admin username:" "admin" AUTHELIA_ADMIN_USER prompt_text " Admin display name:" "Administrator" AUTHELIA_ADMIN_DISPLAY prompt_text " Admin email:" "admin@${AUTHELIA_DOMAIN}" AUTHELIA_ADMIN_EMAIL prompt_text " SMTP server (e.g., smtp.migadu.com):" "smtp.migadu.com" AUTHELIA_SMTP_HOST prompt_text " SMTP port:" "587" AUTHELIA_SMTP_PORT prompt_text " SMTP username (full email):" "authelia@${AUTHELIA_DOMAIN}" AUTHELIA_SMTP_USER prompt_text " SMTP password:" "" AUTHELIA_SMTP_PASS prompt_text " Timezone (e.g., America/New_York):" "${SITE_TZ:-America/New_York}" AUTHELIA_TZ # ── Secrets ────────────────────────────────────────────────────────────── echo "" echo " Generating secrets..." echo "$(openssl rand -hex 32)" > "$AUTHELIA_DIR/config/secrets/jwt_secret" echo "$(openssl rand -hex 32)" > "$AUTHELIA_DIR/config/secrets/session_secret" echo "$(openssl rand -hex 32)" > "$AUTHELIA_DIR/config/secrets/storage_secret" echo "$AUTHELIA_SMTP_PASS" > "$AUTHELIA_DIR/config/secrets/smtp_password" chmod 600 "$AUTHELIA_DIR/config/secrets/"* echo " ✓ Secrets generated" # ── Admin password hash ────────────────────────────────────────────────── echo "" local AUTHELIA_TEMP_PASS AUTHELIA_HASH prompt_text " Temporary password for admin (users reset via email):" "TempPass2026!" AUTHELIA_TEMP_PASS echo " Generating password hash..." AUTHELIA_HASH=$(docker run --rm authelia/authelia:4.39.20 \ authelia crypto hash generate argon2 --password "$AUTHELIA_TEMP_PASS" 2>/dev/null \ | grep -oP '(?<=Digest: ).*' || echo "REPLACE_WITH_HASH") if [ "$AUTHELIA_HASH" = "REPLACE_WITH_HASH" ]; then log_warning "Could not generate hash automatically. After install run:" echo " docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'yourpassword'" echo " then update $AUTHELIA_DIR/config/users.yml" else echo " ✓ Password hash generated" fi ensure_docker_dir_ownership "$AUTHELIA_DIR" cd "$AUTHELIA_DIR" || return 1 # ── .env ───────────────────────────────────────────────────────────────── cat > .env << AUTHELIA_ENV MY_DOMAIN=${AUTHELIA_DOMAIN} SMTP_USER=${AUTHELIA_SMTP_USER} DOCKER_MY_NETWORK=${CADDY_NET} TZ=${AUTHELIA_TZ} AUTHELIA_ENV # ── docker-compose.yml (quoted heredoc: ${SMTP_USER} resolved by compose/.env) ── cat > docker-compose.yml << 'AUTHELIA_COMPOSE' name: authelia services: authelia: image: authelia/authelia:4.39.20 pull_policy: missing container_name: authelia user: "1000:1000" volumes: - ./config:/config - ./data:/data environment: - AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/config/secrets/jwt_secret - AUTHELIA_SESSION_SECRET_FILE=/config/secrets/session_secret - AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/config/secrets/storage_secret - AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/config/secrets/smtp_password - AUTHELIA_NOTIFIER_SMTP_USERNAME=${SMTP_USER} - AUTHELIA_NOTIFIER_SMTP_SENDER=Authelia <${SMTP_USER}> expose: - 9091 restart: unless-stopped networks: - caddy_net networks: caddy_net: external: true AUTHELIA_COMPOSE [ "$CADDY_NET" != "caddy_net" ] && sed -i "s/caddy_net/${CADDY_NET}/g" docker-compose.yml # ── configuration.yml ──────────────────────────────────────────────────── cat > config/configuration.yml << AUTHELIA_CONFIG --- # Authelia configuration. Secrets injected via AUTHELIA_* env vars in compose. theme: dark server: address: tcp://0.0.0.0:9091 log: level: info file_path: /data/authelia.log totp: period: 30 skew: 1 authentication_backend: file: path: /config/users.yml password: algorithm: argon2 argon2: variant: argon2id iterations: 3 memory: 65536 parallelism: 4 key_length: 32 salt_length: 16 access_control: default_policy: deny rules: - domain: "*.${AUTHELIA_DOMAIN}" policy: two_factor session: name: authelia_session expiration: 12h inactivity: 2h remember_me: 7d cookies: - domain: ${AUTHELIA_DOMAIN} authelia_url: https://auth.${AUTHELIA_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 "auth.${AUTHELIA_DOMAIN}" "$CADDY_FILE"; then cat >> "$CADDY_FILE" << CADDY_AUTH_BLOCK # ── Authelia login portal ────────────────────────────────────────────────────── auth.${AUTHELIA_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 auth.${AUTHELIA_DOMAIN} itself) and # overwrites the value a forward_auth caller (e.g. a remote site's # "forward_auth https://auth.${AUTHELIA_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 # auth.${AUTHELIA_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/auth.log } } CADDY_AUTH_BLOCK echo " ✓ Authelia portal block added for auth.${AUTHELIA_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 + auth.${AUTHELIA_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://auth.${AUTHELIA_DOMAIN}** ## Layout \`\`\` $AUTHELIA_DIR/ ├── docker-compose.yml ├── .env ├── config/ │ ├── configuration.yml │ ├── users.yml │ └── secrets/ # jwt/session/storage/smtp — never commit └── data/ # sqlite db + log \`\`\` ## Protect a service with Authelia In that service's Caddy site block, add \`import authelia\`: \`\`\` myservice.${AUTHELIA_DOMAIN} { import authelia reverse_proxy localhost:PORT } \`\`\` The \`(authelia)\` snippet and the \`auth.${AUTHELIA_DOMAIN}\` portal block were added to \`$DOCKER_DIR/caddy/Caddyfile\` automatically. ## Protecting a second (or third) apex domain Re-run this installer (\`sudo ./setup.sh authelia\` or \`sudo bash authelia.sh\`) and choose **"Add another protected domain to this instance"** when it detects the existing install. That domain gets its own \`session.cookies\` entry and its own \`auth.\` portal — a separate login/session from ${AUTHELIA_DOMAIN}, so no accidental cross-domain SSO — but it's still one shared Authelia + Redis container and one shared user database, not a second full stack. Cheaper than standing up an entirely separate instance, and the right way to protect multiple unrelated domains from the same box. ## Letting other apps log in via Authelia (OIDC/SSO) Different from \`import authelia\` above: that gates a whole site behind a login page before the request reaches it. This is for an app with its OWN "Enable OpenID"/SSO setting (ActualBudget, Vaultwarden, etc.) that should delegate ITS login to Authelia instead of a separate app-specific password. Re-run this installer and choose **"Register an app to log in VIA Authelia"** when it detects the existing install. Presets exist for ActualBudget and Vaultwarden (their exact redirect URI is filled in automatically); anything else works too via "Other/custom" — check that app's own OIDC/SSO docs for its redirect URI path first. First time this runs it also enables Authelia's OIDC provider itself (generates a signing key + HMAC secret, one-time, automatic). Each registered app gets its own Client ID/Secret under \`identity_providers.oidc.clients\` in \`config/configuration.yml\` — the secret is shown once at registration time and only the hash is kept. Endpoints (needed if an app asks for them instead of a discovery URL): - Discovery: \`https://auth.${AUTHELIA_DOMAIN}/.well-known/openid-configuration\` - Authorization: \`https://auth.${AUTHELIA_DOMAIN}/api/oidc/authorization\` - Token: \`https://auth.${AUTHELIA_DOMAIN}/api/oidc/token\` - UserInfo: \`https://auth.${AUTHELIA_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://auth.${AUTHELIA_DOMAIN}" echo " Admin login: ${AUTHELIA_ADMIN_USER} (use Forgot Password to set a real password)" echo " README: $AUTHELIA_DIR/README.md" echo "" } # Adds a second (or third, etc.) independent apex domain to an EXISTING Authelia # instance instead of standing up a whole separate Authelia+Redis stack for it. # Authelia natively supports this: session.cookies and access_control.rules are # both lists, so one instance can hold a distinct cookie scope + login portal per # domain, each with its own session (no cross-domain SSO, but also no collision — # see the "Running more than one Authelia instance" note in CLAUDE.md for why two # domains can't just share one session.cookies entry). Far cheaper on RAM than a # second full instance, which matters most on a small droplet. add_authelia_domain() { local AUTHELIA_DIR="$DOCKER_DIR/authelia" local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml" local CADDY_FILE="$DOCKER_DIR/caddy/Caddyfile" if [ ! -f "$CONFIG_FILE" ]; then log_warning "No configuration.yml found at $CONFIG_FILE — install Authelia first." return 1 fi echo "" echo " Add another apex domain to this Authelia instance." echo " It gets its own session-cookie scope and its own auth. portal —" echo " a separate login/session from your other domain(s) — but shares this" echo " same Authelia + Redis container, not a second full stack." echo "" local NEW_DOMAIN="" prompt_text " New domain (e.g., example.com):" "" NEW_DOMAIN if [ -z "$NEW_DOMAIN" ]; then log_warning "No domain entered — nothing to do." return 0 fi if grep -qF "\"*.${NEW_DOMAIN}\"" "$CONFIG_FILE" 2>/dev/null; then log_warning "$NEW_DOMAIN is already configured in $CONFIG_FILE — nothing to do." return 0 fi # ── access_control.rules: insert right after "rules:" ──────────────────── awk -v domain="$NEW_DOMAIN" ' { print } /^ rules:$/ && !done { print " - domain: \"*." domain "\"" print " policy: two_factor" done=1 } ' "$CONFIG_FILE" > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE" # ── session.cookies: insert right after "cookies:" ──────────────────────── awk -v domain="$NEW_DOMAIN" ' { print } /^ cookies:$/ && !done { print " - domain: " domain print " authelia_url: https://auth." domain 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 -q "^auth.${NEW_DOMAIN} {" "$CADDY_FILE"; then cat >> "$CADDY_FILE" << CADDY_AUTH_BLOCK2 # ── Authelia login portal (${NEW_DOMAIN}) ───────────────────────────────────── auth.${NEW_DOMAIN} { # See auth.${AUTHELIA_DOMAIN:-}'s block 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/auth.${NEW_DOMAIN}.log } } CADDY_AUTH_BLOCK2 echo " ✓ Authelia portal block added for auth.${NEW_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 " ✓ auth.${NEW_DOMAIN} portal block already exists in the Caddyfile" fi else echo " ℹ Caddy not installed — add an auth.${NEW_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://auth.${NEW_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 "" } # 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-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 } # Same shape as _authelia_toggle_admin but for an arbitrary group name — # used to scope a user's access to a single service (see # _authelia_scope_access below) rather than the fixed "admins" group. _authelia_toggle_group() { local users_file="$1" start="$2" end="$3" group="$4" enable="$5" if [ "$enable" = "true" ]; then if ! sed -n "${start},${end}p" "$users_file" | grep -qF " - ${group}"; then awk -v s="$start" -v e="$end" -v grp=" - ${group}" ' { print } NR>=s && NR<=e && /^ groups:$/ { print grp } ' "$users_file" > "$users_file.tmp" && mv "$users_file.tmp" "$users_file" fi else awk -v s="$start" -v e="$end" -v grpline=" - ${group}" ' NR>=s && NR<=e && $0==grpline { next } { print } ' "$users_file" > "$users_file.tmp" && mv "$users_file.tmp" "$users_file" fi } # Non-interactive core of add_authelia_user() below — no prompts, takes # everything as args, generates a temp password + hash, and writes the user # block directly into an arbitrary extra group (not just "users"). Used by # _authelia_scope_access() to create users on the fly when someone lists a # username that doesn't exist yet. Deliberately a separate function rather # than a refactor of add_authelia_user() itself — that one's already in # regular use via the interactive menu and this repo's convention is to # extract a non-interactive core only when a second caller actually needs # it (see _authelia_provision_oidc_client for the same reasoning), which # keeps this addition low-risk to the existing, working function. # # Args: USERNAME DISPLAY EMAIL GROUP # Out-param (not `local`): AUTHELIA_NEW_USER_TEMP_PASSWORD # Returns 1 if the user already exists or hash generation fails. _authelia_create_user_noninteractive() { local username="$1" display="$2" email="$3" group="$4" local users_file="$DOCKER_DIR/authelia/config/users.yml" AUTHELIA_NEW_USER_TEMP_PASSWORD="" if grep -qE "^ ${username}:$" "$users_file" 2>/dev/null; then log_warning "'$username' already exists in $users_file." return 1 fi local temp_pass new_hash temp_pass="$(_authelia_gen_temp_password)" new_hash=$(docker run --rm authelia/authelia:4.39.20 \ authelia crypto hash generate argon2 --password "$temp_pass" 2>/dev/null \ | grep -oP '(?<=Digest: ).*') if [ -z "$new_hash" ]; then log_warning "Couldn't generate a password hash for '$username' automatically." return 1 fi local user_block=" ${username}: displayname: \"${display}\" email: ${email} password: \"${new_hash}\" groups: - ${group}" awk -v block="$user_block" ' { print } /^users:$/ && !done { print block; done=1 } ' "$users_file" > "$users_file.tmp" && mv "$users_file.tmp" "$users_file" chown 1000:1000 "$users_file" 2>/dev/null || true AUTHELIA_NEW_USER_TEMP_PASSWORD="$temp_pass" log_success "Created user '$username' (group: $group)" return 0 } # Reusable by ANY service, after it's already been protected by Authelia — # forward_auth gate or native OIDC alike, since this only cares about the # domain, not the gating mechanism. Asks whether access to $DOMAIN should be # open to any Authelia user (today's only behavior, before this existed) or # scoped to a specific list. If scoped: creates a dedicated group named # "-only", adds every listed username to it (creating any that # don't exist yet via _authelia_create_user_noninteractive), and inserts two # access_control rules ABOVE the general catch-all — allow this group on # $DOMAIN, deny this group on every other protected domain on the instance — # so members can reach ONLY this one domain. Idempotent: reruns against a # domain that's already scoped just report the existing group instead of # duplicating rules. # # Args: SERVICE_ID DOMAIN _authelia_scope_access() { local service_id="$1" domain="$2" local authelia_dir="$DOCKER_DIR/authelia" local config_file="$authelia_dir/config/configuration.yml" local users_file="$authelia_dir/config/users.yml" [ -f "$config_file" ] || return 0 local group="${service_id}-only" if grep -qF "subject: \"group:${group}\"" "$config_file" 2>/dev/null; then log_info "Access to $domain is already scoped to group '$group'." log_info "Manage its members via this menu's \"Edit an existing user\" (toggle their groups by hand in users.yml), or the universal-access report below." return 0 fi echo "" echo " Who should be able to reach $domain via Authelia?" echo " 1) Any Authelia user (default — same access as everything else)" echo " 2) Specific users only" local scope_choice="" prompt_text " Choice [1/2]:" "1" scope_choice [ "$scope_choice" = "2" ] || return 0 echo " Usernames who should have access (space-separated). Anyone listed" echo " who doesn't already have an Authelia account gets one created —" echo " you'll get their temporary password to hand over." local raw_users="" prompt_text " Usernames:" "" raw_users local -a usernames read -ra usernames <<< "$raw_users" if [ "${#usernames[@]}" -eq 0 ]; then log_warning "No usernames entered — leaving $domain open to all Authelia users." return 0 fi local u start_end start end for u in "${usernames[@]}"; do u="$(echo "$u" | tr -cs 'a-z0-9_-' '-' | sed 's/^-*//;s/-*$//')" [ -z "$u" ] && continue if grep -qE "^ ${u}:$" "$users_file" 2>/dev/null; then start_end="$(_authelia_user_line_range "$users_file" "$u")" start="${start_end% *}"; end="${start_end#* }" _authelia_toggle_group "$users_file" "$start" "$end" "$group" "true" log_success "Added '$u' to group '$group'" else local email_default="${u}@${SITE_DOMAIN:-example.com}" if _authelia_create_user_noninteractive "$u" "$u" "$email_default" "$group"; then echo " Temp password for '$u': $AUTHELIA_NEW_USER_TEMP_PASSWORD" fi fi done # Two rules, both above the general catch-all: allow this group on the # target domain, deny this group on every other protected domain. Order # matters — Authelia takes the first matching rule, so both must land # before access_control's existing "*.${AUTHELIA_DOMAIN}" catch-all. local authelia_domain authelia_domain="$(awk '/^ cookies:$/{f=1; next} f && /domain:/{print $3; exit}' "$config_file")" local scope_rules=" - domain: \"${domain}\" subject: \"group:${group}\" policy: two_factor - domain: \"*.${authelia_domain}\" subject: \"group:${group}\" policy: deny" awk -v block="$scope_rules" ' /^ rules:$/ && !done { print; print block; done=1; next } { print } ' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file" chown 1000:1000 "$config_file" 2>/dev/null || true local restart_auth="" prompt_yn " Restart Authelia to apply this scoping? (y/n):" "y" restart_auth if [[ "$restart_auth" =~ ^[Yy]$ ]]; then (cd "$authelia_dir" && docker compose restart authelia 2>/dev/null) \ && log_success "Authelia restarted — $domain is now restricted to group '$group'." \ || log_warning "Restart failed — check: docker compose logs authelia" fi } # Reporting/management: lists which users have "universal" access (every # protected domain — anyone not locked into a "-only" group) versus # which are scoped to specific services, then offers to promote a scoped # user to universal by removing them from all their "-only" groups. Doesn't # touch access_control.rules at all — universal access is just the absence # of a restricting group, so "promoting" someone is purely a users.yml edit. _authelia_report_access_scope() { local users_file="$DOCKER_DIR/authelia/config/users.yml" [ -f "$users_file" ] || { log_warning "No users.yml found — install Authelia first."; return 1; } local -a all_users mapfile -t all_users < <(_authelia_list_usernames "$users_file") if [ "${#all_users[@]}" -eq 0 ]; then log_warning "No users found in $users_file." return 0 fi echo "" echo " Universal access (every protected domain):" local -a universal=() restricted=() local u start_end start end groups_in_range for u in "${all_users[@]}"; do start_end="$(_authelia_user_line_range "$users_file" "$u")" start="${start_end% *}"; end="${start_end#* }" groups_in_range="$(sed -n "${start},${end}p" "$users_file" | grep -oE '\- [a-z0-9_-]+-only$' | sed 's/^- //')" if [ -z "$groups_in_range" ]; then universal+=("$u") echo " - $u" else restricted+=("$u ($(echo "$groups_in_range" | tr '\n' ',' | sed 's/,$//'))") fi done [ "${#universal[@]}" -eq 0 ] && echo " (none)" echo "" echo " Scoped to specific services only:" if [ "${#restricted[@]}" -eq 0 ]; then echo " (none)" else printf ' - %s\n' "${restricted[@]}" fi echo "" local promote="" prompt_yn " Promote a scoped user to universal access? (y/n):" "n" promote [[ "$promote" =~ ^[Yy]$ ]] || return 0 local target="" prompt_text " Username to promote:" "" target [ -z "$target" ] && return 0 if ! grep -qE "^ ${target}:$" "$users_file" 2>/dev/null; then log_warning "'$target' not found in $users_file." return 0 fi start_end="$(_authelia_user_line_range "$users_file" "$target")" start="${start_end% *}"; end="${start_end#* }" local -a target_groups mapfile -t target_groups < <(sed -n "${start},${end}p" "$users_file" | grep -oE '\- [a-z0-9_-]+-only$' | sed 's/^- //') if [ "${#target_groups[@]}" -eq 0 ]; then log_info "'$target' already has universal access." return 0 fi local g for g in "${target_groups[@]}"; do _authelia_toggle_group "$users_file" "$start" "$end" "$g" "false" done log_success "'$target' removed from: ${target_groups[*]} — now has universal access." local restart_auth="" prompt_yn " Restart Authelia to apply? (y/n):" "y" restart_auth if [[ "$restart_auth" =~ ^[Yy]$ ]]; then (cd "$DOCKER_DIR/authelia" && docker compose restart authelia 2>/dev/null) \ && log_success "Authelia restarted" \ || log_warning "Restart failed — check: docker compose logs authelia" fi } # Changes how long an Authelia session lasts when a user checks "Remember # me" at login — the actual mechanism behind "log in once, don't get asked # again for a long time" for every domain this instance protects. # # The config key is `remember_me` (plain, under session:), NOT # `remember_me_duration` — that name was retired in Authelia 4.38, this # repo pins 4.39.20. Confirmed against Authelia's own docs/changelog # before writing this; an easy mistake since older guidance (including an # earlier version of this very file's own README section) uses the old # name, which Authelia would just silently ignore rather than error on. # # This only controls AUTHELIA's own session — it does not touch how long # a native-OIDC app's (Gitea/Mealie/ActualBudget) own session/token lasts # after logging in via Authelia. A long remember_me makes re-authenticating # to Authelia itself instant/silent whenever one of those apps' own # session expires and sends you back through the OIDC flow, but doesn't # stop that app's own session from expiring on its own separate schedule. _authelia_set_remember_me() { local config_file="$DOCKER_DIR/authelia/config/configuration.yml" [ -f "$config_file" ] || { log_warning "No configuration.yml found — install Authelia first."; return 1; } local current current="$(grep -E '^ remember_me:' "$config_file" | awk '{print $2}' | tr -d "'\"")" echo "" echo " Current \"remember me\" duration: ${current:-not set}" echo " How long a session lasts when someone checks \"Remember me\" at login —" echo " applies to every domain this Authelia instance protects." echo " Examples: 12h, 7d, 1M (month), 1y. Set to -1 to disable Remember Me entirely." local new_duration="" prompt_text " New duration [${current:-7d}]:" "${current:-7d}" new_duration if [ -z "$new_duration" ] || [ "$new_duration" = "$current" ]; then log_info "No change made." return 0 fi if grep -qE '^ remember_me:' "$config_file"; then sed -i "s/^ remember_me:.*/ remember_me: '${new_duration}'/" "$config_file" else sed -i "/^session:\$/a\\ remember_me: '${new_duration}'" "$config_file" fi chown 1000:1000 "$config_file" 2>/dev/null || true log_success "\"Remember me\" duration set to ${new_duration}." local restart_auth="" prompt_yn " Restart Authelia to apply? (y/n):" "y" restart_auth if [[ "$restart_auth" =~ ^[Yy]$ ]]; then (cd "$DOCKER_DIR/authelia" && docker compose restart authelia 2>/dev/null) \ && log_success "Authelia restarted" \ || log_warning "Restart failed — check: docker compose logs authelia" fi echo "" log_info "Takes effect for NEW logins where \"Remember me\" is checked at Authelia's" log_info "login page — existing sessions keep whatever expiration they already had." log_info "The checkbox itself is already on the login form by default; this only" log_info "changes how long checking it actually keeps you signed in." } # action="exempt": inserts a "policy: one_factor / subject: user:" rule # immediately before EVERY plain "policy: two_factor" catch-all domain rule in # configuration.yml (handles multi-domain instances from add_authelia_domain # automatically). action="restore": removes only this user's own such rules, # leaving any other user's exemptions and the catch-all rules untouched. # Caller is responsible for the idempotency check (only offer "exempt" in the # menu when not already exempt, and vice versa) — this helper doesn't dedupe. _authelia_set_2fa_exempt() { local config_file="$1" username="$2" action="$3" if [ "$action" = "exempt" ]; then awk -v user="$username" ' { lines[NR]=$0 } END { for (i=1; i<=NR; i++) { if (lines[i] ~ /^ - domain:/ && lines[i+1] ~ /policy: two_factor/) { domain = lines[i] sub(/^ - domain: /, "", domain) print " - domain: " domain print " policy: one_factor" print " subject: \"user:" user "\"" } print lines[i] } } ' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file" else awk -v user="$username" ' { lines[NR]=$0 } END { for (i=1; i<=NR; i++) { if (lines[i] ~ /^ - domain:/ && lines[i+1] ~ /policy: one_factor/ && lines[i+2] ~ ("subject: \"user:" user "\"")) { i += 2 continue } print lines[i] } } ' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file" fi chown 1000:1000 "$config_file" 2>/dev/null || true } # Interactive: pick an existing user from users.yml, then act on them — # edit email/display name, force a password reset, reset their 2FA device, # toggle whether they need 2FA at all, or toggle admin group membership. # Loops so multiple actions can be applied to the same user in one pass. edit_authelia_user() { local AUTHELIA_DIR="$DOCKER_DIR/authelia" local USERS_FILE="$AUTHELIA_DIR/config/users.yml" local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml" if [ ! -f "$USERS_FILE" ]; then log_warning "No users.yml found at $USERS_FILE — install Authelia first." return 1 fi local -a USERNAMES mapfile -t USERNAMES < <(_authelia_list_usernames "$USERS_FILE") if [ "${#USERNAMES[@]}" -eq 0 ]; then log_warning "No users found in $USERS_FILE." return 0 fi echo "" echo " Existing users:" local i=1 u for u in "${USERNAMES[@]}"; do echo " $i) $u" i=$((i + 1)) done echo "" local SEL="" prompt_text " Select a user by number (blank to cancel):" "" SEL if [ -z "$SEL" ] || ! [[ "$SEL" =~ ^[0-9]+$ ]] || [ "$SEL" -lt 1 ] || [ "$SEL" -gt "${#USERNAMES[@]}" ]; then log_info "Cancelled." return 0 fi local TARGET="${USERNAMES[$((SEL - 1))]}" local CONTINUE="y" while [[ "$CONTINUE" =~ ^[Yy]$ ]]; do local RANGE START END 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) Done with this user" echo "" local ACTION="" prompt_text " Choice [1-6]:" "6" 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 ;; *) ACTION="6" ;; esac if [[ "$ACTION" =~ ^[1245]$ ]]; then local RESTART_AUTH="" prompt_yn " Restart Authelia to apply this change? (y/n):" "y" RESTART_AUTH if [ "$RESTART_AUTH" = "y" ] || [ "$RESTART_AUTH" = "Y" ]; then (cd "$AUTHELIA_DIR" && docker compose restart authelia 2>/dev/null) \ && log_success "Authelia restarted" \ || log_warning "Restart failed — check: docker compose logs authelia" fi echo "" prompt_yn " Do something else with $TARGET? (y/n):" "n" CONTINUE else CONTINUE="n" fi done } # Enables Authelia's OIDC PROVIDER feature — a distinct thing from the # forward_auth (proxy-auth) setup install_authelia() already does. forward_auth # gates a whole Caddy site behind an Authelia login page before the request # ever reaches the app; OIDC provider mode is the opposite direction — an app # with its OWN "Enable OpenID"/SSO setting (ActualBudget, Vaultwarden, etc.) # delegates ITS login to Authelia instead of asking a user for a # service-specific password. Neither replaces the other; a service can use # either, both, or neither. # # One-time, idempotent (checked via the identity_providers: key already being # present) — every _authelia_add_oidc_client() call runs this first so OIDC # just works the first time an app is registered, no separate "enable OIDC" # step to remember. _authelia_ensure_oidc_provider() { local AUTHELIA_DIR="$1" local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml" local SECRETS_DIR="$AUTHELIA_DIR/config/secrets" grep -q '^identity_providers:' "$CONFIG_FILE" 2>/dev/null && return 0 log_info "Enabling Authelia's OIDC provider (one-time — lets other apps log in via Authelia)..." # hmac_secret: injected via a _FILE env var in docker-compose.yml, same # convention as jwt/session/storage secrets above — configuration.yml # itself never holds this one as a raw string. "Random Value: " # is the exact (and only) line this subcommand prints — confirmed # against Authelia's own CLI source, not assumed. local _rand_out _rand_out="$(docker run --rm authelia/authelia:4.39.20 \ authelia crypto rand --length 64 --charset alphanumeric 2>/dev/null)" echo "${_rand_out#Random Value: }" > "$SECRETS_DIR/oidc_hmac_secret" if [ ! -s "$SECRETS_DIR/oidc_hmac_secret" ]; then log_warning "Couldn't generate the OIDC HMAC secret — skipping OIDC provider setup. Re-run to try again." return 1 fi chmod 600 "$SECRETS_DIR/oidc_hmac_secret" # RSA keypair for signing OIDC tokens (jwks). Authelia's schema requires # the private key inlined as PEM directly in configuration.yml — no # file-path or _FILE-env-var option for this specific nested field # (confirmed against the current identity_providers.oidc.jwks schema) — # so this generates into config/secrets/ for safe permissions, then reads # it back in below. "private.pem"/"public.pem" are the CLI's own default # output filenames (confirmed against Authelia's CLI reference), not # guessed. docker run --rm -u "$(id -u):$(id -g)" -v "$SECRETS_DIR":/keys \ authelia/authelia:4.39.20 authelia crypto pair rsa generate --directory /keys >/dev/null 2>&1 if [ ! -f "$SECRETS_DIR/private.pem" ]; then log_warning "Couldn't generate the OIDC signing key — skipping OIDC provider setup. Re-run to try again." return 1 fi chmod 600 "$SECRETS_DIR/private.pem" "$SECRETS_DIR/public.pem" 2>/dev/null { echo "" echo "identity_providers:" echo " oidc:" echo " jwks:" echo " - key_id: 'main'" echo " algorithm: 'RS256'" echo " use: 'sig'" echo " key: |" sed 's/^/ /' "$SECRETS_DIR/private.pem" echo " clients: []" } >> "$CONFIG_FILE" if ! grep -q 'AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE' "$AUTHELIA_DIR/docker-compose.yml"; then sed -i '/AUTHELIA_NOTIFIER_SMTP_SENDER/a\ - AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE=/config/secrets/oidc_hmac_secret' \ "$AUTHELIA_DIR/docker-compose.yml" fi chown -R 1000:1000 "$AUTHELIA_DIR/config" chmod 600 "$CONFIG_FILE" log_success "OIDC provider enabled (signing key + HMAC secret generated)" } # Deletes one OIDC client block (matched by client_id) from # identity_providers.oidc.clients in configuration.yml. Used by # _authelia_provision_oidc_client below to make re-registering a client_id # idempotent instead of a dead end — see that function's own comment on # why a stale registration is safe to just replace. A client block starts # at its own " - client_id: ''" line (6-space indent) and runs # until either the next such line or a line indented less than 6 spaces # (end of the clients list) — deleting stops exactly there so a sibling # client's block, or whatever config section follows, is untouched. _authelia_remove_oidc_client() { local config_file="$1" client_id="$2" awk -v target="'${client_id}'" ' { if ($0 ~ /^ - client_id: /) { skip = ($0 ~ target) ? 1 : 0 } else if (skip && $0 !~ /^ /) { skip = 0 } if (!skip) print } ' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file" chown 1000:1000 "$config_file" 2>/dev/null || true } # Non-interactive core of _authelia_add_oidc_client() below — generates a # client secret, patches it into identity_providers.oidc.clients, and # (optionally) restarts Authelia. Fully self-contained (re-validates # everything itself rather than trusting a caller's state) so other # services can call it directly to register themselves as an OIDC client # without walking a human through this file's own menu — see # services/gitea.sh's "Sign in with Authelia" step for the reference caller. # Guard every cross-file call with `declare -F` per this repo's chaining # convention (services/gitea.sh does). # # Args: APP_NAME CLIENT_ID AUTH_POLICY RESTART_AUTH(y/n) [ ...] # Out-params (not `local` — read them after the call returns): # OIDC_CLIENT_SECRET_PLAIN the plaintext secret. Shown once — Authelia's # config only ever stores the hash — so the # caller must capture and use/display it now. # OIDC_AUTHELIA_DOMAIN this Authelia instance's apex domain, for # building discovery/authorization/token URLs. # Returns 1 on failure (Authelia not installed, domain undeterminable, # secret generation failed) with the reason already logged. A client_id # that's already registered is NOT a failure — it gets replaced (see the # comment at that check below). _authelia_provision_oidc_client() { local APP_NAME="$1" CLIENT_ID="$2" AUTH_POLICY="$3" RESTART_AUTH="$4"; shift 4 local -a REDIRECT_URIS=("$@") OIDC_CLIENT_SECRET_PLAIN="" OIDC_AUTHELIA_DOMAIN="" 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). OIDC_AUTHELIA_DOMAIN="$(awk '/^ cookies:$/{f=1; next} f && /domain:/{print $3; exit}' "$CONFIG_FILE")" if [ -z "$OIDC_AUTHELIA_DOMAIN" ]; then log_warning "Couldn't determine this Authelia instance's domain from $CONFIG_FILE — aborting." return 1 fi # A stale registration (e.g. from the interactive "Register an app" menu # run previously without ever finishing — its plaintext secret was shown # once and is gone, so the registration is dead weight either way) would # otherwise permanently block this exact service's automated SSO offer # with nothing but a warning. Confirmed live: this is what happened to # ActualBudget the first time its own offer ran, against a client_id the # menu had already registered in an earlier session. Safe to just # replace — every automated caller here uses a fixed, service-specific # client_id, so a collision means "this same service, already # registered" rather than someone else's app using the same ID. if grep -qF "client_id: '${CLIENT_ID}'" "$CONFIG_FILE" 2>/dev/null; then log_warning "A client with ID '$CLIENT_ID' is already registered — replacing it with a fresh one (its old secret was never recoverable anyway)." _authelia_remove_oidc_client "$CONFIG_FILE" "$CLIENT_ID" fi log_info "Generating client secret..." local _hash_out CLIENT_SECRET_HASH _hash_out="$(docker run --rm authelia/authelia:4.39.20 \ authelia crypto hash generate pbkdf2 --variant sha512 --random \ --random.length 72 --random.charset rfc3986 2>/dev/null)" OIDC_CLIENT_SECRET_PLAIN="$(echo "$_hash_out" | sed -n 's/^Random Password: //p')" CLIENT_SECRET_HASH="$(echo "$_hash_out" | sed -n 's/^Digest: //p')" if [ -z "$OIDC_CLIENT_SECRET_PLAIN" ] || [ -z "$CLIENT_SECRET_HASH" ]; then log_warning "Couldn't generate the client secret automatically. Run manually, then add the" log_warning "client to $CONFIG_FILE's identity_providers.oidc.clients by hand:" echo " docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72 --random.charset rfc3986" OIDC_CLIENT_SECRET_PLAIN="" return 1 fi grep -q '^ clients: \[\]$' "$CONFIG_FILE" && sed -i 's/^ clients: \[\]$/ clients:/' "$CONFIG_FILE" local REDIRECT_URIS_YAML REDIRECT_URIS_YAML="$(printf " - '%s'\n" "${REDIRECT_URIS[@]}")" REDIRECT_URIS_YAML="${REDIRECT_URIS_YAML%$'\n'}" local CLIENT_BLOCK=" - client_id: '${CLIENT_ID}' client_name: '${APP_NAME}' client_secret: '${CLIENT_SECRET_HASH}' public: false authorization_policy: '${AUTH_POLICY}' redirect_uris: ${REDIRECT_URIS_YAML} scopes: - 'openid' - 'profile' - 'email' grant_types: - 'authorization_code' response_types: - 'code' response_modes: - 'query' userinfo_signed_response_alg: 'none'" awk -v block="$CLIENT_BLOCK" ' { print } /^ clients:$/ && !done { print block; done=1 } ' "$CONFIG_FILE" > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE" chown 1000:1000 "$CONFIG_FILE" 2>/dev/null || true if [[ "$RESTART_AUTH" =~ ^[Yy]$ ]]; then (cd "$AUTHELIA_DIR" && docker compose restart authelia 2>/dev/null) \ && log_success "Authelia restarted" \ || log_warning "Authelia restart failed — check: docker compose logs authelia" fi return 0 } # Registers an OIDC client for another app to log in via Authelia — the # "Other" provider option in an app's own "Enable OpenID"/SSO dialog. Presets # below hand back the app's own known redirect URI path and the exact fields # to paste where; "Other/custom" covers anything not listed (the app's own # OIDC/SSO docs will say what redirect URI it expects). Interactive wrapper # around _authelia_provision_oidc_client() above, which does the actual work. _authelia_add_oidc_client() { local AUTHELIA_DIR="$DOCKER_DIR/authelia" local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml" if [ ! -f "$CONFIG_FILE" ]; then log_warning "No configuration.yml found at $CONFIG_FILE — install Authelia first." return 1 fi # The apex domain this Authelia instance already serves — read back from # its own session.cookies (same structure install_authelia()/ # add_authelia_domain() write), rather than asking again or assuming a # variable set earlier in this run is still in scope (this flow can be # reached standalone from the "already exists" menu with none of # install_authelia()'s own locals ever having run this session). Used # below only to suggest a domain default — _authelia_provision_oidc_client # re-derives its own copy independently. local AUTHELIA_DOMAIN AUTHELIA_DOMAIN="$(awk '/^ cookies:$/{f=1; next} f && /domain:/{print $3; exit}' "$CONFIG_FILE")" if [ -z "$AUTHELIA_DOMAIN" ]; then log_warning "Couldn't determine this Authelia instance's domain from $CONFIG_FILE — aborting." return 1 fi echo "" echo " Register another app to log in via Authelia (OIDC/SSO)." echo "" echo " 1) ActualBudget" echo " 2) Vaultwarden" echo " 3) Immich (needs multiple redirect URIs — web login, account-linking," echo " and the mobile app's custom-scheme callback — all registered here)" echo " 4) Other / custom app" echo "" local APP_CHOICE="" prompt_text " Choice [1/2/3/4]:" "4" APP_CHOICE local APP_NAME="" CLIENT_ID="" local -a REDIRECT_PATHS=() EXTRA_REDIRECT_URIS=() case "$APP_CHOICE" in 1) APP_NAME="ActualBudget"; CLIENT_ID="actualbudget"; REDIRECT_PATHS=("/openid/callback") ;; 2) APP_NAME="Vaultwarden"; CLIENT_ID="vaultwarden"; REDIRECT_PATHS=("/identity/connect/oidc-signin") ;; 3) APP_NAME="Immich"; CLIENT_ID="immich" REDIRECT_PATHS=("/auth/login" "/user-settings") EXTRA_REDIRECT_URIS=("app.immich:///oauth-callback") ;; *) prompt_text " App name (for your reference):" "" APP_NAME [ -z "$APP_NAME" ] && { log_warning "No app name entered — nothing to do."; return 0; } CLIENT_ID="$(echo "$APP_NAME" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//;s/-*$//')" prompt_text " Client ID [${CLIENT_ID}]:" "$CLIENT_ID" CLIENT_ID echo " Check ${APP_NAME}'s own OIDC/SSO docs for its exact redirect URI path" echo " (often something like /oauth/callback, /auth/callback, /sso/callback)." local _redirect_path="" prompt_text " Redirect URI path (starting with /):" "" _redirect_path [ -n "$_redirect_path" ] && REDIRECT_PATHS=("$_redirect_path") ;; esac if [ -z "$CLIENT_ID" ] || { [ "${#REDIRECT_PATHS[@]}" -eq 0 ] && [ "${#EXTRA_REDIRECT_URIS[@]}" -eq 0 ]; }; then log_warning "Missing client ID or redirect path — nothing to do." return 0 fi if grep -qF "client_id: '${CLIENT_ID}'" "$CONFIG_FILE" 2>/dev/null; then log_warning "A client with ID '$CLIENT_ID' is already registered in $CONFIG_FILE." log_warning "Pick a different app, or edit that entry by hand." return 0 fi local APP_DOMAIN_DEFAULT="" APP_DOMAIN="" [ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ] && APP_DOMAIN_DEFAULT="${CLIENT_ID}.${SITE_DOMAIN}" prompt_text " Domain ${APP_NAME} is reachable at [${APP_DOMAIN_DEFAULT:-required}]:" "$APP_DOMAIN_DEFAULT" APP_DOMAIN if [ -z "$APP_DOMAIN" ]; then log_warning "No domain entered — nothing to do." return 0 fi # Domain-relative paths (web login, account-linking, ...) plus any # already-complete URIs that aren't domain-based (Immich's mobile app # custom-scheme callback isn't reached over https at all). local -a REDIRECT_URIS=() local _p for _p in "${REDIRECT_PATHS[@]}"; do REDIRECT_URIS+=("https://${APP_DOMAIN}${_p}") done for _p in "${EXTRA_REDIRECT_URIS[@]}"; do REDIRECT_URIS+=("$_p") done local _2fa="" AUTH_POLICY="two_factor" prompt_yn " Require two-factor for ${APP_NAME} logins too? (y/n):" "y" _2fa [[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor" local RESTART_AUTH="" prompt_yn " Restart Authelia to apply? (y/n):" "y" RESTART_AUTH _authelia_provision_oidc_client "$APP_NAME" "$CLIENT_ID" "$AUTH_POLICY" "$RESTART_AUTH" "${REDIRECT_URIS[@]}" \ || return 1 local CLIENT_SECRET_PLAIN="$OIDC_CLIENT_SECRET_PLAIN" echo "" echo " ${APP_NAME} is registered. Paste these into its OpenID/SSO settings" echo " (choose \"Other\" as the provider if it's not listed by name):" echo "" echo " Client ID: ${CLIENT_ID}" echo " Client Secret: ${CLIENT_SECRET_PLAIN}" echo " Discovery URL: https://auth.${AUTHELIA_DOMAIN}/.well-known/openid-configuration" echo "" echo " If it asks for individual endpoints instead of a discovery URL:" echo " Authorization: https://auth.${AUTHELIA_DOMAIN}/api/oidc/authorization" echo " Token: https://auth.${AUTHELIA_DOMAIN}/api/oidc/token" echo " UserInfo: https://auth.${AUTHELIA_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://auth.${AUTHELIA_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://auth.${AUTHELIA_DOMAIN}" echo " (Immich appends /.well-known/openid-configuration itself — paste" echo " just the base URL above, not the full Discovery URL from earlier.)" echo " Client ID: ${CLIENT_ID}" echo " Client Secret: ${CLIENT_SECRET_PLAIN}" echo " Scope: openid email profile" echo " Enable OAuth login on that same settings page, then check its other" echo " toggles there (auto-register new accounts, storage label claim, etc.)" echo " — those are Immich-side choices this script doesn't set for you." echo " Three redirect URIs were registered above: the web login, the" echo " account-linking page, and the mobile app's callback — all needed" echo " for OAuth to work in both the browser and the Immich mobile app." echo "" ;; esac log_warning "The Client Secret above is shown once — it isn't stored in plaintext anywhere. Save it now." } # Run immediately when executed directly (deferred until after function definition) [[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_authelia