1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-26 09:33:22 +00:00

Parse the CommunicationServerConfigResponse within the ConfigService and expose changes via an observable. (#19184)

Model was introduced on the server with https://github.com/bitwarden/server/pull/6892

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
This commit is contained in:
Daniel James Smith
2026-02-24 21:03:44 +01:00
committed by GitHub
parent 0a1baa7e42
commit 181e4767d8
4 changed files with 123 additions and 1 deletions

View File

@@ -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<ServerConfig | null>;
/**
* 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<ServerSettings | null>;
/** The cloud region of the currently active user */

View File

@@ -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");
}
}

View File

@@ -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 } } : {}),
});
}

View File

@@ -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<ServerConfig | null>();
private serverCommunicationConfigSubject = new ReplaySubject<{
hostname: string;
config: ServerCommunicationConfig;
}>(1);
serverConfig$: Observable<ServerConfig | null>;
serverCommunicationConfig$ = this.serverCommunicationConfigSubject.asObservable();
serverSettings$: Observable<ServerSettings>;
@@ -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<ServerConfig | null> {
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,
});
}
}