From cc65f5efc6a417acea6e6349e3e3d97783f20139 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Wed, 2 Jul 2025 07:23:45 -0700 Subject: [PATCH] feat(set-initial-password): [Auth/PM-18784] SetInitialPasswordComponent Handle TDE Offboarding (#14861) This PR makes it so that `SetInitialPasswordComponent` handles the TDE offboarding flow where an org user now needs to set an initial master password. Feature flag: `PM16117_SetInitialPasswordRefactor` --- ...initial-password.service.implementation.ts | 42 +++++ ...fault-set-initial-password.service.spec.ts | 132 ++++++++++++++-- .../set-initial-password.component.html | 7 +- .../set-initial-password.component.ts | 148 ++++++++++++------ ...et-initial-password.service.abstraction.ts | 25 +++ .../src/auth/utils/assert-non-nullish.util.ts | 45 ++++++ .../src/auth/utils/assert-truthy.util.ts | 46 ++++++ libs/common/src/auth/utils/index.ts | 2 + 8 files changed, 391 insertions(+), 56 deletions(-) create mode 100644 libs/common/src/auth/utils/assert-non-nullish.util.ts create mode 100644 libs/common/src/auth/utils/assert-truthy.util.ts create mode 100644 libs/common/src/auth/utils/index.ts diff --git a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts index 1c5edb00c8c..dd81f560939 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts @@ -14,6 +14,7 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request"; +import { UpdateTdeOffboardingPasswordRequest } from "@bitwarden/common/auth/models/request/update-tde-offboarding-password.request"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; @@ -28,6 +29,7 @@ import { SetInitialPasswordService, SetInitialPasswordCredentials, SetInitialPasswordUserType, + SetInitialPasswordTdeOffboardingCredentials, } from "./set-initial-password.service.abstraction"; export class DefaultSetInitialPasswordService implements SetInitialPasswordService { @@ -245,4 +247,44 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi enrollmentRequest, ); } + + async setInitialPasswordTdeOffboarding( + credentials: SetInitialPasswordTdeOffboardingCredentials, + userId: UserId, + ) { + const { newMasterKey, newServerMasterKeyHash, newPasswordHint } = credentials; + for (const [key, value] of Object.entries(credentials)) { + if (value == null) { + throw new Error(`${key} not found. Could not set password.`); + } + } + + if (userId == null) { + throw new Error("userId not found. Could not set password."); + } + + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + if (userKey == null) { + throw new Error("userKey not found. Could not set password."); + } + + const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey( + newMasterKey, + userKey, + ); + + if (!newMasterKeyEncryptedUserKey[1].encryptedString) { + throw new Error("newMasterKeyEncryptedUserKey not found. Could not set password."); + } + + const request = new UpdateTdeOffboardingPasswordRequest(); + request.key = newMasterKeyEncryptedUserKey[1].encryptedString; + request.newMasterPasswordHash = newServerMasterKeyHash; + request.masterPasswordHint = newPasswordHint; + + await this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request); + + // Clear force set password reason to allow navigation back to vault. + await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId); + } } diff --git a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts index ca4d9adbd67..979dc5ee82f 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts @@ -19,6 +19,7 @@ import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request"; +import { UpdateTdeOffboardingPasswordRequest } from "@bitwarden/common/auth/models/request/update-tde-offboarding-password.request"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; @@ -35,6 +36,7 @@ import { DefaultSetInitialPasswordService } from "./default-set-initial-password import { SetInitialPasswordCredentials, SetInitialPasswordService, + SetInitialPasswordTdeOffboardingCredentials, SetInitialPasswordUserType, } from "./set-initial-password.service.abstraction"; @@ -52,6 +54,11 @@ describe("DefaultSetInitialPasswordService", () => { let organizationUserApiService: MockProxy; let userDecryptionOptionsService: MockProxy; + let userId: UserId; + let userKey: UserKey; + let userKeyEncString: EncString; + let masterKeyEncryptedUserKey: [UserKey, EncString]; + beforeEach(() => { apiService = mock(); encryptService = mock(); @@ -64,6 +71,11 @@ describe("DefaultSetInitialPasswordService", () => { organizationUserApiService = mock(); userDecryptionOptionsService = mock(); + userId = "userId" as UserId; + userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; + userKeyEncString = new EncString("masterKeyEncryptedUserKey"); + masterKeyEncryptedUserKey = [userKey, userKeyEncString]; + sut = new DefaultSetInitialPasswordService( apiService, encryptService, @@ -86,13 +98,8 @@ describe("DefaultSetInitialPasswordService", () => { // Mock function parameters let credentials: SetInitialPasswordCredentials; let userType: SetInitialPasswordUserType; - let userId: UserId; // Mock other function data - let userKey: UserKey; - let userKeyEncString: EncString; - let masterKeyEncryptedUserKey: [UserKey, EncString]; - let existingUserPublicKey: UserPublicKey; let existingUserPrivateKey: UserPrivateKey; let userKeyEncryptedPrivateKey: EncString; @@ -121,14 +128,9 @@ describe("DefaultSetInitialPasswordService", () => { orgId: "orgId", resetPasswordAutoEnroll: false, }; - userId = "userId" as UserId; userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER; // Mock other function data - userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; - userKeyEncString = new EncString("masterKeyEncryptedUserKey"); - masterKeyEncryptedUserKey = [userKey, userKeyEncString]; - existingUserPublicKey = Utils.fromB64ToArray("existingUserPublicKey") as UserPublicKey; existingUserPrivateKey = Utils.fromB64ToArray("existingUserPrivateKey") as UserPrivateKey; userKeyEncryptedPrivateKey = new EncString("userKeyEncryptedPrivateKey"); @@ -630,4 +632,114 @@ describe("DefaultSetInitialPasswordService", () => { }); }); }); + + describe("setInitialPasswordTdeOffboarding(...)", () => { + // Mock function parameters + let credentials: SetInitialPasswordTdeOffboardingCredentials; + + beforeEach(() => { + // Mock function parameters + credentials = { + newMasterKey: new SymmetricCryptoKey(new Uint8Array(32).buffer as CsprngArray) as MasterKey, + newServerMasterKeyHash: "newServerMasterKeyHash", + newPasswordHint: "newPasswordHint", + }; + }); + + function setupTdeOffboardingMocks() { + keyService.userKey$.mockReturnValue(of(userKey)); + keyService.encryptUserKeyWithMasterKey.mockResolvedValue(masterKeyEncryptedUserKey); + } + + it("should successfully set an initial password for the TDE offboarding user", async () => { + // Arrange + setupTdeOffboardingMocks(); + + const request = new UpdateTdeOffboardingPasswordRequest(); + request.key = masterKeyEncryptedUserKey[1].encryptedString; + request.newMasterPasswordHash = credentials.newServerMasterKeyHash; + request.masterPasswordHint = credentials.newPasswordHint; + + // Act + await sut.setInitialPasswordTdeOffboarding(credentials, userId); + + // Assert + expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledTimes(1); + expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledWith( + request, + ); + }); + + describe("given the initial password has been successfully set", () => { + it("should clear the ForceSetPasswordReason by setting it to None", async () => { + // Arrange + setupTdeOffboardingMocks(); + + // Act + await sut.setInitialPasswordTdeOffboarding(credentials, userId); + + // Assert + expect(masterPasswordApiService.putUpdateTdeOffboardingPassword).toHaveBeenCalledTimes(1); + expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith( + ForceSetPasswordReason.None, + userId, + ); + }); + }); + + describe("general error handling", () => { + ["newMasterKey", "newServerMasterKeyHash", "newPasswordHint"].forEach((key) => { + it(`should throw if ${key} is not provided on the SetInitialPasswordTdeOffboardingCredentials object`, async () => { + // Arrange + const invalidCredentials: SetInitialPasswordTdeOffboardingCredentials = { + ...credentials, + [key]: null, + }; + + // Act + const promise = sut.setInitialPasswordTdeOffboarding(invalidCredentials, userId); + + // Assert + await expect(promise).rejects.toThrow(`${key} not found. Could not set password.`); + }); + }); + + it(`should throw if the userId was not passed in`, async () => { + // Arrange + userId = null; + + // Act + const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId); + + // Assert + await expect(promise).rejects.toThrow("userId not found. Could not set password."); + }); + + it(`should throw if the userKey was not found`, async () => { + // Arrange + keyService.userKey$.mockReturnValue(of(null)); + + // Act + const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId); + + // Assert + await expect(promise).rejects.toThrow("userKey not found. Could not set password."); + }); + + it(`should throw if a newMasterKeyEncryptedUserKey was not returned`, async () => { + // Arrange + masterKeyEncryptedUserKey[1].encryptedString = "" as EncryptedString; + + setupTdeOffboardingMocks(); + + // Act + const promise = sut.setInitialPasswordTdeOffboarding(credentials, userId); + + // Assert + await expect(promise).rejects.toThrow( + "newMasterKeyEncryptedUserKey not found. Could not set password.", + ); + }); + }); + }); }); diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.html b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.html index c83cbbe3521..4956f293d1e 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.html +++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.html @@ -21,7 +21,12 @@ [userId]="userId" [loading]="submitting" [masterPasswordPolicyOptions]="masterPasswordPolicyOptions" - [primaryButtonText]="{ key: 'createAccount' }" + [primaryButtonText]="{ + key: + userType === SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER + ? 'setPassword' + : 'createAccount', + }" [secondaryButtonText]="{ key: 'logOut' }" (onPasswordFormSubmit)="handlePasswordFormSubmit($event)" (onSecondaryButtonClick)="logout()" diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts index fbab9eaa2c3..2de9aaf7b75 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts @@ -10,14 +10,20 @@ import { InputPasswordFlow, PasswordInputResult, } from "@bitwarden/auth/angular"; +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { LogoutService } from "@bitwarden/auth/common"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { assertTruthy, assertNonNullish } from "@bitwarden/common/auth/utils"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { SyncService } from "@bitwarden/common/platform/sync"; @@ -33,6 +39,7 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { SetInitialPasswordCredentials, SetInitialPasswordService, + SetInitialPasswordTdeOffboardingCredentials, SetInitialPasswordUserType, } from "./set-initial-password.service.abstraction"; @@ -54,6 +61,7 @@ export class SetInitialPasswordComponent implements OnInit { protected submitting = false; protected userId?: UserId; protected userType?: SetInitialPasswordUserType; + protected SetInitialPasswordUserType = SetInitialPasswordUserType; constructor( private accountService: AccountService, @@ -61,10 +69,13 @@ export class SetInitialPasswordComponent implements OnInit { private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, private dialogService: DialogService, private i18nService: I18nService, + private logoutService: LogoutService, + private logService: LogService, private masterPasswordService: InternalMasterPasswordServiceAbstraction, private messagingService: MessagingService, private organizationApiService: OrganizationApiServiceAbstraction, private policyApiService: PolicyApiServiceAbstraction, + private policyService: PolicyService, private router: Router, private setInitialPasswordService: SetInitialPasswordService, private ssoLoginService: SsoLoginServiceAbstraction, @@ -80,13 +91,13 @@ export class SetInitialPasswordComponent implements OnInit { this.userId = activeAccount?.id; this.email = activeAccount?.email; - await this.determineUserType(); - await this.handleQueryParams(); + await this.establishUserType(); + await this.getOrgInfo(); this.initializing = false; } - private async determineUserType() { + private async establishUserType() { if (!this.userId) { throw new Error("userId not found. Could not determine user type."); } @@ -95,6 +106,14 @@ export class SetInitialPasswordComponent implements OnInit { this.masterPasswordService.forceSetPasswordReason$(this.userId), ); + if (this.forceSetPasswordReason === ForceSetPasswordReason.SsoNewJitProvisionedUser) { + this.userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER; + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageTitle: { key: "joinOrganization" }, + pageSubtitle: { key: "finishJoiningThisOrganizationBySettingAMasterPassword" }, + }); + } + if ( this.forceSetPasswordReason === ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission @@ -104,20 +123,35 @@ export class SetInitialPasswordComponent implements OnInit { pageTitle: { key: "setMasterPassword" }, pageSubtitle: { key: "orgPermissionsUpdatedMustSetPassword" }, }); - } else { - this.userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER; + } + + if (this.forceSetPasswordReason === ForceSetPasswordReason.TdeOffboarding) { + this.userType = SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER; this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ - pageTitle: { key: "joinOrganization" }, - pageSubtitle: { key: "finishJoiningThisOrganizationBySettingAMasterPassword" }, + pageTitle: { key: "setMasterPassword" }, + pageSubtitle: { key: "tdeDisabledMasterPasswordRequired" }, }); } + + // If we somehow end up here without a reason, navigate to root + if (this.forceSetPasswordReason === ForceSetPasswordReason.None) { + await this.router.navigate(["/"]); + } } - private async handleQueryParams() { + private async getOrgInfo() { if (!this.userId) { throw new Error("userId not found. Could not handle query params."); } + if (this.userType === SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER) { + this.masterPasswordPolicyOptions = + (await firstValueFrom(this.policyService.masterPasswordPolicyOptions$(this.userId))) ?? + null; + + return; + } + const qParams = await firstValueFrom(this.activatedRoute.queryParams); this.orgSsoIdentifier = @@ -146,38 +180,34 @@ export class SetInitialPasswordComponent implements OnInit { protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) { this.submitting = true; - if (!passwordInputResult.newMasterKey) { - throw new Error("newMasterKey not found. Could not set initial password."); - } - if (!passwordInputResult.newServerMasterKeyHash) { - throw new Error("newServerMasterKeyHash not found. Could not set initial password."); - } - if (!passwordInputResult.newLocalMasterKeyHash) { - throw new Error("newLocalMasterKeyHash not found. Could not set initial password."); - } - // newPasswordHint can have an empty string as a valid value, so we specifically check for null or undefined - if (passwordInputResult.newPasswordHint == null) { - throw new Error("newPasswordHint not found. Could not set initial password."); - } - if (!passwordInputResult.kdfConfig) { - throw new Error("kdfConfig not found. Could not set initial password."); - } - if (!this.userId) { - throw new Error("userId not found. Could not set initial password."); - } - if (!this.userType) { - throw new Error("userType not found. Could not set initial password."); - } - if (!this.orgSsoIdentifier) { - throw new Error("orgSsoIdentifier not found. Could not set initial password."); - } - if (!this.orgId) { - throw new Error("orgId not found. Could not set initial password."); - } - // resetPasswordAutoEnroll can have `false` as a valid value, so we specifically check for null or undefined - if (this.resetPasswordAutoEnroll == null) { - throw new Error("resetPasswordAutoEnroll not found. Could not set initial password."); + switch (this.userType) { + case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER: + case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP: + await this.setInitialPassword(passwordInputResult); + break; + case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER: + await this.setInitialPasswordTdeOffboarding(passwordInputResult); + break; + default: + this.logService.error( + `Unexpected user type: ${this.userType}. Could not set initial password.`, + ); + this.validationService.showError("Unexpected user type. Could not set initial password."); } + } + + private async setInitialPassword(passwordInputResult: PasswordInputResult) { + const ctx = "Could not set initial password."; + assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx); + assertTruthy(passwordInputResult.newServerMasterKeyHash, "newServerMasterKeyHash", ctx); + assertTruthy(passwordInputResult.newLocalMasterKeyHash, "newLocalMasterKeyHash", ctx); + assertTruthy(passwordInputResult.kdfConfig, "kdfConfig", ctx); + assertTruthy(this.orgSsoIdentifier, "orgSsoIdentifier", ctx); + assertTruthy(this.orgId, "orgId", ctx); + assertTruthy(this.userType, "userType", ctx); + assertTruthy(this.userId, "userId", ctx); + assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish + assertNonNullish(this.resetPasswordAutoEnroll, "resetPasswordAutoEnroll", ctx); // can have `false` as a valid value, so check non-nullish try { const credentials: SetInitialPasswordCredentials = { @@ -202,11 +232,44 @@ export class SetInitialPasswordComponent implements OnInit { this.submitting = false; await this.router.navigate(["vault"]); } catch (e) { + this.logService.error("Error setting initial password", e); this.validationService.showError(e); this.submitting = false; } } + private async setInitialPasswordTdeOffboarding(passwordInputResult: PasswordInputResult) { + const ctx = "Could not set initial password."; + assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx); + assertTruthy(passwordInputResult.newServerMasterKeyHash, "newServerMasterKeyHash", ctx); + assertTruthy(this.userId, "userId", ctx); + assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish + + try { + const credentials: SetInitialPasswordTdeOffboardingCredentials = { + newMasterKey: passwordInputResult.newMasterKey, + newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash, + newPasswordHint: passwordInputResult.newPasswordHint, + }; + + await this.setInitialPasswordService.setInitialPasswordTdeOffboarding( + credentials, + this.userId, + ); + + this.showSuccessToastByUserType(); + + await this.logoutService.logout(this.userId); + // navigate to root so redirect guard can properly route next active user or null user to correct page + await this.router.navigate(["/"]); + } catch (e) { + this.logService.error("Error setting initial password during TDE offboarding", e); + this.validationService.showError(e); + } finally { + this.submitting = false; + } + } + private showSuccessToastByUserType() { if (this.userType === SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER) { this.toastService.showToast({ @@ -220,12 +283,7 @@ export class SetInitialPasswordComponent implements OnInit { title: "", message: this.i18nService.t("inviteAccepted"), }); - } - - if ( - this.userType === - SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP - ) { + } else { this.toastService.showToast({ variant: "success", title: "", diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts index e594053a906..c167c1675c1 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts @@ -19,6 +19,12 @@ export const _SetInitialPasswordUserType = { */ TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP: "tde_org_user_reset_password_permission_requires_mp", + + /** + * A user in an org that offboarded from trusted device encryption and is now a + * master-password-encryption org + */ + OFFBOARDED_TDE_ORG_USER: "offboarded_tde_org_user", } as const; type _SetInitialPasswordUserType = typeof _SetInitialPasswordUserType; @@ -40,6 +46,12 @@ export interface SetInitialPasswordCredentials { resetPasswordAutoEnroll: boolean; } +export interface SetInitialPasswordTdeOffboardingCredentials { + newMasterKey: MasterKey; + newServerMasterKeyHash: string; + newPasswordHint: string; +} + /** * Handles setting an initial password for an existing authed user. * @@ -61,4 +73,17 @@ export abstract class SetInitialPasswordService { userType: SetInitialPasswordUserType, userId: UserId, ) => Promise; + + /** + * Sets an initial password for a user who logs in after their org offboarded from + * trusted device encryption and is now a master-password-encryption org: + * - {@link SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER} + * + * @param passwordInputResult credentials object received from the `InputPasswordComponent` + * @param userId the account `userId` + */ + abstract setInitialPasswordTdeOffboarding: ( + credentials: SetInitialPasswordTdeOffboardingCredentials, + userId: UserId, + ) => Promise; } diff --git a/libs/common/src/auth/utils/assert-non-nullish.util.ts b/libs/common/src/auth/utils/assert-non-nullish.util.ts new file mode 100644 index 00000000000..91fb8ef44b8 --- /dev/null +++ b/libs/common/src/auth/utils/assert-non-nullish.util.ts @@ -0,0 +1,45 @@ +/** + * Asserts that a value is non-nullish (not `null` or `undefined`); throws if value is nullish. + * + * @param val the value to check + * @param name the name of the value to include in the error message + * @param ctx context to optionally append to the error message + * @throws if the value is null or undefined + * + * @example + * + * ``` + * // `newPasswordHint` can have an empty string as a valid value, so we check non-nullish + * this.assertNonNullish( + * passwordInputResult.newPasswordHint, + * "newPasswordHint", + * "Could not set initial password." + * ); + * // Output error message: "newPasswordHint is null or undefined. Could not set initial password." + * ``` + * + * @remarks + * + * If you use this method repeatedly to check several values, it may help to assign any + * additional context (`ctx`) to a variable and pass it in to each call. This prevents the + * call from reformatting vertically via prettier in your text editor, taking up multiple lines. + * + * For example: + * ``` + * const ctx = "Could not set initial password."; + * + * this.assertNonNullish(valueOne, "valueOne", ctx); + * this.assertNonNullish(valueTwo, "valueTwo", ctx); + * this.assertNonNullish(valueThree, "valueThree", ctx); + * ``` + */ +export function assertNonNullish( + val: T, + name: string, + ctx?: string, +): asserts val is NonNullable { + if (val == null) { + // If context is provided, append it to the error message with a space before it. + throw new Error(`${name} is null or undefined.${ctx ? ` ${ctx}` : ""}`); + } +} diff --git a/libs/common/src/auth/utils/assert-truthy.util.ts b/libs/common/src/auth/utils/assert-truthy.util.ts new file mode 100644 index 00000000000..8e003186929 --- /dev/null +++ b/libs/common/src/auth/utils/assert-truthy.util.ts @@ -0,0 +1,46 @@ +/** + * Asserts that a value is truthy; throws if value is falsy. + * + * @param val the value to check + * @param name the name of the value to include in the error message + * @param ctx context to optionally append to the error message + * @throws if the value is falsy (`false`, `""`, `0`, `null`, `undefined`, `void`, or `NaN`) + * + * @example + * + * ``` + * this.assertTruthy( + * this.organizationId, + * "organizationId", + * "Could not set initial password." + * ); + * // Output error message: "organizationId is falsy. Could not set initial password." + * ``` + * + * @remarks + * + * If you use this method repeatedly to check several values, it may help to assign any + * additional context (`ctx`) to a variable and pass it in to each call. This prevents the + * call from reformatting vertically via prettier in your text editor, taking up multiple lines. + * + * For example: + * ``` + * const ctx = "Could not set initial password."; + * + * this.assertTruthy(valueOne, "valueOne", ctx); + * this.assertTruthy(valueTwo, "valueTwo", ctx); + * this.assertTruthy(valueThree, "valueThree", ctx); + */ +export function assertTruthy( + val: T, + name: string, + ctx?: string, +): asserts val is Exclude { + // Because `NaN` is a value (not a type) of type 'number', that means we cannot add + // it to the list of falsy values in the type assertion. Instead, we check for it + // separately at runtime. + if (!val || (typeof val === "number" && Number.isNaN(val))) { + // If context is provided, append it to the error message with a space before it. + throw new Error(`${name} is falsy.${ctx ? ` ${ctx}` : ""}`); + } +} diff --git a/libs/common/src/auth/utils/index.ts b/libs/common/src/auth/utils/index.ts new file mode 100644 index 00000000000..96bab53d3f9 --- /dev/null +++ b/libs/common/src/auth/utils/index.ts @@ -0,0 +1,2 @@ +export { assertTruthy } from "./assert-truthy.util"; +export { assertNonNullish } from "./assert-non-nullish.util";