1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 05:43:41 +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

@@ -26,7 +26,7 @@
block block
buttonType="secondary" buttonType="secondary"
class="tw-mt-4" class="tw-mt-4"
(click)="startStandardAuthRequestLogin()" (click)="startStandardAuthRequestLogin(true)"
> >
{{ "resendNotification" | i18n }} {{ "resendNotification" | i18n }}
</button> </button>

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
@@ -24,10 +22,13 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request"; import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; 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 { ClientType, HttpStatusCode } from "@bitwarden/common/enums"; import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -40,6 +41,7 @@ import { ButtonModule, LinkModule, ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { AuthRequestApiService } from "../../common/abstractions/auth-request-api.service"; import { AuthRequestApiService } from "../../common/abstractions/auth-request-api.service";
import { LoginViaAuthRequestCacheService } from "../../common/services/auth-request/default-login-via-auth-request-cache.service";
enum Flow { enum Flow {
StandardAuthRequest, // when user clicks "Login with device" from /login or "Approve from your other device" from /login-initiated StandardAuthRequest, // when user clicks "Login with device" from /login or "Approve from your other device" from /login-initiated
@@ -57,23 +59,26 @@ const matchOptions: IsActiveMatchOptions = {
standalone: true, standalone: true,
templateUrl: "./login-via-auth-request.component.html", templateUrl: "./login-via-auth-request.component.html",
imports: [ButtonModule, CommonModule, JslibModule, LinkModule, RouterModule], imports: [ButtonModule, CommonModule, JslibModule, LinkModule, RouterModule],
providers: [{ provide: LoginViaAuthRequestCacheService }],
}) })
export class LoginViaAuthRequestComponent implements OnInit, OnDestroy { export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
private authRequest: AuthRequest; private authRequest: AuthRequest | undefined = undefined;
private authRequestKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array }; private authRequestKeyPair:
private authStatus: AuthenticationStatus; | { publicKey: Uint8Array | undefined; privateKey: Uint8Array | undefined }
| undefined = undefined;
private authStatus: AuthenticationStatus | undefined = undefined;
private showResendNotificationTimeoutSeconds = 12; private showResendNotificationTimeoutSeconds = 12;
protected backToRoute = "/login"; protected backToRoute = "/login";
protected clientType: ClientType; protected clientType: ClientType;
protected ClientType = ClientType; protected ClientType = ClientType;
protected email: string; protected email: string | undefined = undefined;
protected fingerprintPhrase: string; protected fingerprintPhrase: string | undefined = undefined;
protected showResendNotification = false; protected showResendNotification = false;
protected Flow = Flow; protected Flow = Flow;
protected flow = Flow.StandardAuthRequest; protected flow = Flow.StandardAuthRequest;
protected webVaultUrl: string; protected webVaultUrl: string | undefined = undefined;
protected deviceManagementUrl: string; protected deviceManagementUrl: string | undefined;
constructor( constructor(
private accountService: AccountService, private accountService: AccountService,
@@ -95,6 +100,8 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
private toastService: ToastService, private toastService: ToastService,
private validationService: ValidationService, private validationService: ValidationService,
private loginSuccessHandlerService: LoginSuccessHandlerService, private loginSuccessHandlerService: LoginSuccessHandlerService,
private loginViaAuthRequestCacheService: LoginViaAuthRequestCacheService,
private configService: ConfigService,
) { ) {
this.clientType = this.platformUtilsService.getClientType(); this.clientType = this.platformUtilsService.getClientType();
@@ -124,6 +131,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
// Get the authStatus early because we use it in both flows // Get the authStatus early because we use it in both flows
this.authStatus = await firstValueFrom(this.authService.activeAccountStatus$); this.authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
await this.loginViaAuthRequestCacheService.init();
const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked; const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
@@ -133,7 +141,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
/** /**
* The LoginViaAuthRequestComponent handles both the `login-with-device` and * The LoginViaAuthRequestComponent handles both the `login-with-device` and
* the `admin-approval-requested` routes. Therefore we check the route to determine * the `admin-approval-requested` routes. Therefore, we check the route to determine
* which flow to initialize. * which flow to initialize.
*/ */
if (this.router.isActive("admin-approval-requested", matchOptions)) { if (this.router.isActive("admin-approval-requested", matchOptions)) {
@@ -159,7 +167,14 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
// We only allow a single admin approval request to be active at a time // We only allow a single admin approval request to be active at a time
// so we must check state to see if we have an existing one or not // so we must check state to see if we have an existing one or not
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (!userId) {
this.logService.error(
"Not able to get a user id from the account service active account observable.",
);
return;
}
const existingAdminAuthRequest = await this.authRequestService.getAdminAuthRequest(userId); const existingAdminAuthRequest = await this.authRequestService.getAdminAuthRequest(userId);
if (existingAdminAuthRequest) { if (existingAdminAuthRequest) {
@@ -172,7 +187,9 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
private async initStandardAuthRequestFlow(): Promise<void> { private async initStandardAuthRequestFlow(): Promise<void> {
this.flow = Flow.StandardAuthRequest; this.flow = Flow.StandardAuthRequest;
this.email = await firstValueFrom(this.loginEmailService.loginEmail$); this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
if (!this.email) { if (!this.email) {
await this.handleMissingEmail(); await this.handleMissingEmail();
@@ -185,7 +202,6 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
private async handleMissingEmail(): Promise<void> { private async handleMissingEmail(): Promise<void> {
this.toastService.showToast({ this.toastService.showToast({
variant: "error", variant: "error",
title: null,
message: this.i18nService.t("userEmailMissing"), message: this.i18nService.t("userEmailMissing"),
}); });
@@ -194,21 +210,41 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
async ngOnDestroy(): Promise<void> { async ngOnDestroy(): Promise<void> {
await this.anonymousHubService.stopHubConnection(); await this.anonymousHubService.stopHubConnection();
this.loginViaAuthRequestCacheService.clearCacheLoginView();
} }
private async startAdminAuthRequestLogin(): Promise<void> { private async startAdminAuthRequestLogin(): Promise<void> {
try { try {
await this.buildAuthRequest(AuthRequestType.AdminApproval); await this.buildAuthRequest(AuthRequestType.AdminApproval);
if (!this.authRequest) {
this.logService.error("Auth request failed to build.");
return;
}
if (!this.authRequestKeyPair) {
this.logService.error("Key pairs failed to initialize from buildAuthRequest.");
return;
}
const authRequestResponse = await this.authRequestApiService.postAdminAuthRequest( const authRequestResponse = await this.authRequestApiService.postAdminAuthRequest(
this.authRequest, this.authRequest as AuthRequest,
); );
const adminAuthReqStorable = new AdminAuthRequestStorable({ const adminAuthReqStorable = new AdminAuthRequestStorable({
id: authRequestResponse.id, id: authRequestResponse.id,
privateKey: this.authRequestKeyPair.privateKey, privateKey: this.authRequestKeyPair.privateKey,
}); });
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (!userId) {
this.logService.error(
"Not able to get a user id from the account service active account observable.",
);
return;
}
await this.authRequestService.setAdminAuthRequest(adminAuthReqStorable, userId); await this.authRequestService.setAdminAuthRequest(adminAuthReqStorable, userId);
if (authRequestResponse.id) { if (authRequestResponse.id) {
@@ -219,21 +255,104 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
} }
} }
protected async startStandardAuthRequestLogin(): Promise<void> { protected async startStandardAuthRequestLogin(
clearCachedRequest: boolean = false,
): Promise<void> {
this.showResendNotification = false; this.showResendNotification = false;
try { if (await this.configService.getFeatureFlag(FeatureFlag.PM9112_DeviceApprovalPersistence)) {
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock); // Used for manually refreshing the auth request when clicking the resend auth request
// on the ui.
const authRequestResponse = await this.authRequestApiService.postAuthRequest( if (clearCachedRequest) {
this.authRequest, this.loginViaAuthRequestCacheService.clearCacheLoginView();
); }
if (authRequestResponse.id) { try {
await this.anonymousHubService.createHubConnection(authRequestResponse.id); const loginAuthRequestView: LoginViaAuthRequestView | null =
this.loginViaAuthRequestCacheService.getCachedLoginViaAuthRequestView();
if (!loginAuthRequestView) {
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
// I tried several ways to get the IDE/linter to play nice with checking for null values
// in less code / more efficiently, but it struggles to identify code paths that
// are more complicated than this.
if (!this.authRequest) {
this.logService.error("AuthRequest failed to initialize from buildAuthRequest.");
return;
}
if (!this.fingerprintPhrase) {
this.logService.error("FingerprintPhrase failed to initialize from buildAuthRequest.");
return;
}
if (!this.authRequestKeyPair) {
this.logService.error("KeyPair failed to initialize from buildAuthRequest.");
return;
}
const authRequestResponse: AuthRequestResponse =
await this.authRequestApiService.postAuthRequest(this.authRequest);
this.loginViaAuthRequestCacheService.cacheLoginView(
this.authRequest,
authRequestResponse,
this.fingerprintPhrase,
this.authRequestKeyPair,
);
if (authRequestResponse.id) {
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
}
} else {
// Grab the cached information and store it back in component state.
// We don't need the public key for handling the authentication request because
// the verifyAndHandleApprovedAuthReq function will receive the public key back
// from the looked up auth request and all we need is to make sure that
// we can use the cached private key that is associated with it.
this.authRequest = loginAuthRequestView.authRequest;
this.fingerprintPhrase = loginAuthRequestView.fingerprintPhrase;
this.authRequestKeyPair = {
privateKey: loginAuthRequestView.privateKey
? Utils.fromB64ToArray(loginAuthRequestView.privateKey)
: undefined,
publicKey: undefined,
};
if (!loginAuthRequestView.authRequestResponse) {
this.logService.error("No cached auth request response.");
return;
}
if (loginAuthRequestView.authRequestResponse.id) {
await this.anonymousHubService.createHubConnection(
loginAuthRequestView.authRequestResponse.id,
);
}
}
} catch (e) {
this.logService.error(e);
}
} else {
try {
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
if (!this.authRequest) {
this.logService.error("No auth request found.");
return;
}
const authRequestResponse = await this.authRequestApiService.postAuthRequest(
this.authRequest,
);
if (authRequestResponse.id) {
await this.anonymousHubService.createHubConnection(authRequestResponse.id);
}
} catch (e) {
this.logService.error(e);
} }
} catch (e) {
this.logService.error(e);
} }
setTimeout(() => { setTimeout(() => {
@@ -250,12 +369,23 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
}; };
const deviceIdentifier = await this.appIdService.getAppId(); const deviceIdentifier = await this.appIdService.getAppId();
if (!this.authRequestKeyPair.publicKey) {
this.logService.error("AuthRequest public key not set to value in building auth request.");
return;
}
const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey); const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey);
const accessCode = await this.passwordGenerationService.generatePassword({ const accessCode = await this.passwordGenerationService.generatePassword({
type: "password", type: "password",
length: 25, length: 25,
}); });
if (!this.email) {
this.logService.error("Email not defined when building auth request.");
return;
}
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase( this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
this.email, this.email,
this.authRequestKeyPair.publicKey, this.authRequestKeyPair.publicKey,
@@ -288,6 +418,8 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) { if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId); return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
} }
this.logService.error(error);
return;
} }
// Request doesn't exist anymore // Request doesn't exist anymore
@@ -300,6 +432,12 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey( const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey(
adminAuthRequestStorable.privateKey, adminAuthRequestStorable.privateKey,
); );
if (!this.email) {
this.logService.error("Email not defined when handling an existing an admin auth request.");
return;
}
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase( this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
this.email, this.email,
derivedPublicKeyArrayBuffer, derivedPublicKeyArrayBuffer,
@@ -319,9 +457,12 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
); );
} }
// Request still pending response from admin // Request still pending response from admin set keypair and create hub connection
// set keypair and create hub connection so that any approvals will be received via push notification // so that any approvals will be received via push notification
this.authRequestKeyPair = { privateKey: adminAuthRequestStorable.privateKey, publicKey: null }; this.authRequestKeyPair = {
privateKey: adminAuthRequestStorable.privateKey,
publicKey: undefined,
};
await this.anonymousHubService.createHubConnection(adminAuthRequestStorable.id); await this.anonymousHubService.createHubConnection(adminAuthRequestStorable.id);
} }
@@ -403,6 +544,11 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
await this.handleAuthenticatedFlows(authRequestResponse); await this.handleAuthenticatedFlows(authRequestResponse);
} }
} else { } else {
if (!this.authRequest) {
this.logService.error("No auth request defined when handling approved auth request.");
return;
}
// Get the auth request from the server // Get the auth request from the server
// User is unauthenticated, therefore the endpoint requires an access code for user verification. // User is unauthenticated, therefore the endpoint requires an access code for user verification.
const authRequestResponse = await this.authRequestApiService.getAuthResponse( const authRequestResponse = await this.authRequestApiService.getAuthResponse(
@@ -423,11 +569,26 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
} }
this.logService.error(error); this.logService.error(error);
} finally {
// Manually clean out the cache to make sure sensitive
// data does not persist longer than it needs to.
this.loginViaAuthRequestCacheService.clearCacheLoginView();
} }
} }
private async handleAuthenticatedFlows(authRequestResponse: AuthRequestResponse) { private async handleAuthenticatedFlows(authRequestResponse: AuthRequestResponse) {
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (!userId) {
this.logService.error(
"Not able to get a user id from the account service active account observable.",
);
return;
}
if (!this.authRequestKeyPair || !this.authRequestKeyPair.privateKey) {
this.logService.error("No private key set when handling the authenticated flows.");
return;
}
await this.decryptViaApprovedAuthRequest( await this.decryptViaApprovedAuthRequest(
authRequestResponse, authRequestResponse,
@@ -445,6 +606,11 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
authRequestResponse, authRequestResponse,
); );
if (!authRequestLoginCredentials) {
this.logService.error("Didn't set up auth request login credentials properly.");
return;
}
// Note: keys are set by AuthRequestLoginStrategy success handling // Note: keys are set by AuthRequestLoginStrategy success handling
const authResult = await this.loginStrategyService.logIn(authRequestLoginCredentials); const authResult = await this.loginStrategyService.logIn(authRequestLoginCredentials);
@@ -463,7 +629,6 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
* - If `masterPasswordHash` has a value, we receive the `key` as an authRequestPublicKey(masterKey) [plus we have authRequestPublicKey(masterPasswordHash)] * - If `masterPasswordHash` has a value, we receive the `key` as an authRequestPublicKey(masterKey) [plus we have authRequestPublicKey(masterPasswordHash)]
* - If `masterPasswordHash` does not have a value, we receive the `key` as an authRequestPublicKey(userKey) * - If `masterPasswordHash` does not have a value, we receive the `key` as an authRequestPublicKey(userKey)
*/ */
if (authRequestResponse.masterPasswordHash) { if (authRequestResponse.masterPasswordHash) {
// ...in Standard Auth Request Flow 3 // ...in Standard Auth Request Flow 3
await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash( await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash(
@@ -486,13 +651,17 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
title: null,
message: this.i18nService.t("loginApproved"), message: this.i18nService.t("loginApproved"),
}); });
// Now that we have a decrypted user key in memory, we can check if we // Now that we have a decrypted user key in memory, we can check if we
// need to establish trust on the current device // need to establish trust on the current device
const activeAccount = await firstValueFrom(this.accountService.activeAccount$); const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (!activeAccount) {
this.logService.error("No active account defined from the account service.");
return;
}
await this.deviceTrustService.trustDeviceIfRequired(activeAccount.id); await this.deviceTrustService.trustDeviceIfRequired(activeAccount.id);
await this.handleSuccessfulLoginNavigation(userId); await this.handleSuccessfulLoginNavigation(userId);
@@ -508,7 +677,24 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
private async buildAuthRequestLoginCredentials( private async buildAuthRequestLoginCredentials(
requestId: string, requestId: string,
authRequestResponse: AuthRequestResponse, authRequestResponse: AuthRequestResponse,
): Promise<AuthRequestLoginCredentials> { ): Promise<AuthRequestLoginCredentials | undefined> {
if (!this.authRequestKeyPair || !this.authRequestKeyPair.privateKey) {
this.logService.error("No private key set when building auth request login credentials.");
return;
}
if (!this.email) {
this.logService.error("Email not defined.");
return;
}
if (!this.authRequest) {
this.logService.error(
"AuthRequest not defined when building auth request login credentials.",
);
return;
}
/** /**
* See verifyAndHandleApprovedAuthReq() for flow details. * See verifyAndHandleApprovedAuthReq() for flow details.
* *
@@ -516,7 +702,6 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
* - If `masterPasswordHash` has a value, we receive the `key` as an authRequestPublicKey(masterKey) [plus we have authRequestPublicKey(masterPasswordHash)] * - If `masterPasswordHash` has a value, we receive the `key` as an authRequestPublicKey(masterKey) [plus we have authRequestPublicKey(masterPasswordHash)]
* - If `masterPasswordHash` does not have a value, we receive the `key` as an authRequestPublicKey(userKey) * - If `masterPasswordHash` does not have a value, we receive the `key` as an authRequestPublicKey(userKey)
*/ */
if (authRequestResponse.masterPasswordHash) { if (authRequestResponse.masterPasswordHash) {
// ...in Standard Auth Request Flow 1 // ...in Standard Auth Request Flow 1
const { masterKey, masterKeyHash } = const { masterKey, masterKeyHash } =

View File

@@ -53,9 +53,9 @@ export class AuthRequestLoginCredentials {
public email: string, public email: string,
public accessCode: string, public accessCode: string,
public authRequestId: string, public authRequestId: string,
public decryptedUserKey: UserKey, public decryptedUserKey: UserKey | null,
public decryptedMasterKey: MasterKey, public decryptedMasterKey: MasterKey | null,
public decryptedMasterKeyHash: string, public decryptedMasterKeyHash: string | null,
public twoFactor?: TokenTwoFactorRequest, 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();
}
}

View File

@@ -0,0 +1,17 @@
import { Jsonify } from "type-fest";
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { View } from "@bitwarden/common/models/view/view";
export class LoginViaAuthRequestView implements View {
authRequest: AuthRequest | undefined = undefined;
authRequestResponse: AuthRequestResponse | undefined = undefined;
fingerprintPhrase: string | undefined = undefined;
privateKey: string | undefined = undefined;
publicKey: string | undefined = undefined;
static fromJSON(obj: Partial<Jsonify<LoginViaAuthRequestView>>): LoginViaAuthRequestView {
return Object.assign(new LoginViaAuthRequestView(), obj);
}
}

View File

@@ -36,6 +36,9 @@ export enum FeatureFlag {
VaultBulkManagementAction = "vault-bulk-management-action", VaultBulkManagementAction = "vault-bulk-management-action",
SecurityTasks = "security-tasks", SecurityTasks = "security-tasks",
/* Auth */
PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence",
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service", PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh", UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
CipherKeyEncryption = "cipher-key-encryption", CipherKeyEncryption = "cipher-key-encryption",
@@ -93,6 +96,9 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.VaultBulkManagementAction]: FALSE, [FeatureFlag.VaultBulkManagementAction]: FALSE,
[FeatureFlag.SecurityTasks]: FALSE, [FeatureFlag.SecurityTasks]: FALSE,
/* Auth */
[FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE,
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE, [FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE, [FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE, [FeatureFlag.CipherKeyEncryption]: FALSE,