Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions apps/cli/src/auth/commands/login.command.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";

import {
LoginStrategyServiceAbstraction,
SsoUrlService,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { TwoFactorApiService, TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { UserId } from "@bitwarden/user-core";

import { LoginCommand } from "./login.command";

describe("LoginCommand", () => {
let command: LoginCommand;

const logoutCallback = jest.fn(async () => {});
const userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
const keyService = mock<KeyService>();

beforeEach(() => {
jest.clearAllMocks();

command = new LoginCommand(
mock<LoginStrategyServiceAbstraction>(),
mock<AuthService>(),
mock<TwoFactorApiService>(),
mock<MasterPasswordApiService>(),
mock<CryptoFunctionService>(),
mock<EnvironmentService>(),
mock<PasswordGenerationServiceAbstraction>(),
mock<PasswordStrengthServiceAbstraction>(),
mock<PlatformUtilsService>(),
mock<AccountService>(),
keyService,
mock<PolicyService>(),
mock<TwoFactorService>(),
mock<SyncService>(),
mock<KeyConnectorService>(),
mock<PolicyApiServiceAbstraction>(),
mock<OrganizationService>(),
logoutCallback,
mock<KdfConfigService>(),
mock<SsoUrlService>(),
mock<I18nService>(),
mock<MasterPasswordServiceAbstraction>(),
userDecryptionOptionsService,
mock<EncryptedMigrator>(),
);
});

describe("validateSsoUserInMpEncryptionOrgHasMp", () => {
it("fails login for SSO users without a master password and without a user key (regression for #18992)", async () => {
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of({
hasMasterPassword: false,
trustedDeviceOption: { encryptedPrivateKey: "present" },
keyConnectorOption: null,
} as any),
);
keyService.hasUserKey.mockResolvedValue(false);

await expect(
(command as any).validateSsoUserInMpEncryptionOrgHasMp("user-id" as UserId),
).rejects.toMatchObject({ success: false });

expect(logoutCallback).toHaveBeenCalled();
});

it("allows no-master-password SSO users when key connector is configured", async () => {
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of({
hasMasterPassword: false,
trustedDeviceOption: null,
keyConnectorOption: { keyConnectorUrl: "https://kc.example" },
} as any),
);

await expect(
(command as any).validateSsoUserInMpEncryptionOrgHasMp("user-id" as UserId),
).resolves.toBeUndefined();

expect(keyService.hasUserKey).not.toHaveBeenCalled();
expect(logoutCallback).not.toHaveBeenCalled();
});

it("allows login when SSO user has no master password but has a user key available", async () => {
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of({
hasMasterPassword: false,
trustedDeviceOption: { encryptedPrivateKey: "present" },
keyConnectorOption: null,
} as any),
);
keyService.hasUserKey.mockResolvedValue(true);

await expect(
(command as any).validateSsoUserInMpEncryptionOrgHasMp("user-id" as UserId),
).resolves.toBeUndefined();

expect(logoutCallback).not.toHaveBeenCalled();
});

it("allows login when SSO user has a master password", async () => {
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of({
hasMasterPassword: true,
trustedDeviceOption: null,
keyConnectorOption: null,
} as any),
);

await expect(
(command as any).validateSsoUserInMpEncryptionOrgHasMp("user-id" as UserId),
).resolves.toBeUndefined();

expect(keyService.hasUserKey).not.toHaveBeenCalled();
expect(logoutCallback).not.toHaveBeenCalled();
});
});
});
42 changes: 22 additions & 20 deletions apps/cli/src/auth/commands/login.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -842,33 +842,35 @@ export class LoginCommand {
}

/**
* Validate that a user logging in with SSO that is in an org using MP encryption
* has a MP set. If not, they cannot set a MP in the CLI and must use another client.
* @param userId
* @returns void
* Validate that an SSO login can actually decrypt vault data in CLI.
*
* If a user has no master password and we also do not have a user key in memory,
* login should fail with clear guidance instead of reporting success and failing on unlock.
*/
private async validateSsoUserInMpEncryptionOrgHasMp(userId: UserId): Promise<void> {
const userDecryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
);

// device trust isn't supported in the CLI as we don't have persistent device key storage.
const notUsingTrustedDeviceEncryption = !userDecryptionOptions.trustedDeviceOption;
const notUsingKeyConnector = !userDecryptionOptions.keyConnectorOption;
if (userDecryptionOptions.hasMasterPassword) {
return;
}

if (
notUsingTrustedDeviceEncryption &&
notUsingKeyConnector &&
!userDecryptionOptions.hasMasterPassword
) {
// If user is in an org that is using MP encryption and they JIT provisioned but
// have not yet set a MP and come to the CLI to login, they won't be able to unlock
// or set a MP in the CLI as it isn't supported.
await this.logoutCallback();
throw Response.error(
"In order to log in with SSO from the CLI, you must first log in" +
" through the web vault, the desktop, or the extension to set your master password.",
);
// Key Connector users may complete setup immediately after login via domain confirmation,
// so don't block them here before that flow runs.
if (userDecryptionOptions.keyConnectorOption?.keyConnectorUrl) {
return;
}

const hasUserKey = await this.keyService.hasUserKey(userId);
if (hasUserKey) {
return;
}

await this.logoutCallback();
throw Response.error(
"Unable to complete CLI login for this SSO account because no master password is set and no decryptable user key is available. " +
"Log in through the web vault, desktop app, or browser extension first to complete account setup, then try CLI again.",
);
}
}
Loading