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:
fuomag9
2026-04-12 21:11:48 +02:00
parent eb78b64c2f
commit 3a16d6e9b1
100 changed files with 3390 additions and 14495 deletions

View File

@@ -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 }) => {

View File

@@ -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 },
});

View File

@@ -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 }) => {

View File

@@ -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('/');