Compare commits

...

5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 0c7ea6748d fix: social login providers now work from DB config without restart
- Extract all OAuth registration into _setup_social_providers() with a
  _register_oauth_client() helper that clears the authlib _clients cache
  so credentials can change without a restart
- Add refresh_social_providers() public function called after every
  settings reload (lifespan startup + settings_sync live reload)
- Fix connections page linked status to use _get_effective() (DB-aware)
  instead of the stale startup-time SOCIAL_PROVIDERS dict
- Fix oauth_configured template variable similarly
- Add tests: DB-driven linked status, stale-provider clearing,
  register_oauth_client cache-clear, refresh function coverage

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/ef15910f-fd25-469a-814b-9e1fb40659c9
2026-03-22 18:42:18 +00:00
copilot-swe-agent[bot] 33a0e49acd Initial plan 2026-03-22 18:21:40 +00:00
github-actions[bot] ce4bca0186 chore(release): update build metadata files [skip ci] 2026-03-22 17:46:30 +00:00
semantic-release 4b07e996ad 0.171.3
Automatically generated by python-semantic-release
2026-03-22 17:46:27 +00:00
Christian Krakau-Louis 720c9c11b0 Merge pull request #803 from christianlouis/copilot/fix-html-lint-errors
fix(ui): add missing opening `<script>` tag in base.html Sentry init block
2026-03-22 18:46:06 +01:00
10 changed files with 484 additions and 170 deletions
+1 -1
View File
@@ -1 +1 @@
2026-03-22T17:37:50Z 2026-03-22T17:46:27Z
+8
View File
@@ -10,6 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- version list --> <!-- version list -->
## v0.171.3 (2026-03-22)
### Bug Fixes
- **ui**: Add missing opening script tag in base.html Sentry block
([`425472c`](https://github.com/christianlouis/DocuElevate/commit/425472c839b3564c20a29b6e983fa6b9e7d6cf9c))
## v0.171.2 (2026-03-22) ## v0.171.2 (2026-03-22)
### Bug Fixes ### Bug Fixes
+1 -1
View File
@@ -1 +1 @@
3e1b352 720c9c1
+6 -6
View File
@@ -1,10 +1,10 @@
DocuElevate Build Information DocuElevate Build Information
============================== ==============================
Version: 0.171.2 Version: 0.171.3
Build Date: 2026-03-22T17:37:50Z Build Date: 2026-03-22T17:46:27Z
Git Commit: 3e1b35293006b76737c7d2edfc50a14b003ef266 Git Commit: 720c9c11b05a7af6ac33478ecab6cc80c24c83e1
Git Short SHA: 3e1b352 Git Short SHA: 720c9c1
Git Branch: main Git Branch: main
Commit Date: 2026-03-22T18:37:25+01:00 Commit Date: 2026-03-22T18:46:06+01:00
Build Timestamp: 2026-03-22T17:37:50Z Build Timestamp: 2026-03-22T17:46:27Z
============================== ==============================
+1 -1
View File
@@ -1 +1 @@
0.171.2 0.171.3
+153 -81
View File
@@ -45,78 +45,27 @@ OAUTH_PROVIDER_NAME = "Single Sign-On"
# Social login providers that are enabled and registered # Social login providers that are enabled and registered
SOCIAL_PROVIDERS: dict[str, dict[str, str]] = {} SOCIAL_PROVIDERS: dict[str, dict[str, str]] = {}
if AUTH_ENABLED and settings.authentik_client_id and settings.authentik_client_secret:
oauth.register(
name="authentik",
client_id=settings.authentik_client_id,
client_secret=settings.authentik_client_secret,
server_metadata_url=settings.authentik_config_url,
client_kwargs={"scope": "openid profile email"},
)
OAUTH_CONFIGURED = True
OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO"
# --- Social Login Providers --------------------------------------------------- # ---------------------------------------------------------------------------
if AUTH_ENABLED and settings.social_auth_google_enabled: # Helpers for dynamic (re-)registration of OAuth providers
# Determine which credentials to use for Google social login # ---------------------------------------------------------------------------
_google_client_id = settings.social_auth_google_client_id
_google_client_secret = settings.social_auth_google_client_secret
if settings.social_auth_google_use_global_credentials and not (_google_client_id and _google_client_secret):
_google_client_id = settings.google_drive_client_id
_google_client_secret = settings.google_drive_client_secret
if _google_client_id and _google_client_secret:
oauth.register(
name="google",
client_id=_google_client_id,
client_secret=_google_client_secret,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
SOCIAL_PROVIDERS["google"] = {"name": "Google", "icon": "fab fa-google", "color": "red"}
logger.info("Social login provider registered: Google")
else:
logger.warning("SOCIAL_AUTH_GOOGLE_ENABLED=true but client ID/secret not configured")
if AUTH_ENABLED and settings.social_auth_microsoft_enabled: def _register_oauth_client(name: str, **kwargs: object) -> None:
# Determine which credentials to use for Microsoft social login """Register (or re-register) an authlib OAuth client, clearing any cached instance.
_microsoft_client_id = settings.social_auth_microsoft_client_id
_microsoft_client_secret = settings.social_auth_microsoft_client_secret
if settings.social_auth_microsoft_use_global_credentials and not (
_microsoft_client_id and _microsoft_client_secret
):
_microsoft_client_id = settings.onedrive_client_id
_microsoft_client_secret = settings.onedrive_client_secret
if _microsoft_client_id and _microsoft_client_secret: authlib caches the constructed client object in ``oauth._clients`` after the
tenant = settings.social_auth_microsoft_tenant or "common" first ``register()`` call. Subsequent ``register()`` calls overwrite the
oauth.register( registry entry but the stale cached client is still returned by
name="microsoft", ``create_client()`` / ``__getattr__``. Popping the name from ``_clients``
client_id=_microsoft_client_id, before re-registering ensures the new credentials are picked up immediately.
client_secret=_microsoft_client_secret,
server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
SOCIAL_PROVIDERS["microsoft"] = {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"}
logger.info("Social login provider registered: Microsoft (tenant=%s)", tenant)
else:
logger.warning("SOCIAL_AUTH_MICROSOFT_ENABLED=true but client ID/secret not configured")
if AUTH_ENABLED and settings.social_auth_apple_enabled: Args:
if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id: name: Provider name (e.g. ``"google"``, ``"github"``).
oauth.register( **kwargs: Keyword arguments forwarded verbatim to ``oauth.register()``.
name="apple", """
client_id=settings.social_auth_apple_client_id, oauth._clients.pop(name, None)
server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration", oauth.register(name, **kwargs)
client_kwargs={
"scope": "openid name email",
"response_mode": "form_post",
},
)
SOCIAL_PROVIDERS["apple"] = {"name": "Apple", "icon": "fab fa-apple", "color": "gray"}
logger.info("Social login provider registered: Apple")
else:
logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured")
def _dropbox_userinfo_compliance_fix(client, user_cls, token, data): def _dropbox_userinfo_compliance_fix(client, user_cls, token, data):
@@ -145,8 +94,104 @@ def _dropbox_userinfo_compliance_fix(client, user_cls, token, data):
return data return data
if AUTH_ENABLED and settings.social_auth_dropbox_enabled: def _setup_social_providers() -> None:
# Determine which credentials to use for Dropbox social login """Register all configured OAuth / social-login providers from current settings.
This function is **idempotent**: it clears ``SOCIAL_PROVIDERS``,
``OAUTH_CONFIGURED``, and ``OAUTH_PROVIDER_NAME`` before rebuilding them,
and calls :func:`_register_oauth_client` (which also clears the authlib
client cache) so that credential changes in the database are reflected
without an application restart.
Can safely be called multiple times, e.g. after a settings reload.
"""
global OAUTH_CONFIGURED, OAUTH_PROVIDER_NAME
SOCIAL_PROVIDERS.clear()
OAUTH_CONFIGURED = False
OAUTH_PROVIDER_NAME = "Single Sign-On"
if not AUTH_ENABLED:
return
# --- Authentik / OIDC ---
if settings.authentik_client_id and settings.authentik_client_secret:
_register_oauth_client(
"authentik",
client_id=settings.authentik_client_id,
client_secret=settings.authentik_client_secret,
server_metadata_url=settings.authentik_config_url,
client_kwargs={"scope": "openid profile email"},
)
OAUTH_CONFIGURED = True
OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO"
# --- Social Login Providers ---
# Google
if settings.social_auth_google_enabled:
_google_client_id = settings.social_auth_google_client_id
_google_client_secret = settings.social_auth_google_client_secret
if settings.social_auth_google_use_global_credentials and not (_google_client_id and _google_client_secret):
_google_client_id = settings.google_drive_client_id
_google_client_secret = settings.google_drive_client_secret
if _google_client_id and _google_client_secret:
_register_oauth_client(
"google",
client_id=_google_client_id,
client_secret=_google_client_secret,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
SOCIAL_PROVIDERS["google"] = {"name": "Google", "icon": "fab fa-google", "color": "red"}
logger.info("Social login provider registered: Google")
else:
logger.warning("SOCIAL_AUTH_GOOGLE_ENABLED=true but client ID/secret not configured")
# Microsoft
if settings.social_auth_microsoft_enabled:
_microsoft_client_id = settings.social_auth_microsoft_client_id
_microsoft_client_secret = settings.social_auth_microsoft_client_secret
if settings.social_auth_microsoft_use_global_credentials and not (
_microsoft_client_id and _microsoft_client_secret
):
_microsoft_client_id = settings.onedrive_client_id
_microsoft_client_secret = settings.onedrive_client_secret
if _microsoft_client_id and _microsoft_client_secret:
tenant = settings.social_auth_microsoft_tenant or "common"
_register_oauth_client(
"microsoft",
client_id=_microsoft_client_id,
client_secret=_microsoft_client_secret,
server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
SOCIAL_PROVIDERS["microsoft"] = {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"}
logger.info("Social login provider registered: Microsoft (tenant=%s)", tenant)
else:
logger.warning("SOCIAL_AUTH_MICROSOFT_ENABLED=true but client ID/secret not configured")
# Apple
if settings.social_auth_apple_enabled:
if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id:
_register_oauth_client(
"apple",
client_id=settings.social_auth_apple_client_id,
server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration",
client_kwargs={
"scope": "openid name email",
"response_mode": "form_post",
},
)
SOCIAL_PROVIDERS["apple"] = {"name": "Apple", "icon": "fab fa-apple", "color": "gray"}
logger.info("Social login provider registered: Apple")
else:
logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured")
# Dropbox
if settings.social_auth_dropbox_enabled:
_dropbox_client_id = settings.social_auth_dropbox_client_id _dropbox_client_id = settings.social_auth_dropbox_client_id
_dropbox_client_secret = settings.social_auth_dropbox_client_secret _dropbox_client_secret = settings.social_auth_dropbox_client_secret
if settings.social_auth_dropbox_use_global_credentials and not (_dropbox_client_id and _dropbox_client_secret): if settings.social_auth_dropbox_use_global_credentials and not (_dropbox_client_id and _dropbox_client_secret):
@@ -154,8 +199,8 @@ if AUTH_ENABLED and settings.social_auth_dropbox_enabled:
_dropbox_client_secret = settings.dropbox_app_secret _dropbox_client_secret = settings.dropbox_app_secret
if _dropbox_client_id and _dropbox_client_secret: if _dropbox_client_id and _dropbox_client_secret:
oauth.register( _register_oauth_client(
name="dropbox", "dropbox",
client_id=_dropbox_client_id, client_id=_dropbox_client_id,
client_secret=_dropbox_client_secret, client_secret=_dropbox_client_secret,
authorize_url="https://www.dropbox.com/oauth2/authorize", authorize_url="https://www.dropbox.com/oauth2/authorize",
@@ -172,10 +217,11 @@ if AUTH_ENABLED and settings.social_auth_dropbox_enabled:
else: else:
logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured") logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured")
if AUTH_ENABLED and settings.social_auth_github_enabled: # GitHub
if settings.social_auth_github_enabled:
if settings.social_auth_github_client_id and settings.social_auth_github_client_secret: if settings.social_auth_github_client_id and settings.social_auth_github_client_secret:
oauth.register( _register_oauth_client(
name="github", "github",
client_id=settings.social_auth_github_client_id, client_id=settings.social_auth_github_client_id,
client_secret=settings.social_auth_github_client_secret, client_secret=settings.social_auth_github_client_secret,
authorize_url="https://github.com/login/oauth/authorize", authorize_url="https://github.com/login/oauth/authorize",
@@ -188,7 +234,8 @@ if AUTH_ENABLED and settings.social_auth_github_enabled:
else: else:
logger.warning("SOCIAL_AUTH_GITHUB_ENABLED=true but client ID/secret not configured") logger.warning("SOCIAL_AUTH_GITHUB_ENABLED=true but client ID/secret not configured")
if AUTH_ENABLED and settings.social_auth_keycloak_enabled: # Keycloak
if settings.social_auth_keycloak_enabled:
_kc_server = settings.social_auth_keycloak_server_url _kc_server = settings.social_auth_keycloak_server_url
_kc_realm = settings.social_auth_keycloak_realm _kc_realm = settings.social_auth_keycloak_realm
if ( if (
@@ -198,8 +245,8 @@ if AUTH_ENABLED and settings.social_auth_keycloak_enabled:
and _kc_realm and _kc_realm
): ):
_kc_base = f"{_kc_server.rstrip('/')}/realms/{_kc_realm}" _kc_base = f"{_kc_server.rstrip('/')}/realms/{_kc_realm}"
oauth.register( _register_oauth_client(
name="keycloak", "keycloak",
client_id=settings.social_auth_keycloak_client_id, client_id=settings.social_auth_keycloak_client_id,
client_secret=settings.social_auth_keycloak_client_secret, client_secret=settings.social_auth_keycloak_client_secret,
server_metadata_url=f"{_kc_base}/.well-known/openid-configuration", server_metadata_url=f"{_kc_base}/.well-known/openid-configuration",
@@ -210,15 +257,16 @@ if AUTH_ENABLED and settings.social_auth_keycloak_enabled:
else: else:
logger.warning("SOCIAL_AUTH_KEYCLOAK_ENABLED=true but required settings not configured") logger.warning("SOCIAL_AUTH_KEYCLOAK_ENABLED=true but required settings not configured")
if AUTH_ENABLED and settings.social_auth_generic_oauth2_enabled: # Generic OAuth2
if settings.social_auth_generic_oauth2_enabled:
if ( if (
settings.social_auth_generic_oauth2_client_id settings.social_auth_generic_oauth2_client_id
and settings.social_auth_generic_oauth2_client_secret and settings.social_auth_generic_oauth2_client_secret
and settings.social_auth_generic_oauth2_authorize_url and settings.social_auth_generic_oauth2_authorize_url
and settings.social_auth_generic_oauth2_token_url and settings.social_auth_generic_oauth2_token_url
): ):
oauth.register( _register_oauth_client(
name="generic_oauth2", "generic_oauth2",
client_id=settings.social_auth_generic_oauth2_client_id, client_id=settings.social_auth_generic_oauth2_client_id,
client_secret=settings.social_auth_generic_oauth2_client_secret, client_secret=settings.social_auth_generic_oauth2_client_secret,
authorize_url=settings.social_auth_generic_oauth2_authorize_url, authorize_url=settings.social_auth_generic_oauth2_authorize_url,
@@ -227,11 +275,35 @@ if AUTH_ENABLED and settings.social_auth_generic_oauth2_enabled:
client_kwargs={"scope": settings.social_auth_generic_oauth2_scope}, client_kwargs={"scope": settings.social_auth_generic_oauth2_scope},
) )
_generic_name = settings.social_auth_generic_oauth2_name or "OAuth2" _generic_name = settings.social_auth_generic_oauth2_name or "OAuth2"
SOCIAL_PROVIDERS["generic_oauth2"] = {"name": _generic_name, "icon": "fas fa-sign-in-alt", "color": "indigo"} SOCIAL_PROVIDERS["generic_oauth2"] = {
"name": _generic_name,
"icon": "fas fa-sign-in-alt",
"color": "indigo",
}
logger.info("Social login provider registered: Generic OAuth2 (%s)", _generic_name) logger.info("Social login provider registered: Generic OAuth2 (%s)", _generic_name)
else: else:
logger.warning("SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true but required settings not configured") logger.warning("SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true but required settings not configured")
def refresh_social_providers() -> None:
"""Re-register all OAuth providers from the *current* settings object.
Call this after loading or reloading settings from the database so that
providers configured (or updated) through the admin UI take effect
immediately — **no application restart required**.
This function is safe to call multiple times and is idempotent.
"""
logger.info("Refreshing social login provider registrations from current settings")
_setup_social_providers()
# Perform the initial registration from environment / default settings at
# import time. The lifespan hook and settings_sync will call
# refresh_social_providers() again after DB settings are loaded so that
# any providers configured only in the database are also active.
_setup_social_providers()
router = APIRouter() router = APIRouter()
+12
View File
@@ -189,6 +189,18 @@ async def lifespan(app: FastAPI):
finally: finally:
db.close() db.close()
# Re-register OAuth / social-login providers now that DB settings are
# loaded. auth.py runs its initial registration at import time (before
# the lifespan runs), so providers that are only configured in the
# database would not be registered yet. Calling refresh here ensures
# they are active immediately on startup without any manual restart.
try:
from app.auth import refresh_social_providers
refresh_social_providers()
except Exception as e:
logging.warning(f"Could not refresh social login providers on startup: {e}")
# Initialize Sentry after DB settings are loaded so that values configured # Initialize Sentry after DB settings are loaded so that values configured
# via the database UI (e.g. SENTRY_DSN) are respected in addition to env vars. # via the database UI (e.g. SENTRY_DSN) are respected in addition to env vars.
init_sentry() init_sentry()
+10
View File
@@ -71,6 +71,16 @@ def notify_settings_updated() -> None:
except Exception as exc: except Exception as exc:
logger.warning(f"Could not reload in-process settings: {exc}") logger.warning(f"Could not reload in-process settings: {exc}")
# Re-register OAuth / social-login providers so that any provider whose
# credentials were just saved (or updated) in the database is active
# immediately on the login page — no restart required.
try:
from app.auth import refresh_social_providers
refresh_social_providers()
except Exception as exc:
logger.warning(f"Could not refresh social login providers after settings update: {exc}")
# Re-check OCR language availability in the background whenever settings # Re-check OCR language availability in the background whenever settings
# are updated. This ensures that if a user changes tesseract_language or # are updated. This ensures that if a user changes tesseract_language or
# easyocr_languages via the UI, the new language data is downloaded without # easyocr_languages via the UI, the new language data is downloaded without
+57 -12
View File
@@ -206,8 +206,6 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
SSO settings, and service integrations through a wizard-like interface. SSO settings, and service integrations through a wizard-like interface.
""" """
try: try:
from app.auth import OAUTH_CONFIGURED, SOCIAL_PROVIDERS
db_settings = get_all_settings_from_db(db) db_settings = get_all_settings_from_db(db)
def _get_effective(key: str): def _get_effective(key: str):
@@ -227,13 +225,14 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
services = [] services = []
# --- SSO (Authentik / OIDC) --- # --- SSO (Authentik / OIDC) ---
_oidc_linked = bool(_get_effective("authentik_client_id") and _get_effective("authentik_client_secret"))
services.append( services.append(
{ {
"key": "oidc", "key": "oidc",
"name": settings.oauth_provider_name or "Single Sign-On", "name": _get_effective("oauth_provider_name") or "Single Sign-On",
"icon": "fas fa-lock", "icon": "fas fa-lock",
"type": "SSO", "type": "SSO",
"linked": OAUTH_CONFIGURED, "linked": _oidc_linked,
"description": "OpenID Connect SSO provider", "description": "OpenID Connect SSO provider",
"settings_keys": [ "settings_keys": [
"authentik_client_id", "authentik_client_id",
@@ -245,13 +244,23 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
) )
# --- Google --- # --- Google ---
_google_id = _get_effective("social_auth_google_client_id")
_google_secret = _get_effective("social_auth_google_client_secret")
if _is_truthy(_get_effective("social_auth_google_use_global_credentials")) and not (
_google_id and _google_secret
):
_google_id = _google_id or _get_effective("google_drive_client_id")
_google_secret = _google_secret or _get_effective("google_drive_client_secret")
_google_linked = bool(
_is_truthy(_get_effective("social_auth_google_enabled")) and _google_id and _google_secret
)
services.append( services.append(
{ {
"key": "google", "key": "google",
"name": "Google", "name": "Google",
"icon": "fab fa-google", "icon": "fab fa-google",
"type": "Sign-in authentication", "type": "Sign-in authentication",
"linked": "google" in SOCIAL_PROVIDERS, "linked": _google_linked,
"description": "Sign-in authentication", "description": "Sign-in authentication",
"settings_keys": [ "settings_keys": [
"social_auth_google_enabled", "social_auth_google_enabled",
@@ -263,13 +272,18 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
) )
# --- GitHub --- # --- GitHub ---
_github_linked = bool(
_is_truthy(_get_effective("social_auth_github_enabled"))
and _get_effective("social_auth_github_client_id")
and _get_effective("social_auth_github_client_secret")
)
services.append( services.append(
{ {
"key": "github", "key": "github",
"name": "GitHub", "name": "GitHub",
"icon": "fab fa-github", "icon": "fab fa-github",
"type": "Sign-in authentication", "type": "Sign-in authentication",
"linked": "github" in SOCIAL_PROVIDERS, "linked": _github_linked,
"description": "Sign-in authentication", "description": "Sign-in authentication",
"settings_keys": [ "settings_keys": [
"social_auth_github_enabled", "social_auth_github_enabled",
@@ -280,13 +294,19 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
) )
# --- Microsoft --- # --- Microsoft ---
_ms_id = _get_effective("social_auth_microsoft_client_id")
_ms_secret = _get_effective("social_auth_microsoft_client_secret")
if _is_truthy(_get_effective("social_auth_microsoft_use_global_credentials")) and not (_ms_id and _ms_secret):
_ms_id = _ms_id or _get_effective("onedrive_client_id")
_ms_secret = _ms_secret or _get_effective("onedrive_client_secret")
_microsoft_linked = bool(_is_truthy(_get_effective("social_auth_microsoft_enabled")) and _ms_id and _ms_secret)
services.append( services.append(
{ {
"key": "microsoft", "key": "microsoft",
"name": "Microsoft", "name": "Microsoft",
"icon": "fab fa-microsoft", "icon": "fab fa-microsoft",
"type": "Sign-in authentication", "type": "Sign-in authentication",
"linked": "microsoft" in SOCIAL_PROVIDERS, "linked": _microsoft_linked,
"description": "Sign-in authentication", "description": "Sign-in authentication",
"settings_keys": [ "settings_keys": [
"social_auth_microsoft_enabled", "social_auth_microsoft_enabled",
@@ -299,13 +319,18 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
) )
# --- Apple --- # --- Apple ---
_apple_linked = bool(
_is_truthy(_get_effective("social_auth_apple_enabled"))
and _get_effective("social_auth_apple_client_id")
and _get_effective("social_auth_apple_team_id")
)
services.append( services.append(
{ {
"key": "apple", "key": "apple",
"name": "Apple", "name": "Apple",
"icon": "fab fa-apple", "icon": "fab fa-apple",
"type": "Sign-in authentication", "type": "Sign-in authentication",
"linked": "apple" in SOCIAL_PROVIDERS, "linked": _apple_linked,
"description": "Sign-in authentication", "description": "Sign-in authentication",
"settings_keys": [ "settings_keys": [
"social_auth_apple_enabled", "social_auth_apple_enabled",
@@ -318,13 +343,19 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
) )
# --- Dropbox --- # --- Dropbox ---
_dbx_id = _get_effective("social_auth_dropbox_client_id")
_dbx_secret = _get_effective("social_auth_dropbox_client_secret")
if _is_truthy(_get_effective("social_auth_dropbox_use_global_credentials")) and not (_dbx_id and _dbx_secret):
_dbx_id = _dbx_id or _get_effective("dropbox_app_key")
_dbx_secret = _dbx_secret or _get_effective("dropbox_app_secret")
_dropbox_linked = bool(_is_truthy(_get_effective("social_auth_dropbox_enabled")) and _dbx_id and _dbx_secret)
services.append( services.append(
{ {
"key": "dropbox", "key": "dropbox",
"name": "Dropbox", "name": "Dropbox",
"icon": "fab fa-dropbox", "icon": "fab fa-dropbox",
"type": "Sign-in authentication", "type": "Sign-in authentication",
"linked": "dropbox" in SOCIAL_PROVIDERS, "linked": _dropbox_linked,
"description": "Sign-in authentication", "description": "Sign-in authentication",
"settings_keys": [ "settings_keys": [
"social_auth_dropbox_enabled", "social_auth_dropbox_enabled",
@@ -336,13 +367,20 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
) )
# --- Keycloak --- # --- Keycloak ---
_keycloak_linked = bool(
_is_truthy(_get_effective("social_auth_keycloak_enabled"))
and _get_effective("social_auth_keycloak_client_id")
and _get_effective("social_auth_keycloak_client_secret")
and _get_effective("social_auth_keycloak_server_url")
and _get_effective("social_auth_keycloak_realm")
)
services.append( services.append(
{ {
"key": "keycloak", "key": "keycloak",
"name": "Keycloak", "name": "Keycloak",
"icon": "fas fa-key", "icon": "fas fa-key",
"type": "SSO", "type": "SSO",
"linked": "keycloak" in SOCIAL_PROVIDERS, "linked": _keycloak_linked,
"description": "SSO", "description": "SSO",
"settings_keys": [ "settings_keys": [
"social_auth_keycloak_enabled", "social_auth_keycloak_enabled",
@@ -355,13 +393,20 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
) )
# --- Generic OAuth2 --- # --- Generic OAuth2 ---
_generic_oauth2_linked = bool(
_is_truthy(_get_effective("social_auth_generic_oauth2_enabled"))
and _get_effective("social_auth_generic_oauth2_client_id")
and _get_effective("social_auth_generic_oauth2_client_secret")
and _get_effective("social_auth_generic_oauth2_authorize_url")
and _get_effective("social_auth_generic_oauth2_token_url")
)
services.append( services.append(
{ {
"key": "generic_oauth2", "key": "generic_oauth2",
"name": "Generic OAuth2", "name": "Generic OAuth2",
"icon": "fas fa-sign-in-alt", "icon": "fas fa-sign-in-alt",
"type": "SSO", "type": "SSO",
"linked": "generic_oauth2" in SOCIAL_PROVIDERS, "linked": _generic_oauth2_linked,
"description": "SSO", "description": "SSO",
"settings_keys": [ "settings_keys": [
"social_auth_generic_oauth2_enabled", "social_auth_generic_oauth2_enabled",
@@ -474,7 +519,7 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
"services": services, "services": services,
"service_settings": service_settings, "service_settings": service_settings,
"sso_auto_login": sso_auto_login, "sso_auto_login": sso_auto_login,
"oauth_configured": OAUTH_CONFIGURED, "oauth_configured": _oidc_linked,
"qr_login_enabled": qr_login_enabled, "qr_login_enabled": qr_login_enabled,
"frontend_url_configured": frontend_url_configured, "frontend_url_configured": frontend_url_configured,
"app_version": settings.version, "app_version": settings.version,
+169 -2
View File
@@ -268,8 +268,6 @@ class TestConnectionsPageRoute:
patch("app.views.settings.get_all_settings_from_db", return_value={}), patch("app.views.settings.get_all_settings_from_db", return_value={}),
patch("app.views.settings.templates") as mock_templates, patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}), patch("app.views.settings.SETTING_METADATA", {}),
patch("app.auth.OAUTH_CONFIGURED", False),
patch("app.auth.SOCIAL_PROVIDERS", {}),
patch("app.views.settings.get_setting_metadata", return_value={}), patch("app.views.settings.get_setting_metadata", return_value={}),
): ):
mock_templates.TemplateResponse.return_value = "response" mock_templates.TemplateResponse.return_value = "response"
@@ -296,6 +294,175 @@ class TestConnectionsPageRoute:
assert "smtp" in service_keys assert "smtp" in service_keys
assert "telegram" in service_keys assert "telegram" in service_keys
@pytest.mark.asyncio
async def test_connections_page_linked_status_from_db(self):
"""Linked status is derived from DB/effective settings, not SOCIAL_PROVIDERS."""
from app.views.settings import connections_page
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": True}}
mock_db = MagicMock()
# Simulate GitHub configured only in DB (not in SOCIAL_PROVIDERS yet)
db_values = {
"social_auth_github_enabled": "true",
"social_auth_github_client_id": "gh-id",
"social_auth_github_client_secret": "gh-secret",
}
with (
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
await connections_page(mock_request, db=mock_db)
context = mock_templates.TemplateResponse.call_args[0][1]
services_by_key = {s["key"]: s for s in context["services"]}
# GitHub should be linked because DB values say so
assert services_by_key["github"]["linked"] is True
@pytest.mark.asyncio
async def test_connections_page_unlinked_when_credentials_missing(self):
"""Provider is unlinked when enabled=true but credentials are absent."""
from app.views.settings import connections_page
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": True}}
mock_db = MagicMock()
# enabled but no credentials
db_values = {"social_auth_github_enabled": "true"}
with (
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
await connections_page(mock_request, db=mock_db)
context = mock_templates.TemplateResponse.call_args[0][1]
services_by_key = {s["key"]: s for s in context["services"]}
assert services_by_key["github"]["linked"] is False
@pytest.mark.asyncio
async def test_connections_page_oidc_linked_from_db(self):
"""OIDC linked status derives from DB effective settings."""
from app.views.settings import connections_page
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": True}}
mock_db = MagicMock()
db_values = {
"authentik_client_id": "my-client-id",
"authentik_client_secret": "my-secret",
"oauth_provider_name": "My SSO",
}
with (
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
await connections_page(mock_request, db=mock_db)
context = mock_templates.TemplateResponse.call_args[0][1]
services_by_key = {s["key"]: s for s in context["services"]}
assert services_by_key["oidc"]["linked"] is True
assert services_by_key["oidc"]["name"] == "My SSO"
# oauth_configured template var should also reflect the DB state
assert context["oauth_configured"] is True
@pytest.mark.unit
class TestRefreshSocialProviders:
"""Tests for the refresh_social_providers() mechanism."""
def test_refresh_social_providers_exists(self):
"""refresh_social_providers is importable from app.auth."""
from app.auth import refresh_social_providers
assert callable(refresh_social_providers)
def test_refresh_social_providers_clears_and_repopulates(self):
"""After refresh, SOCIAL_PROVIDERS reflects current settings."""
import app.auth as auth_module
with (
patch.object(auth_module, "AUTH_ENABLED", True),
patch.object(auth_module, "settings") as mock_settings,
):
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
mock_settings.social_auth_google_enabled = True
mock_settings.social_auth_google_client_id = "gid"
mock_settings.social_auth_google_client_secret = "gsecret"
mock_settings.social_auth_google_use_global_credentials = False
# All other providers disabled
for attr in (
"social_auth_microsoft_enabled",
"social_auth_apple_enabled",
"social_auth_dropbox_enabled",
"social_auth_github_enabled",
"social_auth_keycloak_enabled",
"social_auth_generic_oauth2_enabled",
):
setattr(mock_settings, attr, False)
with patch.object(auth_module, "_register_oauth_client"):
auth_module._setup_social_providers()
assert "google" in auth_module.SOCIAL_PROVIDERS
assert auth_module.OAUTH_CONFIGURED is False
def test_refresh_clears_previous_providers(self):
"""Providers removed from settings are cleared after refresh."""
import app.auth as auth_module
# Pre-populate with a stale entry
auth_module.SOCIAL_PROVIDERS["stale_provider"] = {"name": "Stale", "icon": "", "color": ""}
with (
patch.object(auth_module, "AUTH_ENABLED", True),
patch.object(auth_module, "settings") as mock_settings,
):
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
for attr in (
"social_auth_google_enabled",
"social_auth_microsoft_enabled",
"social_auth_apple_enabled",
"social_auth_dropbox_enabled",
"social_auth_github_enabled",
"social_auth_keycloak_enabled",
"social_auth_generic_oauth2_enabled",
):
setattr(mock_settings, attr, False)
with patch.object(auth_module, "_register_oauth_client"):
auth_module._setup_social_providers()
assert "stale_provider" not in auth_module.SOCIAL_PROVIDERS
def test_register_oauth_client_clears_cache(self):
"""_register_oauth_client removes the cached client before re-registering."""
import app.auth as auth_module
# Inject a fake cached client
auth_module.oauth._clients["test_provider"] = object()
with patch.object(auth_module.oauth, "register"):
auth_module._register_oauth_client("test_provider", client_id="x", client_secret="y")
assert "test_provider" not in auth_module.oauth._clients
@pytest.mark.unit @pytest.mark.unit
class TestTranslationKeys: class TestTranslationKeys: