diff --git a/runtime/src/js/pgadmin.js b/runtime/src/js/pgadmin.js
index ef14f02733c..73d7a6961ed 100644
--- a/runtime/src/js/pgadmin.js
+++ b/runtime/src/js/pgadmin.js
@@ -247,12 +247,18 @@ function startDesktopMode() {
misc.writeServerLog('pgAdmin Command: "' + command + '"');
misc.writeServerLog('Environment: ');
Object.keys(process.env).forEach(function (key) {
- // Below code is included only for Mac OS as default path for azure CLI
- // installation path is not included in PATH variable while spawning
- // runtime environment.
+ // On macOS, apps launched from the Finder or Dock get a minimal PATH that
+ // does not include the directories Homebrew installs into (/usr/local/bin
+ // on Intel, /opt/homebrew/bin on Apple Silicon), so tools such as the
+ // Azure CLI would not be found.
if (process.platform === 'darwin' && key === 'PATH') {
- let updated_path = process.env[key] + ':/usr/local/bin';
- process.env[key] = updated_path;
+ let pathDirs = process.env[key].split(':');
+ ['/usr/local/bin', '/opt/homebrew/bin'].forEach((dir) => {
+ if (!pathDirs.includes(dir)) {
+ pathDirs.push(dir);
+ }
+ });
+ process.env[key] = pathDirs.join(':');
}
if (process.platform === 'win32' && key.toUpperCase() === 'PATH') {
diff --git a/web/pgadmin/misc/cloud/azure/__init__.py b/web/pgadmin/misc/cloud/azure/__init__.py
index 196f53781bf..38ad7b938f2 100644
--- a/web/pgadmin/misc/cloud/azure/__init__.py
+++ b/web/pgadmin/misc/cloud/azure/__init__.py
@@ -99,6 +99,9 @@ def verify_credentials():
if cached_state is None or auth_type_changed or tenant_changed:
# Drop any stale state — these creds don't match the cached ones.
session['azure'].pop('state', None)
+ # Also drop the device code from any earlier attempt, so the wizard
+ # does not show it in place of the one for this attempt.
+ session['azure'].pop('azure_auth_code', None)
azure = Azure(
interactive_browser_credential=interactive_browser_credential,
tenant_id=tenant_id,
diff --git a/web/pgadmin/misc/cloud/azure/tests/test_azure_stale_auth_code.py b/web/pgadmin/misc/cloud/azure/tests/test_azure_stale_auth_code.py
new file mode 100644
index 00000000000..421362fb443
--- /dev/null
+++ b/web/pgadmin/misc/cloud/azure/tests/test_azure_stale_auth_code.py
@@ -0,0 +1,69 @@
+##########################################################################
+#
+# pgAdmin 4 - PostgreSQL Tools
+#
+# Copyright (C) 2013 - 2026, The pgAdmin Development Team
+# This software is released under the PostgreSQL Licence
+#
+##########################################################################
+
+"""
+Check that starting a new Azure authentication discards the device code
+from an earlier attempt (#9921), so the wizard does not poll it back and
+show the user a code that is no longer valid.
+"""
+
+import json
+import unittest
+from unittest.mock import MagicMock, patch
+
+from flask import Flask, session
+
+from pgadmin.utils.route import BaseTestGenerator
+
+
+class TestAzureVerifyCredentialsDropsStaleAuthCode(BaseTestGenerator):
+ """A new authentication attempt removes the previous device code."""
+
+ scenarios = [('default', dict())]
+
+ def setUp(self):
+ # Pure logic test; skip BaseTestGenerator's server connection.
+ unittest.TestCase.setUp(self)
+
+ def runTest(self):
+ import pgadmin.misc.cloud.azure as azure_mod
+
+ seen_codes = []
+ instance = MagicMock()
+ instance.to_state.return_value = {'tenant_id': 'new-tenant'}
+
+ def _validate():
+ # Record what get_azure_verification_codes() would return
+ # whilst this attempt is waiting for the user.
+ seen_codes.append(session['azure'].get('azure_auth_code'))
+ return True, ''
+
+ instance.validate_azure_credentials.side_effect = _validate
+ azure_cls = MagicMock(return_value=instance)
+
+ app = Flask(__name__)
+ app.secret_key = 'test'
+ data = {'secret': {'auth_type': 'interactive_browser_credential',
+ 'azure_tenant_id': 'new-tenant'}}
+ with app.test_request_context(
+ '/azure/verify_credentials/', method='POST',
+ data=json.dumps(data), content_type='application/json'), \
+ patch.object(azure_mod, 'Azure', azure_cls):
+ session['azure'] = {
+ 'auth_type': 'interactive_browser_credential',
+ 'azure_tenant_id': 'old-tenant',
+ 'state': {'tenant_id': 'old-tenant'},
+ 'azure_auth_code': {'user_code': 'OLDCODE1'},
+ }
+
+ azure_mod.verify_credentials.__wrapped__()
+
+ self.assertEqual(seen_codes, [None])
+ self.assertEqual(session['azure']['azure_tenant_id'],
+ 'new-tenant')
diff --git a/web/pgadmin/misc/cloud/static/js/azure.js b/web/pgadmin/misc/cloud/static/js/azure.js
index a193cabeff3..927926a9148 100644
--- a/web/pgadmin/misc/cloud/static/js/azure.js
+++ b/web/pgadmin/misc/cloud/static/js/azure.js
@@ -25,13 +25,17 @@ export function AzureCredentials(props) {
let _eventBus = React.useContext(CloudWizardEventsContext);
React.useMemo(() => {
+ // Polls for the device code whilst an interactive authentication is in
+ // progress, and is stopped once that authentication has finished.
+ let authCodeInterval = null;
+ const stopAuthCodePolling = () => {
+ clearInterval(authCodeInterval);
+ authCodeInterval = null;
+ };
const azureCloudDBCredSchema = new AzureCredSchema(_eventBus, {
authenticateAzure:(auth_type, azure_tenant_id) => {
- let loading_icon_url = url_for(
- 'static', { 'filename': 'img/loading.gif'}
- );
const axiosApi = getApiInstance();
- _eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD', [MESSAGE_TYPE.INFO, 'Microsoft Azure authentication process is in progress.
']);
+ _eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD', [MESSAGE_TYPE.INFO, gettext('Microsoft Azure authentication process is in progress. Please wait...')]);
let _url = url_for('azure.verify_credentials');
const post_data = {
cloud: 'azure',
@@ -39,6 +43,7 @@ export function AzureCredentials(props) {
};
return new Promise((resolve, reject)=>{axiosApi.post(_url, post_data)
.then((res) => {
+ stopAuthCodePolling();
if (res.data && res.data.success == 1 ) {
_eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD',[MESSAGE_TYPE.SUCCESS, gettext('Authentication completed successfully. Click the Next button to proceed.')]);
_eventBus.fireEvent('SET_CRED_VERIFICATION_INITIATED',true);
@@ -51,6 +56,7 @@ export function AzureCredentials(props) {
}
})
.catch((error) => {
+ stopAuthCodePolling();
_eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD',[MESSAGE_TYPE.ERROR, gettext(`Error while verifying Microsoft Azure: ${error}`)]);
reject(new Error(gettext(error)));
});
@@ -59,12 +65,13 @@ export function AzureCredentials(props) {
getAuthCode:()=>{
let _url_get_azure_verification_codes = url_for('azure.get_azure_verification_codes');
const axiosApi = getApiInstance();
+ stopAuthCodePolling();
return new Promise((resolve, reject)=>{
const interval = setInterval(()=>{
axiosApi.get(_url_get_azure_verification_codes)
.then((res)=>{
- if (res.data.success){
- clearInterval(interval);
+ if (res.data.success && authCodeInterval === interval){
+ stopAuthCodePolling();
let params = 'scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no, width=550,height=650,left=920,top=150';
window.open(res.data.data.verification_uri, 'azure_authentication', params);
resolve(res);
@@ -75,6 +82,7 @@ export function AzureCredentials(props) {
reject(error instanceof Error ? error : Error(gettext('Something went wrong')));
});
}, 1000);
+ authCodeInterval = interval;
});
}
}, {});
diff --git a/web/pgadmin/misc/cloud/static/js/azure_schema.ui.js b/web/pgadmin/misc/cloud/static/js/azure_schema.ui.js
index 89f79733121..eb762a420ae 100644
--- a/web/pgadmin/misc/cloud/static/js/azure_schema.ui.js
+++ b/web/pgadmin/misc/cloud/static/js/azure_schema.ui.js
@@ -67,7 +67,14 @@ class AzureCredSchema extends BaseUISchema {
value: 'azure_cli_credential',
},
],
- disabled: pgAdmin.server_mode == 'True',
+ // Neither credential may change whilst an attempt is pending, as
+ // the server would still complete it for the old values and its
+ // late success would then be taken as authentication for the new
+ // ones. The server cannot cancel an attempt that is waiting for the
+ // user, so locking the fields is simpler than ignoring its result.
+ disabled: (state) => {
+ return pgAdmin.server_mode == 'True' || state.is_authenticating;
+ },
helpMessage: gettext(
'Azure CLI will use the currently logged in identity through the Azure CLI on the local machine. Interactive Browser will open a browser window to authenticate a user interactively.'
),
@@ -82,7 +89,8 @@ class AzureCredSchema extends BaseUISchema {
'Enter the Azure tenant ID against which the user is authenticated.'
),
disabled: (state) => {
- return state.auth_type !== 'interactive_browser_credential';
+ return state.auth_type !== 'interactive_browser_credential' ||
+ state.is_authenticating;
},
depChange: (state) => {
if (state.auth_type == 'azure_cli_credential') {
@@ -90,6 +98,10 @@ class AzureCredSchema extends BaseUISchema {
}
this.eventBus.fireEvent('SET_CRED_VERIFICATION_INITIATED', false);
this.eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD',['', '']);
+ // This runs when the tenant id itself changes as well as when
+ // auth_type does; changed credentials need to be authenticated
+ // again.
+ return {is_authenticated: false, auth_code: ''};
},
},
{
@@ -106,8 +118,19 @@ class AzureCredSchema extends BaseUISchema {
if (!schemaState) return;
const state = schemaState.data;
+ // Go through the schema state so that the fields that depend on
+ // these values (e.g. the device code note) are re-evaluated.
const setSchemaData = (data) => {
- schemaState.data = {...schemaState.data, ...data};
+ Object.entries(data).forEach(([key, value]) => {
+ schemaState.setUnpreparedData([key], value);
+ });
+ };
+
+ const logError = (err) => {
+ console.error(
+ err instanceof Error ?
+ err : Error(gettext('Something went wrong'))
+ );
};
setTimeout(() => {
@@ -115,18 +138,32 @@ class AzureCredSchema extends BaseUISchema {
obj.fieldOptions.authenticateAzure(
state.auth_type, state.azure_tenant_id
- ).then(() => {
+ ).then((authenticated) => {
+ // Leave the button enabled after a failure so the user can
+ // try again.
setSchemaData({
- is_authenticated: true,
+ is_authenticated: Boolean(authenticated),
is_authenticating: false,
auth_code: ''
});
}).catch((err) => {
- console.error(
- err instanceof Error ?
- err : Error(gettext('Something went wrong'))
- );
+ setSchemaData({is_authenticating: false, auth_code: ''});
+ logError(err);
});
+
+ // The interactive (device code) flow only completes once the
+ // user has entered the code from the server, so fetch it and
+ // show it whilst the request above is still waiting. The button
+ // stays disabled until that request settles (its handlers reset
+ // is_authenticating), as a second click would start a second
+ // attempt on the server whose result could be overwritten by the
+ // late settlement of the first.
+ if (state.auth_type == 'interactive_browser_credential') {
+ obj.fieldOptions.getAuthCode()
+ .then((res) => {
+ setSchemaData({auth_code: res.data.data.user_code});
+ }).catch(logError);
+ }
}, 0);
},
disabled: (state)=> {
@@ -140,25 +177,6 @@ class AzureCredSchema extends BaseUISchema {
id: 'is_authenticating',
visible: false,
type: '',
- deps:['auth_btn'],
- deferredDepChange: (state, source)=>{
- return new Promise((resolve, reject)=>{
- if(source == 'auth_btn' && state.auth_type == 'interactive_browser_credential' && state.is_authenticating ) {
- obj.fieldOptions.getAuthCode()
- .then((res)=>{
- resolve(()=>{
- return {
- is_authenticating: false,
- auth_code: res.data.data.user_code,
- };
- });
- })
- .catch((err)=>{
- reject(err instanceof Error ? err : Error(gettext('Something went wrong')));
- });
- }
- });
- },
},
{
id: 'auth_code',
diff --git a/web/pgadmin/misc/cloud/static/js/google.js b/web/pgadmin/misc/cloud/static/js/google.js
index 7f5c54d64cc..2651a717b09 100644
--- a/web/pgadmin/misc/cloud/static/js/google.js
+++ b/web/pgadmin/misc/cloud/static/js/google.js
@@ -28,11 +28,8 @@ export function GoogleCredentials(props) {
React.useMemo(() => {
const googleCredSchema = new GoogleCredSchema({
authenticateGoogle:(client_secret_file) => {
- let loading_icon_url = url_for(
- 'static', { 'filename': 'img/loading.gif'}
- );
const axiosApi = getApiInstance();
- _eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD', [MESSAGE_TYPE.INFO, 'Google authentication process is in progress.
']);
+ _eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD', [MESSAGE_TYPE.INFO, gettext('Google authentication process is in progress. Please wait...')]);
let _url = url_for('google.verify_credentials');
const post_data = {
cloud: 'google',
diff --git a/web/pgadmin/misc/cloud/static/js/google_schema.ui.js b/web/pgadmin/misc/cloud/static/js/google_schema.ui.js
index b4cfd06039e..4c4fe7161ed 100644
--- a/web/pgadmin/misc/cloud/static/js/google_schema.ui.js
+++ b/web/pgadmin/misc/cloud/static/js/google_schema.ui.js
@@ -38,7 +38,7 @@ class GoogleCredSchema extends BaseUISchema{
id: 'client_secret_file',
label: gettext('Client secret file'),
type: 'file',
- helpMessage: gettext('Select a client secrets file containing the client ID, client secret, and other OAuth 2.0 parameters for google authentication. Refer link for creating client secret.'),
+ helpMessage: gettext('Select a client secrets file containing the client ID, client secret, and other OAuth 2.0 parameters for Google authentication. See Manage OAuth Clients in the Google Cloud documentation for how to create one.'),
controlProps: {
dialogType: 'select_file',
supportedTypes: ['json'],
diff --git a/web/pgadmin/static/js/components/FormComponents.jsx b/web/pgadmin/static/js/components/FormComponents.jsx
index ea80389f6d3..9f550a07c97 100644
--- a/web/pgadmin/static/js/components/FormComponents.jsx
+++ b/web/pgadmin/static/js/components/FormComponents.jsx
@@ -122,6 +122,23 @@ const StyledGrid = styled(Grid)(({theme}) => ({
},
}));
+/*
+ * Render the HTML in help messages and notes. DOMPurify drops the target
+ * attribute, so a link would otherwise navigate the pgAdmin page itself
+ * away (which, in the desktop app, prompts to quit). Open links in a new
+ * window instead.
+ */
+function parseHelpHTML(html) {
+ return HTMLReactParse(DOMPurify.sanitize(html || ''), {
+ replace: (node) => {
+ if (node.type === 'tag' && node.name === 'a' && node.attribs?.href) {
+ node.attribs.target = '_blank';
+ node.attribs.rel = 'noopener noreferrer';
+ }
+ },
+ });
+}
+
/* Wrapper on any form component to add label, error indicator and help message */
export function FormInput({ children, error, className, label, helpMessage, required, testcid, lid, withContainer=true, labelGridBasis=3, controlGridBasis=9, labelTooltip='', errorMessage='' }) {
@@ -140,7 +157,7 @@ export function FormInput({ children, error, className, label, helpMessage, requ
{React.cloneElement(children, { cid, helpid })}
- {HTMLReactParse(DOMPurify.sanitize(helpMessage || ''))}
+ {parseHelpHTML(helpMessage)}
>)
);
@@ -164,7 +181,7 @@ export function FormInput({ children, error, className, label, helpMessage, requ
{React.cloneElement(children, { cid, helpid })}
- {HTMLReactParse(DOMPurify.sanitize(helpMessage || ''))}
+ {parseHelpHTML(helpMessage)}
{errorMessage && }
@@ -1204,7 +1221,7 @@ export function FormNote({ text, className, controlProps }) {
{!controlProps?.raw && }
- {HTMLReactParse(DOMPurify.sanitize(text || ''))}
+ {parseHelpHTML(text)}
diff --git a/web/regression/javascript/components/FormComponents.spec.js b/web/regression/javascript/components/FormComponents.spec.js
index 8265b1216a5..5813ab53d27 100644
--- a/web/regression/javascript/components/FormComponents.spec.js
+++ b/web/regression/javascript/components/FormComponents.spec.js
@@ -738,6 +738,29 @@ describe('FormComponents', ()=>{
expect(ctrl.container.querySelector('script')).toBeNull();
});
+ it('opens help message links in a new window', ()=>{
+ const ctrl = render(
+ the docs.'}>
+
+ );
+ const link = ctrl.container.querySelector('a');
+ expect(link.getAttribute('href')).toBe('https://example.com/docs');
+ expect(link.getAttribute('target')).toBe('_blank');
+ expect(link.getAttribute('rel')).toBe('noopener noreferrer');
+ });
+
+ it('still strips unsafe links from help messages', ()=>{
+ const ctrl = render(
+ bad'}>
+
+ );
+ const link = ctrl.container.querySelector('a');
+ expect(link.getAttribute('href')).toBeNull();
+ expect(link.getAttribute('target')).toBeNull();
+ });
+
it('preserves safe HTML formatting', ()=>{
const ctrl = render(
{
+ const eventBus = {fireEvent: jest.fn()};
+
+ beforeEach(()=>{
+ genericBeforeEach();
+ eventBus.fireEvent.mockClear();
+ });
+
+ const getAuthButton = ()=>screen.getByRole('button', {
+ name: 'Click here to authenticate yourself to Microsoft Azure'});
+
+ const renderSchema = async (schemaObj)=>{
+ const view = await getCreateView(schemaObj);
+ // The dialog moves the focus to its first input (the authentication type
+ // toggle) 200ms after it opens. Wait for that to happen, otherwise on a
+ // slow machine the focus moves part way through typing the tenant id and
+ // the remaining keystrokes go to the toggle instead.
+ await waitFor(()=>{
+ expect(screen.getByRole('button', {name: 'Interactive Browser'}))
+ .toHaveFocus();
+ });
+ return view;
+ };
+
+ const clickAuthButton = async (user)=>{
+ await user.click(getAuthButton());
+ // The click handler defers its work with setTimeout().
+ await act(async ()=>{
+ await new Promise((resolve)=>setTimeout(resolve, 10));
+ });
+ };
+
+ it('shows the device code when using the interactive browser', async ()=>{
+ // Authentication only completes once the user has entered the code.
+ const authenticateAzure = jest.fn(()=>new Promise(()=>{}));
+ const getAuthCode = jest.fn(()=>Promise.resolve({
+ data: {data: {user_code: 'TESTCODE1'}},
+ }));
+ const schemaObj = new AzureCredSchema(eventBus, {
+ authenticateAzure, getAuthCode,
+ }, {});
+
+ const {ctrl, user} = await renderSchema(schemaObj);
+ const tenantInput = ctrl.container.querySelector(
+ '[name="azure_tenant_id"]');
+ await user.type(tenantInput, 'tenant-1');
+ expect(tenantInput).toHaveValue('tenant-1');
+ await clickAuthButton(user);
+
+ expect(authenticateAzure).toHaveBeenCalledWith(
+ 'interactive_browser_credential', 'tenant-1');
+ expect(getAuthCode).toHaveBeenCalledTimes(1);
+ expect(await screen.findByText('TESTCODE1')).toBeInTheDocument();
+ });
+
+ it('keeps the button disabled until authentication settles', async ()=>{
+ let settle;
+ const authenticateAzure = jest.fn(()=>new Promise((resolve)=>{
+ settle = resolve;
+ }));
+ const getAuthCode = jest.fn(()=>Promise.resolve({
+ data: {data: {user_code: 'TESTCODE1'}},
+ }));
+ const schemaObj = new AzureCredSchema(eventBus, {
+ authenticateAzure, getAuthCode,
+ }, {});
+
+ const {ctrl, user} = await renderSchema(schemaObj);
+ await user.type(
+ ctrl.container.querySelector('[name="azure_tenant_id"]'),
+ 'tenant-1');
+ await clickAuthButton(user);
+
+ // The device code has arrived but the server is still waiting for the
+ // user, so a second attempt must not be possible yet.
+ expect(await screen.findByText('TESTCODE1')).toBeInTheDocument();
+ expect(getAuthButton()).toBeDisabled();
+
+ // A failed attempt lets the user try again.
+ await act(async ()=>{
+ settle(false);
+ });
+ expect(getAuthButton()).toBeEnabled();
+ expect(screen.queryByText('TESTCODE1')).not.toBeInTheDocument();
+ });
+
+ it('locks the credentials whilst authentication is pending', async ()=>{
+ let settle;
+ const authenticateAzure = jest.fn(()=>new Promise((resolve)=>{
+ settle = resolve;
+ }));
+ const getAuthCode = jest.fn(()=>new Promise(()=>{}));
+ const schemaObj = new AzureCredSchema(eventBus, {
+ authenticateAzure, getAuthCode,
+ }, {});
+
+ const {ctrl, user} = await renderSchema(schemaObj);
+ const tenantInput = ctrl.container.querySelector(
+ '[name="azure_tenant_id"]');
+ await user.type(tenantInput, 'tenant-1');
+ await clickAuthButton(user);
+
+ // The pending attempt is for tenant-1, so neither the tenant nor the
+ // authentication type may change until it settles; otherwise its late
+ // success would be taken as authentication for the new values.
+ // The authentication type toggle is disabled through aria-disabled.
+ const authTypeButton = (name)=>screen.getByRole('button', {name});
+ expect(tenantInput).toBeDisabled();
+ expect(authTypeButton('Interactive Browser'))
+ .toHaveAttribute('aria-disabled', 'true');
+ expect(authTypeButton('Azure CLI'))
+ .toHaveAttribute('aria-disabled', 'true');
+
+ await act(async ()=>{
+ settle(false);
+ });
+ expect(tenantInput).toBeEnabled();
+ expect(authTypeButton('Azure CLI'))
+ .not.toHaveAttribute('aria-disabled', 'true');
+ });
+
+ it('requires authentication again when the tenant id changes', async ()=>{
+ const authenticateAzure = jest.fn(()=>Promise.resolve(true));
+ const getAuthCode = jest.fn(()=>new Promise(()=>{}));
+ const schemaObj = new AzureCredSchema(eventBus, {
+ authenticateAzure, getAuthCode,
+ }, {});
+
+ const {ctrl, user} = await renderSchema(schemaObj);
+ const tenantInput = ctrl.container.querySelector(
+ '[name="azure_tenant_id"]');
+ await user.type(tenantInput, 'tenant-1');
+ await clickAuthButton(user);
+ expect(getAuthButton()).toBeDisabled();
+
+ await user.type(tenantInput, '2');
+ expect(getAuthButton()).toBeEnabled();
+ });
+
+ it('does not wait for a device code when using the Azure CLI', async ()=>{
+ const authenticateAzure = jest.fn(()=>Promise.resolve(true));
+ const getAuthCode = jest.fn(()=>Promise.resolve({
+ data: {data: {user_code: 'TESTCODE1'}},
+ }));
+ const schemaObj = new AzureCredSchema(eventBus, {
+ authenticateAzure, getAuthCode,
+ }, {});
+
+ const {user} = await renderSchema(schemaObj);
+ await user.click(screen.getByRole('button', {name: 'Azure CLI'}));
+ await clickAuthButton(user);
+
+ expect(authenticateAzure).toHaveBeenCalledWith(
+ 'azure_cli_credential', '');
+ expect(getAuthCode).not.toHaveBeenCalled();
+ });
+});