From 13df63fbac4612f8f8b1a4a6011e4360fefb0957 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 19 Oct 2023 21:06:01 +0200 Subject: [PATCH] [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 --- .../src/importers/lastpass/access/index.ts | 1 + .../lastpass/access/models/client-info.ts | 6 ++ .../access/models/exported-account.ts | 23 +++++++ .../importers/lastpass/access/models/index.ts | 1 + .../access/models/user-type-context.ts | 34 ++++++---- .../lastpass/access/services/client.ts | 20 +++--- .../lastpass/access/services/rest-client.ts | 6 +- .../src/importers/lastpass/access/ui/ui.ts | 12 ++-- .../src/importers/lastpass/access/vault.ts | 62 ++++++++++++------- 9 files changed, 116 insertions(+), 49 deletions(-) create mode 100644 libs/importer/src/importers/lastpass/access/models/exported-account.ts diff --git a/libs/importer/src/importers/lastpass/access/index.ts b/libs/importer/src/importers/lastpass/access/index.ts index a124a44b31..1ec8fe0df1 100644 --- a/libs/importer/src/importers/lastpass/access/index.ts +++ b/libs/importer/src/importers/lastpass/access/index.ts @@ -1 +1,2 @@ +export { ClientInfo } from "./models"; export { Vault } from "./vault"; diff --git a/libs/importer/src/importers/lastpass/access/models/client-info.ts b/libs/importer/src/importers/lastpass/access/models/client-info.ts index 275cdc00d3..1f87512780 100644 --- a/libs/importer/src/importers/lastpass/access/models/client-info.ts +++ b/libs/importer/src/importers/lastpass/access/models/client-info.ts @@ -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" }; + } } diff --git a/libs/importer/src/importers/lastpass/access/models/exported-account.ts b/libs/importer/src/importers/lastpass/access/models/exported-account.ts new file mode 100644 index 0000000000..3c42bbffc0 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/models/exported-account.ts @@ -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; + } +} diff --git a/libs/importer/src/importers/lastpass/access/models/index.ts b/libs/importer/src/importers/lastpass/access/models/index.ts index a0c6121a35..9a3c5693ce 100644 --- a/libs/importer/src/importers/lastpass/access/models/index.ts +++ b/libs/importer/src/importers/lastpass/access/models/index.ts @@ -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"; diff --git a/libs/importer/src/importers/lastpass/access/models/user-type-context.ts b/libs/importer/src/importers/lastpass/access/models/user-type-context.ts index 9d849281c2..a4e3c8668e 100644 --- a/libs/importer/src/importers/lastpass/access/models/user-type-context.ts +++ b/libs/importer/src/importers/lastpass/access/models/user-type-context.ts @@ -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() !== ""; } diff --git a/libs/importer/src/importers/lastpass/access/services/client.ts b/libs/importer/src/importers/lastpass/access/services/client.ts index 2d8b503f01..b185ada888 100644 --- a/libs/importer/src/importers/lastpass/access/services/client.ts +++ b/libs/importer/src/importers/lastpass/access/services/client.ts @@ -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 { - 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, ui: Ui, rest: RestClient) { + private async approveOob( + username: string, + parameters: Map, + ui: Ui, + rest: RestClient + ): Promise { 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, ui: Ui, rest: RestClient - ): OobResult { + ): Promise { 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": diff --git a/libs/importer/src/importers/lastpass/access/services/rest-client.ts b/libs/importer/src/importers/lastpass/access/services/rest-client.ts index b26109d8e8..ce5fede33c 100644 --- a/libs/importer/src/importers/lastpass/access/services/rest-client.ts +++ b/libs/importer/src/importers/lastpass/access/services/rest-client.ts @@ -43,9 +43,6 @@ export class RestClient { ): Promise { const setBody = (requestInit: RequestInit, headerMap: Map) => { if (body != null) { - if (headerMap == null) { - headerMap = new Map(); - } 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(); + } setBody(requestInit, headers); this.setHeaders(requestInit, headers, cookies); const request = new Request(this.baseUrl + "/" + endpoint, requestInit); diff --git a/libs/importer/src/importers/lastpass/access/ui/ui.ts b/libs/importer/src/importers/lastpass/access/ui/ui.ts index 2338e8a291..b1640d325f 100644 --- a/libs/importer/src/importers/lastpass/access/ui/ui.ts +++ b/libs/importer/src/importers/lastpass/access/ui/ui.ts @@ -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; + provideMicrosoftAuthPasscode: () => Promise; + provideYubikeyPasscode: () => Promise; /* 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; + approveDuo: () => Promise; + approveSalesforceAuth: () => Promise; } diff --git a/libs/importer/src/importers/lastpass/access/vault.ts b/libs/importer/src/importers/lastpass/access/vault.ts index a461239eea..fc38fab871 100644 --- a/libs/importer/src/importers/lastpass/access/vault.ts +++ b/libs/importer/src/importers/lastpass/access/vault.ts @@ -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 { 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); } }