Skip to content

Commit 1e6f727

Browse files
noelsaw1claude
andcommitted
feat: add --auth-state flag to wp-ajax-test for pw-auth cookie reuse
Adds passwordless authentication to wp-ajax-test by reading Playwright auth state files from pw-auth. This eliminates the need for plaintext credentials in temp/auth.json. - New --auth-state flag reads cookies from pw-auth's cached auth state - Filters cookies by target domain, skips expired, verifies wordpress_logged_in_* - --auth-state takes precedence when both --auth and --auth-state are provided - Deprecation warning on --auth pointing users to --auth-state - MCP wp_ajax_test tool gains authState parameter (preferred over auth) - Updated error suggestions to recommend pw-auth workflow - 2 new MCP tests: auth-state precedence + flag-shaped path rejection - README quick start updated to show pw-auth workflow first Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b170a6d commit 1e6f727

5 files changed

Lines changed: 195 additions & 42 deletions

File tree

tools/mcp-server/src/handlers/wp-ajax-test.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,16 +78,16 @@ function toSuggestions(value: unknown): string[] | null {
7878
return suggestions.length > 0 ? suggestions : null;
7979
}
8080

81-
function normalizeAuthPath(authFile?: string): string | undefined {
82-
if (!authFile) {
81+
function normalizeFilePath(filePath: string | undefined, label: string): string | undefined {
82+
if (!filePath) {
8383
return undefined;
8484
}
8585

86-
if (authFile.startsWith("-")) {
87-
throw new Error("wp_ajax_test auth file path must not start with '-'.");
86+
if (filePath.startsWith("-")) {
87+
throw new Error(`wp_ajax_test ${label} path must not start with '-'.`);
8888
}
8989

90-
return authFile;
90+
return filePath;
9191
}
9292

9393
function assertHttpUrl(url: string): void {
@@ -131,12 +131,17 @@ export function createWpAjaxTestHandlers(deps: WpAjaxTestHandlerDeps) {
131131
method: "GET" | "POST" = "POST",
132132
nopriv = false,
133133
insecure = false,
134+
authState?: string,
134135
): Promise<WpAjaxTestResult> {
135136
assertHttpUrl(url);
136-
const normalizedAuthFile = normalizeAuthPath(authFile);
137+
const normalizedAuthState = normalizeFilePath(authState, "auth-state");
138+
const normalizedAuthFile = normalizeFilePath(authFile, "auth");
137139
const args = ["--url", url, "--action", action, "--data", JSON.stringify(data), "--format", "json", "--method", method];
138140

139-
if (normalizedAuthFile) {
141+
// Prefer --auth-state over --auth
142+
if (normalizedAuthState) {
143+
args.push("--auth-state", normalizedAuthState);
144+
} else if (normalizedAuthFile) {
140145
args.push("--auth", normalizedAuthFile);
141146
}
142147

@@ -163,7 +168,7 @@ export function createWpAjaxTestHandlers(deps: WpAjaxTestHandlerDeps) {
163168
method,
164169
nopriv,
165170
insecure,
166-
authProvided: Boolean(normalizedAuthFile),
171+
authProvided: Boolean(normalizedAuthState || normalizedAuthFile),
167172
success: parsed.success === true,
168173
statusCode: toNumber(parsed.status_code),
169174
responseTimeMs: toNumber(parsed.response_time_ms),
@@ -186,7 +191,7 @@ export function createWpAjaxTestHandlers(deps: WpAjaxTestHandlerDeps) {
186191
method,
187192
nopriv,
188193
insecure,
189-
authProvided: Boolean(normalizedAuthFile),
194+
authProvided: Boolean(normalizedAuthState || normalizedAuthFile),
190195
success: false,
191196
statusCode: null,
192197
responseTimeMs: null,

tools/mcp-server/src/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,8 @@ export function createServer() {
346346
url: z.string().url().refine((value) => /^https?:\/\//i.test(value), "Only http:// and https:// URLs are allowed").describe("Full WordPress site URL, for example http://my-site.local"),
347347
action: z.string().min(1).describe("AJAX action name"),
348348
data: z.record(z.string(), z.unknown()).default({}).describe("JSON object payload passed to --data"),
349-
auth: z.string().min(1).optional().describe("Optional auth JSON file path for authenticated AJAX requests"),
349+
authState: z.string().min(1).optional().describe("Playwright auth state file from pw-auth (preferred — no plaintext passwords)"),
350+
auth: z.string().min(1).optional().describe("Legacy auth JSON file with username/password (prefer authState)"),
350351
method: z.enum(["GET", "POST"]).default("POST"),
351352
nopriv: z.boolean().default(false).describe("Use the nopriv AJAX endpoint"),
352353
insecure: z.boolean().default(false).describe("Skip SSL certificate verification for local/self-signed dev environments"),
@@ -370,9 +371,9 @@ export function createServer() {
370371
exitCode: z.number(),
371372
},
372373
},
373-
async ({ url, action, data, auth, method, nopriv, insecure }) => {
374+
async ({ url, action, data, authState, auth, method, nopriv, insecure }) => {
374375
try {
375-
return successResult(await wpAjaxTestHandlers.runTest(url, action, data ?? {}, auth, method ?? "POST", nopriv ?? false, insecure ?? false));
376+
return successResult(await wpAjaxTestHandlers.runTest(url, action, data ?? {}, auth, method ?? "POST", nopriv ?? false, insecure ?? false, authState));
376377
} catch (error) {
377378
return errorResult(error);
378379
}

tools/mcp-server/test/wp-ajax-test.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,75 @@ test("wp_ajax_test rejects non-http URLs before execution", async () => {
142142
}
143143
});
144144

145+
test("wp_ajax_test prefers --auth-state over --auth when both are provided", async () => {
146+
const fixture = await createFixture();
147+
148+
try {
149+
const handlers = createWpAjaxTestHandlers({
150+
repoRoot: fixture.repoRoot,
151+
execRunner: async (_file, args): Promise<ExecResult> => {
152+
// Should pass --auth-state, not --auth
153+
assert.ok(args.includes("--auth-state"), "Expected --auth-state in args");
154+
assert.ok(!args.includes("--auth"), "Should not include --auth when --auth-state is provided");
155+
assert.ok(args.includes("temp/playwright/.auth/admin.json"), "Expected auth state path in args");
156+
157+
return {
158+
stdout: JSON.stringify({
159+
success: true,
160+
action: "demo_action",
161+
url: "http://demo.local/wp-admin/admin-ajax.php",
162+
status_code: 200,
163+
response_time_ms: 30,
164+
response: { ok: true },
165+
headers: {},
166+
}),
167+
stderr: "",
168+
exitCode: 0,
169+
};
170+
},
171+
});
172+
173+
const result = await handlers.runTest(
174+
"http://demo.local",
175+
"demo_action",
176+
{},
177+
"temp/auth.json", // legacy auth — should be ignored
178+
"POST",
179+
false,
180+
false,
181+
"temp/playwright/.auth/admin.json", // auth-state — should win
182+
);
183+
184+
assert.equal(result.success, true);
185+
assert.equal(result.authProvided, true);
186+
} finally {
187+
await fixture.cleanup();
188+
}
189+
});
190+
191+
test("wp_ajax_test rejects flag-shaped auth-state path", async () => {
192+
const fixture = await createFixture();
193+
let invoked = false;
194+
195+
try {
196+
const handlers = createWpAjaxTestHandlers({
197+
repoRoot: fixture.repoRoot,
198+
execRunner: async (): Promise<ExecResult> => {
199+
invoked = true;
200+
return { stdout: "", stderr: "", exitCode: 0 };
201+
},
202+
});
203+
204+
await assert.rejects(
205+
() => handlers.runTest("http://demo.local", "demo_action", {}, undefined, "POST", false, false, "--verbose"),
206+
/must not start with '-'/,
207+
);
208+
assert.equal(invoked, false);
209+
} finally {
210+
await fixture.cleanup();
211+
}
212+
});
213+
145214
test("wp_ajax_test throws when exit 0 stdout is not valid JSON", async () => {
146215
const fixture = await createFixture();
147216

tools/wp-ajax-test/README.md

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,11 @@ wp-ajax-test --version
2727

2828
## Quick Start
2929

30-
### 1. Create Auth File
30+
### 1. Authenticate (via pw-auth — no plaintext passwords)
3131

3232
```bash
33-
# In your project
34-
mkdir -p temp
35-
cat > temp/auth.json <<'EOF'
36-
{
37-
"username": "admin",
38-
"password": "your-password"
39-
}
40-
EOF
41-
42-
# Add to .gitignore
43-
echo "temp/auth.json" >> .gitignore
33+
# One-time login via pw-auth (creates temp/playwright/.auth/admin.json)
34+
pw-auth login --site-url https://yoursite.local --wp-cli "local-wp yoursite"
4435
```
4536

4637
### 2. Test an Endpoint
@@ -49,26 +40,42 @@ echo "temp/auth.json" >> .gitignore
4940
# Basic test
5041
wp-ajax-test --url https://yoursite.local --action my_ajax_action
5142

52-
# With data payload
43+
# With pw-auth cookies (recommended)
5344
wp-ajax-test \
5445
--url https://yoursite.local \
5546
--action my_ajax_action \
56-
--data '{"user_id": 1, "action_type": "update"}'
47+
--auth-state temp/playwright/.auth/admin.json
5748

58-
# With authentication
49+
# With data payload
5950
wp-ajax-test \
6051
--url https://yoursite.local \
6152
--action my_ajax_action \
62-
--auth temp/auth.json
53+
--auth-state temp/playwright/.auth/admin.json \
54+
--data '{"user_id": 1, "action_type": "update"}'
6355

6456
# JSON output (for AI parsing)
6557
wp-ajax-test \
6658
--url https://yoursite.local \
6759
--action my_ajax_action \
68-
--auth temp/auth.json \
60+
--auth-state temp/playwright/.auth/admin.json \
6961
--format json
7062
```
7163

64+
### Legacy: plaintext auth file (deprecated)
65+
66+
```bash
67+
# Still works but not recommended — stores passwords in plaintext
68+
cat > temp/auth.json <<'EOF'
69+
{
70+
"username": "admin",
71+
"password": "your-password"
72+
}
73+
EOF
74+
echo "temp/auth.json" >> .gitignore
75+
76+
wp-ajax-test --url https://yoursite.local --action my_ajax_action --auth temp/auth.json
77+
```
78+
7279
---
7380

7481
## Usage

tools/wp-ajax-test/index.js

Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* wp-ajax-test --url https://site.local --action my_ajax_action
99
* wp-ajax-test --url https://site.local --action my_ajax_action --data '{"key":"value"}'
1010
* wp-ajax-test --url https://site.local --action my_ajax_action --auth temp/auth.json
11+
* wp-ajax-test --url https://site.local --action my_ajax_action --auth-state temp/playwright/.auth/admin.json
1112
*/
1213

1314
const { program } = require('commander');
@@ -39,7 +40,8 @@ program
3940
.requiredOption('-u, --url <url>', 'WordPress site URL')
4041
.requiredOption('-a, --action <action>', 'AJAX action name')
4142
.option('-d, --data <json>', 'JSON data payload', '{}')
42-
.option('--auth <file>', 'Auth file path (JSON)', null)
43+
.option('--auth <file>', 'Auth file path with username/password (JSON) — prefer --auth-state', null)
44+
.option('--auth-state <file>', 'Playwright auth state file from pw-auth (no plaintext passwords)', null)
4345
.option('-f, --format <format>', 'Output format (human|json)', 'human')
4446
.option('--admin', 'Send the default authenticated/admin-style request flow', true)
4547
.option('--nopriv', 'Send an unauthenticated request and skip auth/nonce discovery')
@@ -178,31 +180,44 @@ async function main() {
178180
throw new Error(`Invalid JSON data: ${e.message}`);
179181
}
180182

181-
// Load authentication if provided
183+
// Auth: prefer --auth-state (pw-auth cookies) over --auth (plaintext credentials)
182184
let auth = null;
183-
if (options.auth && useAuthenticatedFlow) {
185+
let authenticatedViaCookies = false;
186+
187+
if (options.authState && useAuthenticatedFlow) {
188+
// Load pre-authenticated cookies from Playwright auth state
189+
const cookieCount = loadAuthState(options.authState, options.url);
190+
authenticatedViaCookies = true;
191+
if (options.verbose) {
192+
console.log(`🔐 Loaded ${cookieCount} cookies from auth state: ${options.authState}`);
193+
}
194+
if (options.auth) {
195+
console.error('⚠️ --auth-state takes precedence over --auth. Ignoring --auth.');
196+
}
197+
} else if (options.auth && useAuthenticatedFlow) {
198+
console.error('⚠️ --auth uses plaintext credentials. Consider pw-auth login + --auth-state instead.');
184199
auth = await loadAuth(options.auth);
185200
if (options.verbose) {
186201
console.log(`Loaded auth from: ${options.auth}`);
187202
}
188203
}
189204

190-
if (options.auth && !useAuthenticatedFlow && options.verbose) {
191-
console.log('Ignoring --auth because --nopriv sends the request without authentication');
205+
if ((options.auth || options.authState) && !useAuthenticatedFlow && options.verbose) {
206+
console.log('Ignoring auth options because --nopriv sends the request without authentication');
192207
}
193208

194209
if (!useAuthenticatedFlow && options.nonceUrl && options.verbose) {
195210
console.log('Ignoring --nonce-url because --nopriv skips authenticated nonce discovery');
196211
}
197212

198-
// Authenticate if needed
213+
// Authenticate with username/password if using legacy --auth (not needed for --auth-state)
199214
if (auth && auth.username && auth.password) {
200215
await authenticate(options.url, auth);
201216
}
202217

203-
// Get nonce if authenticated
218+
// Get nonce if authenticated (works with both --auth-state cookies and --auth login)
204219
let nonce = null;
205-
if (auth) {
220+
if (auth || authenticatedViaCookies) {
206221
nonce = await getNonce(options.url, auth, options.nonceUrl, options.nonceField);
207222
if (options.verbose && nonce) {
208223
console.log(`Extracted nonce: ${nonce.substring(0, 10)}...`);
@@ -261,7 +276,7 @@ async function main() {
261276
}
262277

263278
/**
264-
* Load authentication from file
279+
* Load authentication from file (plaintext username/password)
265280
*/
266281
async function loadAuth(authFile) {
267282
try {
@@ -276,6 +291,59 @@ async function loadAuth(authFile) {
276291
}
277292
}
278293

294+
/**
295+
* Load cookies from a Playwright auth state file (from pw-auth).
296+
* Filters cookies by the target site domain and populates the global cookies object.
297+
* Returns the number of cookies loaded.
298+
*/
299+
function loadAuthState(authStateFile, siteUrl) {
300+
const authStatePath = path.resolve(authStateFile);
301+
if (!fs.existsSync(authStatePath)) {
302+
throw new Error(`Auth state file not found: ${authStatePath}`);
303+
}
304+
305+
let state;
306+
try {
307+
state = JSON.parse(fs.readFileSync(authStatePath, 'utf8'));
308+
} catch (e) {
309+
throw new Error(`Failed to parse auth state file: ${e.message}`);
310+
}
311+
312+
if (!state || !Array.isArray(state.cookies)) {
313+
throw new Error('Auth state file does not contain a cookies array. Expected Playwright storageState format.');
314+
}
315+
316+
const targetHost = new URL(siteUrl).hostname;
317+
const now = Date.now() / 1000;
318+
let loaded = 0;
319+
320+
for (const cookie of state.cookies) {
321+
if (!cookie.name || typeof cookie.value !== 'string') continue;
322+
323+
// Match domain: Playwright stores "site.local" (no leading dot)
324+
const cookieDomain = (cookie.domain || '').replace(/^\./, '');
325+
if (cookieDomain !== targetHost) continue;
326+
327+
// Skip expired cookies
328+
if (cookie.expires && cookie.expires > 0 && cookie.expires < now) continue;
329+
330+
cookies[cookie.name] = cookie.value;
331+
loaded++;
332+
}
333+
334+
if (loaded === 0) {
335+
throw new Error(`No valid cookies found for ${targetHost} in auth state file. Run pw-auth login first.`);
336+
}
337+
338+
// Verify we have a wordpress_logged_in cookie
339+
const hasLoggedInCookie = Object.keys(cookies).some(k => k.startsWith('wordpress_logged_in_'));
340+
if (!hasLoggedInCookie) {
341+
throw new Error(`Auth state has cookies for ${targetHost} but no wordpress_logged_in_* cookie. Session may have expired — run pw-auth login --force.`);
342+
}
343+
344+
return loaded;
345+
}
346+
279347
/**
280348
* Authenticate with WordPress
281349
*/
@@ -513,14 +581,17 @@ function handleError(error, format) {
513581
};
514582

515583
// Add specific suggestions based on error
516-
if (error.message.includes('Auth file not found')) {
584+
if (error.message.includes('Auth file not found') || error.message.includes('Auth state file not found')) {
517585
errorObj.error.code = 'AUTH_REQUIRED';
518-
errorObj.suggestions.push('Create temp/auth.json with username and password');
519-
errorObj.suggestions.push('Use --auth flag to specify auth file location');
586+
errorObj.suggestions.push('Run: pw-auth login --site-url <url> --wp-cli "local-wp <site>"');
587+
errorObj.suggestions.push('Then: --auth-state temp/playwright/.auth/admin.json');
588+
} else if (error.message.includes('No valid cookies found') || error.message.includes('no wordpress_logged_in_')) {
589+
errorObj.error.code = 'AUTH_EXPIRED';
590+
errorObj.suggestions.push('Run: pw-auth login --site-url <url> --force');
520591
} else if (error.message.includes('Authentication failed')) {
521592
errorObj.error.code = 'AUTH_FAILED';
522593
errorObj.suggestions.push('Check username and password in auth file');
523-
errorObj.suggestions.push('Verify WordPress site URL is correct');
594+
errorObj.suggestions.push('Consider switching to --auth-state with pw-auth (no plaintext passwords)');
524595
} else if (error.message.includes('ENOTFOUND') || error.message.includes('ECONNREFUSED')) {
525596
errorObj.error.code = 'CONNECTION_ERROR';
526597
errorObj.suggestions.push('Check if WordPress site is running');

0 commit comments

Comments
 (0)