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();