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

@@ -58,65 +58,65 @@ describe('mtls-access-rules CRUD', () => {
it('createMtlsAccessRule creates a rule', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id,
path_pattern: '/admin/*',
allowed_role_ids: [1, 2],
allowed_cert_ids: [10],
proxyHostId: host.id,
pathPattern: '/admin/*',
allowedRoleIds: [1, 2],
allowedCertIds: [10],
priority: 5,
description: 'admin only',
}, userId);
expect(rule.proxy_host_id).toBe(host.id);
expect(rule.path_pattern).toBe('/admin/*');
expect(rule.allowed_role_ids).toEqual([1, 2]);
expect(rule.allowed_cert_ids).toEqual([10]);
expect(rule.proxyHostId).toBe(host.id);
expect(rule.pathPattern).toBe('/admin/*');
expect(rule.allowedRoleIds).toEqual([1, 2]);
expect(rule.allowedCertIds).toEqual([10]);
expect(rule.priority).toBe(5);
expect(rule.description).toBe('admin only');
expect(rule.deny_all).toBe(false);
expect(rule.denyAll).toBe(false);
});
it('createMtlsAccessRule trims path_pattern', async () => {
it('createMtlsAccessRule trims pathPattern', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id,
path_pattern: ' /api/* ',
proxyHostId: host.id,
pathPattern: ' /api/* ',
}, userId);
expect(rule.path_pattern).toBe('/api/*');
expect(rule.pathPattern).toBe('/api/*');
});
it('createMtlsAccessRule defaults arrays to empty', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id,
path_pattern: '*',
proxyHostId: host.id,
pathPattern: '*',
}, userId);
expect(rule.allowed_role_ids).toEqual([]);
expect(rule.allowed_cert_ids).toEqual([]);
expect(rule.deny_all).toBe(false);
expect(rule.allowedRoleIds).toEqual([]);
expect(rule.allowedCertIds).toEqual([]);
expect(rule.denyAll).toBe(false);
expect(rule.priority).toBe(0);
});
it('createMtlsAccessRule with deny_all', async () => {
it('createMtlsAccessRule with denyAll', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id,
path_pattern: '/blocked/*',
deny_all: true,
proxyHostId: host.id,
pathPattern: '/blocked/*',
denyAll: true,
}, userId);
expect(rule.deny_all).toBe(true);
expect(rule.denyAll).toBe(true);
});
it('listMtlsAccessRules returns rules ordered by priority desc then path asc', async () => {
const host = await insertHost();
await createMtlsAccessRule({ proxy_host_id: host.id, path_pattern: '/b', priority: 1 }, userId);
await createMtlsAccessRule({ proxy_host_id: host.id, path_pattern: '/a', priority: 10 }, userId);
await createMtlsAccessRule({ proxy_host_id: host.id, path_pattern: '/c', priority: 1 }, userId);
await createMtlsAccessRule({ proxyHostId: host.id, pathPattern: '/b', priority: 1 }, userId);
await createMtlsAccessRule({ proxyHostId: host.id, pathPattern: '/a', priority: 10 }, userId);
await createMtlsAccessRule({ proxyHostId: host.id, pathPattern: '/c', priority: 1 }, userId);
const rules = await listMtlsAccessRules(host.id);
expect(rules).toHaveLength(3);
expect(rules[0].path_pattern).toBe('/a'); // priority 10 (highest)
expect(rules[1].path_pattern).toBe('/b'); // priority 1, path /b
expect(rules[2].path_pattern).toBe('/c'); // priority 1, path /c
expect(rules[0].pathPattern).toBe('/a'); // priority 10 (highest)
expect(rules[1].pathPattern).toBe('/b'); // priority 1, path /b
expect(rules[2].pathPattern).toBe('/c'); // priority 1, path /c
});
it('listMtlsAccessRules returns empty array for host with no rules', async () => {
@@ -128,24 +128,24 @@ describe('mtls-access-rules CRUD', () => {
it('listMtlsAccessRules only returns rules for the specified host', async () => {
const host1 = await insertHost('h1');
const host2 = await insertHost('h2');
await createMtlsAccessRule({ proxy_host_id: host1.id, path_pattern: '/h1' }, userId);
await createMtlsAccessRule({ proxy_host_id: host2.id, path_pattern: '/h2' }, userId);
await createMtlsAccessRule({ proxyHostId: host1.id, pathPattern: '/h1' }, userId);
await createMtlsAccessRule({ proxyHostId: host2.id, pathPattern: '/h2' }, userId);
const rules = await listMtlsAccessRules(host1.id);
expect(rules).toHaveLength(1);
expect(rules[0].path_pattern).toBe('/h1');
expect(rules[0].pathPattern).toBe('/h1');
});
it('getMtlsAccessRule returns a single rule', async () => {
const host = await insertHost();
const created = await createMtlsAccessRule({
proxy_host_id: host.id, path_pattern: '/test',
proxyHostId: host.id, pathPattern: '/test',
}, userId);
const fetched = await getMtlsAccessRule(created.id);
expect(fetched).not.toBeNull();
expect(fetched!.id).toBe(created.id);
expect(fetched!.path_pattern).toBe('/test');
expect(fetched!.pathPattern).toBe('/test');
});
it('getMtlsAccessRule returns null for non-existent rule', async () => {
@@ -155,34 +155,34 @@ describe('mtls-access-rules CRUD', () => {
it('updateMtlsAccessRule updates fields', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id, path_pattern: '/old', priority: 0,
proxyHostId: host.id, pathPattern: '/old', priority: 0,
}, userId);
const updated = await updateMtlsAccessRule(rule.id, {
path_pattern: '/new',
pathPattern: '/new',
priority: 99,
allowed_role_ids: [5],
deny_all: true,
allowedRoleIds: [5],
denyAll: true,
description: 'updated',
}, userId);
expect(updated.path_pattern).toBe('/new');
expect(updated.pathPattern).toBe('/new');
expect(updated.priority).toBe(99);
expect(updated.allowed_role_ids).toEqual([5]);
expect(updated.deny_all).toBe(true);
expect(updated.allowedRoleIds).toEqual([5]);
expect(updated.denyAll).toBe(true);
expect(updated.description).toBe('updated');
});
it('updateMtlsAccessRule partial update leaves other fields unchanged', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id, path_pattern: '/test',
allowed_role_ids: [1], priority: 5, description: 'original',
proxyHostId: host.id, pathPattern: '/test',
allowedRoleIds: [1], priority: 5, description: 'original',
}, userId);
const updated = await updateMtlsAccessRule(rule.id, { priority: 10 }, userId);
expect(updated.path_pattern).toBe('/test');
expect(updated.allowed_role_ids).toEqual([1]);
expect(updated.pathPattern).toBe('/test');
expect(updated.allowedRoleIds).toEqual([1]);
expect(updated.description).toBe('original');
expect(updated.priority).toBe(10);
});
@@ -194,7 +194,7 @@ describe('mtls-access-rules CRUD', () => {
it('deleteMtlsAccessRule removes the rule', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id, path_pattern: '/test',
proxyHostId: host.id, pathPattern: '/test',
}, userId);
await deleteMtlsAccessRule(rule.id, 1);
@@ -221,9 +221,9 @@ describe('getAccessRulesForHosts (bulk query)', () => {
it('groups rules by proxy host ID', async () => {
const h1 = await insertHost('h1');
const h2 = await insertHost('h2');
await createMtlsAccessRule({ proxy_host_id: h1.id, path_pattern: '/a' }, userId);
await createMtlsAccessRule({ proxy_host_id: h1.id, path_pattern: '/b' }, userId);
await createMtlsAccessRule({ proxy_host_id: h2.id, path_pattern: '/c' }, userId);
await createMtlsAccessRule({ proxyHostId: h1.id, pathPattern: '/a' }, userId);
await createMtlsAccessRule({ proxyHostId: h1.id, pathPattern: '/b' }, userId);
await createMtlsAccessRule({ proxyHostId: h2.id, pathPattern: '/c' }, userId);
const map = await getAccessRulesForHosts([h1.id, h2.id]);
expect(map.get(h1.id)).toHaveLength(2);
@@ -233,8 +233,8 @@ describe('getAccessRulesForHosts (bulk query)', () => {
it('excludes hosts not in the query list', async () => {
const h1 = await insertHost('h1');
const h2 = await insertHost('h2');
await createMtlsAccessRule({ proxy_host_id: h1.id, path_pattern: '/a' }, userId);
await createMtlsAccessRule({ proxy_host_id: h2.id, path_pattern: '/b' }, userId);
await createMtlsAccessRule({ proxyHostId: h1.id, pathPattern: '/a' }, userId);
await createMtlsAccessRule({ proxyHostId: h2.id, pathPattern: '/b' }, userId);
const map = await getAccessRulesForHosts([h1.id]);
expect(map.has(h1.id)).toBe(true);
@@ -243,20 +243,20 @@ describe('getAccessRulesForHosts (bulk query)', () => {
it('rules within a host are ordered by priority desc, path asc', async () => {
const h = await insertHost();
await createMtlsAccessRule({ proxy_host_id: h.id, path_pattern: '/z', priority: 10 }, userId);
await createMtlsAccessRule({ proxy_host_id: h.id, path_pattern: '/a', priority: 1 }, userId);
await createMtlsAccessRule({ proxy_host_id: h.id, path_pattern: '/m', priority: 10 }, userId);
await createMtlsAccessRule({ proxyHostId: h.id, pathPattern: '/z', priority: 10 }, userId);
await createMtlsAccessRule({ proxyHostId: h.id, pathPattern: '/a', priority: 1 }, userId);
await createMtlsAccessRule({ proxyHostId: h.id, pathPattern: '/m', priority: 10 }, userId);
const map = await getAccessRulesForHosts([h.id]);
const rules = map.get(h.id)!;
expect(rules[0].path_pattern).toBe('/m'); // priority 10, path /m
expect(rules[1].path_pattern).toBe('/z'); // priority 10, path /z
expect(rules[2].path_pattern).toBe('/a'); // priority 1
expect(rules[0].pathPattern).toBe('/m'); // priority 10, path /m
expect(rules[1].pathPattern).toBe('/z'); // priority 10, path /z
expect(rules[2].pathPattern).toBe('/a'); // priority 1
});
});
describe('JSON parsing edge cases in access rules', () => {
it('handles malformed allowed_role_ids JSON gracefully', async () => {
it('handles malformed allowedRoleIds JSON gracefully', async () => {
const host = await insertHost();
const now = nowIso();
// Insert directly with bad JSON
@@ -267,7 +267,7 @@ describe('JSON parsing edge cases in access rules', () => {
});
const rules = await listMtlsAccessRules(host.id);
expect(rules[0].allowed_role_ids).toEqual([]);
expect(rules[0].allowedRoleIds).toEqual([]);
});
it('filters non-numeric values from JSON arrays', async () => {
@@ -280,7 +280,7 @@ describe('JSON parsing edge cases in access rules', () => {
});
const rules = await listMtlsAccessRules(host.id);
expect(rules[0].allowed_role_ids).toEqual([1, 3]);
expect(rules[0].allowedRoleIds).toEqual([1, 3]);
});
it('handles non-array JSON', async () => {
@@ -293,7 +293,7 @@ describe('JSON parsing edge cases in access rules', () => {
});
const rules = await listMtlsAccessRules(host.id);
expect(rules[0].allowed_role_ids).toEqual([]);
expect(rules[0].allowed_cert_ids).toEqual([]);
expect(rules[0].allowedRoleIds).toEqual([]);
expect(rules[0].allowedCertIds).toEqual([]);
});
});