1
0
mirror of synced 2026-10-23 07:08:55 +03:00

Allow the login page to be skipped when OAuth2 is the only authentication source. #9975 (#10575)

This commit is contained in:
Dave Page
2026-10-14 17:19:12 +01:00
parent a00cd1dd43
commit e8f7a18c98
5 changed files with 315 additions and 1 deletions

View File

@@ -88,6 +88,10 @@ and secure.
"OAUTH2_AUTO_CREATE_USER", "Set the value to *True* if you want to automatically
create a pgAdmin user corresponding to a successfully authenticated Oauth2 user.
Please note that password is not stored in the pgAdmin database."
"OAUTH2_AUTO_LOGIN", "Set the value to *True* to send users straight to the OAuth2 provider
instead of showing the pgAdmin login page. This only takes effect when *oauth2* is the only
value in AUTHENTICATION_SOURCES and exactly one provider is configured. The default is *False*.
See :ref:`oauth2_auto_login` below."
"OAUTH2_ADDITIONAL_CLAIMS", "If a dictionary is provided, pgAdmin will check for a matching key and value on the **ID token first** (for OIDC providers), then fall back to the userinfo endpoint response. In case there is no match with the provided config, the user will receive an authorization error. Useful for checking AzureAD_ *wids* or *groups*, GitLab_ *owner*, *maintainer* and *reporter* claims."
"OAUTH2_SERVER_GROUP_CLAIM", "The name of a claim listing the server groups whose shared servers the user may access. If set, shared servers owned by other users are only visible and accessible if the name of their server group is in the claim. See :ref:`oauth2_server_group_claims` below."
"OAUTH2_SERVER_GROUP_CLAIM_MAPPING", "An optional dictionary mapping values of the OAUTH2_SERVER_GROUP_CLAIM claim to a server group name or a list of server group names. Values without a mapping are used as server group names unchanged."
@@ -95,6 +99,9 @@ and secure.
This may need to set False, in case of self-signed certificates."
"OAUTH2_CHALLENGE_METHOD", "Enable PKCE workflow. PKCE method name, only *S256* is supported"
"OAUTH2_RESPONSE_TYPE", "Enable PKCE workflow. Mandatory with OAUTH2_CHALLENGE_METHOD, must be set to *code*"
"OAUTH2_LOGOUT_URL", "The provider's logout endpoint, used to end the user's session with the provider
when they log out of pgAdmin. *{redirect_uri}* and *{id_token}* in the value are replaced with the pgAdmin
URL and the user's ID token. Ex: *https://example.com/realms/master/protocol/openid-connect/logout?post_logout_redirect_uri={redirect_uri}&id_token_hint={id_token}* (for Keycloak)"
Redirect URL
============
@@ -121,6 +128,21 @@ After configuration, on restart, you can see the login page with the Oauth2 logi
:alt: Oauth2 login
:align: center
.. _oauth2_auto_login:
Skipping the Login Page
-----------------------
If OAuth2 is the only authentication source and a single provider is configured, set
OAUTH2_AUTO_LOGIN to *True* to send users straight to the provider rather than showing them the
login page with a single button. Note that any LOGIN_BANNER will not be seen in this case.
The login page is still shown after a login fails, so that the error can be read, and after the
user logs out, so that they are not logged straight back in by the provider. Setting
OAUTH2_LOGOUT_URL for the provider is recommended, so that logging out of pgAdmin also ends the
user's session with the provider. To show the login page at any other time, add *?no_auto_login*
to its URL, for example *https://pgadmin.example.com/login?no_auto_login*.
PKCE Workflow
=============

View File

@@ -967,6 +967,13 @@ OAUTH2_CONFIG = [
OAUTH2_AUTO_CREATE_USER = True
# If set to True, and OAuth2 is the only authentication source with exactly
# one provider in OAUTH2_CONFIG, users are sent straight to that provider
# instead of seeing the pgAdmin login page. After logging out, the login
# page is shown so the provider does not log the user straight back in.
# Add ?no_auto_login to the login page URL to display it regardless.
OAUTH2_AUTO_LOGIN = False
##########################################################################
# Webserver Configuration
##########################################################################

View File

@@ -914,6 +914,15 @@ def create_app(app_name=None):
request.endpoint in ('redirects.index', 'security.login') and \
app.PGADMIN_EXTERNAL_AUTH_SOURCE in [KERBEROS, WEBSERVER]:
return authenticate.login()
elif config.SERVER_MODE and not current_user.is_authenticated and \
request.endpoint in ('redirects.index', 'security.login') and \
app.PGADMIN_EXTERNAL_AUTH_SOURCE == OAUTH2:
# Send the user straight to the OAuth2 provider if
# OAUTH2_AUTO_LOGIN allows it; otherwise show the login page.
from pgadmin.authenticate.oauth2 import auto_login_redirect
response = auto_login_redirect()
if response is not None:
return response
# if the server is restarted the in memory key will be lost
# but the user session may still be active. Logout the user
# to get the key again when login

View File

@@ -34,7 +34,59 @@ 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'}
_TOP_LEVEL_OAUTH2_SETTINGS = {'OAUTH2_CONFIG', 'OAUTH2_AUTO_CREATE_USER',
'OAUTH2_AUTO_LOGIN'}
def get_auto_login_provider():
"""
Return the name of the OAuth2 provider that users should be sent to
without seeing the login page, or None if auto-login does not apply.
Auto-login only applies when OAUTH2_AUTO_LOGIN is enabled, OAuth2 is the
only authentication source, and exactly one provider is configured, as
otherwise the user has a choice to make on the login page.
"""
if not getattr(config, 'OAUTH2_AUTO_LOGIN', False):
return None
if config.AUTHENTICATION_SOURCES != [OAUTH2]:
return None
if len(config.OAUTH2_CONFIG) != 1:
return None
return config.OAUTH2_CONFIG[0].get('OAUTH2_NAME')
def auto_login_redirect():
"""
Return a redirect to the OAuth2 provider's authorisation endpoint if the
login page should be skipped for this request, or None to show it.
The login page is still shown when there are flashed messages waiting
to be displayed (for example a failed login or a logout, which would
otherwise loop straight back to the provider), and when the request has
a no_auto_login query parameter, so an administrator can always reach it.
"""
provider = get_auto_login_provider()
if provider is None or request.method != 'GET' or \
session.get('_flashes') or 'no_auto_login' in request.args:
return None
from pgadmin.authenticate import get_auth_sources
client = get_auth_sources(OAUTH2).oauth2_clients.get(provider)
if client is None:
return None
try:
response = client.authorize_redirect(
url_for(OAUTH2_AUTHORIZE, _external=True))
except Exception as e:
current_app.logger.exception(
'OAuth2 auto-login to provider "%s" failed: %s', provider, e)
return None
# The /authorize callback reads the provider from the session.
session['oauth2_current_client'] = provider
return response
def warn_on_misplaced_oauth2_config(app):
@@ -160,6 +212,12 @@ def init_app(app):
for key in list(session.keys()):
session.pop(key)
# With auto-login on, the login page would otherwise send the user
# straight back to the provider. A pending message stops that, so
# they see that they have logged out and can choose to log in again.
if get_auto_login_provider() is not None:
flash(gettext('You have been logged out.'), MessageType.INFO)
if logout_url:
return redirect(logout_url.format(
redirect_uri=request.url_root,

View File

@@ -0,0 +1,218 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
from unittest.mock import patch
from flask import current_app
import config as app_config
from pgAdmin4 import app
from pgadmin.authenticate.registry import AuthSourceRegistry
from pgadmin.utils.constants import OAUTH2, INTERNAL, MessageType
from pgadmin.utils.route import BaseTestGenerator
from regression.python_test_utils import test_utils as utils
PROVIDER = {
'OAUTH2_NAME': 'github',
'OAUTH2_DISPLAY_NAME': 'Github',
'OAUTH2_CLIENT_ID': 'testclientid',
'OAUTH2_CLIENT_SECRET': 'testclientsec',
'OAUTH2_TOKEN_URL': 'https://github.com/login/oauth/access_token',
'OAUTH2_AUTHORIZATION_URL': 'https://github.com/login/oauth/authorize',
'OAUTH2_API_BASE_URL': 'https://api.github.com/',
'OAUTH2_USERINFO_ENDPOINT': 'user',
'OAUTH2_SCOPE': 'email profile',
}
SECOND_PROVIDER = dict(PROVIDER, OAUTH2_NAME='gitlab',
OAUTH2_DISPLAY_NAME='GitLab')
class Oauth2AutoLoginTestCase(BaseTestGenerator):
"""
Check that OAUTH2_AUTO_LOGIN sends users straight to the provider only
when it should, and that the login page is shown otherwise.
"""
scenarios = [
('Auto-login redirects to the single provider', dict(
kind='redirects', url='/login', auto_login=True,
sources=[OAUTH2], providers=[PROVIDER],
)),
('Auto-login redirects from the root URL', dict(
kind='redirects', url='/', auto_login=True,
sources=[OAUTH2], providers=[PROVIDER],
)),
('No redirect when auto-login is disabled', dict(
kind='login_page', url='/login', auto_login=False,
sources=[OAUTH2], providers=[PROVIDER],
)),
('No redirect with more than one provider', dict(
kind='login_page', url='/login', auto_login=True,
sources=[OAUTH2], providers=[PROVIDER, SECOND_PROVIDER],
)),
('No redirect with another authentication source', dict(
kind='login_page', url='/login', auto_login=True,
sources=[OAUTH2, INTERNAL], providers=[PROVIDER],
)),
('No redirect with no_auto_login in the URL', dict(
kind='login_page', url='/login?no_auto_login', auto_login=True,
sources=[OAUTH2], providers=[PROVIDER],
)),
('No redirect while a message is waiting to be shown', dict(
kind='flashed_message', url='/login', auto_login=True,
sources=[OAUTH2], providers=[PROVIDER],
)),
('No redirect if the provider redirect fails', dict(
kind='provider_error', url='/login', auto_login=True,
sources=[OAUTH2], providers=[PROVIDER],
)),
('Login page shown after logout', dict(
kind='logout', url='/login', auto_login=True,
sources=[OAUTH2], providers=[PROVIDER],
)),
]
SAVED_SETTINGS = ('AUTHENTICATION_SOURCES', 'OAUTH2_CONFIG',
'OAUTH2_AUTO_CREATE_USER', 'OAUTH2_AUTO_LOGIN')
@classmethod
def setUpClass(cls):
"""Logout the test client as we are testing OAuth2 login."""
cls.saved_settings = {
name: getattr(app_config, name)
for name in cls.SAVED_SETTINGS if hasattr(app_config, name)
}
cls.tester.logout()
def setUp(self):
app_config.AUTHENTICATION_SOURCES = self.sources
self.app.PGADMIN_EXTERNAL_AUTH_SOURCE = OAUTH2
app_config.OAUTH2_AUTO_CREATE_USER = True
app_config.OAUTH2_CONFIG = self.providers
app_config.OAUTH2_AUTO_LOGIN = self.auto_login
self._reset_oauth2_state()
def _reset_oauth2_state(self):
"""Reset the cached auth sources so the config above is used."""
AuthSourceRegistry._objects = dict()
with self.app.app_context():
setattr(current_app, '_pgadmin_auth_sources', {})
from pgadmin.authenticate.oauth2 import OAuth2Authentication
OAuth2Authentication.oauth2_clients = {}
OAuth2Authentication.oauth2_config = {}
def runTest(self):
if app_config.SERVER_MODE is False:
self.skipTest(
"Can not run Oauth2 Authentication in the Desktop mode."
)
if self.kind == 'redirects':
self._test_redirects()
elif self.kind == 'login_page':
self._assert_login_page()
elif self.kind == 'flashed_message':
with self.tester.session_transaction() as sess:
sess['_flashes'] = [(MessageType.ERROR, 'Login failed')]
self._assert_login_page()
elif self.kind == 'provider_error':
from authlib.integrations.flask_client import FlaskOAuth2App
with patch.object(FlaskOAuth2App, 'authorize_redirect',
side_effect=RuntimeError('unreachable')):
self._assert_login_page()
elif self.kind == 'logout':
self._test_logout()
else:
self.fail(f'Unknown test kind: {self.kind}')
def _test_redirects(self):
res = self.tester.get(self.url, follow_redirects=False)
self.assertEqual(res.status_code, 302)
self.assertTrue(
res.location.startswith(PROVIDER['OAUTH2_AUTHORIZATION_URL']),
'Expected a redirect to the provider, got %s' % res.location)
with self.tester.session_transaction() as sess:
self.assertEqual(sess.get('oauth2_current_client'),
PROVIDER['OAUTH2_NAME'])
def _assert_login_page(self):
res = self.tester.get(self.url, follow_redirects=False)
self.assertEqual(res.status_code, 200)
with self.tester.session_transaction() as sess:
self.assertNotIn('oauth2_current_client', sess)
def _test_logout(self):
from pgadmin.authenticate.oauth2 import OAuth2Authentication
def _fake_authenticate(self, _form):
self.oauth2_current_client = PROVIDER['OAUTH2_NAME']
return True, None
def _fake_get_user_profile(self):
from flask import session
session['oauth2_token'] = {
'access_token': 'test-access-token',
'id_token': 'mock.jwt.token',
'token_type': 'Bearer',
'userinfo': {'email': 'oauth2@example.com'}
}
return {'email': 'oauth2@example.com'}
# The test client's login fetches the login page first, so log in
# with auto-login off, then turn it on before logging out.
app_config.OAUTH2_AUTO_LOGIN = False
with patch.object(
OAuth2Authentication, 'authenticate', new=_fake_authenticate
), patch.object(
OAuth2Authentication, 'get_user_profile',
new=_fake_get_user_profile
):
res = self.tester.login(
email=None, password=None,
_follow_redirects=True,
headers=None,
extra_form_data=dict(oauth2_button=PROVIDER['OAUTH2_NAME'])
)
self.assertEqual(res.status_code, 200)
with self.tester.session_transaction() as sess:
self.assertEqual(
sess.get('auth_source_manager', {}).get('current_source'),
OAUTH2)
app_config.OAUTH2_AUTO_LOGIN = True
res = self.tester.get('/oauth2/logout', follow_redirects=False)
self.assertEqual(res.status_code, 302)
with self.tester.session_transaction() as sess:
self.assertIn('_flashes', sess)
# The login page is shown with the message, rather than sending the
# user back to the provider.
self._assert_login_page()
# Once the message has been shown, auto-login applies again.
self._test_redirects()
def tearDown(self):
self.tester.logout()
with self.tester.session_transaction() as sess:
sess.clear()
@classmethod
def tearDownClass(cls):
"""Log the test client back in now that the scenarios are done."""
cls.tester.logout()
for name in cls.SAVED_SETTINGS:
if name in cls.saved_settings:
setattr(app_config, name, cls.saved_settings[name])
elif hasattr(app_config, name):
delattr(app_config, name)
app_config.PGADMIN_EXTERNAL_AUTH_SOURCE = INTERNAL
app.PGADMIN_EXTERNAL_AUTH_SOURCE = INTERNAL
utils.login_tester_account(cls.tester)