install_authelia() and add_authelia_domain() both hardcoded "auth." as the login portal's subdomain prefix everywhere -- configuration.yml's authelia_url, the Caddy portal block/domain, generated README/OIDC text. No prompt ever offered anything else, despite this repo otherwise treating "auth.<domain>" as just this one instance's own choice, not a protocol requirement. Both now prompt for the portal subdomain (default "auth", so existing behavior is unchanged for anyone who doesn't care) and use the actual chosen value throughout. Every function that operates on an EXISTING domain (remove_authelia_domain, _authelia_add_oidc_client) now reads the real portal domain back from that domain's own session.cookies authelia_url entry instead of assuming "auth.<domain>" -- matching the same read-back pattern _authelia_add_oidc_client already used for the apex domain itself, and _authelia_provision_oidc_client already used for the portal URL. _authelia_remove_caddy_portal_block now takes the portal's full domain directly rather than reconstructing it, so removing a domain whose portal used a custom prefix actually finds and removes the right Caddy block. Also fixed a real, separate small bug found while in here: the primary portal's Caddy log path was hardcoded to a generic auth.log (collides across instances/domains) instead of following every other site block's own <domain>.log convention. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
2164 lines
101 KiB
Bash
2164 lines
101 KiB
Bash
#!/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) Reconfigure from scratch (regenerates secrets/users — breaks"
|
||
echo " existing sessions for every domain already on this instance)"
|
||
echo " 7) Show who has universal vs. service-scoped access"
|
||
echo " 8) Change \"Remember me\" session duration (stay logged in longer)"
|
||
echo " 9) Leave as-is"
|
||
echo ""
|
||
local EXISTING_CHOICE=""
|
||
prompt_text " Choice [1/2/3/4/5/6/7/8/9]:" "9" 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)
|
||
: # fall through to the full reinstall flow below
|
||
;;
|
||
7)
|
||
_authelia_report_access_scope
|
||
return 0
|
||
;;
|
||
8)
|
||
_authelia_set_remember_me
|
||
return 0
|
||
;;
|
||
*)
|
||
echo " Keeping existing Authelia. (Edit config/users.yml then: cd $AUTHELIA_DIR && docker compose restart authelia)"
|
||
return 0
|
||
;;
|
||
esac
|
||
fi
|
||
|
||
log_info "Installing Authelia..."
|
||
mkdir -p "$AUTHELIA_DIR/config/secrets" "$AUTHELIA_DIR/data"
|
||
|
||
# ── Collect configuration ────────────────────────────────────────────────
|
||
echo ""
|
||
echo " Authelia needs a few details to configure."
|
||
echo ""
|
||
local CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
|
||
local AUTHELIA_DOMAIN AUTHELIA_PORTAL_SUBDOMAIN AUTHELIA_PORTAL_DOMAIN AUTHELIA_ADMIN_USER AUTHELIA_ADMIN_DISPLAY AUTHELIA_ADMIN_EMAIL
|
||
local AUTHELIA_SMTP_HOST AUTHELIA_SMTP_PORT AUTHELIA_SMTP_USER AUTHELIA_SMTP_PASS AUTHELIA_TZ
|
||
prompt_text " Your domain (e.g., example.com):" "${SITE_DOMAIN:-example.com}" AUTHELIA_DOMAIN
|
||
# Subdomain the login portal itself lives on — "auth" is just the
|
||
# default, not a fixed convention. Every later function that needs this
|
||
# domain's portal (add_authelia_domain for a DIFFERENT domain's own
|
||
# portal, remove_authelia_domain, OIDC client registration, etc.) reads
|
||
# it back from configuration.yml's session.cookies authelia_url instead
|
||
# of assuming "auth." — see those functions for why.
|
||
prompt_text " Subdomain for the login portal (e.g. 'auth' -> auth.${AUTHELIA_DOMAIN}):" "auth" AUTHELIA_PORTAL_SUBDOMAIN
|
||
AUTHELIA_PORTAL_DOMAIN="${AUTHELIA_PORTAL_SUBDOMAIN}.${AUTHELIA_DOMAIN}"
|
||
prompt_text " Admin username:" "admin" AUTHELIA_ADMIN_USER
|
||
prompt_text " Admin display name:" "Administrator" AUTHELIA_ADMIN_DISPLAY
|
||
prompt_text " Admin email:" "admin@${AUTHELIA_DOMAIN}" AUTHELIA_ADMIN_EMAIL
|
||
prompt_text " SMTP server (e.g., smtp.migadu.com):" "smtp.migadu.com" AUTHELIA_SMTP_HOST
|
||
prompt_text " SMTP port:" "587" AUTHELIA_SMTP_PORT
|
||
prompt_text " SMTP username (full email):" "authelia@${AUTHELIA_DOMAIN}" AUTHELIA_SMTP_USER
|
||
prompt_text " SMTP password:" "" AUTHELIA_SMTP_PASS
|
||
prompt_text " Timezone (e.g., America/New_York):" "${SITE_TZ:-America/New_York}" AUTHELIA_TZ
|
||
|
||
# ── Secrets ──────────────────────────────────────────────────────────────
|
||
echo ""
|
||
echo " Generating secrets..."
|
||
echo "$(openssl rand -hex 32)" > "$AUTHELIA_DIR/config/secrets/jwt_secret"
|
||
echo "$(openssl rand -hex 32)" > "$AUTHELIA_DIR/config/secrets/session_secret"
|
||
echo "$(openssl rand -hex 32)" > "$AUTHELIA_DIR/config/secrets/storage_secret"
|
||
echo "$AUTHELIA_SMTP_PASS" > "$AUTHELIA_DIR/config/secrets/smtp_password"
|
||
chmod 600 "$AUTHELIA_DIR/config/secrets/"*
|
||
echo " ✓ Secrets generated"
|
||
|
||
# ── Admin password hash ──────────────────────────────────────────────────
|
||
echo ""
|
||
local AUTHELIA_TEMP_PASS AUTHELIA_HASH
|
||
prompt_text " Temporary password for admin (users reset via email):" "TempPass2026!" AUTHELIA_TEMP_PASS
|
||
echo " Generating password hash..."
|
||
AUTHELIA_HASH=$(docker run --rm authelia/authelia:4.39.20 \
|
||
authelia crypto hash generate argon2 --password "$AUTHELIA_TEMP_PASS" 2>/dev/null \
|
||
| grep -oP '(?<=Digest: ).*' || echo "REPLACE_WITH_HASH")
|
||
if [ "$AUTHELIA_HASH" = "REPLACE_WITH_HASH" ]; then
|
||
log_warning "Could not generate hash automatically. After install run:"
|
||
echo " docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'yourpassword'"
|
||
echo " then update $AUTHELIA_DIR/config/users.yml"
|
||
else
|
||
echo " ✓ Password hash generated"
|
||
fi
|
||
|
||
ensure_docker_dir_ownership "$AUTHELIA_DIR"
|
||
cd "$AUTHELIA_DIR" || return 1
|
||
|
||
# ── .env ─────────────────────────────────────────────────────────────────
|
||
cat > .env << AUTHELIA_ENV
|
||
MY_DOMAIN=${AUTHELIA_DOMAIN}
|
||
SMTP_USER=${AUTHELIA_SMTP_USER}
|
||
DOCKER_MY_NETWORK=${CADDY_NET}
|
||
TZ=${AUTHELIA_TZ}
|
||
AUTHELIA_ENV
|
||
|
||
# ── docker-compose.yml (quoted heredoc: ${SMTP_USER} resolved by compose/.env) ──
|
||
cat > docker-compose.yml << 'AUTHELIA_COMPOSE'
|
||
name: authelia
|
||
|
||
services:
|
||
authelia:
|
||
image: authelia/authelia:4.39.20
|
||
pull_policy: missing
|
||
container_name: authelia
|
||
user: "1000:1000"
|
||
volumes:
|
||
- ./config:/config
|
||
- ./data:/data
|
||
environment:
|
||
- AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/config/secrets/jwt_secret
|
||
- AUTHELIA_SESSION_SECRET_FILE=/config/secrets/session_secret
|
||
- AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/config/secrets/storage_secret
|
||
- AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/config/secrets/smtp_password
|
||
- AUTHELIA_NOTIFIER_SMTP_USERNAME=${SMTP_USER}
|
||
- AUTHELIA_NOTIFIER_SMTP_SENDER=Authelia <${SMTP_USER}>
|
||
expose:
|
||
- 9091
|
||
restart: unless-stopped
|
||
networks:
|
||
- caddy_net
|
||
|
||
networks:
|
||
caddy_net:
|
||
external: true
|
||
AUTHELIA_COMPOSE
|
||
[ "$CADDY_NET" != "caddy_net" ] && sed -i "s/caddy_net/${CADDY_NET}/g" docker-compose.yml
|
||
|
||
# ── configuration.yml ────────────────────────────────────────────────────
|
||
cat > config/configuration.yml << AUTHELIA_CONFIG
|
||
---
|
||
# Authelia configuration. Secrets injected via AUTHELIA_* env vars in compose.
|
||
theme: dark
|
||
|
||
server:
|
||
address: tcp://0.0.0.0:9091
|
||
|
||
log:
|
||
level: info
|
||
file_path: /data/authelia.log
|
||
|
||
totp:
|
||
period: 30
|
||
skew: 1
|
||
|
||
authentication_backend:
|
||
file:
|
||
path: /config/users.yml
|
||
password:
|
||
algorithm: argon2
|
||
argon2:
|
||
variant: argon2id
|
||
iterations: 3
|
||
memory: 65536
|
||
parallelism: 4
|
||
key_length: 32
|
||
salt_length: 16
|
||
|
||
access_control:
|
||
default_policy: deny
|
||
rules:
|
||
- domain: "*.${AUTHELIA_DOMAIN}"
|
||
policy: two_factor
|
||
|
||
session:
|
||
name: authelia_session
|
||
expiration: 12h
|
||
inactivity: 2h
|
||
remember_me: 7d
|
||
cookies:
|
||
- domain: ${AUTHELIA_DOMAIN}
|
||
authelia_url: https://${AUTHELIA_PORTAL_DOMAIN}
|
||
default_redirection_url: https://${AUTHELIA_DOMAIN}
|
||
|
||
storage:
|
||
local:
|
||
path: /data/db.sqlite3
|
||
|
||
notifier:
|
||
disable_startup_check: false
|
||
smtp:
|
||
address: smtp://${AUTHELIA_SMTP_HOST}:${AUTHELIA_SMTP_PORT}
|
||
timeout: 10s
|
||
identifier: localhost
|
||
subject: "[Authelia] {title}"
|
||
startup_check_address: ${AUTHELIA_SMTP_USER}
|
||
disable_require_tls: false
|
||
disable_starttls: false
|
||
AUTHELIA_CONFIG
|
||
|
||
# ── users.yml ────────────────────────────────────────────────────────────
|
||
cat > config/users.yml << AUTHELIA_USERS
|
||
---
|
||
# Authelia users database
|
||
# Add users: copy a block, change username/email/displayname, restart authelia.
|
||
# Generate a hash: docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'thepassword'
|
||
# Login with username (not email). Use "Forgot Password" to set a real password.
|
||
|
||
users:
|
||
${AUTHELIA_ADMIN_USER}:
|
||
displayname: "${AUTHELIA_ADMIN_DISPLAY}"
|
||
email: ${AUTHELIA_ADMIN_EMAIL}
|
||
password: "${AUTHELIA_HASH}"
|
||
groups:
|
||
- admins
|
||
- users
|
||
AUTHELIA_USERS
|
||
|
||
chown -R 1000:1000 "$AUTHELIA_DIR/config" "$AUTHELIA_DIR/data"
|
||
log_success "Authelia configured at $AUTHELIA_DIR"
|
||
|
||
# $CADDY_NET already exists at this point — require_docker (called at the
|
||
# top of this function) creates it via ensure_caddy_network in lib/common.sh.
|
||
|
||
# ── Caddyfile forward-auth snippet + portal block ────────────────────────
|
||
local CADDY_FILE="$DOCKER_DIR/caddy/Caddyfile"
|
||
if [ -f "$CADDY_FILE" ]; then
|
||
echo " Configuring Caddy for Authelia..."
|
||
# Anchored to an actual, uncommented snippet definition — a bare
|
||
# `grep -q "(authelia)"` also matches the commented-out example
|
||
# block caddy.sh's starter Caddyfile ships ("# (authelia) {" as
|
||
# documentation). Confirmed live: that false match made this skip
|
||
# writing the real snippet entirely, leaving any later `import
|
||
# authelia` reference elsewhere in the file dangling — Caddy then
|
||
# refuses to start at all ("File to import not found: authelia"),
|
||
# taking down every site it fronts, not just the Authelia-protected
|
||
# one.
|
||
if ! grep -qE '^\(authelia\)[[:space:]]*\{' "$CADDY_FILE"; then
|
||
cp "$CADDY_FILE" "$CADDY_FILE.backup.$(date +%Y%m%d-%H%M%S)"
|
||
{ cat << 'SNIPPET_EOF'
|
||
# ── Authelia forward auth snippet ─────────────────────────────────────────────
|
||
(authelia) {
|
||
forward_auth authelia:9091 {
|
||
uri /api/authz/forward-auth
|
||
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
|
||
}
|
||
}
|
||
|
||
SNIPPET_EOF
|
||
cat "$CADDY_FILE"; } > "$CADDY_FILE.tmp" && mv "$CADDY_FILE.tmp" "$CADDY_FILE"
|
||
echo " ✓ Authelia snippet added to Caddyfile"
|
||
fi
|
||
if ! grep -q "${AUTHELIA_PORTAL_DOMAIN}" "$CADDY_FILE"; then
|
||
cat >> "$CADDY_FILE" << CADDY_AUTH_BLOCK
|
||
|
||
# ── Authelia login portal ──────────────────────────────────────────────────────
|
||
${AUTHELIA_PORTAL_DOMAIN} {
|
||
# header_up pins X-Forwarded-Host to whatever the client actually sent.
|
||
# Without it, Caddy's reverse_proxy recomputes X-Forwarded-Host from its
|
||
# own incoming request (always ${AUTHELIA_PORTAL_DOMAIN} itself) and
|
||
# overwrites the value a forward_auth caller (e.g. a remote site's
|
||
# "forward_auth https://${AUTHELIA_PORTAL_DOMAIN}" block, see
|
||
# services/asterisk.sh's droplet-mode Caddy block) set for its own domain. Confirmed
|
||
# live: every forward-auth check evaluated as if it were for
|
||
# ${AUTHELIA_PORTAL_DOMAIN} itself (which has policy: bypass in
|
||
# access_control.rules so its own login portal isn't gated behind
|
||
# itself), so every domain behind it silently passed through with no
|
||
# 2FA prompt regardless of that domain's own policy.
|
||
reverse_proxy authelia:9091 {
|
||
header_up X-Forwarded-Host {http.request.header.X-Forwarded-Host}
|
||
}
|
||
log {
|
||
output file /var/log/caddy/${AUTHELIA_PORTAL_DOMAIN}.log
|
||
}
|
||
}
|
||
CADDY_AUTH_BLOCK
|
||
echo " ✓ Authelia portal block added for ${AUTHELIA_PORTAL_DOMAIN}"
|
||
fi
|
||
docker ps --format '{{.Names}}' | grep -q "^caddy$" && \
|
||
{ docker exec -w /etc/caddy caddy caddy reload 2>/dev/null && echo " ✓ Caddy reloaded" || echo " ⚠ Reload manually after checking the Caddyfile"; }
|
||
else
|
||
echo " ℹ Caddy not installed yet — add the (authelia) snippet + ${AUTHELIA_PORTAL_DOMAIN} block to your Caddyfile later (see README)."
|
||
fi
|
||
|
||
# ── README for the service folder ────────────────────────────────────────
|
||
write_readme "$AUTHELIA_DIR" << README_MD
|
||
# Authelia — SSO + 2FA portal
|
||
|
||
Single login (with TOTP two-factor) that protects any Caddy subdomain via
|
||
forward-auth. Portal: **https://${AUTHELIA_PORTAL_DOMAIN}**
|
||
|
||
## Layout
|
||
\`\`\`
|
||
$AUTHELIA_DIR/
|
||
├── docker-compose.yml
|
||
├── .env
|
||
├── config/
|
||
│ ├── configuration.yml
|
||
│ ├── users.yml
|
||
│ └── secrets/ # jwt/session/storage/smtp — never commit
|
||
└── data/ # sqlite db + log
|
||
\`\`\`
|
||
|
||
## Protect a service with Authelia
|
||
In that service's Caddy site block, add \`import authelia\`:
|
||
\`\`\`
|
||
myservice.${AUTHELIA_DOMAIN} {
|
||
import authelia
|
||
reverse_proxy localhost:PORT
|
||
}
|
||
\`\`\`
|
||
The \`(authelia)\` snippet and the \`auth.${AUTHELIA_DOMAIN}\` portal block were
|
||
added to \`$DOCKER_DIR/caddy/Caddyfile\` automatically.
|
||
|
||
## Protecting a second (or third) apex domain
|
||
Re-run this installer (\`sudo ./setup.sh authelia\` or \`sudo bash authelia.sh\`)
|
||
and choose **"Add another protected domain to this instance"** when it detects
|
||
the existing install. That domain gets its own \`session.cookies\` entry and its
|
||
own \`auth.<domain>\` portal — a separate login/session from ${AUTHELIA_DOMAIN},
|
||
so no accidental cross-domain SSO — but it's still one shared Authelia + Redis
|
||
container and one shared user database, not a second full stack. Cheaper than
|
||
standing up an entirely separate instance, and the right way to protect
|
||
multiple unrelated domains from the same box.
|
||
|
||
## Letting other apps log in via Authelia (OIDC/SSO)
|
||
Different from \`import authelia\` above: that gates a whole site behind a
|
||
login page before the request reaches it. This is for an app with its OWN
|
||
"Enable OpenID"/SSO setting (ActualBudget, Vaultwarden, etc.) that should
|
||
delegate ITS login to Authelia instead of a separate app-specific password.
|
||
|
||
Re-run this installer and choose **"Register an app to log in VIA
|
||
Authelia"** when it detects the existing install. Presets exist for
|
||
ActualBudget and Vaultwarden (their exact redirect URI is filled in
|
||
automatically); anything else works too via "Other/custom" — check that
|
||
app's own OIDC/SSO docs for its redirect URI path first.
|
||
|
||
First time this runs it also enables Authelia's OIDC provider itself
|
||
(generates a signing key + HMAC secret, one-time, automatic). Each
|
||
registered app gets its own Client ID/Secret under
|
||
\`identity_providers.oidc.clients\` in \`config/configuration.yml\` — the
|
||
secret is shown once at registration time and only the hash is kept.
|
||
|
||
Endpoints (needed if an app asks for them instead of a discovery URL):
|
||
- Discovery: \`https://${AUTHELIA_PORTAL_DOMAIN}/.well-known/openid-configuration\`
|
||
- Authorization: \`https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/authorization\`
|
||
- Token: \`https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/token\`
|
||
- UserInfo: \`https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/userinfo\`
|
||
|
||
## Manage
|
||
\`\`\`
|
||
cd $AUTHELIA_DIR
|
||
docker compose up -d # start
|
||
docker compose restart authelia
|
||
docker compose logs -f authelia
|
||
docker compose down # stop
|
||
\`\`\`
|
||
|
||
## Users
|
||
- Login with the **username** (not email). Admin user: \`${AUTHELIA_ADMIN_USER}\`.
|
||
- Both self-service paths need working SMTP: **Forgot Password** on the login
|
||
screen emails a reset link, and even the in-portal **Settings → Change
|
||
Password** page (for an already-logged-in user) sends a one-time code to
|
||
their email to confirm the change — confirmed live, it is not a
|
||
no-email path despite Authelia describing it as an in-session action.
|
||
If SMTP isn't working yet, use the admin-side reset instead (next line),
|
||
which never touches email.
|
||
- **Add a user:** re-run this installer (\`sudo ./setup.sh authelia\` or
|
||
\`sudo bash authelia.sh\`) and choose **"Add a new user"** from the menu —
|
||
it prompts for username/email/display name, generates the password hash,
|
||
writes the \`users.yml\` block, and restarts Authelia for you.
|
||
- To add one by hand instead: copy a block in \`config/users.yml\`, change
|
||
username/email/displayname, generate a hash, then
|
||
\`docker compose restart authelia\`:
|
||
\`\`\`
|
||
docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'thepassword'
|
||
\`\`\`
|
||
- Any user added this way can log into every OIDC app already registered on
|
||
this instance (see "Letting other apps log in via Authelia" above) — access
|
||
isn't scoped per-app by default, it's shared across the whole instance.
|
||
|
||
## Notes
|
||
- Authelia listens on 9091 **internally only** (no published port) and is
|
||
reached through Caddy on the shared \`caddy_net\` docker network.
|
||
- Two-factor is **required** (\`default_policy: deny\`, rule \`two_factor\` for
|
||
\`*.${AUTHELIA_DOMAIN}\`).
|
||
README_MD
|
||
|
||
local START_AUTHELIA=""
|
||
prompt_yn "Start Authelia now? (y/n):" "y" START_AUTHELIA
|
||
if [ "$START_AUTHELIA" = "y" ] || [ "$START_AUTHELIA" = "Y" ]; then
|
||
docker compose up -d 2>/dev/null && log_success "Authelia started" || log_warning "Failed to start Authelia"
|
||
fi
|
||
|
||
echo ""
|
||
echo " Auth portal: https://${AUTHELIA_PORTAL_DOMAIN}"
|
||
echo " Admin login: ${AUTHELIA_ADMIN_USER} (use Forgot Password to set a real password)"
|
||
echo " README: $AUTHELIA_DIR/README.md"
|
||
echo ""
|
||
}
|
||
|
||
# Adds a second (or third, etc.) independent apex domain to an EXISTING Authelia
|
||
# instance instead of standing up a whole separate Authelia+Redis stack for it.
|
||
# Authelia natively supports this: session.cookies and access_control.rules are
|
||
# both lists, so one instance can hold a distinct cookie scope + login portal per
|
||
# domain, each with its own session (no cross-domain SSO, but also no collision —
|
||
# see the "Running more than one Authelia instance" note in CLAUDE.md for why two
|
||
# domains can't just share one session.cookies entry). Far cheaper on RAM than a
|
||
# second full instance, which matters most on a small droplet.
|
||
add_authelia_domain() {
|
||
local AUTHELIA_DIR="$DOCKER_DIR/authelia"
|
||
local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml"
|
||
local CADDY_FILE="$DOCKER_DIR/caddy/Caddyfile"
|
||
|
||
if [ ! -f "$CONFIG_FILE" ]; then
|
||
log_warning "No configuration.yml found at $CONFIG_FILE — install Authelia first."
|
||
return 1
|
||
fi
|
||
|
||
echo ""
|
||
echo " Add another apex domain to this Authelia instance."
|
||
echo " It gets its own session-cookie scope and its own auth.<domain> portal —"
|
||
echo " a separate login/session from your other domain(s) — but shares this"
|
||
echo " same Authelia + Redis container, not a second full stack."
|
||
echo ""
|
||
local NEW_DOMAIN=""
|
||
prompt_text " New domain (e.g., example.com):" "" NEW_DOMAIN
|
||
if [ -z "$NEW_DOMAIN" ]; then
|
||
log_warning "No domain entered — nothing to do."
|
||
return 0
|
||
fi
|
||
|
||
if grep -qF "\"*.${NEW_DOMAIN}\"" "$CONFIG_FILE" 2>/dev/null; then
|
||
log_warning "$NEW_DOMAIN is already configured in $CONFIG_FILE — nothing to do."
|
||
return 0
|
||
fi
|
||
|
||
local NEW_PORTAL_SUBDOMAIN NEW_PORTAL_DOMAIN
|
||
prompt_text " Subdomain for this domain's own login portal (e.g. 'auth' -> auth.${NEW_DOMAIN}):" "auth" NEW_PORTAL_SUBDOMAIN
|
||
NEW_PORTAL_DOMAIN="${NEW_PORTAL_SUBDOMAIN}.${NEW_DOMAIN}"
|
||
|
||
# ── access_control.rules: insert right after "rules:" ────────────────────
|
||
awk -v domain="$NEW_DOMAIN" '
|
||
{ print }
|
||
/^ rules:$/ && !done {
|
||
print " - domain: \"*." domain "\""
|
||
print " policy: two_factor"
|
||
done=1
|
||
}
|
||
' "$CONFIG_FILE" > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE"
|
||
|
||
# ── session.cookies: insert right after "cookies:" ────────────────────────
|
||
awk -v domain="$NEW_DOMAIN" -v portal="$NEW_PORTAL_DOMAIN" '
|
||
{ print }
|
||
/^ cookies:$/ && !done {
|
||
print " - domain: " domain
|
||
print " authelia_url: https://" portal
|
||
print " default_redirection_url: https://" domain
|
||
done=1
|
||
}
|
||
' "$CONFIG_FILE" > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE"
|
||
|
||
chown 1000:1000 "$CONFIG_FILE" 2>/dev/null || true
|
||
log_success "Added $NEW_DOMAIN to $CONFIG_FILE (access_control rule + session cookie scope)"
|
||
|
||
# ── Caddy portal block for the new domain ─────────────────────────────────
|
||
if [ -f "$CADDY_FILE" ]; then
|
||
if ! grep -qx "${NEW_PORTAL_DOMAIN} {" "$CADDY_FILE"; then
|
||
cat >> "$CADDY_FILE" << CADDY_AUTH_BLOCK2
|
||
|
||
# ── Authelia login portal (${NEW_DOMAIN}) ─────────────────────────────────────
|
||
${NEW_PORTAL_DOMAIN} {
|
||
# See this instance's other portal block(s) above for why header_up
|
||
# X-Forwarded-Host is required here, not optional.
|
||
reverse_proxy authelia:9091 {
|
||
header_up X-Forwarded-Host {http.request.header.X-Forwarded-Host}
|
||
}
|
||
log {
|
||
output file /var/log/caddy/${NEW_PORTAL_DOMAIN}.log
|
||
}
|
||
}
|
||
CADDY_AUTH_BLOCK2
|
||
echo " ✓ Authelia portal block added for ${NEW_PORTAL_DOMAIN}"
|
||
docker ps --format '{{.Names}}' | grep -q "^caddy$" && \
|
||
{ docker exec -w /etc/caddy caddy caddy reload 2>/dev/null && echo " ✓ Caddy reloaded" || echo " ⚠ Reload manually: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"; }
|
||
else
|
||
echo " ✓ ${NEW_PORTAL_DOMAIN} portal block already exists in the Caddyfile"
|
||
fi
|
||
else
|
||
echo " ℹ Caddy not installed — add a ${NEW_PORTAL_DOMAIN} portal block manually later (see README)."
|
||
fi
|
||
|
||
# ── Restart Authelia to pick up the new config ────────────────────────────
|
||
local RESTART_AUTH=""
|
||
prompt_yn " Restart Authelia to apply the new domain? (y/n):" "y" RESTART_AUTH
|
||
if [ "$RESTART_AUTH" = "y" ] || [ "$RESTART_AUTH" = "Y" ]; then
|
||
(cd "$AUTHELIA_DIR" && docker compose restart authelia 2>/dev/null) \
|
||
&& log_success "Authelia restarted" \
|
||
|| log_warning "Restart failed — check: docker compose logs authelia"
|
||
fi
|
||
|
||
echo ""
|
||
echo " Auth portal for $NEW_DOMAIN: https://${NEW_PORTAL_DOMAIN}"
|
||
echo " Protect a service under this domain the same way as any other:"
|
||
echo " myservice.${NEW_DOMAIN} {"
|
||
echo " import authelia"
|
||
echo " reverse_proxy localhost:PORT"
|
||
echo " }"
|
||
echo " Same users/passwords work across every domain on this instance —"
|
||
echo " it's one shared user database, just separate sessions per domain."
|
||
echo ""
|
||
}
|
||
|
||
# Removes a login-portal Caddy block add_authelia_domain() writes — same
|
||
# bounded-block technique used elsewhere in this repo for Caddy site blocks
|
||
# (find the opening "<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
|
||
|
||
echo ""
|
||
echo " Domains currently on this Authelia instance:"
|
||
grep -oE '^ - domain: "\*\.[^"]+"' "$CONFIG_FILE" | sed -E 's/^ - domain: "\*\.(.+)"$/ - \1/'
|
||
echo ""
|
||
echo " Note: this removes a whole apex domain entry added via 'Add another"
|
||
echo " protected domain' — if you meant to protect a SUBDOMAIN of an apex"
|
||
echo " already listed above, you don't need this at all: it's already covered"
|
||
echo " by that apex's wildcard rule and session-cookie scope. Just point that"
|
||
echo " subdomain's Caddy block at this instance's existing auth.<apex> portal"
|
||
echo " instead of adding it here as its own entry."
|
||
echo ""
|
||
local RM_DOMAIN=""
|
||
prompt_text " Domain to remove (as shown above, e.g. example.com):" "" RM_DOMAIN
|
||
if [ -z "$RM_DOMAIN" ]; then
|
||
log_warning "No domain entered — nothing to do."
|
||
return 0
|
||
fi
|
||
|
||
if ! grep -qF "\"*.${RM_DOMAIN}\"" "$CONFIG_FILE" 2>/dev/null; then
|
||
log_warning "$RM_DOMAIN isn't configured on this instance — nothing to do."
|
||
return 0
|
||
fi
|
||
|
||
# Read the portal's own domain back from this apex's session.cookies
|
||
# entry — it's whatever subdomain was chosen when this domain was added
|
||
# (see add_authelia_domain), not necessarily "auth.<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 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
|
||
}
|
||
|
||
# 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.
|
||
#
|
||
# Args: SERVICE_ID DOMAIN
|
||
_authelia_scope_access() {
|
||
local service_id="$1" domain="$2"
|
||
local authelia_dir="$DOCKER_DIR/authelia"
|
||
local config_file="$authelia_dir/config/configuration.yml"
|
||
local users_file="$authelia_dir/config/users.yml"
|
||
|
||
[ -f "$config_file" ] || return 0
|
||
|
||
local group="${service_id}-only"
|
||
|
||
if grep -qF "subject: \"group:${group}\"" "$config_file" 2>/dev/null; then
|
||
log_info "Access to $domain is already scoped to group '$group'."
|
||
log_info "Manage its members via this menu's \"Edit an existing user\" (toggle their groups by hand in users.yml), or the universal-access report below."
|
||
return 0
|
||
fi
|
||
|
||
echo ""
|
||
echo " Who should be able to reach $domain via Authelia?"
|
||
echo " 1) Any Authelia user (default — same access as everything else)"
|
||
echo " 2) Specific users only"
|
||
local scope_choice=""
|
||
prompt_text " Choice [1/2]:" "1" scope_choice
|
||
[ "$scope_choice" = "2" ] || return 0
|
||
|
||
local -a existing_users
|
||
mapfile -t existing_users < <(_authelia_list_usernames "$users_file")
|
||
local i
|
||
if [ "${#existing_users[@]}" -gt 0 ]; then
|
||
echo " Existing Authelia users:"
|
||
for i in "${!existing_users[@]}"; do
|
||
echo " $((i + 1))) ${existing_users[$i]}"
|
||
done
|
||
echo " Pick by number (space-separated), and/or type new usernames directly"
|
||
echo " to create them — mix freely, e.g. \"1 3 newperson\"."
|
||
else
|
||
echo " No existing Authelia users yet — type usernames below to create them fresh."
|
||
fi
|
||
echo " Anyone typed (not picked by number) who doesn't already have an"
|
||
echo " Authelia account gets one created — you'll get their temporary"
|
||
echo " password to hand over."
|
||
local raw_users=""
|
||
prompt_text " Usernames/numbers:" "" raw_users
|
||
local -a raw_tokens usernames
|
||
read -ra raw_tokens <<< "$raw_users"
|
||
if [ "${#raw_tokens[@]}" -eq 0 ]; then
|
||
log_warning "No usernames entered — leaving $domain open to all Authelia users."
|
||
return 0
|
||
fi
|
||
local t
|
||
for t in "${raw_tokens[@]}"; do
|
||
if [[ "$t" =~ ^[0-9]+$ ]] && [ "$t" -ge 1 ] && [ "$t" -le "${#existing_users[@]}" ]; then
|
||
usernames+=("${existing_users[$((t - 1))]}")
|
||
else
|
||
usernames+=("$t")
|
||
fi
|
||
done
|
||
|
||
local u start_end start end
|
||
for u in "${usernames[@]}"; do
|
||
u="$(echo "$u" | tr -cs 'a-zA-Z0-9_-' '-' | sed 's/^-*//;s/-*$//')"
|
||
[ -z "$u" ] && continue
|
||
if grep -qE "^ ${u}:$" "$users_file" 2>/dev/null; then
|
||
start_end="$(_authelia_user_line_range "$users_file" "$u")"
|
||
start="${start_end% *}"; end="${start_end#* }"
|
||
_authelia_toggle_group "$users_file" "$start" "$end" "$group" "true"
|
||
log_success "Added '$u' to group '$group'"
|
||
else
|
||
local email_default="${u}@${SITE_DOMAIN:-example.com}"
|
||
if _authelia_create_user_noninteractive "$u" "$u" "$email_default" "$group"; then
|
||
echo " Temp password for '$u': $AUTHELIA_NEW_USER_TEMP_PASSWORD"
|
||
fi
|
||
fi
|
||
done
|
||
|
||
# Two rules, both above the general catch-all: allow this group on the
|
||
# target domain, deny this group on every other protected domain. Order
|
||
# matters — Authelia takes the first matching rule, so both must land
|
||
# before access_control's existing "*.${AUTHELIA_DOMAIN}" catch-all.
|
||
local authelia_domain
|
||
authelia_domain="$(awk '/^ cookies:$/{f=1; next} f && /domain:/{print $3; exit}' "$config_file")"
|
||
local scope_rules=" - domain: \"${domain}\"
|
||
subject: \"group:${group}\"
|
||
policy: two_factor
|
||
- domain: \"*.${authelia_domain}\"
|
||
subject: \"group:${group}\"
|
||
policy: deny"
|
||
|
||
awk -v block="$scope_rules" '
|
||
/^ rules:$/ && !done { print; print block; done=1; next }
|
||
{ print }
|
||
' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file"
|
||
chown 1000:1000 "$config_file" 2>/dev/null || true
|
||
|
||
local restart_auth=""
|
||
prompt_yn " Restart Authelia to apply this scoping? (y/n):" "y" restart_auth
|
||
if [[ "$restart_auth" =~ ^[Yy]$ ]]; then
|
||
(cd "$authelia_dir" && docker compose restart authelia 2>/dev/null) \
|
||
&& log_success "Authelia restarted — $domain is now restricted to group '$group'." \
|
||
|| log_warning "Restart failed — check: docker compose logs authelia"
|
||
fi
|
||
}
|
||
|
||
# Reporting/management: lists which users have "universal" access (every
|
||
# protected domain — anyone not locked into a "<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 " Universal access (every protected domain):"
|
||
local -a universal=() restricted=()
|
||
local u start_end start end groups_in_range
|
||
for u in "${all_users[@]}"; do
|
||
start_end="$(_authelia_user_line_range "$users_file" "$u")"
|
||
start="${start_end% *}"; end="${start_end#* }"
|
||
groups_in_range="$(sed -n "${start},${end}p" "$users_file" | grep -oE '\- [a-z0-9_-]+-only$' | sed 's/^- //')"
|
||
if [ -z "$groups_in_range" ]; then
|
||
universal+=("$u")
|
||
echo " - $u"
|
||
else
|
||
restricted+=("$u ($(echo "$groups_in_range" | tr '\n' ',' | sed 's/,$//'))")
|
||
fi
|
||
done
|
||
[ "${#universal[@]}" -eq 0 ] && echo " (none)"
|
||
|
||
echo ""
|
||
echo " Scoped to specific services only:"
|
||
if [ "${#restricted[@]}" -eq 0 ]; then
|
||
echo " (none)"
|
||
else
|
||
printf ' - %s\n' "${restricted[@]}"
|
||
fi
|
||
|
||
echo ""
|
||
local promote=""
|
||
prompt_yn " Promote a scoped user to universal access? (y/n):" "n" promote
|
||
[[ "$promote" =~ ^[Yy]$ ]] || return 0
|
||
|
||
local target=""
|
||
prompt_text " Username to promote:" "" target
|
||
[ -z "$target" ] && return 0
|
||
if ! grep -qE "^ ${target}:$" "$users_file" 2>/dev/null; then
|
||
log_warning "'$target' not found in $users_file."
|
||
return 0
|
||
fi
|
||
|
||
start_end="$(_authelia_user_line_range "$users_file" "$target")"
|
||
start="${start_end% *}"; end="${start_end#* }"
|
||
local -a target_groups
|
||
mapfile -t target_groups < <(sed -n "${start},${end}p" "$users_file" | grep -oE '\- [a-z0-9_-]+-only$' | sed 's/^- //')
|
||
if [ "${#target_groups[@]}" -eq 0 ]; then
|
||
log_info "'$target' already has universal access."
|
||
return 0
|
||
fi
|
||
local g
|
||
for g in "${target_groups[@]}"; do
|
||
_authelia_toggle_group "$users_file" "$start" "$end" "$g" "false"
|
||
done
|
||
log_success "'$target' removed from: ${target_groups[*]} — now has universal access."
|
||
|
||
local restart_auth=""
|
||
prompt_yn " Restart Authelia to apply? (y/n):" "y" restart_auth
|
||
if [[ "$restart_auth" =~ ^[Yy]$ ]]; then
|
||
(cd "$DOCKER_DIR/authelia" && docker compose restart authelia 2>/dev/null) \
|
||
&& log_success "Authelia restarted" \
|
||
|| log_warning "Restart failed — check: docker compose logs authelia"
|
||
fi
|
||
}
|
||
|
||
# Changes how long an Authelia session lasts when a user checks "Remember
|
||
# me" at login — the actual mechanism behind "log in once, don't get asked
|
||
# again for a long time" for every domain this instance protects.
|
||
#
|
||
# The config key is `remember_me` (plain, under session:), NOT
|
||
# `remember_me_duration` — that name was retired in Authelia 4.38, this
|
||
# repo pins 4.39.20. Confirmed against Authelia's own docs/changelog
|
||
# before writing this; an easy mistake since older guidance (including an
|
||
# earlier version of this very file's own README section) uses the old
|
||
# name, which Authelia would just silently ignore rather than error on.
|
||
#
|
||
# This only controls AUTHELIA's own session — it does not touch how long
|
||
# a native-OIDC app's (Gitea/Mealie/ActualBudget) own session/token lasts
|
||
# after logging in via Authelia. A long remember_me makes re-authenticating
|
||
# to Authelia itself instant/silent whenever one of those apps' own
|
||
# session expires and sends you back through the OIDC flow, but doesn't
|
||
# stop that app's own session from expiring on its own separate schedule.
|
||
_authelia_set_remember_me() {
|
||
local config_file="$DOCKER_DIR/authelia/config/configuration.yml"
|
||
[ -f "$config_file" ] || { log_warning "No configuration.yml found — install Authelia first."; return 1; }
|
||
|
||
local current
|
||
current="$(grep -E '^ remember_me:' "$config_file" | awk '{print $2}' | tr -d "'\"")"
|
||
echo ""
|
||
echo " Current \"remember me\" duration: ${current:-not set}"
|
||
echo " How long a session lasts when someone checks \"Remember me\" at login —"
|
||
echo " applies to every domain this Authelia instance protects."
|
||
echo " Examples: 12h, 7d, 1M (month), 1y. Set to -1 to disable Remember Me entirely."
|
||
local new_duration=""
|
||
prompt_text " New duration [${current:-7d}]:" "${current:-7d}" new_duration
|
||
if [ -z "$new_duration" ] || [ "$new_duration" = "$current" ]; then
|
||
log_info "No change made."
|
||
return 0
|
||
fi
|
||
|
||
if grep -qE '^ remember_me:' "$config_file"; then
|
||
sed -i "s/^ remember_me:.*/ remember_me: '${new_duration}'/" "$config_file"
|
||
else
|
||
sed -i "/^session:\$/a\\ remember_me: '${new_duration}'" "$config_file"
|
||
fi
|
||
chown 1000:1000 "$config_file" 2>/dev/null || true
|
||
log_success "\"Remember me\" duration set to ${new_duration}."
|
||
|
||
local restart_auth=""
|
||
prompt_yn " Restart Authelia to apply? (y/n):" "y" restart_auth
|
||
if [[ "$restart_auth" =~ ^[Yy]$ ]]; then
|
||
(cd "$DOCKER_DIR/authelia" && docker compose restart authelia 2>/dev/null) \
|
||
&& log_success "Authelia restarted" \
|
||
|| log_warning "Restart failed — check: docker compose logs authelia"
|
||
fi
|
||
|
||
echo ""
|
||
log_info "Takes effect for NEW logins where \"Remember me\" is checked at Authelia's"
|
||
log_info "login page — existing sessions keep whatever expiration they already had."
|
||
log_info "The checkbox itself is already on the login form by default; this only"
|
||
log_info "changes how long checking it actually keeps you signed in."
|
||
}
|
||
|
||
# action="exempt": inserts a "policy: one_factor / subject: user:<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 " blank to cancel."
|
||
local SEL=""
|
||
prompt_text " User number(s):" "" SEL
|
||
if [ -z "$SEL" ]; then
|
||
log_info "Cancelled."
|
||
return 0
|
||
fi
|
||
|
||
local -a SEL_TOKENS TARGETS=()
|
||
read -ra SEL_TOKENS <<< "$SEL"
|
||
local tok
|
||
for tok in "${SEL_TOKENS[@]}"; do
|
||
if [[ "$tok" =~ ^[0-9]+$ ]] && [ "$tok" -ge 1 ] && [ "$tok" -le "${#USERNAMES[@]}" ]; then
|
||
TARGETS+=("${USERNAMES[$((tok - 1))]}")
|
||
else
|
||
log_warning "Skipping invalid selection: $tok"
|
||
fi
|
||
done
|
||
|
||
local TARGET
|
||
for TARGET in "${TARGETS[@]}"; do
|
||
# A user picked earlier in this same batch may have just been
|
||
# deleted (or this number was picked twice) — re-check before
|
||
# acting instead of operating on a now-stale line range.
|
||
grep -qE "^ ${TARGET}:$" "$USERS_FILE" 2>/dev/null || { log_info "'$TARGET' no longer exists — skipping."; continue; }
|
||
_authelia_manage_one_user "$TARGET" "$USERS_FILE" "$CONFIG_FILE" "$AUTHELIA_DIR"
|
||
done
|
||
|
||
echo ""
|
||
prompt_yn " Manage more users? (y/n):" "n" KEEP_GOING
|
||
done
|
||
}
|
||
|
||
# Per-user action menu (edit/reset-password/2FA/admin/service-access/delete),
|
||
# extracted out of edit_authelia_user() so its caller can drive it once per
|
||
# selected user across a multi-user batch instead of only ever handling one
|
||
# user per script invocation.
|
||
_authelia_manage_one_user() {
|
||
local TARGET="$1" USERS_FILE="$2" CONFIG_FILE="$3" AUTHELIA_DIR="$4"
|
||
|
||
local CONTINUE="y"
|
||
while [[ "$CONTINUE" =~ ^[Yy]$ ]]; do
|
||
local RANGE START END DELETED=0
|
||
RANGE="$(_authelia_user_line_range "$USERS_FILE" "$TARGET")"
|
||
START="${RANGE% *}"; END="${RANGE#* }"
|
||
|
||
local IS_ADMIN="no"
|
||
sed -n "${START},${END}p" "$USERS_FILE" | grep -q '^ - admins$' && IS_ADMIN="yes"
|
||
local IS_EXEMPT="no"
|
||
[ -f "$CONFIG_FILE" ] && grep -qF "subject: \"user:${TARGET}\"" "$CONFIG_FILE" && IS_EXEMPT="yes"
|
||
|
||
echo ""
|
||
echo " Editing user: $TARGET (admin: $IS_ADMIN, 2FA-exempt: $IS_EXEMPT)"
|
||
echo " 1) Edit email / display name"
|
||
echo " 2) Reset password"
|
||
echo " 3) Reset 2FA device (they register a new one on next login)"
|
||
if [ "$IS_EXEMPT" = "yes" ]; then
|
||
echo " 4) Restore the 2FA requirement for this user"
|
||
else
|
||
echo " 4) Exempt this user from 2FA (one_factor only — weakens their account)"
|
||
fi
|
||
if [ "$IS_ADMIN" = "yes" ]; then
|
||
echo " 5) Demote from admin"
|
||
else
|
||
echo " 5) Promote to admin"
|
||
fi
|
||
echo " 6) Promote to (or remove from) a specific service's access group"
|
||
echo " 7) Delete this user"
|
||
echo " 8) Done with this user"
|
||
echo ""
|
||
local ACTION=""
|
||
prompt_text " Choice [1-8]:" "8" ACTION
|
||
|
||
case "$ACTION" in
|
||
1)
|
||
local CUR_EMAIL CUR_DISPLAY NEW_EMAIL NEW_DISPLAY
|
||
CUR_EMAIL="$(sed -n "${START},${END}p" "$USERS_FILE" | grep '^ email:' | sed 's/^ email: *//')"
|
||
CUR_DISPLAY="$(sed -n "${START},${END}p" "$USERS_FILE" | grep '^ displayname:' | sed 's/^ displayname: *//; s/^"//; s/"$//')"
|
||
prompt_text " New email [$CUR_EMAIL]:" "$CUR_EMAIL" NEW_EMAIL
|
||
prompt_text " New display name [$CUR_DISPLAY]:" "$CUR_DISPLAY" NEW_DISPLAY
|
||
_authelia_set_user_field "$USERS_FILE" "$START" "$END" "email" " email: ${NEW_EMAIL}"
|
||
_authelia_set_user_field "$USERS_FILE" "$START" "$END" "displayname" " displayname: \"${NEW_DISPLAY}\""
|
||
chown 1000:1000 "$USERS_FILE" 2>/dev/null || true
|
||
log_success "Updated $TARGET's email/display name."
|
||
;;
|
||
2)
|
||
log_info "Generating a new temporary password + hash..."
|
||
local NEW_TEMP_PASS NEW_HASH
|
||
NEW_TEMP_PASS="$(_authelia_gen_temp_password)"
|
||
NEW_HASH=$(docker run --rm authelia/authelia:4.39.20 \
|
||
authelia crypto hash generate argon2 --password "$NEW_TEMP_PASS" 2>/dev/null \
|
||
| grep -oP '(?<=Digest: ).*')
|
||
if [ -z "$NEW_HASH" ]; then
|
||
log_warning "Couldn't generate the password hash automatically — nothing changed. Try again."
|
||
else
|
||
_authelia_set_user_field "$USERS_FILE" "$START" "$END" "password" " password: \"${NEW_HASH}\""
|
||
chown 1000:1000 "$USERS_FILE" 2>/dev/null || true
|
||
log_success "Password reset for $TARGET."
|
||
echo " New password: ${NEW_TEMP_PASS}"
|
||
echo " Give this to them directly — shown once, not stored in plaintext anywhere."
|
||
fi
|
||
;;
|
||
3)
|
||
if docker ps --format '{{.Names}}' | grep -q '^authelia$'; then
|
||
if docker exec authelia authelia storage user totp delete "$TARGET" --config /config/configuration.yml 2>/dev/null; then
|
||
log_success "TOTP device reset for $TARGET — they'll register a new one on next login."
|
||
else
|
||
log_warning "No TOTP device found for $TARGET (or the delete failed) — check: docker compose logs authelia"
|
||
fi
|
||
echo " WebAuthn devices (if any) aren't covered by this option — reset those manually with:"
|
||
echo " docker exec authelia authelia storage user webauthn delete --username $TARGET --config /config/configuration.yml"
|
||
else
|
||
log_warning "Authelia isn't running — start it first: cd $AUTHELIA_DIR && docker compose up -d"
|
||
fi
|
||
;;
|
||
4)
|
||
if [ "$IS_EXEMPT" = "yes" ]; then
|
||
_authelia_set_2fa_exempt "$CONFIG_FILE" "$TARGET" "restore"
|
||
log_success "Restored the two_factor requirement for $TARGET."
|
||
else
|
||
local CONFIRM_EXEMPT=""
|
||
prompt_yn " $TARGET will be able to log in with just a password (no 2FA) on every domain this instance protects. Continue? (y/n):" "n" CONFIRM_EXEMPT
|
||
if [[ "$CONFIRM_EXEMPT" =~ ^[Yy]$ ]]; then
|
||
_authelia_set_2fa_exempt "$CONFIG_FILE" "$TARGET" "exempt"
|
||
log_success "$TARGET no longer needs 2FA (one_factor only)."
|
||
else
|
||
log_info "Left as-is."
|
||
fi
|
||
fi
|
||
;;
|
||
5)
|
||
if [ "$IS_ADMIN" = "yes" ]; then
|
||
_authelia_toggle_admin "$USERS_FILE" "$START" "$END" "false"
|
||
chown 1000:1000 "$USERS_FILE" 2>/dev/null || true
|
||
log_success "$TARGET demoted from admin."
|
||
else
|
||
_authelia_toggle_admin "$USERS_FILE" "$START" "$END" "true"
|
||
chown 1000:1000 "$USERS_FILE" 2>/dev/null || true
|
||
log_success "$TARGET promoted to admin."
|
||
fi
|
||
;;
|
||
6)
|
||
local -a SCOPED_GROUPS
|
||
mapfile -t SCOPED_GROUPS < <(_authelia_list_scoped_groups "$USERS_FILE")
|
||
if [ "${#SCOPED_GROUPS[@]}" -eq 0 ]; then
|
||
log_info "No service-scoped access groups exist yet — every protected domain is currently open to any Authelia user. A service gets a scoped group when it's first protected with Authelia SSO and \"Specific users only\" is chosen."
|
||
ACTION=""
|
||
else
|
||
echo ""
|
||
echo " Service-scoped access groups (* = $TARGET is currently a member):"
|
||
local gi grp member
|
||
for gi in "${!SCOPED_GROUPS[@]}"; do
|
||
grp="${SCOPED_GROUPS[$gi]}"
|
||
member=" "
|
||
sed -n "${START},${END}p" "$USERS_FILE" | grep -qF " - ${grp}" && member="*"
|
||
echo " $((gi + 1))) [${member}] ${grp%-only}"
|
||
done
|
||
echo ""
|
||
echo " Pick by number (space-separated) to toggle — a member gets removed,"
|
||
echo " a non-member gets added. Blank to leave unchanged."
|
||
local TOGGLE_SEL=""
|
||
prompt_text " Numbers:" "" TOGGLE_SEL
|
||
local -a TOGGLE_TOKENS
|
||
read -ra TOGGLE_TOKENS <<< "$TOGGLE_SEL"
|
||
local tk tidx tgrp t_start t_end t_range is_member
|
||
for tk in "${TOGGLE_TOKENS[@]}"; do
|
||
[[ "$tk" =~ ^[0-9]+$ ]] || continue
|
||
[ "$tk" -ge 1 ] && [ "$tk" -le "${#SCOPED_GROUPS[@]}" ] || continue
|
||
tidx=$((tk - 1))
|
||
tgrp="${SCOPED_GROUPS[$tidx]}"
|
||
# Re-resolve the user's line range before every toggle — a prior
|
||
# toggle in this same loop shifts every line after it, so reusing
|
||
# the outer START/END here would drift after the first change.
|
||
t_range="$(_authelia_user_line_range "$USERS_FILE" "$TARGET")"
|
||
t_start="${t_range% *}"; t_end="${t_range#* }"
|
||
is_member="false"
|
||
sed -n "${t_start},${t_end}p" "$USERS_FILE" | grep -qF " - ${tgrp}" && is_member="true"
|
||
if [ "$is_member" = "true" ]; then
|
||
_authelia_toggle_group "$USERS_FILE" "$t_start" "$t_end" "$tgrp" "false"
|
||
log_success "Removed $TARGET from '${tgrp}' (${tgrp%-only})"
|
||
else
|
||
_authelia_toggle_group "$USERS_FILE" "$t_start" "$t_end" "$tgrp" "true"
|
||
log_success "Added $TARGET to '${tgrp}' (${tgrp%-only})"
|
||
fi
|
||
done
|
||
chown 1000:1000 "$USERS_FILE" 2>/dev/null || true
|
||
fi
|
||
;;
|
||
7)
|
||
echo ""
|
||
log_warning "This permanently removes '$TARGET' from $USERS_FILE — they won't be able to log in again until re-added."
|
||
local CONFIRM_DELETE=""
|
||
prompt_yn " Delete user '$TARGET'? (y/n):" "n" CONFIRM_DELETE
|
||
if [[ "$CONFIRM_DELETE" =~ ^[Yy]$ ]]; then
|
||
_authelia_delete_user_block "$USERS_FILE" "$START" "$END"
|
||
chown 1000:1000 "$USERS_FILE" 2>/dev/null || true
|
||
log_success "Deleted user '$TARGET'."
|
||
DELETED=1
|
||
else
|
||
log_info "Left as-is."
|
||
fi
|
||
;;
|
||
*)
|
||
ACTION="8"
|
||
;;
|
||
esac
|
||
|
||
if [ "$DELETED" = "1" ]; then
|
||
local RESTART_AUTH=""
|
||
prompt_yn " Restart Authelia to apply this change? (y/n):" "y" RESTART_AUTH
|
||
if [ "$RESTART_AUTH" = "y" ] || [ "$RESTART_AUTH" = "Y" ]; then
|
||
(cd "$AUTHELIA_DIR" && docker compose restart authelia 2>/dev/null) \
|
||
&& log_success "Authelia restarted" \
|
||
|| log_warning "Restart failed — check: docker compose logs authelia"
|
||
fi
|
||
CONTINUE="n"
|
||
elif [[ "$ACTION" =~ ^[12456]$ ]]; then
|
||
local RESTART_AUTH=""
|
||
prompt_yn " Restart Authelia to apply this change? (y/n):" "y" RESTART_AUTH
|
||
if [ "$RESTART_AUTH" = "y" ] || [ "$RESTART_AUTH" = "Y" ]; then
|
||
(cd "$AUTHELIA_DIR" && docker compose restart authelia 2>/dev/null) \
|
||
&& log_success "Authelia restarted" \
|
||
|| log_warning "Restart failed — check: docker compose logs authelia"
|
||
fi
|
||
echo ""
|
||
prompt_yn " Do something else with $TARGET? (y/n):" "n" CONTINUE
|
||
else
|
||
CONTINUE="n"
|
||
fi
|
||
done
|
||
}
|
||
|
||
# Enables Authelia's OIDC PROVIDER feature — a distinct thing from the
|
||
# forward_auth (proxy-auth) setup install_authelia() already does. forward_auth
|
||
# gates a whole Caddy site behind an Authelia login page before the request
|
||
# ever reaches the app; OIDC provider mode is the opposite direction — an app
|
||
# with its OWN "Enable OpenID"/SSO setting (ActualBudget, Vaultwarden, etc.)
|
||
# delegates ITS login to Authelia instead of asking a user for a
|
||
# service-specific password. Neither replaces the other; a service can use
|
||
# either, both, or neither.
|
||
#
|
||
# One-time, idempotent (checked via the identity_providers: key already being
|
||
# present) — every _authelia_add_oidc_client() call runs this first so OIDC
|
||
# just works the first time an app is registered, no separate "enable OIDC"
|
||
# step to remember.
|
||
_authelia_ensure_oidc_provider() {
|
||
local AUTHELIA_DIR="$1"
|
||
local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml"
|
||
local SECRETS_DIR="$AUTHELIA_DIR/config/secrets"
|
||
|
||
grep -q '^identity_providers:' "$CONFIG_FILE" 2>/dev/null && return 0
|
||
|
||
log_info "Enabling Authelia's OIDC provider (one-time — lets other apps log in via Authelia)..."
|
||
|
||
# hmac_secret: injected via a _FILE env var in docker-compose.yml, same
|
||
# convention as jwt/session/storage secrets above — configuration.yml
|
||
# itself never holds this one as a raw string. "Random Value: <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 ""
|
||
local APP_CHOICE=""
|
||
prompt_text " Choice [1/2/3/4]:" "4" APP_CHOICE
|
||
|
||
local APP_NAME="" CLIENT_ID=""
|
||
local -a REDIRECT_PATHS=() EXTRA_REDIRECT_URIS=()
|
||
case "$APP_CHOICE" in
|
||
1) APP_NAME="ActualBudget"; CLIENT_ID="actualbudget"; REDIRECT_PATHS=("/openid/callback") ;;
|
||
2) APP_NAME="Vaultwarden"; CLIENT_ID="vaultwarden"; REDIRECT_PATHS=("/identity/connect/oidc-signin") ;;
|
||
3)
|
||
APP_NAME="Immich"; CLIENT_ID="immich"
|
||
REDIRECT_PATHS=("/auth/login" "/user-settings")
|
||
EXTRA_REDIRECT_URIS=("app.immich:///oauth-callback")
|
||
;;
|
||
*)
|
||
prompt_text " App name (for your reference):" "" APP_NAME
|
||
[ -z "$APP_NAME" ] && { log_warning "No app name entered — nothing to do."; return 0; }
|
||
CLIENT_ID="$(echo "$APP_NAME" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//;s/-*$//')"
|
||
prompt_text " Client ID [${CLIENT_ID}]:" "$CLIENT_ID" CLIENT_ID
|
||
echo " Check ${APP_NAME}'s own OIDC/SSO docs for its exact redirect URI path"
|
||
echo " (often something like /oauth/callback, /auth/callback, /sso/callback)."
|
||
local _redirect_path=""
|
||
prompt_text " Redirect URI path (starting with /):" "" _redirect_path
|
||
[ -n "$_redirect_path" ] && REDIRECT_PATHS=("$_redirect_path")
|
||
;;
|
||
esac
|
||
if [ -z "$CLIENT_ID" ] || { [ "${#REDIRECT_PATHS[@]}" -eq 0 ] && [ "${#EXTRA_REDIRECT_URIS[@]}" -eq 0 ]; }; then
|
||
log_warning "Missing client ID or redirect path — nothing to do."
|
||
return 0
|
||
fi
|
||
|
||
if grep -qF "client_id: '${CLIENT_ID}'" "$CONFIG_FILE" 2>/dev/null; then
|
||
log_warning "A client with ID '$CLIENT_ID' is already registered in $CONFIG_FILE."
|
||
log_warning "Pick a different app, or edit that entry by hand."
|
||
return 0
|
||
fi
|
||
|
||
local APP_DOMAIN_DEFAULT="" APP_DOMAIN=""
|
||
[ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ] && APP_DOMAIN_DEFAULT="${CLIENT_ID}.${SITE_DOMAIN}"
|
||
prompt_text " Domain ${APP_NAME} is reachable at [${APP_DOMAIN_DEFAULT:-required}]:" "$APP_DOMAIN_DEFAULT" APP_DOMAIN
|
||
if [ -z "$APP_DOMAIN" ]; then
|
||
log_warning "No domain entered — nothing to do."
|
||
return 0
|
||
fi
|
||
|
||
# Domain-relative paths (web login, account-linking, ...) plus any
|
||
# already-complete URIs that aren't domain-based (Immich's mobile app
|
||
# custom-scheme callback isn't reached over https at all).
|
||
local -a REDIRECT_URIS=()
|
||
local _p
|
||
for _p in "${REDIRECT_PATHS[@]}"; do
|
||
REDIRECT_URIS+=("https://${APP_DOMAIN}${_p}")
|
||
done
|
||
for _p in "${EXTRA_REDIRECT_URIS[@]}"; do
|
||
REDIRECT_URIS+=("$_p")
|
||
done
|
||
|
||
local _2fa="" AUTH_POLICY="two_factor"
|
||
prompt_yn " Require two-factor for ${APP_NAME} logins too? (y/n):" "y" _2fa
|
||
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
|
||
|
||
local RESTART_AUTH=""
|
||
prompt_yn " Restart Authelia to apply? (y/n):" "y" RESTART_AUTH
|
||
|
||
_authelia_provision_oidc_client "$APP_NAME" "$CLIENT_ID" "$AUTH_POLICY" "$RESTART_AUTH" "${REDIRECT_URIS[@]}" \
|
||
|| return 1
|
||
local CLIENT_SECRET_PLAIN="$OIDC_CLIENT_SECRET_PLAIN"
|
||
|
||
echo ""
|
||
echo " ${APP_NAME} is registered. Paste these into its OpenID/SSO settings"
|
||
echo " (choose \"Other\" as the provider if it's not listed by name):"
|
||
echo ""
|
||
echo " Client ID: ${CLIENT_ID}"
|
||
echo " Client Secret: ${CLIENT_SECRET_PLAIN}"
|
||
echo " Discovery URL: https://${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."
|
||
}
|
||
|
||
# Run immediately when executed directly (deferred until after function definition)
|
||
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_authelia
|