Files
ubuntu-post-install/services/authelia.sh
T
Claude 8808de0dbb Add group/site summary view; let Mealie fully hand off login to Authelia
authelia.sh: menu option 15 lists every outside-access group with its
site membership (from access_control.rules, excluding each group's own
deny-elsewhere rule) and user membership (from users.yml) in one place —
previously only visible by grepping both files by hand.

mealie.sh: _mealie_offer_authelia_oidc now offers to set
ALLOW_PASSWORD_LOGIN=false (hides Mealie's own login form) and
OIDC_AUTO_REDIRECT=true (skip the login page, go straight to Authelia),
both confirmed against docs.mealie.io rather than assumed. Off by
default since it's a real access-control change, not just an additive
SSO button — anyone without an Authelia account loses their login path.
2026-08-24 12:58:44 +00:00

3116 lines
146 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# services/authelia.sh — Authelia SSO + 2FA portal (forward-auth for Caddy).
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash authelia.sh
# (Docker must already be installed when run standalone)
#
# Ported from the authelia-setup repo / the monolith's working block.
# ── Standalone bootstrap ──────────────────────────────────────────────────────
# Detected when the script is executed directly rather than sourced by setup.sh.
# Sets up helpers and globals, then defers execution until after the function
# definition at the bottom of this file.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
# Full repo present — use the real helpers (picks up ~/docker/.config too)
# shellcheck source=../lib/common.sh
source "$_COMMON"
else
# One-off copy — inline minimal stubs so the script works without the repo
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
require_docker() {
command -v docker &>/dev/null || {
log_error "Docker not found. Install it first:"
log_error " curl -fsSL https://get.docker.com | sudo sh"
return 1
}
docker compose version &>/dev/null || {
log_error "Docker Compose plugin missing:"
log_error " sudo apt-get install -y docker-compose-plugin"
return 1
}
}
ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile"
local _display_port="${_upstream##*:}"
# Determine mode: local Caddy, remote Caddy, or none
local _mode="none"
[[ -d "$_caddy_dir" ]] && _mode="local"
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
[[ "$_mode" == "none" ]] && {
log_info "Access $_name directly on port $_display_port."
return 0
}
echo ""
local _do_caddy=""
if [[ "$_mode" == "remote" ]]; then
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
fi
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
[[ "${_do_caddy,,}" == "y" ]] || {
log_info "Skipping — access at: http://localhost:$_display_port"
return 0
}
# Domain prompt — pre-fill from SITE_DOMAIN when available
local _default_domain=""
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
_default_domain="${_subdomain}.${SITE_DOMAIN}"
log_info "Default: $_default_domain"
fi
local _domain=""
read -r -p " Domain [${_default_domain:-required}]: " _domain
_domain="${_domain:-$_default_domain}"
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
# Build upstream — remote Caddy uses host IP:port, not container name
local _block_upstream="$_upstream"
if [[ "$_mode" == "remote" ]]; then
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
fi
local _site_block
_site_block="$(cat << CBLOCK
# $_name
${_domain} {
reverse_proxy ${_block_upstream}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/${_domain}.log
format json
}
${_extra}
}
CBLOCK
)"
if [[ "$_mode" == "local" ]]; then
if [[ -f "$_caddyfile" ]]; then
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
cp "$_caddyfile" "$_bk"
log_info "Backed up Caddyfile to $(basename "$_bk")"
else
touch "$_caddyfile"
fi
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
log_warning "$_domain already in Caddyfile"
local _ow=""
read -r -p " Overwrite? [y/N]: " _ow
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
fi
printf '%s\n' "$_site_block" >> "$_caddyfile"
log_success "Added $_domain to Caddyfile"
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
log_success "$_name accessible at: https://$_domain"
else
log_warning "Reload failed — check: docker logs caddy"
log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
fi
else
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
mkdir -p "$_snippet_dir"
printf '%s\n' "$_site_block" > "$_snippet_file"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
log_success "Snippet saved: $_snippet_file"
log_info "Copy to Caddy machine:"
log_info " scp $_snippet_file caddy-host:~/caddy-snippets/"
log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)"
fi
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
}
fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
# ($HOME under sudo is /root, not the real user's home)
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
register_service() { :; } # no-op — no wizard to register into
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service authelia homelab "SSO + 2FA auth portal (Authelia)" 9091
install_authelia() {
require_docker || return 1
local AUTHELIA_DIR="$DOCKER_DIR/authelia"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would set up Authelia:"
echo " • Create $AUTHELIA_DIR (config/secrets, data)"
echo " • Generate jwt/session/storage secrets + admin password hash"
echo " • Write docker-compose.yml, configuration.yml, users.yml, README.md"
echo " • Create the caddy_net network and add the forward-auth snippet to the Caddyfile"
return 0
fi
# Don't clobber an existing install (it would regenerate secrets and break sessions).
if [ -f "$AUTHELIA_DIR/docker-compose.yml" ]; then
echo " ⚠ Authelia already exists at $AUTHELIA_DIR."
echo ""
echo " 1) Add another protected domain to this instance (non-destructive —"
echo " one Authelia+Redis, multiple independent apex domains/logins)"
echo " 2) Remove a protected domain added this way (undoes option 1 for one"
echo " domain — other services still pointed at it will stop authenticating)"
echo " 3) Add a new user (creates a users.yml entry + password hash)"
echo " 4) Manage an existing user (email, password reset, 2FA reset/exempt,"
echo " promote/demote admin, per-service access, delete)"
echo " 5) Register an app to log in VIA Authelia (OIDC/SSO — e.g. ActualBudget,"
echo " Vaultwarden, or any other app with its own \"Enable OpenID\" setting)"
echo " 6) Remove a registered OIDC app (undoes option 5 for one app — its own"
echo " separate password login, if it has one, is untouched)"
echo " 7) Reconfigure from scratch (regenerates secrets/users — breaks"
echo " existing sessions for every domain already on this instance)"
echo " 8) Show who has universal vs. service-scoped access"
echo " 9) Change \"Remember me\" session duration (stay logged in longer)"
echo " 10) Protect an existing site with this instance (pick a local Caddy site,"
echo " or type one on a different box — gates it with a login, same as any"
echo " other service already protected this way)"
echo " 11) Un-protect a site (undoes option 10 for one site)"
echo " 12) 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 " 0) Leave as-is / exit"
echo ""
local EXISTING_CHOICE=""
prompt_text " Choice [1-15, 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
;;
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 ─────────────────────────────────────────────────────────────────
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:
# 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 "<portal-domain> {" 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.<domain>
# 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.<domain>", 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 "<domain-with-dots-as-hyphens>-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 "*.<apex>" 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 "<start_line> <end_line>" (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 " <field>: ..." 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 "<service>-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" 'NR<s || NR>e' "$users_file" > "$users_file.tmp" && mv "$users_file.tmp" "$users_file"
}
# Every "<service>-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
# "<service_id>-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
# "<service_id>-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) Native — 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 native, 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:<old>" line in configuration.yml's access_control.rules,
# and every member's "- <old>" 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 native (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
}
# 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 "<service>-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 " Native (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:<name>" rule
# immediately before EVERY plain "policy: two_factor" catch-all domain rule in
# configuration.yml (handles multi-domain instances from add_authelia_domain
# automatically). action="restore": removes only this user's own such rules,
# leaving any other user's exemptions and the catch-all rules untouched.
# Caller is responsible for the idempotency check (only offer "exempt" in the
# menu when not already exempt, and vice versa) — this helper doesn't dedupe.
_authelia_set_2fa_exempt() {
local config_file="$1" username="$2" action="$3"
if [ "$action" = "exempt" ]; then
awk -v user="$username" '
{ lines[NR]=$0 }
END {
for (i=1; i<=NR; i++) {
if (lines[i] ~ /^ - domain:/ && lines[i+1] ~ /policy: two_factor/) {
domain = lines[i]
sub(/^ - domain: /, "", domain)
print " - domain: " domain
print " policy: one_factor"
print " subject: \"user:" user "\""
}
print lines[i]
}
}
' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file"
else
awk -v user="$username" '
{ lines[NR]=$0 }
END {
for (i=1; i<=NR; i++) {
if (lines[i] ~ /^ - domain:/ && lines[i+1] ~ /policy: one_factor/ && lines[i+2] ~ ("subject: \"user:" user "\"")) {
i += 2
continue
}
print lines[i]
}
}
' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file"
fi
chown 1000:1000 "$config_file" 2>/dev/null || true
}
# Interactive: pick an existing user from users.yml, then act on them —
# edit email/display name, force a password reset, reset their 2FA device,
# toggle whether they need 2FA at all, or toggle admin group membership.
# Loops so multiple actions can be applied to the same user in one pass.
edit_authelia_user() {
local AUTHELIA_DIR="$DOCKER_DIR/authelia"
local USERS_FILE="$AUTHELIA_DIR/config/users.yml"
local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml"
if [ ! -f "$USERS_FILE" ]; then
log_warning "No users.yml found at $USERS_FILE — install Authelia first."
return 1
fi
# Outer loop: pick one or more users by number, act on each in turn (via
# _authelia_manage_one_user below), then ask whether to go again — so
# deleting/editing several users doesn't require re-running the whole
# script and re-navigating this menu from scratch for every single one.
local KEEP_GOING="y"
while [[ "$KEEP_GOING" =~ ^[Yy]$ ]]; do
local -a USERNAMES
mapfile -t USERNAMES < <(_authelia_list_usernames "$USERS_FILE")
if [ "${#USERNAMES[@]}" -eq 0 ]; then
log_warning "No users left in $USERS_FILE."
return 0
fi
echo ""
echo " Existing users:"
local i
for i in "${!USERNAMES[@]}"; do
echo " $((i + 1))) ${USERNAMES[$i]}"
done
echo ""
echo " Select one or more by number (space-separated, e.g. \"2 4\"),"
echo " 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: <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: '<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) <redirect_uri> [<redirect_uri> ...]
# Out-params (not `local` — read them after the call returns):
# OIDC_CLIENT_SECRET_PLAIN the plaintext secret. Shown once — Authelia's
# config only ever stores the hash — so the
# caller must capture and use/display it now.
# OIDC_AUTHELIA_DOMAIN this Authelia instance's apex domain, for
# building discovery/authorization/token URLs.
# OIDC_AUTHELIA_PORTAL_URL the actual login-portal base URL (e.g.
# https://auth.example.com) — read back from this
# instance's own config rather than assumed,
# since the portal subdomain isn't always "auth."
# (install_authelia()/add_authelia_domain() both
# default to it, but it's plain text in
# configuration.yml and gets hand-edited on some
# boxes — e.g. a dedicated VPS instance renamed
# to "authelia." to avoid colliding with another
# instance's "auth." on a different machine).
# Use this, not a hardcoded "https://auth.$domain",
# when building a discovery/redirect URL for a
# native-OIDC app.
# Returns 1 on failure (Authelia not installed, domain undeterminable,
# secret generation failed) with the reason already logged. A client_id
# that's already registered is NOT a failure — it gets replaced (see the
# comment at that check below).
_authelia_provision_oidc_client() {
local APP_NAME="$1" CLIENT_ID="$2" AUTH_POLICY="$3" RESTART_AUTH="$4"; shift 4
local -a REDIRECT_URIS=("$@")
OIDC_CLIENT_SECRET_PLAIN=""
OIDC_AUTHELIA_DOMAIN=""
OIDC_AUTHELIA_PORTAL_URL=""
local AUTHELIA_DIR="$DOCKER_DIR/authelia"
local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml"
if [ ! -f "$CONFIG_FILE" ]; then
log_warning "No configuration.yml found at $CONFIG_FILE — install Authelia first."
return 1
fi
_authelia_ensure_oidc_provider "$AUTHELIA_DIR" || return 1
# The apex domain this Authelia instance already serves — read back from
# its own session.cookies (same structure install_authelia()/
# add_authelia_domain() write), rather than asking again or assuming a
# variable set earlier in this run is still in scope (this flow can be
# reached standalone from the "already exists" menu, or from another
# service entirely, with none of install_authelia()'s own locals ever
# having run this session).
# tr -d '\r' first, not after — a CRLF-tainted config (e.g. a line
# hand-edited by something that saves Windows line endings) makes every
# line-anchored awk pattern below fail to match at all, not just leave a
# stray \r in the captured value: " cookies:\r" doesn't match
# /^ cookies:$/ since $ anchors end-of-string and the \r is still part
# of it. Also strip a leading/trailing quote character: these fields are
# unquoted in every value this repo's own scripts write, but YAML makes
# quoting optional and a hand-edited config can add single or double
# quotes around the value. awk's `print $2`/`print $3` is a naive
# whitespace-split token grab that doesn't know about YAML quoting, so a
# quoted value comes back WITH the literal quote characters still
# attached. Confirmed live: this is what actually caused a "line 12:
# unexpected character '/' in variable name" failure in Mealie's
# .env — an authelia_url value hand-edited to
# `authelia_url: 'https://authelia.example.com'` got captured as the
# literal string including both single quotes, so the generated
# discovery URL came out `'https://authelia.example.com'/.well-known/...`
# — Docker Compose's env parser closed the quoted value at that
# embedded closing quote and choked on everything after it as a new,
# invalid token. (The earlier \r-stripping guards a different,
# also-real failure mode — a CRLF-tainted line failing to match these
# anchored patterns at all — not this one; both are needed.)
OIDC_AUTHELIA_DOMAIN="$(tr -d '\r' < "$CONFIG_FILE" | awk '/^ cookies:$/{f=1; next} f && /domain:/{print $3; exit}' | sed "s/^[\"']//; s/[\"']\$//")"
if [ -z "$OIDC_AUTHELIA_DOMAIN" ]; then
log_warning "Couldn't determine this Authelia instance's domain from $CONFIG_FILE — aborting."
return 1
fi
# Read the real portal URL back from config instead of assuming the
# "auth." prefix — see the OIDC_AUTHELIA_PORTAL_URL out-param comment
# above for why this can't be hardcoded. Falls back to the "auth."
# default only if parsing somehow comes up empty.
OIDC_AUTHELIA_PORTAL_URL="$(tr -d '\r' < "$CONFIG_FILE" | awk '/^ cookies:$/{f=1; next} f && /authelia_url:/{print $2; exit}' | sed "s/^[\"']//; s/[\"']\$//")"
[ -z "$OIDC_AUTHELIA_PORTAL_URL" ] && OIDC_AUTHELIA_PORTAL_URL="https://auth.${OIDC_AUTHELIA_DOMAIN}"
# A stale registration (e.g. from the interactive "Register an app" menu
# run previously without ever finishing — its plaintext secret was shown
# once and is gone, so the registration is dead weight either way) would
# otherwise permanently block this exact service's automated SSO offer
# with nothing but a warning. Confirmed live: this is what happened to
# ActualBudget the first time its own offer ran, against a client_id the
# menu had already registered in an earlier session. Safe to just
# replace — every automated caller here uses a fixed, service-specific
# client_id, so a collision means "this same service, already
# registered" rather than someone else's app using the same ID.
if grep -qF "client_id: '${CLIENT_ID}'" "$CONFIG_FILE" 2>/dev/null; then
log_warning "A client with ID '$CLIENT_ID' is already registered — replacing it with a fresh one (its old secret was never recoverable anyway)."
_authelia_remove_oidc_client "$CONFIG_FILE" "$CLIENT_ID"
fi
log_info "Generating client secret..."
local _hash_out CLIENT_SECRET_HASH
_hash_out="$(docker run --rm authelia/authelia:4.39.20 \
authelia crypto hash generate pbkdf2 --variant sha512 --random \
--random.length 72 --random.charset rfc3986 2>/dev/null)"
OIDC_CLIENT_SECRET_PLAIN="$(echo "$_hash_out" | sed -n 's/^Random Password: //p')"
CLIENT_SECRET_HASH="$(echo "$_hash_out" | sed -n 's/^Digest: //p')"
if [ -z "$OIDC_CLIENT_SECRET_PLAIN" ] || [ -z "$CLIENT_SECRET_HASH" ]; then
log_warning "Couldn't generate the client secret automatically. Run manually, then add the"
log_warning "client to $CONFIG_FILE's identity_providers.oidc.clients by hand:"
echo " docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72 --random.charset rfc3986"
OIDC_CLIENT_SECRET_PLAIN=""
return 1
fi
grep -q '^ clients: \[\]$' "$CONFIG_FILE" && sed -i 's/^ clients: \[\]$/ clients:/' "$CONFIG_FILE"
local REDIRECT_URIS_YAML
REDIRECT_URIS_YAML="$(printf " - '%s'\n" "${REDIRECT_URIS[@]}")"
REDIRECT_URIS_YAML="${REDIRECT_URIS_YAML%$'\n'}"
local CLIENT_BLOCK=" - client_id: '${CLIENT_ID}'
client_name: '${APP_NAME}'
client_secret: '${CLIENT_SECRET_HASH}'
public: false
authorization_policy: '${AUTH_POLICY}'
redirect_uris:
${REDIRECT_URIS_YAML}
scopes:
- 'openid'
- 'profile'
- 'email'
grant_types:
- 'authorization_code'
response_types:
- 'code'
response_modes:
- 'query'
userinfo_signed_response_alg: 'none'"
awk -v block="$CLIENT_BLOCK" '
{ print }
/^ clients:$/ && !done { print block; done=1 }
' "$CONFIG_FILE" > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE"
chown 1000:1000 "$CONFIG_FILE" 2>/dev/null || true
if [[ "$RESTART_AUTH" =~ ^[Yy]$ ]]; then
(cd "$AUTHELIA_DIR" && docker compose restart authelia 2>/dev/null) \
&& log_success "Authelia restarted" \
|| log_warning "Authelia restart failed — check: docker compose logs authelia"
fi
return 0
}
# Registers an OIDC client for another app to log in via Authelia — the
# "Other" provider option in an app's own "Enable OpenID"/SSO dialog. Presets
# below hand back the app's own known redirect URI path and the exact fields
# to paste where; "Other/custom" covers anything not listed (the app's own
# OIDC/SSO docs will say what redirect URI it expects). Interactive wrapper
# around _authelia_provision_oidc_client() above, which does the actual work.
_authelia_add_oidc_client() {
local AUTHELIA_DIR="$DOCKER_DIR/authelia"
local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml"
if [ ! -f "$CONFIG_FILE" ]; then
log_warning "No configuration.yml found at $CONFIG_FILE — install Authelia first."
return 1
fi
# The apex domain this Authelia instance already serves — read back from
# its own session.cookies (same structure install_authelia()/
# add_authelia_domain() write), rather than asking again or assuming a
# variable set earlier in this run is still in scope (this flow can be
# reached standalone from the "already exists" menu with none of
# install_authelia()'s own locals ever having run this session). Used
# below only to suggest a domain default — _authelia_provision_oidc_client
# re-derives its own copy independently.
local AUTHELIA_DOMAIN
AUTHELIA_DOMAIN="$(tr -d '\r' < "$CONFIG_FILE" | awk '/^ cookies:$/{f=1; next} f && /domain:/{print $3; exit}' | sed "s/^[\"']//; s/[\"']\$//")"
if [ -z "$AUTHELIA_DOMAIN" ]; then
log_warning "Couldn't determine this Authelia instance's domain from $CONFIG_FILE — aborting."
return 1
fi
# This domain's own portal — whatever subdomain was actually chosen at
# install time (see install_authelia's own AUTHELIA_PORTAL_SUBDOMAIN
# prompt), not necessarily "auth.<domain>". Read back the same way
# AUTHELIA_DOMAIN itself is, from this entry's own authelia_url.
local AUTHELIA_PORTAL_DOMAIN
AUTHELIA_PORTAL_DOMAIN="$(tr -d '\r' < "$CONFIG_FILE" | awk -v domain="$AUTHELIA_DOMAIN" '
$0 == " - domain: " domain { f=1; next }
f && /authelia_url:/ { print $2; exit }
' | sed -E 's#^https?://##')"
[ -z "$AUTHELIA_PORTAL_DOMAIN" ] && AUTHELIA_PORTAL_DOMAIN="auth.${AUTHELIA_DOMAIN}"
echo ""
echo " Register another app to log in via Authelia (OIDC/SSO)."
echo ""
echo " 1) ActualBudget"
echo " 2) Vaultwarden"
echo " 3) Immich (needs multiple redirect URIs — web login, account-linking,"
echo " and the mobile app's custom-scheme callback — all registered here)"
echo " 4) Other / custom app"
echo " 0) Cancel"
echo ""
local APP_CHOICE=""
prompt_text " Choice [1-4, 0 to cancel]:" "0" 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")
;;
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" "${REDIRECT_URIS[@]}" \
|| return 1
local CLIENT_SECRET_PLAIN="$OIDC_CLIENT_SECRET_PLAIN"
echo ""
echo " ${APP_NAME} is registered. Paste these into its OpenID/SSO settings"
echo " (choose \"Other\" as the provider if it's not listed by name):"
echo ""
echo " Client ID: ${CLIENT_ID}"
echo " Client Secret: ${CLIENT_SECRET_PLAIN}"
echo " Discovery URL: https://${AUTHELIA_PORTAL_DOMAIN}/.well-known/openid-configuration"
echo ""
echo " If it asks for individual endpoints instead of a discovery URL:"
echo " Authorization: https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/authorization"
echo " Token: https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/token"
echo " UserInfo: https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/userinfo"
echo " Scopes: openid profile email"
echo ""
case "$APP_CHOICE" in
1)
echo " ActualBudget's \"Enable OpenID\" dialog → provider \"Other\": paste the"
echo " Discovery URL, Client ID, and Client Secret above."
echo " First OIDC login becomes the ActualBudget server owner."
echo ""
;;
2)
echo " Add these to Vaultwarden's .env, then: cd \$VAULTWARDEN_DIR && docker compose up -d"
echo " SSO_ENABLED=true"
echo " SSO_AUTHORITY=https://${AUTHELIA_PORTAL_DOMAIN}"
echo " SSO_CLIENT_ID=${CLIENT_ID}"
echo " SSO_CLIENT_SECRET=${CLIENT_SECRET_PLAIN}"
echo " SSO_SCOPES=profile email"
echo " Enabling SSO changes Vaultwarden's login flow for everyone on this"
echo " instance — see Vaultwarden's own SSO docs before turning this on for"
echo " a vault other people already use."
echo ""
;;
3)
echo " Immich → Administration → Settings → OAuth Authentication:"
echo " Issuer URL: https://${AUTHELIA_PORTAL_DOMAIN}"
echo " (Immich appends /.well-known/openid-configuration itself — paste"
echo " just the base URL above, not the full Discovery URL from earlier.)"
echo " Client ID: ${CLIENT_ID}"
echo " Client Secret: ${CLIENT_SECRET_PLAIN}"
echo " Scope: openid email profile"
echo " Enable OAuth login on that same settings page, then check its other"
echo " toggles there (auto-register new accounts, storage label claim, etc.)"
echo " — those are Immich-side choices this script doesn't set for you."
echo " Three redirect URIs were registered above: the web login, the"
echo " account-linking page, and the mobile app's callback — all needed"
echo " for OAuth to work in both the browser and the Immich mobile app."
echo ""
;;
esac
log_warning "The Client Secret above is shown once — it isn't stored in plaintext anywhere. Save it now."
}
# Interactive wrapper around _authelia_remove_oidc_client (the internal
# helper _authelia_provision_oidc_client already uses to replace a stale
# registration) — exposes it as its own menu action so removing an app's
# OIDC client doesn't require hand-editing configuration.yml either. Lists
# every registered client's ID and display name, numbered; removing one
# doesn't affect that app's own separate password login (if it has one) or
# any other client on this instance.
_authelia_remove_oidc_client_menu() {
local AUTHELIA_DIR="$DOCKER_DIR/authelia"
local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml"
if [ ! -f "$CONFIG_FILE" ]; then
log_warning "No configuration.yml found at $CONFIG_FILE — install Authelia first."
return 1
fi
local -a client_ids client_names
mapfile -t client_ids < <(grep -oP "(?<=- client_id: ')[^']+" "$CONFIG_FILE")
mapfile -t client_names < <(grep -oP "(?<=client_name: ')[^']+" "$CONFIG_FILE")
if [ "${#client_ids[@]}" -eq 0 ]; then
log_info "No OIDC clients registered on this instance."
return 0
fi
echo ""
echo " Registered OIDC clients:"
local i
for i in "${!client_ids[@]}"; do
echo " $((i + 1))) ${client_ids[$i]} (${client_names[$i]:-unnamed})"
done
echo " 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