[PM-4195] Lastpass lib cleanup (#6636)

* Casing fixes from the original port of the code

* Add static createClientInfo and export

* Add way to transform retrieve accounts into csv format

Create ExportAccount model
accountsToExportedCsvString can transform and export csv

* Make calls needed for UI class async/awaitable

* Add helpers for SSO on the UserTypeContext

* Add additional error handling case

* Fixes for SSO login

---------

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
This commit is contained in:
Daniel James Smith 2023-10-19 21:06:01 +02:00 committed by GitHub
parent 790d666929
commit 13df63fbac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 116 additions and 49 deletions

View File

@ -1 +1,2 @@
export { ClientInfo } from "./models";
export { Vault } from "./vault";

View File

@ -1,7 +1,13 @@
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { Platform } from "../enums";
export class ClientInfo {
platform: Platform;
id: string;
description: string;
static createClientInfo(): ClientInfo {
return { platform: Platform.Desktop, id: Utils.newGuid(), description: "Importer" };
}
}

View File

@ -0,0 +1,23 @@
import { Account } from "./account";
export class ExportedAccount {
url: string;
username: string;
password: string;
totp: string;
extra: string;
name: string;
grouping: string;
fav: number;
constructor(account: Account) {
this.url = account.url;
this.username = account.username;
this.password = account.password;
this.totp = account.totp;
this.extra = account.notes;
this.name = account.name;
this.grouping = account.path === "(none)" ? null : account.path;
this.fav = account.isFavorite ? 1 : 0;
}
}

View File

@ -1,6 +1,7 @@
export { Account } from "./account";
export { Chunk } from "./chunk";
export { ClientInfo } from "./client-info";
export { ExportedAccount } from "./exported-account";
export { FederatedUserContext } from "./federated-user-context";
export { OobResult } from "./oob-result";
export { OtpResult } from "./otp-result";

View File

@ -2,24 +2,36 @@ import { IdpProvider, LastpassLoginType } from "../enums";
export class UserTypeContext {
type: LastpassLoginType;
IdentityProviderGUID: string;
IdentityProviderURL: string;
OpenIDConnectAuthority: string;
OpenIDConnectClientId: string;
CompanyId: number;
Provider: IdpProvider;
PkceEnabled: boolean;
IsPasswordlessEnabled: boolean;
identityProviderGUID: string;
identityProviderURL: string;
openIDConnectAuthority: string;
openIDConnectClientId: string;
companyId: number;
provider: IdpProvider;
pkceEnabled: boolean;
isPasswordlessEnabled: boolean;
isFederated(): boolean {
return (
this.type === LastpassLoginType.Federated &&
this.hasValue(this.IdentityProviderURL) &&
this.hasValue(this.OpenIDConnectAuthority) &&
this.hasValue(this.OpenIDConnectClientId)
this.hasValue(this.identityProviderURL) &&
this.hasValue(this.openIDConnectAuthority) &&
this.hasValue(this.openIDConnectClientId)
);
}
get oidcScope(): string {
let scope = "openid profile email";
if (this.provider === IdpProvider.PingOne) {
scope += " lastpass";
}
return scope;
}
get openIDConnectAuthorityBase(): string {
return this.openIDConnectAuthority.replace("/.well-known/openid-configuration", "");
}
private hasValue(str: string) {
return str != null && str.trim() !== "";
}

View File

@ -229,13 +229,13 @@ export class Client {
let passcode: OtpResult = null;
switch (method) {
case OtpMethod.GoogleAuth:
passcode = ui.provideGoogleAuthPasscode();
passcode = await ui.provideGoogleAuthPasscode();
break;
case OtpMethod.MicrosoftAuth:
passcode = ui.provideMicrosoftAuthPasscode();
passcode = await ui.provideMicrosoftAuthPasscode();
break;
case OtpMethod.Yubikey:
passcode = ui.provideYubikeyPasscode();
passcode = await ui.provideYubikeyPasscode();
break;
default:
throw new Error("Invalid OTP method");
@ -273,7 +273,7 @@ export class Client {
ui: Ui,
rest: RestClient
): Promise<Session> {
const answer = this.approveOob(username, parameters, ui, rest);
const answer = await this.approveOob(username, parameters, ui, rest);
if (answer == OobResult.cancel) {
throw new Error("Out of band step is canceled by the user");
}
@ -318,7 +318,12 @@ export class Client {
return session;
}
private approveOob(username: string, parameters: Map<string, string>, ui: Ui, rest: RestClient) {
private async approveOob(
username: string,
parameters: Map<string, string>,
ui: Ui,
rest: RestClient
): Promise<OobResult> {
const method = parameters.get("outofbandtype");
if (method == null) {
throw new Error("Out of band method is not specified");
@ -335,12 +340,12 @@ export class Client {
}
}
private approveDuo(
private async approveDuo(
username: string,
parameters: Map<string, string>,
ui: Ui,
rest: RestClient
): OobResult {
): Promise<OobResult> {
return parameters.get("preferduowebsdk") == "1"
? this.approveDuoWebSdk(username, parameters, ui, rest)
: ui.approveDuo();
@ -525,6 +530,7 @@ export class Client {
switch (cause.value) {
case "unknownemail":
return "Invalid username";
case "password_invalid":
case "unknownpassword":
return "Invalid password";
case "googleauthfailed":

View File

@ -43,9 +43,6 @@ export class RestClient {
): Promise<Response> {
const setBody = (requestInit: RequestInit, headerMap: Map<string, string>) => {
if (body != null) {
if (headerMap == null) {
headerMap = new Map<string, string>();
}
headerMap.set("Content-Type", "application/json; charset=utf-8");
requestInit.body = JSON.stringify(body);
}
@ -63,6 +60,9 @@ export class RestClient {
method: "POST",
credentials: "include",
};
if (headers == null) {
headers = new Map<string, string>();
}
setBody(requestInit, headers);
this.setHeaders(requestInit, headers, cookies);
const request = new Request(this.baseUrl + "/" + endpoint, requestInit);

View File

@ -4,9 +4,9 @@ import { DuoUi } from "./duo-ui";
export abstract class Ui extends DuoUi {
// To cancel return OtpResult.Cancel, otherwise only valid data is expected.
provideGoogleAuthPasscode: () => OtpResult;
provideMicrosoftAuthPasscode: () => OtpResult;
provideYubikeyPasscode: () => OtpResult;
provideGoogleAuthPasscode: () => Promise<OtpResult>;
provideMicrosoftAuthPasscode: () => Promise<OtpResult>;
provideYubikeyPasscode: () => Promise<OtpResult>;
/*
The UI implementations should provide the following possibilities for the user:
@ -23,7 +23,7 @@ export abstract class Ui extends DuoUi {
passcode instead of performing an action in the app. In this case the UI should return
OobResult.continueWithPasscode(passcode, rememberMe).
*/
approveLastPassAuth: () => OobResult;
approveDuo: () => OobResult;
approveSalesforceAuth: () => OobResult;
approveLastPassAuth: () => Promise<OobResult>;
approveDuo: () => Promise<OobResult>;
approveSalesforceAuth: () => Promise<OobResult>;
}

View File

@ -1,3 +1,5 @@
import * as papa from "papaparse";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { HttpStatusCode } from "@bitwarden/common/enums";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
@ -7,6 +9,7 @@ import { IdpProvider } from "./enums";
import {
Account,
ClientInfo,
ExportedAccount,
FederatedUserContext,
ParserOptions,
UserTypeContext,
@ -68,20 +71,35 @@ export class Vault {
if (response.status === HttpStatusCode.Ok) {
const json = await response.json();
this.userType = new UserTypeContext();
this.userType.CompanyId = json.CompanyId;
this.userType.IdentityProviderGUID = json.IdentityProviderGUID;
this.userType.IdentityProviderURL = json.IdentityProviderURL;
this.userType.IsPasswordlessEnabled = json.IsPasswordlessEnabled;
this.userType.OpenIDConnectAuthority = json.OpenIDConnectAuthority;
this.userType.OpenIDConnectClientId = json.OpenIDConnectClientId;
this.userType.PkceEnabled = json.PkceEnabled;
this.userType.Provider = json.Provider;
this.userType.companyId = json.CompanyId;
this.userType.identityProviderGUID = json.IdentityProviderGUID;
this.userType.identityProviderURL = json.IdentityProviderURL;
this.userType.isPasswordlessEnabled = json.IsPasswordlessEnabled;
this.userType.openIDConnectAuthority = json.OpenIDConnectAuthority;
this.userType.openIDConnectClientId = json.OpenIDConnectClientId;
this.userType.pkceEnabled = json.PkceEnabled;
this.userType.provider = json.Provider;
this.userType.type = json.type;
return;
}
throw new Error("Cannot determine LastPass user type.");
}
accountsToExportedCsvString(skipShared = false): string {
if (this.accounts == null) {
throw new Error("Vault has not opened any accounts.");
}
const exportedAccounts = this.accounts
.filter((a) => !a.isShared || (a.isShared && !skipShared))
.map((a) => new ExportedAccount(a));
if (exportedAccounts.length === 0) {
throw new Error("No accounts to transform");
}
return papa.unparse(exportedAccounts);
}
private async getK1(federatedUser: FederatedUserContext): Promise<Uint8Array> {
if (this.userType == null) {
throw new Error("User type is not set.");
@ -96,18 +114,18 @@ export class Vault {
}
let k1: Uint8Array = null;
if (federatedUser.idpUserInfo?.LastPassK1 !== null) {
if (federatedUser.idpUserInfo?.LastPassK1 != null) {
return Utils.fromByteStringToArray(federatedUser.idpUserInfo.LastPassK1);
} else if (this.userType.Provider === IdpProvider.Azure) {
} else if (this.userType.provider === IdpProvider.Azure) {
k1 = await this.getK1Azure(federatedUser);
} else if (this.userType.Provider === IdpProvider.Google) {
} else if (this.userType.provider === IdpProvider.Google) {
k1 = await this.getK1Google(federatedUser);
} else {
const b64Encoded = this.userType.Provider === IdpProvider.PingOne;
k1 = this.getK1FromAccessToken(federatedUser, b64Encoded);
const b64Encoded = this.userType.provider === IdpProvider.PingOne;
k1 = await this.getK1FromAccessToken(federatedUser, b64Encoded);
}
if (k1 !== null) {
if (k1 != null) {
return k1;
}
@ -125,7 +143,7 @@ export class Vault {
if (response.status === HttpStatusCode.Ok) {
const json = await response.json();
const k1 = json?.extensions?.LastPassK1 as string;
if (k1 !== null) {
if (k1 != null) {
return Utils.fromB64ToArray(k1);
}
}
@ -149,7 +167,7 @@ export class Vault {
if (response.status === HttpStatusCode.Ok) {
const json = await response.json();
const files = json?.files as any[];
if (files !== null && files.length > 0 && files[0].id != null && files[0].name === "k1.lp") {
if (files != null && files.length > 0 && files[0].id != null && files[0].name === "k1.lp") {
// Open the k1.lp file
rest.baseUrl = "https://www.googleapis.com";
const response = await rest.get(
@ -165,10 +183,10 @@ export class Vault {
return null;
}
private getK1FromAccessToken(federatedUser: FederatedUserContext, b64: boolean) {
const decodedAccessToken = this.tokenService.decodeToken(federatedUser.accessToken);
private async getK1FromAccessToken(federatedUser: FederatedUserContext, b64: boolean) {
const decodedAccessToken = await this.tokenService.decodeToken(federatedUser.accessToken);
const k1 = decodedAccessToken?.LastPassK1 as string;
if (k1 !== null) {
if (k1 != null) {
return b64 ? Utils.fromB64ToArray(k1) : Utils.fromByteStringToArray(k1);
}
return null;
@ -184,15 +202,15 @@ export class Vault {
}
const rest = new RestClient();
rest.baseUrl = this.userType.IdentityProviderURL;
rest.baseUrl = this.userType.identityProviderURL;
const response = await rest.postJson("federatedlogin/api/v1/getkey", {
company_id: this.userType.CompanyId,
company_id: this.userType.companyId,
id_token: federatedUser.idToken,
});
if (response.status === HttpStatusCode.Ok) {
const json = await response.json();
const k2 = json?.k2 as string;
if (k2 !== null) {
if (k2 != null) {
return Utils.fromB64ToArray(k2);
}
}