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 736D21FF141 for ; Tue, 05 May 2026 10:34:05 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id C96E81B51E; Tue, 5 May 2026 10:33:37 +0200 (CEST) From: Arthur Bied-Charreton To: pve-devel@lists.proxmox.com, pbs-devel@lists.proxmox.com Subject: [PATCH proxmox-widget-toolkit v5 13/27] utils: add OAuth2 flow handlers Date: Tue, 5 May 2026 10:32:34 +0200 Message-ID: <20260505083248.36450-14-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.113 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: 4N6ZD4Q7BOA7SN5YRNGOZBD6FG5MCHWQ X-Message-ID-Hash: 4N6ZD4Q7BOA7SN5YRNGOZBD6FG5MCHWQ 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 Backup Server development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Introduce the Proxmox.OAuth2 singleton supporting Google and Microsoft OAuth2. The flow is handled by opening a new window with the authorization URL, and expecting to receive the resulting authorization code from the redirect handler via a BroadcastChannel [0], which allows communication between any two browsing contexts. [0] https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel Signed-off-by: Arthur Bied-Charreton --- src/Utils.js | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/src/Utils.js b/src/Utils.js index 5457ffa..8ab4609 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -1723,6 +1723,105 @@ Ext.define('Proxmox.Utils', { }, }); +Ext.define('Proxmox.OAuth2', { + singleton: true, + + handleGoogleFlow: function (clientId, clientSecret, refreshTokenUrl) { + return this._handleFlow({ + authMethod: 'google-oauth2', + clientId, + clientSecret, + refreshTokenUrl, + authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scope: 'https://mail.google.com', + extraAuthParams: { + access_type: 'offline', + prompt: 'consent', + }, + }); + }, + + handleMicrosoftFlow: function (clientId, clientSecret, tenantId, refreshTokenUrl) { + return this._handleFlow({ + authMethod: 'microsoft-oauth2', + tenantId, + clientId, + clientSecret, + refreshTokenUrl, + authUrl: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`, + scope: 'https://outlook.office.com/SMTP.Send offline_access', + extraAuthParams: { + prompt: 'consent', + }, + }); + }, + + _handleFlow: function (config) { + return new Promise((resolve, reject) => { + let redirectUri = window.location.origin; + let channelName = `oauth2_${crypto.randomUUID()}`; + let state = encodeURIComponent(JSON.stringify({ channelName })); + + let authParams = new URLSearchParams({ + client_id: config.clientId, + response_type: 'code', + redirect_uri: redirectUri, + scope: config.scope, + state, + ...config.extraAuthParams, + }); + + let authUrl = `${config.authUrl}?${authParams}`; + + let channel = new BroadcastChannel(channelName); + // Opens OAuth2 authorization window. The app's redirect handler must + // extract the authorization code from the callback URL and send it via + // the BroadcastChannel whose name we passed along as a state parameter. + let popup = window.open(authUrl); + if (!popup) { + reject(gettext('Could not open authorization window')); + return; + } + + channel.addEventListener('message', (event) => { + if (popup && !popup.closed) { + popup.close(); + } + channel.close(); + + let code = event.data.code; + if (!code) { + reject( + gettext('Did not receive any authorization code from authorization window'), + ); + return; + } + + let params = { + 'auth-method': config.authMethod, + 'client-id': config.clientId, + 'client-secret': config.clientSecret, + 'authorization-code': code, + 'redirect-uri': redirectUri, + }; + if (config.tenantId) { + params['tenant-id'] = config.tenantId; + } + + Proxmox.Async.api2({ + url: config.refreshTokenUrl, + method: 'POST', + params, + }) + .then(({ result }) => resolve(result.data)) + .catch((response) => { + reject(response.htmlStatus || gettext('Token exchange failed')); + }); + }); + }); + }, +}); + Ext.define('Proxmox.Async', { singleton: true, -- 2.47.3