Compare commits

...
5 Commits
Author SHA1 Message Date
Outis 60b5dd5f29 Merge pull request #360 from outis1one/claude/gitea-standalone-setup-oxoi2e
Claude/gitea standalone setup oxoi2e
2026-08-20 11:40:40 -04:00
Claude 2b6c06e060 authelia: automate "remember me" duration, fix stale config key in docs
Fixes two things found while answering a question about staying logged
in across every Authelia-protected service:

1. CLAUDE.md's own "stay logged in" instructions referenced
   remember_me_duration — renamed to remember_me in Authelia 4.38, this
   repo pins 4.39.20. Authelia doesn't error on an unknown key, it just
   silently ignores it, so following that guidance as written would have
   done nothing. install_authelia() itself already uses the correct
   `remember_me` key at install time (default 7d) and was never affected
   — only the hand-edit instructions in the docs were stale.

2. There was no way to change it afterward without hand-editing the file,
   contrary to this repo's own "no manual config editing" direction.
   Added _authelia_set_remember_me() (new menu option 7): prompts for a
   new duration (12h/7d/1M/1y/-1 to disable), writes it, restarts.

Tested the sed replacement against a synthetic session block before
trusting it on real config. Also documented clearly (both in the
function's own prompt and in CLAUDE.md) that this only controls
Authelia's own session — a native-OIDC app's own session/token expires
on its own separate schedule, which this setting doesn't touch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEQNc4NfBST1m9NtCZVYa8
2026-08-19 20:37:09 +00:00
Claude 699be5b4e3 authelia: make OIDC client registration self-healing on a stale client_id
Confirmed live: ActualBudget's new automated "Sign in with Authelia" offer
hit a client_id ("actualbudget") already registered from an earlier use of
the interactive "Register an app" menu — that older flow only registers
the client in Authelia and prints instructions to paste the secret into
the app's own settings manually; if that paste step never happened,
ActualBudget's .env never got the OIDC vars, but Authelia still considered
the client_id taken. _authelia_provision_oidc_client's duplicate check
just warned and returned 1, permanently blocking the automated offer with
no path forward — the stale registration's secret was shown once and
already gone, so there was nothing to recover, only reasons to replace it.

Added _authelia_remove_oidc_client() (tested against a synthetic
multi-client config, both mid-list and last-in-list removal) and changed
the duplicate-client_id check to remove-and-replace instead of failing.
Every automated caller (gitea/mealie/actualbudget's SSO offers) uses a
fixed, service-specific client_id, so a collision there means "this same
service was already registered," not a different app's ID being
clobbered. The interactive menu's own earlier duplicate check (a distinct
code path, one step before this one) is untouched — it still warns and
stops before prompting further, since a human-typed ID colliding with an
unrelated app is a different, more ambiguous situation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEQNc4NfBST1m9NtCZVYa8
2026-08-19 19:11:34 +00:00
Claude 3dca8dce1d mealie, actualbudget: add native "Sign in with Authelia" (OIDC)
Researched which of the "has built-in auth" services actually support
native OIDC before wiring anything in (checked live docs, not assumed) —
two services turned out to contradict general assumption: Portainer's
OAuth/OIDC is Business Edition only (this repo installs portainer-ce, which
doesn't have it), and ntfy has no auth-oauth2-* support at all despite it
seeming like the kind of thing a modern self-hosted tool would have added
by now. Full findings recorded in CLAUDE.md so this doesn't need
re-researching.

Two real, verified wins wired up, both entirely env-var driven — no
manual config file editing, matching this repo's "no manual wizard"
philosophy and reusing the exact _authelia_provision_oidc_client /
_authelia_scope_access machinery already built for Gitea:

- mealie: OIDC_AUTH_ENABLED/OIDC_CLIENT_ID/OIDC_CLIENT_SECRET/
  OIDC_CONFIGURATION_URL appended to the existing .env (env_file: .env is
  already how mealie.sh's compose reads it). Also adds a
  --forwarded-allow-ips entrypoint override when Caddy-fronted — confirmed
  against Mealie's own issue tracker that without it, the generated OIDC
  redirect URI comes out http:// even when actually served over https://,
  which providers reject as a scheme mismatch.
- actualbudget: ACTUAL_OPENID_DISCOVERY_URL/CLIENT_ID/CLIENT_SECRET/
  SERVER_HOSTNAME, same pattern. Redirect path (/openid/callback) matches
  the preset already used by authelia.sh's own "Register an app" menu for
  this same service.

Both offered on fresh installs and Update reruns, default no, and both
call _authelia_scope_access() afterward so access can be restricted to
specific users instead of every Authelia user, same as Gitea.

Immich has real OIDC + a system-config API but needs one more
verification pass on the exact request payload before automating — not
guessing that part. Jellyfin and Home Assistant only have third-party
plugin/HACS-based OIDC, a bigger lift than an env-var toggle — noted but
not attempted this pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEQNc4NfBST1m9NtCZVYa8
2026-08-19 18:44:00 +00:00
Claude ab6b554cd8 authelia: add reusable per-service access scoping (universal vs specific users)
Every domain with an access_control rule was reachable by any Authelia
user by default (the existing catch-all *.${AUTHELIA_DOMAIN} rule) — no
way to restrict a specific service to a subset of users without hand-
editing configuration.yml and users.yml directly.

_authelia_scope_access(SERVICE_ID, DOMAIN) is a new generic, reusable
helper: call it after any service finishes being protected by Authelia
(forward_auth gate or native OIDC alike — it only cares about the domain).
Offers universal vs. specific-users access; if scoped, creates a
"<service_id>-only" group, adds every listed username to it (creating
accounts on the fly for names that don't exist yet, via the new
_authelia_create_user_noninteractive — a non-interactive sibling to
add_authelia_user, same extraction pattern already used for
_authelia_provision_oidc_client), and inserts an allow+deny rule pair
above the general catch-all. Idempotent on rerun.

_authelia_report_access_scope() (new menu option 6) is the read side —
lists who has universal vs. service-scoped access, and offers to promote
a scoped user back to universal by removing their "-only" group
membership.

services/gitea.sh's _gitea_offer_authelia_sso() is the reference
integration, calling _authelia_scope_access after successfully wiring up
Gitea's OIDC login. The other services with a plain "Protect X with
Authelia?" prompt (magicmirror, wolf-pair, js99er, drum-rhythm-game,
iopaint, paintplus, stirling-pdf, wolf) are natural follow-ups once this
is confirmed working live — each just needs one added call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEQNc4NfBST1m9NtCZVYa8
2026-08-19 18:29:36 +00:00
5 changed files with 590 additions and 11 deletions
+80 -5
View File
@@ -390,6 +390,58 @@ existing login page. Reuse `_authelia_provision_oidc_client()` (guarded by
instead of duplicating Authelia's client-secret-generation/config-patching instead of duplicating Authelia's client-secret-generation/config-patching
logic again. logic again.
**Scoping a domain to specific users instead of every Authelia user.**
By default, any domain with an `access_control` rule at all is reachable by
every Authelia user (the existing catch-all `*.${AUTHELIA_DOMAIN}` rule).
`services/authelia.sh`'s `_authelia_scope_access(SERVICE_ID, DOMAIN)` is a
generic, reusable opt-in on top of that — call it right after *any* service
finishes being protected by Authelia, forward_auth gate or native OIDC
alike (it only cares about the domain, not the gating mechanism; see
`_gitea_offer_authelia_sso()` for the reference caller). Asks whether
access should stay universal or be scoped to specific usernames; if scoped,
creates a dedicated `<service_id>-only` group, adds every listed username
to it (creating accounts on the fly via
`_authelia_create_user_noninteractive()` for names that don't exist yet,
printing their temp password), and inserts two rules *above* the general
catch-all — allow that group on this domain, deny that group on every
other protected domain. Idempotent: reruns against an already-scoped
domain just report the existing group instead of duplicating rules.
Guard every cross-file call with `declare -F`, same convention as the OIDC
helper above — a service can run standalone with authelia.sh never sourced.
`_authelia_report_access_scope()` (menu option 6 on an existing Authelia
install) is the read side: lists who has universal access versus who's
scoped to which service(s), and offers to promote a scoped user back to
universal by removing them from their `-only` group(s) — a pure users.yml
edit, since universal access is just the *absence* of a restricting group,
not a rule of its own.
`services/gitea.sh`, `services/mealie.sh`, and `services/actualbudget.sh`
call `_authelia_scope_access()` so far. The other services that already
offer a plain "Protect X with Authelia SSO?" prompt (`magicmirror`,
`wolf-pair`, `js99er`, `drum-rhythm-game`, `iopaint`, `paintplus`,
`stirling-pdf`, `wolf`) are natural, mechanical follow-ups — each just
needs one added call to `_authelia_scope_access` after its existing
`configure_caddy_for_service` step, once Gitea's integration has been
confirmed working live.
**Native OIDC support across the "has built-in auth" list — checked
against each app's real docs (2026-08), not assumed.** Don't extend this
pattern to a service without checking its own current settings first —
two of the ones below turned out to need actual verification to get
right (Portainer, ntfy), not general familiarity with the product:
| Service | Native OIDC? | Notes |
|---|---|---|
| `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 `<BASE_URL>/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. |
| `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.
**No built-in auth — should be protected:** **No built-in auth — should be protected:**
`magicmirror`, `wolf-pair`, `js99er`, `drum-rhythm-game`, `iopaint`, `magicmirror`, `wolf-pair`, `js99er`, `drum-rhythm-game`, `iopaint`,
`paintplus`, `stirling-pdf`, `wolf` (web UI). Each of these prompts `paintplus`, `stirling-pdf`, `wolf` (web UI). Each of these prompts
@@ -424,22 +476,45 @@ configure_caddy_for_service "MagicMirror" "8081" "mirror" "$EXTRA_BLOCK"
``` ```
**Authelia "stay logged in" / kiosk mode:** **Authelia "stay logged in" / kiosk mode:**
Edit `~/docker/authelia/config/configuration.yml` and set a long `install_authelia()` already writes `remember_me: 7d` into
`remember_me_duration`. Users then check "Remember me" once on login and `configuration.yml` at install time — the checkbox is on the login form
the session persists through reboots (Redis stores the session in a volume): from day one, this is only about how long checking it actually lasts.
To change the duration later, use the menu instead of hand-editing the
file: re-run `sudo ./setup.sh authelia` against an existing install and
pick **"Change 'remember me' session duration"** (`_authelia_set_remember_me()`
in `services/authelia.sh`) — prompts for a new duration (`12h`, `7d`,
`1M`, `1y`, or `-1` to disable Remember Me entirely) and restarts.
Sessions persist through reboots regardless of duration (Redis stores
session state in a volume).
**The config key is `remember_me`, not `remember_me_duration`.** Authelia
renamed it in 4.38; this repo pins `4.39.20`. A stale `remember_me_duration`
key doesn't error, Authelia just silently ignores it — confirmed against
Authelia's own docs/changelog after this file's own example used the old
name for a while without anyone noticing, since nothing here actually
reads it back to verify the write took effect. If you ever do need to
touch this by hand instead of the menu option, the current schema is:
```yaml ```yaml
session: session:
secret: 'your-existing-secret' secret: 'your-existing-secret'
remember_me_duration: 1y # add or update this line
expiration: 1h expiration: 1h
inactivity: 5m inactivity: 5m
remember_me: 1y
cookies: cookies:
- domain: 'example.com' - domain: 'example.com'
authelia_url: 'https://auth.example.com' authelia_url: 'https://auth.example.com'
``` ```
After editing: `docker compose -f ~/docker/authelia/docker-compose.yml restart` **This only covers Authelia's own session.** A native-OIDC app
(`gitea`/`mealie`/`actualbudget`) issues its own separate session/token
after logging in via Authelia, with its own independent expiry — a long
`remember_me` makes re-authenticating to Authelia itself instant/silent
whenever that app's own session expires and bounces you back through the
OIDC flow, but it doesn't stop that app's session from expiring on its
own schedule. If a native-OIDC app logs users out sooner than expected,
that app's own session-length setting (if it exposes one) is the other
thing to check, not this one.
## Non-Docker services ## Non-Docker services
+71
View File
@@ -198,6 +198,73 @@ fi
register_service actualbudget utilities "Open-source personal finance & budgeting (Actual Budget)" 5006 register_service actualbudget utilities "Open-source personal finance & budgeting (Actual Budget)" 5006
# Offers to add "Sign in with Authelia" (OpenID Connect) to Actual Budget's
# own login page — same additive pattern as services/gitea.sh's
# _gitea_offer_authelia_sso, entirely environment-variable driven like
# services/mealie.sh's equivalent. Confirmed against Actual Budget's own
# OIDC docs: ACTUAL_OPENID_DISCOVERY_URL, ACTUAL_OPENID_CLIENT_ID,
# ACTUAL_OPENID_CLIENT_SECRET, ACTUAL_OPENID_SERVER_HOSTNAME, appended
# into the .env file this installer already writes and reads via
# `env_file: .env`. Redirect path (/openid/callback) matches the preset
# already used by services/authelia.sh's own "Register an app" menu for
# this same app, so both stay consistent with each other.
#
# No stored BASE_URL to read back here (unlike Mealie) — Actual Budget's
# compose/.env never records the public URL, so this asks for the domain
# directly instead, same as services/gitea.sh's SSO offer does.
#
# Args: DIR
_actualbudget_offer_authelia_oidc() {
local DIR="$1"
[ -d "$DOCKER_DIR/authelia" ] || return 0
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
grep -q '^ACTUAL_OPENID_DISCOVERY_URL=' "$DIR/.env" 2>/dev/null && return 0
echo ""
local USE_SSO=""
prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Actual Budget's login page? (y/n):" "n" USE_SSO
[[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0
local _default_domain=""
[ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ] && _default_domain="budget.${SITE_DOMAIN}"
local AB_OIDC_DOMAIN=""
prompt_text " Domain Actual Budget is reachable at [${_default_domain:-required}]:" "$_default_domain" AB_OIDC_DOMAIN
if [ -z "$AB_OIDC_DOMAIN" ]; then
log_warning "No domain entered — skipping Authelia SSO for Actual Budget."
return 0
fi
local _2fa="" AUTH_POLICY="two_factor"
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" \
"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
fi
local _discovery_url="https://auth.${OIDC_AUTHELIA_DOMAIN}/.well-known/openid-configuration"
cat >> "$DIR/.env" << ENV
# Written by services/actualbudget.sh's Authelia SSO step. The first OIDC
# login becomes the Actual Budget server owner if no owner is set yet —
# that's Actual Budget's own behavior, not something this script controls.
ACTUAL_OPENID_DISCOVERY_URL=$_discovery_url
ACTUAL_OPENID_CLIENT_ID=actualbudget
ACTUAL_OPENID_CLIENT_SECRET=$OIDC_CLIENT_SECRET_PLAIN
ACTUAL_OPENID_SERVER_HOSTNAME=https://${AB_OIDC_DOMAIN}
ENV
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/.env" 2>/dev/null || true
(cd "$DIR" && docker compose up -d) \
&& log_success "\"Sign in with Authelia\" added to Actual Budget — local login still works too." \
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "actualbudget" "$AB_OIDC_DOMAIN"
}
install_actualbudget() { install_actualbudget() {
require_docker || return 1 require_docker || return 1
@@ -216,6 +283,7 @@ install_actualbudget() {
echo " - Create \$DOCKER_DIR/actualbudget(-<name>) with docker-compose.yml (data/)" echo " - Create \$DOCKER_DIR/actualbudget(-<name>) with docker-compose.yml (data/)"
echo " - Auto-scan for a free host port if this is an additional instance" echo " - Auto-scan for a free host port if this is an additional instance"
echo " - Offer a Caddy reverse proxy and to start the container" echo " - Offer a Caddy reverse proxy and to start the container"
echo " - Offer \"Sign in with Authelia\" (OIDC) if Authelia is installed"
return 0 return 0
fi fi
@@ -258,6 +326,7 @@ install_actualbudget() {
( cd "$AB_DIR" && docker compose pull && docker compose up -d ) \ ( cd "$AB_DIR" && docker compose pull && docker compose up -d ) \
&& log_success "Actual Budget image refreshed" \ && log_success "Actual Budget image refreshed" \
|| log_warning "Refresh failed — check: docker compose -f $AB_DIR/docker-compose.yml logs" || log_warning "Refresh failed — check: docker compose -f $AB_DIR/docker-compose.yml logs"
declare -F _actualbudget_offer_authelia_oidc >/dev/null 2>&1 && _actualbudget_offer_authelia_oidc "$AB_DIR"
return 0 return 0
;; ;;
cancel) cancel)
@@ -333,6 +402,8 @@ AB_ENV
configure_caddy_for_service "ActualBudget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:5006" "budget${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" configure_caddy_for_service "ActualBudget${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:5006" "budget${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
declare -F _actualbudget_offer_authelia_oidc >/dev/null 2>&1 && _actualbudget_offer_authelia_oidc "$AB_DIR"
write_readme "$AB_DIR" << MD write_readme "$AB_DIR" << MD
# Actual Budget${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} # Actual Budget${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
+356 -6
View File
@@ -232,10 +232,12 @@ install_authelia() {
echo " Vaultwarden, or any other app with its own \"Enable OpenID\" setting)" echo " Vaultwarden, or any other app with its own \"Enable OpenID\" setting)"
echo " 5) Reconfigure from scratch (regenerates secrets/users — breaks" echo " 5) Reconfigure from scratch (regenerates secrets/users — breaks"
echo " existing sessions for every domain already on this instance)" echo " existing sessions for every domain already on this instance)"
echo " 6) Leave as-is" echo " 6) Show who has universal vs. service-scoped access"
echo " 7) Change \"Remember me\" session duration (stay logged in longer)"
echo " 8) Leave as-is"
echo "" echo ""
local EXISTING_CHOICE="" local EXISTING_CHOICE=""
prompt_text " Choice [1/2/3/4/5/6]:" "6" EXISTING_CHOICE prompt_text " Choice [1/2/3/4/5/6/7/8]:" "8" EXISTING_CHOICE
case "$EXISTING_CHOICE" in case "$EXISTING_CHOICE" in
1) 1)
add_authelia_domain add_authelia_domain
@@ -256,6 +258,14 @@ install_authelia() {
5) 5)
: # fall through to the full reinstall flow below : # fall through to the full reinstall flow below
;; ;;
6)
_authelia_report_access_scope
return 0
;;
7)
_authelia_set_remember_me
return 0
;;
*) *)
echo " Keeping existing Authelia. (Edit config/users.yml then: cd $AUTHELIA_DIR && docker compose restart authelia)" echo " Keeping existing Authelia. (Edit config/users.yml then: cd $AUTHELIA_DIR && docker compose restart authelia)"
return 0 return 0
@@ -915,6 +925,310 @@ _authelia_toggle_admin() {
fi fi
} }
# 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
echo " Usernames who should have access (space-separated). Anyone listed"
echo " who doesn't already have an Authelia account gets one created —"
echo " you'll get their temporary password to hand over."
local raw_users=""
prompt_text " Usernames:" "" raw_users
local -a usernames
read -ra usernames <<< "$raw_users"
if [ "${#usernames[@]}" -eq 0 ]; then
log_warning "No usernames entered — leaving $domain open to all Authelia users."
return 0
fi
local u start_end start end
for u in "${usernames[@]}"; do
u="$(echo "$u" | tr -cs 'a-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 # action="exempt": inserts a "policy: one_factor / subject: user:<name>" rule
# immediately before EVERY plain "policy: two_factor" catch-all domain rule in # immediately before EVERY plain "policy: two_factor" catch-all domain rule in
# configuration.yml (handles multi-domain instances from add_authelia_domain # configuration.yml (handles multi-domain instances from add_authelia_domain
@@ -1190,6 +1504,30 @@ _authelia_ensure_oidc_provider() {
log_success "OIDC provider enabled (signing key + HMAC secret generated)" 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 # Non-interactive core of _authelia_add_oidc_client() below — generates a
# client secret, patches it into identity_providers.oidc.clients, and # client secret, patches it into identity_providers.oidc.clients, and
# (optionally) restarts Authelia. Fully self-contained (re-validates # (optionally) restarts Authelia. Fully self-contained (re-validates
@@ -1207,8 +1545,10 @@ _authelia_ensure_oidc_provider() {
# caller must capture and use/display it now. # caller must capture and use/display it now.
# OIDC_AUTHELIA_DOMAIN this Authelia instance's apex domain, for # OIDC_AUTHELIA_DOMAIN this Authelia instance's apex domain, for
# building discovery/authorization/token URLs. # building discovery/authorization/token URLs.
# Returns 1 on failure (Authelia not installed, client ID already taken, # Returns 1 on failure (Authelia not installed, domain undeterminable,
# secret generation failed) with the reason already logged. # 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() { _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"; shift 4
local -a REDIRECT_URIS=("$@") local -a REDIRECT_URIS=("$@")
@@ -1239,9 +1579,19 @@ _authelia_provision_oidc_client() {
return 1 return 1
fi fi
# 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 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 "A client with ID '$CLIENT_ID' is already registered — replacing it with a fresh one (its old secret was never recoverable anyway)."
return 1 _authelia_remove_oidc_client "$CONFIG_FILE" "$CLIENT_ID"
fi fi
log_info "Generating client secret..." log_info "Generating client secret..."
+2
View File
@@ -236,6 +236,8 @@ _gitea_offer_authelia_sso() {
log_warning " Discovery URL: $_discovery_url" log_warning " Discovery URL: $_discovery_url"
log_warning " (The Client Secret above is shown once — it isn't stored in plaintext anywhere.)" log_warning " (The Client Secret above is shown once — it isn't stored in plaintext anywhere.)"
fi fi
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "gitea" "$GITEA_OIDC_DOMAIN"
} }
# Offers to enable Gitea Actions (Gitea's own CI, largely GitHub-Actions- # Offers to enable Gitea Actions (Gitea's own CI, largely GitHub-Actions-
+81
View File
@@ -198,6 +198,83 @@ fi
register_service mealie utilities "Recipe manager & meal planner (Mealie)" 9925 register_service mealie utilities "Recipe manager & meal planner (Mealie)" 9925
# Offers to add "Sign in with Authelia" (OpenID Connect) to Mealie's own
# login page — same additive pattern as services/gitea.sh's
# _gitea_offer_authelia_sso (local login keeps working unchanged), but
# Mealie's OIDC support is entirely environment-variable driven — no CLI
# equivalent to Gitea's `admin auth add-oauth` needed. Confirmed against
# Mealie's own OIDC docs: OIDC_AUTH_ENABLED, OIDC_CLIENT_ID,
# OIDC_CLIENT_SECRET, OIDC_CONFIGURATION_URL, OIDC_SIGNUP_ENABLED, appended
# straight into the .env file this installer already writes and reads via
# `env_file: .env` — no docker-compose.yml regeneration needed for that part.
#
# Reads BASE_URL back from the existing .env rather than taking it as an
# arg, so this works identically whether called right after a fresh
# install (where the URL was just computed) or from an Update rerun
# (where it wasn't recomputed this run, but is already on disk).
#
# Args: DIR CONTAINER
_mealie_offer_authelia_oidc() {
local DIR="$1" CONTAINER="$2"
[ -d "$DOCKER_DIR/authelia" ] || return 0
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
grep -q '^OIDC_AUTH_ENABLED=' "$DIR/.env" 2>/dev/null && return 0
local BASE_URL
BASE_URL="$(grep '^BASE_URL=' "$DIR/.env" 2>/dev/null | cut -d= -f2-)"
if [ -z "$BASE_URL" ]; then
log_warning "Couldn't find BASE_URL in $DIR/.env — skipping Authelia SSO offer for Mealie."
return 0
fi
echo ""
local USE_SSO=""
prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Mealie's login page? (y/n):" "n" USE_SSO
[[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0
local _2fa="" AUTH_POLICY="two_factor"
prompt_yn " Require two-factor for Mealie logins via Authelia too? (y/n):" "y" _2fa
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
if ! _authelia_provision_oidc_client "Mealie" "mealie" "$AUTH_POLICY" "y" "${BASE_URL}/login"; then
log_warning "Couldn't register Mealie as an OIDC client in Authelia — skipping SSO setup."
return 0
fi
local _discovery_url="https://auth.${OIDC_AUTHELIA_DOMAIN}/.well-known/openid-configuration"
cat >> "$DIR/.env" << ENV
# Written by services/mealie.sh's Authelia SSO step — adds "Sign in with
# Authelia" alongside local login; local accounts keep working unchanged.
OIDC_AUTH_ENABLED=true
OIDC_SIGNUP_ENABLED=true
OIDC_CLIENT_ID=mealie
OIDC_CLIENT_SECRET=$OIDC_CLIENT_SECRET_PLAIN
OIDC_CONFIGURATION_URL=$_discovery_url
OIDC_PROVIDER_NAME=Authelia
ENV
chown "$ACTUAL_USER:$ACTUAL_USER" "$DIR/.env" 2>/dev/null || true
# Mealie's OIDC redirect URI generation trusts X-Forwarded-* only from
# explicitly allowed IPs — without this, a Caddy-fronted instance
# generates an http:// redirect URI even when actually served over
# https://, which Authelia/any OIDC provider rejects as a scheme
# mismatch. Confirmed against Mealie's own reverse-proxy docs/issue
# tracker. Only needed (and only added) when Caddy is actually
# fronting this instance — BASE_URL itself tells us that (it's only
# ever https:// when a real domain + Caddy were configured).
if [[ "$BASE_URL" == https://* ]] && ! grep -q '^ entrypoint:' "$DIR/docker-compose.yml"; then
sed -i "/container_name: ${CONTAINER}\$/a\\ entrypoint: [\"uvicorn\", \"mealie.app:app\", \"--host\", \"0.0.0.0\", \"--port\", \"9000\", \"--forwarded-allow-ips=*\"]" "$DIR/docker-compose.yml"
fi
(cd "$DIR" && docker compose up -d) \
&& log_success "\"Sign in with Authelia\" added to Mealie — local login still works too." \
|| log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "mealie" "${BASE_URL#*://}"
}
install_mealie() { install_mealie() {
require_docker || return 1 require_docker || return 1
@@ -217,6 +294,7 @@ install_mealie() {
echo " - Auto-scan for a free host port if this is an additional instance" echo " - Auto-scan for a free host port if this is an additional instance"
echo " - Default login: changeme@email.com / MyPassword (change immediately)" echo " - Default login: changeme@email.com / MyPassword (change immediately)"
echo " - Offer a Caddy reverse proxy and to start the container" echo " - Offer a Caddy reverse proxy and to start the container"
echo " - Offer \"Sign in with Authelia\" (OIDC) if Authelia is installed"
return 0 return 0
fi fi
@@ -259,6 +337,7 @@ install_mealie() {
( cd "$MEALIE_DIR" && docker compose pull && docker compose up -d ) \ ( cd "$MEALIE_DIR" && docker compose pull && docker compose up -d ) \
&& log_success "Mealie image refreshed" \ && log_success "Mealie image refreshed" \
|| log_warning "Refresh failed — check: docker compose -f $MEALIE_DIR/docker-compose.yml logs" || log_warning "Refresh failed — check: docker compose -f $MEALIE_DIR/docker-compose.yml logs"
declare -F _mealie_offer_authelia_oidc >/dev/null 2>&1 && _mealie_offer_authelia_oidc "$MEALIE_DIR" "$CONTAINER"
return 0 return 0
;; ;;
cancel) cancel)
@@ -352,6 +431,8 @@ MEALIE_ENV
configure_caddy_for_service "Mealie${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:9000" "recipes${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}" configure_caddy_for_service "Mealie${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:9000" "recipes${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
declare -F _mealie_offer_authelia_oidc >/dev/null 2>&1 && _mealie_offer_authelia_oidc "$MEALIE_DIR" "$CONTAINER"
write_readme "$MEALIE_DIR" << MD write_readme "$MEALIE_DIR" << MD
# Mealie${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} # Mealie${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}