[PM-4530] Fix sso in snap desktop (#10548)

* Add localhost callback service for sso

* Fix redirect behaviour

* Update apps/desktop/src/app/app.component.ts

Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>

* Fix incorrect http response for sso callback

* Add sso error

* Update error message

---------

Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
This commit is contained in:
Bernd Schoolmann 2024-08-26 15:13:45 +02:00 committed by GitHub
parent 722c4737fc
commit 86f3a679ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 202 additions and 10 deletions

View File

@ -47,7 +47,6 @@ export namespace processisolations {
export function isCoreDumpingDisabled(): Promise<boolean> export function isCoreDumpingDisabled(): Promise<boolean>
export function disableMemoryAccess(): Promise<void> export function disableMemoryAccess(): Promise<void>
} }
export namespace powermonitors { export namespace powermonitors {
export function onLock(callback: (err: Error | null, ) => any): Promise<void> export function onLock(callback: (err: Error | null, ) => any): Promise<void>
export function isLockMonitorAvailable(): Promise<boolean> export function isLockMonitorAvailable(): Promise<boolean>

View File

@ -234,7 +234,7 @@
"autoStart": true, "autoStart": true,
"base": "core22", "base": "core22",
"confinement": "strict", "confinement": "strict",
"plugs": ["default", "password-manager-service"], "plugs": ["default", "network", "network-bind", "password-manager-service"],
"stagePackages": ["default"] "stagePackages": ["default"]
}, },
"protocols": [ "protocols": [

View File

@ -300,13 +300,19 @@ export class AppComponent implements OnInit, OnDestroy {
this.systemService.clearClipboard(message.clipboardValue, message.clearMs); this.systemService.clearClipboard(message.clipboardValue, message.clearMs);
} }
break; break;
case "ssoCallback": case "ssoCallback": {
const queryParams = {
code: message.code,
state: message.state,
redirectUri: message.redirectUri ?? "bitwarden://sso-callback",
};
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["sso"], { this.router.navigate(["sso"], {
queryParams: { code: message.code, state: message.state }, queryParams: queryParams,
}); });
break; break;
}
case "premiumRequired": { case "premiumRequired": {
const premiumConfirmed = await this.dialogService.openSimpleDialog({ const premiumConfirmed = await this.dialogService.openSimpleDialog({
title: { key: "premiumRequired" }, title: { key: "premiumRequired" },
@ -455,6 +461,9 @@ export class AppComponent implements OnInit, OnDestroy {
case "deepLink": case "deepLink":
this.processDeepLink(message.urlString); this.processDeepLink(message.urlString);
break; break;
case "launchUri":
this.platformUtilsService.launchUri(message.url);
break;
} }
}); });
}); });

View File

@ -23,6 +23,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
@ -187,4 +188,40 @@ export class LoginComponent extends BaseLoginComponent implements OnInit, OnDest
const email = this.loggedEmail; const email = this.loggedEmail;
document.getElementById(email == null || email === "" ? "email" : "masterPassword")?.focus(); document.getElementById(email == null || email === "" ? "email" : "masterPassword")?.focus();
} }
async launchSsoBrowser(clientId: string, ssoRedirectUri: string) {
if (!ipc.platform.isAppImage && !ipc.platform.isSnapStore && !ipc.platform.isDev) {
return super.launchSsoBrowser(clientId, ssoRedirectUri);
}
// Save off email for SSO
await this.ssoLoginService.setSsoEmail(this.formGroup.value.email);
// Generate necessary sso params
const passwordOptions: any = {
type: "password",
length: 64,
uppercase: true,
lowercase: true,
numbers: true,
special: false,
};
const state = await this.passwordGenerationService.generatePassword(passwordOptions);
const ssoCodeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
const codeVerifierHash = await this.cryptoFunctionService.hash(ssoCodeVerifier, "sha256");
const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
// Save sso params
await this.ssoLoginService.setSsoState(state);
await this.ssoLoginService.setCodeVerifier(ssoCodeVerifier);
try {
await ipc.platform.localhostCallbackService.openSsoPrompt(codeChallenge, state);
} catch (err) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccured"),
this.i18nService.t("ssoError"),
);
}
}
} }

View File

@ -738,10 +738,10 @@
"selfHostedBaseUrlHint": { "selfHostedBaseUrlHint": {
"message": "Specify the base URL of your on-premises hosted Bitwarden installation. Example: https://bitwarden.company.com" "message": "Specify the base URL of your on-premises hosted Bitwarden installation. Example: https://bitwarden.company.com"
}, },
"selfHostedCustomEnvHeader" :{ "selfHostedCustomEnvHeader": {
"message": "For advanced configuration, you can specify the base URL of each service independently." "message": "For advanced configuration, you can specify the base URL of each service independently."
}, },
"selfHostedEnvFormInvalid" :{ "selfHostedEnvFormInvalid": {
"message": "You must add either the base Server URL or at least one custom environment." "message": "You must add either the base Server URL or at least one custom environment."
}, },
"customEnvironment": { "customEnvironment": {
@ -1279,10 +1279,10 @@
} }
} }
}, },
"errorRefreshingAccessToken":{ "errorRefreshingAccessToken": {
"message": "Access Token Refresh Error" "message": "Access Token Refresh Error"
}, },
"errorRefreshingAccessTokenDesc":{ "errorRefreshingAccessTokenDesc": {
"message": "No refresh token or API keys found. Please try logging out and logging back in." "message": "No refresh token or API keys found. Please try logging out and logging back in."
}, },
"help": { "help": {
@ -1668,7 +1668,7 @@
"message": "Your organization requires you to set a master password.", "message": "Your organization requires you to set a master password.",
"description": "Used as a card title description on the set password page to explain why the user is there" "description": "Used as a card title description on the set password page to explain why the user is there"
}, },
"verificationRequired" : { "verificationRequired": {
"message": "Verification required", "message": "Verification required",
"description": "Default title for the user verification dialog." "description": "Default title for the user verification dialog."
}, },
@ -3052,5 +3052,8 @@
}, },
"textSends": { "textSends": {
"message": "Text Sends" "message": "Text Sends"
},
"ssoError": {
"message": "No free ports could be found for the sso login."
} }
} }

View File

@ -41,6 +41,7 @@ import { ElectronLogMainService } from "./platform/services/electron-log.main.se
import { ElectronStorageService } from "./platform/services/electron-storage.service"; import { ElectronStorageService } from "./platform/services/electron-storage.service";
import { EphemeralValueStorageService } from "./platform/services/ephemeral-value-storage.main.service"; import { EphemeralValueStorageService } from "./platform/services/ephemeral-value-storage.main.service";
import { I18nMainService } from "./platform/services/i18n.main.service"; import { I18nMainService } from "./platform/services/i18n.main.service";
import { SSOLocalhostCallbackService } from "./platform/services/sso-localhost-callback.service";
import { ElectronMainMessagingService } from "./services/electron-main-messaging.service"; import { ElectronMainMessagingService } from "./services/electron-main-messaging.service";
import { isMacAppStore } from "./utils"; import { isMacAppStore } from "./utils";
@ -227,6 +228,7 @@ export class Main {
this.clipboardMain.init(); this.clipboardMain.init();
new EphemeralValueStorageService(); new EphemeralValueStorageService();
new SSOLocalhostCallbackService(this.environmentService, this.messagingService);
} }
bootstrap() { bootstrap() {

View File

@ -11,7 +11,7 @@ import {
UnencryptedMessageResponse, UnencryptedMessageResponse,
} from "../models/native-messaging"; } from "../models/native-messaging";
import { BiometricMessage, BiometricAction } from "../types/biometric-message"; import { BiometricMessage, BiometricAction } from "../types/biometric-message";
import { isDev, isFlatpak, isMacAppStore, isSnapStore, isWindowsStore } from "../utils"; import { isAppImage, isDev, isFlatpak, isMacAppStore, isSnapStore, isWindowsStore } from "../utils";
import { ClipboardWriteMessage } from "./types/clipboard"; import { ClipboardWriteMessage } from "./types/clipboard";
@ -119,6 +119,12 @@ const ephemeralStore = {
ipcRenderer.invoke("deleteEphemeralValue", key), ipcRenderer.invoke("deleteEphemeralValue", key),
}; };
const localhostCallbackService = {
openSsoPrompt: (codeChallenge: string, state: string): Promise<void> => {
return ipcRenderer.invoke("openSsoPrompt", { codeChallenge, state });
},
};
export default { export default {
versions: { versions: {
app: (): Promise<string> => ipcRenderer.invoke("appVersion"), app: (): Promise<string> => ipcRenderer.invoke("appVersion"),
@ -129,6 +135,7 @@ export default {
isWindowsStore: isWindowsStore(), isWindowsStore: isWindowsStore(),
isFlatpak: isFlatpak(), isFlatpak: isFlatpak(),
isSnapStore: isSnapStore(), isSnapStore: isSnapStore(),
isAppImage: isAppImage(),
reloadProcess: () => ipcRenderer.send("reload-process"), reloadProcess: () => ipcRenderer.send("reload-process"),
log: (level: LogLevelType, message?: any, ...optionalParams: any[]) => log: (level: LogLevelType, message?: any, ...optionalParams: any[]) =>
ipcRenderer.invoke("ipc.log", { level, message, optionalParams }), ipcRenderer.invoke("ipc.log", { level, message, optionalParams }),
@ -179,6 +186,7 @@ export default {
nativeMessaging, nativeMessaging,
crypto, crypto,
ephemeralStore, ephemeralStore,
localhostCallbackService,
}; };
function deviceType(): DeviceType { function deviceType(): DeviceType {

View File

@ -0,0 +1,129 @@
import * as http from "http";
import { ipcMain } from "electron";
import { firstValueFrom } from "rxjs";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
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.
*/
export class SSOLocalhostCallbackService {
private ssoRedirectUri = "";
constructor(
private environmentService: EnvironmentService,
private messagingService: MessageSender,
) {
ipcMain.handle("openSsoPrompt", async (event, { codeChallenge, state }) => {
const { ssoCode } = await this.openSsoPrompt(codeChallenge, state);
this.messagingService.send("ssoCallback", {
code: ssoCode,
state: state,
redirectUri: this.ssoRedirectUri,
});
});
}
private async openSsoPrompt(
codeChallenge: string,
state: string,
): Promise<{ ssoCode: string; orgIdentifier: string }> {
const env = await firstValueFrom(this.environmentService.environment$);
return new Promise((resolve, reject) => {
const callbackServer = http.createServer((req, res) => {
// after 5 minutes, close the server
setTimeout(
() => {
callbackServer.close(() => reject());
},
5 * 60 * 1000,
);
const urlString = "http://localhost" + req.url;
const url = new URL(urlString);
const code = url.searchParams.get("code");
const receivedState = url.searchParams.get("state");
const orgIdentifier = this.getOrgIdentifierFromState(receivedState);
res.setHeader("Content-Type", "text/html");
if (code != null && receivedState != null && this.checkState(receivedState, state)) {
res.writeHead(200);
res.end(
"<html><head><title>Success | Bitwarden Desktop</title></head><body>" +
"<h1>Successfully authenticated with the Bitwarden desktop app</h1>" +
"<p>You may now close this tab and return to the app.</p>" +
"</body></html>",
);
callbackServer.close(() =>
resolve({
ssoCode: code,
orgIdentifier: orgIdentifier,
}),
);
} else {
res.writeHead(400);
res.end(
"<html><head><title>Failed | Bitwarden Desktop</title></head><body>" +
"<h1>Something went wrong logging into the Bitwarden desktop app</h1>" +
"<p>You may now close this tab and return to the app.</p>" +
"</body></html>",
);
callbackServer.close(() => reject());
}
});
let foundPort = false;
const webUrl = env.getWebVaultUrl();
for (let port = 8065; port <= 8070; port++) {
try {
this.ssoRedirectUri = "http://localhost:" + port;
callbackServer.listen(port, () => {
this.messagingService.send("launchUri", {
url:
webUrl +
"/#/sso?clientId=" +
"desktop" +
"&redirectUri=" +
encodeURIComponent(this.ssoRedirectUri) +
"&state=" +
state +
"&codeChallenge=" +
codeChallenge,
});
});
foundPort = true;
break;
} catch {
// Ignore error since we run the same command up to 5 times.
}
}
if (!foundPort) {
reject();
}
});
}
private getOrgIdentifierFromState(state: string): string {
if (state === null || state === undefined) {
return null;
}
const stateSplit = state.split("_identifier=");
return stateSplit.length > 1 ? stateSplit[1] : null;
}
private checkState(state: string, checkState: string): boolean {
if (state === null || state === undefined) {
return false;
}
if (checkState === null || checkState === undefined) {
return false;
}
const stateSplit = state.split("_identifier=");
const checkStateSplit = checkState.split("_identifier=");
return stateSplit[0] === checkStateSplit[0];
}
}

View File

@ -81,6 +81,11 @@ export class SsoComponent implements OnInit {
const state = await this.ssoLoginService.getSsoState(); const state = await this.ssoLoginService.getSsoState();
await this.ssoLoginService.setCodeVerifier(null); await this.ssoLoginService.setCodeVerifier(null);
await this.ssoLoginService.setSsoState(null); await this.ssoLoginService.setSsoState(null);
if (qParams.redirectUri != null) {
this.redirectUri = qParams.redirectUri;
}
if ( if (
qParams.code != null && qParams.code != null &&
codeVerifier != null && codeVerifier != null &&