diff --git a/app/auth.py b/app/auth.py index 46fd8234..694d08cc 100644 --- a/app/auth.py +++ b/app/auth.py @@ -45,78 +45,27 @@ OAUTH_PROVIDER_NAME = "Single Sign-On" # Social login providers that are enabled and registered 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: - # 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 +# --------------------------------------------------------------------------- +# Helpers for dynamic (re-)registration of OAuth providers +# --------------------------------------------------------------------------- - 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: - # Determine which credentials to use for Microsoft social login - _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 +def _register_oauth_client(name: str, **kwargs: object) -> None: + """Register (or re-register) an authlib OAuth client, clearing any cached instance. - if _microsoft_client_id and _microsoft_client_secret: - tenant = settings.social_auth_microsoft_tenant or "common" - oauth.register( - name="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") + authlib caches the constructed client object in ``oauth._clients`` after the + first ``register()`` call. Subsequent ``register()`` calls overwrite the + registry entry but the stale cached client is still returned by + ``create_client()`` / ``__getattr__``. Popping the name from ``_clients`` + before re-registering ensures the new credentials are picked up immediately. -if AUTH_ENABLED and settings.social_auth_apple_enabled: - if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id: - oauth.register( - name="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") + Args: + name: Provider name (e.g. ``"google"``, ``"github"``). + **kwargs: Keyword arguments forwarded verbatim to ``oauth.register()``. + """ + oauth._clients.pop(name, None) + oauth.register(name, **kwargs) def _dropbox_userinfo_compliance_fix(client, user_cls, token, data): @@ -145,92 +94,215 @@ def _dropbox_userinfo_compliance_fix(client, user_cls, token, data): return data -if AUTH_ENABLED and settings.social_auth_dropbox_enabled: - # Determine which credentials to use for Dropbox social login - _dropbox_client_id = settings.social_auth_dropbox_client_id - _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): - _dropbox_client_id = settings.dropbox_app_key - _dropbox_client_secret = settings.dropbox_app_secret +def _setup_social_providers() -> None: + """Register all configured OAuth / social-login providers from current settings. - if _dropbox_client_id and _dropbox_client_secret: - oauth.register( - name="dropbox", - client_id=_dropbox_client_id, - client_secret=_dropbox_client_secret, - authorize_url="https://www.dropbox.com/oauth2/authorize", - access_token_url="https://api.dropboxapi.com/oauth2/token", - userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account", - userinfo_compliance_fix=_dropbox_userinfo_compliance_fix, - client_kwargs={ - "token_endpoint_auth_method": "client_secret_post", - "token_access_type": "offline", - }, - ) - SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"} - logger.info("Social login provider registered: Dropbox") - else: - logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured") + 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. -if AUTH_ENABLED and settings.social_auth_github_enabled: - if settings.social_auth_github_client_id and settings.social_auth_github_client_secret: - oauth.register( - name="github", - client_id=settings.social_auth_github_client_id, - client_secret=settings.social_auth_github_client_secret, - authorize_url="https://github.com/login/oauth/authorize", - access_token_url="https://github.com/login/oauth/access_token", - userinfo_endpoint="https://api.github.com/user", - client_kwargs={"scope": "read:user user:email"}, - ) - SOCIAL_PROVIDERS["github"] = {"name": "GitHub", "icon": "fab fa-github", "color": "gray"} - logger.info("Social login provider registered: GitHub") - else: - logger.warning("SOCIAL_AUTH_GITHUB_ENABLED=true but client ID/secret not configured") + Can safely be called multiple times, e.g. after a settings reload. + """ + global OAUTH_CONFIGURED, OAUTH_PROVIDER_NAME -if AUTH_ENABLED and settings.social_auth_keycloak_enabled: - _kc_server = settings.social_auth_keycloak_server_url - _kc_realm = settings.social_auth_keycloak_realm - if ( - settings.social_auth_keycloak_client_id - and settings.social_auth_keycloak_client_secret - and _kc_server - and _kc_realm - ): - _kc_base = f"{_kc_server.rstrip('/')}/realms/{_kc_realm}" - oauth.register( - name="keycloak", - client_id=settings.social_auth_keycloak_client_id, - client_secret=settings.social_auth_keycloak_client_secret, - server_metadata_url=f"{_kc_base}/.well-known/openid-configuration", + 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"}, ) - SOCIAL_PROVIDERS["keycloak"] = {"name": "Keycloak", "icon": "fas fa-key", "color": "gray"} - logger.info("Social login provider registered: Keycloak (realm=%s)", _kc_realm) - else: - logger.warning("SOCIAL_AUTH_KEYCLOAK_ENABLED=true but required settings not configured") + OAUTH_CONFIGURED = True + OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO" -if AUTH_ENABLED and settings.social_auth_generic_oauth2_enabled: - if ( - settings.social_auth_generic_oauth2_client_id - and settings.social_auth_generic_oauth2_client_secret - and settings.social_auth_generic_oauth2_authorize_url - and settings.social_auth_generic_oauth2_token_url - ): - oauth.register( - name="generic_oauth2", - client_id=settings.social_auth_generic_oauth2_client_id, - client_secret=settings.social_auth_generic_oauth2_client_secret, - authorize_url=settings.social_auth_generic_oauth2_authorize_url, - access_token_url=settings.social_auth_generic_oauth2_token_url, - userinfo_endpoint=settings.social_auth_generic_oauth2_userinfo_url, - client_kwargs={"scope": settings.social_auth_generic_oauth2_scope}, - ) - _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"} - logger.info("Social login provider registered: Generic OAuth2 (%s)", _generic_name) - else: - logger.warning("SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true but required settings not configured") + # --- 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_secret = settings.social_auth_dropbox_client_secret + if settings.social_auth_dropbox_use_global_credentials and not (_dropbox_client_id and _dropbox_client_secret): + _dropbox_client_id = settings.dropbox_app_key + _dropbox_client_secret = settings.dropbox_app_secret + + if _dropbox_client_id and _dropbox_client_secret: + _register_oauth_client( + "dropbox", + client_id=_dropbox_client_id, + client_secret=_dropbox_client_secret, + authorize_url="https://www.dropbox.com/oauth2/authorize", + access_token_url="https://api.dropboxapi.com/oauth2/token", + userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account", + userinfo_compliance_fix=_dropbox_userinfo_compliance_fix, + client_kwargs={ + "token_endpoint_auth_method": "client_secret_post", + "token_access_type": "offline", + }, + ) + SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"} + logger.info("Social login provider registered: Dropbox") + else: + logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured") + + # GitHub + if settings.social_auth_github_enabled: + if settings.social_auth_github_client_id and settings.social_auth_github_client_secret: + _register_oauth_client( + "github", + client_id=settings.social_auth_github_client_id, + client_secret=settings.social_auth_github_client_secret, + authorize_url="https://github.com/login/oauth/authorize", + access_token_url="https://github.com/login/oauth/access_token", + userinfo_endpoint="https://api.github.com/user", + client_kwargs={"scope": "read:user user:email"}, + ) + SOCIAL_PROVIDERS["github"] = {"name": "GitHub", "icon": "fab fa-github", "color": "gray"} + logger.info("Social login provider registered: GitHub") + else: + logger.warning("SOCIAL_AUTH_GITHUB_ENABLED=true but client ID/secret not configured") + + # Keycloak + if settings.social_auth_keycloak_enabled: + _kc_server = settings.social_auth_keycloak_server_url + _kc_realm = settings.social_auth_keycloak_realm + if ( + settings.social_auth_keycloak_client_id + and settings.social_auth_keycloak_client_secret + and _kc_server + and _kc_realm + ): + _kc_base = f"{_kc_server.rstrip('/')}/realms/{_kc_realm}" + _register_oauth_client( + "keycloak", + client_id=settings.social_auth_keycloak_client_id, + client_secret=settings.social_auth_keycloak_client_secret, + server_metadata_url=f"{_kc_base}/.well-known/openid-configuration", + client_kwargs={"scope": "openid profile email"}, + ) + SOCIAL_PROVIDERS["keycloak"] = {"name": "Keycloak", "icon": "fas fa-key", "color": "gray"} + logger.info("Social login provider registered: Keycloak (realm=%s)", _kc_realm) + else: + logger.warning("SOCIAL_AUTH_KEYCLOAK_ENABLED=true but required settings not configured") + + # Generic OAuth2 + if settings.social_auth_generic_oauth2_enabled: + if ( + settings.social_auth_generic_oauth2_client_id + and settings.social_auth_generic_oauth2_client_secret + and settings.social_auth_generic_oauth2_authorize_url + and settings.social_auth_generic_oauth2_token_url + ): + _register_oauth_client( + "generic_oauth2", + client_id=settings.social_auth_generic_oauth2_client_id, + client_secret=settings.social_auth_generic_oauth2_client_secret, + authorize_url=settings.social_auth_generic_oauth2_authorize_url, + access_token_url=settings.social_auth_generic_oauth2_token_url, + userinfo_endpoint=settings.social_auth_generic_oauth2_userinfo_url, + client_kwargs={"scope": settings.social_auth_generic_oauth2_scope}, + ) + _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", + } + logger.info("Social login provider registered: Generic OAuth2 (%s)", _generic_name) + else: + 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() diff --git a/app/main.py b/app/main.py index 21b50f30..89477ceb 100644 --- a/app/main.py +++ b/app/main.py @@ -189,6 +189,18 @@ async def lifespan(app: FastAPI): finally: 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 # via the database UI (e.g. SENTRY_DSN) are respected in addition to env vars. init_sentry() diff --git a/app/utils/settings_sync.py b/app/utils/settings_sync.py index 3bcca417..aeeb9a62 100644 --- a/app/utils/settings_sync.py +++ b/app/utils/settings_sync.py @@ -71,6 +71,16 @@ def notify_settings_updated() -> None: except Exception as 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 # are updated. This ensures that if a user changes tesseract_language or # easyocr_languages via the UI, the new language data is downloaded without diff --git a/app/views/settings.py b/app/views/settings.py index 85307a82..0c7acdd5 100644 --- a/app/views/settings.py +++ b/app/views/settings.py @@ -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. """ try: - from app.auth import OAUTH_CONFIGURED, SOCIAL_PROVIDERS - db_settings = get_all_settings_from_db(db) def _get_effective(key: str): @@ -227,13 +225,14 @@ async def connections_page(request: Request, db: Session = Depends(get_db)): services = [] # --- SSO (Authentik / OIDC) --- + _oidc_linked = bool(_get_effective("authentik_client_id") and _get_effective("authentik_client_secret")) services.append( { "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", "type": "SSO", - "linked": OAUTH_CONFIGURED, + "linked": _oidc_linked, "description": "OpenID Connect SSO provider", "settings_keys": [ "authentik_client_id", @@ -245,13 +244,23 @@ async def connections_page(request: Request, db: Session = Depends(get_db)): ) # --- 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( { "key": "google", "name": "Google", "icon": "fab fa-google", "type": "Sign-in authentication", - "linked": "google" in SOCIAL_PROVIDERS, + "linked": _google_linked, "description": "Sign-in authentication", "settings_keys": [ "social_auth_google_enabled", @@ -263,13 +272,18 @@ async def connections_page(request: Request, db: Session = Depends(get_db)): ) # --- 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( { "key": "github", "name": "GitHub", "icon": "fab fa-github", "type": "Sign-in authentication", - "linked": "github" in SOCIAL_PROVIDERS, + "linked": _github_linked, "description": "Sign-in authentication", "settings_keys": [ "social_auth_github_enabled", @@ -280,13 +294,19 @@ async def connections_page(request: Request, db: Session = Depends(get_db)): ) # --- 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( { "key": "microsoft", "name": "Microsoft", "icon": "fab fa-microsoft", "type": "Sign-in authentication", - "linked": "microsoft" in SOCIAL_PROVIDERS, + "linked": _microsoft_linked, "description": "Sign-in authentication", "settings_keys": [ "social_auth_microsoft_enabled", @@ -299,13 +319,18 @@ async def connections_page(request: Request, db: Session = Depends(get_db)): ) # --- 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( { "key": "apple", "name": "Apple", "icon": "fab fa-apple", "type": "Sign-in authentication", - "linked": "apple" in SOCIAL_PROVIDERS, + "linked": _apple_linked, "description": "Sign-in authentication", "settings_keys": [ "social_auth_apple_enabled", @@ -318,13 +343,19 @@ async def connections_page(request: Request, db: Session = Depends(get_db)): ) # --- 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( { "key": "dropbox", "name": "Dropbox", "icon": "fab fa-dropbox", "type": "Sign-in authentication", - "linked": "dropbox" in SOCIAL_PROVIDERS, + "linked": _dropbox_linked, "description": "Sign-in authentication", "settings_keys": [ "social_auth_dropbox_enabled", @@ -336,13 +367,20 @@ async def connections_page(request: Request, db: Session = Depends(get_db)): ) # --- 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( { "key": "keycloak", "name": "Keycloak", "icon": "fas fa-key", "type": "SSO", - "linked": "keycloak" in SOCIAL_PROVIDERS, + "linked": _keycloak_linked, "description": "SSO", "settings_keys": [ "social_auth_keycloak_enabled", @@ -355,13 +393,20 @@ async def connections_page(request: Request, db: Session = Depends(get_db)): ) # --- 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( { "key": "generic_oauth2", "name": "Generic OAuth2", "icon": "fas fa-sign-in-alt", "type": "SSO", - "linked": "generic_oauth2" in SOCIAL_PROVIDERS, + "linked": _generic_oauth2_linked, "description": "SSO", "settings_keys": [ "social_auth_generic_oauth2_enabled", @@ -474,7 +519,7 @@ async def connections_page(request: Request, db: Session = Depends(get_db)): "services": services, "service_settings": service_settings, "sso_auto_login": sso_auto_login, - "oauth_configured": OAUTH_CONFIGURED, + "oauth_configured": _oidc_linked, "qr_login_enabled": qr_login_enabled, "frontend_url_configured": frontend_url_configured, "app_version": settings.version, diff --git a/tests/test_connections.py b/tests/test_connections.py index f19ccedc..f22aae30 100644 --- a/tests/test_connections.py +++ b/tests/test_connections.py @@ -268,8 +268,6 @@ class TestConnectionsPageRoute: patch("app.views.settings.get_all_settings_from_db", return_value={}), patch("app.views.settings.templates") as mock_templates, 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={}), ): mock_templates.TemplateResponse.return_value = "response" @@ -296,6 +294,175 @@ class TestConnectionsPageRoute: assert "smtp" 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 class TestTranslationKeys: