Skip to content

Commit 89e84eb

Browse files
asheshvdpage
authored andcommitted
OAuth2: actionable error when openid scope lacks OAUTH2_SERVER_METADATA_URL (pgadmin-org#10007)
When OAUTH2_SCOPE contains 'openid' but OAUTH2_SERVER_METADATA_URL is not set, Authlib fails deep inside id_token verification with a cryptic `Missing "jwks_uri" in metadata` error that names neither the config knob nor the fix. Add a pre-flight check at the entry of _authorize_access_token: if the scope includes 'openid' and no (non-whitespace) metadata URL is set, raise a RuntimeError with actionable guidance before any network round-trip. server_metadata_url is the only way pgAdmin feeds JWKS to Authlib, so this carries no regression risk for correctly-configured providers. Clarify the OAUTH2_SERVER_METADATA_URL comment in config.py and add regression coverage.
1 parent 879ae40 commit 89e84eb

4 files changed

Lines changed: 106 additions & 1 deletion

File tree

docs/en_US/release_notes_9_9.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Bug fixes
3535
*********
3636

3737
| `Issue #9098 <https://github.com/pgadmin-org/pgadmin4/issues/9098>`_ - Fixed an issue where the query tool displayed 'default' instead of 'null' for null text data in the data output.
38+
| `Issue #9988 <https://github.com/pgadmin-org/pgadmin4/issues/9988>`_ - Provide an actionable error when 'openid' is in OAUTH2_SCOPE but OAUTH2_SERVER_METADATA_URL is not set, instead of a cryptic Authlib failure.
3839
| `Issue #9125 <https://github.com/pgadmin-org/pgadmin4/issues/9125>`_ - Fixed an issue where the pgAdmin configuration database wasn't being created on a fresh install when an external database was used for the configuration.
3940
| `Issue #9157 <https://github.com/pgadmin-org/pgadmin4/issues/9157>`_ - Fixed an issue where shortcuts are not working as expected on multiple keyboard layouts.
4041
| `Issue #9158 <https://github.com/pgadmin-org/pgadmin4/issues/9158>`_ - Fixed an issue where saving the newly changed preferences was not reflecting on the preferences tab.

web/config.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -822,7 +822,10 @@
822822
# URL is used for authentication,
823823
# Ex: https://github.com/login/oauth/authorize
824824
'OAUTH2_AUTHORIZATION_URL': None,
825-
# server metadata url might optional for your provider
825+
# OpenID Connect discovery URL for the provider. Required when
826+
# OAUTH2_SCOPE contains 'openid' (so pgAdmin can fetch the JWKS
827+
# to verify the id_token); optional otherwise.
828+
# Example: https://<issuer>/.well-known/openid-configuration
826829
'OAUTH2_SERVER_METADATA_URL': None,
827830
# Oauth base url, ex: https://api.github.com/
828831
'OAUTH2_API_BASE_URL': None,

web/pgadmin/authenticate/oauth2.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,33 @@ def _read_workload_identity_assertion(self, provider_name, provider):
284284
return token
285285

286286
def _authorize_access_token(self, provider_name, provider, client):
287+
# Pre-flight: if the provider's scope includes 'openid', the
288+
# OAuth server will return an id_token that pgAdmin must
289+
# verify. Verification requires JWKS, which Authlib fetches
290+
# via the discovery document. Without OAUTH2_SERVER_METADATA_URL,
291+
# verification fails deep inside Authlib with a cryptic
292+
# `Missing "jwks_uri" in metadata` error. Catch the misconfig
293+
# here with actionable guidance, before any network round-trip.
294+
scope = provider.get('OAUTH2_SCOPE') or ''
295+
scope_parts = scope.split() if isinstance(scope, str) else []
296+
metadata_url = provider.get('OAUTH2_SERVER_METADATA_URL')
297+
if isinstance(metadata_url, str):
298+
metadata_url = metadata_url.strip()
299+
if 'openid' in scope_parts and not metadata_url:
300+
guidance = gettext(
301+
"OAuth2 provider '%(name)s' is configured with 'openid' "
302+
"in OAUTH2_SCOPE but OAUTH2_SERVER_METADATA_URL is not "
303+
"set. pgAdmin needs the provider's OpenID Connect "
304+
"discovery URL to verify the id_token. Either set "
305+
"OAUTH2_SERVER_METADATA_URL to the discovery URL "
306+
"(e.g. https://<issuer>/.well-known/openid-"
307+
"configuration), or remove 'openid' from OAUTH2_SCOPE."
308+
) % {'name': provider_name}
309+
current_app.logger.error(
310+
"OAuth2 (%s): %s", provider_name, guidance
311+
)
312+
raise RuntimeError(guidance)
313+
287314
client_auth_method = provider.get(
288315
'OAUTH2_CLIENT_AUTH_METHOD', 'client_secret'
289316
)

web/pgadmin/browser/tests/test_oauth2_with_mocking.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ class Oauth2LoginMockTestCase(BaseTestGenerator):
138138
profile={},
139139
id_token_claims=None,
140140
)),
141+
('OAuth2 openid Scope Without Metadata URL Fails Fast', dict(
142+
oauth2_provider='oidc-no-metadata',
143+
kind='openid_without_metadata_url',
144+
profile={},
145+
id_token_claims=None,
146+
)),
141147
]
142148

143149
@classmethod
@@ -320,6 +326,8 @@ def runTest(self):
320326
self._test_session_state_after_redirect(self.oauth2_provider)
321327
elif self.kind == 'callback_missing_provider_state':
322328
self._test_oauth2_callback_missing_provider_state()
329+
elif self.kind == 'openid_without_metadata_url':
330+
self._test_openid_scope_without_metadata_url_fails_fast()
323331
else:
324332
self.fail(f'Unknown test kind: {self.kind}')
325333

@@ -776,6 +784,72 @@ def _test_oauth2_callback_missing_provider_state(self):
776784
# an unhandled exception.
777785
self.assertLess(res.status_code, 500)
778786

787+
def _test_openid_scope_without_metadata_url_fails_fast(self):
788+
"""'openid' in OAUTH2_SCOPE without OAUTH2_SERVER_METADATA_URL must
789+
fail fast with actionable guidance, before any network round-trip.
790+
791+
Also covers the whitespace-only metadata URL bypass and proves the
792+
check does not false-positive when the metadata URL is set.
793+
"""
794+
app_config.OAUTH2_CONFIG = [{
795+
'OAUTH2_NAME': 'oidc-no-metadata',
796+
'OAUTH2_DISPLAY_NAME': 'OIDC No Metadata',
797+
'OAUTH2_CLIENT_ID': 'testclientid',
798+
'OAUTH2_CLIENT_SECRET': 'testclientsec',
799+
'OAUTH2_TOKEN_URL': 'https://idp.example/token',
800+
'OAUTH2_AUTHORIZATION_URL': 'https://idp.example/auth',
801+
'OAUTH2_API_BASE_URL': 'https://idp.example/',
802+
'OAUTH2_SCOPE': 'openid email profile',
803+
# OAUTH2_SERVER_METADATA_URL deliberately omitted.
804+
}]
805+
806+
with patch('pgadmin.authenticate.oauth2.OAuth.register'):
807+
from pgadmin.authenticate.oauth2 import OAuth2Authentication
808+
auth = OAuth2Authentication()
809+
810+
provider_name = 'oidc-no-metadata'
811+
812+
# The client must never be touched — the pre-flight raises first.
813+
client = MagicMock()
814+
815+
with self.app.test_request_context():
816+
# (a) Metadata URL entirely absent -> RuntimeError.
817+
provider = {
818+
'OAUTH2_SCOPE': 'openid email profile',
819+
}
820+
with self.assertRaises(RuntimeError) as cm:
821+
auth._authorize_access_token(provider_name, provider, client)
822+
msg = str(cm.exception)
823+
self.assertIn('OAUTH2_SERVER_METADATA_URL', msg)
824+
self.assertIn(provider_name, msg)
825+
826+
# (b) Whitespace-only metadata URL must be treated as missing.
827+
provider = {
828+
'OAUTH2_SCOPE': 'openid email profile',
829+
'OAUTH2_SERVER_METADATA_URL': ' ',
830+
}
831+
with self.assertRaises(RuntimeError):
832+
auth._authorize_access_token(provider_name, provider, client)
833+
834+
client.authorize_access_token.assert_not_called()
835+
836+
# (c) No false-positive: metadata URL set -> pre-flight passes
837+
# and the call proceeds to the (mocked) client.
838+
provider = {
839+
'OAUTH2_SCOPE': 'openid email profile',
840+
'OAUTH2_SERVER_METADATA_URL':
841+
'https://idp.example/.well-known/openid-configuration',
842+
}
843+
auth._authorize_access_token(provider_name, provider, client)
844+
client.authorize_access_token.assert_called_once()
845+
846+
# (d) No false-positive: 'openid' absent -> pre-flight skipped
847+
# even without a metadata URL.
848+
client.reset_mock()
849+
provider = {'OAUTH2_SCOPE': 'email profile'}
850+
auth._authorize_access_token(provider_name, provider, client)
851+
client.authorize_access_token.assert_called_once()
852+
779853
def tearDown(self):
780854
self.tester.logout()
781855

0 commit comments

Comments
 (0)