mirror of
https://github.com/bitwarden/browser
synced 2025-12-13 14:53:33 +00:00
Autofill/pm 25597 plex password generation (#16997)
* Correctly fill generated passwords and current password on plex.tv * Correctly fill generated passwords and current password on plex.tv * Leave existing forEach * Add tests for changes
This commit is contained in:
@@ -3286,6 +3286,9 @@ describe("OverlayBackground", () => {
|
||||
pageDetails: [pageDetailsForTab],
|
||||
fillNewPassword: true,
|
||||
allowTotpAutofill: true,
|
||||
focusedFieldForm: undefined,
|
||||
focusedFieldOpid: undefined,
|
||||
inlineMenuFillType: undefined,
|
||||
});
|
||||
expect(overlayBackground["inlineMenuCiphers"].entries()).toStrictEqual(
|
||||
new Map([
|
||||
@@ -3680,6 +3683,9 @@ describe("OverlayBackground", () => {
|
||||
pageDetails: [overlayBackground["pageDetailsForTab"][sender.tab.id].get(sender.frameId)],
|
||||
fillNewPassword: true,
|
||||
allowTotpAutofill: false,
|
||||
focusedFieldForm: undefined,
|
||||
focusedFieldOpid: undefined,
|
||||
inlineMenuFillType: InlineMenuFillTypes.PasswordGeneration,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1177,6 +1177,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
||||
allowTotpAutofill: true,
|
||||
focusedFieldForm: this.focusedFieldData?.focusedFieldForm,
|
||||
focusedFieldOpid: this.focusedFieldData?.focusedFieldOpid,
|
||||
inlineMenuFillType: this.focusedFieldData?.inlineMenuFillType,
|
||||
});
|
||||
|
||||
if (totpCode) {
|
||||
@@ -1863,6 +1864,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
||||
allowTotpAutofill: false,
|
||||
focusedFieldForm: this.focusedFieldData?.focusedFieldForm,
|
||||
focusedFieldOpid: this.focusedFieldData?.focusedFieldOpid,
|
||||
inlineMenuFillType: InlineMenuFillTypes.PasswordGeneration,
|
||||
});
|
||||
|
||||
globalThis.setTimeout(async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { AutofillMessageCommand } from "../../enums/autofill-message.enums";
|
||||
import { InlineMenuFillType } from "../../enums/autofill-overlay.enum";
|
||||
import AutofillField from "../../models/autofill-field";
|
||||
import AutofillForm from "../../models/autofill-form";
|
||||
import AutofillPageDetails from "../../models/autofill-page-details";
|
||||
@@ -30,6 +31,7 @@ export interface AutoFillOptions {
|
||||
autoSubmitLogin?: boolean;
|
||||
focusedFieldForm?: string;
|
||||
focusedFieldOpid?: string;
|
||||
inlineMenuFillType?: InlineMenuFillType;
|
||||
}
|
||||
|
||||
export interface FormData {
|
||||
@@ -49,6 +51,7 @@ export interface GenerateFillScriptOptions {
|
||||
tabUrl: string;
|
||||
defaultUriMatch: UriMatchStrategySetting;
|
||||
focusedFieldOpid?: string;
|
||||
inlineMenuFillType?: InlineMenuFillType;
|
||||
}
|
||||
|
||||
export type CollectPageDetailsResponseMessage = {
|
||||
|
||||
@@ -1118,6 +1118,12 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
* @param autofillFieldData - Autofill field data captured from the form field element.
|
||||
*/
|
||||
private async setQualifiedLoginFillType(autofillFieldData: AutofillField) {
|
||||
// Check if this is a current password field in a password change form
|
||||
if (this.inlineMenuFieldQualificationService.isUpdateCurrentPasswordField(autofillFieldData)) {
|
||||
autofillFieldData.inlineMenuFillType = InlineMenuFillTypes.CurrentPasswordUpdate;
|
||||
return;
|
||||
}
|
||||
|
||||
autofillFieldData.inlineMenuFillType = CipherType.Login;
|
||||
autofillFieldData.showPasskeys = autofillFieldData.autoCompleteType.includes("webauthn");
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service";
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service";
|
||||
import { AutofillMessageCommand, AutofillMessageSender } from "../enums/autofill-message.enums";
|
||||
import { InlineMenuFillTypes } from "../enums/autofill-overlay.enum";
|
||||
import { AutofillPort } from "../enums/autofill-port.enum";
|
||||
import AutofillField from "../models/autofill-field";
|
||||
import AutofillPageDetails from "../models/autofill-page-details";
|
||||
@@ -103,6 +104,15 @@ describe("AutofillService", () => {
|
||||
beforeEach(() => {
|
||||
configService = mock<ConfigService>();
|
||||
configService.getFeatureFlag$.mockImplementation(() => of(false));
|
||||
|
||||
// Initialize domainSettingsService BEFORE it's used
|
||||
domainSettingsService = new DefaultDomainSettingsService(
|
||||
fakeStateProvider,
|
||||
policyService,
|
||||
accountService,
|
||||
);
|
||||
domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains);
|
||||
|
||||
scriptInjectorService = new BrowserScriptInjectorService(
|
||||
domainSettingsService,
|
||||
platformUtilsService,
|
||||
@@ -141,12 +151,6 @@ describe("AutofillService", () => {
|
||||
userNotificationsSettings,
|
||||
messageListener,
|
||||
);
|
||||
domainSettingsService = new DefaultDomainSettingsService(
|
||||
fakeStateProvider,
|
||||
policyService,
|
||||
accountService,
|
||||
);
|
||||
domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains);
|
||||
jest.spyOn(BrowserApi, "tabSendMessage");
|
||||
});
|
||||
|
||||
@@ -2077,6 +2081,193 @@ describe("AutofillService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("given password generation with inlineMenuFillType", () => {
|
||||
beforeEach(() => {
|
||||
pageDetails.forms = undefined;
|
||||
pageDetails.fields = []; // Clear fields to start fresh
|
||||
options.inlineMenuFillType = InlineMenuFillTypes.PasswordGeneration;
|
||||
options.cipher.login.totp = null; // Disable TOTP for these tests
|
||||
});
|
||||
|
||||
it("includes all password fields from the same form when filling with password generation", async () => {
|
||||
const newPasswordField = createAutofillFieldMock({
|
||||
opid: "new-password",
|
||||
type: "password",
|
||||
form: "validFormId",
|
||||
elementNumber: 2,
|
||||
});
|
||||
const confirmPasswordField = createAutofillFieldMock({
|
||||
opid: "confirm-password",
|
||||
type: "password",
|
||||
form: "validFormId",
|
||||
elementNumber: 3,
|
||||
});
|
||||
pageDetails.fields.push(newPasswordField, confirmPasswordField);
|
||||
options.focusedFieldOpid = newPasswordField.opid;
|
||||
|
||||
await autofillService["generateLoginFillScript"](
|
||||
fillScript,
|
||||
pageDetails,
|
||||
filledFields,
|
||||
options,
|
||||
);
|
||||
|
||||
expect(filledFields[newPasswordField.opid]).toBeDefined();
|
||||
expect(filledFields[confirmPasswordField.opid]).toBeDefined();
|
||||
});
|
||||
|
||||
it("finds username field for the first password field when generating passwords", async () => {
|
||||
const newPasswordField = createAutofillFieldMock({
|
||||
opid: "new-password",
|
||||
type: "password",
|
||||
form: "validFormId",
|
||||
elementNumber: 2,
|
||||
});
|
||||
pageDetails.fields.push(newPasswordField);
|
||||
options.focusedFieldOpid = newPasswordField.opid;
|
||||
jest.spyOn(autofillService as any, "findUsernameField");
|
||||
|
||||
await autofillService["generateLoginFillScript"](
|
||||
fillScript,
|
||||
pageDetails,
|
||||
filledFields,
|
||||
options,
|
||||
);
|
||||
|
||||
expect(autofillService["findUsernameField"]).toHaveBeenCalledWith(
|
||||
pageDetails,
|
||||
expect.objectContaining({ opid: newPasswordField.opid }),
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not include password fields from different forms", async () => {
|
||||
const formAPasswordField = createAutofillFieldMock({
|
||||
opid: "form-a-password",
|
||||
type: "password",
|
||||
form: "formA",
|
||||
elementNumber: 1,
|
||||
});
|
||||
const formBPasswordField = createAutofillFieldMock({
|
||||
opid: "form-b-password",
|
||||
type: "password",
|
||||
form: "formB",
|
||||
elementNumber: 2,
|
||||
});
|
||||
pageDetails.fields = [formAPasswordField, formBPasswordField];
|
||||
options.focusedFieldOpid = formAPasswordField.opid;
|
||||
|
||||
await autofillService["generateLoginFillScript"](
|
||||
fillScript,
|
||||
pageDetails,
|
||||
filledFields,
|
||||
options,
|
||||
);
|
||||
|
||||
expect(filledFields[formAPasswordField.opid]).toBeDefined();
|
||||
expect(filledFields[formBPasswordField.opid]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("given current password update with inlineMenuFillType", () => {
|
||||
beforeEach(() => {
|
||||
pageDetails.forms = undefined;
|
||||
pageDetails.fields = []; // Clear fields to start fresh
|
||||
options.inlineMenuFillType = InlineMenuFillTypes.CurrentPasswordUpdate;
|
||||
options.cipher.login.totp = null; // Disable TOTP for these tests
|
||||
});
|
||||
|
||||
it("includes all password fields from the same form when updating current password", async () => {
|
||||
const currentPasswordField = createAutofillFieldMock({
|
||||
opid: "current-password",
|
||||
type: "password",
|
||||
form: "validFormId",
|
||||
elementNumber: 1,
|
||||
});
|
||||
const newPasswordField = createAutofillFieldMock({
|
||||
opid: "new-password",
|
||||
type: "password",
|
||||
form: "validFormId",
|
||||
elementNumber: 2,
|
||||
});
|
||||
const confirmPasswordField = createAutofillFieldMock({
|
||||
opid: "confirm-password",
|
||||
type: "password",
|
||||
form: "validFormId",
|
||||
elementNumber: 3,
|
||||
});
|
||||
pageDetails.fields.push(currentPasswordField, newPasswordField, confirmPasswordField);
|
||||
options.focusedFieldOpid = currentPasswordField.opid;
|
||||
|
||||
await autofillService["generateLoginFillScript"](
|
||||
fillScript,
|
||||
pageDetails,
|
||||
filledFields,
|
||||
options,
|
||||
);
|
||||
|
||||
expect(filledFields[currentPasswordField.opid]).toBeDefined();
|
||||
expect(filledFields[newPasswordField.opid]).toBeDefined();
|
||||
expect(filledFields[confirmPasswordField.opid]).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes all password fields from the same form without TOTP", async () => {
|
||||
const currentPasswordField = createAutofillFieldMock({
|
||||
opid: "current-password",
|
||||
type: "password",
|
||||
form: "validFormId",
|
||||
elementNumber: 1,
|
||||
});
|
||||
const newPasswordField = createAutofillFieldMock({
|
||||
opid: "new-password",
|
||||
type: "password",
|
||||
form: "validFormId",
|
||||
elementNumber: 2,
|
||||
});
|
||||
pageDetails.fields.push(currentPasswordField, newPasswordField);
|
||||
options.focusedFieldOpid = currentPasswordField.opid;
|
||||
|
||||
await autofillService["generateLoginFillScript"](
|
||||
fillScript,
|
||||
pageDetails,
|
||||
filledFields,
|
||||
options,
|
||||
);
|
||||
|
||||
expect(filledFields[currentPasswordField.opid]).toBeDefined();
|
||||
expect(filledFields[newPasswordField.opid]).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not include password fields from different forms during password update", async () => {
|
||||
const formAPasswordField = createAutofillFieldMock({
|
||||
opid: "form-a-password",
|
||||
type: "password",
|
||||
form: "formA",
|
||||
elementNumber: 1,
|
||||
});
|
||||
const formBPasswordField = createAutofillFieldMock({
|
||||
opid: "form-b-password",
|
||||
type: "password",
|
||||
form: "formB",
|
||||
elementNumber: 2,
|
||||
});
|
||||
pageDetails.fields = [formAPasswordField, formBPasswordField];
|
||||
options.focusedFieldOpid = formAPasswordField.opid;
|
||||
|
||||
await autofillService["generateLoginFillScript"](
|
||||
fillScript,
|
||||
pageDetails,
|
||||
filledFields,
|
||||
options,
|
||||
);
|
||||
|
||||
expect(filledFields[formAPasswordField.opid]).toBeDefined();
|
||||
expect(filledFields[formBPasswordField.opid]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("given a set of page details that does not contain a password field", () => {
|
||||
let emailField: AutofillField;
|
||||
let emailFieldView: FieldView;
|
||||
@@ -3140,12 +3331,16 @@ describe("AutofillService", () => {
|
||||
"example.com",
|
||||
"exampleapp.com",
|
||||
]);
|
||||
domainSettingsService.equivalentDomains$ = of([["not-example.com"]]);
|
||||
const pageUrl = "https://subdomain.example.com";
|
||||
const tabUrl = "https://www.not-example.com";
|
||||
const generateFillScriptOptions = createGenerateFillScriptOptionsMock({ tabUrl });
|
||||
generateFillScriptOptions.cipher.login.matchesUri = jest.fn().mockReturnValueOnce(false);
|
||||
|
||||
// Mock getUrlEquivalentDomains to return the expected domains
|
||||
jest
|
||||
.spyOn(domainSettingsService, "getUrlEquivalentDomains")
|
||||
.mockReturnValue(of(equivalentDomains));
|
||||
|
||||
const result = await autofillService["inUntrustedIframe"](pageUrl, generateFillScriptOptions);
|
||||
|
||||
expect(generateFillScriptOptions.cipher.login.matchesUri).toHaveBeenCalledWith(
|
||||
|
||||
@@ -52,6 +52,7 @@ import { ScriptInjectorService } from "../../platform/services/abstractions/scri
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window";
|
||||
import { AutofillMessageCommand, AutofillMessageSender } from "../enums/autofill-message.enums";
|
||||
import { InlineMenuFillTypes } from "../enums/autofill-overlay.enum";
|
||||
import { AutofillPort } from "../enums/autofill-port.enum";
|
||||
import AutofillField from "../models/autofill-field";
|
||||
import AutofillPageDetails from "../models/autofill-page-details";
|
||||
@@ -452,6 +453,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
tabUrl: tab.url,
|
||||
defaultUriMatch: defaultUriMatch,
|
||||
focusedFieldOpid: options.focusedFieldOpid,
|
||||
inlineMenuFillType: options.inlineMenuFillType,
|
||||
});
|
||||
|
||||
if (!fillScript || !fillScript.script || !fillScript.script.length) {
|
||||
@@ -971,26 +973,53 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
|
||||
if (passwordFields.length && !passwords.length) {
|
||||
// in the event that password fields exist but weren't processed within form elements.
|
||||
// select matching password if focused, otherwise first in prioritized list. for username, use focused field if it matches, otherwise find field before password.
|
||||
const passwordFieldToUse = focusedField
|
||||
? prioritizedPasswordFields.find(passwordMatchesFocused) || prioritizedPasswordFields[0]
|
||||
: prioritizedPasswordFields[0];
|
||||
const isPasswordGeneration =
|
||||
options.inlineMenuFillType === InlineMenuFillTypes.PasswordGeneration;
|
||||
const isCurrentPasswordUpdate =
|
||||
options.inlineMenuFillType === InlineMenuFillTypes.CurrentPasswordUpdate;
|
||||
|
||||
if (passwordFieldToUse) {
|
||||
passwords.push(passwordFieldToUse);
|
||||
// For password generation or current password update, include all password fields from the same form
|
||||
// This ensures we have access to all fields regardless of their login/registration classification
|
||||
if ((isPasswordGeneration || isCurrentPasswordUpdate) && focusedField) {
|
||||
// Add all password fields from the same form as the focused field
|
||||
const focusedFieldForm = focusedField.form;
|
||||
|
||||
if (login.username && passwordFieldToUse.elementNumber > 0) {
|
||||
username = getUsernameForPassword(passwordFieldToUse, true);
|
||||
// Check both login and registration fields to ensure we get all password fields
|
||||
const allPasswordFields = [...loginPasswordFields, ...registrationPasswordFields];
|
||||
allPasswordFields.forEach((passField) => {
|
||||
if (passField.form === focusedFieldForm) {
|
||||
passwords.push(passField);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If we didn't add any passwords above (either not password generation/update or no matching fields),
|
||||
// select matching password if focused, otherwise first in prioritized list.
|
||||
if (!passwords.length) {
|
||||
const passwordFieldToUse = focusedField
|
||||
? prioritizedPasswordFields.find(passwordMatchesFocused) || prioritizedPasswordFields[0]
|
||||
: prioritizedPasswordFields[0];
|
||||
|
||||
if (passwordFieldToUse) {
|
||||
passwords.push(passwordFieldToUse);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle username and TOTP for the first password field
|
||||
const firstPasswordField = passwords[0];
|
||||
if (firstPasswordField) {
|
||||
if (login.username && firstPasswordField.elementNumber > 0) {
|
||||
username = getUsernameForPassword(firstPasswordField, true);
|
||||
if (username) {
|
||||
usernames.set(username.opid, username);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.allowTotpAutofill && login.totp && passwordFieldToUse.elementNumber > 0) {
|
||||
if (options.allowTotpAutofill && login.totp && firstPasswordField.elementNumber > 0) {
|
||||
totp =
|
||||
isFocusedTotpField && passwordMatchesFocused(passwordFieldToUse)
|
||||
isFocusedTotpField && passwordMatchesFocused(firstPasswordField)
|
||||
? focusedField
|
||||
: this.findTotpField(pageDetails, passwordFieldToUse, false, false, true);
|
||||
: this.findTotpField(pageDetails, firstPasswordField, false, false, true);
|
||||
if (totp) {
|
||||
totps.push(totp);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user