Navigate to URL for authentication (#2493)

#### Details

Navigate to URL for authentication when supported authentication was
detected.

#### Pull request checklist
<!-- If a checklist item is not applicable to this change, write "n/a"
in the checkbox -->

- [ ] Addresses an existing issue: Fixes #0000
- [x] Added relevant unit test for your changes. (`yarn test`)
- [ ] Verified code coverage for the changes made. Check coverage report
at: `<rootDir>/test-results/unit/coverage`
- [ ] Ran precheckin (`yarn precheckin`)
- [x] Validated in an Azure resource group
This commit is contained in:
Maxim Laikine 2023-10-11 09:35:46 -07:00 коммит произвёл GitHub
Родитель aa6707711b
Коммит 01ac5c1431
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
12 изменённых файлов: 240 добавлений и 79 удалений

1
.gitignore поставляемый
Просмотреть файл

@ -10,6 +10,7 @@ node_modules*
!.yarn/sdks
!.yarn/versions
chrome-user-data/
dist/
dist*
drop/

Просмотреть файл

@ -22,6 +22,7 @@
},
"homepage": "https://github.com/Microsoft/accessibility-insights-service#readme",
"devDependencies": {
"@types/fingerprint-generator": "1.0.0",
"@types/jest": "^29.5.0",
"@types/lodash": "^4.14.182",
"@types/node": "^16.18.11",

Просмотреть файл

@ -1,6 +1,6 @@
{
"name": "accessibility-insights-scan",
"version": "2.4.3",
"version": "2.4.4",
"description": "This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit https://cla.microsoft.com.",
"scripts": {
"build": "webpack --config ./webpack.config.js \"$@\"",
@ -29,7 +29,6 @@
"standAlonePackage": "This is a stand-alone package. Do NOT add dependencies to any service packages.",
"devDependencies": {
"@types/escape-html": "^1.0.2",
"@types/fingerprint-generator": "1.0.0",
"@types/jest": "^29.5.0",
"@types/lodash": "^4.14.182",
"@types/node": "^16.18.11",

Просмотреть файл

@ -2,16 +2,25 @@
// Licensed under the MIT License.
import 'reflect-metadata';
import { listMonorepoPackageNames } from 'common';
import _ from 'lodash';
import * as packageJson from '../package.json';
const acceptedMonorepoDependencies = ['accessibility-insights-crawler', 'axe-result-converter', 'common'];
describe('package.json dependencies', () => {
const monorepoPackageNames = listMonorepoPackageNames();
const isMonorepoPackage = (packageName: string) => monorepoPackageNames.includes(packageName);
const monorepoDevDependencies = Object.keys(packageJson.devDependencies).filter(isMonorepoPackage);
const monorepoNonDevDependencies = Object.keys(packageJson.dependencies).filter(isMonorepoPackage);
// We do NOT allow to have any dependencies on service monorepo packages
// since cli package is a purely stand-alone package.
it('does not include any dependencies on service monorepo packages', () => {
expect(monorepoDevDependencies).toEqual(acceptedMonorepoDependencies);
});
// We don't publish other monorepo packages (eg, "common") to npm, so it's important
// that we only depend on them as devDependencies, not dependencies, to avoid consumers
// trying to pull down non-published packages.

Просмотреть файл

@ -0,0 +1,76 @@
#!/bin/bash
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
# Open browser in automation mode
set -eo pipefail
exitWithUsageInfo() {
echo "
Usage: ${BASH_SOURCE} -b <browser application path> [-p <profile location path>]
"
exit 1
}
while getopts ":b:p:" option; do
case $option in
b) browserPath=${OPTARG} ;;
p) profilePath=${OPTARG} ;;
*) exitWithUsageInfo ;;
esac
done
if [[ -z ${browserPath} ]]; then
exitWithUsageInfo
fi
if [[ -z ${profilePath} ]]; then
profilePath="${0%/*}/chrome-user-data"
fi
result=$(
"${browserPath}" \
--allow-pre-commit-input \
--disable-background-networking \
--disable-background-timer-throttling \
--disable-backgrounding-occluded-windows \
--disable-breakpad \
--disable-client-side-phishing-detection \
--disable-component-update \
--disable-features=Translate,AcceptCHFrame,MediaRouter,OptimizationHints,Prerender2 \
--disable-hang-monitor \
--disable-ipc-flooding-protection \
--disable-popup-blocking \
--disable-prompt-on-repost \
--disable-renderer-backgrounding \
--disable-search-engine-choice-screen \
--disable-sync \
--enable-automation \
--enable-blink-features=IdleDetection \
--enable-features=NetworkServiceInProcess2 \
--export-tagged-pdf \
--force-color-profile=srgb \
--metrics-recording-only \
--no-first-run \
--password-store=basic \
--use-mock-keychain \
--enable-remote-extensions \
--disable-blink-features=AutomationControlled \
--remote-debugging-port=0 \
--flag-switches-begin \
--flag-switches-end \
--disable-nacl \
--user-data-dir="${profilePath}" \
--disable-dev-shm-usage \
--no-sandbox \
--disable-setuid-sandbox \
--disable-gpu \
--disable-webgl \
--disable-webgl2 \
--disable-features=BackForwardCache \
--js-flags=--max-old-space-size=8192 \
--window-size=1920,1080 \
about:blank
)

Просмотреть файл

@ -3,62 +3,90 @@
import 'reflect-metadata';
import { IMock, Mock } from 'typemoq';
import { IMock, Mock, Times } from 'typemoq';
import * as Puppeteer from 'puppeteer';
import { GlobalLogger } from 'logger';
import { System } from 'common';
import { NavigationResponse } from '../page-navigator';
import { PageResponseProcessor } from '../page-response-processor';
import { puppeteerTimeoutConfig } from '../page-timeout-config';
import { BrowserError } from '../browser-error';
import { ResourceAuthenticator } from './resource-authenticator';
import { LoginPageDetector } from './login-page-detector';
import { LoginPageClientFactory } from './login-page-client-factory';
import { LoginPageClient } from './azure-login-page-client';
const url = 'authUrl';
let loginPageDetectorMock: IMock<LoginPageDetector>;
let loginPageClientFactoryMock: IMock<LoginPageClientFactory>;
let puppeteerPageMock: IMock<Puppeteer.Page>;
let loginPageClientMock: IMock<LoginPageClient>;
let pageResponseProcessorMock: IMock<PageResponseProcessor>;
let loggerMock: IMock<GlobalLogger>;
let resourceAuthenticator: ResourceAuthenticator;
let puppeteerGotoResponse: Puppeteer.HTTPResponse;
describe(ResourceAuthenticator, () => {
beforeEach(() => {
loginPageClientMock = Mock.ofType<LoginPageClient>();
loginPageDetectorMock = Mock.ofType<LoginPageDetector>();
loginPageClientFactoryMock = Mock.ofType<LoginPageClientFactory>();
puppeteerPageMock = Mock.ofType<Puppeteer.Page>();
pageResponseProcessorMock = Mock.ofType(PageResponseProcessor);
loggerMock = Mock.ofType<GlobalLogger>();
puppeteerPageMock
.setup((o) => o.url())
.returns(() => url)
.verifiable();
System.getElapsedTime = () => 100;
resourceAuthenticator = new ResourceAuthenticator(
loginPageDetectorMock.object,
loginPageClientFactoryMock.object,
pageResponseProcessorMock.object,
loggerMock.object,
);
});
afterEach(() => {
loginPageDetectorMock.verifyAll();
loginPageClientFactoryMock.verifyAll();
puppeteerPageMock.verifyAll();
loginPageClientMock.verifyAll();
pageResponseProcessorMock.verifyAll();
loggerMock.verifyAll();
});
it('should return navigation error', async () => {
const browserError = { statusCode: 404 } as BrowserError;
const gotoError = new Error('404');
puppeteerPageMock
.setup((o) => o.goto(url, { waitUntil: 'networkidle2', timeout: puppeteerTimeoutConfig.navigationTimeoutMsec }))
.returns(() => Promise.reject(gotoError))
.verifiable(Times.atLeastOnce());
pageResponseProcessorMock
.setup((o) => o.getNavigationError(gotoError))
.returns(() => browserError)
.verifiable();
const authenticationResult = {
navigationResponse: {
browserError,
pageNavigationTiming: {
goto: 100,
},
} as NavigationResponse,
authenticationType: 'entraId',
authenticated: false,
};
const response = await resourceAuthenticator.authenticate(url, 'entraId', puppeteerPageMock.object);
expect(response).toEqual(authenticationResult);
});
it('should authenticate resource', async () => {
const authenticationResult = {
navigationResponse: { httpResponse: { url: () => 'url' } } as NavigationResponse,
authenticationType: 'entraId',
authenticated: true,
};
loginPageDetectorMock
.setup((o) => o.getAuthenticationType(url))
.returns(() => 'entraId')
.verifiable();
puppeteerGotoResponse = { puppeteerResponse: 'goto', url: () => url } as unknown as Puppeteer.HTTPResponse;
puppeteerPageMock
.setup((o) => o.goto(url, { waitUntil: 'networkidle2', timeout: puppeteerTimeoutConfig.navigationTimeoutMsec }))
.returns(() => Promise.resolve(puppeteerGotoResponse))
.verifiable(Times.atLeastOnce());
loginPageClientMock
.setup((o) => o.login(puppeteerPageMock.object))
.returns(() => Promise.resolve(authenticationResult.navigationResponse))
@ -68,27 +96,7 @@ describe(ResourceAuthenticator, () => {
.returns(() => loginPageClientMock.object)
.verifiable();
const response = await resourceAuthenticator.authenticate(puppeteerPageMock.object);
const response = await resourceAuthenticator.authenticate(url, 'entraId', puppeteerPageMock.object);
expect(response).toEqual(authenticationResult);
});
it('should skip if no login page detected', async () => {
loginPageDetectorMock
.setup((o) => o.getAuthenticationType(url))
.returns(() => undefined)
.verifiable();
const response = await resourceAuthenticator.authenticate(puppeteerPageMock.object);
expect(response).toBeUndefined();
});
it('should skip if undetermined authentication detected', async () => {
loginPageDetectorMock
.setup((o) => o.getAuthenticationType(url))
.returns(() => 'undetermined')
.verifiable();
const response = await resourceAuthenticator.authenticate(puppeteerPageMock.object);
expect(response).toBeUndefined();
});
});

Просмотреть файл

@ -6,8 +6,9 @@ import * as Puppeteer from 'puppeteer';
import { GlobalLogger } from 'logger';
import { AuthenticationType } from 'storage-documents';
import { System } from 'common';
import { NavigationResponse } from '../page-navigator';
import { LoginPageDetector } from './login-page-detector';
import { NavigationResponse, PageOperationResult } from '../page-navigator';
import { PageNavigationTiming, puppeteerTimeoutConfig } from '../page-timeout-config';
import { PageResponseProcessor } from '../page-response-processor';
import { LoginPageClientFactory } from './login-page-client-factory';
export interface ResourceAuthenticationResult {
@ -19,15 +20,27 @@ export interface ResourceAuthenticationResult {
@injectable()
export class ResourceAuthenticator {
constructor(
@inject(LoginPageDetector) private readonly loginPageDetector: LoginPageDetector,
@inject(LoginPageClientFactory) private readonly loginPageClientFactory: LoginPageClientFactory,
@inject(PageResponseProcessor) public readonly pageResponseProcessor: PageResponseProcessor,
@inject(GlobalLogger) @optional() private readonly logger: GlobalLogger,
) {}
public async authenticate(page: Puppeteer.Page): Promise<ResourceAuthenticationResult> {
const authenticationType = this.loginPageDetector.getAuthenticationType(page.url());
if (authenticationType === undefined || authenticationType === 'undetermined') {
return undefined;
public async authenticate(
url: string,
authenticationType: AuthenticationType,
page: Puppeteer.Page,
): Promise<ResourceAuthenticationResult> {
const operationResult = await this.navigatePage(url, page);
if (operationResult.browserError) {
return {
navigationResponse: {
httpResponse: operationResult.response,
pageNavigationTiming: operationResult.navigationTiming,
browserError: operationResult.browserError,
},
authenticationType,
authenticated: false,
};
}
const loginPageClient = this.loginPageClientFactory.getPageClient(authenticationType);
@ -53,4 +66,27 @@ export class ResourceAuthenticator {
authenticated,
};
}
private async navigatePage(url: string, page: Puppeteer.Page): Promise<PageOperationResult> {
const timestamp = System.getTimestamp();
try {
this.logger?.logInfo('Navigate page to URL for authentication.');
const response = await page.goto(url, { waitUntil: 'networkidle2', timeout: puppeteerTimeoutConfig.navigationTimeoutMsec });
return { response, navigationTiming: { goto: System.getElapsedTime(timestamp) } as PageNavigationTiming };
} catch (error) {
const browserError = this.pageResponseProcessor.getNavigationError(error as Error);
this.logger?.logError(`Page authenticator navigation error.`, {
error: System.serializeError(error),
browserError: System.serializeError(browserError),
});
return {
response: undefined,
navigationTiming: { goto: System.getElapsedTime(timestamp) } as PageNavigationTiming,
browserError,
error,
};
}
}
}

Просмотреть файл

@ -65,57 +65,61 @@ export class PageNavigator {
public async waitForNavigation(page: Puppeteer.Page): Promise<NavigationResponse> {
const pageOperation = this.createPageOperation('wait', page);
const opResult = await this.pageOperationHandler.invoke(pageOperation, page);
if (opResult.error) {
return this.getOperationErrorResult(opResult);
const operationResult = await this.pageOperationHandler.invoke(pageOperation, page);
if (operationResult.error) {
return this.getOperationErrorResult(operationResult);
}
return {
httpResponse: opResult.response,
pageNavigationTiming: opResult.navigationTiming,
browserError: opResult.browserError,
httpResponse: operationResult.response,
pageNavigationTiming: operationResult.navigationTiming,
browserError: operationResult.browserError,
};
}
private async navigatePage(pageOperation: PageOperation, page: Puppeteer.Page): Promise<NavigationResponse> {
const opResult = await this.navigatePageImpl(pageOperation, page);
const operationResult = await this.navigatePageImpl(pageOperation, page);
if (opResult.browserError) {
if (operationResult.browserError) {
return {
httpResponse: undefined,
pageNavigationTiming: opResult.navigationTiming,
browserError: opResult.browserError,
pageNavigationTiming: operationResult.navigationTiming,
browserError: operationResult.browserError,
};
}
const postNavigationPageTiming = await this.pageNavigationHooks.postNavigation(page, opResult.response, async (browserError) => {
opResult.browserError = browserError;
});
const postNavigationPageTiming = await this.pageNavigationHooks.postNavigation(
page,
operationResult.response,
async (browserError) => {
operationResult.browserError = browserError;
},
);
return {
httpResponse: opResult.response,
httpResponse: operationResult.response,
pageNavigationTiming: {
...opResult.navigationTiming,
...operationResult.navigationTiming,
...postNavigationPageTiming,
} as PageNavigationTiming,
browserError: opResult.browserError,
browserError: operationResult.browserError,
};
}
private async navigatePageImpl(pageOperation: PageOperation, page: Puppeteer.Page): Promise<PageOperationResult> {
let opResult = await this.pageOperationHandler.invoke(pageOperation, page);
if (opResult.error) {
return this.getOperationErrorResult(opResult);
let operationResult = await this.pageOperationHandler.invoke(pageOperation, page);
if (operationResult.error) {
return this.getOperationErrorResult(operationResult);
}
opResult = await this.handleCachedResponse(opResult, page);
if (opResult.error) {
return this.getOperationErrorResult(opResult);
operationResult = await this.handleCachedResponse(operationResult, page);
if (operationResult.error) {
return this.getOperationErrorResult(operationResult);
}
await this.resetPageSessionHistory(page);
return opResult;
return operationResult;
}
/**
@ -134,7 +138,7 @@ export class PageNavigator {
});
let count = 0;
let opResult;
let operationResult;
do {
count++;
if (count > maxRetryCount - 1) {
@ -155,13 +159,15 @@ export class PageNavigator {
// Navigation using page.goto() will not resolve HTTP 304 response
// Use of page.goBack() is required with back/forward cache disabled, option --disable-features=BackForwardCache
const pageOperation = async () => page.goBack(this.waitForOptions);
opResult = await this.pageOperationHandler.invoke(pageOperation, page);
operationResult = await this.pageOperationHandler.invoke(pageOperation, page);
} while (
count < maxRetryCount &&
(opResult.error !== undefined || opResult.response?.status() === undefined || opResult.response?.status() === 304)
(operationResult.error !== undefined ||
operationResult.response?.status() === undefined ||
operationResult.response?.status() === 304)
);
return opResult;
return operationResult;
}
private createPageOperation(operation: 'goto' | 'reload' | 'wait', page: Puppeteer.Page, url?: string): PageOperation {

Просмотреть файл

@ -10,7 +10,7 @@ import { GuidGenerator, System } from 'common';
import { GlobalLogger } from 'logger';
import { AxeScanResults } from './axe-scanner/axe-scan-results';
import { BrowserError } from './browser-error';
import { Page } from './page';
import { BrowserStartOptions, Page } from './page';
import { getPromisableDynamicMock } from './test-utilities/promisable-mock';
import { WebDriver } from './web-driver';
import { PageNavigator, NavigationResponse } from './page-navigator';
@ -54,6 +54,7 @@ let resourceAuthenticatorMock: IMock<ResourceAuthenticator>;
let pageAnalyzerMock: IMock<PageAnalyzer>;
let guidGeneratorMock: IMock<GuidGenerator>;
let devToolsSessionMock: IMock<DevToolsSession>;
let browserStartOptions: BrowserStartOptions;
describe(Page, () => {
beforeEach(() => {
@ -78,6 +79,7 @@ describe(Page, () => {
pageAnalyzerMock = Mock.ofType<PageAnalyzer>();
guidGeneratorMock = Mock.ofType<GuidGenerator>();
devToolsSessionMock = Mock.ofType<DevToolsSession>();
browserStartOptions = {} as BrowserStartOptions;
scrollToTopMock = jest.fn().mockImplementation(() => Promise.resolve());
puppeteerResponseMock.setup((o) => o.ok()).returns(() => true);
@ -141,6 +143,7 @@ describe(Page, () => {
timing[key] = `${navigationResponse.pageNavigationTiming[key]}`;
});
loggerMock.setup((o) => o.logInfo('Total page load time 8, msec', { status: 200, ...timing })).verifiable();
page.browserStartOptions = browserStartOptions;
await page.navigate(url);
@ -161,16 +164,19 @@ describe(Page, () => {
pageAnalyzerMock.reset();
pageAnalyzerMock
.setup((o) => o.analyze(url, puppeteerPageMock.object))
.returns(() => Promise.resolve({ navigationResponse, authentication: true } as PageAnalysisResult))
.returns(() =>
Promise.resolve({ navigationResponse, authentication: true, authenticationType: 'entraId' } as PageAnalysisResult),
)
.verifiable();
resourceAuthenticatorMock
.setup((o) => o.authenticate(puppeteerPageMock.object))
.setup((o) => o.authenticate(url, 'entraId', puppeteerPageMock.object))
.returns(() => Promise.resolve(authenticationResult))
.verifiable();
pageNavigatorMock
.setup(async (o) => o.navigate('localhost/2', puppeteerPageMock.object))
.returns(() => Promise.resolve(reloadNavigationResponse))
.verifiable();
page.browserStartOptions = browserStartOptions;
await page.navigate(url, { enableAuthentication: true });
@ -186,6 +192,7 @@ describe(Page, () => {
.setup(async (o) => o.trace(url, puppeteerPageMock.object))
.returns(() => Promise.resolve())
.verifiable();
page.browserStartOptions = browserStartOptions;
await page.navigate(url);
@ -203,6 +210,7 @@ describe(Page, () => {
.setup((o) => o.setExtraHTTPHeaders({ X_FORWARDED_FOR: '1.1.1.1' }))
.returns(() => Promise.resolve())
.verifiable();
page.browserStartOptions = browserStartOptions;
await page.navigate(url);
});
@ -350,6 +358,7 @@ describe(Page, () => {
.setup(async (o) => o.close())
.returns(() => Promise.resolve())
.verifiable();
page.browserStartOptions = browserStartOptions;
await page.close();
});

Просмотреть файл

@ -64,6 +64,8 @@ export class Page {
private readonly enableAuthenticationGlobalFlag: boolean;
private readonly browserWSEndpoint: string;
constructor(
@inject(WebDriver) private readonly webDriver: WebDriver,
@inject(PageNavigator) private readonly pageNavigator: PageNavigator,
@ -76,6 +78,7 @@ export class Page {
private readonly scrollToPageTop: typeof scrollToTop = scrollToTop,
) {
this.enableAuthenticationGlobalFlag = process.env.PAGE_AUTH === 'true' ? true : false;
this.browserWSEndpoint = process.env.BROWSER_ENDPOINT;
}
public get puppeteerPage(): Puppeteer.Page {
@ -88,8 +91,8 @@ export class Page {
public async create(options: BrowserStartOptions = { clearBrowserCache: true }): Promise<void> {
this.browserStartOptions = options;
if (options?.browserWSEndpoint !== undefined) {
this.browser = await this.webDriver.connect(options?.browserWSEndpoint);
if (!isEmpty(options?.browserWSEndpoint) || !isEmpty(this.browserWSEndpoint)) {
this.browser = await this.webDriver.connect(options?.browserWSEndpoint ?? this.browserWSEndpoint);
} else {
this.browser = await this.webDriver.launch({
browserExecutablePath: options?.browserExecutablePath,
@ -213,6 +216,10 @@ export class Page {
}
public async close(): Promise<void> {
if (this.browserStartOptions.browserWSEndpoint || this.browserWSEndpoint) {
return;
}
if (this.webDriver !== undefined) {
await this.webDriver.close();
}
@ -267,7 +274,12 @@ export class Page {
}
// Invoke authentication client
this.authenticationResult = await this.resourceAuthenticator.authenticate(this.page);
this.authenticationResult = await this.resourceAuthenticator.authenticate(
this.requestUrl,
this.pageAnalysisResult.authenticationType,
this.page,
);
if (this.authenticationResult?.navigationResponse?.browserError !== undefined) {
this.setLastNavigationState('auth', this.authenticationResult.navigationResponse);
}
@ -297,6 +309,10 @@ export class Page {
}
private async reopenBrowser(): Promise<void> {
if (this.browserStartOptions.browserWSEndpoint || this.browserWSEndpoint) {
return;
}
await this.close();
await this.create({ ...this.browserStartOptions, clearBrowserCache: false });
// wait for browser to start

Просмотреть файл

@ -110,7 +110,7 @@ export class PageScanProcessor {
if (authenticationResult !== undefined) {
pageScanResult.authentication = {
...pageScanResult.authentication,
detected: authenticationResult.authenticationType,
detected: pageMetadata.authenticationType,
state: authenticationResult.authenticated === true ? 'succeeded' : 'failed',
};
} else {

Просмотреть файл

@ -4867,7 +4867,6 @@ __metadata:
"@opentelemetry/semantic-conventions": ^1.15.0
"@sindresorhus/fnv1a": ^2.0.1
"@types/escape-html": ^1.0.2
"@types/fingerprint-generator": 1.0.0
"@types/jest": ^29.5.0
"@types/lodash": ^4.14.182
"@types/node": ^16.18.11
@ -5491,6 +5490,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "axe-result-converter@workspace:packages/axe-result-converter"
dependencies:
"@types/fingerprint-generator": 1.0.0
"@types/jest": ^29.5.0
"@types/lodash": ^4.14.182
"@types/node": ^16.18.11