[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:
parent
722c4737fc
commit
86f3a679ae
|
@ -47,7 +47,6 @@ export namespace processisolations {
|
|||
export function isCoreDumpingDisabled(): Promise<boolean>
|
||||
export function disableMemoryAccess(): Promise<void>
|
||||
}
|
||||
|
||||
export namespace powermonitors {
|
||||
export function onLock(callback: (err: Error | null, ) => any): Promise<void>
|
||||
export function isLockMonitorAvailable(): Promise<boolean>
|
||||
|
|
|
@ -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": [
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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<void> => {
|
||||
return ipcRenderer.invoke("openSsoPrompt", { codeChallenge, state });
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
versions: {
|
||||
app: (): Promise<string> => 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 {
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
|
@ -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 &&
|
||||
|
|
Loading…
Reference in New Issue