From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 90BBC1FF141 for ; Tue, 05 May 2026 10:35:53 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 8BF851C888; Tue, 5 May 2026 10:33:55 +0200 (CEST) From: Arthur Bied-Charreton To: pve-devel@lists.proxmox.com, pbs-devel@lists.proxmox.com Subject: [PATCH proxmox-widget-toolkit v5 15/27] notifications: add opt-in OAuth2 support for SMTP targets Date: Tue, 5 May 2026 10:32:36 +0200 Message-ID: <20260505083248.36450-16-a.bied-charreton@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260505083248.36450-1-a.bied-charreton@proxmox.com> References: <20260505083248.36450-1-a.bied-charreton@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.111 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods RDNS_NONE 0.793 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an SPF Record Message-ID-Hash: VYOI35ZRQTKZCDKORX5WYXISQZW5DAJU X-Message-ID-Hash: VYOI35ZRQTKZCDKORX5WYXISQZW5DAJU X-MailFrom: abied-charreton@jett.proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Add Google & Microsoft OAuth2 authorization support to SMTP endpoint config. The enableOAuth2 config flag in pmxSmtpEditPanel allows consumers to opt into this new feature, so it can be gradually introduced into products. When disabled, no changes are visible from the UI. The exchange trading the initial authorization code for a refresh token needs to happen in the backend, Azure AD does not allow browser-originated token requests for "Web" client types, which we require in order to be able to keep tokens valid without requiring re-authorization by users. Since the exposed endpoints for performing this exchange live at different paths in PBS and PVE, expect the URL as a config argument passed by the products and throw if it is not set. Signed-off-by: Arthur Bied-Charreton --- src/panel/SmtpEditPanel.js | 266 ++++++++++++++++++++++++++++++--- src/window/EndpointEditBase.js | 2 + 2 files changed, 244 insertions(+), 24 deletions(-) diff --git a/src/panel/SmtpEditPanel.js b/src/panel/SmtpEditPanel.js index 37e4d51..3c32029 100644 --- a/src/panel/SmtpEditPanel.js +++ b/src/panel/SmtpEditPanel.js @@ -6,12 +6,31 @@ Ext.define('Proxmox.panel.SmtpEditPanel', { type: 'smtp', + enableOAuth2: false, + // Backend URL for endpoint exchanging the initial OAuth2 authorization code + // for a refresh token. + refreshTokenUrl: undefined, + + initConfig: function (config) { + this.callParent(arguments); + + if (config.enableOAuth2 && !config.refreshTokenUrl) { + throw new Error('refreshTokenUrl must be set if XOAUTH2 is enabled'); + } + + return config; + }, + viewModel: { xtype: 'viewmodel', data: { mode: 'tls', - authentication: true, - originalAuthentication: true, + authMethod: 'plain', + oAuth2ClientId: '', + oAuth2ClientSecret: '', + oAuth2TenantId: '', + oAuth2RefreshToken: '', + originalAuthMethod: undefined, }, formulas: { portEmptyText: function (get) { @@ -30,14 +49,47 @@ Ext.define('Proxmox.panel.SmtpEditPanel', { } return `${Proxmox.Utils.defaultText} (${port})`; }, + authKind: function (get) { + let method = get('authMethod'); + let isOAuth2 = method === 'google-oauth2' || method === 'microsoft-oauth2'; + return isOAuth2 && !this.getView().enableOAuth2 ? 'none' : method; + }, + isOAuth2Authentication: function (get) { + let kind = get('authKind'); + return kind === 'google-oauth2' || kind === 'microsoft-oauth2'; + }, + enableAuthorize: function (get) { + if (!get('isOAuth2Authentication')) { + return false; + } + let clientId = get('oAuth2ClientId')?.trim(); + let clientSecret = get('oAuth2ClientSecret')?.trim(); + if (!clientId || !clientSecret) { + return false; + } + if (get('authKind') === 'microsoft-oauth2') { + return !!get('oAuth2TenantId')?.trim(); + } + return true; + }, passwordEmptyText: function (get) { let isCreate = this.getView().isCreate; - - let auth = get('authentication'); - let origAuth = get('originalAuthentication'); - let shouldShowUnchanged = !isCreate && auth && origAuth; - - return shouldShowUnchanged ? gettext('Unchanged') : ''; + let isPlain = get('authKind') === 'plain'; + let wasPlain = get('originalAuthMethod') === 'plain'; + return !isCreate && isPlain && wasPlain ? gettext('Unchanged') : ''; + }, + oAuth2ClientSecretEmptyText: function (get) { + let isCreate = this.getView().isCreate; + let isOAuth2 = get('isOAuth2Authentication'); + let origMethod = get('originalAuthMethod'); + let wasOAuth2 = origMethod === 'google-oauth2' || origMethod === 'microsoft-oauth2'; + return !isCreate && isOAuth2 && wasOAuth2 ? gettext('Unchanged') : ''; + }, + isAuthorized: function (get) { + return get('isOAuth2Authentication') && !!get('oAuth2RefreshToken'); + }, + authorizeButtonDisabled: function (get) { + return !get('enableAuthorize') || get('isAuthorized'); }, }, }, @@ -102,11 +154,25 @@ Ext.define('Proxmox.panel.SmtpEditPanel', { ], column2: [ { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Authenticate'), - name: 'authentication', - bind: { - value: '{authentication}', + xtype: 'proxmoxKVComboBox', + fieldLabel: gettext('Authentication'), + name: 'auth-method', + comboItems: [ + ['none', gettext('None')], + ['plain', gettext('Username/Password')], + ['google-oauth2', gettext('OAuth2 (Google)')], + ['microsoft-oauth2', gettext('OAuth2 (Microsoft)')], + ], + bind: '{authMethod}', + cbind: { + deleteEmpty: '{!isCreate}', + }, + listeners: { + render: function () { + if (!this.up('pmxSmtpEditPanel').enableOAuth2) { + this.getStore().filter('key', /^(none|plain)$/); + } + }, }, }, { @@ -118,7 +184,8 @@ Ext.define('Proxmox.panel.SmtpEditPanel', { deleteEmpty: '{!isCreate}', }, bind: { - disabled: '{!authentication}', + hidden: '{authKind !== "plain"}', + disabled: '{authKind !== "plain"}', }, }, { @@ -130,10 +197,109 @@ Ext.define('Proxmox.panel.SmtpEditPanel', { allowBlank: '{!isCreate}', }, bind: { - disabled: '{!authentication}', + hidden: '{authKind !== "plain"}', + disabled: '{authKind !== "plain"}', emptyText: '{passwordEmptyText}', }, }, + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('Client ID'), + name: 'oauth2-client-id', + allowBlank: false, + bind: { + hidden: '{!isOAuth2Authentication}', + disabled: '{!isOAuth2Authentication}', + value: '{oAuth2ClientId}', + }, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxtextfield', + inputType: 'password', + fieldLabel: gettext('Client Secret'), + name: 'oauth2-client-secret', + bind: { + hidden: '{!isOAuth2Authentication}', + disabled: '{!isOAuth2Authentication}', + value: '{oAuth2ClientSecret}', + emptyText: '{oAuth2ClientSecretEmptyText}', + }, + cbind: { + allowBlank: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('Tenant ID'), + name: 'oauth2-tenant-id', + allowBlank: false, + bind: { + hidden: '{authKind !== "microsoft-oauth2"}', + disabled: '{authKind !== "microsoft-oauth2"}', + value: '{oAuth2TenantId}', + }, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'fieldcontainer', + fieldLabel: gettext('Authorize'), + layout: 'hbox', + bind: { + hidden: '{!isOAuth2Authentication}', + }, + items: [ + { + xtype: 'button', + text: gettext('Authorize'), + handler: async function () { + let panel = this.up('pmxSmtpEditPanel'); + let form = panel.up('form'); + let values = form.getValues(); + + try { + let refreshToken = await panel.handleOAuth2Flow(values); + panel.getViewModel().set('oAuth2RefreshToken', refreshToken); + } catch (e) { + Ext.Msg.alert('Error', e); + } + }, + bind: { + disabled: '{authorizeButtonDisabled}', + }, + }, + { + xtype: 'displayfield', + renderer: Ext.identityFn, + value: ` ${gettext('Authorized')}`, + margin: '0 0 0 8', + bind: { + hidden: '{!isAuthorized}', + }, + }, + ], + }, + { + xtype: 'hiddenfield', + name: 'oauth2-refresh-token', + allowBlank: false, + bind: { + value: '{oAuth2RefreshToken}', + disabled: '{!isOAuth2Authentication}', + }, + // Silently block form submissions on create until the user has clicked Authorize + // and obtained a refresh token. + getErrors: function () { + if (this.disabled || !this.up('pmxSmtpEditPanel').isCreate) { + return []; + } + return this.getValue() ? [] : ['']; + }, + }, ], columnB: [ { @@ -172,7 +338,25 @@ Ext.define('Proxmox.panel.SmtpEditPanel', { }, }, ], + handleOAuth2Flow: function (values) { + let authMethod = values['auth-method']; + let refreshTokenUrl = this.refreshTokenUrl; + if (authMethod === 'microsoft-oauth2') { + return Proxmox.OAuth2.handleMicrosoftFlow( + values['oauth2-client-id'], + values['oauth2-client-secret'], + values['oauth2-tenant-id'], + refreshTokenUrl, + ); + } else if (authMethod === 'google-oauth2') { + return Proxmox.OAuth2.handleGoogleFlow( + values['oauth2-client-id'], + values['oauth2-client-secret'], + refreshTokenUrl, + ); + } + }, onGetValues: function (values) { let me = this; @@ -180,9 +364,31 @@ Ext.define('Proxmox.panel.SmtpEditPanel', { values.mailto = values.mailto.split(/[\s,;]+/); } - if (!values.authentication && !me.isCreate) { - Proxmox.Utils.assemble_field_data(values, { delete: 'username' }); - Proxmox.Utils.assemble_field_data(values, { delete: 'password' }); + let authMethod = values['auth-method']; + if (!this.enableOAuth2 || authMethod === 'none') { + delete values['auth-method']; + } + + if (!values['oauth2-refresh-token']) { + delete values['oauth2-refresh-token']; + } + + if (!me.isCreate) { + let oauthFields = ['oauth2-client-id', 'oauth2-client-secret', 'oauth2-tenant-id']; + let deletionsByMethod = { + none: [ + 'username', + 'password', + ...(this.enableOAuth2 ? ['auth-method', ...oauthFields] : []), + ], + plain: this.enableOAuth2 ? oauthFields : [], + 'microsoft-oauth2': ['username', 'password'], + 'google-oauth2': ['username', 'password', 'oauth2-tenant-id'], + }; + + for (let field of deletionsByMethod[authMethod] || []) { + Proxmox.Utils.assemble_field_data(values, { delete: field }); + } } if (values.enable) { @@ -199,19 +405,31 @@ Ext.define('Proxmox.panel.SmtpEditPanel', { return values; }, - onSetValues: function (values) { let me = this; - values.authentication = !!values.username; values.enable = !values.disable; + + if (values['auth-method'] === undefined && this.enableOAuth2) { + if (values['oauth2-tenant-id']) { + values['auth-method'] = 'microsoft-oauth2'; + } else if (values['oauth2-client-id']) { + values['auth-method'] = 'google-oauth2'; + } else if (values.username) { + values['auth-method'] = 'plain'; + } else { + values['auth-method'] = 'none'; + } + } + delete values.disable; // Fix race condition in chromium-based browsers. Without this, the - // 'Authenticate' remains ticked (the default value) if loading an - // SMTP target without authentication. - me.getViewModel().set('authentication', values.authentication); - me.getViewModel().set('originalAuthentication', values.authentication); + // auth method remains set to 'plain' (the default) when loading a + // target with a different method set, which in some cases leads to + // the 'unchanged' empty text for the OAuth2 client secret being + // skipped. + me.getViewModel().set('originalAuthMethod', values['auth-method']); return values; }, diff --git a/src/window/EndpointEditBase.js b/src/window/EndpointEditBase.js index 8c1bfc1..d7810a2 100644 --- a/src/window/EndpointEditBase.js +++ b/src/window/EndpointEditBase.js @@ -47,6 +47,8 @@ Ext.define('Proxmox.window.EndpointEditBase', { baseUrl: me.baseUrl, type: me.type, defaultMailAuthor: endpointConfig.defaultMailAuthor, + enableOAuth2: endpointConfig.enableOAuth2, + refreshTokenUrl: endpointConfig.refreshTokenUrl, }, ], }); -- 2.47.3