Skip to content
Open
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
269 changes: 267 additions & 2 deletions apps/cli/src/auth/commands/login.command.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
54 changes: 35 additions & 19 deletions apps/cli/src/auth/commands/login.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
private async validateSsoUserHasCliSupportedDecryptionPath(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 || 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.",
);
}
}
Loading