mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
feat(prelogin): [Auth/PM-23801] Move Prelogin Request (#17080)
* feat(prelogin): [PM-23801] Move Prelogin Request - Initial implementation. * test(prelogin): [PM-23801] Move Prelogin Request - Removed unneeded test.
This commit is contained in:
committed by
GitHub
parent
f7899991a0
commit
5aa6d38d80
@@ -1,4 +1,4 @@
|
||||
<!--
|
||||
<!--
|
||||
# Table of Contents
|
||||
|
||||
This file contains a single consolidated template for all visual clients.
|
||||
|
||||
102
libs/auth/src/angular/login/login.component.spec.ts
Normal file
102
libs/auth/src/angular/login/login.component.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { FormBuilder } from "@angular/forms";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { LoginComponent } from "./login.component";
|
||||
|
||||
describe("LoginComponent continue() integration", () => {
|
||||
function createComponent({ flagEnabled }: { flagEnabled: boolean }) {
|
||||
const activatedRoute: any = { queryParams: { subscribe: () => {} } };
|
||||
const anonLayoutWrapperDataService: any = { setAnonLayoutWrapperData: () => {} };
|
||||
const appIdService: any = {};
|
||||
const broadcasterService: any = { subscribe: () => {}, unsubscribe: () => {} };
|
||||
const destroyRef: any = {};
|
||||
const devicesApiService: any = {};
|
||||
const formBuilder = new FormBuilder();
|
||||
const i18nService: any = { t: () => "" };
|
||||
const loginEmailService: any = {
|
||||
rememberedEmail$: { pipe: () => ({}) },
|
||||
setLoginEmail: async () => {},
|
||||
setRememberedEmailChoice: async () => {},
|
||||
clearLoginEmail: async () => {},
|
||||
};
|
||||
const loginComponentService: any = {
|
||||
showBackButton: () => {},
|
||||
isLoginWithPasskeySupported: () => false,
|
||||
redirectToSsoLogin: async () => {},
|
||||
};
|
||||
const loginStrategyService = mock<LoginStrategyServiceAbstraction>();
|
||||
const messagingService: any = { send: () => {} };
|
||||
const ngZone: any = { isStable: true, onStable: { pipe: () => ({ subscribe: () => {} }) } };
|
||||
const passwordStrengthService: any = {};
|
||||
const platformUtilsService = mock<PlatformUtilsService>();
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Browser);
|
||||
const policyService: any = { replace: async () => {}, evaluateMasterPassword: () => true };
|
||||
const router: any = { navigate: async () => {}, navigateByUrl: async () => {} };
|
||||
const toastService: any = { showToast: () => {} };
|
||||
const logService: any = { error: () => {} };
|
||||
const validationService: any = { showError: () => {} };
|
||||
const loginSuccessHandlerService: any = { run: async () => {} };
|
||||
const configService = mock<ConfigService>();
|
||||
configService.getFeatureFlag.mockResolvedValue(flagEnabled);
|
||||
const ssoLoginService: any = { ssoRequiredCache$: { pipe: () => ({}) } };
|
||||
const environmentService: any = { environment$: { pipe: () => ({}) } };
|
||||
|
||||
const component = new LoginComponent(
|
||||
activatedRoute,
|
||||
anonLayoutWrapperDataService,
|
||||
appIdService,
|
||||
broadcasterService,
|
||||
destroyRef,
|
||||
devicesApiService,
|
||||
formBuilder,
|
||||
i18nService,
|
||||
loginEmailService,
|
||||
loginComponentService,
|
||||
loginStrategyService,
|
||||
messagingService,
|
||||
ngZone,
|
||||
passwordStrengthService,
|
||||
platformUtilsService,
|
||||
policyService,
|
||||
router,
|
||||
toastService,
|
||||
logService,
|
||||
validationService,
|
||||
loginSuccessHandlerService,
|
||||
configService,
|
||||
ssoLoginService,
|
||||
environmentService,
|
||||
);
|
||||
|
||||
jest.spyOn(component as any, "toggleLoginUiState").mockResolvedValue(undefined);
|
||||
|
||||
return { component, loginStrategyService };
|
||||
}
|
||||
|
||||
it("calls getPasswordPrelogin on continue when flag enabled and email valid", async () => {
|
||||
const { component, loginStrategyService } = createComponent({ flagEnabled: true });
|
||||
(component as any).formGroup.controls.email.setValue("user@example.com");
|
||||
(component as any).formGroup.controls.rememberEmail.setValue(false);
|
||||
(component as any).formGroup.controls.masterPassword.setValue("irrelevant");
|
||||
|
||||
await (component as any).continue();
|
||||
|
||||
expect(loginStrategyService.getPasswordPrelogin).toHaveBeenCalledWith("user@example.com");
|
||||
});
|
||||
|
||||
it("does not call getPasswordPrelogin when flag disabled", async () => {
|
||||
const { component, loginStrategyService } = createComponent({ flagEnabled: false });
|
||||
(component as any).formGroup.controls.email.setValue("user@example.com");
|
||||
(component as any).formGroup.controls.rememberEmail.setValue(false);
|
||||
(component as any).formGroup.controls.masterPassword.setValue("irrelevant");
|
||||
|
||||
await (component as any).continue();
|
||||
|
||||
expect(loginStrategyService.getPasswordPrelogin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -550,6 +550,8 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
const isEmailValid = this.validateEmail();
|
||||
|
||||
if (isEmailValid) {
|
||||
await this.makePasswordPreloginCall();
|
||||
|
||||
await this.toggleLoginUiState(LoginUiState.MASTER_PASSWORD_ENTRY);
|
||||
}
|
||||
}
|
||||
@@ -652,6 +654,23 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
history.back();
|
||||
}
|
||||
|
||||
private async makePasswordPreloginCall() {
|
||||
// Prefetch prelogin KDF config when enabled
|
||||
try {
|
||||
const flagEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM23801_PrefetchPasswordPrelogin,
|
||||
);
|
||||
if (flagEnabled) {
|
||||
const email = this.formGroup.value.email;
|
||||
if (email) {
|
||||
void this.loginStrategyService.getPasswordPrelogin(email);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.error("Failed to prefetch prelogin data.", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the popstate event to transition back to the email entry state when the back button is clicked.
|
||||
* Also handles the case where the user clicks the forward button.
|
||||
|
||||
@@ -65,7 +65,11 @@ export abstract class LoginStrategyServiceAbstraction {
|
||||
/**
|
||||
* Creates a master key from the provided master password and email.
|
||||
*/
|
||||
abstract makePreloginKey(masterPassword: string, email: string): Promise<MasterKey>;
|
||||
abstract makePasswordPreLoginMasterKey(masterPassword: string, email: string): Promise<MasterKey>;
|
||||
/**
|
||||
* Prefetch and cache the KDF configuration for the given email. No-op if already in-flight or cached.
|
||||
*/
|
||||
abstract getPasswordPrelogin(email: string): Promise<void>;
|
||||
/**
|
||||
* Emits true if the authentication session has expired.
|
||||
*/
|
||||
|
||||
@@ -119,7 +119,7 @@ describe("PasswordLoginStrategy", () => {
|
||||
sub: userId,
|
||||
});
|
||||
|
||||
loginStrategyService.makePreloginKey.mockResolvedValue(masterKey);
|
||||
loginStrategyService.makePasswordPreLoginMasterKey.mockResolvedValue(masterKey);
|
||||
|
||||
keyService.hashMasterKey
|
||||
.calledWith(masterPassword, expect.anything(), undefined)
|
||||
|
||||
@@ -79,7 +79,10 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
||||
const { email, masterPassword, twoFactor } = credentials;
|
||||
|
||||
const data = new PasswordLoginStrategyData();
|
||||
data.masterKey = await this.loginStrategyService.makePreloginKey(masterPassword, email);
|
||||
data.masterKey = await this.loginStrategyService.makePasswordPreLoginMasterKey(
|
||||
masterPassword,
|
||||
email,
|
||||
);
|
||||
data.userEnteredEmail = email;
|
||||
|
||||
// Hash the password early (before authentication) so we don't persist it in memory in plaintext
|
||||
|
||||
@@ -37,7 +37,13 @@ import {
|
||||
} from "@bitwarden/common/spec";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KdfConfigService, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
import {
|
||||
Argon2KdfConfig,
|
||||
KdfConfigService,
|
||||
KdfType,
|
||||
KeyService,
|
||||
PBKDF2KdfConfig,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import {
|
||||
AuthRequestServiceAbstraction,
|
||||
@@ -158,6 +164,321 @@ describe("LoginStrategyService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("PM23801_PrefetchPasswordPrelogin", () => {
|
||||
describe("Flag On", () => {
|
||||
it("prefetches and caches KDF, then makePrePasswordLoginMasterKey uses cached", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const email = "a@a.com";
|
||||
apiService.postPrelogin.mockResolvedValue(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.PBKDF2_SHA256,
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||
}),
|
||||
);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
await sut.getPasswordPrelogin(email);
|
||||
|
||||
await sut.makePasswordPreLoginMasterKey("pw", email);
|
||||
|
||||
expect(apiService.postPrelogin).toHaveBeenCalledTimes(1);
|
||||
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||
expect(calls[0][2]).toBeInstanceOf(PBKDF2KdfConfig);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
"pw",
|
||||
email.trim().toLowerCase(),
|
||||
expect.any(PBKDF2KdfConfig),
|
||||
);
|
||||
});
|
||||
|
||||
it("awaits in-flight prelogin promise in makePrePasswordLoginMasterKey", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const email = "a@a.com";
|
||||
let resolveFn: (v: any) => void;
|
||||
const deferred = new Promise<PreloginResponse>((resolve) => (resolveFn = resolve));
|
||||
apiService.postPrelogin.mockReturnValue(deferred as any);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
void sut.getPasswordPrelogin(email);
|
||||
|
||||
const makeKeyPromise = sut.makePasswordPreLoginMasterKey("pw", email);
|
||||
|
||||
// Resolve after makePrePasswordLoginMasterKey has started awaiting
|
||||
resolveFn!(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.PBKDF2_SHA256,
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||
}),
|
||||
);
|
||||
|
||||
await makeKeyPromise;
|
||||
|
||||
expect(apiService.postPrelogin).toHaveBeenCalledTimes(1);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
"pw",
|
||||
email,
|
||||
expect.any(PBKDF2KdfConfig),
|
||||
);
|
||||
});
|
||||
|
||||
it("no cache and no in-flight request", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const email = "a@a.com";
|
||||
apiService.postPrelogin.mockResolvedValue(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.PBKDF2_SHA256,
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||
}),
|
||||
);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
await sut.makePasswordPreLoginMasterKey("pw", email);
|
||||
|
||||
expect(apiService.postPrelogin).toHaveBeenCalledTimes(1);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
"pw",
|
||||
email,
|
||||
expect.any(PBKDF2KdfConfig),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to API call when prefetched email differs", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const emailPrefetched = "a@a.com";
|
||||
const emailUsed = "b@b.com";
|
||||
|
||||
// Prefetch for A
|
||||
apiService.postPrelogin.mockResolvedValueOnce(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.PBKDF2_SHA256,
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||
}),
|
||||
);
|
||||
await sut.getPasswordPrelogin(emailPrefetched);
|
||||
|
||||
// makePrePasswordLoginMasterKey for B (forces new API call) -> Argon2
|
||||
apiService.postPrelogin.mockResolvedValueOnce(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.Argon2id,
|
||||
KdfIterations: 2,
|
||||
KdfMemory: 16,
|
||||
KdfParallelism: 1,
|
||||
}),
|
||||
);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
await sut.makePasswordPreLoginMasterKey("pw", emailUsed);
|
||||
|
||||
expect(apiService.postPrelogin).toHaveBeenCalledTimes(2);
|
||||
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||
expect(calls[calls.length - 1][2]).toBeInstanceOf(Argon2KdfConfig);
|
||||
});
|
||||
|
||||
it("ignores stale prelogin resolution for older email (versioning)", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const emailA = "a@a.com";
|
||||
const emailB = "b@b.com";
|
||||
|
||||
let resolveA!: (v: any) => void;
|
||||
let resolveB!: (v: any) => void;
|
||||
const deferredA = new Promise<PreloginResponse>((res) => (resolveA = res));
|
||||
const deferredB = new Promise<PreloginResponse>((res) => (resolveB = res));
|
||||
|
||||
// First call returns A, second returns B
|
||||
apiService.postPrelogin.mockImplementationOnce(() => deferredA as any);
|
||||
apiService.postPrelogin.mockImplementationOnce(() => deferredB as any);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
// Start A prefetch, then B prefetch (B supersedes A)
|
||||
void sut.getPasswordPrelogin(emailA);
|
||||
void sut.getPasswordPrelogin(emailB);
|
||||
|
||||
// Resolve A (stale) to PBKDF2, then B to Argon2
|
||||
resolveA(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.PBKDF2_SHA256,
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||
}),
|
||||
);
|
||||
resolveB(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.Argon2id,
|
||||
KdfIterations: 2,
|
||||
KdfMemory: 16,
|
||||
KdfParallelism: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
await sut.makePasswordPreLoginMasterKey("pwB", emailB);
|
||||
|
||||
// Ensure B's Argon2 config is used and stale A doesn't overwrite
|
||||
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||
const argB = calls.find((c) => c[0] === "pwB")[2];
|
||||
expect(argB).toBeInstanceOf(Argon2KdfConfig);
|
||||
});
|
||||
|
||||
it("handles concurrent getPasswordPrelogin calls for same email; uses latest result", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const email = "a@a.com";
|
||||
let resolve1!: (v: any) => void;
|
||||
let resolve2!: (v: any) => void;
|
||||
const deferred1 = new Promise<PreloginResponse>((res) => (resolve1 = res));
|
||||
const deferred2 = new Promise<PreloginResponse>((res) => (resolve2 = res));
|
||||
|
||||
apiService.postPrelogin.mockImplementationOnce(() => deferred1 as any);
|
||||
apiService.postPrelogin.mockImplementationOnce(() => deferred2 as any);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
void sut.getPasswordPrelogin(email);
|
||||
void sut.getPasswordPrelogin(email);
|
||||
|
||||
// First resolves to PBKDF2, second resolves to Argon2 (latest wins)
|
||||
resolve1(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.PBKDF2_SHA256,
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||
}),
|
||||
);
|
||||
resolve2(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.Argon2id,
|
||||
KdfIterations: 2,
|
||||
KdfMemory: 16,
|
||||
KdfParallelism: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
await sut.makePasswordPreLoginMasterKey("pw", email);
|
||||
|
||||
expect(apiService.postPrelogin).toHaveBeenCalledTimes(2);
|
||||
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||
expect(calls[0][2]).toBeInstanceOf(Argon2KdfConfig);
|
||||
});
|
||||
|
||||
it("does not throw when prefetch network error occurs; fallback works in makePrePasswordLoginMasterKey", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const email = "a@a.com";
|
||||
|
||||
// Prefetch throws non-404 error
|
||||
const err: any = new Error("network");
|
||||
err.statusCode = 500;
|
||||
apiService.postPrelogin.mockRejectedValueOnce(err);
|
||||
|
||||
await expect(sut.getPasswordPrelogin(email)).resolves.toBeUndefined();
|
||||
|
||||
// makePrePasswordLoginMasterKey falls back to a new API call which succeeds
|
||||
apiService.postPrelogin.mockResolvedValueOnce(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.PBKDF2_SHA256,
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||
}),
|
||||
);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
await sut.makePasswordPreLoginMasterKey("pw", email);
|
||||
|
||||
expect(apiService.postPrelogin).toHaveBeenCalledTimes(2);
|
||||
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||
expect(calls[0][2]).toBeInstanceOf(PBKDF2KdfConfig);
|
||||
});
|
||||
|
||||
it("treats 404 as null prefetch and falls back in makePrePasswordLoginMasterKey", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const email = "a@a.com";
|
||||
|
||||
const notFound: any = new Error("not found");
|
||||
notFound.statusCode = 404;
|
||||
apiService.postPrelogin.mockRejectedValueOnce(notFound);
|
||||
|
||||
await sut.getPasswordPrelogin(email);
|
||||
|
||||
// Fallback call on makePrePasswordLoginMasterKey
|
||||
apiService.postPrelogin.mockResolvedValueOnce(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.Argon2id,
|
||||
KdfIterations: 2,
|
||||
KdfMemory: 16,
|
||||
KdfParallelism: 1,
|
||||
}),
|
||||
);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
await sut.makePasswordPreLoginMasterKey("pw", email);
|
||||
|
||||
expect(apiService.postPrelogin).toHaveBeenCalledTimes(2);
|
||||
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||
expect(calls[0][2]).toBeInstanceOf(Argon2KdfConfig);
|
||||
});
|
||||
|
||||
it("awaits rejected current prelogin promise and then falls back in makePrePasswordLoginMasterKey", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const email = "a@a.com";
|
||||
const err: any = new Error("network");
|
||||
err.statusCode = 500;
|
||||
let rejectFn!: (e: any) => void;
|
||||
const deferred = new Promise<PreloginResponse>((_res, rej) => (rejectFn = rej));
|
||||
apiService.postPrelogin.mockReturnValueOnce(deferred as any);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
void sut.getPasswordPrelogin(email);
|
||||
const makeKey = sut.makePasswordPreLoginMasterKey("pw", email);
|
||||
|
||||
rejectFn(err);
|
||||
|
||||
// Fallback call succeeds
|
||||
apiService.postPrelogin.mockResolvedValueOnce(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.PBKDF2_SHA256,
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||
}),
|
||||
);
|
||||
|
||||
await makeKey;
|
||||
|
||||
expect(apiService.postPrelogin).toHaveBeenCalledTimes(2);
|
||||
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||
expect(calls[0][2]).toBeInstanceOf(PBKDF2KdfConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Flag Off", () => {
|
||||
// remove when pm-23801 feature flag comes out
|
||||
it("uses legacy API path", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
const email = "a@a.com";
|
||||
// prefetch shouldn't affect behavior when flag off
|
||||
apiService.postPrelogin.mockResolvedValue(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.PBKDF2_SHA256,
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||
}),
|
||||
);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
await sut.getPasswordPrelogin(email);
|
||||
await sut.makePasswordPreLoginMasterKey("pw", email);
|
||||
|
||||
// Called twice: once for prefetch, once for legacy path in makePrePasswordLoginMasterKey
|
||||
expect(apiService.postPrelogin).toHaveBeenCalledTimes(2);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
"pw",
|
||||
email,
|
||||
expect.any(PBKDF2KdfConfig),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should return an AuthResult on successful login", async () => {
|
||||
const credentials = new PasswordLoginCredentials("EMAIL", "MASTER_PASSWORD");
|
||||
apiService.postIdentityToken.mockResolvedValue(
|
||||
|
||||
@@ -18,6 +18,7 @@ import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
@@ -92,6 +93,32 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
private authRequestPushNotificationState: GlobalState<string | null>;
|
||||
private authenticationTimeoutSubject = new BehaviorSubject<boolean>(false);
|
||||
|
||||
// Prefetched password prelogin
|
||||
//
|
||||
// About versioning:
|
||||
// Users can quickly change emails (e.g., continue with user1, go back, continue with user2)
|
||||
// which triggers overlapping async prelogin requests. We use a monotonically increasing
|
||||
// "version" to associate each prelogin attempt with the state at the time it was started.
|
||||
// Only if BOTH the email and the version still match when the promise resolves do we commit
|
||||
// the resulting KDF config or clear the in-flight promise. This prevents stale results from
|
||||
// user1 overwriting user2's state in race conditions.
|
||||
private passwordPrelogin: {
|
||||
email: string | null;
|
||||
kdfConfig: KdfConfig | null;
|
||||
promise: Promise<KdfConfig | null> | null;
|
||||
/**
|
||||
* Version guard for prelogin attempts.
|
||||
* Incremented at the start of getPasswordPrelogin for each new submission.
|
||||
* Used to ignore stale async resolutions when email changes mid-flight.
|
||||
*/
|
||||
version: number;
|
||||
} = {
|
||||
email: null,
|
||||
kdfConfig: null,
|
||||
promise: null,
|
||||
version: 0,
|
||||
};
|
||||
|
||||
authenticationSessionTimeout$: Observable<boolean> =
|
||||
this.authenticationTimeoutSubject.asObservable();
|
||||
|
||||
@@ -308,33 +335,106 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
async makePreloginKey(masterPassword: string, email: string): Promise<MasterKey> {
|
||||
async makePasswordPreLoginMasterKey(masterPassword: string, email: string): Promise<MasterKey> {
|
||||
email = email.trim().toLowerCase();
|
||||
let kdfConfig: KdfConfig | undefined;
|
||||
|
||||
if (await this.configService.getFeatureFlag(FeatureFlag.PM23801_PrefetchPasswordPrelogin)) {
|
||||
let kdfConfig: KdfConfig | null = null;
|
||||
if (this.passwordPrelogin.email === email) {
|
||||
if (this.passwordPrelogin.kdfConfig) {
|
||||
kdfConfig = this.passwordPrelogin.kdfConfig;
|
||||
} else if (this.passwordPrelogin.promise != null) {
|
||||
try {
|
||||
await this.passwordPrelogin.promise;
|
||||
} catch (error) {
|
||||
this.logService.error(
|
||||
"Failed to prefetch prelogin data, falling back to fetching now.",
|
||||
error,
|
||||
);
|
||||
}
|
||||
kdfConfig = this.passwordPrelogin.kdfConfig;
|
||||
}
|
||||
}
|
||||
|
||||
if (!kdfConfig) {
|
||||
try {
|
||||
const preloginResponse = await this.apiService.postPrelogin(new PreloginRequest(email));
|
||||
kdfConfig = this.buildKdfConfigFromPrelogin(preloginResponse);
|
||||
} catch (e: any) {
|
||||
if (e == null || e.statusCode !== 404) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!kdfConfig) {
|
||||
throw new Error("KDF config is required");
|
||||
}
|
||||
kdfConfig.validateKdfConfigForPrelogin();
|
||||
return await this.keyService.makeMasterKey(masterPassword, email, kdfConfig);
|
||||
}
|
||||
|
||||
// Legacy behavior when flag is disabled
|
||||
let legacyKdfConfig: KdfConfig | undefined;
|
||||
try {
|
||||
const preloginResponse = await this.apiService.postPrelogin(new PreloginRequest(email));
|
||||
if (preloginResponse != null) {
|
||||
kdfConfig =
|
||||
preloginResponse.kdf === KdfType.PBKDF2_SHA256
|
||||
? new PBKDF2KdfConfig(preloginResponse.kdfIterations)
|
||||
: new Argon2KdfConfig(
|
||||
preloginResponse.kdfIterations,
|
||||
preloginResponse.kdfMemory,
|
||||
preloginResponse.kdfParallelism,
|
||||
);
|
||||
}
|
||||
legacyKdfConfig = this.buildKdfConfigFromPrelogin(preloginResponse) ?? undefined;
|
||||
} catch (e: any) {
|
||||
if (e == null || e.statusCode !== 404) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (!kdfConfig) {
|
||||
if (!legacyKdfConfig) {
|
||||
throw new Error("KDF config is required");
|
||||
}
|
||||
kdfConfig.validateKdfConfigForPrelogin();
|
||||
legacyKdfConfig.validateKdfConfigForPrelogin();
|
||||
return await this.keyService.makeMasterKey(masterPassword, email, legacyKdfConfig);
|
||||
}
|
||||
|
||||
return await this.keyService.makeMasterKey(masterPassword, email, kdfConfig);
|
||||
async getPasswordPrelogin(email: string): Promise<void> {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const version = ++this.passwordPrelogin.version;
|
||||
|
||||
this.passwordPrelogin.email = normalizedEmail;
|
||||
this.passwordPrelogin.kdfConfig = null;
|
||||
const promise: Promise<KdfConfig | null> = (async () => {
|
||||
try {
|
||||
const preloginResponse = await this.apiService.postPrelogin(
|
||||
new PreloginRequest(normalizedEmail),
|
||||
);
|
||||
return this.buildKdfConfigFromPrelogin(preloginResponse);
|
||||
} catch (e: any) {
|
||||
if (e == null || e.statusCode !== 404) {
|
||||
throw e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
this.passwordPrelogin.promise = promise;
|
||||
promise
|
||||
.then((cfg) => {
|
||||
// Only apply if still for the same email and same version
|
||||
if (
|
||||
this.passwordPrelogin.email === normalizedEmail &&
|
||||
this.passwordPrelogin.version === version &&
|
||||
cfg
|
||||
) {
|
||||
this.passwordPrelogin.kdfConfig = cfg;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// swallow; best-effort prefetch
|
||||
})
|
||||
.finally(() => {
|
||||
if (
|
||||
this.passwordPrelogin.email === normalizedEmail &&
|
||||
this.passwordPrelogin.version === version
|
||||
) {
|
||||
this.passwordPrelogin.promise = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async clearCache(): Promise<void> {
|
||||
@@ -342,6 +442,12 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
await this.loginStrategyCacheState.update((_) => null);
|
||||
this.authenticationTimeoutSubject.next(false);
|
||||
await this.clearSessionTimeout();
|
||||
|
||||
// Increment to invalidate any in-flight requests
|
||||
this.passwordPrelogin.version++;
|
||||
this.passwordPrelogin.email = null;
|
||||
this.passwordPrelogin.kdfConfig = null;
|
||||
this.passwordPrelogin.promise = null;
|
||||
}
|
||||
|
||||
private async startSessionTimeout(): Promise<void> {
|
||||
@@ -449,4 +555,24 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private buildKdfConfigFromPrelogin(
|
||||
preloginResponse: {
|
||||
kdf: KdfType;
|
||||
kdfIterations: number;
|
||||
kdfMemory?: number;
|
||||
kdfParallelism?: number;
|
||||
} | null,
|
||||
): KdfConfig | null {
|
||||
if (preloginResponse == null) {
|
||||
return null;
|
||||
}
|
||||
return preloginResponse.kdf === KdfType.PBKDF2_SHA256
|
||||
? new PBKDF2KdfConfig(preloginResponse.kdfIterations)
|
||||
: new Argon2KdfConfig(
|
||||
preloginResponse.kdfIterations,
|
||||
preloginResponse.kdfMemory,
|
||||
preloginResponse.kdfParallelism,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export enum FeatureFlag {
|
||||
|
||||
/* Auth */
|
||||
PM22110_DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods",
|
||||
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
|
||||
|
||||
/* Autofill */
|
||||
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
||||
@@ -111,6 +112,7 @@ export const DefaultFeatureFlagValue = {
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM22110_DisableAlternateLoginMethods]: FALSE,
|
||||
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
|
||||
|
||||
/* Billing */
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
|
||||
Reference in New Issue
Block a user