From ae7799286903f9ac93838afa41fdd7100b528dbf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 20:44:19 +0000 Subject: [PATCH 1/3] Wire the Security Dashboard into Authelia's per-user access scoping _authelia_scope_access() (services/authelia.sh) already works for any Authelia-protected service, forward_auth-gated or OIDC alike -- it's just never been called from security-dashboard.sh on either the domain-takeover path (_secdash_offer_asterisk_domain) or the plain separate-subdomain path, so every domain this dashboard ever protected defaulted to "any Authelia user", with no way to restrict it to specific people. That's why the Authelia menu's "Promote to a specific service's access group" reported no scoped groups existing yet even after protecting this dashboard with Authelia. _secdash_configure_caddy() now offers scoping right after the domain is Authelia-protected (guarded on EXTRA_BLOCK being non-empty, so Basic-Auth-only or no-auth setups aren't offered a scoping question for a gate that doesn't exist), guarded by declare -F for standalone runs where authelia.sh was never sourced. Runs whether the Caddy block was just freshly written or already existed, so re-running the installer on an already-configured domain still offers it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc --- services/security-dashboard.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index f0bb1cd..1cfacdb 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -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 From 098bb833cf9141e8d6887dee8ca712d681449917 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 20:55:14 +0000 Subject: [PATCH 2/3] Add a generic "protect a site with Authelia" menu action Every individual service so far offers its own "Protect X with Authelia SSO?" prompt on install, but there was no way to gate an arbitrary existing site from Authelia's own menu -- especially useful for a site on a DIFFERENT box's Caddy than the one Authelia runs on, this repo's own recurring case (a DigitalOcean droplet's site, protected by an Authelia instance on a separate IONOS box). New option 9, _authelia_protect_site(): lists this box's own local Caddy sites by number (flagging ones already protected), or accepts a typed domain that isn't on this box's Caddy at all. A local site gets "import authelia" inserted as the first line of its existing block -- before reverse_proxy, same ordering rule as everywhere else in this codebase, since Caddy runs directives in the order written and an auth check after reverse_proxy never runs at all. A remote site can't be edited from here, so it prints (and saves to caddy-snippets/) the remote-hop-safe forward_auth block that box's own Caddyfile needs instead, with the portal's actual domain read back from configuration.yml rather than assumed. Either way finishes by calling _authelia_scope_access for the domain, so protecting a site and restricting who can reach it happen in one pass. Verified the site-listing regex, insertion, idempotency detection, and remote-domain/portal lookup against synthetic Caddyfile/configuration.yml fixtures before shipping. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc --- services/authelia.sh | 153 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 2 deletions(-) diff --git a/services/authelia.sh b/services/authelia.sh index 806c2df..fc76aaf 100644 --- a/services/authelia.sh +++ b/services/authelia.sh @@ -236,10 +236,13 @@ install_authelia() { 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 " 9) 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 " 10) 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]:" "10" EXISTING_CHOICE case "$EXISTING_CHOICE" in 1) add_authelia_domain @@ -272,6 +275,10 @@ install_authelia() { _authelia_set_remember_me return 0 ;; + 9) + _authelia_protect_site + return 0 + ;; *) echo " Keeping existing Authelia. (Edit config/users.yml then: cd $AUTHELIA_DIR && docker compose restart authelia)" return 0 @@ -881,6 +888,148 @@ 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" +} + # 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 From b2b83948d8f5576c97c6b8a9a440f277aab634cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 19:41:47 +0000 Subject: [PATCH 3/3] Add site un-protect and OIDC client removal; fix a duplicate-ID dead end Three related fixes so undoing an Authelia change never requires hand-editing configuration.yml or the Caddyfile: - _authelia_add_oidc_client() had its own redundant duplicate-client-ID check that dead-ended with "pick a different app, or edit that entry by hand" -- even though _authelia_provision_oidc_client (called a few lines later in the same function) already handles that exact case safely by replacing the stale registration. Removed the redundant check; the flow now always reaches the safe path. This was the actual blocker in the reported "client with ID 'actualbudget' is already registered" error -- re-registering the same app a second time was never actually broken, just gated by dead code. - New option 6, _authelia_remove_oidc_client_menu(): lists registered OIDC clients by ID and name, removes one via the existing internal _authelia_remove_oidc_client() helper (previously only reachable from the replace-on-duplicate path, never exposed directly). - New option 11, _authelia_unprotect_site(): reverse of option 10 (_authelia_protect_site). Removes a local site's "import authelia" or forward_auth block from its own Caddy block and reloads Caddy; for a domain on a different box's Caddy, cleans up its access- scoping rules here (the actual gate needs removing on that box by hand, same one-way limitation option 10 already has in reverse). Also removes any _authelia_scope_access rules for the domain, found by the same "-only" group name convention, verified against a synthetic multi-domain configuration.yml before shipping so an unrelated domain's rules sharing the same "*." line are left untouched. Both the local-block removal (import authelia one-liner and multi-line forward_auth block shapes) and the access-rule removal were tested against realistic fixtures first. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc --- services/authelia.sh | 227 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 213 insertions(+), 14 deletions(-) diff --git a/services/authelia.sh b/services/authelia.sh index fc76aaf..e5de7b9 100644 --- a/services/authelia.sh +++ b/services/authelia.sh @@ -232,17 +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) Protect an existing site with this instance (pick a local Caddy site," + 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 " 10) Leave as-is" + 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/10]:" "10" 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 @@ -265,20 +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 ;; - 9) + 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 @@ -1030,6 +1041,132 @@ SNIPPET _authelia_scope_access "$(echo "$target_domain" | tr '.' '-')" "$target_domain" } +# Reverse of _authelia_protect_site — removes a local site's "import +# authelia" line from its own Caddy block (reloading Caddy), and the two +# access_control.rules entries _authelia_scope_access may have added for +# it, if any (found by the same "-only" group +# name _authelia_protect_site used). Each scoped rule is a 3-line +# "- domain: ...\n subject: ...\n policy: ..." block — bounded removal by +# buffering exactly 3 lines at a time from each " - domain:" line and +# only dropping the buffer if the group's subject line is inside it, so an +# unrelated rule sharing the same "*." domain line for a DIFFERENT +# group is untouched. +# +# Does NOT remove the user group itself from users.yml (a user's ["x-only"] +# membership with no matching rule left is inert, not a live grant) or +# touch a domain on a different box's Caddy (nothing here can edit that +# file) — for a remote site, this only cleans up the access rules on this +# side; removing the forward_auth block itself is a manual edit on the box +# that actually serves it. +_authelia_unprotect_site() { + local authelia_dir="$DOCKER_DIR/authelia" + local config_file="$authelia_dir/config/configuration.yml" + local caddy_file="$DOCKER_DIR/caddy/Caddyfile" + + if [ ! -f "$config_file" ]; then + log_warning "No configuration.yml found at $config_file — install Authelia first." + return 1 + fi + + local -a protected_domains + if [ -f "$caddy_file" ]; then + mapfile -t protected_domains < <( + grep -oE '^[A-Za-z0-9][A-Za-z0-9.-]*\.[A-Za-z]{2,} \{$' "$caddy_file" | sed 's/ {$//' | + while read -r d; do + sed -n "/^${d} {\$/,/^}/p" "$caddy_file" | grep -qE 'import authelia|forward_auth' && echo "$d" + done + ) + fi + + echo "" + echo " Un-protect a site (remove its Authelia gate)." + if [ "${#protected_domains[@]}" -gt 0 ]; then + echo " Currently-protected local Caddy sites:" + local i + for i in "${!protected_domains[@]}"; do + echo " $((i + 1))) ${protected_domains[$i]}" + done + else + echo " No locally-gated Caddy sites found." + fi + echo " Or type a domain directly — including one on a different box's Caddy, to" + echo " clean up its access-scoping rules here even though the gate itself lives" + echo " elsewhere and needs removing there by hand." + echo "" + 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 @@ -2218,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}" @@ -2308,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