Merge pull request #385 from outis1one/claude/frigate-authelia-openid-0l1htj

Claude/frigate authelia openid 0l1htj
This commit is contained in:
Outis
2026-08-23 19:30:26 -04:00
committed by GitHub
2 changed files with 374 additions and 12 deletions
+360 -12
View File
@@ -232,14 +232,20 @@ install_authelia() {
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 " 6) Remove a registered OIDC app (undoes option 5 for one app — its own"
echo " separate password login, if it has one, is untouched)"
echo " 7) Reconfigure from scratch (regenerates secrets/users — breaks"
echo " existing sessions for every domain already on this instance)"
echo " 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 " 8) Show who has universal vs. service-scoped access"
echo " 9) Change \"Remember me\" session duration (stay logged in longer)"
echo " 10) Protect an existing site with this instance (pick a local Caddy site,"
echo " or type one on a different box — gates it with a login, same as any"
echo " other service already protected this way)"
echo " 11) Un-protect a site (undoes option 10 for one site)"
echo " 12) Leave as-is"
echo ""
local EXISTING_CHOICE=""
prompt_text " Choice [1/2/3/4/5/6/7/8/9]:" "9" EXISTING_CHOICE
prompt_text " Choice [1/2/3/4/5/6/7/8/9/10/11/12]:" "12" EXISTING_CHOICE
case "$EXISTING_CHOICE" in
1)
add_authelia_domain
@@ -262,16 +268,28 @@ install_authelia() {
return 0
;;
6)
: # fall through to the full reinstall flow below
_authelia_remove_oidc_client_menu
return 0
;;
7)
: # fall through to the full reinstall flow below
;;
8)
_authelia_report_access_scope
return 0
;;
8)
9)
_authelia_set_remember_me
return 0
;;
10)
_authelia_protect_site
return 0
;;
11)
_authelia_unprotect_site
return 0
;;
*)
echo " Keeping existing Authelia. (Edit config/users.yml then: cd $AUTHELIA_DIR && docker compose restart authelia)"
return 0
@@ -881,6 +899,274 @@ remove_authelia_domain() {
fi
}
# Generalizes the "Protect X with Authelia SSO?" prompt individual services
# (magicmirror, wolf-pair, security-dashboard, etc.) each offer on their own
# install into one menu action here: pick any existing LOCAL Caddy site by
# number, or type a domain that's on a DIFFERENT box's Caddy entirely (this
# box only runs Authelia, not that site) — e.g. this repo's own case of a
# DigitalOcean droplet's site protected by an Authelia instance on a
# separate IONOS box.
#
# Local site: inserts "import authelia" as the very first line inside its
# existing block — must come before reverse_proxy, since Caddy runs
# directives in the order they're written and an auth check placed after
# reverse_proxy is dead code that never runs (full bypass, not an error;
# see lib/common.sh's configure_caddy_for_service for the fuller version of
# this warning). Idempotent: an already-protected site is flagged in the
# list and skips re-inserting a duplicate import, going straight to access
# scoping below.
#
# Remote site: this box can't edit a file on another machine, so it prints
# (and saves to caddy-snippets/, same convention as every other remote-
# Authelia caller in this repo) the forward_auth block that box's OWN
# Caddyfile needs instead — the remote-hop-safe form with a literal
# X-Forwarded-Host, not the {host} placeholder, for the header-rewrite
# reasons documented at length in CLAUDE.md and services/asterisk.sh.
#
# Either way, finishes by offering _authelia_scope_access for the domain —
# identical either way, since it only cares about the domain, not which box
# is actually enforcing the gate.
_authelia_protect_site() {
local authelia_dir="$DOCKER_DIR/authelia"
local config_file="$authelia_dir/config/configuration.yml"
local caddy_file="$DOCKER_DIR/caddy/Caddyfile"
if [ ! -f "$config_file" ]; then
log_warning "No configuration.yml found at $config_file — install Authelia first."
return 1
fi
local -a site_domains
if [ -f "$caddy_file" ]; then
mapfile -t site_domains < <(grep -oE '^[A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,} \{$' "$caddy_file" | sed 's/ {$//')
fi
echo ""
echo " Protect a site with this Authelia instance."
if [ "${#site_domains[@]}" -gt 0 ]; then
echo " Local Caddy sites on this box:"
local i d marker
for i in "${!site_domains[@]}"; do
d="${site_domains[$i]}"
marker=""
sed -n "/^${d} {\$/,/^}/p" "$caddy_file" | grep -qE 'import authelia|forward_auth' && marker=" (already protected)"
echo " $((i + 1))) ${d}${marker}"
done
else
echo " No local Caddy sites found."
fi
echo " Or type a domain directly — including one on a DIFFERENT box's Caddy"
echo " entirely (this box only needs to run Authelia itself for that to work)."
echo ""
local choice=""
prompt_text " Number or domain:" "" choice
if [ -z "$choice" ]; then
log_warning "Nothing entered — nothing to do."
return 0
fi
local target_domain="" is_local=false
if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#site_domains[@]}" ]; then
target_domain="${site_domains[$((choice - 1))]}"
is_local=true
else
target_domain="$choice"
[ -f "$caddy_file" ] && grep -qx "${target_domain} {" "$caddy_file" 2>/dev/null && is_local=true
fi
if [ "$is_local" = true ]; then
if sed -n "/^${target_domain} {\$/,/^}/p" "$caddy_file" | grep -qE 'import authelia|forward_auth'; then
log_info "${target_domain} is already protected — moving on to access scoping."
else
cp "$caddy_file" "$caddy_file.backup.$(date +%Y%m%d-%H%M%S)"
sed -i "/^${target_domain} {\$/a\\ import authelia" "$caddy_file"
log_success "Inserted 'import authelia' into ${target_domain}'s Caddy block."
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^caddy$"; then
if docker exec -w /etc/caddy caddy caddy reload 2>/dev/null; then
log_success "Caddy reloaded"
elif docker restart caddy &>/dev/null; then
log_success "Caddy restarted (reload API is disabled by default)"
else
log_warning "Reload/restart failed — check: docker logs caddy"
fi
fi
fi
else
# This instance's own portal — read back from the primary apex's
# session.cookies entry (the first one; same read-back pattern
# every other caller in this file uses). A domain on a different
# box isn't "added" to this instance the way add_authelia_domain's
# apex domains are — it's just gated by THIS instance's existing
# portal, same as any local site above, so there's no per-domain
# cookie entry of its own to read from.
local portal_domain
portal_domain="$(tr -d '\r' < "$config_file" | awk '/^ cookies:$/{f=1; next} f && /authelia_url:/{print $2; exit}' | sed -E 's#^https?://##')"
if [ -z "$portal_domain" ]; then
log_warning "Couldn't determine this instance's own portal domain from $config_file — aborting."
return 1
fi
echo ""
log_info "${target_domain} isn't on this box's own Caddy — add this to the OTHER box's"
log_info "Caddyfile instead (the one that actually serves ${target_domain}), BEFORE reverse_proxy:"
echo ""
echo " forward_auth https://${portal_domain} {"
echo " uri /api/authz/forward-auth"
echo " copy_headers Remote-User Remote-Groups Remote-Name Remote-Email"
echo " header_up X-Forwarded-Method {method}"
echo " header_up X-Forwarded-Proto {scheme}"
echo " header_up X-Forwarded-Host ${target_domain}"
echo " header_up X-Forwarded-Uri {uri}"
echo " }"
echo ""
log_warning "Must come BEFORE reverse_proxy in that block, not after — Caddy runs"
log_warning "directives in the order they're written, and an auth check placed after"
log_warning "reverse_proxy never runs at all (full bypass, not an error)."
local snippet_dir="$DOCKER_DIR/caddy-snippets"
mkdir -p "$snippet_dir"
cat > "$snippet_dir/${target_domain}-authelia.caddy" << SNIPPET
forward_auth https://${portal_domain} {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
header_up X-Forwarded-Method {method}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host ${target_domain}
header_up X-Forwarded-Uri {uri}
}
SNIPPET
chown "$ACTUAL_USER:$ACTUAL_USER" "$snippet_dir/${target_domain}-authelia.caddy" 2>/dev/null || true
log_success "Also saved: $snippet_dir/${target_domain}-authelia.caddy"
fi
_authelia_scope_access "$(echo "$target_domain" | tr '.' '-')" "$target_domain"
}
# Reverse of _authelia_protect_site — removes a local site's "import
# authelia" line from its own Caddy block (reloading Caddy), and the two
# access_control.rules entries _authelia_scope_access may have added for
# it, if any (found by the same "<domain-with-dots-as-hyphens>-only" group
# name _authelia_protect_site used). Each scoped rule is a 3-line
# "- domain: ...\n subject: ...\n policy: ..." block — bounded removal by
# buffering exactly 3 lines at a time from each " - domain:" line and
# only dropping the buffer if the group's subject line is inside it, so an
# unrelated rule sharing the same "*.<apex>" domain line for a DIFFERENT
# group is untouched.
#
# Does NOT remove the user group itself from users.yml (a user's ["x-only"]
# membership with no matching rule left is inert, not a live grant) or
# touch a domain on a different box's Caddy (nothing here can edit that
# file) — for a remote site, this only cleans up the access rules on this
# side; removing the forward_auth block itself is a manual edit on the box
# that actually serves it.
_authelia_unprotect_site() {
local authelia_dir="$DOCKER_DIR/authelia"
local config_file="$authelia_dir/config/configuration.yml"
local caddy_file="$DOCKER_DIR/caddy/Caddyfile"
if [ ! -f "$config_file" ]; then
log_warning "No configuration.yml found at $config_file — install Authelia first."
return 1
fi
local -a protected_domains
if [ -f "$caddy_file" ]; then
mapfile -t protected_domains < <(
grep -oE '^[A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,} \{$' "$caddy_file" | sed 's/ {$//' |
while read -r d; do
sed -n "/^${d} {\$/,/^}/p" "$caddy_file" | grep -qE 'import authelia|forward_auth' && echo "$d"
done
)
fi
echo ""
echo " Un-protect a site (remove its Authelia gate)."
if [ "${#protected_domains[@]}" -gt 0 ]; then
echo " Currently-protected local Caddy sites:"
local i
for i in "${!protected_domains[@]}"; do
echo " $((i + 1))) ${protected_domains[$i]}"
done
else
echo " No locally-gated Caddy sites found."
fi
echo " Or type a domain directly — including one on a different box's Caddy, to"
echo " clean up its access-scoping rules here even though the gate itself lives"
echo " elsewhere and needs removing there by hand."
echo ""
local choice=""
prompt_text " Number or domain (blank to cancel):" "" choice
if [ -z "$choice" ]; then
log_info "Cancelled — nothing changed."
return 0
fi
local target_domain="" is_local=false
if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#protected_domains[@]}" ]; then
target_domain="${protected_domains[$((choice - 1))]}"
is_local=true
else
target_domain="$choice"
[ -f "$caddy_file" ] && grep -qx "${target_domain} {" "$caddy_file" 2>/dev/null && is_local=true
fi
if [ "$is_local" = true ]; then
if sed -n "/^${target_domain} {\$/,/^}/p" "$caddy_file" | grep -qE 'import authelia|forward_auth'; then
cp "$caddy_file" "$caddy_file.backup.$(date +%Y%m%d-%H%M%S)"
sed -i "/^${target_domain} {\$/,/^}/{/^ *import authelia\$/d; /^ *forward_auth /,/^ *}\$/d}" "$caddy_file"
log_success "Removed the Authelia gate from ${target_domain}'s Caddy block."
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^caddy$"; then
if docker exec -w /etc/caddy caddy caddy reload 2>/dev/null; then
log_success "Caddy reloaded"
elif docker restart caddy &>/dev/null; then
log_success "Caddy restarted (reload API is disabled by default)"
else
log_warning "Reload/restart failed — check: docker logs caddy"
fi
fi
else
log_info "${target_domain} isn't currently gated — nothing to remove there."
fi
else
log_info "${target_domain} isn't on this box's own Caddy — only cleaning up its access"
log_info "rules here. Remove the forward_auth block from the box that actually serves"
log_info "it yourself (see $DOCKER_DIR/caddy-snippets/ if it was added via option 9)."
fi
local group="$(echo "$target_domain" | tr '.' '-')-only"
if grep -qF "subject: \"group:${group}\"" "$config_file" 2>/dev/null; then
awk -v grp="\"group:${group}\"" '
BEGIN { buf=""; n=0; hit=0 }
/^ - domain:/ {
if (n > 0) { if (!hit) printf "%s", buf; buf=""; n=0; hit=0 }
buf = $0 "\n"; n=1
if ($0 ~ grp) hit=1
next
}
n > 0 && n < 3 {
buf = buf $0 "\n"; n++
if ($0 ~ grp) hit=1
if (n == 3) { if (!hit) printf "%s", buf; buf=""; n=0; hit=0 }
next
}
{ if (n > 0) { if (!hit) printf "%s", buf; buf=""; n=0; hit=0 } print }
END { if (n > 0 && !hit) printf "%s", buf }
' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file"
chown 1000:1000 "$config_file" 2>/dev/null || true
log_success "Removed the '${group}' access-scoping rules — ${target_domain} is open to any"
log_success "Authelia user again (users' membership in '${group}' is left as harmless"
log_success "unused metadata — remove it by hand in users.yml if you want it fully gone)."
fi
local RESTART_AUTH=""
prompt_yn " Restart Authelia to apply? (y/n):" "y" RESTART_AUTH
if [ "$RESTART_AUTH" = "y" ] || [ "$RESTART_AUTH" = "Y" ]; then
(cd "$authelia_dir" && docker compose restart authelia 2>/dev/null) \
&& log_success "Authelia restarted" \
|| log_warning "Restart failed — check: docker compose logs authelia"
fi
}
# Picks "count" random characters from "charset" using an unbiased-enough
# per-byte modulo draw from /dev/urandom. Not part of lib/common.sh's shared
# generate_password (that one is deliberately alphanumeric-only — see its
@@ -2069,11 +2355,14 @@ _authelia_add_oidc_client() {
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
# No duplicate-ID check here — _authelia_provision_oidc_client below
# already handles that safely by replacing the stale registration (its
# old secret was shown once and is unrecoverable either way, so there's
# nothing to lose). An earlier version of this function dead-ended here
# instead ("pick a different app, or edit by hand") before ever
# reaching that safe path — confirmed live: this blocked re-registering
# ActualBudget after nothing more than a first attempt, with no way
# through except hand-editing configuration.yml.
local APP_DOMAIN_DEFAULT="" APP_DOMAIN=""
[ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ] && APP_DOMAIN_DEFAULT="${CLIENT_ID}.${SITE_DOMAIN}"
@@ -2159,5 +2448,64 @@ _authelia_add_oidc_client() {
log_warning "The Client Secret above is shown once — it isn't stored in plaintext anywhere. Save it now."
}
# Interactive wrapper around _authelia_remove_oidc_client (the internal
# helper _authelia_provision_oidc_client already uses to replace a stale
# registration) — exposes it as its own menu action so removing an app's
# OIDC client doesn't require hand-editing configuration.yml either. Lists
# every registered client's ID and display name, numbered; removing one
# doesn't affect that app's own separate password login (if it has one) or
# any other client on this instance.
_authelia_remove_oidc_client_menu() {
local AUTHELIA_DIR="$DOCKER_DIR/authelia"
local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml"
if [ ! -f "$CONFIG_FILE" ]; then
log_warning "No configuration.yml found at $CONFIG_FILE — install Authelia first."
return 1
fi
local -a client_ids client_names
mapfile -t client_ids < <(grep -oP "(?<=- client_id: ')[^']+" "$CONFIG_FILE")
mapfile -t client_names < <(grep -oP "(?<=client_name: ')[^']+" "$CONFIG_FILE")
if [ "${#client_ids[@]}" -eq 0 ]; then
log_info "No OIDC clients registered on this instance."
return 0
fi
echo ""
echo " Registered OIDC clients:"
local i
for i in "${!client_ids[@]}"; do
echo " $((i + 1))) ${client_ids[$i]} (${client_names[$i]:-unnamed})"
done
echo ""
local choice=""
prompt_text " Number to remove (blank to cancel):" "" choice
if [ -z "$choice" ] || ! [[ "$choice" =~ ^[0-9]+$ ]] || [ "$choice" -lt 1 ] || [ "$choice" -gt "${#client_ids[@]}" ]; then
log_info "Cancelled — nothing changed."
return 0
fi
local target_id="${client_ids[$((choice - 1))]}"
log_warning "This removes the OIDC client '${target_id}' — anyone using it to log into that app"
log_warning "via Authelia will no longer be able to until it's re-registered (option 5). It does"
log_warning "NOT touch that app's own separate password login, if it has one."
local confirm=""
prompt_yn " Continue? (y/n):" "n" confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { log_info "Cancelled — nothing changed."; return 0; }
_authelia_remove_oidc_client "$CONFIG_FILE" "$target_id"
log_success "Removed OIDC client '${target_id}'."
local RESTART_AUTH=""
prompt_yn " Restart Authelia to apply? (y/n):" "y" RESTART_AUTH
if [ "$RESTART_AUTH" = "y" ] || [ "$RESTART_AUTH" = "Y" ]; then
(cd "$AUTHELIA_DIR" && docker compose restart authelia 2>/dev/null) \
&& log_success "Authelia restarted" \
|| log_warning "Restart failed — check: docker compose logs authelia"
fi
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_authelia
+14
View File
@@ -814,6 +814,20 @@ CADDYBLOCK
log_warning "$SD_DOMAIN already in Caddyfile — leaving the existing entry alone."
fi
# Offer per-user access scoping now that this domain is actually
# Authelia-protected (local import or remote forward_auth — EXTRA_BLOCK
# is only non-empty when one of those was configured above; skip this
# entirely for Basic-Auth-only or no-auth setups, where there's no
# Authelia gate to scope). This dashboard was never wired into
# _authelia_scope_access before now, on either this domain-takeover path
# or the plain separate-subdomain path below it — every protected
# domain here defaulted to "any Authelia user", with no way to restrict
# it to specific people. Guarded by declare -F: this file can run
# standalone with authelia.sh never sourced.
if [ -n "$EXTRA_BLOCK" ] && declare -F _authelia_scope_access >/dev/null 2>&1; then
_authelia_scope_access "security-dashboard" "$SD_DOMAIN"
fi
# This port never needs to be open to the internet — only Caddy (local,
# via host.docker.internal) ever needs to reach it.
if command -v ufw &>/dev/null; then