576 lines
16 KiB
TypeScript
576 lines
16 KiB
TypeScript
#!/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();
|
|
}
|