Skip to content
auth-generic logo

Generic Authentication

auth-generic

Generic authentication skill for any custom auth system. Use when project.json has authentication.provider: custom or when no specific auth skill exists. Triggers on: custom auth, generic login, form-based auth, custom login flow.

SKILL.md

Full skill instructions

Generic Authentication

Authenticate with any form-based authentication system using configurable selectors. This is the fallback skill when no provider-specific skill exists.


Prerequisites

  1. Project configuration in docs/​project.json:

    {
      "authentication": {
        "method": "email-password",
        "provider": "custom",
        "testUser": {
          "mode": "fixed",
          "emailVar": "TEST_EMAIL",
          "emailDefault": "[email protected]",
          "passwordVar": "TEST_PASSWORD"
        },
        "routes": {
          "login": "/​login",
          "authenticated": "/​dashboard"
        },
        "selectors": {
          "emailInput": "input[name='email']",
          "passwordInput": "input[name='password']",
          "submitButton": "button[type='submit']"
        }
      }
    }
    
  2. Environment variables in .env.local:

    [email protected]
    TEST_PASSWORD=your-test-password
    
  3. Test user exists in your authentication system


The Job

  1. Read authentication config from project.json
  2. Navigate to login page
  3. Fill form fields using configured selectors
  4. Submit the form
  5. Wait for redirect to authenticated page
  6. Return authenticated page context

Supported Authentication Methods

Email/​Password

Standard username/​password login:

{
  "authentication": {
    "method": "email-password",
    "selectors": {
      "emailInput": "input[type='email'], input[name='email'], input#email",
      "passwordInput": "input[type='password'], input[name='password']",
      "submitButton": "button[type='submit']"
    }
  }
}

Passwordless OTP

Two-step OTP verification:

{
  "authentication": {
    "method": "passwordless-otp",
    "selectors": {
      "emailInput": "input[type='email']",
      "submitEmailButton": "button:has-text('Send code')",
      "otpInputs": "input.otp-digit",
      "submitOtpButton": "button:has-text('Verify')"
    },
    "verification": {
      "source": "api",
      "endpoint": "/​api/​test/​get-otp",
      "method": "POST",
      "bodyTemplate": "{\"email\": \"{{email}}\"}"
    },
    "routes": {
      "login": "/​login",
      "verify": "/​verify",
      "authenticated": "/​dashboard"
    }
  }
}

Magic Link (with test override)

For magic link flows, configure a test bypass:

{
  "authentication": {
    "method": "magic-link",
    "selectors": {
      "emailInput": "input[type='email']",
      "submitButton": "button:has-text('Send link')"
    },
    "verification": {
      "source": "database",
      "query": "SELECT token FROM magic_links WHERE email = ? ORDER BY created_at DESC LIMIT 1"
    },
    "routes": {
      "login": "/​login",
      "magicLinkPattern": "/​auth/​verify?token={{token}}",
      "authenticated": "/​dashboard"
    }
  }
}

Authentication Flow

Step 1: Load Configuration

import * as fs from 'fs';
import * as path from 'path';

interface Selectors {
  emailInput?: string;
  passwordInput?: string;
  submitButton?: string;
  submitEmailButton?: string;
  otpInputs?: string;
  submitOtpButton?: string;
  usernameInput?: string;
}

interface AuthConfig {
  method: string;
  provider: string;
  testUser: {
    mode: 'fixed' | 'dynamic';
    emailVar?: string;
    emailDefault?: string;
    emailPattern?: string;
    passwordVar?: string;
    passwordDefault?: string;
    usernameVar?: string;
  };
  routes: {
    login: string;
    verify?: string;
    authenticated: string;
    magicLinkPattern?: string;
  };
  selectors: Selectors;
  verification?: {
    source: string;
    endpoint?: string;
    method?: string;
    bodyTemplate?: string;
    query?: string;
  };
  steps?: AuthStep[];
}

interface AuthStep {
  action: 'fill' | 'click' | 'wait' | 'navigate' | 'custom';
  selector?: string;
  value?: string;
  waitFor?: string;
  url?: string;
  timeout?: number;
}

function loadAuthConfig(projectRoot: string): AuthConfig {
  const projectJsonPath = path.join(projectRoot, 'docs', 'project.json');
  const projectJson = JSON.parse(fs.readFileSync(projectJsonPath, 'utf-8'));
  
  if (!projectJson.authentication) {
    throw new Error('No authentication config found in project.json. Run /​setup-auth first.');
  }
  
  // Ensure selectors exist with defaults
  const config = projectJson.authentication;
  config.selectors = config.selectors || {};
  
  return config;
}

function loadEnv(projectRoot: string): void {
  const envPath = path.join(projectRoot, '.env.local');
  if (fs.existsSync(envPath)) {
    const content = fs.readFileSync(envPath, 'utf-8');
    content.split('\n').forEach(line => {
      const match = line.trim().match(/^([^=]+)=(.*)$/);
      if (match && !process.env[match[1]]) {
        process.env[match[1]] = match[2];
      }
    });
  }
}

function getTestCredentials(config: AuthConfig): { email: string; password?: string; username?: string } {
  let email: string;
  let password: string | undefined;
  let username: string | undefined;
  
  if (config.testUser.mode === 'dynamic') {
    const uuid = crypto.randomUUID().slice(0, 8);
    const pattern = config.testUser.emailPattern || 'test-{uuid}@example.com';
    email = pattern.replace('{uuid}', uuid);
    password = process.env[config.testUser.passwordVar || 'TEST_PASSWORD'] 
      || config.testUser.passwordDefault;
  } else {
    const emailVar = config.testUser.emailVar || 'TEST_EMAIL';
    email = process.env[emailVar] || config.testUser.emailDefault || '[email protected]';
    
    if (config.method === 'email-password') {
      const passwordVar = config.testUser.passwordVar || 'TEST_PASSWORD';
      password = process.env[passwordVar] || config.testUser.passwordDefault;
      
      if (!password) {
        throw new Error(`Password not configured. Set ${passwordVar} in .env.local`);
      }
    }
    
    if (config.testUser.usernameVar) {
      username = process.env[config.testUser.usernameVar];
    }
  }
  
  return { email, password, username };
}

Step 2: Default Selectors

const DEFAULT_SELECTORS: Selectors = {
  emailInput: 'input[type="email"], input[name="email"], input#email, input[autocomplete="email"]',
  passwordInput: 'input[type="password"], input[name="password"], input#password',
  submitButton: 'button[type="submit"], button:has-text("Sign in"), button:has-text("Log in"), button:has-text("Continue")',
  otpInputs: 'input[maxlength="1"], input.otp-input, input[inputmode="numeric"]',
  usernameInput: 'input[name="username"], input#username, input[autocomplete="username"]'
};

function getSelector(config: AuthConfig, key: keyof Selectors): string {
  return config.selectors[key] || DEFAULT_SELECTORS[key] || '';
}

Step 3: Authenticate with Playwright

import { Page } from 'playwright';

interface AuthResult {
  success: boolean;
  email: string;
  error?: string;
}

async function authenticateGeneric(
  page: Page,
  baseUrl: string,
  projectRoot: string
): Promise<AuthResult> {
  loadEnv(projectRoot);
  const config = loadAuthConfig(projectRoot);
  const credentials = getTestCredentials(config);
  
  try {
    // Navigate to login
    await page.goto(`${baseUrl}${config.routes.login}`);
    
    // Handle based on method
    switch (config.method) {
      case 'email-password':
        await handleEmailPassword(page, config, credentials);
        break;
      case 'passwordless-otp':
        await handlePasswordlessOtp(page, config, credentials, baseUrl);
        break;
      case 'magic-link':
        await handleMagicLink(page, config, credentials, baseUrl);
        break;
      default:
        // If custom steps are defined, execute them
        if (config.steps) {
          await executeCustomSteps(page, config.steps, credentials);
        } else {
          throw new Error(`Unknown auth method: ${config.method}. Define custom steps.`);
        }
    }
    
    // Wait for authenticated page
    await page.waitForURL(new RegExp(config.routes.authenticated), { timeout: 15000 });
    console.log(`Authentication successful, landed on ${config.routes.authenticated}`);
    
    return { success: true, email: credentials.email };
    
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    console.error(`Authentication failed: ${message}`);
    return { success: false, email: credentials.email, error: message };
  }
}

async function handleEmailPassword(
  page: Page,
  config: AuthConfig,
  credentials: { email: string; password?: string; username?: string }
): Promise<void> {
  const emailSelector = getSelector(config, 'emailInput');
  const passwordSelector = getSelector(config, 'passwordInput');
  const submitSelector = getSelector(config, 'submitButton');
  
  await page.waitForSelector(emailSelector);
  
  // Fill username if configured, otherwise email
  if (credentials.username && config.selectors.usernameInput) {
    await page.fill(config.selectors.usernameInput, credentials.username);
  } else {
    await page.fill(emailSelector, credentials.email);
  }
  
  await page.fill(passwordSelector, credentials.password!);
  await page.click(submitSelector);
}

async function handlePasswordlessOtp(
  page: Page,
  config: AuthConfig,
  credentials: { email: string },
  baseUrl: string
): Promise<void> {
  const emailSelector = getSelector(config, 'emailInput');
  const submitEmailSelector = config.selectors.submitEmailButton || getSelector(config, 'submitButton');
  
  // Step 1: Enter email
  await page.waitForSelector(emailSelector);
  await page.fill(emailSelector, credentials.email);
  await page.click(submitEmailSelector);
  
  // Step 2: Wait for verify page
  if (config.routes.verify) {
    await page.waitForURL(new RegExp(config.routes.verify));
  }
  await page.waitForTimeout(1000); // Allow OTP to be generated
  
  // Step 3: Get OTP code
  const otp = await fetchOtpCode(config, credentials.email, baseUrl);
  
  // Step 4: Enter OTP
  const otpSelector = getSelector(config, 'otpInputs');
  const otpInputs = page.locator(otpSelector);
  const inputCount = await otpInputs.count();
  
  if (inputCount >= 6) {
    // Multiple single-digit inputs
    for (let i = 0; i < Math.min(otp.length, inputCount); i++) {
      await otpInputs.nth(i).fill(otp[i]);
    }
  } else {
    // Single input for full code
    await otpInputs.first().fill(otp);
  }
  
  // Step 5: Submit OTP
  const submitOtpSelector = config.selectors.submitOtpButton || getSelector(config, 'submitButton');
  await page.click(submitOtpSelector);
}

async function handleMagicLink(
  page: Page,
  config: AuthConfig,
  credentials: { email: string },
  baseUrl: string
): Promise<void> {
  const emailSelector = getSelector(config, 'emailInput');
  const submitSelector = getSelector(config, 'submitButton');
  
  // Enter email
  await page.waitForSelector(emailSelector);
  await page.fill(emailSelector, credentials.email);
  await page.click(submitSelector);
  
  // Wait for magic link to be created
  await page.waitForTimeout(1000);
  
  // Fetch token
  const token = await fetchMagicLinkToken(config, credentials.email);
  
  // Navigate directly to magic link URL
  const magicLinkUrl = config.routes.magicLinkPattern!.replace('{{token}}', token);
  await page.goto(`${baseUrl}${magicLinkUrl}`);
}

async function fetchOtpCode(
  config: AuthConfig,
  email: string,
  baseUrl: string
): Promise<string> {
  if (!config.verification) {
    throw new Error('OTP verification config required for passwordless-otp method');
  }
  
  if (config.verification.source === 'api') {
    const body = config.verification.bodyTemplate?.replace('{{email}}', email) || JSON.stringify({ email });
    const response = await fetch(`${baseUrl}${config.verification.endpoint}`, {
      method: config.verification.method || 'POST',
      headers: { 'Content-Type': 'application/​json' },
      body
    });
    const data = await response.json();
    return data.code || data.otp || data.token;
  }
  
  throw new Error(`OTP source "${config.verification.source}" not implemented in generic skill`);
}

async function fetchMagicLinkToken(config: AuthConfig, email: string): Promise<string> {
  // This would need project-specific implementation
  // Generic skill can call an API endpoint if configured
  throw new Error('Magic link token retrieval requires project-specific implementation');
}

async function executeCustomSteps(
  page: Page,
  steps: AuthStep[],
  credentials: { email: string; password?: string }
): Promise<void> {
  for (const step of steps) {
    const value = step.value
      ?.replace('{{email}}', credentials.email)
      ?.replace('{{password}}', credentials.password || '');
    
    switch (step.action) {
      case 'fill':
        await page.fill(step.selector!, value!);
        break;
      case 'click':
        await page.click(step.selector!);
        break;
      case 'wait':
        if (step.selector) {
          await page.waitForSelector(step.selector, { timeout: step.timeout || 5000 });
        } else if (step.url) {
          await page.waitForURL(new RegExp(step.url), { timeout: step.timeout || 10000 });
        } else {
          await page.waitForTimeout(step.timeout || 1000);
        }
        break;
      case 'navigate':
        await page.goto(step.url!);
        break;
    }
  }
}

Complete Script Template

import { chromium } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';

const PROJECT_ROOT = process.cwd();
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000';

// (Include helper functions from above)

async function main() {
  const browser = await chromium.launch();
  const context = await browser.newContext({
    viewport: { width: 1920, height: 1080 }
  });
  const page = await context.newPage();
  
  try {
    const result = await authenticateGeneric(page, BASE_URL, PROJECT_ROOT);
    
    if (!result.success) {
      console.error('Authentication failed:', result.error);
      process.exit(1);
    }
    
    console.log(`Logged in as ${result.email}`);
    
  } finally {
    await browser.close();
  }
}

main().catch(console.error);

Custom Steps Example

For complex multi-step authentication flows, define custom steps:

{
  "authentication": {
    "method": "custom",
    "provider": "custom",
    "testUser": {
      "mode": "fixed",
      "emailVar": "TEST_EMAIL",
      "passwordVar": "TEST_PASSWORD"
    },
    "routes": {
      "login": "/​login",
      "authenticated": "/​app"
    },
    "steps": [
      { "action": "fill", "selector": "input#username", "value": "{{email}}" },
      { "action": "click", "selector": "button:has-text('Next')" },
      { "action": "wait", "selector": "input#password" },
      { "action": "fill", "selector": "input#password", "value": "{{password}}" },
      { "action": "click", "selector": "input#remember-me" },
      { "action": "click", "selector": "button:has-text('Sign in')" }
    ]
  }
}

Selector Best Practices

Prefer Stable Selectors

{
  "selectors": {
    "emailInput": "input[data-testid='email-input']",
    "passwordInput": "input[data-testid='password-input']",
    "submitButton": "button[data-testid='submit-btn']"
  }
}

Fallback Chains

The skill tries selectors in order. Configure multiple fallbacks:

{
  "selectors": {
    "emailInput": "input[data-testid='email'], input[name='email'], input#email"
  }
}

Avoid Fragile Selectors

  • ❌ .login-form > div:nth-child(2) > input
  • ❌ body > main > form > button
  • ✅ input[name='email']
  • ✅ button[type='submit']
  • ✅ [data-testid='login-button']

Troubleshooting

"Selector not found"

  • Use browser DevTools to verify the selector
  • Check if the element is inside an iframe
  • Wait for dynamic content to load

"Timeout waiting for authenticated page"

  • Verify routes.authenticated matches the actual redirect URL
  • Check for JavaScript errors in browser console
  • Increase timeout if the server is slow

"Unknown auth method"

  • Set method to one of: email-password, passwordless-otp, magic-link
  • Or define custom steps for non-standard flows

Multi-step forms

Use the steps array for forms that span multiple pages or have conditional logic.


When to Use Generic vs Provider-Specific Skills

ScenarioRecommendation
Standard Supabase setupUse auth-supabase-otp or auth-supabase-password
Standard NextAuth setupUse auth-nextauth-credentials
Custom auth with standard formUse auth-generic with selectors
Complex multi-step authUse auth-generic with custom steps
OAuth flowsDefer to provider-specific skill (future)

Integration with Other Skills

This skill is the fallback used by:

  • screenshot - when no provider-specific skill matches
  • e2e-playwright - for custom auth systems
  • qa-browser-tester - for any form-based auth

Agents should:

  1. Check project.json for authentication config
  2. Try provider-specific skill first (auth-supabase-*, auth-nextauth-*)
  3. Fall back to auth-generic if provider is custom or unrecognized
  4. Call authenticateGeneric() before accessing protected pages