| name | auth-generic |
| description | 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. |
Generic Authentication
Authenticate with any form-based authentication system using configurable selectors. This is the fallback skill when no provider-specific skill exists.
Prerequisites
-
Project configuration in docs/project.json:
{
"authentication": {
"method": "email-password",
"provider": "custom",
"testUser": {
"mode": "fixed",
"emailVar": "TEST_EMAIL",
"emailDefault": "test@example.com",
"passwordVar": "TEST_PASSWORD"
},
"routes": {
"login": "/login",
"authenticated": "/dashboard"
},
"selectors": {
"emailInput": "input[name='email']",
"passwordInput": "input[name='password']",
"submitButton": "button[type='submit']"
}
}
}
-
Environment variables in .env.local:
TEST_EMAIL=test@example.com
TEST_PASSWORD=your-test-password
-
Test user exists in your authentication system
The Job
- Read authentication config from
project.json
- Navigate to login page
- Fill form fields using configured selectors
- Submit the form
- Wait for redirect to authenticated page
- 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",
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?: ;
};
: ;
?: {
: ;
?: ;
?: ;
?: ;
?: ;
};
?: [];
}
{
: | | | | ;
?: ;
?: ;
?: ;
?: ;
?: ;
}
(): {
projectJsonPath = path.(projectRoot, , );
projectJson = .(fs.(projectJsonPath, ));
(!projectJson.) {
();
}
config = projectJson.;
config. = config. || {};
config;
}
(): {
envPath = path.(projectRoot, );
(fs.(envPath)) {
content = fs.(envPath, );
content.().( {
match = line.().();
(match && !process.[match[]]) {
process.[match[]] = match[];
}
});
}
}
(): { : ; ?: ; ?: } {
: ;
: | ;
: | ;
(config.. === ) {
uuid = crypto.().(, );
pattern = config.. || ;
email = pattern.(, uuid);
password = process.[config.. || ]
|| config..;
} {
emailVar = config.. || ;
email = process.[emailVar] || config.. || ;
(config. === ) {
passwordVar = config.. || ;
password = process.[passwordVar] || config..;
(!password) {
();
}
}
(config..) {
username = process.[config..];
}
}
{ 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 {
await page.goto(`${baseUrl}${config.routes.login}`);
switch (config.method) {
case 'email-password':
await handleEmailPassword(page, config, credentials);
break;
case 'passwordless-otp':
await handlePasswordlessOtp(page, config, credentials, baseUrl);
break;
case :
(page, config, credentials, baseUrl);
;
:
(config.) {
(page, config., credentials);
} {
();
}
}
page.( (config..), { : });
.();
{ : , : credentials. };
} (error) {
message = error ? error. : (error);
.();
{ : , : credentials., : message };
}
}
(): <> {
emailSelector = (config, );
passwordSelector = (config, );
submitSelector = (config, );
page.(emailSelector);
(credentials. && config..) {
page.(config.., credentials.);
} {
page.(emailSelector, credentials.);
}
page.(passwordSelector, credentials.!);
page.(submitSelector);
}
(): <> {
emailSelector = (config, );
submitEmailSelector = config.. || (config, );
page.(emailSelector);
page.(emailSelector, credentials.);
page.(submitEmailSelector);
(config..) {
page.( (config..));
}
page.();
otp = (config, credentials., baseUrl);
otpSelector = (config, );
otpInputs = page.(otpSelector);
inputCount = otpInputs.();
(inputCount >= ) {
( i = ; i < .(otp., inputCount); i++) {
otpInputs.(i).(otp[i]);
}
} {
otpInputs.().(otp);
}
submitOtpSelector = config.. || (config, );
page.(submitOtpSelector);
}
(): <> {
emailSelector = (config, );
submitSelector = (config, );
page.(emailSelector);
page.(emailSelector, credentials.);
page.(submitSelector);
page.();
token = (config, credentials.);
magicLinkUrl = config..!.(, token);
page.();
}
(): <> {
(!config.) {
();
}
(config.. === ) {
body = config..?.(, email) || .({ email });
response = (, {
: config.. || ,
: { : },
body
});
data = response.();
data. || data. || data.;
}
();
}
(): <> {
();
}
(): <> {
( step steps) {
value = step.
?.(, credentials.)
?.(, credentials. || );
(step.) {
:
page.(step.!, value!);
;
:
page.(step.!);
;
:
(step.) {
page.(step., { : step. || });
} (step.) {
page.( (step.), { : step. || });
} {
page.(step. || );
}
;
:
page.(step.!);
;
}
}
}
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';
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();
}
.();
} {
browser.();
}
}
().(.);
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}}" },
{
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
| Scenario | Recommendation |
|---|
| Standard Supabase setup | Use auth-supabase-otp or auth-supabase-password |
| Standard NextAuth setup | Use auth-nextauth-credentials |
| Custom auth with standard form | Use auth-generic with selectors |
| Complex multi-step auth | Use auth-generic with custom steps |
| OAuth flows | Defer 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:
- Check
project.json for authentication config
- Try provider-specific skill first (
auth-supabase-*, auth-nextauth-*)
- Fall back to
auth-generic if provider is custom or unrecognized
- Call
authenticateGeneric() before accessing protected pages