Replace next-auth with Better Auth, migrate DB columns to camelCase
- Replace next-auth v5 beta with better-auth v1.6.2 (stable releases)
- Add multi-provider OAuth support with admin UI configuration
- New oauthProviders table with encrypted secrets (AES-256-GCM)
- Env var bootstrap (OAUTH_*) syncs to DB, UI-created providers fully editable
- OAuth provider REST API: GET/POST/PUT/DELETE /api/v1/oauth-providers
- Settings page "Authentication Providers" section for admin management
- Account linking uses new accounts table (multi-provider per user)
- Username plugin for credentials sign-in (replaces email@localhost pattern)
- bcrypt password compatibility (existing hashes work)
- Database-backed sessions via Kysely adapter (bun:sqlite direct)
- Configurable rate limiting via AUTH_RATE_LIMIT_* env vars
- All DB columns migrated from snake_case to camelCase
- All TypeScript types/models migrated to camelCase properties
- Removed casing: "snake_case" from Drizzle config
- Callback URL format: {baseUrl}/api/auth/oauth2/callback/{providerId}
- package-lock.json removed and gitignored (using bun.lock)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -54,17 +54,30 @@ async function apiGet(page: Page, path: string) {
|
||||
return page.request.get(`${API}${path}`);
|
||||
}
|
||||
|
||||
/** Log into Dex with email/password. Handles the Dex login form. */
|
||||
/** Log into Dex with email/password. Handles the Dex login form.
|
||||
* If Dex has an existing session and auto-redirects, this is a no-op. */
|
||||
async function dexLogin(page: Page, email: string, password: string) {
|
||||
// Wait for either Dex login form OR auto-redirect back to our app.
|
||||
// Dex may auto-redirect if it has an active session from a prior login.
|
||||
try {
|
||||
await page.waitForURL((url) => url.toString().includes('localhost:5556'), { timeout: 15_000 });
|
||||
} catch {
|
||||
// Already redirected back — no Dex login needed (Dex has existing session)
|
||||
return;
|
||||
}
|
||||
|
||||
// Dex shows a "Log in to dex" page with a link to the local (password) connector
|
||||
// or goes straight to the login form
|
||||
const loginLink = page.getByRole('link', { name: /log in with email/i });
|
||||
if (await loginLink.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await loginLink.click();
|
||||
}
|
||||
|
||||
// If Dex auto-redirected during the wait above, skip the form
|
||||
if (!page.url().includes('localhost:5556')) return;
|
||||
|
||||
// Wait for the Dex login form to appear
|
||||
await expect(page.getByRole('button', { name: /login/i })).toBeVisible({ timeout: 10_000 });
|
||||
// Dex uses "email address" and "Password" as accessible names
|
||||
await page.getByRole('textbox', { name: /email/i }).fill(email);
|
||||
await page.getByRole('textbox', { name: /password/i }).fill(password);
|
||||
await page.getByRole('button', { name: /login/i }).click();
|
||||
@@ -75,6 +88,46 @@ async function freshContext(page: Page): Promise<BrowserContext> {
|
||||
return page.context().browser()!.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform an OAuth login through the /login page and verify the user was created.
|
||||
* Uses a fresh browser context to avoid session conflicts between users.
|
||||
* Retries once on failure (Better Auth OAuth state can race between rapid logins).
|
||||
*/
|
||||
async function doOAuthLogin(page: Page, user: { email: string; password: string }) {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const ctx = await freshContext(page);
|
||||
const p = await ctx.newPage();
|
||||
try {
|
||||
await p.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle' });
|
||||
console.log(`[doOAuthLogin] ${user.email} on: ${p.url()}`);
|
||||
const oauthButton = p.getByRole('button', { name: /continue with|sign in with/i });
|
||||
await expect(oauthButton).toBeVisible({ timeout: 10_000 });
|
||||
await oauthButton.click();
|
||||
// Wait for navigation to Dex
|
||||
await p.waitForURL((url) => url.toString().includes('localhost:5556'), { timeout: 15_000 });
|
||||
console.log(`[doOAuthLogin] ${user.email} after nav: ${p.url()}`);
|
||||
await dexLogin(p, user.email, user.password);
|
||||
// Wait for redirect back to the app
|
||||
await p.waitForURL((url) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.origin === BASE_URL && !u.pathname.startsWith('/api/auth');
|
||||
} catch { return false; }
|
||||
}, { timeout: 30_000 });
|
||||
|
||||
// Verify the URL doesn't indicate an error
|
||||
const finalUrl = p.url();
|
||||
if (finalUrl.includes('error=') || finalUrl.includes('/login')) {
|
||||
if (attempt === 0) continue; // retry
|
||||
throw new Error(`OAuth login failed for ${user.email}: ${finalUrl}`);
|
||||
}
|
||||
return; // success
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform OAuth login on the portal and return the callback URL.
|
||||
* Does NOT navigate to the callback (test domains aren't DNS-resolvable).
|
||||
@@ -149,8 +202,8 @@ test.describe.serial('Forward Auth with OAuth (Dex)', () => {
|
||||
name: 'OAuth Forward Auth Test',
|
||||
domains: [DOMAIN],
|
||||
upstreams: ['echo-server:8080'],
|
||||
ssl_forced: false,
|
||||
cpm_forward_auth: { enabled: true },
|
||||
sslForced: false,
|
||||
cpmForwardAuth: { enabled: true },
|
||||
});
|
||||
expect(res.status()).toBe(201);
|
||||
const host = await res.json();
|
||||
@@ -159,43 +212,11 @@ test.describe.serial('Forward Auth with OAuth (Dex)', () => {
|
||||
});
|
||||
|
||||
test('setup: trigger OAuth login for alice to create her user account', async ({ page }) => {
|
||||
const ctx = await freshContext(page);
|
||||
const p = await ctx.newPage();
|
||||
try {
|
||||
await p.goto(`${BASE_URL}/login`);
|
||||
const oauthButton = p.getByRole('button', { name: /continue with|sign in with/i });
|
||||
await expect(oauthButton).toBeVisible({ timeout: 10_000 });
|
||||
await oauthButton.click();
|
||||
await dexLogin(p, ALICE.email, ALICE.password);
|
||||
await p.waitForURL((url) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.origin === BASE_URL && !u.pathname.startsWith('/api/auth');
|
||||
} catch { return false; }
|
||||
}, { timeout: 30_000 });
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
await doOAuthLogin(page, ALICE);
|
||||
});
|
||||
|
||||
test('setup: trigger OAuth login for bob to create his user account', async ({ page }) => {
|
||||
const ctx = await freshContext(page);
|
||||
const p = await ctx.newPage();
|
||||
try {
|
||||
await p.goto(`${BASE_URL}/login`);
|
||||
const oauthButton = p.getByRole('button', { name: /continue with|sign in with/i });
|
||||
await expect(oauthButton).toBeVisible({ timeout: 10_000 });
|
||||
await oauthButton.click();
|
||||
await dexLogin(p, BOB.email, BOB.password);
|
||||
await p.waitForURL((url) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.origin === BASE_URL && !u.pathname.startsWith('/api/auth');
|
||||
} catch { return false; }
|
||||
}, { timeout: 30_000 });
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
await doOAuthLogin(page, BOB);
|
||||
});
|
||||
|
||||
test('setup: find alice and bob user IDs', async ({ page }) => {
|
||||
|
||||
@@ -28,8 +28,8 @@ test.describe.serial('Forward Auth', () => {
|
||||
name: 'Functional Forward Auth Test',
|
||||
domains: [DOMAIN],
|
||||
upstreams: ['echo-server:8080'],
|
||||
ssl_forced: false,
|
||||
cpm_forward_auth: { enabled: true },
|
||||
sslForced: false,
|
||||
cpmForwardAuth: { enabled: true },
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL },
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ test.describe('L4 Proxy Hosts page', () => {
|
||||
await expect(sortBtn).toBeVisible();
|
||||
|
||||
await sortBtn.click();
|
||||
await expect(page).toHaveURL(/sortBy=listen_address/);
|
||||
await expect(page).toHaveURL(/sortBy=listenAddress/);
|
||||
});
|
||||
|
||||
test('creates a new L4 proxy host', async ({ page }) => {
|
||||
|
||||
@@ -58,13 +58,25 @@ function ensureTestUser(username: string, password: string, role: string) {
|
||||
const now = new Date().toISOString();
|
||||
const existing = db.query("SELECT id FROM users WHERE email = ?").get(email);
|
||||
if (existing) {
|
||||
db.run("UPDATE users SET password_hash = ?, role = ?, status = 'active', updated_at = ? WHERE email = ?",
|
||||
db.run("UPDATE users SET passwordHash = ?, role = ?, status = 'active', updatedAt = ? WHERE email = ?",
|
||||
[hash, "${role}", now, email]);
|
||||
// Update or create credential account for Better Auth
|
||||
const acc = db.query("SELECT id FROM accounts WHERE userId = ? AND providerId = 'credential'").get(existing.id);
|
||||
if (acc) {
|
||||
db.run("UPDATE accounts SET password = ?, updatedAt = ? WHERE id = ?", [hash, now, acc.id]);
|
||||
} else {
|
||||
db.run("INSERT INTO accounts (userId, accountId, providerId, password, createdAt, updatedAt) VALUES (?, ?, 'credential', ?, ?, ?)",
|
||||
[existing.id, String(existing.id), hash, now, now]);
|
||||
}
|
||||
} else {
|
||||
db.run(
|
||||
"INSERT INTO users (email, name, password_hash, role, provider, subject, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'credentials', ?, 'active', ?, ?)",
|
||||
[email, "${username}", hash, "${role}", "${username}", now, now]
|
||||
"INSERT INTO users (email, name, passwordHash, role, provider, subject, username, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, 'credentials', ?, ?, 'active', ?, ?)",
|
||||
[email, "${username}", hash, "${role}", "${username}", "${username}", now, now]
|
||||
);
|
||||
const user = db.query("SELECT id FROM users WHERE email = ?").get(email);
|
||||
// Create credential account for Better Auth
|
||||
db.run("INSERT INTO accounts (userId, accountId, providerId, password, createdAt, updatedAt) VALUES (?, ?, 'credential', ?, ?, ?)",
|
||||
[user.id, String(user.id), hash, now, now]);
|
||||
}
|
||||
`;
|
||||
execFileSync('docker', [...COMPOSE_ARGS, 'exec', '-T', 'web', 'bun', '-e', script], {
|
||||
@@ -90,7 +102,7 @@ async function loginAs(
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
|
||||
// The login client does router.replace('/') on success — wait for that
|
||||
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 });
|
||||
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 60_000 });
|
||||
await page.close();
|
||||
return context;
|
||||
}
|
||||
@@ -281,8 +293,12 @@ test.describe('Role-based access control', () => {
|
||||
|
||||
// ── Admin user — can access all pages ───────────────────────────────
|
||||
|
||||
test('admin role: all dashboard pages are accessible', async ({ browser }) => {
|
||||
const adminContext = await loginAs(browser, 'testadmin', 'TestPassword2026!');
|
||||
test('admin role: all dashboard pages are accessible', async ({ browser }, testInfo) => {
|
||||
testInfo.setTimeout(90_000);
|
||||
// Use the pre-authenticated admin state from global-setup
|
||||
const adminContext = await browser.newContext({
|
||||
storageState: require('path').resolve(__dirname, '../.auth/admin.json'),
|
||||
});
|
||||
try {
|
||||
for (const path of ALL_DASHBOARD_PAGES) {
|
||||
const page = await adminContext.newPage();
|
||||
@@ -298,7 +314,9 @@ test.describe('Role-based access control', () => {
|
||||
});
|
||||
|
||||
test('admin role: sidebar shows all nav items', async ({ browser }) => {
|
||||
const adminContext = await loginAs(browser, 'testadmin', 'TestPassword2026!');
|
||||
const adminContext = await browser.newContext({
|
||||
storageState: require('path').resolve(__dirname, '../.auth/admin.json'),
|
||||
});
|
||||
try {
|
||||
const page = await adminContext.newPage();
|
||||
await page.goto('/');
|
||||
|
||||
Reference in New Issue
Block a user