Skip to content

Commit e6b98c1

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 b766099 commit e6b98c1

2 files changed

Lines changed: 158 additions & 1 deletion

File tree

app/electron/main.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { hideBin } from 'yargs/helpers';
4343
import { setupCustomCAs, setupSystemCAs } from './certificates';
4444
import i18n from './i18next.config';
4545
import MCPClient from './mcp/MCPClient';
46+
import { dispatchOAuthCallback } from './oauth-provider';
4647
import { filterUserOwnedPids } from './ownedProcesses';
4748
import {
4849
addToPath,
@@ -1635,6 +1636,7 @@ function startElectron() {
16351636
}
16361637
});
16371638

1639+
let initialOAuthCallbackHandled = false;
16381640
mainWindow.webContents.on('did-finish-load', async () => {
16391641
const startZoom = await loadZoomFactor();
16401642
if (startZoom !== 1.0) {
@@ -1643,6 +1645,12 @@ function startElectron() {
16431645

16441646
// Inject the backend port into the window object
16451647
mainWindow?.webContents.executeJavaScript(`window.headlampBackendPort = ${actualPort};`);
1648+
1649+
if (!initialOAuthCallbackHandled && process.platform !== 'darwin') {
1650+
const callbackUrl = process.argv.find(value => isProtocolUrl(value, protocolScheme));
1651+
initialOAuthCallbackHandled =
1652+
callbackUrl !== undefined && dispatchOAuthCallback(new URL(callbackUrl), protocolScheme);
1653+
}
16461654
});
16471655

16481656
mainWindow.webContents.on('dom-ready', () => {
@@ -1686,12 +1694,17 @@ function startElectron() {
16861694
// Force Single Instance Application
16871695
const gotTheLock = app.requestSingleInstanceLock();
16881696
if (gotTheLock) {
1689-
app.on('second-instance', () => {
1697+
app.on('second-instance', (_event, argv) => {
16901698
// Someone tried to run a second instance, we should focus our window.
16911699
if (mainWindow) {
16921700
if (mainWindow.isMinimized()) mainWindow.restore();
16931701
mainWindow.focus();
16941702
}
1703+
1704+
const callbackUrl = argv.find(value => isProtocolUrl(value, protocolScheme));
1705+
if (callbackUrl) {
1706+
dispatchOAuthCallback(new URL(callbackUrl), protocolScheme);
1707+
}
16951708
});
16961709
} else {
16971710
app.quit();
@@ -1730,6 +1743,9 @@ function startElectron() {
17301743
);
17311744
return;
17321745
}
1746+
if (dispatchOAuthCallback(urlObj, protocolScheme)) {
1747+
return;
1748+
}
17331749

17341750
const urlParam = urlObj.hostname;
17351751
let baseUrl = startUrl;

app/electron/oauth-provider.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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 Provider registration to validate.
75+
* @returns Whether every registration field is valid.
76+
*/
77+
function isValidRegistration(registration: OAuthProviderRegistration): boolean {
78+
return (
79+
typeof registration.id === 'string' &&
80+
registration.id.length <= MAX_PROVIDER_ID_LENGTH &&
81+
VALID_PROVIDER_ID.test(registration.id) &&
82+
typeof registration.callback?.hostname === 'string' &&
83+
VALID_HOSTNAME.test(registration.callback.hostname) &&
84+
typeof registration.callback?.pathname === 'string' &&
85+
isValidPathname(registration.callback.pathname) &&
86+
typeof registration.handleCallback === 'function'
87+
);
88+
}
89+
90+
/**
91+
* Registers an OAuth provider as the exclusive owner of a callback route.
92+
*
93+
* @param registration Provider callback registration.
94+
* @returns A function that unregisters this provider without affecting replacements.
95+
* @throws When the registration is invalid or its callback route is already owned.
96+
*/
97+
export function registerOAuthProvider(registration: OAuthProviderRegistration): () => void {
98+
if (!isValidRegistration(registration)) {
99+
throw new Error('Invalid OAuth provider registration');
100+
}
101+
const key = callbackKey(registration.callback.hostname, registration.callback.pathname);
102+
if (providersByCallback.has(key)) {
103+
throw new Error('OAuth callback is already registered');
104+
}
105+
providersByCallback.set(key, registration);
106+
107+
return () => {
108+
if (providersByCallback.get(key) === registration) {
109+
providersByCallback.delete(key);
110+
}
111+
};
112+
}
113+
114+
/**
115+
* Dispatches a product protocol URL to its registered OAuth provider.
116+
*
117+
* Provider failures are reported asynchronously and do not release ownership of
118+
* the callback URL to other protocol handlers.
119+
*
120+
* @param url Candidate OAuth callback URL.
121+
* @param protocolScheme Product protocol scheme accepted by this application.
122+
* @returns Whether a provider claimed the callback URL.
123+
*/
124+
export function dispatchOAuthCallback(url: URL, protocolScheme: string): boolean {
125+
if (url.protocol !== `${protocolScheme}:`) {
126+
return false;
127+
}
128+
const provider = providersByCallback.get(callbackKey(url.hostname, url.pathname));
129+
if (!provider) {
130+
return false;
131+
}
132+
133+
try {
134+
Promise.resolve(provider.handleCallback(url)).catch(error => {
135+
console.error(`OAuth callback failed for provider ${provider.id}:`, error);
136+
});
137+
} catch (error) {
138+
console.error(`OAuth callback failed for provider ${provider.id}:`, error);
139+
}
140+
return true;
141+
}

0 commit comments

Comments
 (0)