diff --git a/apps/extension/entrypoints/autofill.content.ts b/apps/extension/entrypoints/autofill.content.ts index 37bc0b5..c39e39d 100644 --- a/apps/extension/entrypoints/autofill.content.ts +++ b/apps/extension/entrypoints/autofill.content.ts @@ -18,9 +18,11 @@ import { fillLoginFields, fillProfileField, fillRegistrationPasswordFields, + filterCredentialsForUsername, generateAdaptiveRegistrationPassword, isCredentialField, isLoginAction, + isProfileOrRegistrationEmailField, isRegistrationEmailField, isRegistrationPasswordField, isUsernameField, @@ -60,6 +62,7 @@ export default defineContentScript({ let lastFilledCredential: { readonly password: string; readonly username: string } | null = null; const suppressedUsernameInputs = new WeakSet(); + const knownUsernamesByInput = new WeakMap(); const generatedPasswords = new WeakMap>(); const closePrompt = () => { promptCleanup?.(); @@ -350,6 +353,23 @@ export default defineContentScript({ return; } if (response?.status !== "suggestions") return; + const currentUsernameField = usernameFieldForCredentialAnchor(document, anchor); + if (currentUsernameField !== null) { + knownUsernamesByInput.set( + currentUsernameField, + response.credentials.map((credential) => credential.username), + ); + } + const credentials = filterCredentialsForUsername( + currentUsernameField?.value ?? "", + response.credentials, + ); + if (credentials.length === 0) { + if (currentUsernameField !== null) suppressedUsernameInputs.add(currentUsernameField); + closePrompt(); + return; + } + if (currentUsernameField !== null) suppressedUsernameInputs.delete(currentUsernameField); const authenticateAndFill = (credentialId: string) => { closePrompt(); void sendMessage({ @@ -362,14 +382,14 @@ export default defineContentScript({ version: 1, }); }; - const options = response.credentials.map((credential) => ({ + const options = credentials.map((credential) => ({ detail: "Verify and fill", icon: response.deviceSlots.length > 0 ? "◎" : "●", label: credential.username || "Saved login", run: () => authenticateAndFill(credential.id), })); prompt("suggestions", "Passwords", response.displayHost, options, anchor); - promptUsernames = response.credentials.map((credential) => credential.username); + promptUsernames = credentials.map((credential) => credential.username); }) .finally(() => { if (!extensionContextActive) return; @@ -681,16 +701,23 @@ export default defineContentScript({ (event) => { if ( !event.isTrusted || - promptKind !== "suggestions" || !(event.target instanceof HTMLInputElement) || !isUsernameField(event.target) ) { return; } - if (shouldDismissSuggestionsForUsername(event.target.value, promptUsernames)) { + const storedUsernames = + knownUsernamesByInput.get(event.target) ?? + (promptKind === "suggestions" ? promptUsernames : null); + if (storedUsernames === null) return; + if (shouldDismissSuggestionsForUsername(event.target.value, storedUsernames)) { suppressedUsernameInputs.add(event.target); - closePrompt(); + if (promptKind === "suggestions") closePrompt(); + return; } + suppressedUsernameInputs.delete(event.target); + if (promptKind === "suggestions") closePrompt(); + requestSuggestions(event.target); }, true, ); @@ -716,7 +743,7 @@ export default defineContentScript({ event.isTrusted && window.top === window && target instanceof HTMLInputElement && - profileFieldKind(target) !== null + isProfileOrRegistrationEmailField(target) ) { requestProfileOrPrivateEmail(target); } else if (event.isTrusted && window.top === window && isCredentialField(target)) { @@ -756,7 +783,7 @@ export default defineContentScript({ requestCardSuggestions(event.target); } else if ( event.target instanceof HTMLInputElement && - profileFieldKind(event.target) !== null + isProfileOrRegistrationEmailField(event.target) ) { requestProfileOrPrivateEmail(event.target); } else if (isCredentialField(event.target)) { @@ -817,7 +844,7 @@ export default defineContentScript({ return ( isRegistrationPasswordField(input) || cardFieldKind(input) !== null || - profileFieldKind(input) !== null || + isProfileOrRegistrationEmailField(input) || isCredentialField(input) ); }) ?? null); @@ -827,7 +854,7 @@ export default defineContentScript({ requestStrongPassword(anchor); } else if (cardFieldKind(anchor) !== null) { requestCardSuggestions(anchor); - } else if (profileFieldKind(anchor) !== null) { + } else if (isProfileOrRegistrationEmailField(anchor)) { requestProfileOrPrivateEmail(anchor); } else { requestSuggestions(anchor); @@ -878,14 +905,14 @@ export default defineContentScript({ document.activeElement instanceof HTMLInputElement && (isCredentialField(document.activeElement) || cardFieldKind(document.activeElement) !== null || - profileFieldKind(document.activeElement) !== null) + isProfileOrRegistrationEmailField(document.activeElement)) ) { const active = document.activeElement; if (isRegistrationPasswordField(active)) { requestStrongPassword(active); } else if (cardFieldKind(active) !== null) { requestCardSuggestions(active); - } else if (profileFieldKind(active) !== null) { + } else if (isProfileOrRegistrationEmailField(active)) { requestProfileOrPrivateEmail(active); } else { requestSuggestions(active); diff --git a/apps/extension/src/autofill.test.ts b/apps/extension/src/autofill.test.ts index f5595c6..79e006f 100644 --- a/apps/extension/src/autofill.test.ts +++ b/apps/extension/src/autofill.test.ts @@ -20,10 +20,12 @@ import { fillLoginFields, fillProfileField, fillRegistrationPasswordFields, + filterCredentialsForUsername, generateAdaptiveRegistrationPassword, generateStrongRegistrationPassword, isCredentialField, isLoginAction, + isProfileOrRegistrationEmailField, isRegistrationEmailField, isRegistrationPasswordField, isUsernameField, @@ -137,6 +139,16 @@ describe("extension automatic autofill", () => { expect(shouldDismissSuggestionsForUsername("typed", null)).toBe(true); }); + it("filters delayed credential suggestions against the current username", () => { + const credentials = [ + { id: "student", username: "Student" }, + { id: "person", username: "person@example.test" }, + ]; + expect(filterCredentialsForUsername("", credentials)).toEqual(credentials); + expect(filterCredentialsForUsername("STU", credentials)).toEqual([credentials[0]]); + expect(filterCredentialsForUsername("pk", credentials)).toEqual([]); + }); + it("associates password-field suggestion requests with their username field", () => { document.body.innerHTML = `
@@ -318,6 +330,26 @@ describe("extension automatic autofill", () => { expect(password?.value).toBe("secret"); }); + it("completes a case-insensitive username prefix before filling the password", () => { + document.body.innerHTML = ` + + + +
+ `; + const username = document.querySelector("[autocomplete=username]"); + const password = document.querySelector("[type=password]"); + let inputEvents = 0; + document.addEventListener("input", () => { + inputEvents += 1; + }); + + expect(fillLoginFields(document, { password: "Password123", username: "student" })).toBe(true); + expect(username?.value).toBe("student"); + expect(password?.value).toBe("Password123"); + expect(inputEvents).toBe(2); + }); + it("fills a login form whose site incorrectly marks its password as new-password", () => { document.body.innerHTML = `
@@ -830,6 +862,24 @@ describe("extension automatic autofill", () => { expect(isRegistrationEmailField(document.querySelector("#login-email"))).toBe(false); }); + it("routes a legacy metadata-free registration email field to private email", () => { + document.body.innerHTML = ` +

Register

+ + + + + + +
+ `; + const email = document.querySelector('input[type="email"]'); + + expect(email === null ? null : profileFieldKind(email)).toBeNull(); + expect(isRegistrationEmailField(email)).toBe(true); + expect(isProfileOrRegistrationEmailField(email)).toBe(true); + }); + it("supports passwordless multi-step signup but rejects ambiguous standalone email fields", () => { document.body.innerHTML = `
diff --git a/apps/extension/src/autofill.ts b/apps/extension/src/autofill.ts index 187a27b..dedde8b 100644 --- a/apps/extension/src/autofill.ts +++ b/apps/extension/src/autofill.ts @@ -238,6 +238,17 @@ export function shouldDismissSuggestionsForUsername( return !storedUsernames.some((username) => normalizedUsername(username).startsWith(typed)); } +export function filterCredentialsForUsername( + value: string, + credentials: readonly T[], +): readonly T[] { + const typed = normalizedUsername(value); + if (typed.length === 0) return credentials; + return credentials.filter((credential) => + normalizedUsername(credential.username).startsWith(typed), + ); +} + function isInvalidatedExtensionContext(error: unknown): boolean { return error instanceof Error && /extension context invalidated/iu.test(error.message); } @@ -788,6 +799,15 @@ export function isRegistrationEmailField(element: Element | null): element is HT return evidence.positive && !evidence.negative; } +export function isProfileOrRegistrationEmailField( + element: Element | null, +): element is HTMLInputElement { + return ( + element instanceof HTMLInputElement && + (isRegistrationEmailField(element) || profileFieldKind(element) !== null) + ); +} + export function isRegistrationPasswordField(element: Element | null): element is HTMLInputElement { if ( !(element instanceof HTMLInputElement) || @@ -1153,10 +1173,11 @@ function selectLoginFields( if (!renderedForCredentialUse(password)) return false; if (credential === undefined) return true; if (password.value.length > 0 && password.value !== credential.password) return false; - return !( - username !== undefined && - username.value.length > 0 && - normalizedUsername(username.value) !== normalizedUsername(credential.username) + if (username === undefined || username.value.length === 0) return true; + const currentUsername = normalizedUsername(username.value); + const credentialUsername = normalizedUsername(credential.username); + return ( + currentUsername === credentialUsername || credentialUsername.startsWith(currentUsername) ); }) .map((fields) => { @@ -1217,8 +1238,15 @@ export function fillLoginFields( input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); input.dispatchEvent(new Event("change", { bubbles: true })); }; - if (fields.username !== undefined && fields.username.value.length === 0) { - setValue(fields.username, credential.username); + if (fields.username !== undefined) { + const currentUsername = normalizedUsername(fields.username.value); + const credentialUsername = normalizedUsername(credential.username); + if ( + currentUsername.length === 0 || + (currentUsername !== credentialUsername && credentialUsername.startsWith(currentUsername)) + ) { + setValue(fields.username, credential.username); + } } if (fields.password.value.length === 0) setValue(fields.password, credential.password); return (