[AC-2928] Create automatic app login policy (#10295)

* Create automatic app login policy

* update copy

* update copy

* [PM-10155] Automatic Login After Autofill (#10297)

---------

Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com>
This commit is contained in:
Kyle Spearrin 2024-08-14 10:38:33 -04:00 committed by GitHub
parent 8274ea783c
commit 5547b953ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 2027 additions and 41 deletions

View File

@ -0,0 +1,21 @@
import AutofillPageDetails from "../../models/autofill-page-details";
export type AutoSubmitLoginMessage = {
command: string;
pageDetails?: AutofillPageDetails;
};
export type AutoSubmitLoginMessageParams = {
message: AutoSubmitLoginMessage;
sender: chrome.runtime.MessageSender;
};
export type AutoSubmitLoginBackgroundExtensionMessageHandlers = {
[key: string]: ({ message, sender }: AutoSubmitLoginMessageParams) => any;
triggerAutoSubmitLogin: ({ message, sender }: AutoSubmitLoginMessageParams) => Promise<void>;
multiStepAutoSubmitLoginComplete: ({ sender }: AutoSubmitLoginMessageParams) => void;
};
export abstract class AutoSubmitLoginBackground {
abstract init(): void;
}

View File

@ -0,0 +1,503 @@
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { BrowserApi } from "../../platform/browser/browser-api";
import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service";
import AutofillPageDetails from "../models/autofill-page-details";
import { AutofillService } from "../services/abstractions/autofill.service";
import {
flushPromises,
sendMockExtensionMessage,
triggerTabOnActivatedEvent,
triggerTabOnRemovedEvent,
triggerTabOnUpdatedEvent,
triggerWebNavigationOnCompletedEvent,
triggerWebRequestOnBeforeRedirectEvent,
triggerWebRequestOnBeforeRequestEvent,
} from "../spec/testing-utils";
import { AutoSubmitLoginBackground } from "./auto-submit-login.background";
describe("AutoSubmitLoginBackground", () => {
let logService: MockProxy<LogService>;
let autofillService: MockProxy<AutofillService>;
let scriptInjectorService: MockProxy<ScriptInjectorService>;
let authStatus$: BehaviorSubject<AuthenticationStatus>;
let authService: MockProxy<AuthService>;
let configService: MockProxy<ConfigService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let policyDetails: MockProxy<Policy>;
let automaticAppLogInPolicy$: BehaviorSubject<Policy>;
let policyAppliesToActiveUser$: BehaviorSubject<boolean>;
let policyService: MockProxy<PolicyService>;
let autoSubmitLoginBackground: AutoSubmitLoginBackground;
const validIpdUrl1 = "https://example.com";
const validIpdUrl2 = "https://subdomain.example3.com";
const validAutoSubmitHost = "some-valid-url.com";
const validAutoSubmitUrl = `https://${validAutoSubmitHost}/?autofill=1`;
beforeEach(() => {
logService = mock<LogService>();
autofillService = mock<AutofillService>();
scriptInjectorService = mock<ScriptInjectorService>();
authStatus$ = new BehaviorSubject(AuthenticationStatus.Unlocked);
authService = mock<AuthService>();
authService.activeAccountStatus$ = authStatus$;
configService = mock<ConfigService>({
getFeatureFlag: jest.fn().mockResolvedValue(true),
});
platformUtilsService = mock<PlatformUtilsService>();
policyDetails = mock<Policy>({
enabled: true,
data: {
idpHost: `${validIpdUrl1} , https://example2.com/some/sub-route ,${validIpdUrl2}, [invalidValue] ,,`,
},
});
automaticAppLogInPolicy$ = new BehaviorSubject<Policy>(policyDetails);
policyAppliesToActiveUser$ = new BehaviorSubject<boolean>(true);
policyService = mock<PolicyService>({
get$: jest.fn().mockReturnValue(automaticAppLogInPolicy$),
policyAppliesToActiveUser$: jest.fn().mockReturnValue(policyAppliesToActiveUser$),
});
autoSubmitLoginBackground = new AutoSubmitLoginBackground(
logService,
autofillService,
scriptInjectorService,
authService,
configService,
platformUtilsService,
policyService,
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe("when the AutoSubmitLoginBackground feature is disabled", () => {
it("destroys all event listeners when the AutomaticAppLogIn policy is not enabled", async () => {
automaticAppLogInPolicy$.next(mock<Policy>({ ...policyDetails, enabled: false }));
await autoSubmitLoginBackground.init();
expect(chrome.webRequest.onBeforeRequest.removeListener).toHaveBeenCalled();
});
it("destroys all event listeners when the AutomaticAppLogIn policy does not apply to the current user", async () => {
policyAppliesToActiveUser$.next(false);
await autoSubmitLoginBackground.init();
expect(chrome.webRequest.onBeforeRequest.removeListener).toHaveBeenCalled();
});
it("destroys all event listeners when the idpHost is not specified in the AutomaticAppLogIn policy", async () => {
automaticAppLogInPolicy$.next(mock<Policy>({ ...policyDetails, data: { idpHost: "" } }));
await autoSubmitLoginBackground.init();
expect(chrome.webRequest.onBeforeRequest.addListener).not.toHaveBeenCalled();
});
});
describe("when the AutoSubmitLoginBackground feature is enabled", () => {
let webRequestDetails: chrome.webRequest.WebRequestBodyDetails;
describe("starting the auto-submit login workflow", () => {
beforeEach(async () => {
webRequestDetails = mock<chrome.webRequest.WebRequestBodyDetails>({
initiator: validIpdUrl1,
url: validAutoSubmitUrl,
type: "main_frame",
tabId: 1,
});
await autoSubmitLoginBackground.init();
});
it("sets up the auto-submit workflow when the web request occurs in the main frame and the destination URL contains a valid auto-fill param", () => {
triggerWebRequestOnBeforeRequestEvent(webRequestDetails);
expect(autoSubmitLoginBackground["currentAutoSubmitHostData"]).toStrictEqual({
url: validAutoSubmitUrl,
tabId: webRequestDetails.tabId,
});
expect(chrome.webNavigation.onCompleted.addListener).toBeCalledWith(expect.any(Function), {
url: [{ hostEquals: validAutoSubmitHost }],
});
});
it("sets up the auto-submit workflow when the web request occurs in a sub frame and the initiator of the request is a valid auto-submit host", async () => {
const topFrameHost = "some-top-frame.com";
const subFrameHost = "some-sub-frame.com";
autoSubmitLoginBackground["validAutoSubmitHosts"].add(topFrameHost);
webRequestDetails.type = "sub_frame";
webRequestDetails.initiator = `https://${topFrameHost}`;
webRequestDetails.url = `https://${subFrameHost}`;
triggerWebRequestOnBeforeRequestEvent(webRequestDetails);
expect(chrome.webNavigation.onCompleted.addListener).toBeCalledWith(expect.any(Function), {
url: [{ hostEquals: subFrameHost }],
});
});
describe("injecting the auto-submit login content script", () => {
let webNavigationDetails: chrome.webNavigation.WebNavigationFramedCallbackDetails;
beforeEach(() => {
triggerWebRequestOnBeforeRequestEvent(webRequestDetails);
webNavigationDetails = mock<chrome.webNavigation.WebNavigationFramedCallbackDetails>({
tabId: webRequestDetails.tabId,
url: webRequestDetails.url,
});
});
it("skips injecting the content script when the routed-to url is invalid", () => {
webNavigationDetails.url = "[invalid-host]";
triggerWebNavigationOnCompletedEvent(webNavigationDetails);
expect(scriptInjectorService.inject).not.toHaveBeenCalled();
});
it("skips injecting the content script when the extension is not unlocked", async () => {
authStatus$.next(AuthenticationStatus.Locked);
triggerWebNavigationOnCompletedEvent(webNavigationDetails);
await flushPromises();
expect(scriptInjectorService.inject).not.toHaveBeenCalled();
});
it("injects the auto-submit login content script", async () => {
triggerWebNavigationOnCompletedEvent(webNavigationDetails);
await flushPromises();
expect(scriptInjectorService.inject).toBeCalledWith({
tabId: webRequestDetails.tabId,
injectDetails: {
file: "content/auto-submit-login.js",
runAt: "document_start",
frame: "all_frames",
},
});
});
});
});
describe("cancelling an active auto-submit login workflow", () => {
beforeEach(async () => {
webRequestDetails = mock<chrome.webRequest.WebRequestBodyDetails>({
initiator: validIpdUrl1,
url: validAutoSubmitUrl,
type: "main_frame",
});
await autoSubmitLoginBackground.init();
autoSubmitLoginBackground["currentAutoSubmitHostData"] = {
url: validAutoSubmitUrl,
tabId: webRequestDetails.tabId,
};
autoSubmitLoginBackground["validAutoSubmitHosts"].add(validAutoSubmitHost);
});
it("clears the auto-submit data when a POST request is encountered during an active auto-submit login workflow", async () => {
webRequestDetails.method = "POST";
triggerWebRequestOnBeforeRequestEvent(webRequestDetails);
expect(autoSubmitLoginBackground["currentAutoSubmitHostData"]).toStrictEqual({});
});
it("clears the auto-submit data when a redirection to an invalid host is made during an active auto-submit workflow", () => {
webRequestDetails.url = "https://invalid-host.com";
triggerWebRequestOnBeforeRequestEvent(webRequestDetails);
expect(autoSubmitLoginBackground["currentAutoSubmitHostData"]).toStrictEqual({});
});
it("disables the auto-submit workflow if a web request is initiated after the auto-submit route has been visited", () => {
webRequestDetails.url = `https://${validAutoSubmitHost}`;
webRequestDetails.initiator = `https://${validAutoSubmitHost}?autofill=1`;
triggerWebRequestOnBeforeRequestEvent(webRequestDetails);
expect(autoSubmitLoginBackground["validAutoSubmitHosts"].has(validAutoSubmitHost)).toBe(
false,
);
});
it("disables the auto-submit workflow if a web request to a different page is initiated after the auto-submit route has been visited", async () => {
webRequestDetails.url = `https://${validAutoSubmitHost}/some-other-route.com`;
jest
.spyOn(BrowserApi, "getTab")
.mockResolvedValue(mock<chrome.tabs.Tab>({ url: validAutoSubmitHost }));
triggerWebRequestOnBeforeRequestEvent(webRequestDetails);
await flushPromises();
expect(autoSubmitLoginBackground["validAutoSubmitHosts"].has(validAutoSubmitHost)).toBe(
false,
);
});
});
describe("when the extension is running on a Safari browser", () => {
const tabId = 1;
const tab = mock<chrome.tabs.Tab>({ id: tabId, url: validIpdUrl1 });
beforeEach(() => {
platformUtilsService.isSafari.mockReturnValue(true);
autoSubmitLoginBackground = new AutoSubmitLoginBackground(
logService,
autofillService,
scriptInjectorService,
authService,
configService,
platformUtilsService,
policyService,
);
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(tab);
});
it("sets the most recent IDP host to the current tab", async () => {
await autoSubmitLoginBackground.init();
await flushPromises();
expect(autoSubmitLoginBackground["mostRecentIdpHost"]).toStrictEqual({
url: validIpdUrl1,
tabId: tabId,
});
});
describe("requests that occur within a sub-frame", () => {
const webRequestDetails = mock<chrome.webRequest.WebRequestBodyDetails>({
url: validAutoSubmitUrl,
frameId: 1,
});
it("sets the initiator of the request to an empty value when the most recent IDP host has not be set", async () => {
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null);
await autoSubmitLoginBackground.init();
await flushPromises();
autoSubmitLoginBackground["validAutoSubmitHosts"].add(validAutoSubmitHost);
triggerWebRequestOnBeforeRequestEvent(webRequestDetails);
expect(chrome.webNavigation.onCompleted.addListener).not.toHaveBeenCalledWith(
autoSubmitLoginBackground["handleAutoSubmitHostNavigationCompleted"],
{ url: [{ hostEquals: validAutoSubmitHost }] },
);
});
it("treats the routed to url as the initiator of a request", async () => {
await autoSubmitLoginBackground.init();
await flushPromises();
autoSubmitLoginBackground["validAutoSubmitHosts"].add(validAutoSubmitHost);
triggerWebRequestOnBeforeRequestEvent(webRequestDetails);
expect(chrome.webNavigation.onCompleted.addListener).toBeCalledWith(
autoSubmitLoginBackground["handleAutoSubmitHostNavigationCompleted"],
{ url: [{ hostEquals: validAutoSubmitHost }] },
);
});
});
describe("event listeners that update the most recently visited IDP host", () => {
const newTabId = 2;
const newTab = mock<chrome.tabs.Tab>({ id: newTabId, url: validIpdUrl2 });
beforeEach(async () => {
await autoSubmitLoginBackground.init();
});
it("updates the most recent idp host when a tab is activated", async () => {
jest.spyOn(BrowserApi, "getTab").mockResolvedValue(newTab);
triggerTabOnActivatedEvent(mock<chrome.tabs.TabActiveInfo>({ tabId: newTabId }));
await flushPromises();
expect(autoSubmitLoginBackground["mostRecentIdpHost"]).toStrictEqual({
url: validIpdUrl2,
tabId: newTabId,
});
});
it("updates the most recent id host when a tab is updated", () => {
triggerTabOnUpdatedEvent(
newTabId,
mock<chrome.tabs.TabChangeInfo>({ url: validIpdUrl1 }),
newTab,
);
expect(autoSubmitLoginBackground["mostRecentIdpHost"]).toStrictEqual({
url: validIpdUrl1,
tabId: newTabId,
});
});
describe("when a tab completes a navigation event", () => {
it("clears the set of valid auto-submit hosts", () => {
autoSubmitLoginBackground["validAutoSubmitHosts"].add(validIpdUrl1);
triggerWebNavigationOnCompletedEvent(
mock<chrome.webNavigation.WebNavigationFramedCallbackDetails>({
tabId: newTabId,
url: validIpdUrl2,
frameId: 0,
}),
);
expect(autoSubmitLoginBackground["validAutoSubmitHosts"].size).toBe(0);
});
it("updates the most recent idp host", () => {
triggerWebNavigationOnCompletedEvent(
mock<chrome.webNavigation.WebNavigationFramedCallbackDetails>({
tabId: newTabId,
url: validIpdUrl2,
frameId: 0,
}),
);
expect(autoSubmitLoginBackground["mostRecentIdpHost"]).toStrictEqual({
url: validIpdUrl2,
tabId: newTabId,
});
});
it("clears the auto submit host data if the tab is removed or closed", () => {
triggerWebNavigationOnCompletedEvent(
mock<chrome.webNavigation.WebNavigationFramedCallbackDetails>({
tabId: newTabId,
url: validIpdUrl2,
frameId: 0,
}),
);
autoSubmitLoginBackground["currentAutoSubmitHostData"] = {
url: validIpdUrl2,
tabId: newTabId,
};
triggerTabOnRemovedEvent(newTabId, mock<chrome.tabs.TabRemoveInfo>());
expect(autoSubmitLoginBackground["currentAutoSubmitHostData"]).toStrictEqual({});
});
});
});
it("allows the route to trigger auto-submit after a chain redirection to a valid auto-submit URL is made", async () => {
await autoSubmitLoginBackground.init();
autoSubmitLoginBackground["mostRecentIdpHost"] = {
url: validIpdUrl1,
tabId: tabId,
};
triggerWebRequestOnBeforeRedirectEvent(
mock<chrome.webRequest.WebRedirectionResponseDetails>({
url: validIpdUrl1,
redirectUrl: validIpdUrl2,
frameId: 0,
}),
);
triggerWebRequestOnBeforeRedirectEvent(
mock<chrome.webRequest.WebRedirectionResponseDetails>({
url: validIpdUrl2,
redirectUrl: validAutoSubmitUrl,
frameId: 0,
}),
);
triggerWebRequestOnBeforeRequestEvent(
mock<chrome.webRequest.WebRequestBodyDetails>({
tabId: tabId,
url: `https://${validAutoSubmitHost}`,
initiator: null,
frameId: 0,
}),
);
expect(chrome.webNavigation.onCompleted.addListener).toBeCalledWith(expect.any(Function), {
url: [{ hostEquals: validAutoSubmitHost }],
});
});
});
describe("extension message listeners", () => {
let sender: chrome.runtime.MessageSender;
beforeEach(async () => {
await autoSubmitLoginBackground.init();
autoSubmitLoginBackground["validAutoSubmitHosts"].add(validAutoSubmitHost);
autoSubmitLoginBackground["currentAutoSubmitHostData"] = {
url: validAutoSubmitUrl,
tabId: 1,
};
sender = mock<chrome.runtime.MessageSender>({
tab: mock<chrome.tabs.Tab>({ id: 1 }),
frameId: 0,
url: validAutoSubmitUrl,
});
});
it("skips acting on messages that do not come from the current auto-fill workflow's tab", () => {
sender.tab = mock<chrome.tabs.Tab>({ id: 2 });
sendMockExtensionMessage({ command: "triggerAutoSubmitLogin" }, sender);
expect(autofillService.doAutoFillOnTab).not.toHaveBeenCalled;
});
it("skips acting on messages whose command does not have a registered handler", () => {
sendMockExtensionMessage({ command: "someInvalidCommand" }, sender);
expect(autofillService.doAutoFillOnTab).not.toHaveBeenCalled;
});
describe("triggerAutoSubmitLogin extension message", () => {
it("triggers an autofill action with auto-submission on the sender of the message", async () => {
const message = {
command: "triggerAutoSubmitLogin",
pageDetails: mock<AutofillPageDetails>(),
};
sendMockExtensionMessage(message, sender);
await flushPromises();
expect(autofillService.doAutoFillOnTab).toBeCalledWith(
[
{
frameId: sender.frameId,
tab: sender.tab,
details: message.pageDetails,
},
],
sender.tab,
true,
true,
);
});
});
describe("multiStepAutoSubmitLoginComplete extension message", () => {
it("removes the sender URL from the set of valid auto-submit hosts", () => {
const message = { command: "multiStepAutoSubmitLoginComplete" };
sendMockExtensionMessage(message, sender);
expect(autoSubmitLoginBackground["validAutoSubmitHosts"].has(validAutoSubmitHost)).toBe(
false,
);
});
});
});
});
});

View File

@ -0,0 +1,648 @@
import { firstValueFrom } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { BrowserApi } from "../../platform/browser/browser-api";
import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service";
import { AutofillService } from "../services/abstractions/autofill.service";
import {
AutoSubmitLoginBackground as AutoSubmitLoginBackgroundAbstraction,
AutoSubmitLoginBackgroundExtensionMessageHandlers,
AutoSubmitLoginMessage,
} from "./abstractions/auto-submit-login.background";
export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstraction {
private validIdpHosts: Set<string> = new Set();
private validAutoSubmitHosts: Set<string> = new Set();
private mostRecentIdpHost: { url?: string; tabId?: number } = {};
private currentAutoSubmitHostData: { url?: string; tabId?: number } = {};
private readonly isSafariBrowser: boolean = false;
private readonly extensionMessageHandlers: AutoSubmitLoginBackgroundExtensionMessageHandlers = {
triggerAutoSubmitLogin: ({ message, sender }) => this.triggerAutoSubmitLogin(message, sender),
multiStepAutoSubmitLoginComplete: ({ sender }) =>
this.handleMultiStepAutoSubmitLoginComplete(sender),
};
constructor(
private logService: LogService,
private autofillService: AutofillService,
private scriptInjectorService: ScriptInjectorService,
private authService: AuthService,
private configService: ConfigService,
private platformUtilsService: PlatformUtilsService,
private policyService: PolicyService,
) {
this.isSafariBrowser = this.platformUtilsService.isSafari();
}
/**
* Initializes the auto-submit login policy. Will return early if
* the feature flag is not set. If the policy is not enabled, it
* will trigger a removal of any established listeners.
*/
async init() {
const featureFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.IdpAutoSubmitLogin,
);
if (featureFlagEnabled) {
this.policyService
.get$(PolicyType.AutomaticAppLogIn)
.subscribe(this.handleAutoSubmitLoginPolicySubscription.bind(this));
}
}
/**
* Handles changes to the AutomaticAppLogIn policy. If the policy is not enabled, trigger
* a removal of any established listeners. If the policy is enabled, apply the policy to
* the active user.
*
* @param policy - The AutomaticAppLogIn policy details.
*/
private handleAutoSubmitLoginPolicySubscription = (policy: Policy) => {
if (!policy?.enabled) {
this.destroy();
return;
}
this.applyPolicyToActiveUser(policy).catch((error) => this.logService.error(error));
};
/**
* Verifies if the policy applies to the active user. If so, the event listeners
* used to trigger auto-submission of login forms will be established.
*
* @param policy - The AutomaticAppLogIn policy details.
*/
private applyPolicyToActiveUser = async (policy: Policy) => {
const policyAppliesToUser = await firstValueFrom(
this.policyService.policyAppliesToActiveUser$(PolicyType.AutomaticAppLogIn),
);
if (!policyAppliesToUser) {
this.destroy();
return;
}
await this.setupAutoSubmitLoginListeners(policy);
};
/**
* Sets up the event listeners used to trigger auto-submission of login forms.
*
* @param policy - The AutomaticAppLogIn policy details.
*/
private setupAutoSubmitLoginListeners = async (policy: Policy) => {
this.parseIpdHostsFromPolicy(policy?.data.idpHost);
if (!this.validIdpHosts.size) {
this.destroy();
return;
}
BrowserApi.addListener(chrome.runtime.onMessage, this.handleExtensionMessage);
chrome.webRequest.onBeforeRequest.addListener(this.handleOnBeforeRequest, {
urls: ["<all_urls>"],
types: ["main_frame", "sub_frame"],
});
chrome.webRequest.onBeforeRedirect.addListener(this.handleWebRequestOnBeforeRedirect, {
urls: ["<all_urls>"],
types: ["main_frame", "sub_frame"],
});
if (this.isSafariBrowser) {
this.initSafari().catch((error) => this.logService.error(error));
}
};
/**
* Parses the comma-separated list of IDP hosts from the AutomaticAppLogIn policy.
*
* @param idpHost - The comma-separated list of IDP hosts.
*/
private parseIpdHostsFromPolicy = (idpHost?: string) => {
if (!idpHost) {
return;
}
const urls = idpHost.split(",");
urls.forEach((url) => {
const host = this.getUrlHost(url?.trim());
if (host) {
this.validIdpHosts.add(host);
}
});
};
/**
* Handles the onBeforeRequest event. This event is used to determine if a request should initialize
* the auto-submit login workflow. A valid request will initialize the workflow, while an invalid
* request will clear and disable the workflow.
*
* @param details - The details of the request.
*/
private handleOnBeforeRequest = (details: chrome.webRequest.WebRequestBodyDetails) => {
const requestInitiator = this.getRequestInitiator(details);
const isValidInitiator = this.isValidInitiator(requestInitiator);
if (
this.postRequestEncounteredAfterSubmission(details, isValidInitiator) ||
this.requestRedirectsToInvalidHost(details, isValidInitiator)
) {
this.clearAutoSubmitHostData();
return;
}
if (isValidInitiator && this.shouldRouteTriggerAutoSubmit(details, requestInitiator)) {
this.setupAutoSubmitFlow(details);
return;
}
this.disableAutoSubmitFlow(requestInitiator, details).catch((error) =>
this.logService.error(error),
);
};
/**
* This triggers if the upcoming request is a POST request and the initiator is valid. It indicates
* that a submission has occurred and the auto-submit login workflow should be cleared.
*
* @param details - The details of the request.
* @param isValidInitiator - A flag indicating if the initiator of the request is valid.
*/
private postRequestEncounteredAfterSubmission = (
details: chrome.webRequest.WebRequestBodyDetails,
isValidInitiator: boolean,
) => {
return details.method === "POST" && this.validAutoSubmitHosts.size > 0 && isValidInitiator;
};
/**
* Determines if a request is attempting to redirect to an invalid host. We identify this as a case
* where the top level frame has navigated to either an invalid IDP host or auto-submit host.
*
* @param details - The details of the request.
* @param isValidInitiator - A flag indicating if the initiator of the request is valid.
*/
private requestRedirectsToInvalidHost = (
details: chrome.webRequest.WebRequestBodyDetails,
isValidInitiator: boolean,
) => {
return (
this.validAutoSubmitHosts.size > 0 &&
this.isRequestInMainFrame(details) &&
(!isValidInitiator || !this.isValidAutoSubmitHost(details.url))
);
};
/**
* Initializes the auto-submit flow for the given request, and adds the routed-to URL
* to the list of valid auto-submit hosts.
*
* @param details - The details of the request.
*/
private setupAutoSubmitFlow = (details: chrome.webRequest.WebRequestBodyDetails) => {
if (this.isRequestInMainFrame(details)) {
this.currentAutoSubmitHostData = {
url: details.url,
tabId: details.tabId,
};
}
const autoSubmitHost = this.getUrlHost(details.url);
this.validAutoSubmitHosts.add(autoSubmitHost);
chrome.webNavigation.onCompleted.removeListener(this.handleAutoSubmitHostNavigationCompleted);
chrome.webNavigation.onCompleted.addListener(this.handleAutoSubmitHostNavigationCompleted, {
url: [{ hostEquals: autoSubmitHost }],
});
};
/**
* Triggers the injection of the auto-submit login content script once the page has completely loaded.
*
* @param details - The details of the navigation event.
*/
private handleAutoSubmitHostNavigationCompleted = (
details: chrome.webNavigation.WebNavigationFramedCallbackDetails,
) => {
if (
details.tabId === this.currentAutoSubmitHostData.tabId &&
this.urlContainsAutoFillParam(details.url)
) {
this.injectAutoSubmitLoginScript(details.tabId).catch((error) =>
this.logService.error(error),
);
chrome.webNavigation.onCompleted.removeListener(this.handleAutoSubmitHostNavigationCompleted);
}
};
/**
* Triggers the injection of the auto-submit login script if the user is authenticated.
*
* @param tabId - The ID of the tab to inject the script into.
*/
private injectAutoSubmitLoginScript = async (tabId: number) => {
if ((await this.getAuthStatus()) === AuthenticationStatus.Unlocked) {
await this.scriptInjectorService.inject({
tabId: tabId,
injectDetails: {
file: "content/auto-submit-login.js",
runAt: "document_start",
frame: "all_frames",
},
});
}
};
/**
* Retrieves the authentication status of the active user.
*/
private getAuthStatus = async () => {
return firstValueFrom(this.authService.activeAccountStatus$);
};
/**
* Handles web requests that are triggering a redirect. Stores the redirect URL as a valid
* auto-submit host if the redirectUrl should trigger an auto-submit.
*
* @param details - The details of the request.
*/
private handleWebRequestOnBeforeRedirect = (
details: chrome.webRequest.WebRedirectionResponseDetails,
) => {
if (this.isRequestInMainFrame(details) && this.urlContainsAutoFillParam(details.redirectUrl)) {
this.validAutoSubmitHosts.add(this.getUrlHost(details.redirectUrl));
this.validAutoSubmitHosts.add(this.getUrlHost(details.url));
}
};
/**
* Determines if the provided URL is a valid initiator for the auto-submit login feature.
*
* @param url - The URL to validate as an initiator.
*/
private isValidInitiator = (url: string) => {
return this.isValidIdpHost(url) || this.isValidAutoSubmitHost(url);
};
/**
* Determines if the provided URL is a valid IDP host.
*
* @param url - The URL to validate as an IDP host.
*/
private isValidIdpHost = (url: string) => {
const host = this.getUrlHost(url);
if (!host) {
return false;
}
return this.validIdpHosts.has(host);
};
/**
* Determines if the provided URL is a valid auto-submit host.
*
* @param url - The URL to validate as an auto-submit host.
*/
private isValidAutoSubmitHost = (url: string) => {
const host = this.getUrlHost(url);
if (!host) {
return false;
}
return this.validAutoSubmitHosts.has(host);
};
/**
* Removes the provided URL from the list of valid auto-submit hosts.
*
* @param url - The URL to remove from the list of valid auto-submit hosts.
*/
private removeUrlFromAutoSubmitHosts = (url: string) => {
this.validAutoSubmitHosts.delete(this.getUrlHost(url));
};
/**
* Disables an active auto-submit login workflow. This triggers when a request is made that should
* not trigger auto-submit. If the initiator of the request is a valid auto-submit host, we need to
* treat this request as a navigation within the current website, but away from the intended
* auto-submit route. If that isn't the case, we capture the tab's details and check if an
* internal navigation is occurring. If so, we invalidate that host.
*
* @param requestInitiator - The initiator of the request.
* @param details - The details of the request.
*/
private disableAutoSubmitFlow = async (
requestInitiator: string,
details: chrome.webRequest.WebRequestBodyDetails,
) => {
if (this.isValidAutoSubmitHost(requestInitiator)) {
this.removeUrlFromAutoSubmitHosts(requestInitiator);
return;
}
if (details.tabId < 0) {
return;
}
const tab = await BrowserApi.getTab(details.tabId);
if (this.isValidAutoSubmitHost(tab?.url)) {
this.removeUrlFromAutoSubmitHosts(tab.url);
}
};
/**
* Clears all data associated with the current auto-submit host workflow.
*/
private clearAutoSubmitHostData = () => {
this.validAutoSubmitHosts.clear();
this.currentAutoSubmitHostData = {};
this.mostRecentIdpHost = {};
};
/**
* Determines if the provided URL is a valid auto-submit host. If the request is occurring
* in the main frame, we will check for the presence of the `autofill=1` query parameter.
* If the request is occurring in a sub frame, the main frame URL should be set as a
* valid auto-submit host and can be used to validate the request.
*
* @param details - The details of the request.
* @param initiator - The initiator of the request.
*/
private shouldRouteTriggerAutoSubmit = (
details: chrome.webRequest.ResourceRequest,
initiator: string,
) => {
if (this.isRequestInMainFrame(details)) {
return !!(
this.urlContainsAutoFillParam(details.url) ||
this.triggerAutoSubmitAfterRedirectOnSafari(details.url)
);
}
return this.isValidAutoSubmitHost(initiator);
};
/**
* Determines if the provided URL contains the `autofill=1` query parameter.
*
* @param url - The URL to check for the `autofill=1` query parameter.
*/
private urlContainsAutoFillParam = (url: string) => {
try {
const urlObj = new URL(url);
return urlObj.search.indexOf("autofill=1") !== -1;
} catch {
return false;
}
};
/**
* Extracts the host from a given URL.
* Will return an empty string if the provided URL is invalid.
*
* @param url - The URL to extract the host from.
*/
private getUrlHost = (url: string) => {
let parsedUrl = url;
if (!parsedUrl) {
return "";
}
if (!parsedUrl.startsWith("http")) {
parsedUrl = `https://${parsedUrl}`;
}
try {
const urlObj = new URL(parsedUrl);
return urlObj.host;
} catch {
return "";
}
};
/**
* Determines the initiator of a request. If the request is happening in a Safari browser, we
* need to determine the initiator based on the stored most recently visited IDP host. When
* handling a sub frame request in Safari, we treat the passed URL detail as the initiator
* of the request, as long as an IPD host has been previously identified.
*
* @param details - The details of the request.
*/
private getRequestInitiator = (details: chrome.webRequest.ResourceRequest) => {
if (!this.isSafariBrowser) {
return details.initiator || (details as browser.webRequest._OnBeforeRequestDetails).originUrl;
}
if (this.isRequestInMainFrame(details)) {
return this.mostRecentIdpHost.url;
}
if (!this.mostRecentIdpHost.url) {
return "";
}
return details.url;
};
/**
* Verifies if a request is occurring in the main / top-level frame of a tab.
*
* @param details - The details of the request.
*/
private isRequestInMainFrame = (details: chrome.webRequest.ResourceRequest) => {
if (this.isSafariBrowser) {
return details.frameId === 0;
}
return details.type === "main_frame";
};
/**
* Triggers the auto-submit login feature on the provided tab.
*
* @param message - The auto-submit login message.
* @param sender - The message sender.
*/
private triggerAutoSubmitLogin = async (
message: AutoSubmitLoginMessage,
sender: chrome.runtime.MessageSender,
) => {
await this.autofillService.doAutoFillOnTab(
[
{
frameId: sender.frameId,
tab: sender.tab,
details: message.pageDetails,
},
],
sender.tab,
true,
true,
);
};
/**
* Handles the completion of auto-submit login workflow on a multistep form.
*
* @param sender - The message sender.
*/
private handleMultiStepAutoSubmitLoginComplete = (sender: chrome.runtime.MessageSender) => {
this.removeUrlFromAutoSubmitHosts(sender.url);
};
/**
* Initializes several fallback event listeners for the auto-submit login feature on the Safari browser.
* This is required due to limitations that Safari has with the `webRequest` API. Specifically, Safari
* does not provide the `initiator` of a request, which is required to determine if a request is coming
* from a valid IDP host.
*/
private async initSafari() {
const currentTab = await BrowserApi.getTabFromCurrentWindow();
if (currentTab) {
this.setMostRecentIdpHost(currentTab.url, currentTab.id);
}
chrome.tabs.onActivated.addListener(this.handleSafariTabOnActivated);
chrome.tabs.onUpdated.addListener(this.handleSafariTabOnUpdated);
chrome.webNavigation.onCompleted.addListener(this.handleSafariWebNavigationOnCompleted);
}
/**
* Sets the most recent IDP host based on the provided URL and tab ID.
*
* @param url - The URL to set as the most recent IDP host.
* @param tabId - The tab ID associated with the URL.
*/
private setMostRecentIdpHost(url: string, tabId: number) {
if (this.isValidIdpHost(url)) {
this.mostRecentIdpHost = { url, tabId };
}
}
/**
* Triggers an update of the most recently visited IDP host when a user focuses a different tab.
*
* @param activeInfo - The active tab information.
*/
private handleSafariTabOnActivated = async (activeInfo: chrome.tabs.TabActiveInfo) => {
if (activeInfo.tabId < 0) {
return;
}
const tab = await BrowserApi.getTab(activeInfo.tabId);
if (tab) {
this.setMostRecentIdpHost(tab.url, tab.id);
}
};
/**
* Triggers an update of the most recently visited IDP host when the URL of a tab is updated.
*
* @param tabId - The tab ID associated with the URL.
* @param changeInfo - The change information of the tab.
*/
private handleSafariTabOnUpdated = (tabId: number, changeInfo: chrome.tabs.TabChangeInfo) => {
if (changeInfo) {
this.setMostRecentIdpHost(changeInfo.url, tabId);
}
};
/**
* Handles the completion of a web navigation event on the Safari browser. If the navigation event
* is for the main frame and the URL is a valid IDP host, the most recent IDP host will be updated.
*
* @param details - The web navigation details.
*/
private handleSafariWebNavigationOnCompleted = (
details: chrome.webNavigation.WebNavigationFramedCallbackDetails,
) => {
if (details.frameId === 0 && this.isValidIdpHost(details.url)) {
this.validAutoSubmitHosts.clear();
this.mostRecentIdpHost = {
url: details.url,
tabId: details.tabId,
};
chrome.tabs.onRemoved.addListener(this.handleSafariTabOnRemoved);
}
};
/**
* Handles the removal of a tab on the Safari browser. If the tab being removed is the current
* auto-submit host tab, all data associated with the current auto-submit workflow will be cleared.
*
* @param tabId - The tab ID of the tab being removed.
*/
private handleSafariTabOnRemoved = (tabId: number) => {
if (this.currentAutoSubmitHostData.tabId === tabId) {
this.clearAutoSubmitHostData();
chrome.tabs.onRemoved.removeListener(this.handleSafariTabOnRemoved);
}
};
/**
* Determines if the auto-submit login feature should be triggered after a redirect on the Safari browser.
* This is required because Safari does not provide query params for the URL that is being routed to within
* the onBefore request listener.
*
* @param url - The URL of the redirect.
*/
private triggerAutoSubmitAfterRedirectOnSafari = (url: string) => {
return this.isSafariBrowser && this.isValidAutoSubmitHost(url);
};
/**
* Handles incoming messages from the extension. The message is only listened to if it comes from
* the current auto-submit workflow tab and the URL is a valid auto-submit host.
*
* @param message - The incoming message.
* @param sender - The message sender.
* @param sendResponse - The response callback.
*/
private handleExtensionMessage = async (
message: AutoSubmitLoginMessage,
sender: chrome.runtime.MessageSender,
sendResponse: (response?: any) => void,
) => {
const { tab, url } = sender;
if (tab?.id !== this.currentAutoSubmitHostData.tabId || !this.isValidAutoSubmitHost(url)) {
return null;
}
const handler: CallableFunction | undefined = this.extensionMessageHandlers[message?.command];
if (!handler) {
return null;
}
const messageResponse = handler({ message, sender });
if (typeof messageResponse === "undefined") {
return null;
}
Promise.resolve(messageResponse)
.then((response) => sendResponse(response))
.catch((error) => this.logService.error(error));
return true;
};
/**
* Tears down all established event listeners for the auto-submit login feature.
*/
private destroy() {
BrowserApi.removeListener(chrome.runtime.onMessage, this.handleExtensionMessage);
chrome.webRequest.onBeforeRequest.removeListener(this.handleOnBeforeRequest);
chrome.webRequest.onBeforeRedirect.removeListener(this.handleWebRequestOnBeforeRedirect);
chrome.webNavigation.onCompleted.removeListener(this.handleAutoSubmitHostNavigationCompleted);
chrome.webNavigation.onCompleted.removeListener(this.handleSafariWebNavigationOnCompleted);
chrome.tabs.onActivated.removeListener(this.handleSafariTabOnActivated);
chrome.tabs.onUpdated.removeListener(this.handleSafariTabOnUpdated);
chrome.tabs.onRemoved.removeListener(this.handleSafariTabOnRemoved);
}
}

View File

@ -0,0 +1,327 @@
import AutofillPageDetails from "../models/autofill-page-details";
import AutofillScript from "../models/autofill-script";
import {
createAutofillFieldMock,
createAutofillPageDetailsMock,
createAutofillScriptMock,
} from "../spec/autofill-mocks";
import { flushPromises, sendMockExtensionMessage } from "../spec/testing-utils";
import { FormFieldElement } from "../types";
let pageDetailsMock: AutofillPageDetails;
let fillScriptMock: AutofillScript;
let autofillFieldElementByOpidMock: FormFieldElement;
jest.mock("../services/collect-autofill-content.service", () => {
const module = jest.requireActual("../services/collect-autofill-content.service");
return {
CollectAutofillContentService: class extends module.CollectAutofillContentService {
async getPageDetails(): Promise<AutofillPageDetails> {
return pageDetailsMock;
}
deepQueryElements<T>(element: HTMLElement, queryString: string): T[] {
return Array.from(element.querySelectorAll(queryString)) as T[];
}
getAutofillFieldElementByOpid(opid: string) {
const mockedEl = autofillFieldElementByOpidMock;
if (mockedEl) {
autofillFieldElementByOpidMock = null;
return mockedEl;
}
return Array.from(document.querySelectorAll(`*`)).find(
(el) => (el as any).opid === opid,
) as FormFieldElement;
}
},
};
});
jest.mock("../services/insert-autofill-content.service");
describe("AutoSubmitLogin content script", () => {
beforeEach(() => {
jest.useFakeTimers();
setupEnvironmentDefaults();
require("./auto-submit-login");
});
afterEach(() => {
jest.resetModules();
jest.clearAllTimers();
});
afterAll(() => {
jest.clearAllMocks();
});
it("ends the auto-submit login workflow if the page does not contain any fields", async () => {
pageDetailsMock.fields = [];
await initAutoSubmitWorkflow();
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
command: "updateIsFieldCurrentlyFilling",
isFieldCurrentlyFilling: false,
});
});
describe("when the page contains form fields", () => {
it("ends the auto-submit login workflow if the provided fill script does not contain an autosubmit value", async () => {
await initAutoSubmitWorkflow();
sendMockExtensionMessage({
command: "triggerAutoSubmitLogin",
fillScript: fillScriptMock,
pageDetailsUrl: globalThis.location.href,
});
await flushPromises();
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
command: "updateIsFieldCurrentlyFilling",
isFieldCurrentlyFilling: false,
});
});
describe("triggering auto-submit on formless fields", () => {
beforeEach(async () => {
pageDetailsMock.fields = [
createAutofillFieldMock({ htmlID: "username", formOpid: null, opid: "name-field" }),
createAutofillFieldMock({
htmlID: "password",
type: "password",
formOpid: null,
opid: "password-field",
}),
];
fillScriptMock = createAutofillScriptMock(
{
autosubmit: [null],
},
{ "name-field": "name-value", "password-field": "password-value" },
);
document.body.innerHTML = `
<div>
<div>
<label for="username">Username</label>
<input type="text" id="username" name="username">
</div>
<div>
<label for="password">Password</label>
<input type="password" id="password" name="password">
</div>
</div>
<div class="submit-container">
<input type="submit" value="Submit">
</div>
`;
const passwordElement = document.getElementById("password") as HTMLInputElement;
(passwordElement as any).opid = "password-field";
await initAutoSubmitWorkflow();
});
it("triggers the submit action on an element that contains a type=Submit attribute", async () => {
const submitButton = document.querySelector(
".submit-container input[type=submit]",
) as HTMLInputElement;
jest.spyOn(submitButton, "click");
sendMockExtensionMessage({
command: "triggerAutoSubmitLogin",
fillScript: fillScriptMock,
pageDetailsUrl: globalThis.location.href,
});
await flushPromises();
expect(submitButton.click).toHaveBeenCalled();
});
it("triggers the submit action on a button element if a type=Submit element does not exist", async () => {
const submitButton = document.createElement("button");
submitButton.innerHTML = "Submit";
const submitContainer = document.querySelector(".submit-container");
submitContainer.innerHTML = "";
submitContainer.appendChild(submitButton);
jest.spyOn(submitButton, "click");
sendMockExtensionMessage({
command: "triggerAutoSubmitLogin",
fillScript: fillScriptMock,
pageDetailsUrl: globalThis.location.href,
});
await flushPromises();
expect(submitButton.click).toHaveBeenCalled();
});
it("triggers the submit action when the field is within a shadow root", async () => {
createFormlessShadowRootFields();
const submitButton = document.querySelector("input[type=submit]") as HTMLInputElement;
jest.spyOn(submitButton, "click");
sendMockExtensionMessage({
command: "triggerAutoSubmitLogin",
fillScript: fillScriptMock,
pageDetailsUrl: globalThis.location.href,
});
await flushPromises();
expect(submitButton.click).toHaveBeenCalled();
});
});
describe("triggering auto-submit on a form", () => {
beforeEach(async () => {
pageDetailsMock.fields = [
createAutofillFieldMock({
htmlID: "username",
formOpid: "__form0__",
opid: "name-field",
}),
createAutofillFieldMock({
htmlID: "password",
type: "password",
formOpid: "__form0__",
opid: "password-field",
}),
];
fillScriptMock = createAutofillScriptMock(
{
autosubmit: ["__form0__"],
},
{ "name-field": "name-value", "password-field": "password-value" },
);
document.body.innerHTML = `
<form>
<div>
<div>
<label for="username">Username</label>
<input type="text" id="username" name="username">
</div>
<div>
<label for="password">Password</label>
<input type="password" id="password" name="password">
</div>
</div>
<div class="submit-container">
<input type="submit" value="Submit">
</div>
</form>
`;
const formElement = document.querySelector("form") as HTMLFormElement;
(formElement as any).opid = "__form0__";
formElement.addEventListener("submit", (e) => e.preventDefault());
const passwordElement = document.getElementById("password") as HTMLInputElement;
(passwordElement as any).opid = "password-field";
await initAutoSubmitWorkflow();
});
it("attempts to trigger submission of the element as a formless field if the form cannot be found by opid", async () => {
const formElement = document.querySelector("form") as HTMLFormElement;
(formElement as any).opid = "__form1__";
const submitButton = document.querySelector(
".submit-container input[type=submit]",
) as HTMLInputElement;
jest.spyOn(submitButton, "click");
sendMockExtensionMessage({
command: "triggerAutoSubmitLogin",
fillScript: fillScriptMock,
pageDetailsUrl: globalThis.location.href,
});
await flushPromises();
expect(submitButton.click).toHaveBeenCalled();
});
it("triggers the submit action on an element that contains a type=Submit attribute", async () => {
const submitButton = document.querySelector(
".submit-container input[type=submit]",
) as HTMLInputElement;
jest.spyOn(submitButton, "click");
sendMockExtensionMessage({
command: "triggerAutoSubmitLogin",
fillScript: fillScriptMock,
pageDetailsUrl: globalThis.location.href,
});
await flushPromises();
expect(submitButton.click).toHaveBeenCalled();
});
it("triggers the form's requestSubmit method when the form does not contain an button to allow submission", async () => {
const submitButton = document.querySelector(
".submit-container input[type=submit]",
) as HTMLInputElement;
submitButton.remove();
const formElement = document.querySelector("form") as HTMLFormElement;
jest.spyOn(formElement, "requestSubmit").mockImplementation();
sendMockExtensionMessage({
command: "triggerAutoSubmitLogin",
fillScript: fillScriptMock,
pageDetailsUrl: globalThis.location.href,
});
await flushPromises();
expect(formElement.requestSubmit).toHaveBeenCalled();
});
it("triggers the form's submit method when the requestSubmit method is not available", async () => {
const submitButton = document.querySelector(
".submit-container input[type=submit]",
) as HTMLInputElement;
submitButton.remove();
const formElement = document.querySelector("form") as HTMLFormElement;
formElement.requestSubmit = undefined;
jest.spyOn(formElement, "submit").mockImplementation();
sendMockExtensionMessage({
command: "triggerAutoSubmitLogin",
fillScript: fillScriptMock,
pageDetailsUrl: globalThis.location.href,
});
await flushPromises();
expect(formElement.submit).toHaveBeenCalled();
});
});
});
});
function setupEnvironmentDefaults() {
document.body.innerHTML = ``;
pageDetailsMock = createAutofillPageDetailsMock();
fillScriptMock = createAutofillScriptMock();
}
async function initAutoSubmitWorkflow() {
jest.advanceTimersByTime(250);
await flushPromises();
}
function createFormlessShadowRootFields() {
document.body.innerHTML = ``;
const wrapper = document.createElement("div");
const usernameShadowRoot = document.createElement("div");
usernameShadowRoot.attachShadow({ mode: "open" });
usernameShadowRoot.shadowRoot.innerHTML = `<input type="text" id="username" name="username">`;
const passwordShadowRoot = document.createElement("div");
passwordShadowRoot.attachShadow({ mode: "open" });
const passwordElement = document.createElement("input");
passwordElement.type = "password";
passwordElement.id = "password";
passwordElement.name = "password";
(passwordElement as any).opid = "password-field";
autofillFieldElementByOpidMock = passwordElement;
passwordShadowRoot.shadowRoot.appendChild(passwordElement);
const normalSubmitButton = document.createElement("input");
normalSubmitButton.type = "submit";
wrapper.appendChild(usernameShadowRoot);
wrapper.appendChild(passwordShadowRoot);
wrapper.appendChild(normalSubmitButton);
document.body.appendChild(wrapper);
}

View File

@ -0,0 +1,328 @@
import { EVENTS } from "@bitwarden/common/autofill/constants";
import AutofillPageDetails from "../models/autofill-page-details";
import AutofillScript from "../models/autofill-script";
import { CollectAutofillContentService } from "../services/collect-autofill-content.service";
import DomElementVisibilityService from "../services/dom-element-visibility.service";
import InsertAutofillContentService from "../services/insert-autofill-content.service";
import { elementIsInputElement, nodeIsFormElement, sendExtensionMessage } from "../utils";
(function (globalContext) {
const domElementVisibilityService = new DomElementVisibilityService();
const collectAutofillContentService = new CollectAutofillContentService(
domElementVisibilityService,
);
const insertAutofillContentService = new InsertAutofillContentService(
domElementVisibilityService,
collectAutofillContentService,
);
const loginKeywords = [
"login",
"log in",
"log-in",
"signin",
"sign in",
"sign-in",
"submit",
"continue",
"next",
];
let autoSubmitLoginTimeout: number | NodeJS.Timeout;
init();
/**
* Initializes the auto-submit workflow with a delay to ensure that all page content is loaded.
*/
function init() {
const triggerOnPageLoad = () => setAutoSubmitLoginTimeout(250);
if (globalContext.document.readyState === "complete") {
triggerOnPageLoad();
return;
}
globalContext.document.addEventListener(EVENTS.DOMCONTENTLOADED, triggerOnPageLoad);
}
/**
* Collects the autofill page details and triggers the auto-submit login workflow.
* If no details are found, we exit the auto-submit workflow.
*/
async function startAutoSubmitLoginWorkflow() {
const pageDetails: AutofillPageDetails = await collectAutofillContentService.getPageDetails();
if (!pageDetails?.fields.length) {
endUpAutoSubmitLoginWorkflow();
return;
}
chrome.runtime.onMessage.addListener(handleExtensionMessage);
await sendExtensionMessage("triggerAutoSubmitLogin", { pageDetails });
}
/**
* Ends the auto-submit login workflow.
*/
function endUpAutoSubmitLoginWorkflow() {
clearAutoSubmitLoginTimeout();
updateIsFieldCurrentlyFilling(false);
}
/**
* Handles the extension message used to trigger the auto-submit login action.
*
* @param command - The command to execute
* @param fillScript - The autofill script to use
* @param pageDetailsUrl - The URL of the page details
*/
async function handleExtensionMessage({
command,
fillScript,
pageDetailsUrl,
}: {
command: string;
fillScript: AutofillScript;
pageDetailsUrl: string;
}) {
if (
command !== "triggerAutoSubmitLogin" ||
(globalContext.document.defaultView || globalContext).location.href !== pageDetailsUrl
) {
return;
}
await triggerAutoSubmitLogin(fillScript);
}
/**
* Fills the fields set within the autofill script and triggers the auto-submit action. Will
* also set up a subsequent auto-submit action to continue the workflow on any multistep
* login forms.
*
* @param fillScript - The autofill script to use
*/
async function triggerAutoSubmitLogin(fillScript: AutofillScript) {
if (!fillScript?.autosubmit?.length) {
endUpAutoSubmitLoginWorkflow();
throw new Error("Unable to auto-submit form, no autosubmit reference found.");
}
updateIsFieldCurrentlyFilling(true);
await insertAutofillContentService.fillForm(fillScript);
setAutoSubmitLoginTimeout(400);
triggerAutoSubmitOnForm(fillScript);
}
/**
* Triggers the auto-submit action on the form element. Will attempt to click an existing
* submit button, and if none are found, will attempt to submit the form directly. Note
* only the first matching field will be used to trigger the submit action. We will not
* attempt to trigger the submit action on multiple forms that might exist on a page.
*
* @param fillScript - The autofill script to use
*/
function triggerAutoSubmitOnForm(fillScript: AutofillScript) {
const formOpid = fillScript.autosubmit[0];
if (formOpid === null) {
triggerAutoSubmitOnFormlessFields(fillScript);
return;
}
const formElement = getAutofillFormElementByOpid(formOpid);
if (!formElement) {
triggerAutoSubmitOnFormlessFields(fillScript);
return;
}
if (submitElementFoundAndClicked(formElement)) {
return;
}
if (formElement.requestSubmit) {
formElement.requestSubmit();
return;
}
formElement.submit();
}
/**
* Triggers the auto-submit action on formless fields. This is done by iterating up the DOM
* tree, and attempting to find a submit button or form element to trigger the submit action.
*
* @param fillScript - The autofill script to use
*/
function triggerAutoSubmitOnFormlessFields(fillScript: AutofillScript) {
let currentElement = collectAutofillContentService.getAutofillFieldElementByOpid(
fillScript.script[fillScript.script.length - 1][1],
);
const lastFieldIsPasswordInput =
elementIsInputElement(currentElement) && currentElement.type === "password";
while (currentElement && currentElement.tagName !== "HTML") {
if (submitElementFoundAndClicked(currentElement, lastFieldIsPasswordInput)) {
return;
}
if (!currentElement.parentElement && currentElement.getRootNode() instanceof ShadowRoot) {
currentElement = (currentElement.getRootNode() as ShadowRoot).host as any;
continue;
}
currentElement = currentElement.parentElement;
}
if (!currentElement || currentElement.tagName === "HTML") {
endUpAutoSubmitLoginWorkflow();
throw new Error("Unable to auto-submit form, no submit button or form element found.");
}
}
/**
* Queries the element for an element of type="submit" or a button element with a keyword
* that matches a login action. If found, the element is clicked and the submit action is
* triggered.
*
* @param element - The element to query for a submit action
* @param lastFieldIsPasswordInput - Whether the last field is a password input
*/
function submitElementFoundAndClicked(
element: HTMLElement,
lastFieldIsPasswordInput = false,
): boolean {
const genericSubmitElement = collectAutofillContentService.deepQueryElements<HTMLButtonElement>(
element,
"[type='submit']",
);
if (genericSubmitElement[0]) {
clickSubmitElement(genericSubmitElement[0], lastFieldIsPasswordInput);
return true;
}
const buttons = collectAutofillContentService.deepQueryElements<HTMLButtonElement>(
element,
"button",
);
for (let i = 0; i < buttons.length; i++) {
if (isLoginButton(buttons[i])) {
clickSubmitElement(buttons[i], lastFieldIsPasswordInput);
return true;
}
}
return false;
}
/**
* Handles clicking the submit element and optionally triggering
* a completion action for multistep login forms.
*
* @param element - The element to click
* @param lastFieldIsPasswordInput - Whether the last field is a password input
*/
function clickSubmitElement(element: HTMLElement, lastFieldIsPasswordInput = false) {
if (lastFieldIsPasswordInput) {
triggerMultiStepAutoSubmitLoginComplete();
}
element.click();
}
/**
* Gathers attributes from the element and checks if any of the values match the login
* keywords. This is used to determine if the element is a login button.
*
* @param element - The element to check
*/
function isLoginButton(element: HTMLElement) {
const keywordValues = [
element.textContent,
element.getAttribute("value"),
element.getAttribute("aria-label"),
element.getAttribute("aria-labelledby"),
element.getAttribute("aria-describedby"),
element.getAttribute("title"),
element.getAttribute("id"),
element.getAttribute("name"),
element.getAttribute("class"),
]
.join(",")
.toLowerCase();
return loginKeywords.some((keyword) => keywordValues.includes(keyword));
}
/**
* Retrieves a form element by its opid attribute.
*
* @param opid - The opid to search for
*/
function getAutofillFormElementByOpid(opid: string): HTMLFormElement | null {
const cachedFormElements = Array.from(
collectAutofillContentService.autofillFormElements.keys(),
);
const formElements = cachedFormElements?.length
? cachedFormElements
: getAutofillFormElements();
return formElements.find((formElement) => formElement.opid === opid) || null;
}
/**
* Gets all form elements on the page.
*/
function getAutofillFormElements(): HTMLFormElement[] {
const formElements: HTMLFormElement[] = [];
collectAutofillContentService.queryAllTreeWalkerNodes(
globalContext.document.documentElement,
(node: Node) => {
if (nodeIsFormElement(node)) {
formElements.push(node);
return true;
}
return false;
},
);
return formElements;
}
/**
* Sets a timeout to trigger the auto-submit login workflow.
*
* @param delay - The delay to wait before triggering the workflow
*/
function setAutoSubmitLoginTimeout(delay: number) {
clearAutoSubmitLoginTimeout();
autoSubmitLoginTimeout = globalContext.setTimeout(() => startAutoSubmitLoginWorkflow(), delay);
}
/**
* Clears the auto-submit login timeout.
*/
function clearAutoSubmitLoginTimeout() {
if (autoSubmitLoginTimeout) {
globalContext.clearInterval(autoSubmitLoginTimeout);
}
}
/**
* Triggers a completion action for multistep login forms.
*/
function triggerMultiStepAutoSubmitLoginComplete() {
endUpAutoSubmitLoginWorkflow();
void sendExtensionMessage("multiStepAutoSubmitLoginComplete");
}
/**
* Updates the state of whether a field is currently being filled. This ensures that
* the inline menu is not displayed when a field is being filled.
*
* @param isFieldCurrentlyFilling - Whether a field is currently being filled
*/
function updateIsFieldCurrentlyFilling(isFieldCurrentlyFilling: boolean) {
void sendExtensionMessage("updateIsFieldCurrentlyFilling", { isFieldCurrentlyFilling });
}
})(globalThis);

View File

@ -3,7 +3,7 @@ import { EVENTS } from "@bitwarden/common/autofill/constants";
import AutofillPageDetails from "../models/autofill-page-details";
import { AutofillInlineMenuContentService } from "../overlay/inline-menu/abstractions/autofill-inline-menu-content.service";
import { AutofillOverlayContentService } from "../services/abstractions/autofill-overlay-content.service";
import CollectAutofillContentService from "../services/collect-autofill-content.service";
import { CollectAutofillContentService } from "../services/collect-autofill-content.service";
import DomElementVisibilityService from "../services/dom-element-visibility.service";
import InsertAutofillContentService from "../services/insert-autofill-content.service";
import { sendExtensionMessage } from "../utils";

View File

@ -1,6 +1,6 @@
import { AutofillInit } from "../../content/abstractions/autofill-init";
import AutofillPageDetails from "../../models/autofill-page-details";
import CollectAutofillContentService from "../../services/collect-autofill-content.service";
import { CollectAutofillContentService } from "../../services/collect-autofill-content.service";
import DomElementVisibilityService from "../../services/dom-element-visibility.service";
import InsertAutofillContentService from "../../services/insert-autofill-content.service";
import { sendExtensionMessage } from "../../utils";

View File

@ -17,7 +17,7 @@ export default class AutofillScript {
script: FillScript[] = [];
properties: AutofillScriptProperties = {};
metadata: any = {}; // Unused, not written or read
autosubmit: any = null; // Appears to be unused, read but not written
autosubmit: string[]; // Appears to be unused, read but not written
savedUrls: string[];
untrustedIframe: boolean;
itemType: string; // Appears to be unused, read but not written

View File

@ -28,6 +28,7 @@ export interface AutoFillOptions {
skipLastUsed?: boolean;
allowUntrustedIframe?: boolean;
allowTotpAutofill?: boolean;
autoSubmitLogin?: boolean;
}
export interface FormData {
@ -43,6 +44,7 @@ export interface GenerateFillScriptOptions {
onlyVisibleFields: boolean;
fillNewPassword: boolean;
allowTotpAutofill: boolean;
autoSubmitLogin: boolean;
cipher: CipherView;
tabUrl: string;
defaultUriMatch: UriMatchStrategySetting;
@ -75,6 +77,7 @@ export abstract class AutofillService {
pageDetails: PageDetail[],
tab: chrome.tabs.Tab,
fromCommand: boolean,
autoSubmitLogin?: boolean,
) => Promise<string | null>;
doAutoFillActiveTab: (
pageDetails: PageDetail[],

View File

@ -15,6 +15,7 @@ type UpdateAutofillDataAttributeParams = {
};
interface CollectAutofillContentService {
autofillFormElements: AutofillFormElements;
getPageDetails(): Promise<AutofillPageDetails>;
getAutofillFieldElementByOpid(opid: string): HTMLElement | null;
deepQueryElements<T>(
@ -22,6 +23,11 @@ interface CollectAutofillContentService {
selector: string,
isObservingShadowRoot?: boolean,
): T[];
queryAllTreeWalkerNodes(
rootNode: Node,
filterCallback: CallableFunction,
isObservingShadowRoot?: boolean,
): Node[];
destroy(): void;
}

View File

@ -696,6 +696,7 @@ describe("AutofillService", () => {
onlyVisibleFields: autofillOptions.onlyVisibleFields || false,
fillNewPassword: autofillOptions.fillNewPassword || false,
allowTotpAutofill: autofillOptions.allowTotpAutofill || false,
autoSubmitLogin: autofillOptions.allowTotpAutofill || false,
cipher: autofillOptions.cipher,
tabUrl: autofillOptions.tab.url,
defaultUriMatch: 0,
@ -709,7 +710,6 @@ describe("AutofillService", () => {
{
command: "fillForm",
fillScript: {
autosubmit: null,
metadata: {},
properties: {
delay_between_operations: 20,
@ -1015,6 +1015,7 @@ describe("AutofillService", () => {
fillNewPassword: fromCommand,
allowUntrustedIframe: fromCommand,
allowTotpAutofill: fromCommand,
autoSubmitLogin: false,
});
expect(result).toBe(totpCode);
});
@ -1044,6 +1045,7 @@ describe("AutofillService", () => {
fillNewPassword: fromCommand,
allowUntrustedIframe: fromCommand,
allowTotpAutofill: fromCommand,
autoSubmitLogin: false,
});
expect(result).toBe(totpCode);
});
@ -1070,6 +1072,7 @@ describe("AutofillService", () => {
fillNewPassword: fromCommand,
allowUntrustedIframe: fromCommand,
allowTotpAutofill: fromCommand,
autoSubmitLogin: false,
});
expect(result).toBe(totpCode);
});
@ -1548,7 +1551,6 @@ describe("AutofillService", () => {
expect(autofillService["generateLoginFillScript"]).toHaveBeenCalledWith(
{
autosubmit: null,
metadata: {},
properties: {},
script: [
@ -1587,7 +1589,6 @@ describe("AutofillService", () => {
expect(autofillService["generateCardFillScript"]).toHaveBeenCalledWith(
{
autosubmit: null,
metadata: {},
properties: {},
script: [
@ -1626,7 +1627,6 @@ describe("AutofillService", () => {
expect(autofillService["generateIdentityFillScript"]).toHaveBeenCalledWith(
{
autosubmit: null,
metadata: {},
properties: {},
script: [

View File

@ -346,6 +346,7 @@ export default class AutofillService implements AutofillServiceInterface {
onlyVisibleFields: options.onlyVisibleFields || false,
fillNewPassword: options.fillNewPassword || false,
allowTotpAutofill: options.allowTotpAutofill || false,
autoSubmitLogin: options.autoSubmitLogin || false,
cipher: options.cipher,
tabUrl: tab.url,
defaultUriMatch: defaultUriMatch,
@ -379,7 +380,7 @@ export default class AutofillService implements AutofillServiceInterface {
BrowserApi.tabSendMessage(
tab,
{
command: "fillForm",
command: options.autoSubmitLogin ? "triggerAutoSubmitLogin" : "fillForm",
fillScript: fillScript,
url: tab.url,
pageDetailsUrl: pd.details.url,
@ -424,12 +425,14 @@ export default class AutofillService implements AutofillServiceInterface {
* @param {PageDetail[]} pageDetails The data scraped from the page
* @param {chrome.tabs.Tab} tab The tab to be autofilled
* @param {boolean} fromCommand Whether the autofill is triggered by a keyboard shortcut (`true`) or autofill on page load (`false`)
* @param {boolean} autoSubmitLogin Whether the autofill is for an auto-submit login
* @returns {Promise<string | null>} The TOTP code of the successfully autofilled login, if any
*/
async doAutoFillOnTab(
pageDetails: PageDetail[],
tab: chrome.tabs.Tab,
fromCommand: boolean,
autoSubmitLogin = false,
): Promise<string | null> {
let cipher: CipherView;
if (fromCommand) {
@ -469,6 +472,7 @@ export default class AutofillService implements AutofillServiceInterface {
fillNewPassword: fromCommand,
allowUntrustedIframe: fromCommand,
allowTotpAutofill: fromCommand,
autoSubmitLogin,
});
// Update last used index as autofill has succeeded
@ -833,6 +837,7 @@ export default class AutofillService implements AutofillServiceInterface {
});
}
const formElementsSet = new Set<string>();
usernames.forEach((u) => {
// eslint-disable-next-line
if (filledFields.hasOwnProperty(u.opid)) {
@ -841,6 +846,7 @@ export default class AutofillService implements AutofillServiceInterface {
filledFields[u.opid] = u;
AutofillService.fillByOpid(fillScript, u, login.username);
formElementsSet.add(u.form);
});
passwords.forEach((p) => {
@ -851,8 +857,13 @@ export default class AutofillService implements AutofillServiceInterface {
filledFields[p.opid] = p;
AutofillService.fillByOpid(fillScript, p, login.password);
formElementsSet.add(p.form);
});
if (options.autoSubmitLogin && formElementsSet.size) {
fillScript.autosubmit = Array.from(formElementsSet);
}
if (options.allowTotpAutofill) {
await Promise.all(
totps.map(async (t) => {

View File

@ -13,7 +13,7 @@ import {
import { InlineMenuFieldQualificationService } from "./abstractions/inline-menu-field-qualifications.service";
import { AutofillOverlayContentService } from "./autofill-overlay-content.service";
import CollectAutofillContentService from "./collect-autofill-content.service";
import { CollectAutofillContentService } from "./collect-autofill-content.service";
import DomElementVisibilityService from "./dom-element-visibility.service";
const mockLoginForm = `
@ -155,7 +155,7 @@ describe("CollectAutofillContentService", () => {
"data-stripe": null,
};
collectAutofillContentService["domRecentlyMutated"] = false;
collectAutofillContentService["autofillFormElements"] = new Map([
collectAutofillContentService["_autofillFormElements"] = new Map([
[formElement, autofillForm],
]);
collectAutofillContentService["autofillFieldElements"] = new Map([
@ -243,7 +243,7 @@ describe("CollectAutofillContentService", () => {
"data-stripe": null,
};
collectAutofillContentService["domRecentlyMutated"] = false;
collectAutofillContentService["autofillFormElements"] = new Map([
collectAutofillContentService["_autofillFormElements"] = new Map([
[formElement, autofillForm],
]);
collectAutofillContentService["autofillFieldElements"] = new Map([
@ -527,7 +527,7 @@ describe("CollectAutofillContentService", () => {
htmlID: formId,
htmlMethod: formMethod,
};
collectAutofillContentService["autofillFormElements"] = new Map([
collectAutofillContentService["_autofillFormElements"] = new Map([
[formElement, existingAutofillForm],
]);
const formElements = Array.from(document.querySelectorAll("form"));
@ -2135,7 +2135,7 @@ describe("CollectAutofillContentService", () => {
const removedNodes = document.querySelectorAll("form");
const autofillForm: AutofillForm = createAutofillFormMock({});
const autofillField: AutofillField = createAutofillFieldMock({});
collectAutofillContentService["autofillFormElements"] = new Map([[form, autofillForm]]);
collectAutofillContentService["_autofillFormElements"] = new Map([[form, autofillForm]]);
collectAutofillContentService["autofillFieldElements"] = new Map([
[usernameInput, autofillField],
]);
@ -2158,7 +2158,7 @@ describe("CollectAutofillContentService", () => {
]);
await waitForIdleCallback();
expect(collectAutofillContentService["autofillFormElements"].size).toEqual(0);
expect(collectAutofillContentService["_autofillFormElements"].size).toEqual(0);
expect(collectAutofillContentService["autofillFieldElements"].size).toEqual(0);
});
@ -2280,13 +2280,13 @@ describe("CollectAutofillContentService", () => {
htmlAction: "https://example.com",
htmlMethod: "POST",
};
collectAutofillContentService["autofillFormElements"] = new Map([
collectAutofillContentService["_autofillFormElements"] = new Map([
[formElement, autofillForm],
]);
collectAutofillContentService["deleteCachedAutofillElement"](formElement);
expect(collectAutofillContentService["autofillFormElements"].size).toEqual(0);
expect(collectAutofillContentService["_autofillFormElements"].size).toEqual(0);
});
it("removes the autofill field element form the map of elements", () => {
@ -2332,7 +2332,7 @@ describe("CollectAutofillContentService", () => {
expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(true);
expect(collectAutofillContentService["noFieldsFound"]).toEqual(false);
expect(collectAutofillContentService["updateAutofillElementsAfterMutation"]).toBeCalled();
expect(collectAutofillContentService["autofillFormElements"].size).toEqual(0);
expect(collectAutofillContentService["_autofillFormElements"].size).toEqual(0);
expect(collectAutofillContentService["autofillFieldElements"].size).toEqual(0);
});
});
@ -2379,7 +2379,9 @@ describe("CollectAutofillContentService", () => {
removedNodes: null,
target: targetNode,
};
collectAutofillContentService["autofillFormElements"] = new Map([[targetNode, autofillForm]]);
collectAutofillContentService["_autofillFormElements"] = new Map([
[targetNode, autofillForm],
]);
jest.spyOn(collectAutofillContentService as any, "updateAutofillFormElementData");
collectAutofillContentService["handleAutofillElementAttributeMutation"](mutationRecord);
@ -2451,14 +2453,14 @@ describe("CollectAutofillContentService", () => {
const updatedAttributes = ["action", "name", "id", "method"];
beforeEach(() => {
collectAutofillContentService["autofillFormElements"] = new Map([
collectAutofillContentService["_autofillFormElements"] = new Map([
[formElement, autofillForm],
]);
});
updatedAttributes.forEach((attribute) => {
it(`will update the ${attribute} value for the form element`, () => {
jest.spyOn(collectAutofillContentService["autofillFormElements"], "set");
jest.spyOn(collectAutofillContentService["_autofillFormElements"], "set");
collectAutofillContentService["updateAutofillFormElementData"](
attribute,
@ -2466,7 +2468,7 @@ describe("CollectAutofillContentService", () => {
autofillForm,
);
expect(collectAutofillContentService["autofillFormElements"].set).toBeCalledWith(
expect(collectAutofillContentService["_autofillFormElements"].set).toBeCalledWith(
formElement,
autofillForm,
);
@ -2474,7 +2476,7 @@ describe("CollectAutofillContentService", () => {
});
it("will not update an attribute value if it is not present in the updateActions object", () => {
jest.spyOn(collectAutofillContentService["autofillFormElements"], "set");
jest.spyOn(collectAutofillContentService["_autofillFormElements"], "set");
collectAutofillContentService["updateAutofillFormElementData"](
"aria-label",
@ -2482,7 +2484,7 @@ describe("CollectAutofillContentService", () => {
autofillForm,
);
expect(collectAutofillContentService["autofillFormElements"].set).not.toBeCalled();
expect(collectAutofillContentService["_autofillFormElements"].set).not.toBeCalled();
});
});

View File

@ -31,7 +31,7 @@ import {
} from "./abstractions/collect-autofill-content.service";
import { DomElementVisibilityService } from "./abstractions/dom-element-visibility.service";
class CollectAutofillContentService implements CollectAutofillContentServiceInterface {
export class CollectAutofillContentService implements CollectAutofillContentServiceInterface {
private readonly sendExtensionMessage = sendExtensionMessage;
private readonly domElementVisibilityService: DomElementVisibilityService;
private readonly autofillOverlayContentService: AutofillOverlayContentService;
@ -39,7 +39,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
private readonly getPropertyOrAttribute = getPropertyOrAttribute;
private noFieldsFound = false;
private domRecentlyMutated = true;
private autofillFormElements: AutofillFormElements = new Map();
private _autofillFormElements: AutofillFormElements = new Map();
private autofillFieldElements: AutofillFieldElements = new Map();
private currentLocationHref = "";
private intersectionObserver: IntersectionObserver;
@ -79,6 +79,10 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
// );
}
get autofillFormElements(): AutofillFormElements {
return this._autofillFormElements;
}
/**
* Builds the data for all forms and fields found within the page DOM.
* Sets up a mutation observer to verify DOM changes and returns early
@ -302,14 +306,14 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
const formElement = formElements[index] as ElementWithOpId<HTMLFormElement>;
formElement.opid = `__form__${index}`;
const existingAutofillForm = this.autofillFormElements.get(formElement);
const existingAutofillForm = this._autofillFormElements.get(formElement);
if (existingAutofillForm) {
existingAutofillForm.opid = formElement.opid;
this.autofillFormElements.set(formElement, existingAutofillForm);
this._autofillFormElements.set(formElement, existingAutofillForm);
continue;
}
this.autofillFormElements.set(formElement, {
this._autofillFormElements.set(formElement, {
opid: formElement.opid,
htmlAction: this.getFormActionAttribute(formElement),
htmlName: this.getPropertyOrAttribute(formElement, "name"),
@ -340,7 +344,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
*/
private getFormattedAutofillFormsData(): Record<string, AutofillForm> {
const autofillForms: Record<string, AutofillForm> = {};
const autofillFormElements = Array.from(this.autofillFormElements);
const autofillFormElements = Array.from(this._autofillFormElements);
for (let index = 0; index < autofillFormElements.length; index++) {
const [formElement, autofillForm] = autofillFormElements[index];
autofillForms[formElement.opid] = autofillForm;
@ -1042,7 +1046,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
}
this.noFieldsFound = false;
this.autofillFormElements.clear();
this._autofillFormElements.clear();
this.autofillFieldElements.clear();
this.updateAutofillElementsAfterMutation();
@ -1178,8 +1182,8 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
private deleteCachedAutofillElement(
element: ElementWithOpId<HTMLFormElement> | ElementWithOpId<FormFieldElement>,
) {
if (elementIsFormElement(element) && this.autofillFormElements.has(element)) {
this.autofillFormElements.delete(element);
if (elementIsFormElement(element) && this._autofillFormElements.has(element)) {
this._autofillFormElements.delete(element);
return;
}
@ -1216,7 +1220,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
}
const attributeName = mutation.attributeName?.toLowerCase();
const autofillForm = this.autofillFormElements.get(
const autofillForm = this._autofillFormElements.get(
targetElement as ElementWithOpId<HTMLFormElement>,
);
@ -1271,8 +1275,8 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
}
updateActions[attributeName]();
if (this.autofillFormElements.has(element)) {
this.autofillFormElements.set(element, dataTarget);
if (this._autofillFormElements.has(element)) {
this._autofillFormElements.set(element, dataTarget);
}
}
@ -1462,7 +1466,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
*
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
*/
private queryAllTreeWalkerNodes(
queryAllTreeWalkerNodes(
rootNode: Node,
filterCallback: CallableFunction,
isObservingShadowRoot = true,
@ -1597,5 +1601,3 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
return Boolean(this.deepQueryElements(document, `input[type="password"]`)?.length);
}
}
export default CollectAutofillContentService;

View File

@ -8,7 +8,7 @@ import { FillableFormFieldElement, FormElementWithAttribute, FormFieldElement }
import { InlineMenuFieldQualificationService } from "./abstractions/inline-menu-field-qualifications.service";
import { AutofillOverlayContentService } from "./autofill-overlay-content.service";
import CollectAutofillContentService from "./collect-autofill-content.service";
import { CollectAutofillContentService } from "./collect-autofill-content.service";
import DomElementVisibilityService from "./dom-element-visibility.service";
import InsertAutofillContentService from "./insert-autofill-content.service";

View File

@ -10,7 +10,7 @@ import {
} from "../utils";
import { InsertAutofillContentService as InsertAutofillContentServiceInterface } from "./abstractions/insert-autofill-content.service";
import CollectAutofillContentService from "./collect-autofill-content.service";
import { CollectAutofillContentService } from "./collect-autofill-content.service";
import DomElementVisibilityService from "./dom-element-visibility.service";
class InsertAutofillContentService implements InsertAutofillContentServiceInterface {

View File

@ -114,6 +114,7 @@ export function createGenerateFillScriptOptionsMock(customFields = {}): Generate
onlyVisibleFields: false,
fillNewPassword: false,
allowTotpAutofill: false,
autoSubmitLogin: false,
cipher: mock<CipherView>(),
tabUrl: "https://jest-testing-website.com",
defaultUriMatch: UriMatchStrategy.Domain,

View File

@ -132,6 +132,39 @@ export function triggerWebNavigationOnCommittedEvent(
);
}
export function triggerWebNavigationOnCompletedEvent(
details: chrome.webNavigation.WebNavigationFramedCallbackDetails,
) {
(chrome.webNavigation.onCompleted.addListener as unknown as jest.SpyInstance).mock.calls.forEach(
(call) => {
const callback = call[0];
callback(details);
},
);
}
export function triggerWebRequestOnBeforeRequestEvent(
details: chrome.webRequest.WebRequestDetails,
) {
(chrome.webRequest.onBeforeRequest.addListener as unknown as jest.SpyInstance).mock.calls.forEach(
(call) => {
const callback = call[0];
callback(details);
},
);
}
export function triggerWebRequestOnBeforeRedirectEvent(
details: chrome.webRequest.WebRequestDetails,
) {
(
chrome.webRequest.onBeforeRedirect.addListener as unknown as jest.SpyInstance
).mock.calls.forEach((call) => {
const callback = call[0];
callback(details);
});
}
export function mockQuerySelectorAllDefinedCall() {
const originalDocumentQuerySelectorAll = document.querySelectorAll;
document.querySelectorAll = function (selector: string) {

View File

@ -204,6 +204,7 @@ import {
} from "@bitwarden/vault-export-core";
import { OverlayBackground as OverlayBackgroundInterface } from "../autofill/background/abstractions/overlay.background";
import { AutoSubmitLoginBackground } from "../autofill/background/auto-submit-login.background";
import ContextMenusBackground from "../autofill/background/context-menus.background";
import NotificationBackground from "../autofill/background/notification.background";
import { OverlayBackground } from "../autofill/background/overlay.background";
@ -353,6 +354,7 @@ export default class MainBackground {
offscreenDocumentService: OffscreenDocumentService;
syncServiceListener: SyncServiceListener;
themeStateService: DefaultThemeStateService;
autoSubmitLoginBackground: AutoSubmitLoginBackground;
onUpdatedRan: boolean;
onReplacedRan: boolean;
@ -1103,6 +1105,16 @@ export default class MainBackground {
this.scriptInjectorService,
);
this.autoSubmitLoginBackground = new AutoSubmitLoginBackground(
this.logService,
this.autofillService,
this.scriptInjectorService,
this.authService,
this.configService,
this.platformUtilsService,
this.policyService,
);
const contextMenuClickedHandler = new ContextMenuClickedHandler(
(options) => this.platformUtilsService.copyToClipboard(options.text),
async (_tab) => {
@ -1223,6 +1235,7 @@ export default class MainBackground {
await this.idleBackground.init();
this.webRequestBackground?.startListening();
this.syncServiceListener?.listener$().subscribe();
await this.autoSubmitLoginBackground.init();
if (
BrowserApi.isManifestVersion(2) &&

View File

@ -148,6 +148,21 @@ const webNavigation = {
addListener: jest.fn(),
removeListener: jest.fn(),
},
onCompleted: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
};
const webRequest = {
onBeforeRequest: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
onBeforeRedirect: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
};
const alarms = {
@ -177,5 +192,6 @@ global.chrome = {
offscreen,
permissions,
webNavigation,
webRequest,
alarms,
} as any;

View File

@ -179,6 +179,7 @@ const mainConfig = {
"content/bootstrap-legacy-autofill-overlay":
"./src/autofill/deprecated/content/bootstrap-legacy-autofill-overlay.ts",
"content/autofiller": "./src/autofill/content/autofiller.ts",
"content/auto-submit-login": "./src/autofill/content/auto-submit-login.ts",
"content/notificationBar": "./src/autofill/content/notification-bar.ts",
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
"content/content-message-handler": "./src/autofill/content/content-message-handler.ts",

View File

@ -94,7 +94,7 @@ export class AppComponent implements OnDestroy, OnInit {
private policyService: InternalPolicyService,
protected policyListService: PolicyListService,
private keyConnectorService: KeyConnectorService,
private configService: ConfigService,
protected configService: ConfigService,
private dialogService: DialogService,
private biometricStateService: BiometricStateService,
private stateEventRunnerService: StateEventRunnerService,

View File

@ -5343,6 +5343,18 @@
"updateWeakMasterPasswordWarning": {
"message": "Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour."
},
"automaticAppLogin": {
"message": "Automatically log in users for allowed applications"
},
"automaticAppLoginDesc": {
"message": "Login forms will automatically be filled and submitted for apps launched from your configured identity provider."
},
"automaticAppLoginIdpHostLabel": {
"message": "Identity provider host"
},
"automaticAppLoginIdpHostDesc": {
"message": "Enter your identity provider host URL. Enter multiple URLs by separating with a comma."
},
"tdeDisabledMasterPasswordRequired": {
"message": "Your organization has updated your decryption options. Please set a master password to access your vault."
},

View File

@ -0,0 +1,14 @@
<bit-form-control>
<input type="checkbox" id="enabled" bitCheckbox [formControl]="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
<div [formGroup]="data">
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-12 !tw-mb-0">
<bit-label>{{ "automaticAppLoginIdpHostLabel" | i18n }}</bit-label>
<input bitInput type="text" min="0" formControlName="idpHost" />
<bit-hint>{{ "automaticAppLoginIdpHostDesc" | i18n }}</bit-hint>
</bit-form-field>
</div>
</div>

View File

@ -0,0 +1,29 @@
import { Component } from "@angular/core";
import { FormBuilder, FormControl } from "@angular/forms";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import {
BasePolicy,
BasePolicyComponent,
} from "@bitwarden/web-vault/app/admin-console/organizations/policies/base-policy.component";
export class AutomaticAppLoginPolicy extends BasePolicy {
name = "automaticAppLogin";
description = "automaticAppLoginDesc";
type = PolicyType.AutomaticAppLogIn;
component = AutomaticAppLoginPolicyComponent;
}
@Component({
selector: "policy-automatic-app-login",
templateUrl: "automatic-app-login.component.html",
})
export class AutomaticAppLoginPolicyComponent extends BasePolicyComponent {
data = this.formBuilder.group({
idpHost: new FormControl<string>(null),
});
constructor(private formBuilder: FormBuilder) {
super();
}
}

View File

@ -1,8 +1,10 @@
import { Component, OnInit } from "@angular/core";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AppComponent as BaseAppComponent } from "@bitwarden/web-vault/app/app.component";
import { ActivateAutofillPolicy } from "./admin-console/policies/activate-autofill.component";
import { AutomaticAppLoginPolicy } from "./admin-console/policies/automatic-app-login.component";
import { DisablePersonalVaultExportPolicy } from "./admin-console/policies/disable-personal-vault-export.component";
import { MaximumVaultTimeoutPolicy } from "./admin-console/policies/maximum-vault-timeout.component";
@ -19,5 +21,14 @@ export class AppComponent extends BaseAppComponent implements OnInit {
new DisablePersonalVaultExportPolicy(),
new ActivateAutofillPolicy(),
]);
this.configService.getFeatureFlag(FeatureFlag.IdpAutoSubmitLogin).then((enabled) => {
if (
enabled &&
!this.policyListService.getPolicies().some((p) => p instanceof AutomaticAppLoginPolicy)
) {
this.policyListService.addPolicies([new AutomaticAppLoginPolicy()]);
}
});
}
}

View File

@ -14,6 +14,7 @@ import { WildcardRoutingModule } from "@bitwarden/web-vault/app/wildcard-routing
import { OrganizationsModule } from "./admin-console/organizations/organizations.module";
import { ActivateAutofillPolicyComponent } from "./admin-console/policies/activate-autofill.component";
import { AutomaticAppLoginPolicyComponent } from "./admin-console/policies/automatic-app-login.component";
import { DisablePersonalVaultExportPolicyComponent } from "./admin-console/policies/disable-personal-vault-export.component";
import { MaximumVaultTimeoutPolicyComponent } from "./admin-console/policies/maximum-vault-timeout.component";
import { AppRoutingModule } from "./app-routing.module";
@ -47,6 +48,7 @@ import { AppComponent } from "./app.component";
DisablePersonalVaultExportPolicyComponent,
MaximumVaultTimeoutPolicyComponent,
ActivateAutofillPolicyComponent,
AutomaticAppLoginPolicyComponent,
],
bootstrap: [AppComponent],
})

View File

@ -11,4 +11,5 @@ export enum PolicyType {
MaximumVaultTimeout = 9, // Sets the maximum allowed vault timeout
DisablePersonalVaultExport = 10, // Disable personal vault export
ActivateAutofill = 11, // Activates autofill with page load on the browser extension
AutomaticAppLogIn = 12, // Enables automatic log in of apps from configured identity provider
}

View File

@ -25,6 +25,7 @@ export enum FeatureFlag {
ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner",
VaultBulkManagementAction = "vault-bulk-management-action",
AC2828_ProviderPortalMembersPage = "AC-2828_provider-portal-members-page",
IdpAutoSubmitLogin = "idp-auto-submit-login",
DeviceTrustLogging = "pm-8285-device-trust-logging",
AuthenticatorTwoFactorToken = "authenticator-2fa-token",
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
@ -66,6 +67,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE,
[FeatureFlag.VaultBulkManagementAction]: FALSE,
[FeatureFlag.AC2828_ProviderPortalMembersPage]: FALSE,
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
[FeatureFlag.DeviceTrustLogging]: FALSE,
[FeatureFlag.AuthenticatorTwoFactorToken]: FALSE,
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,