From 3ea81f3e80a3373d16739bcccf764a3475307d0d Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:40:03 +0100 Subject: [PATCH] [PM-29149] Add ServerCommunicationConfigService (#18815) * Add state- and key-definitions for persisting serverCommunicationConfig(s) * Add implementation of the SDK-defined ServerCommunicationConfigRepository * Add ServerCommunicationConfigService --------- Co-authored-by: Daniel James Smith --- .../server-communication-config.service.ts | 27 +++ ...erver-communication-config.service.spec.ts | 146 +++++++++++++ ...ult-server-communication-config.service.ts | 47 +++++ .../server-communication-config/index.ts | 3 + ...er-communication-config.repository.spec.ts | 193 ++++++++++++++++++ .../server-communication-config.repository.ts | 57 ++++++ .../server-communication-config.state.ts | 18 ++ libs/state/src/core/state-definitions.ts | 4 + 8 files changed, 495 insertions(+) create mode 100644 libs/common/src/platform/abstractions/server-communication-config/server-communication-config.service.ts create mode 100644 libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.spec.ts create mode 100644 libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.ts create mode 100644 libs/common/src/platform/services/server-communication-config/index.ts create mode 100644 libs/common/src/platform/services/server-communication-config/server-communication-config.repository.spec.ts create mode 100644 libs/common/src/platform/services/server-communication-config/server-communication-config.repository.ts create mode 100644 libs/common/src/platform/services/server-communication-config/server-communication-config.state.ts diff --git a/libs/common/src/platform/abstractions/server-communication-config/server-communication-config.service.ts b/libs/common/src/platform/abstractions/server-communication-config/server-communication-config.service.ts new file mode 100644 index 00000000000..19afebaa516 --- /dev/null +++ b/libs/common/src/platform/abstractions/server-communication-config/server-communication-config.service.ts @@ -0,0 +1,27 @@ +import { Observable } from "rxjs"; + +/** + * Service for managing server communication configuration, + * including bootstrap detection and cookie management. + */ +export abstract class ServerCommunicationConfigService { + /** + * Observable that emits true when the specified hostname + * requires bootstrap (cookie acquisition) before API calls can succeed. + * + * Automatically updates when server communication config state changes. + * + * @param hostname - The server hostname (e.g., "vault.acme.com") + * @returns Observable that emits bootstrap status for the hostname + */ + abstract needsBootstrap$(hostname: string): Observable; + + /** + * Retrieves cookies that should be included in HTTP requests + * to the specified hostname. + * + * @param hostname - The server hostname + * @returns Promise resolving to array of [cookie_name, cookie_value] tuples + */ + abstract getCookies(hostname: string): Promise>; +} diff --git a/libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.spec.ts b/libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.spec.ts new file mode 100644 index 00000000000..8e565d7ee1c --- /dev/null +++ b/libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.spec.ts @@ -0,0 +1,146 @@ +import { firstValueFrom } from "rxjs"; + +import { ServerCommunicationConfig } from "@bitwarden/sdk-internal"; + +import { awaitAsync, FakeAccountService, FakeStateProvider } from "../../../../spec"; + +import { DefaultServerCommunicationConfigService } from "./default-server-communication-config.service"; +import { ServerCommunicationConfigRepository } from "./server-communication-config.repository"; + +// Mock SDK client +jest.mock("@bitwarden/sdk-internal", () => ({ + ServerCommunicationConfigClient: jest.fn().mockImplementation(() => ({ + needsBootstrap: jest.fn(), + cookies: jest.fn(), + getConfig: jest.fn(), + })), +})); + +describe("DefaultServerCommunicationConfigService", () => { + let stateProvider: FakeStateProvider; + let repository: ServerCommunicationConfigRepository; + let service: DefaultServerCommunicationConfigService; + let mockClient: any; + + beforeEach(() => { + const accountService = new FakeAccountService({}); + stateProvider = new FakeStateProvider(accountService); + repository = new ServerCommunicationConfigRepository(stateProvider); + service = new DefaultServerCommunicationConfigService(repository); + mockClient = (service as any).client; + }); + + describe("needsBootstrap$", () => { + it("emits false when direct bootstrap configured", async () => { + mockClient.needsBootstrap.mockResolvedValue(false); + + const result = await firstValueFrom(service.needsBootstrap$("vault.bitwarden.com")); + + expect(result).toBe(false); + expect(mockClient.needsBootstrap).toHaveBeenCalledWith("vault.bitwarden.com"); + }); + + it("emits true when SSO cookie vendor bootstrap needed", async () => { + mockClient.needsBootstrap.mockResolvedValue(true); + + const result = await firstValueFrom(service.needsBootstrap$("vault.acme.com")); + + expect(result).toBe(true); + expect(mockClient.needsBootstrap).toHaveBeenCalledWith("vault.acme.com"); + }); + + it("re-emits when config state changes", async () => { + mockClient.needsBootstrap.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + const observable = service.needsBootstrap$("vault.bitwarden.com"); + const emissions: boolean[] = []; + + // Subscribe to collect emissions + const subscription = observable.subscribe((value) => emissions.push(value)); + + // Wait for first emission + await awaitAsync(); + expect(emissions[0]).toBe(false); + + // Update config state to trigger re-check + const config: ServerCommunicationConfig = { + bootstrap: { type: "direct" }, + }; + await repository.save("vault.bitwarden.com", config); + + // Wait for second emission + await awaitAsync(); + expect(emissions[1]).toBe(true); + + subscription.unsubscribe(); + }); + + it("creates independent observables per hostname", async () => { + mockClient.needsBootstrap.mockImplementation(async (hostname: string) => { + return hostname === "vault1.acme.com"; + }); + + const result1 = await firstValueFrom(service.needsBootstrap$("vault1.acme.com")); + const result2 = await firstValueFrom(service.needsBootstrap$("vault2.acme.com")); + + expect(result1).toBe(true); + expect(result2).toBe(false); + expect(mockClient.needsBootstrap).toHaveBeenCalledWith("vault1.acme.com"); + expect(mockClient.needsBootstrap).toHaveBeenCalledWith("vault2.acme.com"); + }); + + it("shares result between simultaneous subscribers", async () => { + mockClient.needsBootstrap.mockResolvedValue(true); + + const observable = service.needsBootstrap$("vault.bitwarden.com"); + + // Multiple simultaneous subscribers should share the same call + const [result1, result2] = await Promise.all([ + firstValueFrom(observable), + firstValueFrom(observable), + ]); + + expect(result1).toBe(true); + expect(result2).toBe(true); + // Should only call once for simultaneous subscribers + expect(mockClient.needsBootstrap).toHaveBeenCalledTimes(1); + }); + }); + + describe("getCookies", () => { + it("retrieves cookies for hostname", async () => { + const expectedCookies: Array<[string, string]> = [ + ["auth_token", "abc123"], + ["session_id", "xyz789"], + ]; + mockClient.cookies.mockResolvedValue(expectedCookies); + + const result = await service.getCookies("vault.bitwarden.com"); + + expect(result).toEqual(expectedCookies); + expect(mockClient.cookies).toHaveBeenCalledWith("vault.bitwarden.com"); + }); + + it("returns empty array when no cookies configured", async () => { + mockClient.cookies.mockResolvedValue([]); + + const result = await service.getCookies("vault.bitwarden.com"); + + expect(result).toEqual([]); + expect(mockClient.cookies).toHaveBeenCalledWith("vault.bitwarden.com"); + }); + + it("handles different hostnames independently", async () => { + mockClient.cookies + .mockResolvedValueOnce([["cookie1", "value1"]]) + .mockResolvedValueOnce([["cookie2", "value2"]]); + + const result1 = await service.getCookies("vault1.acme.com"); + const result2 = await service.getCookies("vault2.acme.com"); + + expect(result1).toEqual([["cookie1", "value1"]]); + expect(result2).toEqual([["cookie2", "value2"]]); + expect(mockClient.cookies).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.ts b/libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.ts new file mode 100644 index 00000000000..b9194981622 --- /dev/null +++ b/libs/common/src/platform/services/server-communication-config/default-server-communication-config.service.ts @@ -0,0 +1,47 @@ +import { Observable, shareReplay, switchMap } from "rxjs"; + +import { ServerCommunicationConfigClient } from "@bitwarden/sdk-internal"; + +import { ServerCommunicationConfigService } from "../../abstractions/server-communication-config/server-communication-config.service"; + +import { ServerCommunicationConfigRepository } from "./server-communication-config.repository"; + +/** + * Default implementation of ServerCommunicationConfigService. + * + * Manages server communication configuration and bootstrap detection for different + * server environments. Provides reactive observables that automatically respond to + * configuration changes and integrate with the SDK's ServerCommunicationConfigClient. + * + * @remarks + * Bootstrap detection determines if SSO cookie acquisition is required before + * API calls can succeed. The service watches for configuration changes and + * re-evaluates bootstrap requirements automatically. + * + * Key features: + * - Reactive observables for bootstrap status (`needsBootstrap$`) + * - Per-hostname configuration management + * - Automatic re-evaluation when config state changes + * - Cookie retrieval for HTTP request headers + * + */ +export class DefaultServerCommunicationConfigService implements ServerCommunicationConfigService { + private client: ServerCommunicationConfigClient; + + constructor(private repository: ServerCommunicationConfigRepository) { + // Initialize SDK client with repository + this.client = new ServerCommunicationConfigClient(repository); + } + + needsBootstrap$(hostname: string): Observable { + // Watch hostname-specific config changes and re-check when it updates + return this.repository.get$(hostname).pipe( + switchMap(() => this.client.needsBootstrap(hostname)), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + } + + async getCookies(hostname: string): Promise> { + return this.client.cookies(hostname); + } +} diff --git a/libs/common/src/platform/services/server-communication-config/index.ts b/libs/common/src/platform/services/server-communication-config/index.ts new file mode 100644 index 00000000000..7d6bac1c067 --- /dev/null +++ b/libs/common/src/platform/services/server-communication-config/index.ts @@ -0,0 +1,3 @@ +export { ServerCommunicationConfigRepository } from "./server-communication-config.repository"; +export { DefaultServerCommunicationConfigService } from "./default-server-communication-config.service"; +export { SERVER_COMMUNICATION_CONFIGS } from "./server-communication-config.state"; diff --git a/libs/common/src/platform/services/server-communication-config/server-communication-config.repository.spec.ts b/libs/common/src/platform/services/server-communication-config/server-communication-config.repository.spec.ts new file mode 100644 index 00000000000..2ed16e96c11 --- /dev/null +++ b/libs/common/src/platform/services/server-communication-config/server-communication-config.repository.spec.ts @@ -0,0 +1,193 @@ +import { ServerCommunicationConfig } from "@bitwarden/sdk-internal"; + +import { FakeAccountService, FakeStateProvider } from "../../../../spec"; + +import { ServerCommunicationConfigRepository } from "./server-communication-config.repository"; + +describe("ServerCommunicationConfigRepository", () => { + let stateProvider: FakeStateProvider; + let repository: ServerCommunicationConfigRepository; + + beforeEach(() => { + const accountService = new FakeAccountService({}); + stateProvider = new FakeStateProvider(accountService); + repository = new ServerCommunicationConfigRepository(stateProvider); + }); + + it("returns undefined when no config exists for hostname", async () => { + const result = await repository.get("vault.acme.com"); + + expect(result).toBeUndefined(); + }); + + it("saves and retrieves a direct bootstrap config", async () => { + const config: ServerCommunicationConfig = { + bootstrap: { type: "direct" }, + }; + + await repository.save("vault.acme.com", config); + const result = await repository.get("vault.acme.com"); + + expect(result).toEqual(config); + }); + + it("saves and retrieves an SSO cookie vendor config", async () => { + const config: ServerCommunicationConfig = { + bootstrap: { + type: "ssoCookieVendor", + idp_login_url: "https://idp.example.com/login", + cookie_name: "auth_token", + cookie_domain: ".acme.com", + cookie_value: "abc123", + }, + }; + + await repository.save("vault.acme.com", config); + const result = await repository.get("vault.acme.com"); + + expect(result).toEqual(config); + }); + + it("handles SSO config with undefined cookie_value", async () => { + const config: ServerCommunicationConfig = { + bootstrap: { + type: "ssoCookieVendor", + idp_login_url: "https://idp.example.com/login", + cookie_name: "auth_token", + cookie_domain: ".acme.com", + cookie_value: undefined, + }, + }; + + await repository.save("vault.acme.com", config); + const result = await repository.get("vault.acme.com"); + + expect(result).toEqual(config); + }); + + it("overwrites existing config for same hostname", async () => { + const initialConfig: ServerCommunicationConfig = { + bootstrap: { type: "direct" }, + }; + await repository.save("vault.acme.com", initialConfig); + + const newConfig: ServerCommunicationConfig = { + bootstrap: { + type: "ssoCookieVendor", + idp_login_url: "https://idp.example.com", + cookie_name: "token", + cookie_domain: ".acme.com", + cookie_value: "xyz789", + }, + }; + await repository.save("vault.acme.com", newConfig); + + const result = await repository.get("vault.acme.com"); + expect(result).toEqual(newConfig); + }); + + it("maintains separate configs for different hostnames", async () => { + const config1: ServerCommunicationConfig = { + bootstrap: { type: "direct" }, + }; + const config2: ServerCommunicationConfig = { + bootstrap: { + type: "ssoCookieVendor", + idp_login_url: "https://idp.example.com", + cookie_name: "token", + cookie_domain: ".example.com", + cookie_value: "token123", + }, + }; + + await repository.save("vault1.acme.com", config1); + await repository.save("vault2.example.com", config2); + + const result1 = await repository.get("vault1.acme.com"); + const result2 = await repository.get("vault2.example.com"); + + expect(result1).toEqual(config1); + expect(result2).toEqual(config2); + }); + + describe("get$", () => { + it("emits undefined for hostname with no config", (done) => { + repository.get$("vault.acme.com").subscribe((config) => { + expect(config).toBeUndefined(); + done(); + }); + }); + + it("emits config when it exists", async () => { + const config: ServerCommunicationConfig = { + bootstrap: { type: "direct" }, + }; + + await repository.save("vault.acme.com", config); + + repository.get$("vault.acme.com").subscribe((result) => { + expect(result).toEqual(config); + }); + }); + + it("emits new value when config changes", (done) => { + const config1: ServerCommunicationConfig = { + bootstrap: { type: "direct" }, + }; + const config2: ServerCommunicationConfig = { + bootstrap: { + type: "ssoCookieVendor", + idp_login_url: "https://idp.example.com", + cookie_name: "token", + cookie_domain: ".acme.com", + cookie_value: "abc123", + }, + }; + + const emissions: (ServerCommunicationConfig | undefined)[] = []; + const subscription = repository.get$("vault.acme.com").subscribe((config) => { + emissions.push(config); + + if (emissions.length === 3) { + expect(emissions[0]).toBeUndefined(); + expect(emissions[1]).toEqual(config1); + expect(emissions[2]).toEqual(config2); + subscription.unsubscribe(); + done(); + } + }); + + // Trigger updates + setTimeout(async () => { + await repository.save("vault.acme.com", config1); + setTimeout(async () => { + await repository.save("vault.acme.com", config2); + }, 10); + }, 10); + }); + + it("only emits when the specific hostname config changes", (done) => { + const config1: ServerCommunicationConfig = { + bootstrap: { type: "direct" }, + }; + + const emissions: (ServerCommunicationConfig | undefined)[] = []; + const subscription = repository.get$("vault1.acme.com").subscribe((config) => { + emissions.push(config); + }); + + setTimeout(async () => { + // Save config for different hostname - should not trigger emission for vault1 + await repository.save("vault2.acme.com", config1); + + setTimeout(() => { + // Only initial undefined emission should exist + expect(emissions.length).toBe(1); + expect(emissions[0]).toBeUndefined(); + subscription.unsubscribe(); + done(); + }, 50); + }, 10); + }); + }); +}); diff --git a/libs/common/src/platform/services/server-communication-config/server-communication-config.repository.ts b/libs/common/src/platform/services/server-communication-config/server-communication-config.repository.ts new file mode 100644 index 00000000000..7ca38be1e6e --- /dev/null +++ b/libs/common/src/platform/services/server-communication-config/server-communication-config.repository.ts @@ -0,0 +1,57 @@ +import { distinctUntilChanged, firstValueFrom, map, Observable } from "rxjs"; + +import { + ServerCommunicationConfigRepository as SdkRepository, + ServerCommunicationConfig, +} from "@bitwarden/sdk-internal"; + +import { GlobalState, StateProvider } from "../../state"; + +import { SERVER_COMMUNICATION_CONFIGS } from "./server-communication-config.state"; + +/** + * Implementation of SDK-defined interface. + * Bridges the SDK's repository abstraction with StateProvider for persistence. + * + * This repository manages server communication configurations keyed by hostname, + * storing information about bootstrap requirements (direct vs SSO cookie vendor) + * for each server environment. + * + * @remarks + * - Uses global state (application-level, not user-scoped) + * - Configurations persist across sessions (stored on disk) + * - Each hostname maintains independent configuration + * - All error handling is performed by the SDK caller + * + */ +export class ServerCommunicationConfigRepository implements SdkRepository { + private state: GlobalState>; + + constructor(private stateProvider: StateProvider) { + this.state = this.stateProvider.getGlobal(SERVER_COMMUNICATION_CONFIGS); + } + + async get(hostname: string): Promise { + return firstValueFrom(this.get$(hostname)); + } + + /** + * Observable that emits when the configuration for a specific hostname changes. + * + * @param hostname - The server hostname + * @returns Observable that emits the config for the hostname, or undefined if not set + */ + get$(hostname: string): Observable { + return this.state.state$.pipe( + map((configs) => configs?.[hostname]), + distinctUntilChanged(), + ); + } + + async save(hostname: string, config: ServerCommunicationConfig): Promise { + await this.state.update((configs) => ({ + ...configs, + [hostname]: config, + })); + } +} diff --git a/libs/common/src/platform/services/server-communication-config/server-communication-config.state.ts b/libs/common/src/platform/services/server-communication-config/server-communication-config.state.ts new file mode 100644 index 00000000000..65bc692df5f --- /dev/null +++ b/libs/common/src/platform/services/server-communication-config/server-communication-config.state.ts @@ -0,0 +1,18 @@ +import { ServerCommunicationConfig } from "@bitwarden/sdk-internal"; + +import { KeyDefinition, SERVER_COMMUNICATION_CONFIG_DISK } from "../../state"; + +/** + * Key definition for server communication configurations. + * + * Record type: Maps hostname (string) to ServerCommunicationConfig + * Storage: Disk (persisted across sessions) + * Scope: Global (application-level, not user-specific) + */ +export const SERVER_COMMUNICATION_CONFIGS = KeyDefinition.record( + SERVER_COMMUNICATION_CONFIG_DISK, + "configs", + { + deserializer: (value: ServerCommunicationConfig) => value, + }, +); diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index ae6938b2069..f9113b7e64e 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -137,6 +137,10 @@ export const EXTENSION_INITIAL_INSTALL_DISK = new StateDefinition( export const WEB_PUSH_SUBSCRIPTION = new StateDefinition("webPushSubscription", "disk", { web: "disk-local", }); +export const SERVER_COMMUNICATION_CONFIG_DISK = new StateDefinition( + "serverCommunicationConfig", + "disk", +); // Design System