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.
This commit is contained in:
rr-bw 2025-08-07 08:19:35 -07:00 committed by GitHub
parent 1ffe8e433f
commit 46046ca1fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 88 additions and 46 deletions

View File

@ -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();
});
});
});
});

View File

@ -66,10 +66,27 @@ export class WebLoginComponentService
return;
}
async getOrgPoliciesFromOrgInvite(): Promise<PasswordPolicies | undefined> {
async getOrgPoliciesFromOrgInvite(email: string): Promise<PasswordPolicies | undefined> {
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 {

View File

@ -23,7 +23,7 @@ export abstract class LoginComponentService {
* Gets the organization policies if there is an organization invite.
* - Used by: Web
*/
getOrgPoliciesFromOrgInvite?: () => Promise<PasswordPolicies | null>;
getOrgPoliciesFromOrgInvite?: (email: string) => Promise<PasswordPolicies | null>;
/**
* Indicates whether login with passkey is supported on the given client

View File

@ -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;
}
}