From 2609227cf1fca4dae1b0d1aa597623d2200c711e Mon Sep 17 00:00:00 2001 From: Dave Page Date: Fri, 19 Jun 2026 11:23:55 +0100 Subject: [PATCH 1/2] Warn when OAuth2 provider settings are misplaced at the top level of the config pgAdmin reads OAuth2 provider settings only from the OAUTH2_CONFIG list, so values supplied at the top level of the configuration (for example a bare OAUTH2_CLIENT_ID or OAUTH2_SSL_CERT_VERIFICATION) are silently ignored. This is an easy trap in container deployments, where every other option is set via an individual PGADMIN_CONFIG_ environment variable and it is natural to assume PGADMIN_CONFIG_OAUTH2_CLIENT_ID and friends behave the same way, when in fact the whole provider list must be supplied through a single PGADMIN_CONFIG_OAUTH2_CONFIG. Detect this at startup and log an actionable warning, turning a silent misconfiguration into a diagnosable one, and document the supported approach for container deployments. Closes #10053 --- docs/en_US/oauth2.rst | 19 ++++ web/pgadmin/authenticate/oauth2.py | 54 +++++++++++ .../tests/test_oauth2_misplaced_config.py | 89 +++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 web/pgadmin/browser/tests/test_oauth2_misplaced_config.py diff --git a/docs/en_US/oauth2.rst b/docs/en_US/oauth2.rst index 10cec8b9087..bbc6ecb81f5 100644 --- a/docs/en_US/oauth2.rst +++ b/docs/en_US/oauth2.rst @@ -11,6 +11,25 @@ file (see the :ref:`config.py ` documentation) on the system where pgAdmin is installed in Server mode. You can copy these settings from *config.py* file and modify the values for the following parameters. +.. warning:: + pgAdmin reads OAuth2 provider settings **only** from the ``OAUTH2_CONFIG`` + list; each provider is a single dictionary entry within that list. Settings + such as ``OAUTH2_CLIENT_ID`` or ``OAUTH2_SSL_CERT_VERIFICATION`` defined at + the top level of the configuration are ignored. + + This matters in container deployments. Although most options can be set with + an individual ``PGADMIN_CONFIG_`` environment variable, OAuth2 is the + exception: individual variables such as ``PGADMIN_CONFIG_OAUTH2_CLIENT_ID`` + or ``PGADMIN_CONFIG_OAUTH2_SSL_CERT_VERIFICATION`` will **not** configure a + provider. Instead, configure the whole provider list through a single + ``PGADMIN_CONFIG_OAUTH2_CONFIG`` variable, for example:: + + PGADMIN_CONFIG_OAUTH2_CONFIG="[{'OAUTH2_NAME': 'my-provider', 'OAUTH2_DISPLAY_NAME': 'My Provider', 'OAUTH2_CLIENT_ID': '...', 'OAUTH2_CLIENT_SECRET': '...', 'OAUTH2_SERVER_METADATA_URL': 'https://provider.example.com/.well-known/openid-configuration', 'OAUTH2_SCOPE': 'openid email profile', 'OAUTH2_SSL_CERT_VERIFICATION': False}]" + + If pgAdmin detects per-provider OAuth2 settings at the top level while no + provider is configured in ``OAUTH2_CONFIG``, it logs a warning at startup + to highlight the misconfiguration. + OAuth2 vs OpenID Connect (OIDC) ================================ diff --git a/web/pgadmin/authenticate/oauth2.py b/web/pgadmin/authenticate/oauth2.py index 2d259c295f3..4e04b5b23be 100644 --- a/web/pgadmin/authenticate/oauth2.py +++ b/web/pgadmin/authenticate/oauth2.py @@ -31,6 +31,58 @@ OAUTH2_LOGOUT = 'oauth2.logout' OAUTH2_AUTHORIZE = 'oauth2.authorize' +# OAuth2 settings that legitimately live at the top level of the +# configuration. Every other OAUTH2_* setting belongs *inside* an entry +# of the OAUTH2_CONFIG list and is ignored if set at the top level. +_TOP_LEVEL_OAUTH2_SETTINGS = {'OAUTH2_CONFIG', 'OAUTH2_AUTO_CREATE_USER'} + + +def warn_on_misplaced_oauth2_config(app): + """Warn when per-provider OAuth2 settings have been supplied as + top-level configuration variables instead of inside an OAUTH2_CONFIG + entry. + + pgAdmin only reads provider settings from the OAUTH2_CONFIG list, so + values such as a top-level OAUTH2_CLIENT_ID or OAUTH2_SSL_CERT_VERIFICATION + are silently ignored. This is an easy trap in container deployments: + every other setting is configured as PGADMIN_CONFIG_, so it is + natural to assume PGADMIN_CONFIG_OAUTH2_CLIENT_ID and friends will work + the same way, when in fact OAuth2 must be configured through a single + PGADMIN_CONFIG_OAUTH2_CONFIG holding the provider list. Surfacing this + loudly turns a silent misconfiguration into a diagnosable one (see + issue #10053). + """ + misplaced = sorted( + key for key in dir(config) + if key.startswith('OAUTH2_') and + key not in _TOP_LEVEL_OAUTH2_SETTINGS + ) + if not misplaced: + return + + # If at least one provider is actually configured in OAUTH2_CONFIG we + # assume the deployment knows what it is doing and stay quiet, even if + # some stray top-level keys are also present. + providers_configured = any( + isinstance(provider, dict) and provider.get('OAUTH2_NAME') + for provider in (getattr(config, 'OAUTH2_CONFIG', None) or []) + ) + if providers_configured: + return + + app.logger.warning( + "OAuth2: the following settings are defined at the top level of the " + "configuration but pgAdmin reads OAuth2 provider settings only from " + "the OAUTH2_CONFIG list, so they are being ignored: %s. If you are " + "configuring OAuth2 through individual PGADMIN_CONFIG_OAUTH2_* " + "environment variables, this will not work; configure a single " + "PGADMIN_CONFIG_OAUTH2_CONFIG holding the provider list instead, " + "e.g. PGADMIN_CONFIG_OAUTH2_CONFIG='[{\"OAUTH2_NAME\": \"...\", " + "\"OAUTH2_CLIENT_ID\": \"...\"}]'. See the OAuth2 documentation for " + "details.", + ", ".join(misplaced) + ) + class Oauth2Module(PgAdminModule): def register(self, app, options): @@ -114,6 +166,8 @@ def oauth_logout(): app.register_blueprint(blueprint) app.login_manager.logout_view = OAUTH2_LOGOUT + warn_on_misplaced_oauth2_config(app) + class OAuth2Authentication(BaseAuthentication): """OAuth Authentication Class""" diff --git a/web/pgadmin/browser/tests/test_oauth2_misplaced_config.py b/web/pgadmin/browser/tests/test_oauth2_misplaced_config.py new file mode 100644 index 00000000000..66c84c22ac1 --- /dev/null +++ b/web/pgadmin/browser/tests/test_oauth2_misplaced_config.py @@ -0,0 +1,89 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +from types import SimpleNamespace +from unittest.mock import patch, MagicMock + +from pgadmin.utils.route import BaseTestGenerator +from pgadmin.authenticate import oauth2 + + +class Oauth2MisplacedConfigTestCase(BaseTestGenerator): + """ + Verify warn_on_misplaced_oauth2_config() warns only when per-provider + OAuth2 settings are supplied as top-level configuration variables while + no provider is actually configured in OAUTH2_CONFIG (issue #10053). + """ + + # The unconfigured default shipped in config.py. + _DEFAULT_OAUTH2_CONFIG = [{'OAUTH2_NAME': None}] + _CONFIGURED_OAUTH2_CONFIG = [{'OAUTH2_NAME': 'authentik'}] + + scenarios = [ + ('Misplaced top-level keys with unconfigured OAUTH2_CONFIG warns', + dict( + config_attrs={ + 'OAUTH2_CONFIG': _DEFAULT_OAUTH2_CONFIG, + 'OAUTH2_AUTO_CREATE_USER': True, + 'OAUTH2_CLIENT_ID': 'redacted', + 'OAUTH2_SSL_CERT_VERIFICATION': False, + }, + expect_warning=True, + expect_keys=['OAUTH2_CLIENT_ID', 'OAUTH2_SSL_CERT_VERIFICATION'], + )), + ('Misplaced top-level keys with a configured provider stays quiet', + dict( + config_attrs={ + 'OAUTH2_CONFIG': _CONFIGURED_OAUTH2_CONFIG, + 'OAUTH2_AUTO_CREATE_USER': True, + 'OAUTH2_CLIENT_ID': 'redacted', + }, + expect_warning=False, + expect_keys=None, + )), + ('Only legitimate top-level OAuth2 settings stays quiet', + dict( + config_attrs={ + 'OAUTH2_CONFIG': _DEFAULT_OAUTH2_CONFIG, + 'OAUTH2_AUTO_CREATE_USER': True, + }, + expect_warning=False, + expect_keys=None, + )), + ('No OAuth2 configuration at all stays quiet', + dict( + config_attrs={ + 'OAUTH2_CONFIG': [], + 'OAUTH2_AUTO_CREATE_USER': True, + }, + expect_warning=False, + expect_keys=None, + )), + ] + + def runTest(self): + app = MagicMock() + app.logger = MagicMock() + fake_config = SimpleNamespace(**self.config_attrs) + + with patch.object(oauth2, 'config', fake_config): + oauth2.warn_on_misplaced_oauth2_config(app) + + if not self.expect_warning: + app.logger.warning.assert_not_called() + return + + app.logger.warning.assert_called_once() + # The misplaced keys are interpolated into the message as the + # single positional argument after the format string. + args = app.logger.warning.call_args.args + self.assertEqual(len(args), 2) + listed = args[1] + for key in self.expect_keys: + self.assertIn(key, listed) From f1a668410146715afedae77086a0de64090b3e53 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Fri, 19 Jun 2026 11:31:24 +0100 Subject: [PATCH 2/2] Add docstring to the misplaced-config test runner --- web/pgadmin/browser/tests/test_oauth2_misplaced_config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/pgadmin/browser/tests/test_oauth2_misplaced_config.py b/web/pgadmin/browser/tests/test_oauth2_misplaced_config.py index 66c84c22ac1..a7f3b3f8ab8 100644 --- a/web/pgadmin/browser/tests/test_oauth2_misplaced_config.py +++ b/web/pgadmin/browser/tests/test_oauth2_misplaced_config.py @@ -68,6 +68,8 @@ class Oauth2MisplacedConfigTestCase(BaseTestGenerator): ] def runTest(self): + """Run a single misplaced-config scenario and assert whether a + startup warning is emitted, and that it lists the expected keys.""" app = MagicMock() app.logger = MagicMock() fake_config = SimpleNamespace(**self.config_attrs)