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

@@ -40,7 +40,7 @@ beforeEach(() => {
describe('authenticateApiRequest', () => {
it('authenticates via Bearer token', async () => {
mockValidateToken.mockResolvedValue({
token: { id: 1, name: 'test', created_by: 42, created_at: '', last_used_at: null, expires_at: null },
token: { id: 1, name: 'test', createdBy: 42, createdAt: '', lastUsedAt: null, expiresAt: null },
user: { id: 42, role: 'admin' },
});
@@ -91,7 +91,7 @@ describe('authenticateApiRequest', () => {
describe('requireApiAdmin', () => {
it('allows admin users', async () => {
mockValidateToken.mockResolvedValue({
token: { id: 1, name: 'test', created_by: 1, created_at: '', last_used_at: null, expires_at: null },
token: { id: 1, name: 'test', createdBy: 1, createdAt: '', lastUsedAt: null, expiresAt: null },
user: { id: 1, role: 'admin' },
});
@@ -101,7 +101,7 @@ describe('requireApiAdmin', () => {
it('rejects non-admin users with 403', async () => {
mockValidateToken.mockResolvedValue({
token: { id: 1, name: 'test', created_by: 2, created_at: '', last_used_at: null, expires_at: null },
token: { id: 1, name: 'test', createdBy: 2, createdAt: '', lastUsedAt: null, expiresAt: null },
user: { id: 2, role: 'user' },
});
@@ -154,7 +154,7 @@ describe('requireApiUser', () => {
it('CSRF check skips for Bearer-authenticated POST', async () => {
mockValidateToken.mockResolvedValue({
token: { id: 1, name: 'test', created_by: 42, created_at: '', last_used_at: null, expires_at: null },
token: { id: 1, name: 'test', createdBy: 42, createdAt: '', lastUsedAt: null, expiresAt: null },
user: { id: 42, role: 'admin' },
});

View File

@@ -53,8 +53,8 @@ const sampleUser = {
name: 'Admin User',
email: 'admin@example.com',
role: 'admin',
password_hash: '$2b$10$hashedpassword',
created_at: '2026-01-01',
passwordHash: '$2b$10$hashedpassword',
createdAt: '2026-01-01',
};
beforeEach(() => {
@@ -64,7 +64,7 @@ beforeEach(() => {
});
describe('GET /api/v1/users', () => {
it('returns list of users with password_hash stripped', async () => {
it('returns list of users with passwordHash stripped', async () => {
mockListUsers.mockResolvedValue([sampleUser] as any);
const response = await listGET(createMockRequest());
@@ -72,7 +72,7 @@ describe('GET /api/v1/users', () => {
expect(response.status).toBe(200);
expect(data).toHaveLength(1);
expect(data[0]).not.toHaveProperty('password_hash');
expect(data[0]).not.toHaveProperty('passwordHash');
expect(data[0].name).toBe('Admin User');
expect(data[0].email).toBe('admin@example.com');
});
@@ -87,14 +87,14 @@ describe('GET /api/v1/users', () => {
});
describe('GET /api/v1/users/[id]', () => {
it('returns a user by id with password_hash stripped', async () => {
it('returns a user by id with passwordHash stripped', async () => {
mockGetUserById.mockResolvedValue(sampleUser as any);
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).not.toHaveProperty('password_hash');
expect(data).not.toHaveProperty('passwordHash');
expect(data.name).toBe('Admin User');
});
@@ -128,7 +128,7 @@ describe('GET /api/v1/users/[id]', () => {
expect(response.status).toBe(200);
expect(data.id).toBe(5);
expect(data).not.toHaveProperty('password_hash');
expect(data).not.toHaveProperty('passwordHash');
});
});
@@ -144,7 +144,7 @@ describe('PUT /api/v1/users/[id]', () => {
expect(response.status).toBe(200);
expect(data.name).toBe('Updated Name');
expect(data).not.toHaveProperty('password_hash');
expect(data).not.toHaveProperty('passwordHash');
expect(mockUpdateUserProfile).toHaveBeenCalledWith(1, { name: 'Updated Name' });
});

View File

@@ -20,10 +20,10 @@ import {
function makeRule(overrides: Partial<MtlsAccessRuleLike> = {}): MtlsAccessRuleLike {
return {
path_pattern: "/admin/*",
allowed_role_ids: [],
allowed_cert_ids: [],
deny_all: false,
pathPattern: "/admin/*",
allowedRoleIds: [],
allowedCertIds: [],
denyAll: false,
...overrides,
};
}
@@ -54,7 +54,7 @@ describe("resolveAllowedFingerprints", () => {
]);
const certFpMap = new Map<number, string>();
const rule = makeRule({ allowed_role_ids: [1, 2] });
const rule = makeRule({ allowedRoleIds: [1, 2] });
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
expect(result).toEqual(new Set(["fp_a", "fp_b", "fp_c"]));
@@ -67,7 +67,7 @@ describe("resolveAllowedFingerprints", () => {
[20, "fp_y"],
]);
const rule = makeRule({ allowed_cert_ids: [10, 20] });
const rule = makeRule({ allowedCertIds: [10, 20] });
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
expect(result).toEqual(new Set(["fp_x", "fp_y"]));
@@ -79,7 +79,7 @@ describe("resolveAllowedFingerprints", () => {
]);
const certFpMap = new Map<number, string>([[10, "fp_b"]]);
const rule = makeRule({ allowed_role_ids: [1], allowed_cert_ids: [10] });
const rule = makeRule({ allowedRoleIds: [1], allowedCertIds: [10] });
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
expect(result).toEqual(new Set(["fp_a", "fp_b"]));
@@ -91,7 +91,7 @@ describe("resolveAllowedFingerprints", () => {
]);
const certFpMap = new Map<number, string>([[10, "fp_a"]]);
const rule = makeRule({ allowed_role_ids: [1], allowed_cert_ids: [10] });
const rule = makeRule({ allowedRoleIds: [1], allowedCertIds: [10] });
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
expect(result.size).toBe(1);
@@ -102,7 +102,7 @@ describe("resolveAllowedFingerprints", () => {
const roleFpMap = new Map<number, Set<string>>();
const certFpMap = new Map<number, string>();
const rule = makeRule({ allowed_role_ids: [999], allowed_cert_ids: [999] });
const rule = makeRule({ allowedRoleIds: [999], allowedCertIds: [999] });
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
expect(result.size).toBe(0);
@@ -146,7 +146,7 @@ describe("buildMtlsRbacSubroutes", () => {
const roleFpMap = new Map<number, Set<string>>([
[1, new Set(["fp_admin"])],
]);
const rules = [makeRule({ allowed_role_ids: [1] })];
const rules = [makeRule({ allowedRoleIds: [1] })];
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
@@ -174,8 +174,8 @@ describe("buildMtlsRbacSubroutes", () => {
expect(catchAll.terminal).toBe(true);
});
it("generates 403 for deny_all rule", () => {
const rules = [makeRule({ deny_all: true })];
it("generates 403 for denyAll rule", () => {
const rules = [makeRule({ denyAll: true })];
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
expect(result).not.toBeNull();
@@ -188,7 +188,7 @@ describe("buildMtlsRbacSubroutes", () => {
});
it("generates 403 when rule has no matching fingerprints", () => {
const rules = [makeRule({ allowed_role_ids: [999] })]; // role doesn't exist
const rules = [makeRule({ allowedRoleIds: [999] })]; // role doesn't exist
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
expect(result).not.toBeNull();
@@ -206,8 +206,8 @@ describe("buildMtlsRbacSubroutes", () => {
[2, new Set(["fp_api"])],
]);
const rules = [
makeRule({ path_pattern: "/admin/*", allowed_role_ids: [1] }),
makeRule({ path_pattern: "/api/*", allowed_role_ids: [1, 2] }),
makeRule({ pathPattern: "/admin/*", allowedRoleIds: [1] }),
makeRule({ pathPattern: "/api/*", allowedRoleIds: [1, 2] }),
];
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
@@ -219,7 +219,7 @@ describe("buildMtlsRbacSubroutes", () => {
it("uses direct cert fingerprints as overrides", () => {
const certFpMap = new Map<number, string>([[42, "fp_special"]]);
const rules = [makeRule({ allowed_cert_ids: [42] })];
const rules = [makeRule({ allowedCertIds: [42] })];
const result = buildMtlsRbacSubroutes(rules, new Map(), certFpMap, baseHandlers, reverseProxy);
@@ -230,7 +230,7 @@ describe("buildMtlsRbacSubroutes", () => {
});
it("catch-all route includes base handlers + reverse proxy", () => {
const rules = [makeRule({ deny_all: true })];
const rules = [makeRule({ denyAll: true })];
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
const catchAll = result![result!.length - 1] as Record<string, unknown>;
@@ -242,7 +242,7 @@ describe("buildMtlsRbacSubroutes", () => {
it("allow route includes base handlers + reverse proxy", () => {
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp"])]]);
const rules = [makeRule({ allowed_role_ids: [1] })];
const rules = [makeRule({ allowedRoleIds: [1] })];
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
const allowRoute = result![0] as Record<string, unknown>;
@@ -252,17 +252,17 @@ describe("buildMtlsRbacSubroutes", () => {
});
it("deny route body is 'mTLS access denied'", () => {
const rules = [makeRule({ deny_all: true })];
const rules = [makeRule({ denyAll: true })];
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
const denyHandler = (result![0] as any).handle[0];
expect(denyHandler.body).toBe("mTLS access denied");
});
it("handles mixed deny_all and role-based rules", () => {
it("handles mixed denyAll and role-based rules", () => {
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp"])]]);
const rules = [
makeRule({ path_pattern: "/secret/*", deny_all: true }),
makeRule({ path_pattern: "/api/*", allowed_role_ids: [1] }),
makeRule({ pathPattern: "/secret/*", denyAll: true }),
makeRule({ pathPattern: "/api/*", allowedRoleIds: [1] }),
];
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
@@ -281,7 +281,7 @@ describe("buildMtlsRbacSubroutes", () => {
it("handles rule with both roles and certs combined", () => {
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp_role"])]]);
const certFpMap = new Map<number, string>([[42, "fp_cert"]]);
const rules = [makeRule({ allowed_role_ids: [1], allowed_cert_ids: [42] })];
const rules = [makeRule({ allowedRoleIds: [1], allowedCertIds: [42] })];
const result = buildMtlsRbacSubroutes(rules, roleFpMap, certFpMap, baseHandlers, reverseProxy);
const match = (result![0] as any).match[0];
@@ -292,7 +292,7 @@ describe("buildMtlsRbacSubroutes", () => {
it("preserves base handlers order in generated routes", () => {
const multiHandlers = [{ handler: "waf" }, { handler: "headers" }, { handler: "auth" }];
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp"])]]);
const rules = [makeRule({ allowed_role_ids: [1] })];
const rules = [makeRule({ allowedRoleIds: [1] })];
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), multiHandlers, reverseProxy);
const allowHandlers = (result![0] as any).handle;
@@ -344,14 +344,14 @@ describe("buildFingerprintCelExpression edge cases", () => {
describe("resolveAllowedFingerprints edge cases", () => {
it("handles empty arrays in rule", () => {
const rule = makeRule({ allowed_role_ids: [], allowed_cert_ids: [] });
const rule = makeRule({ allowedRoleIds: [], allowedCertIds: [] });
const result = resolveAllowedFingerprints(rule, new Map(), new Map());
expect(result.size).toBe(0);
});
it("handles role with empty fingerprint set", () => {
const roleFpMap = new Map<number, Set<string>>([[1, new Set()]]);
const rule = makeRule({ allowed_role_ids: [1] });
const rule = makeRule({ allowedRoleIds: [1] });
const result = resolveAllowedFingerprints(rule, roleFpMap, new Map());
expect(result.size).toBe(0);
});
@@ -362,7 +362,7 @@ describe("resolveAllowedFingerprints edge cases", () => {
[2, new Set(["b", "c"])],
[3, new Set(["c", "d"])],
]);
const rule = makeRule({ allowed_role_ids: [1, 2, 3] });
const rule = makeRule({ allowedRoleIds: [1, 2, 3] });
const result = resolveAllowedFingerprints(rule, roleFpMap, new Map());
expect(result).toEqual(new Set(["a", "b", "c", "d"]));
});

View File

@@ -68,7 +68,7 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: '',
protocol: 'tcp',
listen_address: ':5432',
listenAddress: ':5432',
upstreams: ['10.0.0.1:5432'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Name is required');
@@ -78,7 +78,7 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'sctp' as any,
listen_address: ':5432',
listenAddress: ':5432',
upstreams: ['10.0.0.1:5432'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("Protocol must be 'tcp' or 'udp'");
@@ -88,7 +88,7 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: '',
listenAddress: '',
upstreams: ['10.0.0.1:5432'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Listen address is required');
@@ -98,7 +98,7 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: '10.0.0.1',
listenAddress: '10.0.0.1',
upstreams: ['10.0.0.1:5432'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("Listen address must be in format ':PORT' or 'HOST:PORT'");
@@ -108,7 +108,7 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':0',
listenAddress: ':0',
upstreams: ['10.0.0.1:5432'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Port must be between 1 and 65535');
@@ -118,7 +118,7 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':99999',
listenAddress: ':99999',
upstreams: ['10.0.0.1:5432'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Port must be between 1 and 65535');
@@ -128,7 +128,7 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':5432',
listenAddress: ':5432',
upstreams: [],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('At least one upstream must be specified');
@@ -138,7 +138,7 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':5432',
listenAddress: ':5432',
upstreams: ['10.0.0.1'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("must be in 'host:port' format");
@@ -148,9 +148,9 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'udp',
listen_address: ':5353',
listenAddress: ':5353',
upstreams: ['8.8.8.8:53'],
tls_termination: true,
tlsTermination: true,
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('TLS termination is only supported with TCP');
});
@@ -159,10 +159,10 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':5432',
listenAddress: ':5432',
upstreams: ['10.0.0.1:5432'],
matcher_type: 'tls_sni',
matcher_value: [],
matcherType: 'tls_sni',
matcherValue: [],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Matcher value is required');
});
@@ -171,10 +171,10 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':8080',
listenAddress: ':8080',
upstreams: ['10.0.0.1:8080'],
matcher_type: 'http_host',
matcher_value: [],
matcherType: 'http_host',
matcherValue: [],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Matcher value is required');
});
@@ -183,9 +183,9 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':5432',
listenAddress: ':5432',
upstreams: ['10.0.0.1:5432'],
proxy_protocol_version: 'v3' as any,
proxyProtocolVersion: 'v3' as any,
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("Proxy protocol version must be 'v1' or 'v2'");
});
@@ -194,9 +194,9 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':5432',
listenAddress: ':5432',
upstreams: ['10.0.0.1:5432'],
matcher_type: 'invalid' as any,
matcherType: 'invalid' as any,
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Matcher type must be one of');
});
@@ -205,33 +205,33 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Full Featured',
protocol: 'tcp',
listen_address: ':993',
listenAddress: ':993',
upstreams: ['localhost:143'],
matcher_type: 'tls_sni',
matcher_value: ['mail.example.com'],
tls_termination: true,
proxy_protocol_version: 'v1',
proxy_protocol_receive: true,
matcherType: 'tls_sni',
matcherValue: ['mail.example.com'],
tlsTermination: true,
proxyProtocolVersion: 'v1',
proxyProtocolReceive: true,
enabled: true,
};
const result = await createL4ProxyHost(input, 1);
expect(result).toBeDefined();
expect(result.name).toBe('Full Featured');
expect(result.protocol).toBe('tcp');
expect(result.listen_address).toBe(':993');
expect(result.listenAddress).toBe(':993');
expect(result.upstreams).toEqual(['localhost:143']);
expect(result.matcher_type).toBe('tls_sni');
expect(result.matcher_value).toEqual(['mail.example.com']);
expect(result.tls_termination).toBe(true);
expect(result.proxy_protocol_version).toBe('v1');
expect(result.proxy_protocol_receive).toBe(true);
expect(result.matcherType).toBe('tls_sni');
expect(result.matcherValue).toEqual(['mail.example.com']);
expect(result.tlsTermination).toBe(true);
expect(result.proxyProtocolVersion).toBe('v1');
expect(result.proxyProtocolReceive).toBe(true);
});
it('accepts valid UDP proxy', async () => {
const input: L4ProxyHostInput = {
name: 'DNS',
protocol: 'udp',
listen_address: ':5353',
listenAddress: ':5353',
upstreams: ['8.8.8.8:53'],
};
const result = await createL4ProxyHost(input, 1);
@@ -243,54 +243,54 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Bound',
protocol: 'tcp',
listen_address: '0.0.0.0:5432',
listenAddress: '0.0.0.0:5432',
upstreams: ['10.0.0.1:5432'],
};
const result = await createL4ProxyHost(input, 1);
expect(result.listen_address).toBe('0.0.0.0:5432');
expect(result.listenAddress).toBe('0.0.0.0:5432');
});
it('accepts none matcher without matcher_value', async () => {
it('accepts none matcher without matcherValue', async () => {
const input: L4ProxyHostInput = {
name: 'Catch All',
protocol: 'tcp',
listen_address: ':5432',
listenAddress: ':5432',
upstreams: ['10.0.0.1:5432'],
matcher_type: 'none',
matcherType: 'none',
};
const result = await createL4ProxyHost(input, 1);
expect(result.matcher_type).toBe('none');
expect(result.matcherType).toBe('none');
});
it('accepts proxy_protocol matcher without matcher_value', async () => {
it('accepts proxy_protocol matcher without matcherValue', async () => {
const input: L4ProxyHostInput = {
name: 'PP Detect',
protocol: 'tcp',
listen_address: ':8443',
listenAddress: ':8443',
upstreams: ['10.0.0.1:443'],
matcher_type: 'proxy_protocol',
matcherType: 'proxy_protocol',
};
const result = await createL4ProxyHost(input, 1);
expect(result.matcher_type).toBe('proxy_protocol');
expect(result.matcherType).toBe('proxy_protocol');
});
it('trims whitespace from name and listen_address', async () => {
it('trims whitespace from name and listenAddress', async () => {
const input: L4ProxyHostInput = {
name: ' Spacey Name ',
protocol: 'tcp',
listen_address: ' :5432 ',
listenAddress: ' :5432 ',
upstreams: ['10.0.0.1:5432'],
};
const result = await createL4ProxyHost(input, 1);
expect(result.name).toBe('Spacey Name');
expect(result.listen_address).toBe(':5432');
expect(result.listenAddress).toBe(':5432');
});
it('deduplicates upstreams', async () => {
const input: L4ProxyHostInput = {
name: 'Dedup',
protocol: 'tcp',
listen_address: ':5432',
listenAddress: ':5432',
upstreams: ['10.0.0.1:5432', '10.0.0.1:5432', '10.0.0.2:5432'],
};
const result = await createL4ProxyHost(input, 1);