1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

fix(passkeys): [PM-28324] Add a guard that conditionally forces a popout depending on platform

* Add a guard that conditionally forces a popout depending on platform

* Test the routeguard

* Use mockImplementation instead.

* autoclose popout
This commit is contained in:
Anders Åberg
2025-12-03 20:40:55 +01:00
committed by GitHub
parent d64da69fa7
commit 28fbddb63f
4 changed files with 261 additions and 2 deletions

View File

@@ -0,0 +1,193 @@
import { TestBed } from "@angular/core/testing";
import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
import { platformPopoutGuard } from "./platform-popout.guard";
describe("platformPopoutGuard", () => {
let getPlatformInfoSpy: jest.SpyInstance;
let inPopoutSpy: jest.SpyInstance;
let inSidebarSpy: jest.SpyInstance;
let openPopoutSpy: jest.SpyInstance;
let closePopupSpy: jest.SpyInstance;
const mockRoute = {} as ActivatedRouteSnapshot;
const mockState: RouterStateSnapshot = {
url: "/login-with-passkey?param=value",
} as RouterStateSnapshot;
beforeEach(() => {
getPlatformInfoSpy = jest.spyOn(BrowserApi, "getPlatformInfo");
inPopoutSpy = jest.spyOn(BrowserPopupUtils, "inPopout");
inSidebarSpy = jest.spyOn(BrowserPopupUtils, "inSidebar");
openPopoutSpy = jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation();
closePopupSpy = jest.spyOn(BrowserApi, "closePopup").mockImplementation();
TestBed.configureTestingModule({});
});
afterEach(() => {
jest.clearAllMocks();
});
describe("when platform matches", () => {
beforeEach(() => {
getPlatformInfoSpy.mockResolvedValue({ os: "linux" });
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it("should open popout and block navigation when not already in popout or sidebar", async () => {
const guard = platformPopoutGuard(["linux"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(getPlatformInfoSpy).toHaveBeenCalled();
expect(inPopoutSpy).toHaveBeenCalledWith(window);
expect(inSidebarSpy).toHaveBeenCalledWith(window);
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/login-with-passkey?param=value&autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
expect(result).toBe(false);
});
it("should allow navigation when already in popout", async () => {
inPopoutSpy.mockReturnValue(true);
const guard = platformPopoutGuard(["linux"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(closePopupSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
it("should allow navigation when already in sidebar", async () => {
inSidebarSpy.mockReturnValue(true);
const guard = platformPopoutGuard(["linux"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(closePopupSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
});
describe("when platform does not match", () => {
beforeEach(() => {
getPlatformInfoSpy.mockResolvedValue({ os: "win" });
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it("should allow navigation without opening popout", async () => {
const guard = platformPopoutGuard(["linux"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(getPlatformInfoSpy).toHaveBeenCalled();
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
});
describe("when forcePopout is true", () => {
beforeEach(() => {
getPlatformInfoSpy.mockResolvedValue({ os: "win" });
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it("should open popout regardless of platform", async () => {
const guard = platformPopoutGuard(["linux"], true);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/login-with-passkey?param=value&autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
expect(result).toBe(false);
});
it("should not open popout when already in popout", async () => {
inPopoutSpy.mockReturnValue(true);
const guard = platformPopoutGuard(["linux"], true);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
});
describe("with multiple platforms", () => {
beforeEach(() => {
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it.each(["linux", "mac", "win"])(
"should open popout when platform is %s and included in platforms array",
async (platform) => {
getPlatformInfoSpy.mockResolvedValue({ os: platform });
const guard = platformPopoutGuard(["linux", "mac", "win"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/login-with-passkey?param=value&autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
expect(result).toBe(false);
},
);
it("should not open popout when platform is not in the array", async () => {
getPlatformInfoSpy.mockResolvedValue({ os: "android" });
const guard = platformPopoutGuard(["linux", "mac"]);
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
expect(openPopoutSpy).not.toHaveBeenCalled();
expect(result).toBe(true);
});
});
describe("url handling", () => {
beforeEach(() => {
getPlatformInfoSpy.mockResolvedValue({ os: "linux" });
inPopoutSpy.mockReturnValue(false);
inSidebarSpy.mockReturnValue(false);
});
it("should preserve query parameters in the popout url", async () => {
const stateWithQuery: RouterStateSnapshot = {
url: "/path?foo=bar&baz=qux",
} as RouterStateSnapshot;
const guard = platformPopoutGuard(["linux"]);
await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithQuery));
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/path?foo=bar&baz=qux&autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
});
it("should handle urls without query parameters", async () => {
const stateWithoutQuery: RouterStateSnapshot = {
url: "/simple-path",
} as RouterStateSnapshot;
const guard = platformPopoutGuard(["linux"]);
await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithoutQuery));
expect(openPopoutSpy).toHaveBeenCalledWith(
"popup/index.html#/simple-path?autoClosePopout=true",
);
expect(closePopupSpy).toHaveBeenCalledWith(window);
});
});
});

View File

@@ -0,0 +1,46 @@
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from "@angular/router";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
/**
* Guard that forces a popout window for specific platforms.
* Useful when popup context would close during operations (e.g., WebAuthn on Linux).
*
* @param platforms - Array of platform OS strings (e.g., ["linux", "mac", "win"])
* @param forcePopout - If true, always force popout regardless of platform (useful for testing)
* @returns CanActivateFn that opens popout and blocks navigation if conditions met
*/
export function platformPopoutGuard(
platforms: string[],
forcePopout: boolean = false,
): CanActivateFn {
return async (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
// Check if current platform matches
const platformInfo = await BrowserApi.getPlatformInfo();
const isPlatformMatch = platforms.includes(platformInfo.os);
// Check if already in popout/sidebar
const inPopout = BrowserPopupUtils.inPopout(window);
const inSidebar = BrowserPopupUtils.inSidebar(window);
// Open popout if conditions met
if ((isPlatformMatch || forcePopout) && !inPopout && !inSidebar) {
// Add autoClosePopout query param to signal the popout should close after completion
const [path, existingQuery] = state.url.split("?");
const params = new URLSearchParams(existingQuery || "");
params.set("autoClosePopout", "true");
const urlWithAutoClose = `${path}?${params.toString()}`;
// Open the popout window
await BrowserPopupUtils.openPopout(`popup/index.html#${urlWithAutoClose}`);
// Close the original popup window
BrowserApi.closePopup(window);
return false; // Block navigation - popout will reload
}
return true; // Allow navigation
};
}

View File

@@ -48,6 +48,7 @@ import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/ke
import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component";
import { AuthExtensionRoute } from "../auth/popup/constants/auth-extension-route.constant";
import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard";
import { platformPopoutGuard } from "../auth/popup/guards/platform-popout.guard";
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
import { ExtensionDeviceManagementComponent } from "../auth/popup/settings/extension-device-management.component";
import { Fido2Component } from "../autofill/popup/fido2/fido2.component";
@@ -414,7 +415,7 @@ const routes: Routes = [
},
{
path: AuthRoute.LoginWithPasskey,
canActivate: [unauthGuardFn(unauthRouteOverrides)],
canActivate: [unauthGuardFn(unauthRouteOverrides), platformPopoutGuard(["linux"])],
data: {
pageIcon: TwoFactorAuthSecurityKeyIcon,
pageTitle: {