Skip to content

Commit c7afd0c

Browse files
committed
feat: authenticate hook for easier programmatic authentication
1 parent 2e51250 commit c7afd0c

File tree

6 files changed

+117
-45
lines changed

6 files changed

+117
-45
lines changed

docs/content/1.guide/guides/authentication.md

+35-9
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,19 @@ Alternatively, you can provide the `--default-query-params` flag to the CLI.
103103
unlighthouse --site <your-site> --default-query-params auth=<token>,foo=bar
104104
```
105105

106+
## Local Storage
107+
108+
If you can configure your authentication using local storage,
109+
then you can provide them using the `localStorage` option in your configuration file:
110+
111+
```ts
112+
// unlighthouse.config.ts
113+
export default {
114+
localStorage: {
115+
auth: '<token>'
116+
}
117+
}
118+
```
106119

107120
## Programmatic Usage
108121

@@ -115,16 +128,8 @@ You can see an example here:
115128
```ts
116129
// unlighthouse.config.ts
117130
export default {
118-
puppeteerOptions: {
119-
// slow down slightly so input is not missed
120-
slowMo: 50,
121-
},
122-
lighthouseOptions: {
123-
// allow storage to persist between pages
124-
disableStorageReset: true,
125-
},
126131
hooks: {
127-
'puppeteer:before-goto': async (page) => {
132+
'authenticate': async (page) => {
128133
// login to the page
129134
await page.goto('https://example.com/login')
130135
const emailInput = await page.$('input[type="email"]')
@@ -139,3 +144,24 @@ export default {
139144
},
140145
}
141146
```
147+
148+
## Troubleshooting
149+
150+
If you're having trouble authenticating,
151+
you can use the `debug: true` and `headless: false`,
152+
flags to see what's happening.
153+
154+
```bash
155+
// unlighthouse.config.ts
156+
export default {
157+
debug: true,
158+
// show the browser window
159+
puppeteerOptions: {
160+
headless: false,
161+
},
162+
// only run a single scan at a time
163+
puppeteerClusterOptions: {
164+
maxConcurrency: 1,
165+
},
166+
}
167+
```

packages/core/src/puppeteer/tasks/html.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@ import { join } from 'node:path'
22
import fs from 'fs-extra'
33
import type { CheerioAPI } from 'cheerio'
44
import cheerio from 'cheerio'
5-
import type { Page } from 'puppeteer-core'
5+
import type { Page } from 'puppeteer'
66
import { $URL, withoutTrailingSlash } from 'ufo'
77
import chalk from 'chalk'
88
import type { HTMLExtractPayload, PuppeteerTask } from '../../types'
99
import { useUnlighthouse } from '../../unlighthouse'
1010
import { useLogger } from '../../logger'
1111
import { ReportArtifacts, fetchUrlRaw, formatBytes, trimSlashes } from '../../util'
1212
import { normaliseRoute } from '../../router'
13+
import { setupPage } from '../util'
1314

1415
export const extractHtmlPayload: (page: Page, route: string) => Promise<{ success: boolean; redirected?: false | string; message?: string; payload?: string }> = async (page, route) => {
15-
const { worker, resolvedConfig, hooks } = useUnlighthouse()
16+
const { worker, resolvedConfig } = useUnlighthouse()
1617

1718
// if we don't need to execute any javascript we can do a less expensive fetch of the URL
1819
if (resolvedConfig.scanner.skipJavascript) {
@@ -41,9 +42,11 @@ export const extractHtmlPayload: (page: Page, route: string) => Promise<{ succes
4142
request.continue()
4243
})
4344

44-
await hooks.callHook('puppeteer:before-goto', page)
45+
await setupPage(page)
4546

4647
const pageVisit = await page.goto(route, { waitUntil: resolvedConfig.scanner.skipJavascript ? 'domcontentloaded' : 'networkidle0' })
48+
if (!pageVisit)
49+
return { success: false, message: `Failed to go to route ${route}.` }
4750

4851
// only 2xx we'll consider valid
4952
const { 'content-type': contentType, location } = pageVisit.headers()

packages/core/src/puppeteer/tasks/lighthouse.ts

+4-26
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { LighthouseReport, PuppeteerTask, UnlighthouseRouteReport } from '.
1010
import { useUnlighthouse } from '../../unlighthouse'
1111
import { useLogger } from '../../logger'
1212
import { ReportArtifacts, base64ToBuffer } from '../../util'
13+
import { setupPage } from '../util'
1314

1415
export function normaliseLighthouseResult(route: UnlighthouseRouteReport, result: LH.Result): LighthouseReport {
1516
const { resolvedConfig, runtimeSettings } = useUnlighthouse()
@@ -85,7 +86,7 @@ export function normaliseLighthouseResult(route: UnlighthouseRouteReport, result
8586

8687
export const runLighthouseTask: PuppeteerTask = async (props) => {
8788
const logger = useLogger()
88-
const { resolvedConfig, runtimeSettings, worker, hooks } = useUnlighthouse()
89+
const { resolvedConfig, runtimeSettings, worker } = useUnlighthouse()
8990
const { page, data: routeReport } = props
9091

9192
// if the report doesn't exist, we're going to run a new lighthouse process to generate it
@@ -96,32 +97,9 @@ export const runLighthouseTask: PuppeteerTask = async (props) => {
9697
return routeReport
9798
}
9899

99-
const browser = page.browser()
100-
const port = new URL(browser.wsEndpoint()).port
101-
// ignore csp errors
102-
await page.setBypassCSP(true)
103-
104-
if (resolvedConfig.auth)
105-
await page.authenticate(resolvedConfig.auth)
106-
107-
if (resolvedConfig.cookies)
108-
await page.setCookie(...resolvedConfig.cookies)
109-
if (resolvedConfig.extraHeaders)
110-
await page.setExtraHTTPHeaders(resolvedConfig.extraHeaders)
111-
112-
// Wait for Lighthouse to open url, then allow hook to run
113-
browser.on('targetchanged', async (target) => {
114-
const page = await target.page()
115-
if (page) {
116-
// in case they get reset
117-
if (resolvedConfig.cookies)
118-
await page.setCookie(...resolvedConfig.cookies)
119-
if (resolvedConfig.extraHeaders)
120-
await page.setExtraHTTPHeaders(resolvedConfig.extraHeaders)
121-
await hooks.callHook('puppeteer:before-goto', page)
122-
}
123-
})
100+
await setupPage(page)
124101

102+
const port = new URL(page.browser().wsEndpoint()).port
125103
// allow changing behavior of the page
126104
const clonedRouteReport = { ...routeReport }
127105
// just modify the url for the unlighthouse request

packages/core/src/puppeteer/util.ts

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { Page } from 'puppeteer'
2+
import { useUnlighthouse } from '../unlighthouse'
3+
4+
export async function setupPage(page: Page) {
5+
const { resolvedConfig, hooks } = useUnlighthouse()
6+
7+
const browser = page.browser()
8+
// ignore csp errors
9+
await page.setBypassCSP(true)
10+
11+
if (resolvedConfig.auth)
12+
await page.authenticate(resolvedConfig.auth)
13+
14+
// set local storage
15+
if (resolvedConfig.localStorage) {
16+
await page.evaluateOnNewDocument(
17+
(data) => {
18+
localStorage.clear()
19+
for (const key in data)
20+
localStorage.setItem(key, data[key])
21+
}, resolvedConfig.localStorage)
22+
}
23+
if (resolvedConfig.cookies)
24+
await page.setCookie(...resolvedConfig.cookies.map(cookie => ({ domain: resolvedConfig.site, ...cookie })))
25+
if (resolvedConfig.extraHeaders)
26+
await page.setExtraHTTPHeaders(resolvedConfig.extraHeaders)
27+
28+
// Wait for Lighthouse to open url, then allow hook to run
29+
browser.on('targetchanged', async (target) => {
30+
const page = await target.page()
31+
if (page) {
32+
// in case they get reset
33+
if (resolvedConfig.cookies)
34+
await page.setCookie(...resolvedConfig.cookies.map(cookie => ({ domain: resolvedConfig.site, ...cookie })))
35+
// set local storage
36+
if (resolvedConfig.extraHeaders)
37+
await page.setExtraHTTPHeaders(resolvedConfig.extraHeaders)
38+
await hooks.callHook('puppeteer:before-goto', page)
39+
}
40+
})
41+
}

packages/core/src/types.ts

+12-1
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,12 @@ export interface ResolvedUserConfig {
263263
* @default false
264264
*/
265265
cookies: false | { name: string; value: string; [v: string]: string }[]
266+
/**
267+
* Local storage to add to the browser context.
268+
*
269+
* @default {}
270+
*/
271+
localStorage: Record<string, any>
266272
/**
267273
* Extra headers to provide for any HTTP requests.
268274
*
@@ -699,6 +705,11 @@ export interface UnlighthouseHooks {
699705
* @param page
700706
*/
701707
'puppeteer:before-goto': (page: Page) => HookResult
708+
/**
709+
* Authenticate a page before it's visited.
710+
* @param page
711+
*/
712+
'authenticate': (page: Page) => HookResult
702713
}
703714

704715
/**
@@ -800,7 +811,7 @@ export interface UnlighthouseContext {
800811
mockRouter?: MockRouter
801812
/**
802813
* Settings that are computed from runtime data.
803-
*/
814+
*/localStorage
804815
runtimeSettings: RuntimeSettings
805816
/**
806817
* Access the hook system, either calling a hook or listening to one.

packages/core/src/unlighthouse.ts

+19-6
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { version } from '../package.json'
1515
import { WS, createApi, createBroadcastingEvents, createMockRouter } from './router'
1616
import { createUnlighthouseWorker, inspectHtmlTask, runLighthouseTask } from './puppeteer'
1717
import type {
18-
Provider, RuntimeSettings,
18+
Provider, ResolvedUserConfig, RuntimeSettings,
1919
UnlighthouseContext,
2020
UnlighthouseHooks,
2121
UserConfig,
@@ -136,6 +136,24 @@ export async function createUnlighthouse(userConfig: UserConfig, provider?: Prov
136136

137137
const worker = await createUnlighthouseWorker(tasks)
138138

139+
// do an authentication step
140+
await worker.cluster.execute({}, async (taskCtx) => {
141+
await hooks.callHook('authenticate', taskCtx.page)
142+
// collect page authentication, either cookie or localStorage tokens
143+
const localStorageData = await taskCtx.page.evaluate(() => {
144+
const json = {}
145+
for (let i = 0; i < localStorage.length; i++) {
146+
const key = localStorage.key(i)
147+
json[key] = localStorage.getItem(key)
148+
}
149+
return json
150+
})
151+
const cookies = await taskCtx.page.cookies()
152+
// merge this into the config
153+
ctx.resolvedConfig.cookies = [...(ctx.resolvedConfig.cookies || []), ...cookies as any as ResolvedUserConfig['cookies']]
154+
ctx.resolvedConfig.localStorage = { ...ctx.resolvedConfig.localStorage, ...localStorageData }
155+
})
156+
139157
ctx.worker = worker
140158

141159
ctx.setCiContext = async () => {
@@ -248,11 +266,6 @@ export async function createUnlighthouse(userConfig: UserConfig, provider?: Prov
248266
}
249267

250268
ctx.start = async () => {
251-
if (worker.hasStarted()) {
252-
logger.debug('Attempted to start Unlighthouse, has already started.')
253-
return ctx
254-
}
255-
256269
logger.debug(`Starting Unlighthouse [Server: ${provider?.name === 'ci' ? 'N/A' : ctx.runtimeSettings.clientUrl} Site: ${ctx.resolvedConfig.site} Debug: \`${ctx.resolvedConfig.debug}\`]`)
257270

258271
if (typeof provider?.routeDefinitions === 'function')

0 commit comments

Comments
 (0)