diff --git a/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts b/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts index 799e10bc15c..8edf98e569e 100644 --- a/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts +++ b/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts @@ -9,6 +9,7 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { ResetPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/reset-password-policy-options"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite"; import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -86,26 +87,29 @@ describe("WebLoginComponentService", () => { }); describe("getOrgPoliciesFromOrgInvite", () => { + const mockEmail = "test@example.com"; + const orgInvite: OrganizationInvite = { + organizationId: "org-id", + token: "token", + email: mockEmail, + organizationUserId: "org-user-id", + initOrganization: false, + orgSsoIdentifier: "sso-id", + orgUserHasExistingUser: false, + organizationName: "org-name", + }; + it("returns undefined if organization invite is null", async () => { organizationInviteService.getOrganizationInvite.mockResolvedValue(null); - const result = await service.getOrgPoliciesFromOrgInvite(); + const result = await service.getOrgPoliciesFromOrgInvite(mockEmail); expect(result).toBeUndefined(); }); it("logs an error if getPoliciesByToken throws an error", async () => { const error = new Error("Test error"); - organizationInviteService.getOrganizationInvite.mockResolvedValue({ - organizationId: "org-id", - token: "token", - email: "email", - organizationUserId: "org-user-id", - initOrganization: false, - orgSsoIdentifier: "sso-id", - orgUserHasExistingUser: false, - organizationName: "org-name", - }); + organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite); policyApiService.getPoliciesByToken.mockRejectedValue(error); - await service.getOrgPoliciesFromOrgInvite(); + await service.getOrgPoliciesFromOrgInvite(mockEmail); expect(logService.error).toHaveBeenCalledWith(error); }); @@ -120,16 +124,7 @@ describe("WebLoginComponentService", () => { const resetPasswordPolicyOptions = new ResetPasswordPolicyOptions(); resetPasswordPolicyOptions.autoEnrollEnabled = autoEnrollEnabled; - organizationInviteService.getOrganizationInvite.mockResolvedValue({ - organizationId: "org-id", - token: "token", - email: "email", - organizationUserId: "org-user-id", - initOrganization: false, - orgSsoIdentifier: "sso-id", - orgUserHasExistingUser: false, - organizationName: "org-name", - }); + organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite); policyApiService.getPoliciesByToken.mockResolvedValue(policies); internalPolicyService.getResetPasswordPolicyOptions.mockReturnValue([ @@ -141,7 +136,7 @@ describe("WebLoginComponentService", () => { masterPasswordPolicyOptions, ); - const result = await service.getOrgPoliciesFromOrgInvite(); + const result = await service.getOrgPoliciesFromOrgInvite(mockEmail); expect(result).toEqual({ policies: policies, @@ -151,5 +146,40 @@ describe("WebLoginComponentService", () => { }); }, ); + + describe("given the orgInvite email does not match the provided email", () => { + const mockMismatchedEmail = "mismatched@example.com"; + it("should clear the login redirect URL and organization invite", async () => { + // Arrange + organizationInviteService.getOrganizationInvite.mockResolvedValue({ + ...orgInvite, + email: mockMismatchedEmail, + }); + + // Act + await service.getOrgPoliciesFromOrgInvite(mockEmail); + + // Assert + expect(routerService.getAndClearLoginRedirectUrl).toHaveBeenCalledTimes(1); + expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalledTimes(1); + }); + + it("should log an error and return undefined", async () => { + // Arrange + organizationInviteService.getOrganizationInvite.mockResolvedValue({ + ...orgInvite, + email: mockMismatchedEmail, + }); + + // Act + const result = await service.getOrgPoliciesFromOrgInvite(mockEmail); + + // Assert + expect(logService.error).toHaveBeenCalledWith( + `WebLoginComponentService.getOrgPoliciesFromOrgInvite: Email mismatch. Expected: ${mockMismatchedEmail}, Received: ${mockEmail}`, + ); + expect(result).toBeUndefined(); + }); + }); }); }); diff --git a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts index 4ee84ecfde2..5bea0908b0a 100644 --- a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts +++ b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts @@ -66,10 +66,27 @@ export class WebLoginComponentService return; } - async getOrgPoliciesFromOrgInvite(): Promise { + async getOrgPoliciesFromOrgInvite(email: string): Promise { const orgInvite = await this.organizationInviteService.getOrganizationInvite(); if (orgInvite != null) { + /** + * Check if the email on the org invite matches the email submitted in the login form. This is + * important because say userA at "userA@mail.com" clicks an emailed org invite link, but then + * on the login page form they change the email to "userB@mail.com". We don't want to apply the org + * invite in state to userB. Therefore we clear the login redirect url as well as the org invite, + * allowing userB to login as normal. + */ + if (orgInvite.email !== email.toLowerCase()) { + await this.routerService.getAndClearLoginRedirectUrl(); + await this.organizationInviteService.clearOrganizationInvitation(); + + this.logService.error( + `WebLoginComponentService.getOrgPoliciesFromOrgInvite: Email mismatch. Expected: ${orgInvite.email}, Received: ${email}`, + ); + return undefined; + } + let policies: Policy[]; try { diff --git a/libs/auth/src/angular/login/login-component.service.ts b/libs/auth/src/angular/login/login-component.service.ts index 796a01c71c3..5ca83c97c5f 100644 --- a/libs/auth/src/angular/login/login-component.service.ts +++ b/libs/auth/src/angular/login/login-component.service.ts @@ -23,7 +23,7 @@ export abstract class LoginComponentService { * Gets the organization policies if there is an organization invite. * - Used by: Web */ - getOrgPoliciesFromOrgInvite?: () => Promise; + getOrgPoliciesFromOrgInvite?: (email: string) => Promise; /** * Indicates whether login with passkey is supported on the given client diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index 2a2be148a86..f2954933bc6 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -80,6 +80,7 @@ export class LoginComponent implements OnInit, OnDestroy { clientType: ClientType; ClientType = ClientType; + orgPoliciesFromInvite: PasswordPolicies | null = null; LoginUiState = LoginUiState; isKnownDevice = false; loginUiState: LoginUiState = LoginUiState.EMAIL_ENTRY; @@ -232,11 +233,12 @@ export class LoginComponent implements OnInit, OnDestroy { // Try to retrieve any org policies from an org invite now so we can send it to the // login strategies. Since it is optional and we only want to be doing this on the // web we will only send in content in the right context. - const orgPoliciesFromInvite = this.loginComponentService.getOrgPoliciesFromOrgInvite - ? await this.loginComponentService.getOrgPoliciesFromOrgInvite() + this.orgPoliciesFromInvite = this.loginComponentService.getOrgPoliciesFromOrgInvite + ? await this.loginComponentService.getOrgPoliciesFromOrgInvite(email) : null; - const orgMasterPasswordPolicyOptions = orgPoliciesFromInvite?.enforcedPasswordPolicyOptions; + const orgMasterPasswordPolicyOptions = + this.orgPoliciesFromInvite?.enforcedPasswordPolicyOptions; const credentials = new PasswordLoginCredentials( email, @@ -327,25 +329,18 @@ export class LoginComponent implements OnInit, OnDestroy { // TODO: PM-18269 - evaluate if we can combine this with the // password evaluation done in the password login strategy. - // If there's an existing org invite, use it to get the org's password policies - // so we can evaluate the MP against the org policies - if (this.loginComponentService.getOrgPoliciesFromOrgInvite) { - const orgPolicies: PasswordPolicies | null = - await this.loginComponentService.getOrgPoliciesFromOrgInvite(); + if (this.orgPoliciesFromInvite) { + // Since we have retrieved the policies, we can go ahead and set them into state for future use + // e.g., the change-password page currently only references state for policy data and + // doesn't fallback to pulling them from the server like it should if they are null. + await this.setPoliciesIntoState(authResult.userId, this.orgPoliciesFromInvite.policies); - if (orgPolicies) { - // Since we have retrieved the policies, we can go ahead and set them into state for future use - // e.g., the change-password page currently only references state for policy data and - // doesn't fallback to pulling them from the server like it should if they are null. - await this.setPoliciesIntoState(authResult.userId, orgPolicies.policies); - - const isPasswordChangeRequired = await this.isPasswordChangeRequiredByOrgPolicy( - orgPolicies.enforcedPasswordPolicyOptions, - ); - if (isPasswordChangeRequired) { - await this.router.navigate(["change-password"]); - return; - } + const isPasswordChangeRequired = await this.isPasswordChangeRequiredByOrgPolicy( + this.orgPoliciesFromInvite.enforcedPasswordPolicyOptions, + ); + if (isPasswordChangeRequired) { + await this.router.navigate(["change-password"]); + return; } }