1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-18 09:13:33 +00:00

[PM-21024] Use Server for Password Change URLs (#14912)

* migrate change login password service to use bitwarden server rather than fetch directly

- avoids CSP entirely

* add `HelpUsersUpdatePasswords` policy to policy type

* add `HelpUsersUpdatePasswordsPolicy` components

* allow list description override for policy description

* add `HelpUsersUpdatePasswordsPolicy` when the feature flag is enabled

* apply `HelpUsersUpdatePasswords` to everyone in an org

* use policy to guard the well known password API

* fix tests

* refactor to use `policyAppliesToUser$`

* remove policy work for change password - this was removed from scope

* update copy for show favicon setting - it now handles both favicons and change password urls

* remove favicon setting description - no longer needed

* only call change password service when the setting is enabled

* add popover for permitting cipher details

* import permit popover directly into the settings component

* replace `nativeFetch` with `fetch`

* use string literal to construct URL rather than `URL` class
- The `getIconsUrl` can return with an appended path which the new URL constructor will strip when passed as the base parameter

* use string literal to construct URL rather than `URL` class instance (#16045)

- The `getIconsUrl` can return with an appended path which the new URL constructor will strip when passed as the base parameter

* [PM-24716] UI changes for Change URI work (#16043)

* use platform service to launch the URI
- this allows desktop to open a separate browser instance rather than use electron

* fix spacing on web app

* add bitLink for focus/hover states

* remove spacing around links
This commit is contained in:
Nick Krantz
2025-08-27 09:03:44 -05:00
committed by GitHub
parent 7ccb94d0a2
commit fcc2bc96d1
16 changed files with 205 additions and 180 deletions

View File

@@ -4,10 +4,14 @@
*/
import { mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ClientType } from "@bitwarden/common/enums";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import {
Environment,
EnvironmentService,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
@@ -18,37 +22,30 @@ import { DefaultChangeLoginPasswordService } from "./default-change-login-passwo
describe("DefaultChangeLoginPasswordService", () => {
let service: DefaultChangeLoginPasswordService;
let mockShouldNotExistResponse: Response;
let mockWellKnownResponse: Response;
const getClientType = jest.fn(() => ClientType.Browser);
const mockApiService = mock<ApiService>();
const platformUtilsService = mock<PlatformUtilsService>({
getClientType,
});
const mockDomainSettingsService = mock<DomainSettingsService>();
const showFavicons$ = new BehaviorSubject<boolean>(true);
beforeEach(() => {
mockApiService.nativeFetch.mockClear();
mockApiService.fetch.mockClear();
mockApiService.fetch.mockImplementation(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ uri: null }) } as Response),
);
// Default responses to success state
mockShouldNotExistResponse = new Response("Not Found", { status: 404 });
mockWellKnownResponse = new Response("OK", { status: 200 });
mockDomainSettingsService.showFavicons$ = showFavicons$;
mockApiService.nativeFetch.mockImplementation((request) => {
if (
request.url.endsWith("resource-that-should-not-exist-whose-status-code-should-not-be-200")
) {
return Promise.resolve(mockShouldNotExistResponse);
}
const mockEnvironmentService = {
environment$: of({
getIconsUrl: () => "https://icons.bitwarden.com",
} as Environment),
} as EnvironmentService;
if (request.url.endsWith(".well-known/change-password")) {
return Promise.resolve(mockWellKnownResponse);
}
throw new Error("Unexpected request");
});
service = new DefaultChangeLoginPasswordService(mockApiService, platformUtilsService);
service = new DefaultChangeLoginPasswordService(
mockApiService,
mockEnvironmentService,
mockDomainSettingsService,
);
});
it("should return null for non-login ciphers", async () => {
@@ -85,7 +82,7 @@ describe("DefaultChangeLoginPasswordService", () => {
expect(url).toBeNull();
});
it("should check the origin for a reliable status code", async () => {
it("should call the icons url endpoint", async () => {
const cipher = {
type: CipherType.Login,
login: Object.assign(new LoginView(), {
@@ -95,35 +92,42 @@ describe("DefaultChangeLoginPasswordService", () => {
await service.getChangePasswordUrl(cipher);
expect(mockApiService.nativeFetch).toHaveBeenCalledWith(
expect(mockApiService.fetch).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://example.com/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200",
url: "https://icons.bitwarden.com/change-password-uri?uri=https%3A%2F%2Fexample.com%2F",
}),
);
});
it("should attempt to fetch the well-known change password URL", async () => {
it("should return the original URI when unable to verify the response", async () => {
mockApiService.fetch.mockImplementation(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ uri: null }) } as Response),
);
const cipher = {
type: CipherType.Login,
login: Object.assign(new LoginView(), {
uris: [{ uri: "https://example.com" }],
uris: [{ uri: "https://example.com/" }],
}),
} as CipherView;
await service.getChangePasswordUrl(cipher);
const url = await service.getChangePasswordUrl(cipher);
expect(mockApiService.nativeFetch).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://example.com/.well-known/change-password",
}),
);
expect(url).toBe("https://example.com/");
});
it("should return the well-known change password URL when successful at verifying the response", async () => {
it("should return the well known change url from the response", async () => {
mockApiService.fetch.mockImplementation(() => {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ uri: "https://example.com/.well-known/change-password" }),
} as Response);
});
const cipher = {
type: CipherType.Login,
login: Object.assign(new LoginView(), {
uris: [{ uri: "https://example.com" }],
uris: [{ uri: "https://example.com/" }, { uri: "https://working.com/" }],
}),
} as CipherView;
@@ -132,49 +136,20 @@ describe("DefaultChangeLoginPasswordService", () => {
expect(url).toBe("https://example.com/.well-known/change-password");
});
it("should return the original URI when unable to verify the response", async () => {
mockShouldNotExistResponse = new Response("Ok", { status: 200 });
const cipher = {
type: CipherType.Login,
login: Object.assign(new LoginView(), {
uris: [{ uri: "https://example.com/" }],
}),
} as CipherView;
const url = await service.getChangePasswordUrl(cipher);
expect(url).toBe("https://example.com/");
});
it("should return the original URI when the well-known URL is not found", async () => {
mockWellKnownResponse = new Response("Not Found", { status: 404 });
const cipher = {
type: CipherType.Login,
login: Object.assign(new LoginView(), {
uris: [{ uri: "https://example.com/" }],
}),
} as CipherView;
const url = await service.getChangePasswordUrl(cipher);
expect(url).toBe("https://example.com/");
});
it("should try the next URI if the first one fails", async () => {
mockApiService.nativeFetch.mockImplementation((request) => {
if (
request.url.endsWith("resource-that-should-not-exist-whose-status-code-should-not-be-200")
) {
return Promise.resolve(mockShouldNotExistResponse);
mockApiService.fetch.mockImplementation((request) => {
if (request.url.includes("no-wellknown.com")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ uri: null }),
} as Response);
}
if (request.url.endsWith(".well-known/change-password")) {
if (request.url.includes("working.com")) {
return Promise.resolve(mockWellKnownResponse);
}
return Promise.resolve(new Response("Not Found", { status: 404 }));
if (request.url.includes("working.com")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ uri: "https://working.com/.well-known/change-password" }),
} as Response);
}
throw new Error("Unexpected request");
@@ -192,19 +167,19 @@ describe("DefaultChangeLoginPasswordService", () => {
expect(url).toBe("https://working.com/.well-known/change-password");
});
it("should return the first URI when the client type is not browser", async () => {
getClientType.mockReturnValue(ClientType.Web);
it("returns the first URI when `showFavicons$` setting is disabled", async () => {
showFavicons$.next(false);
const cipher = {
type: CipherType.Login,
login: Object.assign(new LoginView(), {
uris: [{ uri: "https://example.com/" }, { uri: "https://example-2.com/" }],
uris: [{ uri: "https://example.com/" }, { uri: "https://another.com/" }],
}),
} as CipherView;
const url = await service.getChangePasswordUrl(cipher);
expect(mockApiService.nativeFetch).not.toHaveBeenCalled();
expect(url).toBe("https://example.com/");
expect(mockApiService.fetch).not.toHaveBeenCalled();
});
});