From 46046ca1fa796f7d8a8a4bc2cced97c1b17b915c Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Thu, 7 Aug 2025 08:19:35 -0700 Subject: [PATCH] fix(change-password-prompt) [Auth/PM-22356] Scope org invite email to submitted email (#15783) Adds a check to make sure that the email on the Org Invite matches the email submitted in the form. If it matches, only then do we apply the org invite to get the MP policies. But if the emails do not match, it means the user attempting to login is no longer the user who originally clicked the emailed org invite link. Therefore, we clear the Org Invite + Deep Link and allow the user to login as normal. --- .../login/web-login-component.service.spec.ts | 76 +++++++++++++------ .../login/web-login-component.service.ts | 19 ++++- .../angular/login/login-component.service.ts | 2 +- .../auth/src/angular/login/login.component.ts | 37 ++++----- 4 files changed, 88 insertions(+), 46 deletions(-) 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; } }