diff --git a/libs/common/src/platform/abstractions/config/config.service.ts b/libs/common/src/platform/abstractions/config/config.service.ts index 04f150838e4..0c128bc28f6 100644 --- a/libs/common/src/platform/abstractions/config/config.service.ts +++ b/libs/common/src/platform/abstractions/config/config.service.ts @@ -3,6 +3,8 @@ import { Observable } from "rxjs"; import { SemVer } from "semver"; +import { ServerCommunicationConfig } from "@bitwarden/sdk-internal"; + import { FeatureFlag, FeatureFlagValueType } from "../../../enums/feature-flag.enum"; import { UserId } from "../../../types/guid"; import { ServerSettings } from "../../models/domain/server-settings"; @@ -13,6 +15,12 @@ import { ServerConfig } from "./server-config"; export abstract class ConfigService { /** The server config of the currently active user */ serverConfig$: Observable; + /** + * Emits whenever a server config is successfully fetched, pairing the server's + * API URL with the parsed ServerCommunicationConfig. Use this to react to + * communication config changes without coupling to the config fetch pipeline. + */ + serverCommunicationConfig$: Observable<{ hostname: string; config: ServerCommunicationConfig }>; /** The server settings of the currently active user */ serverSettings$: Observable; /** The cloud region of the currently active user */ diff --git a/libs/common/src/platform/models/response/server-config.response.ts b/libs/common/src/platform/models/response/server-config.response.ts index afe98c2c349..56efd8e59e4 100644 --- a/libs/common/src/platform/models/response/server-config.response.ts +++ b/libs/common/src/platform/models/response/server-config.response.ts @@ -13,6 +13,7 @@ export class ServerConfigResponse extends BaseResponse { featureStates: { [key: string]: AllowedFeatureFlagTypes } = {}; push: PushSettingsConfigResponse; settings: ServerSettings; + communication: CommunicationServerConfigResponse; constructor(response: any) { super(response); @@ -28,6 +29,9 @@ export class ServerConfigResponse extends BaseResponse { this.featureStates = this.getResponseProperty("FeatureStates"); this.push = new PushSettingsConfigResponse(this.getResponseProperty("Push")); this.settings = new ServerSettings(this.getResponseProperty("Settings")); + this.communication = new CommunicationServerConfigResponse( + this.getResponseProperty("Communication"), + ); } } @@ -86,3 +90,37 @@ export class ThirdPartyServerConfigResponse extends BaseResponse { this.url = this.getResponseProperty("Url"); } } + +export class CommunicationServerConfigResponse extends BaseResponse { + bootstrap: BootstrapServerConfigResponse; + + constructor(data: any = null) { + super(data); + + if (data == null) { + return; + } + + this.bootstrap = new BootstrapServerConfigResponse(this.getResponseProperty("Bootstrap")); + } +} + +export class BootstrapServerConfigResponse extends BaseResponse { + type: string; + idpLoginUrl: string; + cookieName: string; + cookieDomain: string; + + constructor(data: any = null) { + super(data); + + if (data == null) { + return; + } + + this.type = this.getResponseProperty("Type"); + this.idpLoginUrl = this.getResponseProperty("IdpLoginUrl"); + this.cookieName = this.getResponseProperty("CookieName"); + this.cookieDomain = this.getResponseProperty("CookieDomain"); + } +} diff --git a/libs/common/src/platform/services/config/config.service.spec.ts b/libs/common/src/platform/services/config/config.service.spec.ts index e8a1872c4c1..55e4e0d6980 100644 --- a/libs/common/src/platform/services/config/config.service.spec.ts +++ b/libs/common/src/platform/services/config/config.service.spec.ts @@ -166,6 +166,46 @@ describe("ConfigService", () => { expect(actual).toEqual(newConfig); }); + + describe("serverCommunicationConfig$", () => { + it("emits direct bootstrap config when response has no communication config", async () => { + await firstValueFrom(sut.serverConfig$); + + const result = await firstValueFrom(sut.serverCommunicationConfig$); + + expect(result).toEqual({ + hostname: activeApiUrl, + config: { bootstrap: { type: "direct" } }, + }); + }); + + it("emits ssoCookieVendor config when response includes ssoCookieVendor bootstrap", async () => { + const ssoResponse = serverConfigResponseFactory("hash", { + type: "ssoCookieVendor", + idpLoginUrl: "https://idp.example.com", + cookieName: "auth_token", + cookieDomain: ".example.com", + }); + configApiService.get.mockResolvedValue(ssoResponse); + + await firstValueFrom(sut.serverConfig$); + + const result = await firstValueFrom(sut.serverCommunicationConfig$); + + expect(result).toEqual({ + hostname: activeApiUrl, + config: { + bootstrap: { + type: "ssoCookieVendor", + idpLoginUrl: "https://idp.example.com", + cookieName: "auth_token", + cookieDomain: ".example.com", + cookieValue: undefined, + }, + }, + }); + }); + }); }); }); @@ -375,7 +415,10 @@ function serverConfigDataFactory(hash?: string) { return new ServerConfigData(serverConfigResponseFactory(hash)); } -function serverConfigResponseFactory(hash?: string) { +function serverConfigResponseFactory( + hash?: string, + bootstrap?: { type: string; idpLoginUrl?: string; cookieName?: string; cookieDomain?: string }, +) { return new ServerConfigResponse({ version: "myConfigVersion", gitHash: hash ?? Utils.newGuid(), // Use optional git hash to store uniqueness value @@ -391,6 +434,7 @@ function serverConfigResponseFactory(hash?: string) { feat2: "on", feat3: "off", }, + ...(bootstrap != null ? { communication: { bootstrap } } : {}), }); } diff --git a/libs/common/src/platform/services/config/default-config.service.ts b/libs/common/src/platform/services/config/default-config.service.ts index 2dad227876e..a30b154a88f 100644 --- a/libs/common/src/platform/services/config/default-config.service.ts +++ b/libs/common/src/platform/services/config/default-config.service.ts @@ -16,6 +16,8 @@ import { } from "rxjs"; import { SemVer } from "semver"; +import { ServerCommunicationConfig } from "@bitwarden/sdk-internal"; + import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { FeatureFlag, getFeatureFlagValue } from "../../../enums/feature-flag.enum"; @@ -28,6 +30,7 @@ import { LogService } from "../../abstractions/log.service"; import { devFlagEnabled, devFlagValue } from "../../misc/flags"; import { ServerConfigData } from "../../models/data/server-config.data"; import { ServerSettings } from "../../models/domain/server-settings"; +import { ServerConfigResponse } from "../../models/response/server-config.response"; import { CONFIG_DISK, KeyDefinition, StateProvider, UserKeyDefinition } from "../../state"; export const RETRIEVAL_INTERVAL = devFlagEnabled("configRetrievalIntervalMs") @@ -58,8 +61,13 @@ const environmentComparer = (previous: Environment, current: Environment) => { // FIXME: currently we are limited to api requests for active users. Update to accept a UserId and APIUrl once ApiService supports it. export class DefaultConfigService implements ConfigService { private failedFetchFallbackSubject = new Subject(); + private serverCommunicationConfigSubject = new ReplaySubject<{ + hostname: string; + config: ServerCommunicationConfig; + }>(1); serverConfig$: Observable; + serverCommunicationConfig$ = this.serverCommunicationConfigSubject.asObservable(); serverSettings$: Observable; @@ -206,6 +214,8 @@ export class DefaultConfigService implements ConfigService { clearTimeout(handle); const newConfig = new ServerConfig(new ServerConfigData(response)); + this.parseBoostrapConfig(environment.getApiUrl(), response); + // Update the environment region if ( newConfig?.environment?.cloudRegion != null && @@ -241,4 +251,26 @@ export class DefaultConfigService implements ConfigService { private userConfigFor$(userId: UserId): Observable { return this.stateProvider.getUser(userId, USER_SERVER_CONFIG).state$; } + + private parseBoostrapConfig(hostname: string, response: ServerConfigResponse) { + const bootstrap = response.communication?.bootstrap ?? null; + + // Emit communication config so subscribers (e.g. ServerCommunicationConfigService) can persist it + const communicationConfig: ServerCommunicationConfig = + bootstrap?.type === "ssoCookieVendor" + ? { + bootstrap: { + type: "ssoCookieVendor", + idpLoginUrl: bootstrap.idpLoginUrl, + cookieName: bootstrap.cookieName, + cookieDomain: bootstrap.cookieDomain, + cookieValue: undefined, + }, + } + : { bootstrap: { type: "direct" } }; + this.serverCommunicationConfigSubject.next({ + hostname, + config: communicationConfig, + }); + } }