diff --git a/docs/en_US/server_dialog.rst b/docs/en_US/server_dialog.rst index bab8f2effe1..e524357a7fc 100644 --- a/docs/en_US/server_dialog.rst +++ b/docs/en_US/server_dialog.rst @@ -79,6 +79,19 @@ Use the fields in the *Connection* tab to configure a connection: installed on that database. * Use the *Username* field to specify the name of a role that will be used when authenticating with the server. +* In server mode, when Microsoft Entra database authentication is enabled by + the pgAdmin administrator, use *Authentication* to choose *Microsoft Entra + ID*, then click *Sign in to Microsoft*. On a local pgAdmin desktop, the + Microsoft sign-in opens in a browser. On a remote server, pgAdmin can show + a device code and sign-in URL instead. Enter the user's Entra UPN, or an + authorized group name, in *Username*. pgAdmin obtains a PostgreSQL token + for each new connection and renews it when permitted; no database password + is saved. If an organization's sign-in policy requires reauthentication, + pgAdmin prompts for Microsoft sign-in again. This development flow uses + Azure Identity's public client. + The host must be an Azure Database for PostgreSQL host ending in + ``.postgres.database.azure.com``. pgAdmin verifies TLS using its bundled + certificate authorities for this method. * When *Kerberos authentication?* is set to *True*, pgAdmin will try to connect the PostgreSQL server using Kerberos authentication. * Use the *Password* field to provide a password that will be supplied when @@ -333,4 +346,4 @@ Click on the *+* button to add a new tag. Some of the parameters are: .. toctree:: - clear_saved_passwords \ No newline at end of file + clear_saved_passwords diff --git a/web/config.py b/web/config.py index 4d8cd8cb436..b62b37fe2be 100644 --- a/web/config.py +++ b/web/config.py @@ -977,6 +977,15 @@ # Add ?no_auto_login to the login page URL to display it regardless. OAUTH2_AUTO_LOGIN = False +# Enable interactive Microsoft Entra sign-in for Azure PostgreSQL servers. +# Uses the Azure Identity development client and encrypted per-user token +# caches. A dedicated app registration is recommended for production use. +ENTRA_DB_AUTH_ENABLED = False +ENTRA_DB_TENANT_ID = None +# Browser opens on the pgAdmin host; use only when the user and server share +# a desktop. Device code presents a URL and code to remote browser users. +ENTRA_DB_SIGNIN_METHOD = 'device_code' + ########################################################################## # Webserver Configuration ########################################################################## diff --git a/web/migrations/versions/entra_server_auth_.py b/web/migrations/versions/entra_server_auth_.py new file mode 100644 index 00000000000..ac6ee289744 --- /dev/null +++ b/web/migrations/versions/entra_server_auth_.py @@ -0,0 +1,32 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""Add the authentication method for registered PostgreSQL servers. + +Revision ID: entra_server_auth +Revises: server_passexec_name +""" +from alembic import op +import sqlalchemy as sa + +revision = 'entra_server_auth' +down_revision = 'server_passexec_name' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('server') as batch_op: + batch_op.add_column(sa.Column('auth_method', sa.String(16), + nullable=False, + server_default='password')) + + +def downgrade(): + pass diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index b1d5e9cbef6..47c857eb652 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -50,6 +50,8 @@ from pgadmin.settings.utils import with_object_filters from pgadmin.utils.server_access import get_server, \ get_user_server_query, get_server_group +from pgadmin.utils.entra_auth import start_sign_in, \ + EntraAuthenticationError # File-path keys in connection_params that are per-user and must @@ -105,6 +107,50 @@ def prepare_passexec_data(data, is_non_owner): return data, None +def prepare_auth_method_data(data, server=None, is_non_owner=False): + """Validate database authentication without accepting a hidden password.""" + data = dict(data) + method = data.get('auth_method', + server.auth_method if server is not None else 'password') + if method not in ('password', 'entra'): + return data, gettext('Unknown database authentication method.') + if is_non_owner: + data.pop('auth_method', None) + method = server.auth_method + if method == 'entra': + if not config.SERVER_MODE or not getattr( + config, 'ENTRA_DB_AUTH_ENABLED', False): + return data, gettext( + 'Microsoft Entra database authentication is not enabled.') + if data.get('password') or data.get('save_password') or \ + data.get('passexec_cmd') or \ + data.get('passexec_name') not in ( + None, '', '__none__', '__inherit__'): + return data, gettext( + 'Microsoft Entra authentication cannot use a saved ' + 'password or password exec command.') + data['password'] = None + data['save_password'] = False + data['passexec_name'] = None + data['passexec_expiration'] = None + return data, None + + +def require_entra_ssl(params): + """A database access token must not be sent on a plaintext connection.""" + params = dict(params or {}) + params.pop('passfile', None) + params['sslmode'] = 'verify-full' + return params + + +def should_connect_server_on_create(data): + """Defer an Entra connection until this pgAdmin user signs in.""" + return bool(data.get('connect_now')) and not ( + data.get('auth_method') == 'entra' and + not session.get('entra_db_auth')) + + def passexec_api_fields(server, shared_server, is_non_owner): """The password exec fields for the properties response. In server mode the free-text command is never returned, and a non-owner sees @@ -509,7 +555,11 @@ def register_preferences(self): ServerType.register_preferences() def get_exposed_url_endpoints(self): - return ['NODE-server.connect_id'] + return [ + 'NODE-server.connect_id', + 'NODE-server.entra_signin', + 'NODE-server.entra_signin_code', + ] @staticmethod def create_shared_server(data, gid): @@ -624,6 +674,29 @@ def __init__(self, **kwargs): blueprint = ServerModule(__name__) +@blueprint.route('/entra_signin/', methods=['POST'], + endpoint='entra_signin') +@pga_login_required +def entra_signin(): + """Authenticate this pgAdmin user to Azure PostgreSQL.""" + try: + username = start_sign_in() + except EntraAuthenticationError as error: + return make_json_response(success=0, status=401, errormsg=str(error)) + return make_json_response(success=1, data={'username': username}) + + +@blueprint.route('/entra_signin_code/', methods=['GET'], + endpoint='entra_signin_code') +@pga_login_required +def entra_signin_code(): + """Return the current device challenge without exposing a token.""" + if not getattr(config, 'ENTRA_DB_AUTH_ENABLED', False): + return make_json_response(success=0, status=404) + code = session.get('entra_db_code') + return make_json_response(success=1 if code else 0, data=code) + + class ServerNode(PGChildNodeView): node_type = ServerModule._NODE_TYPE node_label = "Server" @@ -957,6 +1030,7 @@ def update(self, gid, sid): 'port': 'port', 'db': 'maintenance_db', 'username': 'username', + 'auth_method': 'auth_method', 'password': 'password', 'save_password': 'save_password', 'gid': 'servergroup_id', @@ -992,6 +1066,7 @@ def update(self, gid, sid): 'port': gettext('Port'), 'db': gettext('Maintenance database'), 'username': gettext('Username'), + 'auth_method': gettext('Authentication'), 'comment': gettext('Comments'), 'role': gettext('Role'), 'service': gettext('Service') @@ -1007,11 +1082,21 @@ def update(self, gid, sid): return make_json_response( success=0, status=400, errormsg=passexec_err) + data, auth_err = prepare_auth_method_data( + data, server, _is_non_owner(server)) + if auth_err: + return make_json_response( + success=0, status=400, errormsg=auth_err) + if 'db_res' in data and isinstance(data['db_res'], list): data['db_res'] = ','.join(data['db_res']) # Update connection parameter if any. self.update_connection_parameter(data, server, sharedserver) + if data.get('auth_method', server.auth_method) == 'entra': + data['connection_params'] = require_entra_ssl( + data.get('connection_params', + (sharedserver or server).connection_params)) self.update_tags(data, sharedserver or server, server.tags if sharedserver else None) @@ -1032,6 +1117,11 @@ def update(self, gid, sid): conn = manager.connection() connected = conn.connected() + if connected and 'auth_method' in data and \ + data['auth_method'] != server.auth_method: + return forbidden(errmsg=gettext( + 'Disconnect the server before changing authentication.')) + disallowed = self._server_modify_disallowed_when_connected( connected, data, disp_lbl) if disallowed: @@ -1116,7 +1206,7 @@ def _set_valid_attr_value(self, gid, data, config_param_map, server, # SharedServer — they enable command/SQL execution # or are owner-level concepts not on SharedServer. _owner_only_fields = frozenset({ - 'passexec_cmd', + 'passexec_cmd', 'auth_method', 'db_res', 'db_res_type', }) @@ -1128,7 +1218,8 @@ def _set_valid_attr_value(self, gid, data, config_param_map, server, continue value = data[arg] if arg == 'password': - value = encrypt(data[arg], crypt_key) + value = encrypt(data[arg], crypt_key) \ + if data[arg] is not None else None # sqlite3 do not have boolean type so we need to # convert it manually to integer. # Only the owner may unshare — this deletes ALL @@ -1277,6 +1368,7 @@ def properties(self, gid, sid): 'shared_username': server.shared_username if config.SERVER_MODE else None, 'username': server.username, + 'auth_method': server.auth_method, 'gid': str(server.servergroup_id), 'group-name': sg.name if (sg and sg.name) else gettext('Servers'), 'comment': server.comment, @@ -1357,6 +1449,11 @@ def create(self, gid): return make_json_response( success=0, status=400, errormsg=passexec_err) + data, auth_err = prepare_auth_method_data(data) + if auth_err: + return make_json_response( + success=0, status=400, errormsg=auth_err) + # Get enc key crypt_key_present, crypt_key = get_crypt_key() if not crypt_key_present: @@ -1402,6 +1499,8 @@ def create(self, gid): # To check ssl configuration _, connection_params = check_ssl_fields(connection_params) + if data.get('auth_method') == 'entra': + connection_params = require_entra_ssl(connection_params) # set the connection params again in the data if 'connection_params' in data: data['connection_params'] = connection_params @@ -1423,6 +1522,7 @@ def create(self, gid): port=data.get('port'), maintenance_db=data.get('db', None), username=data.get('username'), + auth_method=data.get('auth_method', 'password'), save_password=1 if data.get('save_password', False) and config.ALLOW_SAVE_PASSWORD else 0, comment=data.get('comment', None), @@ -1461,7 +1561,7 @@ def create(self, gid): replication_type = None tunnel_password_saved = False - if 'connect_now' in data and data['connect_now']: + if should_connect_server_on_create(data): manager = get_driver(PG_DEFAULT_DRIVER).connection_manager( server.id) manager.update(server) @@ -1478,9 +1578,9 @@ def create(self, gid): have_password = True password = data['password'] password = encrypt(password, crypt_key) - elif 'passfile' in data['connection_params'] and \ - data['connection_params']['passfile'] != '': - passfile = data['connection_params']['passfile'] + elif 'passfile' in connection_params and \ + connection_params['passfile'] != '': + passfile = connection_params['passfile'] if 'tunnel_password' in data and data["tunnel_password"] != '': have_tunnel_password = True @@ -1824,11 +1924,17 @@ def connect(self, gid, sid, is_qt=False, server=None): server_types=ServerType.types() ) except Exception as e: + if server.auth_method == 'entra': + return self.get_response_for_password( + server, 428, errmsg=str(e)) return self.get_response_for_password( server, 401, not server.save_password, prompt_tunnel_password, getattr(e, 'message', str(e))) if not status: + if server.auth_method == 'entra': + return self.get_response_for_password( + server, 428, errmsg=errmsg) current_app.logger.error( "Could not connect to server(#{0}) - '{1}'.\nError: {2}" .format(server.id, server.name, errmsg) @@ -2274,6 +2380,18 @@ def check_pgpass(self, gid, sid): def get_response_for_password(self, server, status, prompt_password=False, prompt_tunnel_password=False, errmsg=None): + if server.auth_method == 'entra' and not prompt_tunnel_password: + return make_json_response( + success=0, status=status, + result={ + 'sid': server.id, + 'server_label': server.name, + 'username': server.username, + 'errmsg': errmsg, + 'prompt_entra': True, + 'entra_signin_method': config.ENTRA_DB_SIGNIN_METHOD, + }) + if server.use_ssh_tunnel: data = { "server_label": server.name, diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js b/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js index e4b6618ebcd..7d102f5e702 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js @@ -16,6 +16,8 @@ import current_user from 'pgadmin.user_management.current_user'; import { isEmptyString } from 'sources/validators'; import VariableSchema from './variable.ui'; import { getRandomColor } from '../../../../../static/js/utils'; +import getApiInstance from '../../../../../static/js/api_instance'; +import url_for from 'sources/url_for'; class TagsSchema extends BaseUISchema { get idAttribute() { return 'old_text'; } @@ -217,6 +219,11 @@ export default class ServerSchema extends BaseUISchema { port: 5432, db: 'postgres', username: current_user.name, + auth_method: 'password', + entra_auth_code: '', + entra_auth_uri: '', + entra_auth_status: '', + entra_authenticating: false, role: null, connect_now: true, password: undefined, @@ -411,9 +418,97 @@ export default class ServerSchema extends BaseUISchema { obj.informText = undefined; } } + },{ + id: 'auth_method', label: gettext('Authentication'), type: 'select', + group: gettext('Connection'), mode: ['properties', 'edit', 'create'], + visible: () => pgAdmin.server_mode == 'True' && + pgAdmin.entra_db_auth_enabled, + options: [ + {label: gettext('Password'), value: 'password'}, + {label: gettext('Microsoft Entra ID'), value: 'entra'}, + ], + readonly: obj.isConnectedOrShared, + depChange: (state) => state.auth_method == 'entra' ? { + password: null, save_password: false, + passexec_name: '__none__', passexec_cmd: null, + passexec_expiration: null, kerberos_conn: false, + } : {}, + helpMessage: gettext('Sign in to Microsoft. pgAdmin refreshes database tokens until your organization requires sign-in again.'), + },{ + id: 'entra_signin', type: 'button', + group: gettext('Connection'), mode: ['create', 'edit'], + btnName: gettext('Sign in to Microsoft'), + visible: (state) => state.auth_method == 'entra', + disabled: (state) => state.entra_authenticating, + onClick: () => { + const schemaState = obj.state; + if (!schemaState) return; + const setState = (key, value) => + schemaState.setUnpreparedData([key], value); + const api = getApiInstance(); + const deviceCode = pgAdmin.entra_db_signin_method != 'browser'; + const popup = deviceCode ? window.open('', 'pgadmin_entra_signin', + 'width=550,height=650,resizable=yes,scrollbars=yes') : null; + setState('entra_authenticating', true); + setState('entra_auth_code', ''); + setState('entra_auth_uri', ''); + setState('entra_auth_status', gettext('Waiting for Microsoft sign-in...')); + let polling = true; + const poll = async () => { + if (!polling) return; + try { + const response = await api.get(url_for('NODE-server.entra_signin_code')); + const code = response.data.data; + if (code && code.user_code) { + setState('entra_auth_code', code.user_code); + setState('entra_auth_uri', code.verification_uri); + if (popup && !popup.closed) popup.location = code.verification_uri; + return; + } + } catch { + // The POST response reports sign-in errors to the user. + } + if (polling) setTimeout(poll, 1000); + }; + if (deviceCode) setTimeout(poll, 500); + api.post(url_for('NODE-server.entra_signin')).then((response) => { + if (response.data.success !== 1) { + throw new Error(response.data.errormsg || + gettext('Microsoft sign-in failed.')); + } + const username = response.data.data?.username; + if (username && (!schemaState.data.username || + schemaState.data.username == current_user.name)) { + setState('username', username); + } + setState('entra_auth_status', gettext('Microsoft sign-in complete.')); + }).catch((error) => { + if (popup && !popup.closed) popup.close(); + setState('entra_auth_status', + error.response?.data?.errormsg || error.message || + gettext('Microsoft sign-in failed.')); + }).finally(() => { + polling = false; + setState('entra_authenticating', false); + setState('entra_auth_code', ''); + }); + }, + },{ + id: 'entra_auth_note', + group: gettext('Connection'), mode: ['create', 'edit'], + deps: ['entra_auth_code', 'entra_auth_status'], + type: (state) => ({ + type: 'note', + text: state.entra_auth_code ? + `${gettext('Open')} ${state.entra_auth_uri} ${gettext('and enter this code:')} ${state.entra_auth_code}` : + state.entra_auth_status, + }), + visible: (state) => state.auth_method == 'entra' && + Boolean(state.entra_auth_code || state.entra_auth_status), },{ id: 'kerberos_conn', label: gettext('Kerberos authentication?'), type: 'switch', group: gettext('Connection'), disabled: obj.isShared, + visible: (state) => state.auth_method != 'entra', },{ id: 'gss_authenticated', label: gettext('GSS authenticated?'), type: 'switch', group: gettext('Connection'), mode: ['properties'], visible: obj.isConnected, @@ -424,7 +519,8 @@ export default class ServerSchema extends BaseUISchema { id: 'password', label: gettext('Password'), type: 'password', group: gettext('Connection'), mode: ['create', 'edit'], - deps: ['kerberos_conn', 'save_password'], + deps: ['kerberos_conn', 'save_password', 'auth_method'], + visible: (state) => state.auth_method != 'entra', controlProps: { maxLength: null, autoComplete: 'new-password' @@ -439,7 +535,8 @@ export default class ServerSchema extends BaseUISchema { },{ id: 'save_password', label: gettext('Save password?'), type: 'switch', group: gettext('Connection'), mode: ['create', 'edit'], - deps: ['kerberos_conn'], + deps: ['kerberos_conn', 'auth_method'], + visible: (state) => state.auth_method != 'entra', readonly: function(state) { return state.connected; }, @@ -605,6 +702,7 @@ export default class ServerSchema extends BaseUISchema { group: gettext('Advanced'), controlProps: {maxLength: null}, mode: ['properties', 'edit', 'create'], visible: () => pgAdmin.server_mode != 'True', + disabled: (state) => state.auth_method == 'entra', helpMessage: gettext('The server hostname, port, and username can be passed as variables by using the placeholders %HOSTNAME%, %PORT%, and %USERNAME%, which will be replaced with the corresponding server connection information.') }, { @@ -612,7 +710,8 @@ export default class ServerSchema extends BaseUISchema { group: gettext('Advanced'), mode: ['properties', 'edit', 'create'], controlProps: {allowClear: false}, - visible: () => pgAdmin.server_mode == 'True' && + visible: (state) => pgAdmin.server_mode == 'True' && + state.auth_method != 'entra' && (pgAdmin.server_passexec_commands || []).length > 0, type: (state) => { let options = [{label: gettext('None'), value: '__none__'}]; @@ -631,8 +730,9 @@ export default class ServerSchema extends BaseUISchema { id: 'passexec_expiration', label: gettext('Password exec expiration (seconds)'), type: 'int', group: gettext('Advanced'), mode: ['properties', 'edit', 'create'], - visible: () => pgAdmin.server_mode != 'True' || - (pgAdmin.server_passexec_commands || []).length > 0, + visible: (state) => state.auth_method != 'entra' && + (pgAdmin.server_mode != 'True' || + (pgAdmin.server_passexec_commands || []).length > 0), disabled: function(state) { if (pgAdmin.server_mode == 'True') { return !state.passexec_name || diff --git a/web/pgadmin/browser/templates/browser/js/utils.js b/web/pgadmin/browser/templates/browser/js/utils.js index dd64e138137..1b9a57240f8 100644 --- a/web/pgadmin/browser/templates/browser/js/utils.js +++ b/web/pgadmin/browser/templates/browser/js/utils.js @@ -45,6 +45,8 @@ define('pgadmin.browser.utils', pgAdmin['csrf_token_header'] = '{{ current_app.config.get('WTF_CSRF_HEADERS')[0] }}'; pgAdmin['csrf_token'] = '{{ csrf_token() }}'; pgAdmin['server_mode'] = '{{ current_app.config.get('SERVER_MODE') }}'; + pgAdmin['entra_db_auth_enabled'] = {{ current_app.config.get('ENTRA_DB_AUTH_ENABLED', False)|tojson }}; + pgAdmin['entra_db_signin_method'] = {{ current_app.config.get('ENTRA_DB_SIGNIN_METHOD', 'device_code')|tojson }}; /* Get the inactivity related config */ pgAdmin['user_inactivity_timeout'] = {{ current_app.config.get('USER_INACTIVITY_TIMEOUT') }}; diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py index 2714eb14ca5..ce6f2c083c7 100644 --- a/web/pgadmin/model/__init__.py +++ b/web/pgadmin/model/__init__.py @@ -34,7 +34,7 @@ # ########################################################################## -SCHEMA_VERSION = 53 +SCHEMA_VERSION = 54 ########################################################################## # @@ -306,6 +306,8 @@ class Server(db.Model, UserScopedMixin): nullable=False) maintenance_db = db.Column(db.String(64), nullable=True) username = db.Column(db.String(64), nullable=False) + auth_method = db.Column(db.String(16), nullable=False, + default='password', server_default='password') password = db.Column(PgAdminDbBinaryString()) save_password = db.Column( db.Integer(), diff --git a/web/pgadmin/static/js/Dialogs/ConnectServerContent.jsx b/web/pgadmin/static/js/Dialogs/ConnectServerContent.jsx index 796e1441c3a..c9545bdedf4 100644 --- a/web/pgadmin/static/js/Dialogs/ConnectServerContent.jsx +++ b/web/pgadmin/static/js/Dialogs/ConnectServerContent.jsx @@ -16,6 +16,8 @@ import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; import PropTypes from 'prop-types'; import { FormFooterMessage, InputCheckbox, InputText, MESSAGE_TYPE } from '../components/FormComponents'; import { ModalContent, ModalFooter } from '../../../static/js/components/ModalContent'; +import getApiInstance from '../api_instance'; +import url_for from 'sources/url_for'; export default function ConnectServerContent({closeModal, data, onOK, setHeight, hideSavePassword=false}) { @@ -28,6 +30,58 @@ export default function ConnectServerContent({closeModal, data, onOK, setHeight, password: '', save_password: false, }); + const [entraStatus, setEntraStatus] = useState(''); + const [entraCode, setEntraCode] = useState(null); + const [entraAuthenticating, setEntraAuthenticating] = useState(false); + const signInActive = useRef(true); + + useEffect(() => () => { signInActive.current = false; }, []); + + const onEntraSignIn = () => { + const deviceCode = data.entra_signin_method != 'browser'; + const popup = deviceCode ? window.open('', 'pgadmin_entra_signin', + 'width=550,height=650,resizable=yes,scrollbars=yes') : null; + const api = getApiInstance(); + let polling = true; + setEntraAuthenticating(true); + setEntraStatus(gettext('Waiting for Microsoft sign-in...')); + setEntraCode(null); + + const poll = async () => { + if (!polling || !signInActive.current) return; + try { + const response = await api.get(url_for('NODE-server.entra_signin_code')); + const code = response.data.data; + if (code?.user_code) { + setEntraCode(code); + if (popup && !popup.closed) popup.location = code.verification_uri; + return; + } + } catch { + // The sign-in request reports errors below. + } + if (polling) setTimeout(poll, 1000); + }; + if (deviceCode) setTimeout(poll, 500); + + api.post(url_for('NODE-server.entra_signin')).then((response) => { + if (response.data.success !== 1) { + throw new Error(response.data.errormsg || + gettext('Microsoft sign-in failed.')); + } + if (popup && !popup.closed) popup.close(); + onOK?.(new FormData()); + closeModal(); + }).catch((error) => { + if (popup && !popup.closed) popup.close(); + if (signInActive.current) setEntraStatus( + error.response?.data?.errormsg || error.message || + gettext('Microsoft sign-in failed.')); + }).finally(() => { + polling = false; + if (signInActive.current) setEntraAuthenticating(false); + }); + }; const onTextChange = (e, id) => { let val = e; @@ -101,6 +155,16 @@ export default function ConnectServerContent({closeModal, data, onOK, setHeight, onChange={(e)=>onTextChange(e.target.checked, 'save_password')} disabled={!data.allow_save_password} /> } + {data.prompt_entra && <> + + {gettext('Sign in to Microsoft to connect the server "%s".', data.server_label)} + + {entraCode && + {gettext('Open %s and enter this code: %s', + entraCode.verification_uri, entraCode.user_code)} + } + {entraStatus && {entraStatus}} + } @@ -126,6 +190,10 @@ export default function ConnectServerContent({closeModal, data, onOK, setHeight, closeModal(); }} >{gettext('OK')} } + {data.prompt_entra && + {gettext('Sign in to Microsoft')} + } ); diff --git a/web/pgadmin/tools/sqleditor/__init__.py b/web/pgadmin/tools/sqleditor/__init__.py index 8fa4c9204e2..49ebbb12949 100644 --- a/web/pgadmin/tools/sqleditor/__init__.py +++ b/web/pgadmin/tools/sqleditor/__init__.py @@ -18,6 +18,7 @@ from io import BytesIO import threading import math +import config import json @@ -85,6 +86,20 @@ TRANSACTION_STATUS_CHECK_FAILED = gettext("Transaction status check failed.") _NODES_SQL = 'nodes.sql' sqleditor_close_session_lock = Lock() + + +def entra_signin_challenge(server, errmsg): + """Ask for Microsoft sign-in instead of a PostgreSQL password.""" + return make_json_response( + success=0, status=428, + result={ + 'sid': server.id, + 'server_label': server.name, + 'username': server.username, + 'errmsg': errmsg, + 'prompt_entra': True, + 'entra_signin_method': config.ENTRA_DB_SIGNIN_METHOD, + }) # SQLAutoComplete objects, keyed by _auto_complete_key(). Each one holds a # live connection belonging to the session that created it, so the key must # include the session as well as the client-chosen transaction id; otherwise @@ -287,6 +302,8 @@ def initialize_viewdata(trans_id, cmd_type, obj_type, sgid, sid, did, obj_id): conn,**kwargs) if not status: current_app.logger.error(msg) + if server.auth_method == 'entra': + return entra_signin_challenge(server, msg) if is_ask_password: return make_json_response( success=0, @@ -315,11 +332,15 @@ def initialize_viewdata(trans_id, cmd_type, obj_type, sgid, sid, did, obj_id): status, msg = default_conn.connect() if not status: current_app.logger.error(msg) + if server.auth_method == 'entra': + return entra_signin_challenge(server, msg) return internal_server_error(errormsg=str(msg)) status, msg = conn.connect() if not status: current_app.logger.error(msg) + if server.auth_method == 'entra': + return entra_signin_challenge(server, msg) return internal_server_error(errormsg=str(msg)) try: # if object type is partition then it is nothing but a table. @@ -639,6 +660,8 @@ def _init_sqleditor(trans_id, connect, sgid, sid, did, dbname=None, **kwargs): conn, **kwargs) if not status: current_app.logger.error(msg) + if server.auth_method == 'entra': + return True, entra_signin_challenge(server, msg), '', '' if is_ask_password: return True, make_json_response( success=0, diff --git a/web/pgadmin/utils/__init__.py b/web/pgadmin/utils/__init__.py index 7e2f86967da..1c455b267dd 100644 --- a/web/pgadmin/utils/__init__.py +++ b/web/pgadmin/utils/__init__.py @@ -616,6 +616,7 @@ def dump_database_servers(output_file, selected_servers, add_value(attr_dict, "Port", server.port) add_value(attr_dict, "MaintenanceDB", server.maintenance_db) add_value(attr_dict, "Username", server.username) + add_value(attr_dict, "AuthenticationMethod", server.auth_method) add_value(attr_dict, "Role", server.role) add_value(attr_dict, "Comment", server.comment) add_value(attr_dict, "Shared", server.shared) @@ -886,6 +887,12 @@ def load_database_servers(input_file, selected_servers, new_server.port = obj.get("Port", None) new_server.username = username + auth_method = obj.get("AuthenticationMethod", "password") + if auth_method not in ("password", "entra"): + return _handle_error( + gettext("Invalid authentication method for server '%s'." + % obj["Name"]), from_setup) + new_server.auth_method = auth_method new_server.role = obj.get("Role", None) @@ -951,7 +958,12 @@ def load_database_servers(input_file, selected_servers, new_server.post_connection_sql = obj.get("PostConnectionSQL", None) - if not current_app.config['SERVER_MODE']: + if auth_method == 'entra': + new_server.save_password = 0 + new_server.password = None + new_server.passexec_name = None + new_server.passexec_cmd = None + elif not current_app.config['SERVER_MODE']: new_server.passexec_cmd = obj.get("PasswordExecCommand", None) new_server.passexec_expiration = obj.get( "PasswordExecExpiration", None) diff --git a/web/pgadmin/utils/driver/psycopg3/connection.py b/web/pgadmin/utils/driver/psycopg3/connection.py index fe9fb13c4f9..a07019c44d8 100644 --- a/web/pgadmin/utils/driver/psycopg3/connection.py +++ b/web/pgadmin/utils/driver/psycopg3/connection.py @@ -455,7 +455,7 @@ def _check_user_password(self, kwargs): encpass = None is_update_password = True - if 'user' in kwargs and kwargs['password']: + if 'user' in kwargs and kwargs.get('password'): password = kwargs['password'] kwargs.pop('password') is_update_password = False @@ -498,6 +498,12 @@ def connect(self, **kwargs): manager = self.manager + if getattr(manager, 'auth_method', 'password') == 'entra': + # Ignore credentials supplied by a caller or a restored session. + kwargs.pop('password', None) + kwargs.pop('encpass', None) + self.password = None + crypt_key_present, crypt_key = get_crypt_key() if not crypt_key_present: raise CryptKeyMissing() @@ -535,6 +541,18 @@ def connect(self, **kwargs): if is_error: return False, errmsg + if getattr(manager, 'auth_method', 'password') == 'entra': + # Tokens are short lived and belong to the current pgAdmin user. + # Never reuse an encrypted password from a restored connection. + from pgadmin.utils.entra_auth import EntraAuthenticationError + try: + password = manager.passexec.get() + except EntraAuthenticationError as error: + return False, str(error) + encpass = None + self.password = None + is_update_password = False + # If no password credential is found then connect request might come # from Query tool, ViewData grid, debugger, etc. In that case, fall # back to using the password returned from manager.passexec. @@ -1994,7 +2012,15 @@ def cancel_transaction(self, conn_id, did=None): # to cancel the query and release it. if cancel_conn.__backend_pid == self.__backend_pid: password = getattr(self.manager, 'password', None) - if password: + if getattr(self.manager, 'auth_method', 'password') == 'entra': + # Query cancellation uses a separate physical connection and + # therefore needs its own current Entra access token. + from pgadmin.utils.entra_auth import EntraAuthenticationError + try: + password = self.manager.passexec.get() + except EntraAuthenticationError as error: + return False, str(error) + elif password: # Fetch Logged in User Details. user = User.query.filter_by(id=current_user.id).first() if user is None: diff --git a/web/pgadmin/utils/driver/psycopg3/server_manager.py b/web/pgadmin/utils/driver/psycopg3/server_manager.py index 8134e74c4e1..6e8fc8602b7 100644 --- a/web/pgadmin/utils/driver/psycopg3/server_manager.py +++ b/web/pgadmin/utils/driver/psycopg3/server_manager.py @@ -14,6 +14,7 @@ import datetime import config import logging +import certifi from flask import current_app, session from flask_security import current_user from flask_babel import gettext @@ -30,6 +31,7 @@ from pgadmin.utils.master_password import get_crypt_key from pgadmin.utils.exception import ObjectGone from pgadmin.utils.passexec import build_passexec +from pgadmin.utils.entra_auth import EntraPassword from psycopg.conninfo import make_conninfo if config.SUPPORT_SSH_TUNNEL: @@ -79,7 +81,9 @@ def update(self, server): self.shared = server.shared self.did = None self.user = server.username - self.password = server.password + self.auth_method = server.auth_method or 'password' + self.password = None if self.auth_method == 'entra' \ + else server.password self.role = server.role self.pinged = datetime.datetime.now() self.db_info = dict() @@ -87,8 +91,10 @@ def update(self, server): self.db_res = server.db_res self.db_res_type = server.db_res_type self.name = server.name - self.passexec = build_passexec(server) self.service = server.service + self.passexec = EntraPassword( + server.host, server.service, server.connection_params + ) if self.auth_method == 'entra' else build_passexec(server) if config.SUPPORT_SSH_TUNNEL: self.use_ssh_tunnel = server.use_ssh_tunnel @@ -373,6 +379,8 @@ def _restore(self, data): masterpass_processed = process_masterpass_disabled() ServerManager._get_password_to_conn(data, masterpass_processed) + if self.auth_method == 'entra': + data['password'] = None # Get server type. self._get_server_type() @@ -409,8 +417,12 @@ def _restore(self, data): self._check_and_reconnect_server(conn, conn_info, data) def _restore_connections(self): - for conn_id in self.connections: - conn = self.connections[conn_id] + # Reconnecting a connection can add or remove entries while this + # manager is being restored (for example from the Query Tool). + for conn_id in list(self.connections): + conn = self.connections.get(conn_id) + if conn is None: + continue # only try to reconnect if connection was connected previously # and auto_reconnect is true. was_connected = conn.wasConnected @@ -729,6 +741,14 @@ def create_connection_string(self, database, user, password=None): display_dsn_args[key] = orig_value if with_complete_path else \ value + if self.auth_method == 'entra': + # Entra access tokens must only be sent to a TLS peer whose + # certificate matches the configured Azure hostname. + dsn_args['sslmode'] = 'verify-full' + display_dsn_args['sslmode'] = 'verify-full' + dsn_args['sslrootcert'] = certifi.where() + display_dsn_args['sslrootcert'] = certifi.where() + # Enable TCP keepalive so that stale/half-open connections are # detected by the OS within a reasonable time instead of hanging # for the full TCP retransmission timeout (which can be many diff --git a/web/pgadmin/utils/entra_auth.py b/web/pgadmin/utils/entra_auth.py new file mode 100644 index 00000000000..81dcf286fee --- /dev/null +++ b/web/pgadmin/utils/entra_auth.py @@ -0,0 +1,154 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## +"""Per-user Microsoft sign-in for Azure Database for PostgreSQL. + +The Microsoft device sign-in uses Azure Identity's public development client. +Its encrypted cache holds refresh credentials; the pgAdmin session holds only +the non-secret authentication record that selects the signed-in account. +""" + +import re + +from azure.identity import ( + AuthenticationRecord, DeviceCodeCredential, InteractiveBrowserCredential, + TokenCachePersistenceOptions) +from flask import current_app, session +from flask_security import current_user + +import config + +POSTGRES_SCOPE = 'https://ossrdbms-aad.database.windows.net/.default' +AZURE_POSTGRES_HOST = re.compile( + r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?' + r'\.postgres\.database\.azure\.com', re.IGNORECASE) + + +class EntraAuthenticationError(Exception): + """A safe, non-secret-bearing error for the connection UI.""" + + +def _check_enabled(): + if not getattr(config, 'ENTRA_DB_AUTH_ENABLED', False): + raise EntraAuthenticationError( + 'Microsoft Entra database authentication is not enabled.') + if not current_user.is_authenticated: + raise EntraAuthenticationError('Sign in to pgAdmin first.') + + +def _tenant(): + return getattr(config, 'ENTRA_DB_TENANT_ID', None) or 'organizations' + + +def _sign_in_method(): + method = getattr(config, 'ENTRA_DB_SIGNIN_METHOD', 'device_code') + if method not in ('browser', 'device_code'): + raise EntraAuthenticationError( + 'Invalid Microsoft Entra sign-in method configuration.') + return method + + +def _cache_options(): + # A distinct encrypted OS cache for each pgAdmin user and tenant. + name = 'pgadmin-entra-db-{}-{}'.format(current_user.id, _tenant()) + return TokenCachePersistenceOptions(name=name) + + +def start_sign_in(): + """Start Microsoft device sign-in in the request handling this call.""" + _check_enabled() + session.pop('entra_db_auth', None) + session.pop('entra_db_code', None) + + def on_code(verification_uri, user_code, expires_at): + session['entra_db_code'] = { + 'verification_uri': verification_uri, + 'user_code': user_code, + 'expires_at': expires_at.isoformat(), + } + session.modified = True + session.force_write = True + # The authentication request remains open while Microsoft waits for + # the user. Make the code visible to a concurrent polling request. + current_app.session_interface.manager.put(session) + + method = _sign_in_method() + credential = None + try: + options = { + 'tenant_id': _tenant(), 'timeout': 180, + 'cache_persistence_options': _cache_options(), + } + if method == 'browser': + credential = InteractiveBrowserCredential(**options) + else: + credential = DeviceCodeCredential( + prompt_callback=on_code, **options) + record = credential.authenticate(scopes=[POSTGRES_SCOPE]) + except Exception: + raise EntraAuthenticationError( + 'Microsoft sign-in did not complete. Try signing in again.') \ + from None + finally: + if credential is not None: + credential.close() + session.pop('entra_db_code', None) + session.modified = True + session.force_write = True + session['entra_db_auth'] = { + 'record': record.serialize(), + 'method': method, + } + session.force_write = True + return record.username + + +class EntraPassword: + """Supply a fresh PostgreSQL token when a physical connection opens.""" + + def __init__(self, host, service, connection_params): + self.host = host + self.service = service + self.connection_params = connection_params or {} + + def get(self): + if not self.host or not AZURE_POSTGRES_HOST.fullmatch( + self.host) or self.service: + raise EntraAuthenticationError( + 'Microsoft Entra authentication requires an Azure Database ' + 'for PostgreSQL host and no service override.') + if any(name in self.connection_params for name in + ('host', 'hostaddr', 'port', 'dbname', 'user', 'password', + 'service', 'passfile')): + raise EntraAuthenticationError( + 'Connection parameters cannot override the destination ' + 'or user for Microsoft Entra authentication.') + _check_enabled() + saved = session.get('entra_db_auth') + if not saved or not isinstance(saved, dict): + raise EntraAuthenticationError( + 'Sign in to Microsoft to connect this server.') + try: + record = AuthenticationRecord.deserialize(saved['record']) + credential_class = InteractiveBrowserCredential if \ + saved['method'] == 'browser' else DeviceCodeCredential + credential = credential_class( + tenant_id=_tenant(), authentication_record=record, + disable_automatic_authentication=True, + cache_persistence_options=_cache_options()) + try: + return credential.get_token(POSTGRES_SCOPE).token + finally: + credential.close() + except Exception as error: + current_app.logger.error( + 'Entra database token acquisition failed: %s', + type(error).__name__) + raise EntraAuthenticationError( + 'The Microsoft sign-in has expired. Sign in again to ' + 'reconnect.') from None diff --git a/web/pgadmin/utils/tests/test_entra_auth.py b/web/pgadmin/utils/tests/test_entra_auth.py new file mode 100644 index 00000000000..7468bc6d544 --- /dev/null +++ b/web/pgadmin/utils/tests/test_entra_auth.py @@ -0,0 +1,280 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## +"""Tests for Microsoft sign-in and PostgreSQL token acquisition.""" + +import datetime +import unittest +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from flask import Flask, session + +from pgadmin.utils import entra_auth +from pgadmin.browser.server_groups.servers import ( + ServerModule, ServerNode, prepare_auth_method_data, require_entra_ssl, + should_connect_server_on_create) +from pgadmin.utils.driver.psycopg3 import connection as connection_module +from pgadmin.utils.driver.psycopg3.connection import Connection +from pgadmin.utils.driver.psycopg3.server_manager import ServerManager +from pgadmin.tools.sqleditor import entra_signin_challenge + + +class TestEntraPassword(unittest.TestCase): + def setUp(self): + self.app = Flask(__name__) + self.app.secret_key = 'test-only' + self.app.session_interface.manager = Mock() + self.context = self.app.test_request_context() + self.context.push() + self.user = Mock(id=42, is_authenticated=True) + self.user_patch = patch.object(entra_auth, 'current_user', self.user) + self.user_patch.start() + self.config_patches = [ + patch.object(entra_auth.config, 'ENTRA_DB_AUTH_ENABLED', True), + patch.object(entra_auth.config, 'ENTRA_DB_TENANT_ID', None), + patch.object(entra_auth.config, 'ENTRA_DB_SIGNIN_METHOD', + 'device_code'), + ] + for config_patch in self.config_patches: + config_patch.start() + self.password = entra_auth.EntraPassword( + 'test.postgres.database.azure.com', None, {}) + + def tearDown(self): + for config_patch in reversed(self.config_patches): + config_patch.stop() + self.user_patch.stop() + self.context.pop() + + @patch.object(entra_auth, 'DeviceCodeCredential') + def test_sign_in_stores_account_record_and_shows_code( + self, credential_class): + def authenticate(**kwargs): + callback = credential_class.call_args.kwargs['prompt_callback'] + callback('https://microsoft.com/devicelogin', 'CODE-123', + datetime.datetime.now(datetime.timezone.utc)) + self.assertEqual(session['entra_db_code']['user_code'], + 'CODE-123') + self.app.session_interface.manager.put.assert_called_once() + return Mock(username='user@example.com', + serialize=lambda: 'account-record') + + credential_class.return_value.authenticate.side_effect = authenticate + self.assertEqual(entra_auth.start_sign_in(), 'user@example.com') + self.assertEqual(session['entra_db_auth'], { + 'record': 'account-record', 'method': 'device_code'}) + self.assertNotIn('entra_db_code', session) + credential_class.return_value.authenticate.assert_called_once_with( + scopes=[entra_auth.POSTGRES_SCOPE]) + + @patch.object(entra_auth, 'AuthenticationRecord') + @patch.object(entra_auth, 'DeviceCodeCredential') + def test_uses_signed_in_account_to_get_database_token( + self, credential_class, record_class): + session['entra_db_auth'] = { + 'record': 'account-record', 'method': 'device_code'} + credential_class.return_value.get_token.return_value = Mock( + token='database-token') + self.assertEqual(self.password.get(), 'database-token') + record_class.deserialize.assert_called_once_with('account-record') + credential_class.return_value.get_token.assert_called_once_with( + entra_auth.POSTGRES_SCOPE) + self.assertTrue(credential_class.call_args.kwargs[ + 'disable_automatic_authentication']) + + @patch.object(entra_auth, 'InteractiveBrowserCredential') + def test_browser_sign_in_needs_no_device_code(self, credential_class): + credential_class.return_value.authenticate.return_value = Mock( + username='user@example.com', serialize=lambda: 'browser-record') + with patch.object(entra_auth.config, 'ENTRA_DB_SIGNIN_METHOD', + 'browser'): + self.assertEqual(entra_auth.start_sign_in(), 'user@example.com') + self.assertNotIn('entra_db_code', session) + self.assertEqual(session['entra_db_auth']['method'], 'browser') + + @patch.object(entra_auth, 'AuthenticationRecord') + @patch.object(entra_auth, 'InteractiveBrowserCredential') + def test_browser_connection_refreshes_from_its_own_cache( + self, credential_class, record_class): + session['entra_db_auth'] = { + 'record': 'browser-record', 'method': 'browser'} + credential_class.return_value.get_token.return_value = Mock( + token='database-token') + self.assertEqual(self.password.get(), 'database-token') + record_class.deserialize.assert_called_once_with('browser-record') + credential_class.return_value.get_token.assert_called_once_with( + entra_auth.POSTGRES_SCOPE) + + @patch.object(entra_auth, 'DeviceCodeCredential') + def test_requires_sign_in_before_connection(self, credential_class): + with self.assertRaisesRegex(entra_auth.EntraAuthenticationError, + 'Sign in to Microsoft'): + self.password.get() + credential_class.assert_not_called() + + @patch.object(entra_auth, 'DeviceCodeCredential') + def test_destination_override_is_rejected_before_token_request( + self, credential_class): + for host, service, params in ( + ('attacker.example', None, {}), + ('attacker.example,test.postgres.database.azure.com', + None, {}), + ('test.postgres.database.azure.com', 'other', {}), + ('test.postgres.database.azure.com', None, + {'hostaddr': '127.0.0.1'}), + ('test.postgres.database.azure.com', None, + {'passfile': '/tmp/other.pass'})): + with self.subTest(host=host, params=params): + with self.assertRaises(entra_auth.EntraAuthenticationError): + entra_auth.EntraPassword(host, service, params).get() + credential_class.assert_not_called() + + +class TestEntraServerSettings(unittest.TestCase): + def test_expired_token_requests_sign_in_instead_of_password(self): + app = Flask(__name__) + with app.test_request_context(): + server = SimpleNamespace(id=7, name='Azure test', + username='user@example.com', + auth_method='entra') + response = ServerNode.get_response_for_password( + None, server, 428, errmsg='Sign in again.') + self.assertEqual(response.status_code, 428) + self.assertTrue(response.json['result']['prompt_entra']) + self.assertNotIn('prompt_password', response.json['result']) + + def test_query_tool_requests_sign_in_instead_of_password(self): + app = Flask(__name__) + with app.test_request_context(): + server = SimpleNamespace(id=7, name='Azure test', + username='user@example.com') + response = entra_signin_challenge(server, 'Sign in again.') + self.assertEqual(response.status_code, 428) + self.assertTrue(response.json['result']['prompt_entra']) + + def test_restoring_connections_tolerates_new_query_tool_connection(self): + manager = Mock(use_ssh_tunnel=0) + connection = Mock(wasConnected=True, auto_reconnect=True) + manager.connections = {'existing': connection} + connection.connect.side_effect = lambda: manager.connections.update( + {'query-tool': Mock(wasConnected=False, auto_reconnect=False)}) + ServerManager._restore_connections(manager) + connection.connect.assert_called_once_with() + + def test_query_tool_connection_accepts_no_password_field(self): + password, encpass, update_password = \ + Connection._check_user_password(None, { + 'user': 'user@example.com', 'encpass': None}) + self.assertIsNone(password) + self.assertIsNone(encpass) + self.assertTrue(update_password) + + def test_password_connection_still_uses_supplied_password(self): + password, encpass, update_password = \ + Connection._check_user_password(None, { + 'user': 'test_admin', 'password': 'test-only-password'}) + self.assertEqual(password, 'test-only-password') + self.assertIsNone(encpass) + self.assertFalse(update_password) + + @patch.object(connection_module.psycopg, 'connect') + @patch.object(connection_module, 'ConnectionLocker') + def test_cancel_transaction_uses_a_fresh_entra_token( + self, locker_class, connect): + manager = Mock(auth_method='entra', password=None, + kerberos_conn=False, user='user@example.com', + passexec=Mock(get=Mock(return_value='database-token'))) + cancel_connection = Mock() + cancel_connection._Connection__backend_pid = 42 + manager.connection.return_value = cancel_connection + manager.create_connection_string.return_value = 'token-dsn' + pg_connection = Mock() + connect.return_value = pg_connection + connection = Connection(manager, 'connection-id', 'postgres') + connection._Connection__backend_pid = 42 + + status, message = connection.cancel_transaction('connection-id') + + self.assertTrue(status) + self.assertEqual(message, '') + manager.passexec.get.assert_called_once_with() + manager.create_connection_string.assert_called_once_with( + 'postgres', 'user@example.com', 'database-token') + connect.assert_called_once_with( + 'token-dsn', cursor_factory=connection_module.DictCursor) + + @patch.object(connection_module.psycopg, 'connect') + @patch.object(connection_module, 'ConnectionLocker') + def test_cancel_transaction_returns_signin_error_when_token_is_unavailable( + self, locker_class, connect): + auth_error = entra_auth.EntraAuthenticationError( + 'Sign in to Microsoft again.') + manager = Mock(auth_method='entra', password=None, + kerberos_conn=False, + passexec=Mock(get=Mock(side_effect=auth_error))) + cancel_connection = Mock() + cancel_connection._Connection__backend_pid = 42 + manager.connection.return_value = cancel_connection + connection = Connection(manager, 'connection-id', 'postgres') + connection._Connection__backend_pid = 42 + + status, message = connection.cancel_transaction('connection-id') + + self.assertFalse(status) + self.assertEqual(message, 'Sign in to Microsoft again.') + connect.assert_not_called() + + def test_sign_in_routes_are_exposed_to_the_browser(self): + endpoints = ServerModule(__name__).get_exposed_url_endpoints() + self.assertIn('NODE-server.entra_signin', endpoints) + self.assertIn('NODE-server.entra_signin_code', endpoints) + + def test_switching_to_entra_clears_password_fields(self): + with patch.object(entra_auth.config, 'SERVER_MODE', True), \ + patch.object(entra_auth.config, 'ENTRA_DB_AUTH_ENABLED', True): + data, error = prepare_auth_method_data( + {'auth_method': 'entra'}, Mock(auth_method='password')) + self.assertIsNone(error) + self.assertIsNone(data['password']) + self.assertFalse(data['save_password']) + self.assertIsNone(data['passexec_name']) + + def test_entra_rejects_a_password(self): + with patch.object(entra_auth.config, 'SERVER_MODE', True), \ + patch.object(entra_auth.config, 'ENTRA_DB_AUTH_ENABLED', True): + _, error = prepare_auth_method_data({ + 'auth_method': 'entra', 'password': 'hidden-password'}) + self.assertIn('cannot use', error) + + def test_entra_requires_verified_tls(self): + self.assertEqual(require_entra_ssl({'sslmode': 'disable'})['sslmode'], + 'verify-full') + + def test_entra_removes_password_file_from_connection_parameters(self): + params = {'passfile': '/tmp/pgpass', 'sslmode': 'disable'} + + normalized = require_entra_ssl(params) + + self.assertNotIn('passfile', normalized) + self.assertEqual(normalized['sslmode'], 'verify-full') + self.assertIn('passfile', params) + + def test_server_registration_defers_entra_connect_until_sign_in(self): + app = Flask(__name__) + app.secret_key = 'test-only' + with app.test_request_context(): + self.assertFalse(should_connect_server_on_create({ + 'auth_method': 'entra', 'connect_now': True})) + session['entra_db_auth'] = {'record': 'signed-in-account'} + self.assertTrue(should_connect_server_on_create({ + 'auth_method': 'entra', 'connect_now': True})) + self.assertTrue(should_connect_server_on_create({ + 'auth_method': 'password', 'connect_now': True})) + self.assertFalse(should_connect_server_on_create({ + 'auth_method': 'entra', 'connect_now': False})) diff --git a/web/regression/javascript/schema_ui_files/server.ui.spec.js b/web/regression/javascript/schema_ui_files/server.ui.spec.js index 053da12aa81..30783126abd 100644 --- a/web/regression/javascript/schema_ui_files/server.ui.spec.js +++ b/web/regression/javascript/schema_ui_files/server.ui.spec.js @@ -41,6 +41,30 @@ describe('ServerSchema', ()=>{ await getPropertiesView(createSchemaObject(), getInitData); }); + it('shows Entra authentication without password controls when enabled', ()=>{ + const originalMode = pgAdmin.server_mode; + const originalEnabled = pgAdmin.entra_db_auth_enabled; + try { + pgAdmin.server_mode = 'True'; + pgAdmin.entra_db_auth_enabled = true; + const schema = createSchemaObject(); + const field = (id) => _.find(schema.fields, (f) => f.id == id); + expect(field('auth_method').visible({})).toBe(true); + expect(field('auth_method').options).toContainEqual({ + label: 'Microsoft Entra ID', value: 'entra', + }); + expect(field('password').visible({auth_method: 'entra'})).toBe(false); + expect(field('save_password').visible({auth_method: 'entra'})).toBe(false); + expect(field('entra_signin').visible({auth_method: 'entra'})).toBe(true); + expect(field('entra_signin').btnName).toBe('Sign in to Microsoft'); + expect(field('auth_method').depChange({auth_method: 'entra'})) + .toMatchObject({password: null, save_password: false}); + } finally { + pgAdmin.server_mode = originalMode; + pgAdmin.entra_db_auth_enabled = originalEnabled; + } + }); + it('validate', ()=>{ let state = {}; let setError = jest.fn();