export interface FieldScore {
element: HTMLInputElement;
score: number;
fieldType: "username" | "password" | "email" | "totp" | "unknown";
factors: {
autocompleteMatch: number;
typeMatch: number;
namePattern: number;
idPattern: number;
placeholderPattern: number;
ariaLabelPattern: number;
siblingContext: number;
};
}
export interface FormFields {
username: HTMLInputElement | null;
password: HTMLInputElement | null;
email: HTMLInputElement | null;
totp: HTMLInputElement | null;
}
const USERNAME_PATTERNS = [
/user/i,
/login/i,
/usr/i,
/uname/i,
/account/i,
/acct/i,
/signin/i,
/sign-in/i,
/session/i,
/member/i,
/alias/i,
];
const EMAIL_PATTERNS = [/email/i, /e-mail/i, /mail/i];
const PASSWORD_PATTERNS = [
/pass/i,
/password/i,
/pwd/i,
/secret/i,
/passphrase/i,
/key/i,
/credential/i,
];
const TOTP_PATTERNS = [
/totp/i,
/otp/i,
/2fa/i,
/mfa/i,
/two-factor/i,
/twofactor/i,
/authenticat/i,
/verification/i,
/code/i,
/pin/i,
/token/i,
];
const AUTOCOMPLETE_VALUES: Record<
"username" | "email" | "password" | "totp",
string[]
> = {
username: ["username", "login", "user"],
email: ["email", "username"],
password: ["current-password", "password", "new-password"],
totp: ["one-time-code", "totp"],
};
function matchesPattern(value: string, patterns: RegExp[]): boolean {
return patterns.some((pattern) => pattern.test(value));
}
function getPatternsForType(
type: "username" | "password" | "email" | "totp"
): RegExp[] {
switch (type) {
case "username":
return USERNAME_PATTERNS;
case "password":
return PASSWORD_PATTERNS;
case "email":
return EMAIL_PATTERNS;
case "totp":
return TOTP_PATTERNS;
}
}
function getLabelText(labelledById: string): string {
const label = document.getElementById(labelledById);
return label?.textContent || "";
}
function analyzeSiblingContext(input: HTMLInputElement, patterns: RegExp[]): number {
const parent = input.parentElement;
if (!parent) return 0;
for (const sibling of Array.from(parent.children)) {
if (sibling === input) continue;
if (matchesPattern(sibling.textContent || "", patterns)) return 5;
}
const grandparent = parent.parentElement;
if (!grandparent) return 0;
for (const sibling of Array.from(grandparent.children)) {
if (sibling.contains(input)) continue;
if (matchesPattern(sibling.textContent || "", patterns)) return 5;
}
return 0;
}
function calculateFieldScore(
input: HTMLInputElement,
type: "username" | "password" | "email" | "totp"
): number {
const patterns = getPatternsForType(type);
let score = 0;
const autocomplete = input.getAttribute("autocomplete") || "";
const expected = AUTOCOMPLETE_VALUES[type] || [];
if (expected.some((value) => autocomplete.includes(value))) score += 10;
if (type === "password" && input.type === "password") score += 8;
if (type === "email" && input.type === "email") score += 8;
if (type === "totp" && (input.type === "tel" || input.inputMode === "numeric")) {
score += 6;
}
const name = input.name || "";
const id = input.id || "";
const placeholder = input.placeholder || "";
const ariaLabel = input.getAttribute("aria-label") || "";
const ariaLabelledBy = input.getAttribute("aria-labelledby") || "";
if (matchesPattern(name, patterns)) score += 6;
if (matchesPattern(id, patterns)) score += 6;
if (matchesPattern(placeholder, patterns)) score += 4;
if (matchesPattern(ariaLabel, patterns)) score += 4;
if (ariaLabelledBy && matchesPattern(getLabelText(ariaLabelledBy), patterns)) {
score += 4;
}
score += analyzeSiblingContext(input, patterns);
return score;
}
export function scoreInputField(input: HTMLInputElement): FieldScore {
const types: Array<"username" | "password" | "email" | "totp"> = [
"username",
"password",
"email",
"totp",
];
const scores = types.map((type) => {
const factors = {
autocompleteMatch: 0,
typeMatch: 0,
namePattern: 0,
idPattern: 0,
placeholderPattern: 0,
ariaLabelPattern: 0,
siblingContext: 0,
};
const autocomplete = input.getAttribute("autocomplete") || "";
const patterns = getPatternsForType(type);
const name = input.name || "";
const id = input.id || "";
const placeholder = input.placeholder || "";
const ariaLabel = input.getAttribute("aria-label") || "";
if (AUTOCOMPLETE_VALUES[type]?.some((value) => autocomplete.includes(value))) {
factors.autocompleteMatch = 10;
}
if (type === "password" && input.type === "password") factors.typeMatch = 8;
if (type === "email" && input.type === "email") factors.typeMatch = 8;
if (type === "totp" && (input.type === "tel" || input.inputMode === "numeric")) {
factors.typeMatch = 6;
}
if (matchesPattern(name, patterns)) factors.namePattern = 6;
if (matchesPattern(id, patterns)) factors.idPattern = 6;
if (matchesPattern(placeholder, patterns)) factors.placeholderPattern = 4;
if (matchesPattern(ariaLabel, patterns)) factors.ariaLabelPattern = 4;
factors.siblingContext = analyzeSiblingContext(input, patterns);
return {
type,
score: calculateFieldScore(input, type),
factors,
};
});
scores.sort((a, b) => b.score - a.score);
const best = scores[0];
return {
element: input,
score: best.score,
fieldType: best.score > 5 ? best.type : "unknown",
factors: best.factors,
};
}
export function identifyLoginFields(form: HTMLFormElement | null): FormFields {
const inputs = Array.from(
(form || document).querySelectorAll(
'input:not([type="hidden"]):not([type="submit"]):not([type="button"])'
)
);
const scoredFields = inputs.map(scoreInputField).sort((a, b) => b.score - a.score);
const result: FormFields = {
username: null,
password: null,
email: null,
totp: null,
};
const used = new Set();
for (const field of scoredFields) {
if (used.has(field.element) || field.score < 3) continue;
switch (field.fieldType) {
case "username":
if (!result.username) {
result.username = field.element;
used.add(field.element);
}
break;
case "password":
if (!result.password) {
result.password = field.element;
used.add(field.element);
}
break;
case "email":
if (!result.email && !result.username) {
result.email = field.element;
used.add(field.element);
}
break;
case "totp":
if (!result.totp) {
result.totp = field.element;
used.add(field.element);
}
break;
}
}
return result;
}