From 879cb24d3b9699029ff6e6b0977b7e3731dcf84e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 16:08:33 +0000 Subject: [PATCH 1/2] Wire Authelia SSO into Immich, Audiobookshelf, and Beszel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _authelia_provision_oidc_client gains an optional PKCE flag (new 5th positional arg; every existing caller updated to pass "n", producing an identical client block to before) — Audiobookshelf and Beszel's own Authelia integration docs both require require_pkce/pkce_challenge_method, which Authelia doesn't turn on by default. immich.sh: _immich_offer_authelia_oidc() is real server-side automation, not just paste-in instructions — confirmed the exact system-config "oauth" JSON field names against Immich's own config-file.md and source (not guessed, closing out the "needs one more verification pass" note this repo's own CLAUDE.md already had on file). GET/PUT exchange the whole config object, so it round-trips everything else unchanged. Needs an admin API key that doesn't exist until first web-UI visit, so it's wired into both the fresh-install path and the "update" rerun path. audiobookshelf.sh, beszel.sh: both apps' OIDC config is UI-only (checked against audiobookshelf.org and beszel.dev directly — no config API or env var for the provider fields), so their new offers automate the Authelia side and print exact paste-in values. Beszel also gets a real, separate DISABLE_PASSWORD_AUTH/USER_CREATION toggle to fully replace its login, gated behind a warning to register a working account first. Also adds Audiobookshelf and Beszel as presets in authelia.sh's own generic "Register another app" menu, and updates CLAUDE.md's OIDC verification table to match reality (Immich now wired, Audiobookshelf was wrongly listed as "high-confidence no", Beszel added). --- CLAUDE.md | 6 +- services/actualbudget.sh | 2 +- services/audiobookshelf.sh | 56 +++++++++++++++ services/authelia.sh | 68 ++++++++++++++++-- services/beszel.sh | 77 ++++++++++++++++++++ services/gitea.sh | 2 +- services/immich.sh | 140 +++++++++++++++++++++++++++++++++++++ services/mealie.sh | 2 +- 8 files changed, 343 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fd75310..e9e76f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -467,12 +467,14 @@ right (Portainer, ntfy), not general familiarity with the product: |---|---|---| | `mealie` | Yes — wired up | Pure env vars (`OIDC_AUTH_ENABLED`, `OIDC_CLIENT_ID/SECRET`, `OIDC_CONFIGURATION_URL`), see `_mealie_offer_authelia_oidc()`. Redirect URI is `/login`. Needs a `--forwarded-allow-ips` entrypoint override when Caddy-fronted, or the generated redirect URI comes out `http://` even when actually served over `https://` — see the function's own comment. | | `actualbudget` | Yes — wired up | Pure env vars (`ACTUAL_OPENID_DISCOVERY_URL`, `ACTUAL_OPENID_CLIENT_ID/SECRET`, `ACTUAL_OPENID_SERVER_HOSTNAME`), see `_actualbudget_offer_authelia_oidc()`. Redirect path `/openid/callback` (matches the existing preset in `_authelia_add_oidc_client()`'s menu). First OIDC login becomes the server owner if none is set yet — Actual's own behavior. | -| `immich` | Yes, not yet wired up | Real OAuth2/OIDC settings under Administration → Settings, backed by a `system-config` API (GET/PUT) — confirmed the API exists, but didn't confirm the exact request payload shape needed to set OAuth fields specifically. Needs one more verification pass against the live OpenAPI spec before automating; don't guess the payload. | +| `immich` | Yes — wired up | Real OAuth2/OIDC settings under Administration → Settings, backed by `GET`/`PUT /api/system-config` — confirmed the exact JSON field names against Immich's own `config-file.md` and source directly (the `oauth` sub-object: `enabled`/`issuerUrl`/`clientId`/`clientSecret`/`scope`/`buttonText`, etc.), not guessed. See `_immich_offer_authelia_oidc()`. GET/PUT exchange the *whole* config object (no partial-patch endpoint), so it round-trips everything else — storage template, library settings — completely unchanged; the same shape already proven by `import-photos.sh`'s own storage-template step in this file. Needs an admin API key, which doesn't exist until the user creates their account on first web visit — this offer runs from both the fresh-install path (usually a no-op that first time) and the "update" rerun path, which is the realistic way most people finish this. | +| `audiobookshelf` | Yes — wired up (Authelia side only) | Checked against audiobookshelf.org's own OIDC docs: config is UI-only (Settings → Authentication), no env var or config API — so `_audiobookshelf_offer_authelia_oidc()` registers the Authelia client (needs PKCE, confirmed via authelia.com's own integration page for it) and prints the exact individual-endpoint values to paste in, since Audiobookshelf wants those rather than a discovery URL. Three redirect URIs: web callback, mobile-redirect, and the `audiobookshelf://oauth` app-scheme callback. | +| `beszel` | Yes — wired up (Authelia side only) | PocketBase-based; its OAuth2 provider is a PocketBase admin-UI setting (Settings → Auth providers), not an API — checked against beszel.dev directly. `_beszel_offer_authelia_oidc()` registers the Authelia client (also needs PKCE, per authelia.com's Beszel integration page) and prints the paste-in values. Separately offers the real, documented `DISABLE_PASSWORD_AUTH`/`USER_CREATION` env vars to fully replace Beszel's own login — gated behind an explicit warning to register a working account first, since Beszel has no default account and no signup-fallback if that hasn't happened yet. | | `jellyfin` | Only via a third-party plugin | No official native OIDC. Community plugins exist (`jellyfin-plugin-sso`, `jellyfin-plugin-oidc`) but are web-UI-only — native mobile/desktop Jellyfin clients can't use them. A bigger lift than an env-var toggle (plugin install via Jellyfin's own plugin repo system); hold off until that's worth doing deliberately. | | `homeassistant` | Only via a third-party HACS integration | No native core OIDC as of 2026 (open community discussion asking for it, not shipped). `hass-oidc-auth`/`hass-openid` exist as HACS-installed integrations — same "bigger lift" caveat as Jellyfin. | | `portainer` | No (CE) | OAuth/OIDC is a **Business Edition** feature — this repo installs `portainer-ce` (confirmed in `services/portainer.sh`), which doesn't have it. CE's documented path is fronting it with `oauth2-proxy`, i.e. no different from the forward_auth pattern any no-built-in-auth service already uses — not "native OIDC" in the sense this section means. | | `ntfy` | No | Checked ntfy's own config docs directly — no `auth-oauth2-*` keys exist. Only basic auth + access tokens + ACLs. (Worth a re-check on a future ntfy release if this matters to you — this class of feature does get added to self-hosted tools over time.) | -| `emby`, `audiobookshelf`, `meshcentral`, `traccar`, `uptimekuma`, `filebrowser`, `wg-easy` | Not individually re-verified | High-confidence no, based on general familiarity with each product rather than a fresh doc check this pass (unlike everything above, which was actually checked and in two cases contradicted assumption). Verify before wiring any of these in, the same way the checked ones were — don't extrapolate from this table's pattern. +| `emby`, `meshcentral`, `traccar`, `uptimekuma`, `filebrowser`, `wg-easy` | Not individually re-verified | High-confidence no, based on general familiarity with each product rather than a fresh doc check this pass (unlike everything above, which was actually checked and in two cases contradicted assumption). Verify before wiring any of these in, the same way the checked ones were — don't extrapolate from this table's pattern. **No built-in auth — should be protected:** `magicmirror`, `wolf-pair`, `js99er`, `drum-rhythm-game`, `iopaint`, diff --git a/services/actualbudget.sh b/services/actualbudget.sh index 928f34c..c54accd 100644 --- a/services/actualbudget.sh +++ b/services/actualbudget.sh @@ -252,7 +252,7 @@ _actualbudget_offer_authelia_oidc() { prompt_yn " Require two-factor for Actual Budget logins via Authelia too? (y/n):" "y" _2fa [[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor" - if ! _authelia_provision_oidc_client "ActualBudget" "actualbudget" "$AUTH_POLICY" "y" \ + if ! _authelia_provision_oidc_client "ActualBudget" "actualbudget" "$AUTH_POLICY" "y" "n" \ "https://${AB_OIDC_DOMAIN}/openid/callback"; then log_warning "Couldn't register Actual Budget as an OIDC client in Authelia — skipping SSO setup." return 0 diff --git a/services/audiobookshelf.sh b/services/audiobookshelf.sh index 27be645..f385064 100644 --- a/services/audiobookshelf.sh +++ b/services/audiobookshelf.sh @@ -198,6 +198,59 @@ fi register_service audiobookshelf media "Audiobook & podcast server (Audiobookshelf)" 13378 +# Offers to register Audiobookshelf as an Authelia OIDC client and prints +# exactly what to paste into its own settings — checked against +# audiobookshelf.org's own OIDC docs directly: config lives entirely in +# Settings -> Authentication in the app's UI, no env var or config API to +# automate the app side with (unlike Mealie/ActualBudget/Immich), so this +# only automates the Authelia half. Its own Authelia integration doc +# (authelia.com) requires PKCE — the fifth arg to +# _authelia_provision_oidc_client below. +_audiobookshelf_offer_authelia_oidc() { + declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0 + [ -d "$DOCKER_DIR/authelia" ] || return 0 + + echo "" + local USE_SSO="" + prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Audiobookshelf? (y/n):" "n" USE_SSO + [[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0 + + local APP_DOMAIN + APP_DOMAIN="$(_authelia_pick_domain "Domain Audiobookshelf is reachable at (number or domain)")" + if [ -z "$APP_DOMAIN" ]; then + log_warning "No domain entered — skipping SSO setup." + return 0 + fi + + local _2fa="" AUTH_POLICY="two_factor" + prompt_yn " Require two-factor for Audiobookshelf logins via Authelia too? (y/n):" "y" _2fa + [[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor" + + if ! _authelia_provision_oidc_client "Audiobookshelf" "audiobookshelf" "$AUTH_POLICY" "y" "y" \ + "https://${APP_DOMAIN}/auth/openid/callback" "https://${APP_DOMAIN}/auth/openid/mobile-redirect" "audiobookshelf://oauth"; then + log_warning "Couldn't register Audiobookshelf as an OIDC client in Authelia — skipping SSO setup." + return 0 + fi + + echo "" + echo " Audiobookshelf -> Settings -> Authentication -> enable OpenID Connect" + echo " Authentication, then fill in (it wants individual endpoints, not a" + echo " discovery URL):" + echo " Issuer URL: ${OIDC_AUTHELIA_PORTAL_URL}" + echo " Authorize URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/authorization" + echo " Token URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/token" + echo " Userinfo URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/userinfo" + echo " JWKS URL: ${OIDC_AUTHELIA_PORTAL_URL}/jwks.json" + echo " Client ID: audiobookshelf" + echo " Client Secret: $OIDC_CLIENT_SECRET_PLAIN" + echo " Signing Algorithm: RS256" + echo " Allowed Mobile Redirect URIs: audiobookshelf://oauth" + echo "" + log_warning "The Client Secret above is shown once — save it now." + + declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "audiobookshelf" "$APP_DOMAIN" +} + install_audiobookshelf() { require_docker || return 1 @@ -261,6 +314,7 @@ install_audiobookshelf() { ( cd "$ABS_DIR" && docker compose pull && docker compose up -d ) \ && log_success "Audiobookshelf image refreshed" \ || log_warning "Refresh failed — check: docker compose -f $ABS_DIR/docker-compose.yml logs" + _audiobookshelf_offer_authelia_oidc return 0 ;; cancel) @@ -358,6 +412,8 @@ ABS_ENV configure_caddy_for_service "Audiobookshelf${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:80" "audiobooks${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" + _audiobookshelf_offer_authelia_oidc + write_readme "$ABS_DIR" << MD # Audiobookshelf${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} diff --git a/services/authelia.sh b/services/authelia.sh index 9927a43..869db6e 100644 --- a/services/authelia.sh +++ b/services/authelia.sh @@ -2746,7 +2746,7 @@ _authelia_remove_oidc_client() { # 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 APP_NAME="$1" CLIENT_ID="$2" AUTH_POLICY="$3" RESTART_AUTH="$4" REQUIRE_PKCE="$5"; shift 5 local -a REDIRECT_URIS=("$@") OIDC_CLIENT_SECRET_PLAIN="" @@ -2841,11 +2841,27 @@ _authelia_provision_oidc_client() { REDIRECT_URIS_YAML="$(printf " - '%s'\n" "${REDIRECT_URIS[@]}")" REDIRECT_URIS_YAML="${REDIRECT_URIS_YAML%$'\n'}" + # PKCE lines are opt-in, not default — Authelia's own defaults for every + # other field here (client_secret_basic auth method for a confidential + # client, access_token_signed_response_alg: none) already match what + # Audiobookshelf/Beszel's own Authelia integration docs specify, but + # require_pkce defaults to false and has to be set explicitly for the + # apps that need it. Checked against authelia.com's own per-client + # integration pages for those two, not assumed — every existing caller + # (Mealie/ActualBudget/Vaultwarden/Gitea/Immich) passes "n" here and + # gets byte-for-byte the same client block as before this was added. + local PKCE_YAML="" + if [[ "$REQUIRE_PKCE" =~ ^[Yy]$ ]]; then + PKCE_YAML=" + require_pkce: true + pkce_challenge_method: 'S256'" + fi + local CLIENT_BLOCK=" - client_id: '${CLIENT_ID}' client_name: '${APP_NAME}' client_secret: '${CLIENT_SECRET_HASH}' public: false - authorization_policy: '${AUTH_POLICY}' + authorization_policy: '${AUTH_POLICY}'${PKCE_YAML} redirect_uris: ${REDIRECT_URIS_YAML} scopes: @@ -2922,13 +2938,17 @@ _authelia_add_oidc_client() { 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 " 5) Audiobookshelf (needs PKCE — checked against its own Authelia" + echo " integration docs, registered here automatically)" + echo " 6) Beszel (PocketBase-based — also needs PKCE; its own side is" + echo " configured in its Settings -> Auth providers page, not an API)" echo " 4) Other / custom app" echo " 0) Cancel" echo "" local APP_CHOICE="" - prompt_text " Choice [1-4, 0 to cancel]:" "0" APP_CHOICE + prompt_text " Choice [1-6, 0 to cancel]:" "0" APP_CHOICE - local APP_NAME="" CLIENT_ID="" + local APP_NAME="" CLIENT_ID="" REQUIRE_PKCE="n" local -a REDIRECT_PATHS=() EXTRA_REDIRECT_URIS=() case "$APP_CHOICE" in 1) APP_NAME="ActualBudget"; CLIENT_ID="actualbudget"; REDIRECT_PATHS=("/openid/callback") ;; @@ -2938,6 +2958,15 @@ _authelia_add_oidc_client() { REDIRECT_PATHS=("/auth/login" "/user-settings") EXTRA_REDIRECT_URIS=("app.immich:///oauth-callback") ;; + 5) + APP_NAME="Audiobookshelf"; CLIENT_ID="audiobookshelf"; REQUIRE_PKCE="y" + REDIRECT_PATHS=("/auth/openid/callback" "/auth/openid/mobile-redirect") + EXTRA_REDIRECT_URIS=("audiobookshelf://oauth") + ;; + 6) + APP_NAME="Beszel"; CLIENT_ID="beszel"; REQUIRE_PKCE="y" + REDIRECT_PATHS=("/api/oauth2-redirect") + ;; 4) prompt_text " App name (for your reference):" "" APP_NAME [ -z "$APP_NAME" ] && { log_warning "No app name entered — nothing to do."; return 0; } @@ -2994,7 +3023,7 @@ _authelia_add_oidc_client() { 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[@]}" \ + _authelia_provision_oidc_client "$APP_NAME" "$CLIENT_ID" "$AUTH_POLICY" "$RESTART_AUTH" "$REQUIRE_PKCE" "${REDIRECT_URIS[@]}" \ || return 1 local CLIENT_SECRET_PLAIN="$OIDC_CLIENT_SECRET_PLAIN" @@ -3047,6 +3076,35 @@ _authelia_add_oidc_client() { echo " for OAuth to work in both the browser and the Immich mobile app." echo "" ;; + 5) + echo " Audiobookshelf -> Settings -> Authentication -> enable OpenID Connect" + echo " Authentication, then fill in (checked against audiobookshelf.org's own" + echo " OIDC docs — it wants individual endpoints, not a discovery URL):" + echo " Issuer URL: https://${AUTHELIA_PORTAL_DOMAIN}" + echo " Authorize URL: https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/authorization" + echo " Token URL: https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/token" + echo " Userinfo URL: https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/userinfo" + echo " JWKS URL: https://${AUTHELIA_PORTAL_DOMAIN}/jwks.json" + echo " Client ID: ${CLIENT_ID}" + echo " Client Secret: ${CLIENT_SECRET_PLAIN}" + echo " Signing Algorithm: RS256" + echo " Allowed Mobile Redirect URIs: audiobookshelf://oauth" + echo "" + ;; + 6) + echo " Beszel is PocketBase-based — its OAuth2 provider is a PocketBase admin" + echo " setting, not an API this script can write (checked against beszel.dev's" + echo " own docs). In the hub: Settings -> Auth providers -> OpenID Connect:" + echo " Client ID: ${CLIENT_ID}" + echo " Client Secret: ${CLIENT_SECRET_PLAIN}" + echo " Auth URL: https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/authorization" + echo " Token URL: https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/token" + echo " User Info URL: https://${AUTHELIA_PORTAL_DOMAIN}/api/oidc/userinfo" + echo " Register your first Beszel account with a password BEFORE touching" + echo " DISABLE_PASSWORD_AUTH/USER_CREATION in its .env — flipping those before" + echo " a working login exists risks locking the hub's UI out entirely." + echo "" + ;; esac log_warning "The Client Secret above is shown once — it isn't stored in plaintext anywhere. Save it now." } diff --git a/services/beszel.sh b/services/beszel.sh index a613a82..1d8ddbf 100644 --- a/services/beszel.sh +++ b/services/beszel.sh @@ -251,6 +251,80 @@ _beszel_configure_agent() { || log_warning "Agent failed to start — check: docker compose -f $dir/docker-compose.yml logs beszel-agent" } +# Registers Beszel as an Authelia OIDC client and prints exactly what to +# paste into the hub's own settings. Checked against beszel.dev's own OAuth +# docs directly: Beszel is PocketBase-based, and its OAuth2 provider config +# is a PocketBase admin-UI setting (Settings -> Auth providers), not +# something exposed by any documented API or env var — so, like +# Audiobookshelf, this only automates the Authelia half. Beszel's own +# Authelia integration doc (authelia.com) requires PKCE. +# +# Args: DIR (the .env holding DISABLE_PASSWORD_AUTH/USER_CREATION lives there) +_beszel_offer_authelia_oidc() { + local dir="$1" + declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0 + [ -d "$DOCKER_DIR/authelia" ] || return 0 + + echo "" + local USE_SSO="" + prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Beszel? (y/n):" "n" USE_SSO + [[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0 + + local APP_DOMAIN + APP_DOMAIN="$(_authelia_pick_domain "Domain Beszel is reachable at (number or domain)")" + if [ -z "$APP_DOMAIN" ]; then + log_warning "No domain entered — skipping SSO setup." + return 0 + fi + + local _2fa="" AUTH_POLICY="two_factor" + prompt_yn " Require two-factor for Beszel logins via Authelia too? (y/n):" "y" _2fa + [[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor" + + if ! _authelia_provision_oidc_client "Beszel" "beszel" "$AUTH_POLICY" "y" "y" \ + "https://${APP_DOMAIN}/api/oauth2-redirect"; then + log_warning "Couldn't register Beszel as an OIDC client in Authelia — skipping SSO setup." + return 0 + fi + + echo "" + echo " In the hub: Settings -> Auth providers -> OpenID Connect:" + echo " Client ID: beszel" + echo " Client Secret: $OIDC_CLIENT_SECRET_PLAIN" + echo " Auth URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/authorization" + echo " Token URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/token" + echo " User Info URL: ${OIDC_AUTHELIA_PORTAL_URL}/api/oidc/userinfo" + echo "" + log_warning "The Client Secret above is shown once — save it now." + + declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "beszel" "$APP_DOMAIN" + + # DISABLE_PASSWORD_AUTH/USER_CREATION are real, documented env vars + # (beszel.dev's own OAuth guide) — but Beszel has no default account: + # the FIRST person to register becomes admin (see this file's own + # README section), and there's no signup-fallback like Mealie's + # OIDC_SIGNUP_ENABLED convenience wording to lean on if that hasn't + # happened yet. Off by default and gated behind an explicit warning — + # flipping this before a working login exists risks locking the hub's + # UI out with no account able to reach it at all. + echo "" + local _disable_local="" + prompt_yn " Also disable Beszel's own password login, so Authelia is the only way in? Only do this AFTER you've registered a working account. (y/n):" "n" _disable_local + if [[ "$_disable_local" =~ ^[Yy]$ ]]; then + local _auto_register="" + prompt_yn " Auto-create Beszel accounts for new Authelia logins? (y/n):" "n" _auto_register + sed -i '/^DISABLE_PASSWORD_AUTH=/d; /^USER_CREATION=/d' "$dir/.env" + { + echo "DISABLE_PASSWORD_AUTH=true" + [[ "$_auto_register" =~ ^[Yy]$ ]] && echo "USER_CREATION=true" + } >> "$dir/.env" + chown "$ACTUAL_USER:$ACTUAL_USER" "$dir/.env" 2>/dev/null || true + ( cd "$dir" && docker compose up -d beszel ) \ + && log_success "Beszel's own password login is now disabled — Authelia is the only way in." \ + || log_warning "Restart failed — check: docker compose -f $dir/docker-compose.yml logs beszel" + fi +} + install_beszel() { require_docker || return 1 log_info "Installing Beszel..." @@ -286,6 +360,7 @@ install_beszel() { prompt_yn " The agent was never connected — set it up now? (y/n):" "y" FINISH_AGENT [[ "$FINISH_AGENT" =~ ^[Yy]$ ]] && _beszel_configure_agent "$DIR" "http://localhost:${_WP} (or its Caddy domain, once configured)" fi + _beszel_offer_authelia_oidc "$DIR" return 0 ;; cancel) @@ -423,6 +498,8 @@ BESZEL_ENV _beszel_configure_agent "$DIR" "http://localhost:${WEB_PORT} (or its Caddy domain, once configured)" + _beszel_offer_authelia_oidc "$DIR" + write_readme "$DIR" << 'BESZEL_README' # Beszel — lightweight server + Docker monitoring diff --git a/services/gitea.sh b/services/gitea.sh index aedbd97..57bd147 100644 --- a/services/gitea.sh +++ b/services/gitea.sh @@ -214,7 +214,7 @@ _gitea_offer_authelia_sso() { prompt_yn " Require two-factor for Gitea logins via Authelia too? (y/n):" "y" _2fa [[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor" - if ! _authelia_provision_oidc_client "Gitea" "gitea" "$AUTH_POLICY" "y" \ + if ! _authelia_provision_oidc_client "Gitea" "gitea" "$AUTH_POLICY" "y" "n" \ "https://${GITEA_OIDC_DOMAIN}/user/oauth2/authelia/callback"; then log_warning "Couldn't register Gitea as an OIDC client in Authelia — skipping SSO setup." return 0 diff --git a/services/immich.sh b/services/immich.sh index a4eaff3..a31943a 100644 --- a/services/immich.sh +++ b/services/immich.sh @@ -201,6 +201,129 @@ fi register_service immich media "Self-hosted photo & video backup — like Google Photos (Immich)" 2283 +# Offers to wire Immich's own native OAuth support to Authelia — real +# server-side automation, not just paste-in instructions, unlike +# Audiobookshelf/Beszel below (neither exposes a config API; Immich does). +# Confirmed against docs.mealie.io's sibling page for Immich +# (docs.immich.app/administration/oauth) and, since that page doesn't +# document the underlying API, against Immich's own config-file.md and +# GitHub source directly for the exact JSON field names under the "oauth" +# key — not guessed. GET/PUT /api/system-config exchanges the WHOLE config +# object (there's no partial-patch endpoint), so this only ever touches the +# "oauth" sub-object and round-trips everything else completely unchanged +# — the same GET-modify-PUT shape already proven in this file for the +# storage-template step in import-photos.sh (search CURRENT_CONFIG above). +# +# Unlike Mealie/ActualBudget/Gitea, Immich's admin account isn't created by +# this installer — the user creates it themselves on first web visit (see +# "First launch" in the generated README) — so there's no API key to call +# with at the moment a FRESH install finishes. This is deliberately called +# from both the fresh-install path (where it'll usually just tell you to +# come back later) and the "update" rerun path (the realistic way most +# people actually complete this, once they have an account), same as +# _mealie_offer_authelia_oidc's own "works from either" design. +# +# Args: IMMICH_DIR WEB_PORT +_immich_offer_authelia_oidc() { + local DIR="$1" WEB_PORT="$2" + + [ -d "$DOCKER_DIR/authelia" ] || return 0 + declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0 + + echo "" + local USE_SSO="" + prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Immich? (y/n):" "n" USE_SSO + [[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0 + + echo " This writes Immich's OAuth settings for you via its own API — needs an" + echo " admin API key: Administration -> Settings -> API Keys -> New API Key" + echo " (Admin scope). Leave blank to skip for now — safe to come back to this" + echo " later by re-running 'sudo ./setup.sh immich' once you have one." + local IMMICH_API_KEY="" + prompt_text " Immich admin API key:" "" IMMICH_API_KEY + if [ -z "$IMMICH_API_KEY" ]; then + log_info "Skipped — no account/API key yet. Come back to this by re-running" + log_info "'sudo ./setup.sh immich' (choose \"Manage that install\" -> update)." + return 0 + fi + + local IMMICH_LOCAL_URL="http://localhost:${WEB_PORT}" + local VERIFY_CODE + VERIFY_CODE="$(curl -s -o /dev/null -w '%{http_code}' -H "x-api-key: $IMMICH_API_KEY" "$IMMICH_LOCAL_URL/api/users/me" 2>/dev/null)" + if [ "$VERIFY_CODE" != "200" ]; then + log_warning "Couldn't verify that API key against Immich (HTTP $VERIFY_CODE) — skipping SSO setup." + return 0 + fi + + local APP_DOMAIN + APP_DOMAIN="$(_authelia_pick_domain "Domain Immich is reachable at (number or domain)")" + if [ -z "$APP_DOMAIN" ]; then + log_warning "No domain entered — skipping SSO setup." + return 0 + fi + + local _2fa="" AUTH_POLICY="two_factor" + prompt_yn " Require two-factor for Immich logins via Authelia too? (y/n):" "y" _2fa + [[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor" + + # Same three redirect URIs as the "Immich" preset in authelia.sh's own + # generic OIDC menu (web login, account-linking, mobile app callback) — + # kept identical on purpose so either path produces the same client. + if ! _authelia_provision_oidc_client "Immich" "immich" "$AUTH_POLICY" "y" "n" \ + "https://${APP_DOMAIN}/auth/login" "https://${APP_DOMAIN}/user-settings" "app.immich:///oauth-callback"; then + log_warning "Couldn't register Immich as an OIDC client in Authelia — skipping SSO setup." + return 0 + fi + local _client_secret="$OIDC_CLIENT_SECRET_PLAIN" _portal_url="$OIDC_AUTHELIA_PORTAL_URL" + + local CURRENT_CONFIG + CURRENT_CONFIG="$(curl -s -H "x-api-key: $IMMICH_API_KEY" "$IMMICH_LOCAL_URL/api/system-config" 2>/dev/null)" + if [ -z "$CURRENT_CONFIG" ] || ! command -v python3 &>/dev/null; then + log_warning "Couldn't read Immich's system config — set OAuth manually instead:" + echo " Administration -> Settings -> OAuth Authentication" + echo " Issuer URL: ${_portal_url}" + echo " Client ID: immich" + echo " Client Secret: ${_client_secret}" + return 0 + fi + + # Secret/issuer are passed via env vars, not interpolated into the + # python source as string literals — Authelia's generated secret uses + # an rfc3986 charset that isn't guaranteed free of characters (a stray + # quote, say) that would otherwise break out of a quoted Python literal. + local UPDATED_CONFIG + UPDATED_CONFIG="$(echo "$CURRENT_CONFIG" | OIDC_SECRET="$_client_secret" OIDC_ISSUER="$_portal_url" python3 -c " +import sys, json, os +config = json.load(sys.stdin) +config['oauth']['enabled'] = True +config['oauth']['issuerUrl'] = os.environ['OIDC_ISSUER'] +config['oauth']['clientId'] = 'immich' +config['oauth']['clientSecret'] = os.environ['OIDC_SECRET'] +config['oauth']['scope'] = 'openid email profile' +config['oauth']['buttonText'] = 'Login with Authelia' +json.dump(config, sys.stdout) +" 2>/dev/null)" + if [ -z "$UPDATED_CONFIG" ]; then + log_warning "Couldn't parse Immich's config — set OAuth manually: Administration -> Settings -> OAuth Authentication" + echo " Issuer URL: ${_portal_url} Client ID: immich Client Secret: ${_client_secret}" + return 0 + fi + + local RESULT + RESULT="$(curl -s -o /dev/null -w '%{http_code}' -X PUT \ + -H "x-api-key: $IMMICH_API_KEY" -H "Content-Type: application/json" \ + "$IMMICH_LOCAL_URL/api/system-config" -d "$UPDATED_CONFIG" 2>/dev/null)" + if [ "$RESULT" = "200" ]; then + log_success "\"Sign in with Authelia\" enabled in Immich — local login still works too." + else + log_warning "Couldn't set Immich's OAuth config (HTTP $RESULT) — set it manually instead:" + echo " Administration -> Settings -> OAuth Authentication" + echo " Issuer URL: ${_portal_url} Client ID: immich Client Secret: ${_client_secret}" + fi + + declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "immich" "$APP_DOMAIN" +} + install_immich() { require_docker || return 1 @@ -279,6 +402,15 @@ install_immich() { ( cd "$IMMICH_DIR" && docker compose pull && docker compose up -d ) \ && log_success "Immich image refreshed" \ || log_warning "Refresh failed — check: docker compose -f $IMMICH_DIR/docker-compose.yml logs" + # WEB_PORT isn't persisted anywhere but the compose + # file's own port mapping — re-derive it here rather + # than assuming the "2283" default this local started + # with, which may not match if it was shifted at + # install time (collision avoidance / another instance). + local _EXISTING_PORT + _EXISTING_PORT="$(grep -oP '^\s+- "?\K[0-9]+(?=:2283)' "$IMMICH_DIR/docker-compose.yml" 2>/dev/null | head -1)" + [ -n "$_EXISTING_PORT" ] && WEB_PORT="$_EXISTING_PORT" + _immich_offer_authelia_oidc "$IMMICH_DIR" "$WEB_PORT" return 0 ;; cancel) @@ -886,6 +1018,14 @@ IMPORT_BODY configure_caddy_for_service "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${C_SERVER}:2283" "immich${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" + # Almost always a no-op on a truly fresh install — the admin account + # (and thus an API key) doesn't exist until the user visits the web UI + # for the first time, which hasn't happened yet at this point in the + # script. Still offered here for the rare case an instance is being + # reconfigured with credentials already in hand; the update rerun path + # above is the realistic way most people complete this. + _immich_offer_authelia_oidc "$IMMICH_DIR" "$WEB_PORT" + write_readme "$IMMICH_DIR" << MD # Immich${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} diff --git a/services/mealie.sh b/services/mealie.sh index c4668cb..11c89ab 100644 --- a/services/mealie.sh +++ b/services/mealie.sh @@ -272,7 +272,7 @@ _mealie_offer_authelia_oidc() { prompt_yn " Skip Mealie's login page entirely and jump straight to Authelia? (y/n):" "y" _auto_redirect fi - if ! _authelia_provision_oidc_client "Mealie" "mealie" "$AUTH_POLICY" "y" "${BASE_URL}/login"; then + if ! _authelia_provision_oidc_client "Mealie" "mealie" "$AUTH_POLICY" "y" "n" "${BASE_URL}/login"; then log_warning "Couldn't register Mealie as an OIDC client in Authelia — skipping SSO setup." return 0 fi From 49b965bd1dcead6874837d25067f94491a1ffeba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 16:32:34 +0000 Subject: [PATCH 2/2] Add group-first membership management (menu option 16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group membership was previously only editable per-user (option 4's user menu, option 6 toggles that one user's groups) — no way to pick a group and see/toggle its members directly. Adds the reverse entry point: pick a group, then toggle which users are in it. Same _authelia_toggle_group() underneath, just entered from the other direction. --- services/authelia.sh | 110 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/services/authelia.sh b/services/authelia.sh index 869db6e..566f058 100644 --- a/services/authelia.sh +++ b/services/authelia.sh @@ -248,10 +248,12 @@ install_authelia() { echo " safe to re-run any time)" echo " 14) Rename an outside-access group (e.g. \"customer1\" -> \"acme-corp\")" echo " 15) Show every group's sites and users (site groups + user groups overview)" + echo " 16) Add/remove users from a group (pick the group, then toggle members —" + echo " the reverse of option 4's per-user group toggle)" echo " 0) Leave as-is / exit" echo "" local EXISTING_CHOICE="" - prompt_text " Choice [1-15, 0 to exit]:" "0" EXISTING_CHOICE + prompt_text " Choice [1-16, 0 to exit]:" "0" EXISTING_CHOICE case "$EXISTING_CHOICE" in 1) add_authelia_domain @@ -312,6 +314,10 @@ install_authelia() { _authelia_report_groups return 0 ;; + 16) + _authelia_manage_group_membership + return 0 + ;; 0|*) echo " Keeping existing Authelia. (Edit config/users.yml then: cd $AUTHELIA_DIR && docker compose restart authelia)" return 0 @@ -1967,6 +1973,108 @@ _authelia_report_groups() { done } +# Group-first complement to _authelia_manage_one_user()'s option 6 (which is +# user-first: pick a user, then toggle which groups they're in). This is the +# other direction — pick a group, then toggle which users are in it — for +# when you know which group you want to populate and don't want to visit +# each user one at a time. Same _authelia_toggle_group() underneath either +# way; this is purely a different entry point onto the same membership data. +# +# A group only exists here once it's been attached to at least one site +# (via site protection's "Outside access" choice) — Authelia has no notion +# of a group that isn't referenced by an access rule or a user's +# membership, so there's no separate "create an empty group" step; naming +# a new group during site protection is what creates it. +_authelia_manage_group_membership() { + local users_file="$DOCKER_DIR/authelia/config/users.yml" + local config_file="$DOCKER_DIR/authelia/config/configuration.yml" + [ -f "$users_file" ] || { log_warning "No users.yml found — install Authelia first."; return 1; } + + local -a groups + mapfile -t groups < <(_authelia_list_scoped_groups "$users_file") + if [ "${#groups[@]}" -eq 0 ]; then + log_info "No outside-access groups exist yet. A group is created the first time you" + log_info "protect a site (option 10, or a service's own \"Add Sign in with Authelia\"" + log_info "offer) and choose \"Outside access\" instead of \"Native\" — name it there" + log_info "(e.g. \"customer1\"), and it'll show up here afterward to manage its members." + return 0 + fi + + echo "" + echo " Outside-access groups:" + local gi + for gi in "${!groups[@]}"; do + echo " $((gi + 1))) ${groups[$gi]%-only}" + done + echo " 0) Cancel" + local GROUP_CHOICE="" + prompt_text " Which group? [0]:" "0" GROUP_CHOICE + if ! [[ "$GROUP_CHOICE" =~ ^[0-9]+$ ]] || [ "$GROUP_CHOICE" -lt 1 ] || [ "$GROUP_CHOICE" -gt "${#groups[@]}" ]; then + log_info "Cancelled — nothing changed." + return 0 + fi + local GROUP="${groups[$((GROUP_CHOICE - 1))]}" + + local -a all_users + mapfile -t all_users < <(_authelia_list_usernames "$users_file") + if [ "${#all_users[@]}" -eq 0 ]; then + log_info "No users exist yet — add one first (this menu's \"Add a new user\")." + return 0 + fi + + echo "" + echo " Members of '${GROUP%-only}' (* = currently a member):" + local ui u start_end start end member + for ui in "${!all_users[@]}"; do + u="${all_users[$ui]}" + start_end="$(_authelia_user_line_range "$users_file" "$u")" + start="${start_end% *}"; end="${start_end#* }" + member=" " + sed -n "${start},${end}p" "$users_file" | grep -qF " - ${GROUP}" && member="*" + echo " $((ui + 1))) [${member}] ${u}" + done + echo "" + echo " Pick by number (space-separated) to toggle — a member gets removed, a" + echo " non-member gets added. 0 (or blank) to leave unchanged." + local TOGGLE_SEL="" + prompt_text " Numbers [0]:" "0" TOGGLE_SEL + local -a TOGGLE_TOKENS + read -ra TOGGLE_TOKENS <<< "$TOGGLE_SEL" + local tk tidx tu t_range t_start t_end is_member CHANGED=0 + for tk in "${TOGGLE_TOKENS[@]}"; do + [[ "$tk" =~ ^[0-9]+$ ]] || continue + [ "$tk" -ge 1 ] && [ "$tk" -le "${#all_users[@]}" ] || continue + tidx=$((tk - 1)) + tu="${all_users[$tidx]}" + # Re-resolve line range before every toggle — a prior toggle in this + # same loop shifts every line after it (see the equivalent comment + # in _authelia_manage_one_user's own group-toggle option). + t_range="$(_authelia_user_line_range "$users_file" "$tu")" + t_start="${t_range% *}"; t_end="${t_range#* }" + is_member="false" + sed -n "${t_start},${t_end}p" "$users_file" | grep -qF " - ${GROUP}" && is_member="true" + if [ "$is_member" = "true" ]; then + _authelia_toggle_group "$users_file" "$t_start" "$t_end" "$GROUP" "false" + log_success "Removed ${tu} from '${GROUP%-only}'" + else + _authelia_toggle_group "$users_file" "$t_start" "$t_end" "$GROUP" "true" + log_success "Added ${tu} to '${GROUP%-only}'" + fi + CHANGED=1 + done + + if [ "$CHANGED" = "1" ]; then + chown 1000:1000 "$users_file" 2>/dev/null || true + 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 + fi +} + # Menu-driven, idempotent bulk version of _authelia_ensure_admin_bypass — # backfills the admin-bypass rule for every apex domain currently on this # instance in one pass. install_authelia and add_authelia_domain bake the