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 C4A541FF138 for ; Wed, 04 Feb 2026 17:15:21 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id DADFF1ADE2; Wed, 4 Feb 2026 17:15:00 +0100 (CET) From: Arthur Bied-Charreton To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox-widget-toolkit 1/2] utils: Add OAuth2 flow handlers Date: Wed, 4 Feb 2026 17:13:46 +0100 Message-ID: <20260204161354.458814-8-a.bied-charreton@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260204161354.458814-1-a.bied-charreton@proxmox.com> References: <20260204161354.458814-1-a.bied-charreton@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.120 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: HKZFTIKL7AEVPFUQ7NBVMKVDSS6ZFGCF X-Message-ID-Hash: HKZFTIKL7AEVPFUQ7NBVMKVDSS6ZFGCF 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: Introduce the Proxmox.OAuth2 singleton supporting Google and Microsoft OAuth2. The flow is handled by opening a new window with the authorization URL, and expects to receive the resulting authorization code from the redirect handler via a [BroadcastChannel]. [BroadcastChannel] https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel Signed-off-by: Arthur Bied-Charreton --- src/Utils.js | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/src/Utils.js b/src/Utils.js index 5457ffa..f59c4a5 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -1723,6 +1723,90 @@ Ext.define('Proxmox.Utils', { }, }); +Ext.define('Proxmox.OAuth2', { + singleton: true, + + handleGoogleFlow: function (clientId, clientSecret) { + return this._handleFlow({ + clientId, + clientSecret, + authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + scope: 'https://mail.google.com', + extraAuthParams: { + access_type: 'offline', + prompt: 'consent', + }, + }); + }, + + handleMicrosoftFlow: function(clientId, clientSecret, tenantId) { + return this._handleFlow({ + clientId, + clientSecret, + authUrl: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`, + tokenUrl: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, + scope: 'https://outlook.office.com/SMTP.Send offline_access', + extraAuthParams: { + prompt: 'consent', + }, + }); + }, + + _handleFlow: function (config) { + return new Promise((resolve, reject) => { + const redirectUri = window.location.origin; + const channelName = `oauth2_${crypto.randomUUID()}`; + const state = encodeURIComponent(JSON.stringify({ channelName })); + + const authParams = new URLSearchParams({ + client_id: config.clientId, + response_type: 'code', + redirect_uri: redirectUri, + scope: config.scope, + state, + ...config.extraAuthParams, + }); + + const authUrl = `${config.authUrl}?${authParams}`; + + // Opens OAuth2 authentication window. The app's redirect handler must + // extract the authorization code from the callback URL and send it via: + // new BroadcastChannel(state.channelName).postMessage({ code }) + const channel = new BroadcastChannel(channelName); + const popup = window.open(authUrl); + + channel.addEventListener('message', async (event) => { + if (popup && !popup.closed) { + popup.close(); + } + channel.close(); + + try { + const response = await fetch(config.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: event.data.code, + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: redirectUri, + }), + }); + + const tokens = await response.json(); + resolve(tokens.refresh_token); + } catch (error) { + reject(error); + } + }); + }) + } +}) + Ext.define('Proxmox.Async', { singleton: true, -- 2.47.3