1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 13:53:34 +00:00

Auth/pm 14943/auth request extension dialog approve (#16132)

* feat(notification-processing): [PM-19877] System Notification Implementation - Implemented the full feature set for device approval from extension.

* test(notification-processing): [PM-19877] System Notification Implementation - Updated tests.

---------

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
This commit is contained in:
Patrick-Pimentel-Bitwarden
2025-09-05 13:27:16 -04:00
committed by GitHub
parent 493788c9d0
commit fe692acc07
14 changed files with 365 additions and 65 deletions

View File

@@ -2,7 +2,17 @@
// @ts-strict-ignore // @ts-strict-ignore
import "core-js/proposals/explicit-resource-management"; import "core-js/proposals/explicit-resource-management";
import { filter, firstValueFrom, map, merge, Subject, switchMap, timeout } from "rxjs"; import {
filter,
firstValueFrom,
from,
map,
merge,
Observable,
Subject,
switchMap,
timeout,
} from "rxjs";
import { CollectionService, DefaultCollectionService } from "@bitwarden/admin-console/common"; import { CollectionService, DefaultCollectionService } from "@bitwarden/admin-console/common";
import { import {
@@ -42,6 +52,7 @@ import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-se
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/auth-request-answering.service"; import { AuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/auth-request-answering.service";
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; import { AvatarService } from "@bitwarden/common/auth/services/avatar.service";
import { DefaultActiveUserAccessor } from "@bitwarden/common/auth/services/default-active-user.accessor"; import { DefaultActiveUserAccessor } from "@bitwarden/common/auth/services/default-active-user.accessor";
@@ -152,6 +163,7 @@ import { SyncService } from "@bitwarden/common/platform/sync";
// eslint-disable-next-line no-restricted-imports -- Needed for service creation // eslint-disable-next-line no-restricted-imports -- Needed for service creation
import { DefaultSyncService } from "@bitwarden/common/platform/sync/internal"; import { DefaultSyncService } from "@bitwarden/common/platform/sync/internal";
import { SystemNotificationsService } from "@bitwarden/common/platform/system-notifications/"; import { SystemNotificationsService } from "@bitwarden/common/platform/system-notifications/";
import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
import { UnsupportedSystemNotificationsService } from "@bitwarden/common/platform/system-notifications/unsupported-system-notifications.service"; import { UnsupportedSystemNotificationsService } from "@bitwarden/common/platform/system-notifications/unsupported-system-notifications.service";
import { DefaultThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { DefaultThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { ApiService } from "@bitwarden/common/services/api.service"; import { ApiService } from "@bitwarden/common/services/api.service";
@@ -407,6 +419,7 @@ export default class MainBackground {
individualVaultExportService: IndividualVaultExportServiceAbstraction; individualVaultExportService: IndividualVaultExportServiceAbstraction;
organizationVaultExportService: OrganizationVaultExportServiceAbstraction; organizationVaultExportService: OrganizationVaultExportServiceAbstraction;
vaultSettingsService: VaultSettingsServiceAbstraction; vaultSettingsService: VaultSettingsServiceAbstraction;
pendingAuthRequestStateService: PendingAuthRequestsStateService;
biometricStateService: BiometricStateService; biometricStateService: BiometricStateService;
biometricsService: BiometricsService; biometricsService: BiometricsService;
stateEventRunnerService: StateEventRunnerService; stateEventRunnerService: StateEventRunnerService;
@@ -1135,12 +1148,16 @@ export default class MainBackground {
this.systemNotificationService = new UnsupportedSystemNotificationsService(); this.systemNotificationService = new UnsupportedSystemNotificationsService();
} }
this.pendingAuthRequestStateService = new PendingAuthRequestsStateService(this.stateProvider);
this.authRequestAnsweringService = new AuthRequestAnsweringService( this.authRequestAnsweringService = new AuthRequestAnsweringService(
this.accountService, this.accountService,
this.actionsService, this.actionsService,
this.authService, this.authService,
this.i18nService, this.i18nService,
this.masterPasswordService, this.masterPasswordService,
this.messagingService,
this.pendingAuthRequestStateService,
this.platformUtilsService, this.platformUtilsService,
this.systemNotificationService, this.systemNotificationService,
); );
@@ -1421,7 +1438,7 @@ export default class MainBackground {
// Only the "true" background should run migrations // Only the "true" background should run migrations
await this.migrationRunner.run(); await this.migrationRunner.run();
// This is here instead of in in the InitService b/c we don't plan for // This is here instead of in the InitService b/c we don't plan for
// side effects to run in the Browser InitService. // side effects to run in the Browser InitService.
const accounts = await firstValueFrom(this.accountService.accounts$); const accounts = await firstValueFrom(this.accountService.accounts$);
@@ -1800,18 +1817,41 @@ export default class MainBackground {
/** /**
* This function is for creating any subscriptions for the background service worker. We do this * This function is for creating any subscriptions for the background service worker. We do this
* here because it's important to run this during the evaluation period of the browser extension * here because it's important to run this during the evaluation period of the browser extension
* service worker. * service worker. If it's not done this way we risk the service worker being closed before it's
* registered these system notification click events.
*/ */
initNotificationSubscriptions() { initNotificationSubscriptions() {
this.systemNotificationService.notificationClicked$ const handlers: Array<{
.pipe( startsWith: string;
filter((n) => n.id.startsWith(AuthServerNotificationTags.AuthRequest + "_")), handler: (event: SystemNotificationEvent) => Promise<void>;
map((n) => ({ event: n, authRequestId: n.id.split("_")[1] })), }> = [];
switchMap(({ event }) =>
const register = (
startsWith: string,
handler: (event: SystemNotificationEvent) => Promise<void>,
) => {
handlers.push({ startsWith, handler });
};
// ======= Register All System Notification Handlers Here =======
register(AuthServerNotificationTags.AuthRequest, (event) =>
this.authRequestAnsweringService.handleAuthRequestNotificationClicked(event), this.authRequestAnsweringService.handleAuthRequestNotificationClicked(event),
);
// ======= End Register All System Notification Handlers =======
const streams: Observable<void>[] = handlers.map(({ startsWith, handler }) =>
this.systemNotificationService.notificationClicked$.pipe(
filter((event: SystemNotificationEvent): boolean => event.id.startsWith(startsWith + "_")),
switchMap(
(event: SystemNotificationEvent): Observable<void> =>
from(Promise.resolve(handler(event))),
), ),
) ),
.subscribe(); );
if (streams.length > 0) {
merge(...streams).subscribe();
}
} }
/** /**

View File

@@ -4,33 +4,47 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef, DestroyRef,
inject,
NgZone, NgZone,
OnDestroy, OnDestroy,
OnInit, OnInit,
inject,
} from "@angular/core"; } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
import { import {
Subject,
takeUntil,
firstValueFrom,
concatMap,
filter,
tap,
catchError, catchError,
of, concatMap,
distinctUntilChanged,
filter,
firstValueFrom,
map, map,
of,
pairwise,
startWith,
Subject,
switchMap,
take,
takeUntil,
tap,
} from "rxjs"; } from "rxjs";
import { LoginApprovalDialogComponent } from "@bitwarden/angular/auth/login-approval/login-approval-dialog.component";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction"; import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n"; import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n";
import { LogoutReason, UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import {
AuthRequestServiceAbstraction,
LogoutReason,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service"; import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -67,6 +81,8 @@ export class AppComponent implements OnInit, OnDestroy {
private lastActivity: Date; private lastActivity: Date;
private activeUserId: UserId; private activeUserId: UserId;
private routerAnimations = false; private routerAnimations = false;
private processingPendingAuth = false;
private extensionLoginApprovalFeatureFlag = false;
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@@ -99,6 +115,10 @@ export class AppComponent implements OnInit, OnDestroy {
private readonly documentLangSetter: DocumentLangSetter, private readonly documentLangSetter: DocumentLangSetter,
private popupSizeService: PopupSizeService, private popupSizeService: PopupSizeService,
private logService: LogService, private logService: LogService,
private authRequestService: AuthRequestServiceAbstraction,
private pendingAuthRequestsState: PendingAuthRequestsStateService,
private authRequestAnsweringService: AuthRequestAnsweringServiceAbstraction,
private readonly configService: ConfigService,
) { ) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
@@ -107,6 +127,10 @@ export class AppComponent implements OnInit, OnDestroy {
} }
async ngOnInit() { async ngOnInit() {
this.extensionLoginApprovalFeatureFlag = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.PM14938_BrowserExtensionLoginApproval),
);
initPopupClosedListener(); initPopupClosedListener();
this.compactModeService.init(); this.compactModeService.init();
@@ -116,6 +140,25 @@ export class AppComponent implements OnInit, OnDestroy {
this.activeUserId = account?.id; this.activeUserId = account?.id;
}); });
if (this.extensionLoginApprovalFeatureFlag) {
// Trigger processing auth requests when the active user is in an unlocked state. Runs once when
// the popup is open.
this.accountService.activeAccount$
.pipe(
map((a) => a?.id), // Extract active userId
distinctUntilChanged(), // Only when userId actually changes
filter((userId) => userId != null), // Require a valid userId
switchMap((userId) => this.authService.authStatusFor$(userId).pipe(take(1))), // Get current auth status once for new user
filter((status) => status === AuthenticationStatus.Unlocked), // Only when the new user is Unlocked
tap(() => {
// Trigger processing when switching users while popup is open
void this.authRequestAnsweringService.processPendingAuthRequests();
}),
takeUntil(this.destroy$),
)
.subscribe();
}
this.authService.activeAccountStatus$ this.authService.activeAccountStatus$
.pipe( .pipe(
filter((status) => status === AuthenticationStatus.Unlocked), filter((status) => status === AuthenticationStatus.Unlocked),
@@ -126,6 +169,25 @@ export class AppComponent implements OnInit, OnDestroy {
) )
.subscribe(); .subscribe();
if (this.extensionLoginApprovalFeatureFlag) {
// When the popup is already open and the active account transitions to Unlocked,
// process any pending auth requests for the active user. The above subscription does not handle
// this case.
this.authService.activeAccountStatus$
.pipe(
startWith(null as unknown as AuthenticationStatus), // Seed previous value to handle initial emission
pairwise(), // Compare previous and current statuses
filter(
([prev, curr]) =>
prev !== AuthenticationStatus.Unlocked && curr === AuthenticationStatus.Unlocked, // Fire on transitions into Unlocked (incl. initial)
),
takeUntil(this.destroy$),
)
.subscribe(() => {
void this.authRequestAnsweringService.processPendingAuthRequests();
});
}
this.ngZone.runOutsideAngular(() => { this.ngZone.runOutsideAngular(() => {
window.onmousedown = () => this.recordActivity(); window.onmousedown = () => this.recordActivity();
window.ontouchstart = () => this.recordActivity(); window.ontouchstart = () => this.recordActivity();
@@ -179,6 +241,42 @@ export class AppComponent implements OnInit, OnDestroy {
} }
await this.router.navigate(["lock"]); await this.router.navigate(["lock"]);
} else if (
msg.command === "openLoginApproval" &&
this.extensionLoginApprovalFeatureFlag
) {
if (this.processingPendingAuth) {
return;
}
this.processingPendingAuth = true;
try {
// Always query server for all pending requests and open a dialog for each
const pendingList = await firstValueFrom(
this.authRequestService.getPendingAuthRequests$(),
);
if (Array.isArray(pendingList) && pendingList.length > 0) {
const respondedIds = new Set<string>();
for (const req of pendingList) {
if (req?.id == null) {
continue;
}
const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, {
notificationId: req.id,
});
const result = await firstValueFrom(dialogRef.closed);
if (result !== undefined && typeof result === "boolean") {
respondedIds.add(req.id);
if (respondedIds.size === pendingList.length && this.activeUserId != null) {
await this.pendingAuthRequestsState.clear(this.activeUserId);
}
}
}
}
} finally {
this.processingPendingAuth = false;
}
} else if (msg.command === "showDialog") { } else if (msg.command === "showDialog") {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises

View File

@@ -50,6 +50,7 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/auth-request-answering.service"; import { AuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/auth-request-answering.service";
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import { import {
AutofillSettingsService, AutofillSettingsService,
AutofillSettingsServiceAbstraction, AutofillSettingsServiceAbstraction,
@@ -86,7 +87,10 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; import {
MessagingService,
MessagingService as MessagingServiceAbstraction,
} from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
@@ -484,6 +488,8 @@ const safeProviders: SafeProvider[] = [
AuthService, AuthService,
I18nServiceAbstraction, I18nServiceAbstraction,
MasterPasswordServiceAbstraction, MasterPasswordServiceAbstraction,
MessagingService,
PendingAuthRequestsStateService,
PlatformUtilsService, PlatformUtilsService,
SystemNotificationsService, SystemNotificationsService,
], ],

View File

@@ -6,12 +6,15 @@ import { firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { AuthRequestApiServiceAbstraction } from "@bitwarden/auth/common"; import { AuthRequestApiServiceAbstraction } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
import { import {
DevicePendingAuthRequest, DevicePendingAuthRequest,
DeviceResponse, DeviceResponse,
} from "@bitwarden/common/auth/abstractions/devices/responses/device.response"; } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
import { DeviceView } from "@bitwarden/common/auth/abstractions/devices/views/device.view"; import { DeviceView } from "@bitwarden/common/auth/abstractions/devices/views/device.view";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import { DeviceType, DeviceTypeMetadata } from "@bitwarden/common/enums"; import { DeviceType, DeviceTypeMetadata } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
@@ -66,14 +69,16 @@ export class DeviceManagementComponent implements OnInit {
protected showHeaderInfo = false; protected showHeaderInfo = false;
constructor( constructor(
private authRequestApiService: AuthRequestApiServiceAbstraction, private readonly accountService: AccountService,
private destroyRef: DestroyRef, private readonly authRequestApiService: AuthRequestApiServiceAbstraction,
private deviceManagementComponentService: DeviceManagementComponentServiceAbstraction, private readonly destroyRef: DestroyRef,
private devicesService: DevicesServiceAbstraction, private readonly deviceManagementComponentService: DeviceManagementComponentServiceAbstraction,
private dialogService: DialogService, private readonly devicesService: DevicesServiceAbstraction,
private i18nService: I18nService, private readonly dialogService: DialogService,
private messageListener: MessageListener, private readonly i18nService: I18nService,
private validationService: ValidationService, private readonly messageListener: MessageListener,
private readonly pendingAuthRequestStateService: PendingAuthRequestsStateService,
private readonly validationService: ValidationService,
) { ) {
this.showHeaderInfo = this.deviceManagementComponentService.showHeaderInformation(); this.showHeaderInfo = this.deviceManagementComponentService.showHeaderInformation();
} }
@@ -248,6 +253,12 @@ export class DeviceManagementComponent implements OnInit {
// Auth request was approved or denied, so clear the // Auth request was approved or denied, so clear the
// pending auth request and re-sort the device array // pending auth request and re-sort the device array
this.devices = clearAuthRequestAndResortDevices(this.devices, pendingAuthRequest); this.devices = clearAuthRequestAndResortDevices(this.devices, pendingAuthRequest);
// If a user ignores or doesn't see the auth request dialog, but comes to account settings
// to approve a device login attempt, clear out the state for that user.
await this.pendingAuthRequestStateService.clear(
await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)),
);
} }
} }
} }

View File

@@ -106,6 +106,7 @@ import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service"; import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service";
import { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service"; import { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service";
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; import { AvatarService } from "@bitwarden/common/auth/services/avatar.service";
import { DefaultActiveUserAccessor } from "@bitwarden/common/auth/services/default-active-user.accessor"; import { DefaultActiveUserAccessor } from "@bitwarden/common/auth/services/default-active-user.accessor";
@@ -942,6 +943,11 @@ const safeProviders: SafeProvider[] = [
useClass: UnsupportedSystemNotificationsService, useClass: UnsupportedSystemNotificationsService,
deps: [], deps: [],
}), }),
safeProvider({
provide: PendingAuthRequestsStateService,
useClass: PendingAuthRequestsStateService,
deps: [StateProvider],
}),
safeProvider({ safeProvider({
provide: AuthRequestAnsweringServiceAbstraction, provide: AuthRequestAnsweringServiceAbstraction,
useClass: NoopAuthRequestAnsweringService, useClass: NoopAuthRequestAnsweringService,

View File

@@ -159,7 +159,6 @@ export class SelfHostedEnvConfigDialogComponent implements OnInit, OnDestroy {
}, },
}); });
} }
submit = async () => { submit = async () => {
this.showErrorSummary = false; this.showErrorSummary = false;

View File

@@ -0,0 +1,10 @@
# Auth Request Answering Service
This feature is to allow for the taking of auth requests that are received via websockets by the background service to
be acted on when the user loads up a client. Currently only implemented with the browser client.
See diagram for the high level picture of how this is wired up.
## Diagram
![img.png](notification-architecture.png)

View File

@@ -2,7 +2,29 @@ import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notif
import { UserId } from "@bitwarden/user-core"; import { UserId } from "@bitwarden/user-core";
export abstract class AuthRequestAnsweringServiceAbstraction { export abstract class AuthRequestAnsweringServiceAbstraction {
abstract receivedPendingAuthRequest(userId: UserId, notificationId: string): Promise<void>; /**
* Tries to either display the dialog for the user or will preserve its data and show it at a
* later time. Even in the event the dialog is shown immediately, this will write to global state
* so that even if someone closes a window or a popup and comes back, it could be processed later.
* Only way to clear out the global state is to respond to the auth request.
*
* Currently, this is only implemented for browser extension.
*
* @param userId The UserId that the auth request is for.
* @param authRequestId The id of the auth request that is to be processed.
*/
abstract receivedPendingAuthRequest(userId: UserId, authRequestId: string): Promise<void>;
/**
* When a system notification is clicked, this function is used to process that event.
*
* @param event The event passed in. Check initNotificationSubscriptions in main.background.ts.
*/
abstract handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void>; abstract handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void>;
/**
* Process notifications that have been received but didn't meet the conditions to display the
* approval dialog.
*/
abstract processPendingAuthRequests(): Promise<void>;
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

View File

@@ -7,6 +7,7 @@ import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-se
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ActionsService } from "@bitwarden/common/platform/actions"; import { ActionsService } from "@bitwarden/common/platform/actions";
import { import {
@@ -17,6 +18,7 @@ import {
import { UserId } from "@bitwarden/user-core"; import { UserId } from "@bitwarden/user-core";
import { AuthRequestAnsweringService } from "./auth-request-answering.service"; import { AuthRequestAnsweringService } from "./auth-request-answering.service";
import { PendingAuthRequestsStateService } from "./pending-auth-requests.state";
describe("AuthRequestAnsweringService", () => { describe("AuthRequestAnsweringService", () => {
let accountService: MockProxy<AccountService>; let accountService: MockProxy<AccountService>;
@@ -24,6 +26,8 @@ describe("AuthRequestAnsweringService", () => {
let authService: MockProxy<AuthService>; let authService: MockProxy<AuthService>;
let i18nService: MockProxy<I18nService>; let i18nService: MockProxy<I18nService>;
let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$ let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$
let messagingService: MockProxy<MessagingService>;
let pendingAuthRequestsState: MockProxy<PendingAuthRequestsStateService>;
let platformUtilsService: MockProxy<PlatformUtilsService>; let platformUtilsService: MockProxy<PlatformUtilsService>;
let systemNotificationsService: MockProxy<SystemNotificationsService>; let systemNotificationsService: MockProxy<SystemNotificationsService>;
@@ -37,6 +41,8 @@ describe("AuthRequestAnsweringService", () => {
authService = mock<AuthService>(); authService = mock<AuthService>();
i18nService = mock<I18nService>(); i18nService = mock<I18nService>();
masterPasswordService = { forceSetPasswordReason$: jest.fn() }; masterPasswordService = { forceSetPasswordReason$: jest.fn() };
messagingService = mock<MessagingService>();
pendingAuthRequestsState = mock<PendingAuthRequestsStateService>();
platformUtilsService = mock<PlatformUtilsService>(); platformUtilsService = mock<PlatformUtilsService>();
systemNotificationsService = mock<SystemNotificationsService>(); systemNotificationsService = mock<SystemNotificationsService>();
@@ -66,6 +72,8 @@ describe("AuthRequestAnsweringService", () => {
authService, authService,
i18nService, i18nService,
masterPasswordService, masterPasswordService,
messagingService,
pendingAuthRequestsState,
platformUtilsService, platformUtilsService,
systemNotificationsService, systemNotificationsService,
); );

View File

@@ -5,9 +5,10 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags"; import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
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 { getUserId } from "@bitwarden/common/auth/services/account.service"; import { getOptionalUserId, getUserId } from "@bitwarden/common/auth/services/account.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ActionsService } from "@bitwarden/common/platform/actions"; import { ActionsService } from "@bitwarden/common/platform/actions";
import { import {
@@ -19,6 +20,11 @@ import { UserId } from "@bitwarden/user-core";
import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import {
PendingAuthRequestsStateService,
PendingAuthUserMarker,
} from "./pending-auth-requests.state";
export class AuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction { export class AuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction {
constructor( constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
@@ -26,10 +32,51 @@ export class AuthRequestAnsweringService implements AuthRequestAnsweringServiceA
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
private readonly masterPasswordService: MasterPasswordServiceAbstraction, private readonly masterPasswordService: MasterPasswordServiceAbstraction,
private readonly messagingService: MessagingService,
private readonly pendingAuthRequestsState: PendingAuthRequestsStateService,
private readonly platformUtilsService: PlatformUtilsService, private readonly platformUtilsService: PlatformUtilsService,
private readonly systemNotificationsService: SystemNotificationsService, private readonly systemNotificationsService: SystemNotificationsService,
) {} ) {}
async receivedPendingAuthRequest(userId: UserId, authRequestId: string): Promise<void> {
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
const activeUserId: UserId | null = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
const forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(userId),
);
const popupOpen = await this.platformUtilsService.isPopupOpen();
// Always persist the pending marker for this user to global state.
await this.pendingAuthRequestsState.add(userId);
// These are the conditions we are looking for to know if the extension is in a state to show
// the approval dialog.
const userIsAvailableToReceiveAuthRequest =
popupOpen &&
authStatus === AuthenticationStatus.Unlocked &&
activeUserId === userId &&
forceSetPasswordReason === ForceSetPasswordReason.None;
if (!userIsAvailableToReceiveAuthRequest) {
// Get the user's email to include in the system notification
const accounts = await firstValueFrom(this.accountService.accounts$);
const emailForUser = accounts[userId].email;
await this.systemNotificationsService.create({
id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`, // the underscore is an important delimiter.
title: this.i18nService.t("accountAccessRequested"),
body: this.i18nService.t("confirmAccessAttempt", emailForUser),
buttons: [],
});
return;
}
// Popup is open and conditions are met; open dialog immediately for this request
this.messagingService.send("openLoginApproval");
}
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> { async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
if (event.buttonIdentifier === ButtonLocation.NotificationButton) { if (event.buttonIdentifier === ButtonLocation.NotificationButton) {
await this.systemNotificationsService.clear({ await this.systemNotificationsService.clear({
@@ -39,32 +86,26 @@ export class AuthRequestAnsweringService implements AuthRequestAnsweringServiceA
} }
} }
async receivedPendingAuthRequest(userId: UserId, authRequestId: string): Promise<void> { async processPendingAuthRequests(): Promise<void> {
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$); // Prune any stale pending requests (older than 15 minutes)
// This comes from GlobalSettings.cs
// public TimeSpan UserRequestExpiration { get; set; } = TimeSpan.FromMinutes(15);
const fifteenMinutesMs = 15 * 60 * 1000;
await this.pendingAuthRequestsState.pruneOlderThan(fifteenMinutesMs);
const pendingAuthRequestsInState: PendingAuthUserMarker[] =
(await firstValueFrom(this.pendingAuthRequestsState.getAll$())) ?? [];
if (pendingAuthRequestsInState.length > 0) {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const forceSetPasswordReason = await firstValueFrom( const pendingAuthRequestsForActiveUser = pendingAuthRequestsInState.some(
this.masterPasswordService.forceSetPasswordReason$(userId), (e) => e.userId === activeUserId,
); );
// Is the popup already open? if (pendingAuthRequestsForActiveUser) {
if ( this.messagingService.send("openLoginApproval");
(await this.platformUtilsService.isPopupOpen()) && }
authStatus === AuthenticationStatus.Unlocked &&
activeUserId === userId &&
forceSetPasswordReason === ForceSetPasswordReason.None
) {
// TODO: Handled in 14934
} else {
// Get the user's email to include in the system notification
const accounts = await firstValueFrom(this.accountService.accounts$);
const emailForUser = accounts[userId].email;
await this.systemNotificationsService.create({
id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`,
title: this.i18nService.t("accountAccessRequested"),
body: this.i18nService.t("confirmAccessAttempt", emailForUser),
buttons: [],
});
} }
} }
} }

View File

@@ -5,7 +5,10 @@ import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-
export class NoopAuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction { export class NoopAuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction {
constructor() {} constructor() {}
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {}
async receivedPendingAuthRequest(userId: UserId, notificationId: string): Promise<void> {} async receivedPendingAuthRequest(userId: UserId, notificationId: string): Promise<void> {}
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {}
async processPendingAuthRequests(): Promise<void> {}
} }

View File

@@ -0,0 +1,57 @@
import { Observable } from "rxjs";
import {
AUTH_REQUEST_DISK_LOCAL,
GlobalState,
KeyDefinition,
StateProvider,
} from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/user-core";
export type PendingAuthUserMarker = {
userId: UserId;
receivedAtMs: number;
};
export const PENDING_AUTH_REQUESTS = KeyDefinition.array<PendingAuthUserMarker>(
AUTH_REQUEST_DISK_LOCAL,
"pendingAuthRequests",
{
deserializer: (json) => json,
},
);
export class PendingAuthRequestsStateService {
private readonly state: GlobalState<PendingAuthUserMarker[]>;
constructor(private readonly stateProvider: StateProvider) {
this.state = this.stateProvider.getGlobal(PENDING_AUTH_REQUESTS);
}
getAll$(): Observable<PendingAuthUserMarker[] | null> {
return this.state.state$;
}
async add(userId: UserId): Promise<void> {
const now = Date.now();
await this.stateProvider.getGlobal(PENDING_AUTH_REQUESTS).update((current) => {
const list = (current ?? []).filter((e) => e.userId !== userId);
return [...list, { userId, receivedAtMs: now }];
});
}
async pruneOlderThan(maxAgeMs: number): Promise<void> {
const cutoff = Date.now() - maxAgeMs;
await this.stateProvider.getGlobal(PENDING_AUTH_REQUESTS).update((current) => {
const list = current ?? [];
return list.filter((e) => e.receivedAtMs >= cutoff);
});
}
async clear(userId: UserId): Promise<void> {
await this.stateProvider.getGlobal(PENDING_AUTH_REQUESTS).update((current) => {
const list = current ?? [];
return list.filter((e) => e.userId !== userId);
});
}
}

View File

@@ -131,6 +131,8 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => { configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => {
const flagValueByFlag: Partial<Record<FeatureFlag, boolean>> = { const flagValueByFlag: Partial<Record<FeatureFlag, boolean>> = {
[FeatureFlag.InactiveUserServerNotification]: true, [FeatureFlag.InactiveUserServerNotification]: true,
[FeatureFlag.PushNotificationsWhenLocked]: true,
[FeatureFlag.PM14938_BrowserExtensionLoginApproval]: true,
}; };
return new BehaviorSubject(flagValueByFlag[flag] ?? false) as any; return new BehaviorSubject(flagValueByFlag[flag] ?? false) as any;
}); });
@@ -253,8 +255,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
.next({ type: "not-supported", reason: "test" } as any); .next({ type: "not-supported", reason: "test" } as any);
} }
// TODO: When PM-14943 goes in, uncomment authRequestAnsweringService.receivedPendingAuthRequest.mockResolvedValue(undefined as any);
// authRequestAnsweringService.receivedPendingAuthRequest.mockResolvedValue(undefined as any);
const subscription = defaultServerNotificationsService.startListening(); const subscription = defaultServerNotificationsService.startListening();
@@ -273,12 +274,10 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval", { expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval", {
notificationId: "auth-id-2", notificationId: "auth-id-2",
}); });
expect(authRequestAnsweringService.receivedPendingAuthRequest).toHaveBeenCalledWith(
// TODO: When PM-14943 goes in, uncomment mockUserId2,
// expect(authRequestAnsweringService.receivedPendingAuthRequest).toHaveBeenCalledWith( "auth-id-2",
// mockUserId2, );
// "auth-id-2",
// );
subscription.unsubscribe(); subscription.unsubscribe();
}); });