diff --git a/apps/cli/src/auth/commands/login.command.spec.ts b/apps/cli/src/auth/commands/login.command.spec.ts index 94eb1786bd33..abd1b31927b6 100644 --- a/apps/cli/src/auth/commands/login.command.spec.ts +++ b/apps/cli/src/auth/commands/login.command.spec.ts @@ -525,13 +525,278 @@ describe("LoginCommand", () => { // ========================================================================= describe("post-login flows", () => { - describe("SSO MP validation", () => { - it("skips SSO MP validation for password login", async () => { + describe("SSO decryption path validation", () => { + it("skips SSO decryption path validation for password login", async () => { // Password login path: ssoCode and ssoCodeVerifier are null, so validation is skipped await command.run("a@b.c", "password", {}); expect(userDecryptionOptionsService.userDecryptionOptionsById$).not.toHaveBeenCalled(); }); + + describe("validateSsoUserHasCliSupportedDecryptionPath", () => { + it("allows user with master password", async () => { + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of({ + hasMasterPassword: true, + trustedDeviceOption: undefined, + keyConnectorOption: undefined, + webAuthnPrfOptions: undefined, + } as any), + ); + + await expect( + (command as any).validateSsoUserHasCliSupportedDecryptionPath(TEST_USER_ID), + ).resolves.toBeUndefined(); + + expect(logoutCallback).not.toHaveBeenCalled(); + }); + + it("allows user with Key Connector", async () => { + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of({ + hasMasterPassword: false, + trustedDeviceOption: undefined, + keyConnectorOption: { keyConnectorUrl: "https://kc.example" }, + webAuthnPrfOptions: undefined, + } as any), + ); + + await expect( + (command as any).validateSsoUserHasCliSupportedDecryptionPath(TEST_USER_ID), + ).resolves.toBeUndefined(); + + expect(logoutCallback).not.toHaveBeenCalled(); + }); + + it("allows user with master password AND PRF (MP short-circuits)", async () => { + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of({ + hasMasterPassword: true, + trustedDeviceOption: undefined, + keyConnectorOption: undefined, + webAuthnPrfOptions: [{ encryptedPrivateKey: "present" }], + } as any), + ); + + await expect( + (command as any).validateSsoUserHasCliSupportedDecryptionPath(TEST_USER_ID), + ).resolves.toBeUndefined(); + + expect(logoutCallback).not.toHaveBeenCalled(); + }); + + it("allows user with Key Connector AND PRF (KC short-circuits)", async () => { + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of({ + hasMasterPassword: false, + trustedDeviceOption: undefined, + keyConnectorOption: { keyConnectorUrl: "https://kc.example" }, + webAuthnPrfOptions: [{ encryptedPrivateKey: "present" }], + } as any), + ); + + await expect( + (command as any).validateSsoUserHasCliSupportedDecryptionPath(TEST_USER_ID), + ).resolves.toBeUndefined(); + + expect(logoutCallback).not.toHaveBeenCalled(); + }); + + it("allows user with master password AND TDE (MP short-circuits)", async () => { + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of({ + hasMasterPassword: true, + trustedDeviceOption: { encryptedPrivateKey: "present" }, + keyConnectorOption: undefined, + webAuthnPrfOptions: undefined, + } as any), + ); + + await expect( + (command as any).validateSsoUserHasCliSupportedDecryptionPath(TEST_USER_ID), + ).resolves.toBeUndefined(); + + expect(logoutCallback).not.toHaveBeenCalled(); + }); + + it("rejects TDE-only user with TDE-specific error", async () => { + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of({ + hasMasterPassword: false, + trustedDeviceOption: { encryptedPrivateKey: "present" }, + keyConnectorOption: undefined, + webAuthnPrfOptions: undefined, + } as any), + ); + + await expect( + (command as any).validateSsoUserHasCliSupportedDecryptionPath(TEST_USER_ID), + ).rejects.toMatchObject({ + success: false, + message: expect.stringContaining( + "does not support SSO login for accounts using trusted device encryption", + ), + }); + + expect(logoutCallback).toHaveBeenCalled(); + }); + + it("rejects PRF-only user with PRF-specific error", async () => { + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of({ + hasMasterPassword: false, + trustedDeviceOption: undefined, + keyConnectorOption: undefined, + webAuthnPrfOptions: [{ encryptedPrivateKey: "present" }], + } as any), + ); + + await expect( + (command as any).validateSsoUserHasCliSupportedDecryptionPath(TEST_USER_ID), + ).rejects.toMatchObject({ + success: false, + message: expect.stringContaining( + "does not support SSO login for accounts using PRF passkey decryption", + ), + }); + + expect(logoutCallback).toHaveBeenCalled(); + }); + + it("rejects TDE + PRF user with TDE error (TDE checked first)", async () => { + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of({ + hasMasterPassword: false, + trustedDeviceOption: { encryptedPrivateKey: "present" }, + keyConnectorOption: undefined, + webAuthnPrfOptions: [{ encryptedPrivateKey: "present" }], + } as any), + ); + + await expect( + (command as any).validateSsoUserHasCliSupportedDecryptionPath(TEST_USER_ID), + ).rejects.toMatchObject({ + success: false, + message: expect.stringContaining( + "does not support SSO login for accounts using trusted device encryption", + ), + }); + + expect(logoutCallback).toHaveBeenCalled(); + }); + + it("rejects user with no configured decryption method (JIT'd MP-encryption-org)", async () => { + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of({ + hasMasterPassword: false, + trustedDeviceOption: undefined, + keyConnectorOption: undefined, + webAuthnPrfOptions: undefined, + } as any), + ); + + await expect( + (command as any).validateSsoUserHasCliSupportedDecryptionPath(TEST_USER_ID), + ).rejects.toMatchObject({ + success: false, + message: expect.stringContaining( + "log in through the web vault, the desktop, or the extension to set your master password", + ), + }); + + expect(logoutCallback).toHaveBeenCalled(); + }); + }); + }); + + describe("end-to-end SSO login flow", () => { + // Spy-mock the private SSO helpers so the SSO login path can complete deterministically + // without opening a real HTTP callback server on ports 8065-8070. + beforeEach(() => { + process.env.BW_NOINTERACTION = "false"; + jest.spyOn(command as any, "makeSsoPromptData").mockResolvedValue({ + ssoCodeVerifier: "mock-verifier", + codeChallenge: "mock-challenge", + state: "mock-state", + }); + jest.spyOn(command as any, "openSsoPrompt").mockResolvedValue({ + ssoCode: "mock-code", + orgIdentifier: "mock-org", + }); + }); + + it("rejects SSO login for TDE-only user with TDE-specific message", async () => { + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of({ + hasMasterPassword: false, + trustedDeviceOption: { encryptedPrivateKey: "present" }, + keyConnectorOption: undefined, + webAuthnPrfOptions: undefined, + } as any), + ); + + const response = await command.run(NULL, NULL, { sso: true }); + + expect(response.success).toBe(false); + expect(response.message).toContain( + "does not support SSO login for accounts using trusted device encryption", + ); + expect(logoutCallback).toHaveBeenCalled(); + }); + + it("rejects SSO login for PRF-only user with PRF-specific message", async () => { + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of({ + hasMasterPassword: false, + trustedDeviceOption: undefined, + keyConnectorOption: undefined, + webAuthnPrfOptions: [{ encryptedPrivateKey: "present" }], + } as any), + ); + + const response = await command.run(NULL, NULL, { sso: true }); + + expect(response.success).toBe(false); + expect(response.message).toContain( + "does not support SSO login for accounts using PRF passkey decryption", + ); + expect(logoutCallback).toHaveBeenCalled(); + }); + + it("rejects SSO login for JIT'd MP-encryption-org user with 'set your master password' message", async () => { + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of({ + hasMasterPassword: false, + trustedDeviceOption: undefined, + keyConnectorOption: undefined, + webAuthnPrfOptions: undefined, + } as any), + ); + + const response = await command.run(NULL, NULL, { sso: true }); + + expect(response.success).toBe(false); + expect(response.message).toContain( + "log in through the web vault, the desktop, or the extension to set your master password", + ); + expect(logoutCallback).toHaveBeenCalled(); + }); + + it("allows SSO login for user with master password", async () => { + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of({ + hasMasterPassword: true, + trustedDeviceOption: undefined, + keyConnectorOption: undefined, + webAuthnPrfOptions: undefined, + } as any), + ); + + const response = await command.run(NULL, NULL, { sso: true }); + + expect(response.success).toBe(true); + expect(logoutCallback).not.toHaveBeenCalled(); + }); }); describe("key connector domain confirmation", () => { diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index 35143af3280d..7e8bccc2678c 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -360,12 +360,12 @@ export class LoginCommand { } // If we are in the SSO flow and we got a successful login response (we are past rejection scenarios - // and should always have a userId here), validate that SSO user in MP encryption org has MP set + // and should always have a userId here), validate the SSO account has a decryption path the CLI supports. // This must be done here b/c we have 2 places we try to login with SSO above and neither has a // common handleSsoAuthnResult method to consoldiate this logic into (1. the normal SSO flow and // 2. the requiresSso automatic authentication flow) if (ssoCode != null && ssoCodeVerifier != null && response.userId) { - await this.validateSsoUserInMpEncryptionOrgHasMp(response.userId); + await this.validateSsoUserHasCliSupportedDecryptionPath(response.userId); } // Check if Key Connector domain confirmation is required @@ -647,33 +647,49 @@ 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. + * Validate that an SSO user has a decryption path the CLI supports. + * + * The CLI can decrypt via master password or Key Connector. If neither is available, + * throw with the most specific remediation for the user's state: + * - Trusted Device Encryption configured — not supported (CLI lacks persistent device key storage). + * - WebAuthn PRF passkey configured — not supported (CLI has no WebAuthn platform). + * - MP-encryption org with no MP set — user JIT provisioned but never completed setup. * @param userId * @returns void */ - private async validateSsoUserInMpEncryptionOrgHasMp(userId: UserId): Promise { + private async validateSsoUserHasCliSupportedDecryptionPath(userId: UserId): Promise { 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 || userDecryptionOptions.keyConnectorOption) { + 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(); + await this.logoutCallback(); + + if (userDecryptionOptions.trustedDeviceOption) { + throw Response.error( + "The CLI does not support SSO login for accounts using trusted device encryption." + + " Log in through the web vault, desktop app, or browser extension.", + ); + } + + // Currently unreachable via SSO — the server only populates PRF options through + // the WebAuthn-grant login — but kept as defense-in-depth in case that changes. + if (userDecryptionOptions.webAuthnPrfOptions) { 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.", + "The CLI does not support SSO login for accounts using PRF passkey decryption." + + " Log in through the web vault or browser extension.", ); } + + // Invariant: no MP, no KC, no TDE, no PRF. MP is the intended decryption method + // for the user's org but they haven't set an MP yet — JIT'd into an + // MP-encryption org without completing setup. + 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.", + ); } }