1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 14:23:32 +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
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 {
@@ -42,6 +52,7 @@ import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-se
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.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 { AvatarService } from "@bitwarden/common/auth/services/avatar.service";
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
import { DefaultSyncService } from "@bitwarden/common/platform/sync/internal";
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 { DefaultThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { ApiService } from "@bitwarden/common/services/api.service";
@@ -407,6 +419,7 @@ export default class MainBackground {
individualVaultExportService: IndividualVaultExportServiceAbstraction;
organizationVaultExportService: OrganizationVaultExportServiceAbstraction;
vaultSettingsService: VaultSettingsServiceAbstraction;
pendingAuthRequestStateService: PendingAuthRequestsStateService;
biometricStateService: BiometricStateService;
biometricsService: BiometricsService;
stateEventRunnerService: StateEventRunnerService;
@@ -1135,12 +1148,16 @@ export default class MainBackground {
this.systemNotificationService = new UnsupportedSystemNotificationsService();
}
this.pendingAuthRequestStateService = new PendingAuthRequestsStateService(this.stateProvider);
this.authRequestAnsweringService = new AuthRequestAnsweringService(
this.accountService,
this.actionsService,
this.authService,
this.i18nService,
this.masterPasswordService,
this.messagingService,
this.pendingAuthRequestStateService,
this.platformUtilsService,
this.systemNotificationService,
);
@@ -1421,7 +1438,7 @@ export default class MainBackground {
// Only the "true" background should run migrations
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.
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
* 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() {
this.systemNotificationService.notificationClicked$
.pipe(
filter((n) => n.id.startsWith(AuthServerNotificationTags.AuthRequest + "_")),
map((n) => ({ event: n, authRequestId: n.id.split("_")[1] })),
switchMap(({ event }) =>
this.authRequestAnsweringService.handleAuthRequestNotificationClicked(event),
const handlers: Array<{
startsWith: string;
handler: (event: SystemNotificationEvent) => Promise<void>;
}> = [];
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),
);
// ======= 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,
Component,
DestroyRef,
inject,
NgZone,
OnDestroy,
OnInit,
inject,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
import {
Subject,
takeUntil,
firstValueFrom,
concatMap,
filter,
tap,
catchError,
of,
concatMap,
distinctUntilChanged,
filter,
firstValueFrom,
map,
of,
pairwise,
startWith,
Subject,
switchMap,
take,
takeUntil,
tap,
} 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 { 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 { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
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 { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.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 activeUserId: UserId;
private routerAnimations = false;
private processingPendingAuth = false;
private extensionLoginApprovalFeatureFlag = false;
private destroy$ = new Subject<void>();
@@ -99,6 +115,10 @@ export class AppComponent implements OnInit, OnDestroy {
private readonly documentLangSetter: DocumentLangSetter,
private popupSizeService: PopupSizeService,
private logService: LogService,
private authRequestService: AuthRequestServiceAbstraction,
private pendingAuthRequestsState: PendingAuthRequestsStateService,
private authRequestAnsweringService: AuthRequestAnsweringServiceAbstraction,
private readonly configService: ConfigService,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
@@ -107,6 +127,10 @@ export class AppComponent implements OnInit, OnDestroy {
}
async ngOnInit() {
this.extensionLoginApprovalFeatureFlag = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.PM14938_BrowserExtensionLoginApproval),
);
initPopupClosedListener();
this.compactModeService.init();
@@ -116,6 +140,25 @@ export class AppComponent implements OnInit, OnDestroy {
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$
.pipe(
filter((status) => status === AuthenticationStatus.Unlocked),
@@ -126,6 +169,25 @@ export class AppComponent implements OnInit, OnDestroy {
)
.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(() => {
window.onmousedown = () => this.recordActivity();
window.ontouchstart = () => this.recordActivity();
@@ -179,6 +241,42 @@ export class AppComponent implements OnInit, OnDestroy {
}
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") {
// 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

View File

@@ -50,6 +50,7 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
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 { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import {
AutofillSettingsService,
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 { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.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 { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
@@ -484,6 +488,8 @@ const safeProviders: SafeProvider[] = [
AuthService,
I18nServiceAbstraction,
MasterPasswordServiceAbstraction,
MessagingService,
PendingAuthRequestsStateService,
PlatformUtilsService,
SystemNotificationsService,
],