import { mock, MockProxy } from "jest-mock-extended"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response"; import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; import { UserId } from "@bitwarden/common/types/guid"; import { DeviceKey, UserKey, MasterKey } from "@bitwarden/common/types/key"; import { AuthRequestServiceAbstraction, InternalUserDecryptionOptionsServiceAbstraction, } from "../abstractions"; import { SsoLoginCredentials } from "../models/domain/login-credentials"; import { identityTokenResponseFactory } from "./login.strategy.spec"; import { SsoLoginStrategy } from "./sso-login.strategy"; describe("SsoLoginStrategy", () => { let accountService: FakeAccountService; let masterPasswordService: FakeMasterPasswordService; let cryptoService: MockProxy; let apiService: MockProxy; let tokenService: MockProxy; let appIdService: MockProxy; let platformUtilsService: MockProxy; let messagingService: MockProxy; let logService: MockProxy; let stateService: MockProxy; let twoFactorService: MockProxy; let userDecryptionOptionsService: MockProxy; let keyConnectorService: MockProxy; let deviceTrustService: MockProxy; let authRequestService: MockProxy; let i18nService: MockProxy; let billingAccountProfileStateService: MockProxy; let kdfConfigService: MockProxy; let ssoLoginStrategy: SsoLoginStrategy; let credentials: SsoLoginCredentials; const userId = Utils.newGuid() as UserId; const deviceId = Utils.newGuid(); const keyConnectorUrl = "KEY_CONNECTOR_URL"; const ssoCode = "SSO_CODE"; const ssoCodeVerifier = "SSO_CODE_VERIFIER"; const ssoRedirectUrl = "SSO_REDIRECT_URL"; const ssoOrgId = "SSO_ORG_ID"; beforeEach(async () => { accountService = mockAccountServiceWith(userId); masterPasswordService = new FakeMasterPasswordService(); cryptoService = mock(); apiService = mock(); tokenService = mock(); appIdService = mock(); platformUtilsService = mock(); messagingService = mock(); logService = mock(); stateService = mock(); twoFactorService = mock(); userDecryptionOptionsService = mock(); keyConnectorService = mock(); deviceTrustService = mock(); authRequestService = mock(); i18nService = mock(); billingAccountProfileStateService = mock(); kdfConfigService = mock(); tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); tokenService.decodeAccessToken.mockResolvedValue({ sub: userId, }); ssoLoginStrategy = new SsoLoginStrategy( null, accountService, masterPasswordService, cryptoService, apiService, tokenService, appIdService, platformUtilsService, messagingService, logService, stateService, twoFactorService, userDecryptionOptionsService, keyConnectorService, deviceTrustService, authRequestService, i18nService, billingAccountProfileStateService, kdfConfigService, ); credentials = new SsoLoginCredentials(ssoCode, ssoCodeVerifier, ssoRedirectUrl, ssoOrgId); }); it("sends SSO information to server", async () => { apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory()); await ssoLoginStrategy.logIn(credentials); expect(apiService.postIdentityToken).toHaveBeenCalledWith( expect.objectContaining({ code: ssoCode, codeVerifier: ssoCodeVerifier, redirectUri: ssoRedirectUrl, device: expect.objectContaining({ identifier: deviceId, }), twoFactor: expect.objectContaining({ provider: null, token: null, }), }), ); }); it("does not set keys for new SSO user flow", async () => { const tokenResponse = identityTokenResponseFactory(); tokenResponse.key = null; apiService.postIdentityToken.mockResolvedValue(tokenResponse); await ssoLoginStrategy.logIn(credentials); expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled(); expect(cryptoService.setUserKey).not.toHaveBeenCalled(); expect(cryptoService.setPrivateKey).not.toHaveBeenCalled(); }); it("sets master key encrypted user key for existing SSO users", async () => { // Arrange const tokenResponse = identityTokenResponseFactory(); apiService.postIdentityToken.mockResolvedValue(tokenResponse); // Act await ssoLoginStrategy.logIn(credentials); // Assert expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledTimes(1); expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key); }); describe("Trusted Device Decryption", () => { const deviceKeyBytesLength = 64; const mockDeviceKeyRandomBytes = new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray; const mockDeviceKey: DeviceKey = new SymmetricCryptoKey(mockDeviceKeyRandomBytes) as DeviceKey; const userKeyBytesLength = 64; const mockUserKeyRandomBytes = new Uint8Array(userKeyBytesLength).buffer as CsprngArray; const mockUserKey: UserKey = new SymmetricCryptoKey(mockUserKeyRandomBytes) as UserKey; const mockEncDevicePrivateKey = "2.eh465OrUcluL9UpnCOUTAg==|2HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc="; const mockEncUserKey = "4.Xht6K9GA9jKcSNy4TaIvdj7f9+WsgQycs/HdkrJi33aC//roKkjf3UTGpdzFLxVP3WhyOVGyo9f2Jymf1MFPdpg7AuMnpGJlcrWLDbnPjOJo4x5gUwwBUmy3nFw6+wamyS1LRmrBPcv56yKpf80k5Q3hUrum8q9YS9m2I10vklX/TaB1YML0yo+K1feWUxg8vIx+vloxhUdkkysvcV5xU3R+AgYLrwvJS8TLL7Ug/P5HxinCaIroRrNe8xcv84vyVnzPFdXe0cfZ0cpcrm586LwfEXP2seeldO/bC51Uk/mudeSALJURPC64f5ch2cOvk48GOTapGnssCqr6ky5yFw=="; const userDecryptionOptsServerResponseWithTdeOption: IUserDecryptionOptionsServerResponse = { HasMasterPassword: true, TrustedDeviceOption: { HasAdminApproval: true, HasLoginApprovingDevice: true, HasManageResetPasswordPermission: false, EncryptedPrivateKey: mockEncDevicePrivateKey, EncryptedUserKey: mockEncUserKey, }, }; const mockIdTokenResponseWithModifiedTrustedDeviceOption = (key: string, value: any) => { const userDecryptionOpts: IUserDecryptionOptionsServerResponse = { ...userDecryptionOptsServerResponseWithTdeOption, TrustedDeviceOption: { ...userDecryptionOptsServerResponseWithTdeOption.TrustedDeviceOption, [key]: value, }, }; return identityTokenResponseFactory(null, userDecryptionOpts); }; beforeEach(() => { jest.clearAllMocks(); }); it("decrypts and sets user key when trusted device decryption option exists with valid device key and enc key data", async () => { // Arrange const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( null, userDecryptionOptsServerResponseWithTdeOption, ); apiService.postIdentityToken.mockResolvedValue(idTokenResponse); deviceTrustService.getDeviceKey.mockResolvedValue(mockDeviceKey); deviceTrustService.decryptUserKeyWithDeviceKey.mockResolvedValue(mockUserKey); const cryptoSvcSetUserKeySpy = jest.spyOn(cryptoService, "setUserKey"); // Act await ssoLoginStrategy.logIn(credentials); // Assert expect(deviceTrustService.getDeviceKey).toHaveBeenCalledTimes(1); expect(deviceTrustService.decryptUserKeyWithDeviceKey).toHaveBeenCalledTimes(1); expect(cryptoSvcSetUserKeySpy).toHaveBeenCalledTimes(1); expect(cryptoSvcSetUserKeySpy).toHaveBeenCalledWith(mockUserKey); }); it("does not set the user key when deviceKey is missing", async () => { // Arrange const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( null, userDecryptionOptsServerResponseWithTdeOption, ); apiService.postIdentityToken.mockResolvedValue(idTokenResponse); // Set deviceKey to be null deviceTrustService.getDeviceKey.mockResolvedValue(null); deviceTrustService.decryptUserKeyWithDeviceKey.mockResolvedValue(mockUserKey); // Act await ssoLoginStrategy.logIn(credentials); // Assert expect(cryptoService.setUserKey).not.toHaveBeenCalled(); }); describe.each([ { valueName: "encDevicePrivateKey", }, { valueName: "encUserKey", }, ])("given trusted device decryption option has missing encrypted key data", ({ valueName }) => { it(`does not set the user key when ${valueName} is missing`, async () => { // Arrange const idTokenResponse = mockIdTokenResponseWithModifiedTrustedDeviceOption(valueName, null); apiService.postIdentityToken.mockResolvedValue(idTokenResponse); deviceTrustService.getDeviceKey.mockResolvedValue(mockDeviceKey); // Act await ssoLoginStrategy.logIn(credentials); // Assert expect(cryptoService.setUserKey).not.toHaveBeenCalled(); }); }); it("does not set user key when decrypted user key is null", async () => { // Arrange const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( null, userDecryptionOptsServerResponseWithTdeOption, ); apiService.postIdentityToken.mockResolvedValue(idTokenResponse); deviceTrustService.getDeviceKey.mockResolvedValue(mockDeviceKey); // Set userKey to be null deviceTrustService.decryptUserKeyWithDeviceKey.mockResolvedValue(null); // Act await ssoLoginStrategy.logIn(credentials); // Assert expect(cryptoService.setUserKey).not.toHaveBeenCalled(); }); describe("AdminAuthRequest", () => { let tokenResponse: IdentityTokenResponse; beforeEach(() => { tokenResponse = identityTokenResponseFactory(null, { HasMasterPassword: true, TrustedDeviceOption: { HasAdminApproval: true, HasLoginApprovingDevice: false, HasManageResetPasswordPermission: false, EncryptedPrivateKey: mockEncDevicePrivateKey, EncryptedUserKey: mockEncUserKey, }, }); const adminAuthRequest = { id: "1", privateKey: "PRIVATE" as any, } as AdminAuthRequestStorable; authRequestService.getAdminAuthRequest.mockResolvedValue( new AdminAuthRequestStorable(adminAuthRequest), ); }); it("sets the user key using master key and hash from approved admin request if exists", async () => { apiService.postIdentityToken.mockResolvedValue(tokenResponse); cryptoService.hasUserKey.mockResolvedValue(true); const adminAuthResponse = { id: "1", publicKey: "PRIVATE" as any, key: "KEY" as any, masterPasswordHash: "HASH" as any, requestApproved: true, }; apiService.getAuthRequest.mockResolvedValue(adminAuthResponse as AuthRequestResponse); await ssoLoginStrategy.logIn(credentials); expect(authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash).toHaveBeenCalled(); expect(deviceTrustService.decryptUserKeyWithDeviceKey).not.toHaveBeenCalled(); }); it("sets the user key from approved admin request if exists", async () => { apiService.postIdentityToken.mockResolvedValue(tokenResponse); cryptoService.hasUserKey.mockResolvedValue(true); const adminAuthResponse = { id: "1", publicKey: "PRIVATE" as any, key: "KEY" as any, requestApproved: true, }; apiService.getAuthRequest.mockResolvedValue(adminAuthResponse as AuthRequestResponse); await ssoLoginStrategy.logIn(credentials); expect(authRequestService.setUserKeyAfterDecryptingSharedUserKey).toHaveBeenCalled(); expect(deviceTrustService.decryptUserKeyWithDeviceKey).not.toHaveBeenCalled(); }); it("attempts to establish a trusted device if successful", async () => { apiService.postIdentityToken.mockResolvedValue(tokenResponse); cryptoService.hasUserKey.mockResolvedValue(true); const adminAuthResponse = { id: "1", publicKey: "PRIVATE" as any, key: "KEY" as any, requestApproved: true, }; apiService.getAuthRequest.mockResolvedValue(adminAuthResponse as AuthRequestResponse); await ssoLoginStrategy.logIn(credentials); expect(authRequestService.setUserKeyAfterDecryptingSharedUserKey).toHaveBeenCalled(); expect(deviceTrustService.trustDeviceIfRequired).toHaveBeenCalled(); }); it("clears the admin auth request if server returns a 404, meaning it was deleted", async () => { apiService.postIdentityToken.mockResolvedValue(tokenResponse); apiService.getAuthRequest.mockRejectedValue(new ErrorResponse(null, 404)); await ssoLoginStrategy.logIn(credentials); expect(authRequestService.clearAdminAuthRequest).toHaveBeenCalled(); expect( authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash, ).not.toHaveBeenCalled(); expect(authRequestService.setUserKeyAfterDecryptingSharedUserKey).not.toHaveBeenCalled(); expect(deviceTrustService.trustDeviceIfRequired).not.toHaveBeenCalled(); }); it("attempts to login with a trusted device if admin auth request isn't successful", async () => { apiService.postIdentityToken.mockResolvedValue(tokenResponse); const adminAuthResponse = { id: "1", publicKey: "PRIVATE" as any, key: "KEY" as any, requestApproved: true, }; apiService.getAuthRequest.mockResolvedValue(adminAuthResponse as AuthRequestResponse); cryptoService.hasUserKey.mockResolvedValue(false); deviceTrustService.getDeviceKey.mockResolvedValue("DEVICE_KEY" as any); await ssoLoginStrategy.logIn(credentials); expect(deviceTrustService.decryptUserKeyWithDeviceKey).toHaveBeenCalled(); }); }); }); describe("Key Connector", () => { let tokenResponse: IdentityTokenResponse; beforeEach(() => { tokenResponse = identityTokenResponseFactory(null, { HasMasterPassword: false, KeyConnectorOption: { KeyConnectorUrl: keyConnectorUrl }, }); tokenResponse.keyConnectorUrl = keyConnectorUrl; }); it("gets and sets the master key if Key Connector is enabled and the user doesn't have a master password", async () => { const masterKey = new SymmetricCryptoKey( new Uint8Array(64).buffer as CsprngArray, ) as MasterKey; apiService.postIdentityToken.mockResolvedValue(tokenResponse); masterPasswordService.masterKeySubject.next(masterKey); await ssoLoginStrategy.logIn(credentials); expect(keyConnectorService.setMasterKeyFromUrl).toHaveBeenCalledWith(keyConnectorUrl); }); it("converts new SSO user with no master password to Key Connector on first login", async () => { tokenResponse.key = null; apiService.postIdentityToken.mockResolvedValue(tokenResponse); await ssoLoginStrategy.logIn(credentials); expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith( tokenResponse, ssoOrgId, ); }); it("decrypts and sets the user key if Key Connector is enabled and the user doesn't have a master password", async () => { const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; const masterKey = new SymmetricCryptoKey( new Uint8Array(64).buffer as CsprngArray, ) as MasterKey; apiService.postIdentityToken.mockResolvedValue(tokenResponse); masterPasswordService.masterKeySubject.next(masterKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); await ssoLoginStrategy.logIn(credentials); expect(cryptoService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(masterKey); expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey); }); }); describe("Key Connector Pre-TDE", () => { let tokenResponse: IdentityTokenResponse; beforeEach(() => { tokenResponse = identityTokenResponseFactory(); tokenResponse.userDecryptionOptions = null; tokenResponse.keyConnectorUrl = keyConnectorUrl; }); it("gets and sets the master key if Key Connector is enabled and the user doesn't have a master password", async () => { const masterKey = new SymmetricCryptoKey( new Uint8Array(64).buffer as CsprngArray, ) as MasterKey; apiService.postIdentityToken.mockResolvedValue(tokenResponse); masterPasswordService.masterKeySubject.next(masterKey); await ssoLoginStrategy.logIn(credentials); expect(keyConnectorService.setMasterKeyFromUrl).toHaveBeenCalledWith(keyConnectorUrl); }); it("converts new SSO user with no master password to Key Connector on first login", async () => { tokenResponse.key = null; apiService.postIdentityToken.mockResolvedValue(tokenResponse); await ssoLoginStrategy.logIn(credentials); expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith( tokenResponse, ssoOrgId, ); }); it("decrypts and sets the user key if Key Connector is enabled and the user doesn't have a master password", async () => { const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; const masterKey = new SymmetricCryptoKey( new Uint8Array(64).buffer as CsprngArray, ) as MasterKey; apiService.postIdentityToken.mockResolvedValue(tokenResponse); masterPasswordService.masterKeySubject.next(masterKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); await ssoLoginStrategy.logIn(credentials); expect(cryptoService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(masterKey); expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey); }); }); });