From 86f3a679aefdbd54eacab3b7b498fc0e1f5925bb Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Mon, 26 Aug 2024 15:13:45 +0200 Subject: [PATCH] [PM-4530] Fix sso in snap desktop (#10548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add localhost callback service for sso * Fix redirect behaviour * Update apps/desktop/src/app/app.component.ts Co-authored-by: Daniel García * Fix incorrect http response for sso callback * Add sso error * Update error message --------- Co-authored-by: Daniel García --- apps/desktop/desktop_native/napi/index.d.ts | 1 - apps/desktop/electron-builder.json | 2 +- apps/desktop/src/app/app.component.ts | 13 +- .../desktop/src/auth/login/login.component.ts | 37 +++++ apps/desktop/src/locales/en/messages.json | 13 +- apps/desktop/src/main.ts | 2 + apps/desktop/src/platform/preload.ts | 10 +- .../sso-localhost-callback.service.ts | 129 ++++++++++++++++++ .../src/auth/components/sso.component.ts | 5 + 9 files changed, 202 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/platform/services/sso-localhost-callback.service.ts diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index dc3cc7ec0b..deaf6b8e57 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -47,7 +47,6 @@ export namespace processisolations { export function isCoreDumpingDisabled(): Promise export function disableMemoryAccess(): Promise } - export namespace powermonitors { export function onLock(callback: (err: Error | null, ) => any): Promise export function isLockMonitorAvailable(): Promise diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index 15139a9929..aded8cc391 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -234,7 +234,7 @@ "autoStart": true, "base": "core22", "confinement": "strict", - "plugs": ["default", "password-manager-service"], + "plugs": ["default", "network", "network-bind", "password-manager-service"], "stagePackages": ["default"] }, "protocols": [ diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index a311ed2b86..089eb1c027 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -300,13 +300,19 @@ export class AppComponent implements OnInit, OnDestroy { this.systemService.clearClipboard(message.clipboardValue, message.clearMs); } 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. // eslint-disable-next-line @typescript-eslint/no-floating-promises this.router.navigate(["sso"], { - queryParams: { code: message.code, state: message.state }, + queryParams: queryParams, }); break; + } case "premiumRequired": { const premiumConfirmed = await this.dialogService.openSimpleDialog({ title: { key: "premiumRequired" }, @@ -455,6 +461,9 @@ export class AppComponent implements OnInit, OnDestroy { case "deepLink": this.processDeepLink(message.urlString); break; + case "launchUri": + this.platformUtilsService.launchUri(message.url); + break; } }); }); diff --git a/apps/desktop/src/auth/login/login.component.ts b/apps/desktop/src/auth/login/login.component.ts index c5ee9a0760..68b25b8b7e 100644 --- a/apps/desktop/src/auth/login/login.component.ts +++ b/apps/desktop/src/auth/login/login.component.ts @@ -23,6 +23,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.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 { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; @@ -187,4 +188,40 @@ export class LoginComponent extends BaseLoginComponent implements OnInit, OnDest const email = this.loggedEmail; 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"), + ); + } + } } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 84cfab039f..1bd3718550 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -738,10 +738,10 @@ "selfHostedBaseUrlHint": { "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." }, - "selfHostedEnvFormInvalid" :{ + "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, "customEnvironment": { @@ -1279,10 +1279,10 @@ } } }, - "errorRefreshingAccessToken":{ + "errorRefreshingAccessToken": { "message": "Access Token Refresh Error" }, - "errorRefreshingAccessTokenDesc":{ + "errorRefreshingAccessTokenDesc": { "message": "No refresh token or API keys found. Please try logging out and logging back in." }, "help": { @@ -1668,7 +1668,7 @@ "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" }, - "verificationRequired" : { + "verificationRequired": { "message": "Verification required", "description": "Default title for the user verification dialog." }, @@ -3052,5 +3052,8 @@ }, "textSends": { "message": "Text Sends" + }, + "ssoError": { + "message": "No free ports could be found for the sso login." } } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 816d317f41..b77cc72269 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -41,6 +41,7 @@ import { ElectronLogMainService } from "./platform/services/electron-log.main.se import { ElectronStorageService } from "./platform/services/electron-storage.service"; import { EphemeralValueStorageService } from "./platform/services/ephemeral-value-storage.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 { isMacAppStore } from "./utils"; @@ -227,6 +228,7 @@ export class Main { this.clipboardMain.init(); new EphemeralValueStorageService(); + new SSOLocalhostCallbackService(this.environmentService, this.messagingService); } bootstrap() { diff --git a/apps/desktop/src/platform/preload.ts b/apps/desktop/src/platform/preload.ts index 163ef9ae22..c1c56c5522 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -11,7 +11,7 @@ import { UnencryptedMessageResponse, } from "../models/native-messaging"; 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"; @@ -119,6 +119,12 @@ const ephemeralStore = { ipcRenderer.invoke("deleteEphemeralValue", key), }; +const localhostCallbackService = { + openSsoPrompt: (codeChallenge: string, state: string): Promise => { + return ipcRenderer.invoke("openSsoPrompt", { codeChallenge, state }); + }, +}; + export default { versions: { app: (): Promise => ipcRenderer.invoke("appVersion"), @@ -129,6 +135,7 @@ export default { isWindowsStore: isWindowsStore(), isFlatpak: isFlatpak(), isSnapStore: isSnapStore(), + isAppImage: isAppImage(), reloadProcess: () => ipcRenderer.send("reload-process"), log: (level: LogLevelType, message?: any, ...optionalParams: any[]) => ipcRenderer.invoke("ipc.log", { level, message, optionalParams }), @@ -179,6 +186,7 @@ export default { nativeMessaging, crypto, ephemeralStore, + localhostCallbackService, }; function deviceType(): DeviceType { diff --git a/apps/desktop/src/platform/services/sso-localhost-callback.service.ts b/apps/desktop/src/platform/services/sso-localhost-callback.service.ts new file mode 100644 index 0000000000..5efe73e2ad --- /dev/null +++ b/apps/desktop/src/platform/services/sso-localhost-callback.service.ts @@ -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( + "Success | Bitwarden Desktop" + + "

Successfully authenticated with the Bitwarden desktop app

" + + "

You may now close this tab and return to the app.

" + + "", + ); + callbackServer.close(() => + resolve({ + ssoCode: code, + orgIdentifier: orgIdentifier, + }), + ); + } else { + res.writeHead(400); + res.end( + "Failed | Bitwarden Desktop" + + "

Something went wrong logging into the Bitwarden desktop app

" + + "

You may now close this tab and return to the app.

" + + "", + ); + 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]; + } +} diff --git a/libs/angular/src/auth/components/sso.component.ts b/libs/angular/src/auth/components/sso.component.ts index aa2532afc8..cc105222c2 100644 --- a/libs/angular/src/auth/components/sso.component.ts +++ b/libs/angular/src/auth/components/sso.component.ts @@ -81,6 +81,11 @@ export class SsoComponent implements OnInit { const state = await this.ssoLoginService.getSsoState(); await this.ssoLoginService.setCodeVerifier(null); await this.ssoLoginService.setSsoState(null); + + if (qParams.redirectUri != null) { + this.redirectUri = qParams.redirectUri; + } + if ( qParams.code != null && codeVerifier != null &&