Skip to content

Commit 5ee8116

Browse files
authored
feat(Auth): Support both HTTP Basic auth and form authentication (#1082)
Follow-up to #1053 This PR does 5 things: 1. Ensures Jekyll includes the example post when building the “site-with-errors”, by correcting its path: github-community-projects/continuous-ai-for-accessibility-scanner@10fdd64 2. Updates the ‘Auth’ sub-action to support both HTTP Basic auth and form authentication (automatically picking whichever is needed): github-community-projects/continuous-ai-for-accessibility-scanner@42035f7 3. Replaces `session_state_path` with `playwright_context_options`, a new input and output that includes the `session_state_path` string _and_ HTTP Basic auth credentials which are not persisted in the saved session file: github-community-projects/continuous-ai-for-accessibility-scanner@bdb5de9 4. Supports serving “site-with-errors” with Puma, behind HTTP Basic auth (instead of just serving with Jekyll (WEBrick) directly): github-community-projects/continuous-ai-for-accessibility-scanner@ad0ccb1 5. Updates the E2E test workflow so the ‘Auth’ sub-action is tested, by scanning an HTTP Basic auth-protected “site-with-errors”: github-community-projects/continuous-ai-for-accessibility-scanner@f319ee3
2 parents 3f96730 + 39420b7 commit 5ee8116

File tree

19 files changed

+273
-69
lines changed

19 files changed

+273
-69
lines changed

.github/actions/auth/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# auth
22

3-
Log in using Playwright, then write authenticated session state to a file for reuse in other Playwright sessions.
3+
Authenticate with Playwright and save session state. Supports HTTP Basic and form authentication.
44

55
## Usage
66

@@ -23,6 +23,6 @@ Log in using Playwright, then write authenticated session state to a file for re
2323
2424
### Outputs
2525

26-
#### `session_state_path`
26+
#### `auth_context`
2727

28-
Path to a file containing authenticated session state.
28+
Stringified JSON object containing `username`, `password`, `cookies`, and/or `localStorage` from an authenticated session. For example: `{"username":"some-user","password":"correct-horse-battery-staple","cookies":[{"name":"theme-preference","value":"light","domain":"primer.style","path":"/"}],"localStorage":{"https://primer.style":{"theme-preference":"light"}}}`

.github/actions/auth/action.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: "Auth"
2-
description: "Log in using Playwright, then write authenticated session state to a file for reuse in other Playwright sessions."
2+
description: "Authenticate with Playwright and save session state. Supports HTTP Basic and form authentication."
33

44
inputs:
55
login_url:
@@ -13,13 +13,13 @@ inputs:
1313
required: true
1414

1515
outputs:
16-
session_state_path:
17-
description: "Path to a file containing authenticated session state"
16+
auth_context:
17+
description: "Stringified JSON object containing 'username', 'password', 'cookies', and/or 'localStorage' from an authenticated session"
1818

1919
runs:
2020
using: "node20"
2121
main: "bootstrap.js"
2222

2323
branding:
2424
icon: "lock"
25-
color: "blue"
25+
color: "blue"

.github/actions/auth/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "auth",
33
"version": "1.0.0",
4-
"description": "Log in using Playwright, then write authenticated session state to a file for reuse in other Playwright sessions.",
4+
"description": "Authenticate with Playwright and save session state. Supports HTTP Basic and form authentication.",
55
"main": "dist/index.js",
66
"module": "dist/index.js",
77
"scripts": {

.github/actions/auth/src/index.ts

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { AuthContextOutput } from "./types.d.js";
12
import crypto from "node:crypto";
23
import process from "node:process";
34
import * as url from "node:url";
@@ -29,26 +30,62 @@ export default async function () {
2930
headless: true,
3031
executablePath: process.env.CI ? "/usr/bin/google-chrome" : undefined,
3132
});
32-
context = await browser.newContext();
33+
context = await browser.newContext({
34+
// Try HTTP Basic authentication
35+
httpCredentials: {
36+
username,
37+
password,
38+
},
39+
});
3340
page = await context.newPage();
3441

35-
// Log in
42+
// Navigate to login page
3643
core.info("Navigating to login page");
3744
await page.goto(loginUrl);
38-
core.info("Filling username");
39-
await page.getByLabel(/username/i).fill(username);
40-
core.info("Filling password");
41-
await page.getByLabel(/password/i).fill(password);
42-
core.info("Logging in");
43-
await page
44-
.getByLabel(/password/i)
45-
.locator("xpath=ancestor::form")
46-
.evaluate((form) => (form as HTMLFormElement).submit());
4745

48-
// Write authenticated session state to a file and output its path
49-
await context.storageState({ path: sessionStatePath });
50-
core.setOutput("session_state_path", sessionStatePath);
51-
core.info(`Wrote authenticated session state to ${sessionStatePath}`);
46+
// Check for a login form.
47+
// If no login form is found, then either HTTP Basic auth succeeded, or the page does not require authentication.
48+
core.info("Checking for login form");
49+
const [usernameField, passwordField] = await Promise.all([
50+
page.getByLabel(/username/i).first(),
51+
page.getByLabel(/password/i).first(),
52+
]);
53+
const [usernameFieldExists, passwordFieldExists] = await Promise.all([
54+
usernameField.count(),
55+
passwordField.count(),
56+
]);
57+
if (usernameFieldExists && passwordFieldExists) {
58+
// Try form authentication
59+
core.info("Filling username");
60+
await usernameField.fill(username);
61+
core.info("Filling password");
62+
await passwordField.fill(password);
63+
core.info("Logging in");
64+
await page
65+
.getByLabel(/password/i)
66+
.locator("xpath=ancestor::form")
67+
.evaluate((form) => (form as HTMLFormElement).submit());
68+
} else {
69+
core.info("No login form detected");
70+
// This occurs if HTTP Basic auth succeeded, or if the page does not require authentication.
71+
}
72+
73+
// Output authenticated session state
74+
const { cookies, origins } = await context.storageState();
75+
const authContextOutput: AuthContextOutput = {
76+
username,
77+
password,
78+
cookies,
79+
localStorage: origins.reduce((acc, { origin, localStorage }) => {
80+
acc[origin] = localStorage.reduce((acc, { name, value }) => {
81+
acc[name] = value;
82+
return acc;
83+
}, {} as Record<string, string>);
84+
return acc;
85+
}, {} as Record<string, Record<string, string>>),
86+
};
87+
core.setOutput("auth_context", JSON.stringify(authContextOutput));
88+
core.debug("Output: 'auth_context'");
5289
} catch (error) {
5390
if (page) {
5491
core.info(`Errored at page URL: ${page.url()}`);
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export type Cookie = {
2+
name: string;
3+
value: string;
4+
domain: string;
5+
path: string;
6+
expires?: number;
7+
httpOnly?: boolean;
8+
secure?: boolean;
9+
sameSite?: "Strict" | "Lax" | "None";
10+
};
11+
12+
export type LocalStorage = {
13+
[origin: string]: {
14+
[key: string]: string;
15+
};
16+
};
17+
18+
export type AuthContextOutput = {
19+
username?: string;
20+
password?: string;
21+
cookies?: Cookie[];
22+
localStorage?: LocalStorage;
23+
};

.github/actions/find/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ https://primer.style
1515
https://primer.style/octicons/
1616
```
1717

18-
#### `session_state_path`
18+
#### `auth_context`
1919

20-
**Optional** Path to a file containing authenticated session state.
20+
**Optional** Stringified JSON object containing `username`, `password`, `cookies`, and/or `localStorage` from an authenticated session. For example: `{"username":"some-user","password":"correct-horse-battery-staple","cookies":[{"name":"theme-preference","value":"light","domain":"primer.style","path":"/"}],"localStorage":{"https://primer.style":{"theme-preference":"light"}}}`
2121

2222
### Outputs
2323

.github/actions/find/action.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ inputs:
66
description: "Newline-delimited list of URLs to check for accessibility issues"
77
required: true
88
multiline: true
9-
session_state_path:
10-
description: "Path to a file containing authenticated session state"
9+
auth_context:
10+
description: "Stringified JSON object containing 'username', 'password', 'cookies', and/or 'localStorage' from an authenticated session"
1111
required: false
1212

1313
outputs:
@@ -20,4 +20,4 @@ runs:
2020

2121
branding:
2222
icon: "compass"
23-
color: "blue"
23+
color: "blue"
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type playwright from "playwright";
2+
import type { Cookie, LocalStorage, AuthContextInput } from "./types.js";
3+
4+
export class AuthContext implements AuthContextInput {
5+
readonly username?: string;
6+
readonly password?: string;
7+
readonly cookies?: Cookie[];
8+
readonly localStorage?: LocalStorage;
9+
10+
constructor({ username, password, cookies, localStorage }: AuthContextInput) {
11+
this.username = username;
12+
this.password = password;
13+
this.cookies = cookies;
14+
this.localStorage = localStorage;
15+
}
16+
17+
toPlaywrightBrowserContextOptions(): playwright.BrowserContextOptions {
18+
const playwrightBrowserContextOptions: playwright.BrowserContextOptions =
19+
{};
20+
if (this.username && this.password) {
21+
playwrightBrowserContextOptions.httpCredentials = {
22+
username: this.username,
23+
password: this.password,
24+
};
25+
}
26+
if (this.cookies || this.localStorage) {
27+
playwrightBrowserContextOptions.storageState = {
28+
// Add default values for fields Playwright requires which aren’t actually required by the Cookie API.
29+
cookies:
30+
this.cookies?.map((cookie) => ({
31+
expires: -1,
32+
httpOnly: false,
33+
secure: false,
34+
sameSite: "Lax",
35+
...cookie,
36+
})) ?? [],
37+
// Transform the localStorage object into the shape Playwright expects.
38+
origins:
39+
Object.entries(this.localStorage ?? {}).map(([origin, kv]) => ({
40+
origin,
41+
localStorage: Object.entries(kv).map(([name, value]) => ({
42+
name,
43+
value,
44+
})),
45+
})) ?? [],
46+
};
47+
}
48+
return playwrightBrowserContextOptions;
49+
}
50+
}

.github/actions/find/src/findForUrl.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { Finding } from './types.d.js';
22
import AxeBuilder from '@axe-core/playwright'
33
import playwright from 'playwright';
4+
import { AuthContext } from './AuthContext.js';
45

5-
export async function findForUrl(url: string, sessionStatePath?: string): Promise<Finding[]> {
6+
export async function findForUrl(url: string, authContext?: AuthContext): Promise<Finding[]> {
67
const browser = await playwright.chromium.launch({ headless: true, executablePath: process.env.CI ? '/usr/bin/google-chrome' : undefined });
7-
const context = await browser.newContext({ storageState: !!sessionStatePath ? sessionStatePath : undefined });
8+
const contextOptions = authContext?.toPlaywrightBrowserContextOptions() ?? {};
9+
const context = await browser.newContext(contextOptions);
810
const page = await context.newPage();
911
await page.goto(url);
1012

.github/actions/find/src/index.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
1+
import type { AuthContextInput } from "./types.js";
12
import core from "@actions/core";
3+
import { AuthContext } from "./AuthContext.js";
24
import { findForUrl } from "./findForUrl.js";
35

46
export default async function () {
57
core.info("Starting 'find' action");
6-
const urls = core.getMultilineInput('urls', { required: true });
8+
const urls = core.getMultilineInput("urls", { required: true });
79
core.debug(`Input: 'urls: ${JSON.stringify(urls)}'`);
8-
const sessionStatePath = core.getInput('session_state_path', { required: false });
9-
core.debug(`Input: 'session_state_path: ${sessionStatePath}'`);
10+
const authContextInput: AuthContextInput = JSON.parse(
11+
core.getInput("auth_context", { required: false }) ?? "{}"
12+
);
13+
const authContext = new AuthContext(authContextInput);
1014

1115
let findings = [];
1216
for (const url of urls) {
13-
core.info(`Scanning ${url}`)
14-
const findingsForUrl = await findForUrl(url, sessionStatePath);
17+
core.info(`Scanning ${url}`);
18+
const findingsForUrl = await findForUrl(url, authContext);
1519
if (findingsForUrl.length === 0) {
1620
core.info(`No accessibility gaps were found on ${url}`);
1721
continue;

0 commit comments

Comments
 (0)