Skip to content

Commit f3862a6

Browse files
gambthoillume
andcommitted
app: electron: Register OAuth callback providers
Route validated product protocol callbacks to their owning OAuth provider so desktop integrations can complete authentication safely. Co-authored-by: René Dudfield <renedudfield@microsoft.com>
1 parent 48a712d commit f3862a6

3 files changed

Lines changed: 278 additions & 31 deletions

File tree

app/electron/main.ts

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
PluginManager,
5555
} from './plugin-management';
5656
import { isProtocolUrl, readProtocolScheme } from './protocol';
57+
import { createProtocolCallbackHandler } from './protocol-callback-handler';
5758
import {
5859
addRunCmdConsent,
5960
environmentOverrides,
@@ -205,6 +206,30 @@ let mcpClient: MCPClient | null = null;
205206
let isQuitting = false;
206207
let hasTray = false;
207208

209+
const protocolCallbackHandler = createProtocolCallbackHandler({
210+
protocolScheme,
211+
handleInvalidUrl(value) {
212+
dialog.showErrorBox(
213+
i18n.t('Invalid URL'),
214+
i18n.t('Application opened with an invalid URL: {{ url }}', { url: value })
215+
);
216+
},
217+
handleUnclaimedUrl(callbackUrl) {
218+
const urlParam = callbackUrl.hostname;
219+
let baseUrl = startUrl;
220+
if (baseUrl.endsWith('/')) {
221+
baseUrl = baseUrl.slice(0, startUrl.length - 1);
222+
}
223+
mainWindow?.loadURL(baseUrl + '#' + urlParam + callbackUrl.search);
224+
},
225+
});
226+
227+
app.on('open-url', (event, value) => {
228+
event.preventDefault();
229+
mainWindow?.focus();
230+
protocolCallbackHandler.handle(value);
231+
});
232+
208233
/**
209234
* `Action` is an interface for an action to be performed by the plugin manager.
210235
*
@@ -1635,6 +1660,7 @@ function startElectron() {
16351660
}
16361661
});
16371662

1663+
let initialProtocolCallbackHandled = false;
16381664
mainWindow.webContents.on('did-finish-load', async () => {
16391665
const startZoom = await loadZoomFactor();
16401666
if (startZoom !== 1.0) {
@@ -1643,6 +1669,15 @@ function startElectron() {
16431669

16441670
// Inject the backend port into the window object
16451671
mainWindow?.webContents.executeJavaScript(`window.headlampBackendPort = ${actualPort};`);
1672+
1673+
protocolCallbackHandler.setReady();
1674+
if (!initialProtocolCallbackHandled && process.platform !== 'darwin') {
1675+
initialProtocolCallbackHandled = true;
1676+
const callbackUrl = process.argv.find(value => isProtocolUrl(value, protocolScheme));
1677+
if (callbackUrl) {
1678+
protocolCallbackHandler.handle(callbackUrl);
1679+
}
1680+
}
16461681
});
16471682

16481683
mainWindow.webContents.on('dom-ready', () => {
@@ -1686,12 +1721,17 @@ function startElectron() {
16861721
// Force Single Instance Application
16871722
const gotTheLock = app.requestSingleInstanceLock();
16881723
if (gotTheLock) {
1689-
app.on('second-instance', () => {
1724+
app.on('second-instance', (_event, argv) => {
16901725
// Someone tried to run a second instance, we should focus our window.
16911726
if (mainWindow) {
16921727
if (mainWindow.isMinimized()) mainWindow.restore();
16931728
mainWindow.focus();
16941729
}
1730+
1731+
const callbackUrl = argv.find(value => isProtocolUrl(value, protocolScheme));
1732+
if (callbackUrl) {
1733+
protocolCallbackHandler.handle(callbackUrl);
1734+
}
16951735
});
16961736
} else {
16971737
app.quit();
@@ -1711,36 +1751,6 @@ function startElectron() {
17111751
shell.openExternal(url);
17121752
});
17131753

1714-
app.on('open-url', (event, url) => {
1715-
mainWindow?.focus();
1716-
let urlObj;
1717-
try {
1718-
urlObj = new URL(url);
1719-
} catch (e) {
1720-
dialog.showErrorBox(
1721-
i18n.t('Invalid URL'),
1722-
i18n.t('Application opened with an invalid URL: {{ url }}', { url })
1723-
);
1724-
return;
1725-
}
1726-
if (!isProtocolUrl(url, protocolScheme)) {
1727-
dialog.showErrorBox(
1728-
i18n.t('Invalid URL'),
1729-
i18n.t('Application opened with an invalid URL: {{ url }}', { url })
1730-
);
1731-
return;
1732-
}
1733-
1734-
const urlParam = urlObj.hostname;
1735-
let baseUrl = startUrl;
1736-
// this check helps us to avoid adding multiple / to the startUrl when appending the incoming url to it
1737-
if (baseUrl.endsWith('/')) {
1738-
baseUrl = baseUrl.slice(0, startUrl.length - 1);
1739-
}
1740-
// load the index.html from build and route to the hostname received in the protocol handler url
1741-
mainWindow?.loadURL(baseUrl + '#' + urlParam + urlObj.search);
1742-
});
1743-
17441754
i18n.on('languageChanged', () => {
17451755
updateMenuLabels(currentMenu);
17461756
setMenu(mainWindow, currentMenu);

app/electron/oauth-provider.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*
2+
* Copyright 2025 The Kubernetes Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
const VALID_PROVIDER_ID = /^[a-z0-9][a-z0-9._-]*$/i;
18+
const VALID_HOSTNAME = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i;
19+
const VALID_PATHNAME = /^\/[a-z0-9/._~-]*$/i;
20+
const MAX_PROVIDER_ID_LENGTH = 128;
21+
22+
/** Identifies the protocol route owned by an OAuth provider. */
23+
export interface OAuthProviderCallback {
24+
/** Hostname expected in the provider callback URL. */
25+
hostname: string;
26+
/** Absolute path expected in the provider callback URL. */
27+
pathname: string;
28+
}
29+
30+
/** Describes an OAuth provider that can receive desktop protocol callbacks. */
31+
export interface OAuthProviderRegistration {
32+
/** Stable identifier used to attribute callback failures. */
33+
id: string;
34+
/** Protocol route owned by the provider. */
35+
callback: OAuthProviderCallback;
36+
/**
37+
* Handles a callback claimed by this provider.
38+
*
39+
* @param url Validated callback URL, including provider query parameters.
40+
* @returns Nothing, or a promise that settles after callback handling completes.
41+
*/
42+
handleCallback(url: URL): void | Promise<void>;
43+
}
44+
45+
const providersByCallback = new Map<string, OAuthProviderRegistration>();
46+
47+
/**
48+
* Creates a case-insensitive lookup key for a callback route.
49+
*
50+
* @param hostname Callback URL hostname.
51+
* @param pathname Callback URL pathname.
52+
* @returns A registry key for the callback route.
53+
*/
54+
function callbackKey(hostname: string, pathname: string): string {
55+
return `${hostname.toLowerCase()}\n${pathname}`;
56+
}
57+
58+
/**
59+
* Checks whether a callback pathname is absolute and contains no traversal segments.
60+
*
61+
* @param pathname Callback pathname to validate.
62+
* @returns Whether the pathname is safe for exact route matching.
63+
*/
64+
function isValidPathname(pathname: string): boolean {
65+
return (
66+
VALID_PATHNAME.test(pathname) &&
67+
pathname.split('/').every(segment => segment !== '.' && segment !== '..')
68+
);
69+
}
70+
71+
/**
72+
* Validates an OAuth provider registration before storing it.
73+
*
74+
* @param registration Candidate provider registration to validate.
75+
* @returns Whether every registration field is valid.
76+
*/
77+
function isValidRegistration(registration: unknown): registration is OAuthProviderRegistration {
78+
if (typeof registration !== 'object' || registration === null) {
79+
return false;
80+
}
81+
82+
const candidate = registration as Partial<OAuthProviderRegistration>;
83+
return (
84+
typeof candidate.id === 'string' &&
85+
candidate.id.length <= MAX_PROVIDER_ID_LENGTH &&
86+
VALID_PROVIDER_ID.test(candidate.id) &&
87+
typeof candidate.callback?.hostname === 'string' &&
88+
VALID_HOSTNAME.test(candidate.callback.hostname) &&
89+
typeof candidate.callback?.pathname === 'string' &&
90+
isValidPathname(candidate.callback.pathname) &&
91+
typeof candidate.handleCallback === 'function'
92+
);
93+
}
94+
95+
/**
96+
* Registers an OAuth provider as the exclusive owner of a callback route.
97+
*
98+
* @param registration Provider callback registration.
99+
* @returns A function that unregisters this provider without affecting replacements.
100+
* @throws When the registration is invalid or its callback route is already owned.
101+
*/
102+
export function registerOAuthProvider(registration: OAuthProviderRegistration): () => void {
103+
if (!isValidRegistration(registration)) {
104+
throw new Error('Invalid OAuth provider registration');
105+
}
106+
const key = callbackKey(registration.callback.hostname, registration.callback.pathname);
107+
if (providersByCallback.has(key)) {
108+
throw new Error('OAuth callback is already registered');
109+
}
110+
providersByCallback.set(key, registration);
111+
112+
return () => {
113+
if (providersByCallback.get(key) === registration) {
114+
providersByCallback.delete(key);
115+
}
116+
};
117+
}
118+
119+
/**
120+
* Dispatches a product protocol URL to its registered OAuth provider.
121+
*
122+
* Provider failures are reported asynchronously and do not release ownership of
123+
* the callback URL to other protocol handlers.
124+
*
125+
* @param url Candidate OAuth callback URL.
126+
* @param protocolScheme Product protocol scheme accepted by this application.
127+
* @returns Whether a provider claimed the callback URL.
128+
*/
129+
export function dispatchOAuthCallback(url: URL, protocolScheme: string): boolean {
130+
if (url.protocol !== `${protocolScheme}:`) {
131+
return false;
132+
}
133+
const provider = providersByCallback.get(callbackKey(url.hostname, url.pathname));
134+
if (!provider) {
135+
return false;
136+
}
137+
138+
try {
139+
Promise.resolve(provider.handleCallback(url)).catch(error => {
140+
console.error(`OAuth callback failed for provider ${provider.id}:`, error);
141+
});
142+
} catch (error) {
143+
console.error(`OAuth callback failed for provider ${provider.id}:`, error);
144+
}
145+
return true;
146+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright 2025 The Kubernetes Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { dispatchOAuthCallback } from './oauth-provider';
18+
19+
/** Configures product protocol callback validation and fallback behavior. */
20+
export interface ProtocolCallbackHandlerOptions {
21+
/** Product protocol scheme accepted by the desktop application. */
22+
protocolScheme: string;
23+
/** Handles malformed URLs and URLs for a different product protocol. */
24+
handleInvalidUrl(value: string): void;
25+
/** Handles valid product protocol URLs that no OAuth provider claimed. */
26+
handleUnclaimedUrl(url: URL): void;
27+
}
28+
29+
/** Buffers and dispatches product protocol callbacks around application startup. */
30+
export interface ProtocolCallbackHandler {
31+
/**
32+
* Accepts a protocol callback immediately or queues it until initialization completes.
33+
*
34+
* @param value Raw protocol callback URL.
35+
*/
36+
handle(value: string): void;
37+
/** Marks providers and the application window ready, then drains queued callbacks. */
38+
setReady(): void;
39+
}
40+
41+
/**
42+
* Creates a handler that delays protocol callback dispatch until application initialization.
43+
*
44+
* @param options Product protocol and fallback handlers.
45+
* @returns A callback handler with an explicit readiness boundary.
46+
*/
47+
export function createProtocolCallbackHandler(
48+
options: ProtocolCallbackHandlerOptions
49+
): ProtocolCallbackHandler {
50+
const pendingUrls: string[] = [];
51+
let isReady = false;
52+
53+
/** Processes a callback after providers and the application window are ready. */
54+
function processUrl(value: string): void {
55+
let callbackUrl: URL;
56+
try {
57+
callbackUrl = new URL(value);
58+
} catch {
59+
options.handleInvalidUrl(value);
60+
return;
61+
}
62+
63+
if (callbackUrl.protocol !== `${options.protocolScheme}:`) {
64+
options.handleInvalidUrl(value);
65+
return;
66+
}
67+
68+
if (!dispatchOAuthCallback(callbackUrl, options.protocolScheme)) {
69+
options.handleUnclaimedUrl(callbackUrl);
70+
}
71+
}
72+
73+
return {
74+
handle(value) {
75+
if (!isReady) {
76+
pendingUrls.push(value);
77+
return;
78+
}
79+
processUrl(value);
80+
},
81+
setReady() {
82+
if (isReady) {
83+
return;
84+
}
85+
isReady = true;
86+
for (const value of pendingUrls.splice(0)) {
87+
processUrl(value);
88+
}
89+
},
90+
};
91+
}

0 commit comments

Comments
 (0)