From bf79993295535a21ebf29de8db0668d76362796f Mon Sep 17 00:00:00 2001 From: Dave Page Date: Thu, 15 Oct 2026 11:53:19 +0100 Subject: [PATCH 1/4] Fix Azure and Google authentication in the cloud deployment wizard. #9921 The Azure interactive browser authentication never showed the device code or opened the sign-in window, because the button no longer triggered the change that fetched the code; the request then waited until it timed out. Fetch the code directly from the button handler, update the form through the schema state so the code is displayed, stop polling once authentication finishes, let the user retry after a failure, and discard the code from an earlier attempt on the server. The Azure and Google progress messages showed raw HTML, as the wizard footer now renders plain text. On macOS, add /opt/homebrew/bin to the PATH of the server process so an Azure CLI installed with Homebrew on Apple Silicon is found. Links in form help messages and notes navigated the main window away, because DOMPurify drops the target attribute, prompting the desktop app to quit. Open them in a new window instead, and reword the Google client secret help text. Closes #9921 --- runtime/src/js/pgadmin.js | 16 +++-- web/pgadmin/misc/cloud/azure/__init__.py | 3 + .../azure/tests/test_azure_stale_auth_code.py | 69 ++++++++++++++++++ web/pgadmin/misc/cloud/static/js/azure.js | 20 ++++-- .../misc/cloud/static/js/azure_schema.ui.js | 59 +++++++++------- web/pgadmin/misc/cloud/static/js/google.js | 5 +- .../misc/cloud/static/js/google_schema.ui.js | 2 +- .../static/js/components/FormComponents.jsx | 23 +++++- .../components/FormComponents.spec.js | 23 ++++++ .../misc/cloud/azure_schema.spec.js | 70 +++++++++++++++++++ 10 files changed, 245 insertions(+), 45 deletions(-) create mode 100644 web/pgadmin/misc/cloud/azure/tests/test_azure_stale_auth_code.py create mode 100644 web/regression/javascript/misc/cloud/azure_schema.spec.js 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. ' + gettext('Loading...') + '']); + _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..27bc3ec40e1 100644 --- a/web/pgadmin/misc/cloud/static/js/azure_schema.ui.js +++ b/web/pgadmin/misc/cloud/static/js/azure_schema.ui.js @@ -90,6 +90,8 @@ class AzureCredSchema extends BaseUISchema { } this.eventBus.fireEvent('SET_CRED_VERIFICATION_INITIATED', false); this.eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD',['', '']); + // Changed credentials need to be authenticated again. + return {is_authenticated: false, auth_code: ''}; }, }, { @@ -106,8 +108,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 +128,31 @@ 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. + if (state.auth_type == 'interactive_browser_credential') { + obj.fieldOptions.getAuthCode() + .then((res) => { + setSchemaData({ + is_authenticating: false, + auth_code: res.data.data.user_code, + }); + }).catch(logError); + } }, 0); }, disabled: (state)=> { @@ -140,25 +166,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. ' + gettext('Loading...') + '']); + _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..599a15a48e0 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 clickAuthButton = async (user)=>{ + await user.click(screen.getByRole('button', { + name: 'Click here to authenticate yourself to Microsoft Azure'})); + // 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 getCreateView(schemaObj); + await user.type( + ctrl.container.querySelector('[name="azure_tenant_id"]'), + 'tenant-1'); + await clickAuthButton(user); + + expect(authenticateAzure).toHaveBeenCalledWith( + 'interactive_browser_credential', 'tenant-1'); + expect(getAuthCode).toHaveBeenCalledTimes(1); + expect(await screen.findByText('TESTCODE1')).toBeInTheDocument(); + }); + + 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 getCreateView(schemaObj); + await user.click(screen.getByRole('button', {name: 'Azure CLI'})); + await clickAuthButton(user); + + expect(authenticateAzure).toHaveBeenCalledWith( + 'azure_cli_credential', ''); + expect(getAuthCode).not.toHaveBeenCalled(); + }); +}); From 465e6bf8c753d06133b1657b5c936e8b3b8952d0 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Thu, 15 Oct 2026 12:18:22 +0100 Subject: [PATCH 2/4] Keep the Azure authenticate button disabled until the attempt settles, and update the Google OAuth help link. Re-enabling the button when the device code arrived let a second click start a second server-side attempt, whose success could then be wiped out by the late settlement of the first. Add tests for that, and for the authentication being reset when the tenant id changes. --- .../misc/cloud/static/js/azure_schema.ui.js | 15 +++-- .../misc/cloud/static/js/google_schema.ui.js | 2 +- .../misc/cloud/azure_schema.spec.js | 55 ++++++++++++++++++- 3 files changed, 63 insertions(+), 9 deletions(-) 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 27bc3ec40e1..2244ca39847 100644 --- a/web/pgadmin/misc/cloud/static/js/azure_schema.ui.js +++ b/web/pgadmin/misc/cloud/static/js/azure_schema.ui.js @@ -90,7 +90,9 @@ class AzureCredSchema extends BaseUISchema { } this.eventBus.fireEvent('SET_CRED_VERIFICATION_INITIATED', false); this.eventBus.fireEvent('SET_ERROR_MESSAGE_FOR_CLOUD_WIZARD',['', '']); - // Changed credentials need to be authenticated again. + // 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: ''}; }, }, @@ -143,14 +145,15 @@ class AzureCredSchema extends BaseUISchema { // 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. + // 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({ - is_authenticating: false, - auth_code: res.data.data.user_code, - }); + setSchemaData({auth_code: res.data.data.user_code}); }).catch(logError); } }, 0); 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 599a15a48e0..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. See Manage OAuth Clients in the Google Cloud documentation for how to create one.'), + 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/regression/javascript/misc/cloud/azure_schema.spec.js b/web/regression/javascript/misc/cloud/azure_schema.spec.js index 398d6212fa8..f053c346444 100644 --- a/web/regression/javascript/misc/cloud/azure_schema.spec.js +++ b/web/regression/javascript/misc/cloud/azure_schema.spec.js @@ -19,9 +19,11 @@ describe('AzureCredSchema', ()=>{ eventBus.fireEvent.mockClear(); }); + const getAuthButton = ()=>screen.getByRole('button', { + name: 'Click here to authenticate yourself to Microsoft Azure'}); + const clickAuthButton = async (user)=>{ - await user.click(screen.getByRole('button', { - name: 'Click here to authenticate yourself to Microsoft Azure'})); + await user.click(getAuthButton()); // The click handler defers its work with setTimeout(). await act(async ()=>{ await new Promise((resolve)=>setTimeout(resolve, 10)); @@ -50,6 +52,55 @@ describe('AzureCredSchema', ()=>{ 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 getCreateView(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('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 getCreateView(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({ From 516f0f3758339eb7007b297908f883a9e83f6145 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Thu, 15 Oct 2026 16:55:17 +0100 Subject: [PATCH 3/4] Wait for the dialog's initial focus before typing in the Azure credential tests. The dialog moves the focus to its first input 200ms after opening. On a loaded CI runner the test was still typing the tenant id at that point, so the remaining keystrokes went to the authentication type toggle and the field held only part of the value (or nothing, leaving the button disabled). --- .../misc/cloud/azure_schema.spec.js | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/web/regression/javascript/misc/cloud/azure_schema.spec.js b/web/regression/javascript/misc/cloud/azure_schema.spec.js index f053c346444..ee1b9017c6d 100644 --- a/web/regression/javascript/misc/cloud/azure_schema.spec.js +++ b/web/regression/javascript/misc/cloud/azure_schema.spec.js @@ -9,7 +9,7 @@ import {AzureCredSchema} from '../../../../pgadmin/misc/cloud/static/js/azure_schema.ui'; import {genericBeforeEach, getCreateView} from '../../genericFunctions'; -import {act, screen} from '@testing-library/react'; +import {act, screen, waitFor} from '@testing-library/react'; describe('AzureCredSchema', ()=>{ const eventBus = {fireEvent: jest.fn()}; @@ -22,6 +22,19 @@ describe('AzureCredSchema', ()=>{ 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(). @@ -40,10 +53,11 @@ describe('AzureCredSchema', ()=>{ authenticateAzure, getAuthCode, }, {}); - const {ctrl, user} = await getCreateView(schemaObj); - await user.type( - ctrl.container.querySelector('[name="azure_tenant_id"]'), - 'tenant-1'); + 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( @@ -64,7 +78,7 @@ describe('AzureCredSchema', ()=>{ authenticateAzure, getAuthCode, }, {}); - const {ctrl, user} = await getCreateView(schemaObj); + const {ctrl, user} = await renderSchema(schemaObj); await user.type( ctrl.container.querySelector('[name="azure_tenant_id"]'), 'tenant-1'); @@ -90,7 +104,7 @@ describe('AzureCredSchema', ()=>{ authenticateAzure, getAuthCode, }, {}); - const {ctrl, user} = await getCreateView(schemaObj); + const {ctrl, user} = await renderSchema(schemaObj); const tenantInput = ctrl.container.querySelector( '[name="azure_tenant_id"]'); await user.type(tenantInput, 'tenant-1'); @@ -110,7 +124,7 @@ describe('AzureCredSchema', ()=>{ authenticateAzure, getAuthCode, }, {}); - const {user} = await getCreateView(schemaObj); + const {user} = await renderSchema(schemaObj); await user.click(screen.getByRole('button', {name: 'Azure CLI'})); await clickAuthButton(user); From 7886c819993865bd04375abdd7b5b45a7a2d7a6f Mon Sep 17 00:00:00 2001 From: Dave Page Date: Thu, 15 Oct 2026 19:47:16 +0100 Subject: [PATCH 4/4] Lock the Azure tenant and authentication type whilst authentication is pending. Otherwise the late success of an attempt for the old values would be taken as authentication for the new ones, enabling Next whilst the server's cached credential is still bound to the old tenant. --- .../misc/cloud/static/js/azure_schema.ui.js | 12 +++++-- .../misc/cloud/azure_schema.spec.js | 35 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) 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 2244ca39847..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') { diff --git a/web/regression/javascript/misc/cloud/azure_schema.spec.js b/web/regression/javascript/misc/cloud/azure_schema.spec.js index ee1b9017c6d..9cd5352b546 100644 --- a/web/regression/javascript/misc/cloud/azure_schema.spec.js +++ b/web/regression/javascript/misc/cloud/azure_schema.spec.js @@ -97,6 +97,41 @@ describe('AzureCredSchema', ()=>{ 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(()=>{}));