Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
node_modules/
.env
.env.*
workspaces/
credentials/
configs/
dist/
repos/
.turbo/
audit-logs/
177 changes: 177 additions & 0 deletions apps/cli/src/auth/pre-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* Interactive pre-authentication via Playwright.
*
* Opens a headed (visible) Chromium browser, navigates to the login URL,
* and waits for the user to complete authentication (e.g., Google OAuth + 2FA).
* Once the success condition is met, captures the browser's storage state
* (cookies + localStorage) and writes it to auth-state.json.
*
* Playwright is NOT a bundled dependency — it must be installed on the host:
* npm install -g playwright && npx playwright install chromium
*/

import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';

export interface PreAuthOptions {
loginUrl: string;
successType: string;
successValue: string;
outputPath: string;
}

interface PlaywrightBrowser {
newContext(): Promise<PlaywrightContext>;
close(): Promise<void>;
}

interface PlaywrightContext {
newPage(): Promise<PlaywrightPage>;
storageState(): Promise<unknown>;
close(): Promise<void>;
}

interface PlaywrightPage {
goto(url: string, opts?: { waitUntil?: string }): Promise<void>;
url(): string;
textContent(selector: string): Promise<string | null>;
waitForSelector(selector: string, opts?: { timeout?: number }): Promise<unknown>;
waitForTimeout(ms: number): Promise<void>;
}

interface PlaywrightChromium {
launch(opts: { headless: boolean }): Promise<PlaywrightBrowser>;
}

function resolvePlaywrightPath(): string {
// Try local node_modules first, then global
const localPath = path.resolve('node_modules', 'playwright');
if (fs.existsSync(localPath)) return localPath;

try {
const globalRoot = execFileSync('npm', ['root', '-g'], { encoding: 'utf-8' }).trim();
const globalPath = path.join(globalRoot, 'playwright');
if (fs.existsSync(globalPath)) return globalPath;
} catch {
// npm not available or failed
}

return 'playwright'; // Fallback to bare specifier
}

async function loadPlaywright(): Promise<PlaywrightChromium> {
try {
// Use createRequire to resolve playwright from local or global node_modules.
// ESM dynamic import can't resolve bare directory paths, but require.resolve can.
const resolved = resolvePlaywrightPath();
const require = createRequire(import.meta.url);
const pw = require(resolved) as { chromium: PlaywrightChromium };
if (!pw.chromium) throw new Error('chromium not found in playwright module');
return pw.chromium;
} catch {
console.error('\nERROR: Playwright is required for interactive authentication.');
console.error('Install it with:\n');
console.error(' npm install -g playwright');
console.error(' npx playwright install chromium\n');
process.exit(1);
}
}

function checkSuccessCondition(page: PlaywrightPage, successType: string, successValue: string): boolean {
switch (successType) {
case 'url_contains':
return page.url().includes(successValue);
case 'url_equals_exactly':
return page.url() === successValue;
default:
// element_present and text_contains are checked asynchronously below
return false;
}
}

async function checkAsyncSuccessCondition(
page: PlaywrightPage,
successType: string,
successValue: string,
): Promise<boolean> {
try {
switch (successType) {
case 'element_present':
await page.waitForSelector(successValue, { timeout: 500 });
return true;
case 'text_contains': {
const text = await page.textContent('body');
return text ? text.includes(successValue) : false;
}
default:
return false;
}
} catch {
return false;
}
}

const POLL_INTERVAL_MS = 2000;
const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes

export async function runPreAuth(opts: PreAuthOptions): Promise<void> {
const chromium = await loadPlaywright();

console.log('\nOpening browser for interactive login...');
console.log(` Login URL: ${opts.loginUrl}`);
console.log(` Success: ${opts.successType} = "${opts.successValue}"`);
console.log('\nComplete the login in the browser window.');
console.log('Shannon will detect when you are done and continue automatically.\n');

const browser = await chromium.launch({ headless: false });
const context = await browser.newContext();
const page = await context.newPage();

try {
await page.goto(opts.loginUrl, { waitUntil: 'domcontentloaded' });

// Poll for success condition
const deadline = Date.now() + TIMEOUT_MS;
let authenticated = false;

while (Date.now() < deadline) {
// Check URL-based conditions synchronously
if (checkSuccessCondition(page, opts.successType, opts.successValue)) {
authenticated = true;
break;
}

// Check DOM-based conditions asynchronously
if (opts.successType === 'element_present' || opts.successType === 'text_contains') {
if (await checkAsyncSuccessCondition(page, opts.successType, opts.successValue)) {
authenticated = true;
break;
}
}

await page.waitForTimeout(POLL_INTERVAL_MS);
}

if (!authenticated) {
console.error('\nERROR: Login timed out after 5 minutes.');
console.error('The success condition was not met. Please try again.');
process.exit(1);
}

// Capture storage state (cookies including HttpOnly + localStorage)
const storageState = await context.storageState();

// Write to output path
const outputDir = path.dirname(opts.outputPath);
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(opts.outputPath, JSON.stringify(storageState, null, 2), 'utf-8');

console.log('\nAuthentication successful!');
console.log(`Session state saved to: ${opts.outputPath}`);
} finally {
await context.close();
await browser.close();
}
}
160 changes: 160 additions & 0 deletions apps/cli/src/commands/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* `shannon auth` command — interactive pre-authentication.
*
* Opens a visible browser for the user to complete OAuth/SSO login (e.g.,
* Google Sign-In with 2FA). Captures the authenticated session state and
* saves it so `shannon start` can distribute it to agents.
*
* Requires: login_type "interactive" in the YAML config.
* Requires: Playwright installed on the host (npm install -g playwright).
*/

import fs from 'node:fs';
import path from 'node:path';
import { runPreAuth } from '../auth/pre-auth.js';
import { getWorkspacesDir, initHome } from '../home.js';

export interface AuthArgs {
config: string;
workspace?: string;
}

interface AuthenticationBlock {
login_type?: string;
login_url?: string;
success_condition?: {
type?: string;
value?: string;
};
}

/**
* Minimal YAML parser for extracting the authentication block.
* Avoids adding js-yaml as a dependency — only needs login_url,
* success_condition.type, and success_condition.value.
*/
function parseAuthFromYaml(content: string): AuthenticationBlock | null {
const lines = content.split('\n');
const auth: AuthenticationBlock = {};
let inAuth = false;
let inSuccessCondition = false;

for (const rawLine of lines) {
const line = rawLine.trimEnd();
const stripped = line.replace(/#.*$/, '').trimEnd();
if (!stripped) continue;

const indent = line.search(/\S/);

// Top-level key
if (indent === 0) {
inAuth = stripped.startsWith('authentication:');
inSuccessCondition = false;
continue;
}

if (!inAuth) continue;

// Authentication-level keys (indent 2)
if (indent === 2 && stripped.includes('login_type:')) {
auth.login_type = extractYamlValue(stripped);
} else if (indent === 2 && stripped.includes('login_url:')) {
auth.login_url = extractYamlValue(stripped);
} else if (indent === 2 && stripped.includes('success_condition:')) {
inSuccessCondition = true;
auth.success_condition = {};
} else if (indent === 2) {
inSuccessCondition = false;
}

// Success condition keys (indent 4)
if (inSuccessCondition && indent === 4) {
if (stripped.includes('type:')) {
auth.success_condition!.type = extractYamlValue(stripped);
} else if (stripped.includes('value:')) {
auth.success_condition!.value = extractYamlValue(stripped);
}
}
}

return auth.login_type ? auth : null;
}

function extractYamlValue(line: string): string {
const colonIdx = line.indexOf(':');
if (colonIdx === -1) return '';
const raw = line.slice(colonIdx + 1).trim();
// Strip surrounding quotes
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
return raw.slice(1, -1);
}
return raw;
}

export async function auth(args: AuthArgs): Promise<void> {
initHome();

// 1. Read and parse the config file
const configPath = path.resolve(args.config);
if (!fs.existsSync(configPath)) {
console.error(`ERROR: Config file not found: ${configPath}`);
process.exit(1);
}

const configContent = fs.readFileSync(configPath, 'utf-8');
const authBlock = parseAuthFromYaml(configContent);

if (!authBlock) {
console.error('ERROR: No authentication section found in config file.');
process.exit(1);
}

if (authBlock.login_type !== 'interactive') {
console.error(`ERROR: login_type must be "interactive" for the auth command (got: "${authBlock.login_type}").`);
console.error('The auth command is only for interactive pre-authentication (OAuth, SSO, etc.).');
process.exit(1);
}

if (!authBlock.login_url) {
console.error('ERROR: authentication.login_url is required.');
process.exit(1);
}

if (!authBlock.success_condition?.type || !authBlock.success_condition?.value) {
console.error('ERROR: authentication.success_condition (type + value) is required.');
process.exit(1);
}

// 2. Resolve workspace name
let workspaceName: string;
if (args.workspace) {
workspaceName = args.workspace;
} else {
try {
const hostname = new URL(authBlock.login_url).hostname.replace(/[^a-zA-Z0-9-]/g, '-');
workspaceName = `${hostname}_shannon-${Date.now()}`;
} catch {
console.error(`ERROR: Invalid login_url: ${authBlock.login_url}`);
process.exit(1);
}
}

// 3. Run pre-auth
const workspacesDir = getWorkspacesDir();
const workspaceDir = path.join(workspacesDir, workspaceName);
fs.mkdirSync(workspaceDir, { recursive: true });

const authStatePath = path.join(workspaceDir, 'auth-state.json');

await runPreAuth({
loginUrl: authBlock.login_url,
successType: authBlock.success_condition.type,
successValue: authBlock.success_condition.value,
outputPath: authStatePath,
});

// 4. Show next steps
const prefix = process.env.SHANNON_LOCAL === '1' ? './shannon' : 'npx @keygraph/shannon';
console.log(`\nNext step — start the scan with the same workspace:\n`);
console.log(` ${prefix} start -u <target-url> -r <repo> -c ${args.config} -w ${workspaceName}\n`);
}
12 changes: 9 additions & 3 deletions apps/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,16 @@ export async function start(args: StartArgs): Promise<void> {
// 11. Resolve prompts directory (local mode only)
const promptsDir = isLocal() ? path.resolve('apps/worker/prompts') : undefined;

// 12. Display splash screen
// 12. Check for pre-authenticated session
const authStatePath = path.join(workspacesDir, workspace, 'auth-state.json');
if (fs.existsSync(authStatePath)) {
console.log('Using pre-authenticated session from auth-state.json');
}

// 13. Display splash screen
displaySplash(isLocal() ? undefined : args.version);

// 13. Spawn worker container
// 14. Spawn worker container
const proc = spawnWorker({
version: args.version,
url: args.url,
Expand All @@ -105,7 +111,7 @@ export async function start(args: StartArgs): Promise<void> {
...(args.pipelineTesting && { pipelineTesting: true }),
});

// 14. Wait for workflow to register, then display info
// 15. Wait for workflow to register, then display info
proc.on('error', (err) => {
console.error(`Failed to start worker: ${err.message}`);
process.exit(1);
Expand Down
Loading