diff --git a/scripts/authentik-audiobookshelf-sso.sh b/scripts/authentik-audiobookshelf-sso.sh
index caafca7..7dbba89 100755
--- a/scripts/authentik-audiobookshelf-sso.sh
+++ b/scripts/authentik-audiobookshelf-sso.sh
@@ -11,12 +11,15 @@
# dist-server/server/auth/OidcAuthStrategy.js + objects/settings/ServerSettings.js)
# rather than a forum, because each of these is a dead-login trap:
#
-# * TWO redirect URIs on the provider: /auth/openid/callback (browser) and
-# /auth/openid/mobile-redirect (mobile). ABS SWAPS the app's own
-# audiobookshelf://oauth for the mobile-redirect URL before the IdP ever sees a
-# redirect_uri, so registering the app scheme with authentik — the obvious move —
-# is what makes phone logins fail. The app scheme belongs in ABS's
-# authOpenIDMobileRedirectURIs list (its built-in default), not here.
+# * The browser callback URI /auth/openid/callback for EVERY origin ABS is
+# reached at, plus /auth/openid/mobile-redirect on ABS_BASE only. ABS SWAPS
+# the app's own audiobookshelf://oauth for the mobile-redirect URL before the IdP
+# ever sees a redirect_uri, so registering the app scheme with authentik — the
+# obvious move — is what makes phone logins fail. The app scheme belongs in ABS's
+# authOpenIDMobileRedirectURIs list (its built-in default), not here. The mobile
+# URI is registered on the LAN origin only because the phone is configured with
+# the LAN address: it is a non-browser client and cannot solve the guard
+# challenge that sits in front of the vhost.
# * CARRIES THE BASE PATH. ABS builds redirect_uri as
# /auth/openid/callback, and that subfolder is
# the ROUTER_BASE_PATH the container is mounted at — /audiobookshelf, which is also the only
@@ -72,8 +75,17 @@
# (qa-e2e-admin — tests/e2e/specs/23-audiobookshelf-sso.spec.ts
# signs in as this identity, so a re-run restores the fixture)
# ABS_BASE ABS URL as the browser sees it, base path included
-# (http://192.168.0.235:8000/audiobookshelf — LAN-only by policy:
-# no vhost, no reverse proxy in front)
+# (http://192.168.0.235:8000/audiobookshelf — the LAN
+# origin, reached directly, which is also what the phone
+# app is configured with)
+# ABS_EXTRA_BASES space-separated additional origins, base path included
+# (default: https://abs.franksdank.cloud/audiobookshelf —
+# the guarded vhost, dankclaw/Caddyfile).
+# ABS builds its redirect_uri from the ORIGIN of the
+# request, so each origin the app is served at needs its
+# own registered URI — an unregistered origin is a
+# redirect_uri_mismatch at the IdP, before any credential.
+# Set it empty to register the LAN origin alone.
# AUTHENTIK_HOST ssh target (dankclaw)
set -euo pipefail
@@ -88,6 +100,7 @@ SSO_USER="${ABS_OIDC_USERNAME:-akadmin}"
MEMBERS="${ABS_OIDC_USERS:-}"
E2E_ADMIN="${ABS_E2E_ADMIN:-qa-e2e-admin}"
BASE="${ABS_BASE:-http://192.168.0.235:8000/audiobookshelf}"
+EXTRA_BASES="${ABS_EXTRA_BASES:-https://abs.franksdank.cloud/audiobookshelf}"
HOST="${AUTHENTIK_HOST:-dankclaw}"
# The claim expression: translate group membership into ABS role names. It is stored
@@ -110,6 +123,7 @@ from authentik.providers.oauth2.models import (
ADMIN_GROUP, USER_GROUP = "$ADMIN_GROUP", "$USER_GROUP"
CLAIM, CLIENT_ID, SECRET, BASE = "$CLAIM", "$CLIENT_ID", "$SECRET", "$BASE"
+BASES = [BASE] + """$EXTRA_BASES""".split()
SSO_USER, E2E_ADMIN = "$SSO_USER", "$E2E_ADMIN"
MEMBERS = """$MEMBERS""".split()
EXPR = '''$EXPR'''
@@ -143,10 +157,12 @@ _ns = {}
exec(_src, _ns)
# --- provider --------------------------------------------------------------------
+# One browser callback per origin (LAN address and the guarded vhost), because ABS
+# derives redirect_uri from the request origin; the mobile URI stays on the LAN origin.
uris = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT,
- url="%s/auth/openid/callback" % BASE),
- RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT,
- url="%s/auth/openid/mobile-redirect" % BASE)]
+ url="%s/auth/openid/callback" % b) for b in BASES]
+uris.append(RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT,
+ url="%s/auth/openid/mobile-redirect" % BASE))
authz_flow = Flow.objects.get(slug="default-provider-authorization-implicit-consent")
# Three things beyond the name, each one a silent dead login: the grant-type list (2026.8