diff --git a/libs/angular/src/auth/components/sso.component.spec.ts b/libs/angular/src/auth/components/sso.component.spec.ts deleted file mode 100644 index 0d28c6327b7..00000000000 --- a/libs/angular/src/auth/components/sso.component.spec.ts +++ /dev/null @@ -1,611 +0,0 @@ -import { Component } from "@angular/core"; -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { ActivatedRoute, Router } from "@angular/router"; -import { MockProxy, mock } from "jest-mock-extended"; -import { BehaviorSubject, Observable, of } from "rxjs"; - -import { - FakeKeyConnectorUserDecryptionOption as KeyConnectorUserDecryptionOption, - LoginStrategyServiceAbstraction, - FakeTrustedDeviceUserDecryptionOption as TrustedDeviceUserDecryptionOption, - FakeUserDecryptionOptions as UserDecryptionOptions, - UserDecryptionOptionsServiceAbstraction, -} from "@bitwarden/auth/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; -import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; -import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; -import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; -import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; -import { UserId } from "@bitwarden/common/types/guid"; -import { ToastService } from "@bitwarden/components"; -import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; - -import { SsoComponent } from "./sso.component"; -// test component that extends the SsoComponent -@Component({}) -class TestSsoComponent extends SsoComponent {} - -interface SsoComponentProtected { - twoFactorRoute: string; - successRoute: string; - trustedDeviceEncRoute: string; - changePasswordRoute: string; - forcePasswordResetRoute: string; - logIn(code: string, codeVerifier: string, orgIdFromState: string): Promise; - handleLoginError(e: any): Promise; -} - -// The ideal scenario would be to not have to test the protected / private methods of the SsoComponent -// but that will require a refactor of the SsoComponent class which is out of scope for now. -// This test suite allows us to be sure that the new Trusted Device encryption flows + mild refactors -// of the SsoComponent don't break the existing post login flows. -describe("SsoComponent", () => { - let component: TestSsoComponent; - let _component: SsoComponentProtected; - let fixture: ComponentFixture; - const userId = "userId" as UserId; - - // Mock Services - let mockLoginStrategyService: MockProxy; - let mockRouter: MockProxy; - let mockI18nService: MockProxy; - - let mockQueryParams: Observable; - let mockActivatedRoute: ActivatedRoute; - - let mockSsoLoginService: MockProxy; - let mockStateService: MockProxy; - let mockToastService: MockProxy; - let mockApiService: MockProxy; - let mockCryptoFunctionService: MockProxy; - let mockEnvironmentService: MockProxy; - let mockPasswordGenerationService: MockProxy; - let mockLogService: MockProxy; - let mockUserDecryptionOptionsService: MockProxy; - let mockConfigService: MockProxy; - let mockMasterPasswordService: FakeMasterPasswordService; - let mockAccountService: FakeAccountService; - let mockPlatformUtilsService: MockProxy; - - // Mock authService.logIn params - let code: string; - let codeVerifier: string; - let orgIdFromState: string; - - // Mock component callbacks - let mockOnSuccessfulLogin: jest.Mock; - let mockOnSuccessfulLoginNavigate: jest.Mock; - let mockOnSuccessfulLoginTwoFactorNavigate: jest.Mock; - let mockOnSuccessfulLoginChangePasswordNavigate: jest.Mock; - let mockOnSuccessfulLoginForceResetNavigate: jest.Mock; - let mockOnSuccessfulLoginTdeNavigate: jest.Mock; - - let mockUserDecryptionOpts: { - noMasterPassword: UserDecryptionOptions; - withMasterPassword: UserDecryptionOptions; - withMasterPasswordAndTrustedDevice: UserDecryptionOptions; - withMasterPasswordAndTrustedDeviceWithManageResetPassword: UserDecryptionOptions; - withMasterPasswordAndKeyConnector: UserDecryptionOptions; - noMasterPasswordWithTrustedDevice: UserDecryptionOptions; - noMasterPasswordWithTrustedDeviceWithManageResetPassword: UserDecryptionOptions; - noMasterPasswordWithKeyConnector: UserDecryptionOptions; - }; - - let selectedUserDecryptionOptions: BehaviorSubject; - - beforeEach(() => { - // Mock Services - mockLoginStrategyService = mock(); - mockRouter = mock(); - mockI18nService = mock(); - - // Default mockQueryParams - mockQueryParams = of({ code: "code", state: "state" }); - // Create a custom mock for ActivatedRoute with mock queryParams - mockActivatedRoute = { - queryParams: mockQueryParams, - } as any as ActivatedRoute; - - mockSsoLoginService = mock(); - mockStateService = mock(); - mockToastService = mock(); - mockApiService = mock(); - mockCryptoFunctionService = mock(); - mockEnvironmentService = mock(); - mockPasswordGenerationService = mock(); - mockLogService = mock(); - mockUserDecryptionOptionsService = mock(); - mockConfigService = mock(); - mockAccountService = mockAccountServiceWith(userId); - mockMasterPasswordService = new FakeMasterPasswordService(); - mockPlatformUtilsService = mock(); - - // Mock loginStrategyService.logIn params - code = "code"; - codeVerifier = "codeVerifier"; - orgIdFromState = "orgIdFromState"; - - // Mock component callbacks - mockOnSuccessfulLogin = jest.fn(); - mockOnSuccessfulLoginNavigate = jest.fn(); - mockOnSuccessfulLoginTwoFactorNavigate = jest.fn(); - mockOnSuccessfulLoginChangePasswordNavigate = jest.fn(); - mockOnSuccessfulLoginForceResetNavigate = jest.fn(); - mockOnSuccessfulLoginTdeNavigate = jest.fn(); - - mockUserDecryptionOpts = { - noMasterPassword: new UserDecryptionOptions({ - hasMasterPassword: false, - trustedDeviceOption: undefined, - keyConnectorOption: undefined, - }), - withMasterPassword: new UserDecryptionOptions({ - hasMasterPassword: true, - trustedDeviceOption: undefined, - keyConnectorOption: undefined, - }), - withMasterPasswordAndTrustedDevice: new UserDecryptionOptions({ - hasMasterPassword: true, - trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false, false), - keyConnectorOption: undefined, - }), - withMasterPasswordAndTrustedDeviceWithManageResetPassword: new UserDecryptionOptions({ - hasMasterPassword: true, - trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true, false), - keyConnectorOption: undefined, - }), - withMasterPasswordAndKeyConnector: new UserDecryptionOptions({ - hasMasterPassword: true, - trustedDeviceOption: undefined, - keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"), - }), - noMasterPasswordWithTrustedDevice: new UserDecryptionOptions({ - hasMasterPassword: false, - trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false, false), - keyConnectorOption: undefined, - }), - noMasterPasswordWithTrustedDeviceWithManageResetPassword: new UserDecryptionOptions({ - hasMasterPassword: false, - trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true, false), - keyConnectorOption: undefined, - }), - noMasterPasswordWithKeyConnector: new UserDecryptionOptions({ - hasMasterPassword: false, - trustedDeviceOption: undefined, - keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"), - }), - }; - - selectedUserDecryptionOptions = new BehaviorSubject(null); - mockUserDecryptionOptionsService.userDecryptionOptions$ = selectedUserDecryptionOptions; - - TestBed.configureTestingModule({ - declarations: [TestSsoComponent], - providers: [ - { provide: SsoLoginServiceAbstraction, useValue: mockSsoLoginService }, - { provide: LoginStrategyServiceAbstraction, useValue: mockLoginStrategyService }, - { provide: Router, useValue: mockRouter }, - { provide: I18nService, useValue: mockI18nService }, - { provide: ActivatedRoute, useValue: mockActivatedRoute }, - { provide: StateService, useValue: mockStateService }, - { provide: ToastService, useValue: mockToastService }, - - { provide: ApiService, useValue: mockApiService }, - { provide: CryptoFunctionService, useValue: mockCryptoFunctionService }, - { provide: EnvironmentService, useValue: mockEnvironmentService }, - { - provide: PasswordGenerationServiceAbstraction, - useValue: mockPasswordGenerationService, - }, - - { - provide: UserDecryptionOptionsServiceAbstraction, - useValue: mockUserDecryptionOptionsService, - }, - { provide: LogService, useValue: mockLogService }, - { provide: ConfigService, useValue: mockConfigService }, - { provide: InternalMasterPasswordServiceAbstraction, useValue: mockMasterPasswordService }, - { provide: AccountService, useValue: mockAccountService }, - { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, - ], - }); - - fixture = TestBed.createComponent(TestSsoComponent); - component = fixture.componentInstance; - _component = component as any; - }); - - afterEach(() => { - // Reset all mocks after each test - jest.resetAllMocks(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); - - describe("navigateViaCallbackOrRoute(...)", () => { - it("calls the provided callback when callback is defined", async () => { - const callback = jest.fn().mockResolvedValue(null); - const commands = ["some", "route"]; - - await (component as any).navigateViaCallbackOrRoute(callback, commands); - - expect(callback).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).not.toHaveBeenCalled(); - }); - - it("calls router.navigate when callback is not defined", async () => { - const commands = ["some", "route"]; - - await (component as any).navigateViaCallbackOrRoute(undefined, commands); - - expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith(commands, undefined); - }); - }); - - describe("logIn(...)", () => { - describe("2FA scenarios", () => { - beforeEach(() => { - const authResult = new AuthResult(); - authResult.twoFactorProviders = { [TwoFactorProviderType.Authenticator]: {} }; - - // use standard user with MP because this test is not concerned with password reset. - selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword); - - mockLoginStrategyService.logIn.mockResolvedValue(authResult); - }); - - it("calls authService.logIn and navigates to the component's defined 2FA route when the auth result requires 2FA and onSuccessfulLoginTwoFactorNavigate is not defined", async () => { - await _component.logIn(code, codeVerifier, orgIdFromState); - expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1); - - expect(mockOnSuccessfulLoginTwoFactorNavigate).not.toHaveBeenCalled(); - - expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith([_component.twoFactorRoute], { - queryParams: { - identifier: orgIdFromState, - sso: "true", - }, - }); - - expect(mockLogService.error).not.toHaveBeenCalled(); - }); - - it("calls onSuccessfulLoginTwoFactorNavigate instead of router.navigate when response.requiresTwoFactor is true and the callback is defined", async () => { - mockOnSuccessfulLoginTwoFactorNavigate = jest.fn().mockResolvedValue(null); - component.onSuccessfulLoginTwoFactorNavigate = mockOnSuccessfulLoginTwoFactorNavigate; - - await _component.logIn(code, codeVerifier, orgIdFromState); - - expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1); - expect(mockOnSuccessfulLoginTwoFactorNavigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).not.toHaveBeenCalled(); - expect(mockLogService.error).not.toHaveBeenCalled(); - }); - }); - - // Shared test helpers - const testChangePasswordOnSuccessfulLogin = () => { - it("navigates to the component's defined change password route when onSuccessfulLoginChangePasswordNavigate callback is undefined", async () => { - await _component.logIn(code, codeVerifier, orgIdFromState); - expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1); - - expect(mockOnSuccessfulLoginChangePasswordNavigate).not.toHaveBeenCalled(); - - expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith([_component.changePasswordRoute], { - queryParams: { - identifier: orgIdFromState, - }, - }); - - expect(mockLogService.error).not.toHaveBeenCalled(); - }); - }; - - const testOnSuccessfulLoginChangePasswordNavigate = () => { - it("calls onSuccessfulLoginChangePasswordNavigate instead of router.navigate when the callback is defined", async () => { - mockOnSuccessfulLoginChangePasswordNavigate = jest.fn().mockResolvedValue(null); - component.onSuccessfulLoginChangePasswordNavigate = - mockOnSuccessfulLoginChangePasswordNavigate; - - await _component.logIn(code, codeVerifier, orgIdFromState); - - expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1); - expect(mockOnSuccessfulLoginChangePasswordNavigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).not.toHaveBeenCalled(); - expect(mockLogService.error).not.toHaveBeenCalled(); - }); - }; - - const testForceResetOnSuccessfulLogin = (reasonString: string) => { - it(`navigates to the component's defined forcePasswordResetRoute when response.forcePasswordReset is ${reasonString}`, async () => { - await _component.logIn(code, codeVerifier, orgIdFromState); - - expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1); - - expect(mockOnSuccessfulLoginForceResetNavigate).not.toHaveBeenCalled(); - - expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith([_component.forcePasswordResetRoute], { - queryParams: { - identifier: orgIdFromState, - }, - }); - expect(mockLogService.error).not.toHaveBeenCalled(); - }); - }; - - const testOnSuccessfulLoginForceResetNavigate = (reasonString: string) => { - it(`calls onSuccessfulLoginForceResetNavigate instead of router.navigate when response.forcePasswordReset is ${reasonString} and the callback is defined`, async () => { - mockOnSuccessfulLoginForceResetNavigate = jest.fn().mockResolvedValue(null); - component.onSuccessfulLoginForceResetNavigate = mockOnSuccessfulLoginForceResetNavigate; - - await _component.logIn(code, codeVerifier, orgIdFromState); - - expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1); - expect(mockOnSuccessfulLoginForceResetNavigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).not.toHaveBeenCalled(); - expect(mockLogService.error).not.toHaveBeenCalled(); - }); - }; - - describe("Trusted Device Encryption scenarios", () => { - beforeEach(() => { - mockConfigService.getFeatureFlag.mockResolvedValue(true); // TDE enabled - }); - - describe("Given Trusted Device Encryption is enabled and user needs to set a master password", () => { - let authResult; - beforeEach(() => { - selectedUserDecryptionOptions.next( - mockUserDecryptionOpts.noMasterPasswordWithTrustedDeviceWithManageResetPassword, - ); - - authResult = new AuthResult(); - mockLoginStrategyService.logIn.mockResolvedValue(authResult); - }); - - it("navigates to the component's defined trustedDeviceEncRoute route and sets correct flag when onSuccessfulLoginTdeNavigate is undefined ", async () => { - await _component.logIn(code, codeVerifier, orgIdFromState); - expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1); - - expect(mockMasterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith( - ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, - userId, - ); - - expect(mockOnSuccessfulLoginTdeNavigate).not.toHaveBeenCalled(); - - expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith( - [_component.trustedDeviceEncRoute], - undefined, - ); - - expect(mockLogService.error).not.toHaveBeenCalled(); - }); - }); - - describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is required", () => { - [ - ForceSetPasswordReason.AdminForcePasswordReset, - // ForceResetPasswordReason.WeakMasterPassword, -- not possible in SSO flow as set client side - ].forEach((forceResetPasswordReason) => { - const reasonString = ForceSetPasswordReason[forceResetPasswordReason]; - let authResult; - beforeEach(() => { - selectedUserDecryptionOptions.next( - mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice, - ); - - authResult = new AuthResult(); - authResult.forcePasswordReset = ForceSetPasswordReason.AdminForcePasswordReset; - mockLoginStrategyService.logIn.mockResolvedValue(authResult); - }); - - testForceResetOnSuccessfulLogin(reasonString); - testOnSuccessfulLoginForceResetNavigate(reasonString); - }); - }); - - describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is not required", () => { - let authResult; - beforeEach(() => { - selectedUserDecryptionOptions.next( - mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice, - ); - - authResult = new AuthResult(); - authResult.forcePasswordReset = ForceSetPasswordReason.None; - mockLoginStrategyService.logIn.mockResolvedValue(authResult); - }); - - it("navigates to the component's defined trusted device encryption route when login is successful and no callback is defined", async () => { - await _component.logIn(code, codeVerifier, orgIdFromState); - - expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith( - [_component.trustedDeviceEncRoute], - undefined, - ); - expect(mockLogService.error).not.toHaveBeenCalled(); - }); - - it("calls onSuccessfulLoginTdeNavigate instead of router.navigate when the callback is defined", async () => { - mockOnSuccessfulLoginTdeNavigate = jest.fn().mockResolvedValue(null); - component.onSuccessfulLoginTdeNavigate = mockOnSuccessfulLoginTdeNavigate; - - await _component.logIn(code, codeVerifier, orgIdFromState); - - expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1); - - expect(mockOnSuccessfulLoginTdeNavigate).toHaveBeenCalledTimes(1); - - expect(mockRouter.navigate).not.toHaveBeenCalled(); - expect(mockLogService.error).not.toHaveBeenCalled(); - }); - }); - }); - - describe("Set Master Password scenarios", () => { - beforeEach(() => { - const authResult = new AuthResult(); - mockLoginStrategyService.logIn.mockResolvedValue(authResult); - }); - - describe("Given user needs to set a master password", () => { - beforeEach(() => { - // Only need to test the case where the user has no master password to test the primary change mp flow here - selectedUserDecryptionOptions.next(mockUserDecryptionOpts.noMasterPassword); - }); - - testChangePasswordOnSuccessfulLogin(); - testOnSuccessfulLoginChangePasswordNavigate(); - }); - - it("does not navigate to the change password route when the user has key connector even if user has no master password", async () => { - selectedUserDecryptionOptions.next(mockUserDecryptionOpts.noMasterPasswordWithKeyConnector); - - await _component.logIn(code, codeVerifier, orgIdFromState); - expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1); - - expect(mockOnSuccessfulLoginChangePasswordNavigate).not.toHaveBeenCalled(); - expect(mockRouter.navigate).not.toHaveBeenCalledWith([_component.changePasswordRoute], { - queryParams: { - identifier: orgIdFromState, - }, - }); - }); - }); - - describe("Force Master Password Reset scenarios", () => { - [ - ForceSetPasswordReason.AdminForcePasswordReset, - // ForceResetPasswordReason.WeakMasterPassword, -- not possible in SSO flow as set client side - ].forEach((forceResetPasswordReason) => { - const reasonString = ForceSetPasswordReason[forceResetPasswordReason]; - - beforeEach(() => { - // use standard user with MP because this test is not concerned with password reset. - selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword); - - const authResult = new AuthResult(); - authResult.forcePasswordReset = forceResetPasswordReason; - mockLoginStrategyService.logIn.mockResolvedValue(authResult); - }); - - testForceResetOnSuccessfulLogin(reasonString); - testOnSuccessfulLoginForceResetNavigate(reasonString); - }); - }); - - describe("Success scenarios", () => { - beforeEach(() => { - const authResult = new AuthResult(); - authResult.twoFactorProviders = null; - // use standard user with MP because this test is not concerned with password reset. - selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword); - authResult.forcePasswordReset = ForceSetPasswordReason.None; - mockLoginStrategyService.logIn.mockResolvedValue(authResult); - }); - - it("calls authService.logIn and navigates to the component's defined success route when the login is successful", async () => { - await _component.logIn(code, codeVerifier, orgIdFromState); - - expect(mockLoginStrategyService.logIn).toHaveBeenCalled(); - - expect(mockOnSuccessfulLoginNavigate).not.toHaveBeenCalled(); - expect(mockOnSuccessfulLogin).not.toHaveBeenCalled(); - - expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith([_component.successRoute], undefined); - expect(mockLogService.error).not.toHaveBeenCalled(); - }); - - it("calls onSuccessfulLogin if defined when login is successful", async () => { - mockOnSuccessfulLogin = jest.fn().mockResolvedValue(null); - component.onSuccessfulLogin = mockOnSuccessfulLogin; - - await _component.logIn(code, codeVerifier, orgIdFromState); - - expect(mockLoginStrategyService.logIn).toHaveBeenCalled(); - expect(mockOnSuccessfulLogin).toHaveBeenCalledTimes(1); - - expect(mockOnSuccessfulLoginNavigate).not.toHaveBeenCalled(); - - expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith([_component.successRoute], undefined); - - expect(mockLogService.error).not.toHaveBeenCalled(); - }); - - it("calls onSuccessfulLoginNavigate instead of router.navigate when login is successful and the callback is defined", async () => { - mockOnSuccessfulLoginNavigate = jest.fn().mockResolvedValue(null); - component.onSuccessfulLoginNavigate = mockOnSuccessfulLoginNavigate; - - await _component.logIn(code, codeVerifier, orgIdFromState); - - expect(mockLoginStrategyService.logIn).toHaveBeenCalled(); - - expect(mockOnSuccessfulLoginNavigate).toHaveBeenCalledTimes(1); - - expect(mockRouter.navigate).not.toHaveBeenCalled(); - - expect(mockLogService.error).not.toHaveBeenCalled(); - }); - }); - - describe("Error scenarios", () => { - it("calls handleLoginError when an error is thrown during logIn", async () => { - const errorMessage = "Key Connector error"; - const error = new Error(errorMessage); - mockLoginStrategyService.logIn.mockRejectedValue(error); - - const handleLoginErrorSpy = jest.spyOn(_component, "handleLoginError"); - - await _component.logIn(code, codeVerifier, orgIdFromState); - - expect(handleLoginErrorSpy).toHaveBeenCalledWith(error); - }); - }); - }); - - describe("handleLoginError(e)", () => { - it("logs the error and shows a toast when the error message is 'Key Connector error'", async () => { - const errorMessage = "Key Connector error"; - const error = new Error(errorMessage); - - mockI18nService.t.mockReturnValueOnce("ssoKeyConnectorError"); - - await _component.handleLoginError(error); - - expect(mockLogService.error).toHaveBeenCalledTimes(1); - expect(mockLogService.error).toHaveBeenCalledWith(error); - - expect(mockToastService.showToast).toHaveBeenCalledTimes(1); - expect(mockToastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: null, - message: "ssoKeyConnectorError", - }); - - expect(mockRouter.navigate).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/libs/angular/src/auth/components/sso.component.ts b/libs/angular/src/auth/components/sso.component.ts deleted file mode 100644 index eda5c06e0ab..00000000000 --- a/libs/angular/src/auth/components/sso.component.ts +++ /dev/null @@ -1,422 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Directive, OnInit } from "@angular/core"; -import { ActivatedRoute, NavigationExtras, Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; -import { first } from "rxjs/operators"; - -import { - LoginStrategyServiceAbstraction, - SsoLoginCredentials, - TrustedDeviceUserDecryptionOption, - UserDecryptionOptions, - UserDecryptionOptionsServiceAbstraction, -} from "@bitwarden/auth/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; -import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; -import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; -import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response"; -import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.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 { ToastService } from "@bitwarden/components"; -import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; - -@Directive() -export class SsoComponent implements OnInit { - identifier: string; - loggingIn = false; - - formPromise: Promise; - initiateSsoFormPromise: Promise; - onSuccessfulLogin: () => Promise; - onSuccessfulLoginNavigate: () => Promise; - onSuccessfulLoginTwoFactorNavigate: () => Promise; - onSuccessfulLoginChangePasswordNavigate: () => Promise; - onSuccessfulLoginForceResetNavigate: () => Promise; - - onSuccessfulLoginTde: () => Promise; - onSuccessfulLoginTdeNavigate: () => Promise; - - protected twoFactorRoute = "2fa"; - protected successRoute = "lock"; - protected trustedDeviceEncRoute = "login-initiated"; - protected changePasswordRoute = "set-password-jit"; - protected forcePasswordResetRoute = "update-temp-password"; - protected clientId: string; - protected redirectUri: string; - protected state: string; - protected codeChallenge: string; - - constructor( - protected ssoLoginService: SsoLoginServiceAbstraction, - protected loginStrategyService: LoginStrategyServiceAbstraction, - protected router: Router, - protected i18nService: I18nService, - protected route: ActivatedRoute, - protected stateService: StateService, - protected platformUtilsService: PlatformUtilsService, - protected apiService: ApiService, - protected cryptoFunctionService: CryptoFunctionService, - protected environmentService: EnvironmentService, - protected passwordGenerationService: PasswordGenerationServiceAbstraction, - protected logService: LogService, - protected userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, - protected configService: ConfigService, - protected masterPasswordService: InternalMasterPasswordServiceAbstraction, - protected accountService: AccountService, - protected toastService: ToastService, - ) {} - - async ngOnInit() { - // eslint-disable-next-line rxjs/no-async-subscribe - this.route.queryParams.pipe(first()).subscribe(async (qParams) => { - if (qParams.code != null && qParams.state != null) { - const codeVerifier = await this.ssoLoginService.getCodeVerifier(); - const state = await this.ssoLoginService.getSsoState(); - await this.ssoLoginService.setCodeVerifier(null); - await this.ssoLoginService.setSsoState(null); - - if (qParams.redirectUri != null) { - this.redirectUri = qParams.redirectUri; - } - - if ( - qParams.code != null && - codeVerifier != null && - state != null && - this.checkState(state, qParams.state) - ) { - const ssoOrganizationIdentifier = this.getOrgIdentifierFromState(qParams.state); - await this.logIn(qParams.code, codeVerifier, ssoOrganizationIdentifier); - } - } else if ( - qParams.clientId != null && - qParams.redirectUri != null && - qParams.state != null && - qParams.codeChallenge != null - ) { - this.redirectUri = qParams.redirectUri; - this.state = qParams.state; - this.codeChallenge = qParams.codeChallenge; - this.clientId = qParams.clientId; - } - }); - } - - async submit(returnUri?: string, includeUserIdentifier?: boolean) { - if (this.identifier == null || this.identifier === "") { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("ssoValidationFailed"), - message: this.i18nService.t("ssoIdentifierRequired"), - }); - return; - } - - this.initiateSsoFormPromise = this.apiService.preValidateSso(this.identifier); - const response = await this.initiateSsoFormPromise; - - const authorizeUrl = await this.buildAuthorizeUrl( - returnUri, - includeUserIdentifier, - response.token, - ); - this.platformUtilsService.launchUri(authorizeUrl, { sameWindow: true }); - } - - protected async buildAuthorizeUrl( - returnUri?: string, - includeUserIdentifier?: boolean, - token?: string, - ): Promise { - let codeChallenge = this.codeChallenge; - let state = this.state; - - const passwordOptions: any = { - type: "password", - length: 64, - uppercase: true, - lowercase: true, - numbers: true, - special: false, - }; - - if (codeChallenge == null) { - const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); - const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256"); - codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash); - await this.ssoLoginService.setCodeVerifier(codeVerifier); - } - - if (state == null) { - state = await this.passwordGenerationService.generatePassword(passwordOptions); - if (returnUri) { - state += `_returnUri='${returnUri}'`; - } - } - - // Add Organization Identifier to state - state += `_identifier=${this.identifier}`; - - // Save state (regardless of new or existing) - await this.ssoLoginService.setSsoState(state); - - const env = await firstValueFrom(this.environmentService.environment$); - - let authorizeUrl = - env.getIdentityUrl() + - "/connect/authorize?" + - "client_id=" + - this.clientId + - "&redirect_uri=" + - encodeURIComponent(this.redirectUri) + - "&" + - "response_type=code&scope=api offline_access&" + - "state=" + - state + - "&code_challenge=" + - codeChallenge + - "&" + - "code_challenge_method=S256&response_mode=query&" + - "domain_hint=" + - encodeURIComponent(this.identifier) + - "&ssoToken=" + - encodeURIComponent(token); - - if (includeUserIdentifier) { - const userIdentifier = await this.apiService.getSsoUserIdentifier(); - authorizeUrl += `&user_identifier=${encodeURIComponent(userIdentifier)}`; - } - - return authorizeUrl; - } - - private async logIn(code: string, codeVerifier: string, orgSsoIdentifier: string): Promise { - this.loggingIn = true; - try { - const email = await this.ssoLoginService.getSsoEmail(); - - const credentials = new SsoLoginCredentials( - code, - codeVerifier, - this.redirectUri, - orgSsoIdentifier, - email, - ); - this.formPromise = this.loginStrategyService.logIn(credentials); - const authResult = await this.formPromise; - - if (authResult.requiresTwoFactor) { - return await this.handleTwoFactorRequired(orgSsoIdentifier); - } - - // Everything after the 2FA check is considered a successful login - // Just have to figure out where to send the user - - // Save off the OrgSsoIdentifier for use in the TDE flows (or elsewhere) - // - TDE login decryption options component - // - Browser SSO on extension open - // Note: you cannot set this in state before 2FA b/c there won't be an account in state. - const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(orgSsoIdentifier, userId); - - // Users enrolled in admin acct recovery can be forced to set a new password after - // having the admin set a temp password for them (affects TDE & standard users) - if (authResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) { - // Weak password is not a valid scenario here b/c we cannot have evaluated a MP yet - return await this.handleForcePasswordReset(orgSsoIdentifier); - } - - // must come after 2fa check since user decryption options aren't available if 2fa is required - const userDecryptionOpts = await firstValueFrom( - this.userDecryptionOptionsService.userDecryptionOptions$, - ); - - const tdeEnabled = await this.isTrustedDeviceEncEnabled( - userDecryptionOpts.trustedDeviceOption, - ); - - if (tdeEnabled) { - return await this.handleTrustedDeviceEncryptionEnabled( - authResult, - orgSsoIdentifier, - userDecryptionOpts, - ); - } - - // In the standard, non TDE case, a user must set password if they don't - // have one and they aren't using key connector. - // Note: TDE & Key connector are mutually exclusive org config options. - const requireSetPassword = - !userDecryptionOpts.hasMasterPassword && - userDecryptionOpts.keyConnectorOption === undefined; - - if (requireSetPassword || authResult.resetMasterPassword) { - // Change implies going no password -> password in this case - return await this.handleChangePasswordRequired(orgSsoIdentifier); - } - - // Standard SSO login success case - return await this.handleSuccessfulLogin(); - } catch (e) { - await this.handleLoginError(e); - } - } - - private async isTrustedDeviceEncEnabled( - trustedDeviceOption: TrustedDeviceUserDecryptionOption, - ): Promise { - return trustedDeviceOption !== undefined; - } - - private async handleTwoFactorRequired(orgIdentifier: string) { - await this.navigateViaCallbackOrRoute( - this.onSuccessfulLoginTwoFactorNavigate, - [this.twoFactorRoute], - { - queryParams: { - identifier: orgIdentifier, - sso: "true", - }, - }, - ); - } - - private async handleTrustedDeviceEncryptionEnabled( - authResult: AuthResult, - orgIdentifier: string, - userDecryptionOpts: UserDecryptionOptions, - ): Promise { - // Tde offboarding takes precedence - if ( - !userDecryptionOpts.hasMasterPassword && - userDecryptionOpts.trustedDeviceOption.isTdeOffboarding - ) { - const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - await this.masterPasswordService.setForceSetPasswordReason( - ForceSetPasswordReason.TdeOffboarding, - userId, - ); - } else if ( - // If user doesn't have a MP, but has reset password permission, they must set a MP - !userDecryptionOpts.hasMasterPassword && - userDecryptionOpts.trustedDeviceOption.hasManageResetPasswordPermission - ) { - // Set flag so that auth guard can redirect to set password screen after decryption (trusted or untrusted device) - // Note: we cannot directly navigate in this scenario as we are in a pre-decryption state, and - // if you try to set a new MP before decrypting, you will invalidate the user's data by making a new user key. - const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - await this.masterPasswordService.setForceSetPasswordReason( - ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, - userId, - ); - } - - if (this.onSuccessfulLoginTde != null) { - // Don't await b/c causes hang on desktop & browser - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.onSuccessfulLoginTde(); - } - - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.navigateViaCallbackOrRoute( - this.onSuccessfulLoginTdeNavigate, - // Navigate to TDE page (if user was on trusted device and TDE has decrypted - // their user key, the login-initiated guard will redirect them to the vault) - [this.trustedDeviceEncRoute], - ); - } - - private async handleChangePasswordRequired(orgIdentifier: string) { - await this.navigateViaCallbackOrRoute( - this.onSuccessfulLoginChangePasswordNavigate, - [this.changePasswordRoute], - { - queryParams: { - identifier: orgIdentifier, - }, - }, - ); - } - - private async handleForcePasswordReset(orgIdentifier: string) { - await this.navigateViaCallbackOrRoute( - this.onSuccessfulLoginForceResetNavigate, - [this.forcePasswordResetRoute], - { - queryParams: { - identifier: orgIdentifier, - }, - }, - ); - } - - private async handleSuccessfulLogin() { - if (this.onSuccessfulLogin != null) { - // Don't await b/c causes hang on desktop & browser - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.onSuccessfulLogin(); - } - - await this.navigateViaCallbackOrRoute(this.onSuccessfulLoginNavigate, [this.successRoute]); - } - - private async handleLoginError(e: any) { - this.logService.error(e); - - // TODO: Key Connector Service should pass this error message to the logout callback instead of displaying here - if (e.message === "Key Connector error") { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("ssoKeyConnectorError"), - }); - } - } - - private async navigateViaCallbackOrRoute( - callback: () => Promise, - commands: unknown[], - extras?: NavigationExtras, - ): Promise { - if (callback) { - await callback(); - } else { - await this.router.navigate(commands, extras); - } - } - - private getOrgIdentifierFromState(state: string): string { - if (state === null || state === undefined) { - return null; - } - - const stateSplit = state.split("_identifier="); - return stateSplit.length > 1 ? stateSplit[1] : null; - } - - private checkState(state: string, checkState: string): boolean { - if (state === null || state === undefined) { - return false; - } - if (checkState === null || checkState === undefined) { - return false; - } - - const stateSplit = state.split("_identifier="); - const checkStateSplit = checkState.split("_identifier="); - return stateSplit[0] === checkStateSplit[0]; - } -}