Add web-automation skill variants and inline prerequisite checks
This commit is contained in:
575
skills/web-automation/codex/scripts/auth.ts
Normal file
575
skills/web-automation/codex/scripts/auth.ts
Normal file
@@ -0,0 +1,575 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Authentication handler for web automation
|
||||
* Supports generic form login and Microsoft SSO (MSAL)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx auth.ts --url "https://example.com/login" --type form
|
||||
* npx tsx auth.ts --url "https://example.com" --type msal
|
||||
* npx tsx auth.ts --url "https://example.com" --type auto
|
||||
*/
|
||||
|
||||
import { getPage, launchBrowser } from './browse.js';
|
||||
import parseArgs from 'minimist';
|
||||
import type { Page, BrowserContext } from 'playwright-core';
|
||||
import { createInterface } from 'readline';
|
||||
|
||||
// Types
|
||||
type AuthType = 'auto' | 'form' | 'msal';
|
||||
|
||||
interface AuthOptions {
|
||||
url: string;
|
||||
authType: AuthType;
|
||||
credentials?: {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
headless?: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface AuthResult {
|
||||
success: boolean;
|
||||
finalUrl: string;
|
||||
authType: AuthType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Get credentials from environment or options
|
||||
function getCredentials(options?: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
}): { username: string; password: string } | null {
|
||||
const username = options?.username || process.env.CAMOUFOX_USERNAME;
|
||||
const password = options?.password || process.env.CAMOUFOX_PASSWORD;
|
||||
|
||||
if (!username || !password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { username, password };
|
||||
}
|
||||
|
||||
// Prompt user for input (for MFA or credentials)
|
||||
async function promptUser(question: string, hidden = false): Promise<string> {
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (hidden) {
|
||||
process.stdout.write(question);
|
||||
// Note: This is a simple implementation. For production, use a proper hidden input library
|
||||
}
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Detect authentication type from page
|
||||
async function detectAuthType(page: Page): Promise<AuthType> {
|
||||
const url = page.url();
|
||||
|
||||
// Check for Microsoft login
|
||||
if (
|
||||
url.includes('login.microsoftonline.com') ||
|
||||
url.includes('login.live.com') ||
|
||||
url.includes('login.windows.net')
|
||||
) {
|
||||
return 'msal';
|
||||
}
|
||||
|
||||
// Check for common form login patterns
|
||||
const hasLoginForm = await page.evaluate(() => {
|
||||
const passwordField = document.querySelector(
|
||||
'input[type="password"], input[name*="password"], input[id*="password"]'
|
||||
);
|
||||
const usernameField = document.querySelector(
|
||||
'input[type="email"], input[type="text"][name*="user"], input[type="text"][name*="email"], input[id*="user"], input[id*="email"]'
|
||||
);
|
||||
return !!(passwordField && usernameField);
|
||||
});
|
||||
|
||||
if (hasLoginForm) {
|
||||
return 'form';
|
||||
}
|
||||
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
// Handle generic form login
|
||||
async function handleFormLogin(
|
||||
page: Page,
|
||||
credentials: { username: string; password: string },
|
||||
timeout: number
|
||||
): Promise<boolean> {
|
||||
console.log('Attempting form login...');
|
||||
|
||||
// Find and fill username/email field
|
||||
const usernameSelectors = [
|
||||
'input[type="email"]',
|
||||
'input[name*="user" i]',
|
||||
'input[name*="email" i]',
|
||||
'input[id*="user" i]',
|
||||
'input[id*="email" i]',
|
||||
'input[autocomplete="username"]',
|
||||
'input[type="text"]:first-of-type',
|
||||
];
|
||||
|
||||
let usernameField = null;
|
||||
for (const selector of usernameSelectors) {
|
||||
usernameField = await page.$(selector);
|
||||
if (usernameField) break;
|
||||
}
|
||||
|
||||
if (!usernameField) {
|
||||
console.error('Could not find username/email field');
|
||||
return false;
|
||||
}
|
||||
|
||||
await usernameField.fill(credentials.username);
|
||||
console.log('Filled username field');
|
||||
|
||||
// Find and fill password field
|
||||
const passwordSelectors = [
|
||||
'input[type="password"]',
|
||||
'input[name*="password" i]',
|
||||
'input[id*="password" i]',
|
||||
'input[autocomplete="current-password"]',
|
||||
];
|
||||
|
||||
let passwordField = null;
|
||||
for (const selector of passwordSelectors) {
|
||||
passwordField = await page.$(selector);
|
||||
if (passwordField) break;
|
||||
}
|
||||
|
||||
if (!passwordField) {
|
||||
console.error('Could not find password field');
|
||||
return false;
|
||||
}
|
||||
|
||||
await passwordField.fill(credentials.password);
|
||||
console.log('Filled password field');
|
||||
|
||||
// Check for "Remember me" checkbox and check it
|
||||
const rememberCheckbox = await page.$(
|
||||
'input[type="checkbox"][name*="remember" i], input[type="checkbox"][id*="remember" i]'
|
||||
);
|
||||
if (rememberCheckbox) {
|
||||
await rememberCheckbox.check();
|
||||
console.log('Checked "Remember me" checkbox');
|
||||
}
|
||||
|
||||
// Find and click submit button
|
||||
const submitSelectors = [
|
||||
'button[type="submit"]',
|
||||
'input[type="submit"]',
|
||||
'button:has-text("Sign in")',
|
||||
'button:has-text("Log in")',
|
||||
'button:has-text("Login")',
|
||||
'button:has-text("Submit")',
|
||||
'[role="button"]:has-text("Sign in")',
|
||||
];
|
||||
|
||||
let submitButton = null;
|
||||
for (const selector of submitSelectors) {
|
||||
submitButton = await page.$(selector);
|
||||
if (submitButton) break;
|
||||
}
|
||||
|
||||
if (!submitButton) {
|
||||
// Try pressing Enter as fallback
|
||||
await passwordField.press('Enter');
|
||||
} else {
|
||||
await submitButton.click();
|
||||
}
|
||||
|
||||
console.log('Submitted login form');
|
||||
|
||||
// Wait for navigation or error
|
||||
try {
|
||||
await page.waitForNavigation({ timeout, waitUntil: 'domcontentloaded' });
|
||||
return true;
|
||||
} catch {
|
||||
// Check if we're still on login page with error
|
||||
const errorMessages = await page.$$eval(
|
||||
'.error, .alert-danger, [role="alert"], .login-error',
|
||||
(els) => els.map((el) => el.textContent?.trim()).filter(Boolean)
|
||||
);
|
||||
|
||||
if (errorMessages.length > 0) {
|
||||
console.error('Login error:', errorMessages.join(', '));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true; // Might have succeeded without navigation
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Microsoft SSO login
|
||||
async function handleMsalLogin(
|
||||
page: Page,
|
||||
credentials: { username: string; password: string },
|
||||
timeout: number
|
||||
): Promise<boolean> {
|
||||
console.log('Attempting Microsoft SSO login...');
|
||||
|
||||
const currentUrl = page.url();
|
||||
|
||||
// If not already on Microsoft login, wait for redirect
|
||||
if (!currentUrl.includes('login.microsoftonline.com')) {
|
||||
try {
|
||||
await page.waitForURL('**/login.microsoftonline.com/**', { timeout: 10000 });
|
||||
} catch {
|
||||
console.log('Not redirected to Microsoft login');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for email input
|
||||
const emailInput = await page.waitForSelector(
|
||||
'input[type="email"], input[name="loginfmt"]',
|
||||
{ timeout }
|
||||
);
|
||||
|
||||
if (!emailInput) {
|
||||
console.error('Could not find email input on Microsoft login');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fill email and submit
|
||||
await emailInput.fill(credentials.username);
|
||||
console.log('Filled email field');
|
||||
|
||||
const nextButton = await page.$('input[type="submit"], button[type="submit"]');
|
||||
if (nextButton) {
|
||||
await nextButton.click();
|
||||
} else {
|
||||
await emailInput.press('Enter');
|
||||
}
|
||||
|
||||
// Wait for password page
|
||||
try {
|
||||
await page.waitForSelector(
|
||||
'input[type="password"], input[name="passwd"]',
|
||||
{ timeout }
|
||||
);
|
||||
} catch {
|
||||
// Might be using passwordless auth or different flow
|
||||
console.log('Password field not found - might be using different auth flow');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fill password
|
||||
const passwordInput = await page.$('input[type="password"], input[name="passwd"]');
|
||||
if (!passwordInput) {
|
||||
console.error('Could not find password input');
|
||||
return false;
|
||||
}
|
||||
|
||||
await passwordInput.fill(credentials.password);
|
||||
console.log('Filled password field');
|
||||
|
||||
// Submit
|
||||
const signInButton = await page.$('input[type="submit"], button[type="submit"]');
|
||||
if (signInButton) {
|
||||
await signInButton.click();
|
||||
} else {
|
||||
await passwordInput.press('Enter');
|
||||
}
|
||||
|
||||
// Handle "Stay signed in?" prompt
|
||||
try {
|
||||
const staySignedInButton = await page.waitForSelector(
|
||||
'input[value="Yes"], button:has-text("Yes")',
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
if (staySignedInButton) {
|
||||
await staySignedInButton.click();
|
||||
console.log('Clicked "Stay signed in" button');
|
||||
}
|
||||
} catch {
|
||||
// Prompt might not appear
|
||||
}
|
||||
|
||||
// Check for Conditional Access Policy error
|
||||
const caError = await page.$('text=Conditional Access policy');
|
||||
if (caError) {
|
||||
console.error('Blocked by Conditional Access Policy');
|
||||
// Take screenshot for debugging
|
||||
await page.screenshot({ path: 'ca-policy-error.png' });
|
||||
console.log('Screenshot saved: ca-policy-error.png');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wait for redirect away from Microsoft login
|
||||
try {
|
||||
await page.waitForURL(
|
||||
(url) => !url.href.includes('login.microsoftonline.com'),
|
||||
{ timeout }
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is already authenticated
|
||||
async function isAuthenticated(page: Page, targetUrl: string): Promise<boolean> {
|
||||
const currentUrl = page.url();
|
||||
|
||||
// If we're on the target URL (not a login page), we're likely authenticated
|
||||
if (currentUrl.startsWith(targetUrl)) {
|
||||
// Check for common login page indicators
|
||||
const isLoginPage = await page.evaluate(() => {
|
||||
const loginIndicators = [
|
||||
'input[type="password"]',
|
||||
'form[action*="login"]',
|
||||
'form[action*="signin"]',
|
||||
'.login-form',
|
||||
'#login',
|
||||
];
|
||||
return loginIndicators.some((sel) => document.querySelector(sel) !== null);
|
||||
});
|
||||
|
||||
return !isLoginPage;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Main authentication function
|
||||
export async function authenticate(options: AuthOptions): Promise<AuthResult> {
|
||||
const browser = await launchBrowser({ headless: options.headless ?? true });
|
||||
const page = await browser.newPage();
|
||||
const timeout = options.timeout ?? 30000;
|
||||
|
||||
try {
|
||||
// Navigate to URL
|
||||
console.log(`Navigating to: ${options.url}`);
|
||||
await page.goto(options.url, { timeout: 60000, waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Check if already authenticated
|
||||
if (await isAuthenticated(page, options.url)) {
|
||||
return {
|
||||
success: true,
|
||||
finalUrl: page.url(),
|
||||
authType: 'auto',
|
||||
message: 'Already authenticated (session persisted from profile)',
|
||||
};
|
||||
}
|
||||
|
||||
// Get credentials
|
||||
const credentials = options.credentials
|
||||
? options.credentials
|
||||
: getCredentials();
|
||||
|
||||
if (!credentials) {
|
||||
// No credentials - open interactive browser
|
||||
console.log('\nNo credentials provided. Opening browser for manual login...');
|
||||
console.log('Please complete the login process manually.');
|
||||
console.log('The session will be saved to your profile.');
|
||||
|
||||
// Switch to headed mode for manual login
|
||||
await browser.close();
|
||||
const interactiveBrowser = await launchBrowser({ headless: false });
|
||||
const interactivePage = await interactiveBrowser.newPage();
|
||||
await interactivePage.goto(options.url);
|
||||
|
||||
await promptUser('\nPress Enter when you have completed login...');
|
||||
|
||||
const finalUrl = interactivePage.url();
|
||||
await interactiveBrowser.close();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
finalUrl,
|
||||
authType: 'auto',
|
||||
message: 'Manual login completed - session saved to profile',
|
||||
};
|
||||
}
|
||||
|
||||
// Detect auth type if auto
|
||||
let authType = options.authType;
|
||||
if (authType === 'auto') {
|
||||
authType = await detectAuthType(page);
|
||||
console.log(`Detected auth type: ${authType}`);
|
||||
}
|
||||
|
||||
// Handle authentication based on type
|
||||
let success = false;
|
||||
switch (authType) {
|
||||
case 'msal':
|
||||
success = await handleMsalLogin(page, credentials, timeout);
|
||||
break;
|
||||
case 'form':
|
||||
default:
|
||||
success = await handleFormLogin(page, credentials, timeout);
|
||||
break;
|
||||
}
|
||||
|
||||
const finalUrl = page.url();
|
||||
|
||||
return {
|
||||
success,
|
||||
finalUrl,
|
||||
authType,
|
||||
message: success
|
||||
? `Authentication successful - session saved to profile`
|
||||
: 'Authentication failed',
|
||||
};
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to authenticated page (handles auth if needed)
|
||||
export async function navigateAuthenticated(
|
||||
url: string,
|
||||
options?: {
|
||||
credentials?: { username: string; password: string };
|
||||
headless?: boolean;
|
||||
}
|
||||
): Promise<{ page: Page; browser: BrowserContext }> {
|
||||
const { page, browser } = await getPage({ headless: options?.headless ?? true });
|
||||
|
||||
await page.goto(url, { timeout: 60000, waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Check if we need to authenticate
|
||||
if (!(await isAuthenticated(page, url))) {
|
||||
console.log('Session expired or not authenticated. Attempting login...');
|
||||
|
||||
// Get credentials
|
||||
const credentials = options?.credentials ?? getCredentials();
|
||||
|
||||
if (!credentials) {
|
||||
throw new Error(
|
||||
'Authentication required but no credentials provided. ' +
|
||||
'Set CAMOUFOX_USERNAME and CAMOUFOX_PASSWORD environment variables.'
|
||||
);
|
||||
}
|
||||
|
||||
// Detect and handle auth
|
||||
const authType = await detectAuthType(page);
|
||||
|
||||
let success = false;
|
||||
if (authType === 'msal') {
|
||||
success = await handleMsalLogin(page, credentials, 30000);
|
||||
} else {
|
||||
success = await handleFormLogin(page, credentials, 30000);
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
await browser.close();
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
|
||||
// Navigate back to original URL if we were redirected
|
||||
if (!page.url().startsWith(url)) {
|
||||
await page.goto(url, { timeout: 60000, waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
}
|
||||
|
||||
return { page, browser };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2), {
|
||||
string: ['url', 'type', 'username', 'password'],
|
||||
boolean: ['headless', 'help'],
|
||||
default: {
|
||||
type: 'auto',
|
||||
headless: false, // Default to headed for auth so user can see/interact
|
||||
},
|
||||
alias: {
|
||||
u: 'url',
|
||||
t: 'type',
|
||||
h: 'help',
|
||||
},
|
||||
});
|
||||
|
||||
if (args.help || !args.url) {
|
||||
console.log(`
|
||||
Web Authentication Handler
|
||||
|
||||
Usage:
|
||||
npx tsx auth.ts --url <url> [options]
|
||||
|
||||
Options:
|
||||
-u, --url <url> URL to authenticate (required)
|
||||
-t, --type <type> Auth type: auto, form, or msal (default: auto)
|
||||
--username <user> Username/email (or set CAMOUFOX_USERNAME env var)
|
||||
--password <pass> Password (or set CAMOUFOX_PASSWORD env var)
|
||||
--headless <bool> Run in headless mode (default: false for auth)
|
||||
-h, --help Show this help message
|
||||
|
||||
Auth Types:
|
||||
auto Auto-detect authentication type
|
||||
form Generic username/password form
|
||||
msal Microsoft SSO (login.microsoftonline.com)
|
||||
|
||||
Environment Variables:
|
||||
CAMOUFOX_USERNAME Default username/email for authentication
|
||||
CAMOUFOX_PASSWORD Default password for authentication
|
||||
|
||||
Examples:
|
||||
# Interactive login (no credentials, opens browser)
|
||||
npx tsx auth.ts --url "https://example.com/login"
|
||||
|
||||
# Form login with credentials
|
||||
npx tsx auth.ts --url "https://example.com/login" --type form \\
|
||||
--username "user@example.com" --password "secret"
|
||||
|
||||
# Microsoft SSO login
|
||||
CAMOUFOX_USERNAME=user@company.com CAMOUFOX_PASSWORD=secret \\
|
||||
npx tsx auth.ts --url "https://internal.company.com" --type msal
|
||||
|
||||
Notes:
|
||||
- Session is saved to ~/.camoufox-profile/ for persistence
|
||||
- After successful auth, subsequent browses will be authenticated
|
||||
- Use --headless false if you need to handle MFA manually
|
||||
`);
|
||||
process.exit(args.help ? 0 : 1);
|
||||
}
|
||||
|
||||
const authType = args.type as AuthType;
|
||||
if (!['auto', 'form', 'msal'].includes(authType)) {
|
||||
console.error(`Invalid auth type: ${authType}. Must be auto, form, or msal.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authenticate({
|
||||
url: args.url,
|
||||
authType,
|
||||
credentials:
|
||||
args.username && args.password
|
||||
? { username: args.username, password: args.password }
|
||||
: undefined,
|
||||
headless: args.headless,
|
||||
});
|
||||
|
||||
console.log(`\nAuthentication result:`);
|
||||
console.log(` Success: ${result.success}`);
|
||||
console.log(` Auth type: ${result.authType}`);
|
||||
console.log(` Final URL: ${result.finalUrl}`);
|
||||
console.log(` Message: ${result.message}`);
|
||||
|
||||
process.exit(result.success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error('Error:', error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if executed directly
|
||||
const isMainModule = process.argv[1]?.includes('auth.ts');
|
||||
if (isMainModule) {
|
||||
main();
|
||||
}
|
||||
Reference in New Issue
Block a user