1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-21 18:53:29 +00:00

fix(device-approval-persistence): [PM-9112] Device Approval Persistence (#13680)

* feat(device-approval-persistence): [PM-9112] Device Approval Persistence - Added in view cache data needed to persist the approval process. Clears after 2 minutes.
This commit is contained in:
Patrick-Pimentel-Bitwarden
2025-03-19 15:26:10 -04:00
committed by GitHub
parent 4c4019c35f
commit 2e0c991f83
7 changed files with 446 additions and 39 deletions

View File

@@ -53,9 +53,9 @@ export class AuthRequestLoginCredentials {
public email: string,
public accessCode: string,
public authRequestId: string,
public decryptedUserKey: UserKey,
public decryptedMasterKey: MasterKey,
public decryptedMasterKeyHash: string,
public decryptedUserKey: UserKey | null,
public decryptedMasterKey: MasterKey | null,
public decryptedMasterKeyHash: string | null,
public twoFactor?: TokenTwoFactorRequest,
) {}

View File

@@ -0,0 +1,111 @@
import { signal } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { AuthRequestType } from "@bitwarden/common/auth/enums/auth-request-type";
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LoginViaAuthRequestCacheService } from "./default-login-via-auth-request-cache.service";
describe("LoginViaAuthRequestCache", () => {
let service: LoginViaAuthRequestCacheService;
let testBed: TestBed;
const cacheSignal = signal<LoginViaAuthRequestView | null>(null);
const getCacheSignal = jest.fn().mockReturnValue(cacheSignal);
const getFeatureFlag = jest.fn().mockResolvedValue(false);
const cacheSetMock = jest.spyOn(cacheSignal, "set");
beforeEach(() => {
getCacheSignal.mockClear();
getFeatureFlag.mockClear();
cacheSetMock.mockClear();
testBed = TestBed.configureTestingModule({
providers: [
{ provide: ViewCacheService, useValue: { signal: getCacheSignal } },
{ provide: ConfigService, useValue: { getFeatureFlag } },
LoginViaAuthRequestCacheService,
],
});
});
describe("feature enabled", () => {
beforeEach(() => {
getFeatureFlag.mockResolvedValue(true);
});
it("`getCachedLoginViaAuthRequestView` returns the cached data", async () => {
cacheSignal.set({ ...buildAuthenticMockAuthView() });
service = testBed.inject(LoginViaAuthRequestCacheService);
await service.init();
expect(service.getCachedLoginViaAuthRequestView()).toEqual({
...buildAuthenticMockAuthView(),
});
});
it("updates the signal value", async () => {
service = testBed.inject(LoginViaAuthRequestCacheService);
await service.init();
const parameters = buildAuthenticMockAuthView();
service.cacheLoginView(
parameters.authRequest,
parameters.authRequestResponse,
parameters.fingerprintPhrase,
{ publicKey: new Uint8Array(), privateKey: new Uint8Array() },
);
expect(cacheSignal.set).toHaveBeenCalledWith(parameters);
});
});
describe("feature disabled", () => {
beforeEach(async () => {
cacheSignal.set({ ...buildAuthenticMockAuthView() } as LoginViaAuthRequestView);
getFeatureFlag.mockResolvedValue(false);
cacheSetMock.mockClear();
service = testBed.inject(LoginViaAuthRequestCacheService);
await service.init();
});
it("`getCachedCipherView` returns null", () => {
expect(service.getCachedLoginViaAuthRequestView()).toBeNull();
});
it("does not update the signal value", () => {
const params = buildAuthenticMockAuthView();
service.cacheLoginView(
params.authRequest,
params.authRequestResponse,
params.fingerprintPhrase,
{ publicKey: new Uint8Array(), privateKey: new Uint8Array() },
);
expect(cacheSignal.set).not.toHaveBeenCalled();
});
});
const buildAuthenticMockAuthView = () => {
return {
fingerprintPhrase: "",
privateKey: "",
publicKey: "",
authRequest: new AuthRequest(
"test@gmail.com",
"deviceIdentifier",
"publicKey",
AuthRequestType.Unlock,
"accessCode",
),
authRequestResponse: new AuthRequestResponse({}),
};
};
});

View File

@@ -0,0 +1,88 @@
import { inject, Injectable, WritableSignal } from "@angular/core";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
const LOGIN_VIA_AUTH_CACHE_KEY = "login-via-auth-request-form-cache";
/**
* This is a cache service used for the login via auth request component.
*
* There is sensitive information stored temporarily here. Cache will be cleared
* after 2 minutes.
*/
@Injectable()
export class LoginViaAuthRequestCacheService {
private viewCacheService: ViewCacheService = inject(ViewCacheService);
private configService: ConfigService = inject(ConfigService);
/** True when the `PM9112_DeviceApproval` flag is enabled */
private featureEnabled: boolean = false;
private defaultLoginViaAuthRequestCache: WritableSignal<LoginViaAuthRequestView | null> =
this.viewCacheService.signal<LoginViaAuthRequestView | null>({
key: LOGIN_VIA_AUTH_CACHE_KEY,
initialValue: null,
deserializer: LoginViaAuthRequestView.fromJSON,
});
constructor() {}
/**
* Must be called once before interacting with the cached data, otherwise methods will be noop.
*/
async init() {
this.featureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM9112_DeviceApprovalPersistence,
);
}
/**
* Update the cache with the new LoginView.
*/
cacheLoginView(
authRequest: AuthRequest,
authRequestResponse: AuthRequestResponse,
fingerprintPhrase: string,
keys: { privateKey: Uint8Array | undefined; publicKey: Uint8Array | undefined },
): void {
if (!this.featureEnabled) {
return;
}
// When the keys get stored they should be converted to a B64 string to ensure
// data can be properly formed when json-ified. If not done, they are not stored properly and
// will not be parsable by the cryptography library after coming out of storage.
this.defaultLoginViaAuthRequestCache.set({
authRequest,
authRequestResponse,
fingerprintPhrase,
privateKey: keys.privateKey ? Utils.fromBufferToB64(keys.privateKey.buffer) : undefined,
publicKey: keys.publicKey ? Utils.fromBufferToB64(keys.publicKey.buffer) : undefined,
} as LoginViaAuthRequestView);
}
clearCacheLoginView(): void {
if (!this.featureEnabled) {
return;
}
this.defaultLoginViaAuthRequestCache.set(null);
}
/**
* Returns the cached LoginViaAuthRequestView when available.
*/
getCachedLoginViaAuthRequestView(): LoginViaAuthRequestView | null {
if (!this.featureEnabled) {
return null;
}
return this.defaultLoginViaAuthRequestCache();
}
}