1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

fix(sso-localhost-callback) [PM-18678]: Update http server error handling to be asynchronous and solve for EADDRINUSE uncaught exceptions. (#16585)

This commit is contained in:
Dave
2025-10-15 08:08:51 -04:00
committed by GitHub
parent 98af7a13ed
commit 8ff4fb1ed4

View File

@@ -12,10 +12,13 @@ import { MessageSender } from "@bitwarden/common/platform/messaging";
/**
* The SSO Localhost login service uses a local host listener as fallback in case scheme handling deeplinks does not work.
* This way it is possible to log in with SSO on appimage, snap, and electron dev using the same methods that the cli uses.
* This way it is possible to log in with SSO on appimage and electron dev using the same methods that the cli uses.
*/
export class SSOLocalhostCallbackService {
private ssoRedirectUri = "";
// We will only track one server at a time for use-case and performance considerations.
// This will result in a last-one-wins behavior if multiple SSO flows are started simultaneously.
private currentServer: http.Server | null = null;
constructor(
private environmentService: EnvironmentService,
@@ -23,11 +26,30 @@ export class SSOLocalhostCallbackService {
private ssoUrlService: SsoUrlService,
) {
ipcMain.handle("openSsoPrompt", async (event, { codeChallenge, state, email }) => {
const { ssoCode, recvState } = await this.openSsoPrompt(codeChallenge, state, email);
this.messagingService.send("ssoCallback", {
code: ssoCode,
state: recvState,
redirectUri: this.ssoRedirectUri,
// Close any existing server before starting new one
if (this.currentServer) {
await this.closeCurrentServer();
}
return this.openSsoPrompt(codeChallenge, state, email).then(({ ssoCode, recvState }) => {
this.messagingService.send("ssoCallback", {
code: ssoCode,
state: recvState,
redirectUri: this.ssoRedirectUri,
});
});
});
}
private async closeCurrentServer(): Promise<void> {
if (!this.currentServer) {
return;
}
return new Promise<void>((resolve) => {
this.currentServer!.close(() => {
this.currentServer = null;
resolve();
});
});
}
@@ -59,6 +81,7 @@ export class SSOLocalhostCallbackService {
"<p>You may now close this tab and return to the app.</p>" +
"</body></html>",
);
this.currentServer = null;
callbackServer.close(() =>
resolve({
ssoCode: code,
@@ -73,41 +96,68 @@ export class SSOLocalhostCallbackService {
"<p>You may now close this tab and return to the app.</p>" +
"</body></html>",
);
this.currentServer = null;
callbackServer.close(() => reject());
}
});
let foundPort = false;
const webUrl = env.getWebVaultUrl();
for (let port = 8065; port <= 8070; port++) {
try {
this.ssoRedirectUri = "http://localhost:" + port;
const ssoUrl = this.ssoUrlService.buildSsoUrl(
webUrl,
ClientType.Desktop,
this.ssoRedirectUri,
state,
codeChallenge,
email,
);
callbackServer.listen(port, () => {
this.messagingService.send("launchUri", {
url: ssoUrl,
});
});
foundPort = true;
break;
} catch {
// Ignore error since we run the same command up to 5 times.
}
}
if (!foundPort) {
reject();
}
// Store reference to current server
this.currentServer = callbackServer;
// after 5 minutes, close the server
const webUrl = env.getWebVaultUrl();
const tryNextPort = (port: number) => {
if (port > 8070) {
this.currentServer = null;
reject("All available SSO ports in use");
return;
}
this.ssoRedirectUri = "http://localhost:" + port;
const ssoUrl = this.ssoUrlService.buildSsoUrl(
webUrl,
ClientType.Desktop,
this.ssoRedirectUri,
state,
codeChallenge,
email,
);
// Set up error handler before attempting to listen
callbackServer.once("error", (err: any) => {
if (err.code === "EADDRINUSE") {
// Port is in use, try next port
tryNextPort(port + 1);
} else {
// Another error - reject and set the current server to null
// (one server alive at a time)
this.currentServer = null;
reject();
}
});
// Attempt to listen on the port
callbackServer.listen(port, () => {
// Success - remove error listener and launch SSO
callbackServer.removeAllListeners("error");
this.messagingService.send("launchUri", {
url: ssoUrl,
});
});
};
// Start trying from port 8065
tryNextPort(8065);
// Don't allow any server to stay up for more than 5 minutes;
// this gives plenty of time to complete SSO but ensures we don't
// have a server running indefinitely.
setTimeout(
() => {
if (this.currentServer === callbackServer) {
this.currentServer = null;
}
callbackServer.close(() => reject());
},
5 * 60 * 1000,