- 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>
207 lines
6.8 KiB
TypeScript
207 lines
6.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// Mock the api-tokens model
|
|
vi.mock('@/src/lib/models/api-tokens', () => ({
|
|
validateToken: vi.fn(),
|
|
}));
|
|
|
|
// Mock next-auth
|
|
vi.mock('@/src/lib/auth', () => ({
|
|
auth: vi.fn(),
|
|
checkSameOrigin: vi.fn(() => null),
|
|
}));
|
|
|
|
import { authenticateApiRequest, requireApiUser, requireApiAdmin, ApiAuthError, apiErrorResponse } from '@/src/lib/api-auth';
|
|
import { validateToken } from '@/src/lib/models/api-tokens';
|
|
import { auth, checkSameOrigin } from '@/src/lib/auth';
|
|
import { NextResponse } from 'next/server';
|
|
|
|
const mockValidateToken = vi.mocked(validateToken);
|
|
const mockAuth = vi.mocked(auth);
|
|
|
|
function createMockRequest(options: { authorization?: string; method?: string; origin?: string } = {}): any {
|
|
return {
|
|
headers: {
|
|
get(name: string) {
|
|
if (name === 'authorization') return options.authorization ?? null;
|
|
if (name === 'origin') return options.origin ?? null;
|
|
return null;
|
|
},
|
|
},
|
|
method: options.method ?? 'GET',
|
|
nextUrl: { pathname: '/api/v1/test' },
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
describe('authenticateApiRequest', () => {
|
|
it('authenticates via Bearer token', async () => {
|
|
mockValidateToken.mockResolvedValue({
|
|
token: { id: 1, name: 'test', createdBy: 42, createdAt: '', lastUsedAt: null, expiresAt: null },
|
|
user: { id: 42, role: 'admin' },
|
|
});
|
|
|
|
const result = await authenticateApiRequest(createMockRequest({ authorization: 'Bearer test-token' }));
|
|
|
|
expect(result.userId).toBe(42);
|
|
expect(result.role).toBe('admin');
|
|
expect(result.authMethod).toBe('bearer');
|
|
expect(mockValidateToken).toHaveBeenCalledWith('test-token');
|
|
});
|
|
|
|
it('rejects invalid Bearer token', async () => {
|
|
mockValidateToken.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
authenticateApiRequest(createMockRequest({ authorization: 'Bearer bad-token' }))
|
|
).rejects.toThrow(ApiAuthError);
|
|
});
|
|
|
|
it('falls back to session auth when no Bearer header', async () => {
|
|
mockAuth.mockResolvedValue({
|
|
user: { id: '10', role: 'user', name: 'Test', email: 'test@test.com' },
|
|
expires: '',
|
|
} as any);
|
|
|
|
const result = await authenticateApiRequest(createMockRequest());
|
|
|
|
expect(result.userId).toBe(10);
|
|
expect(result.role).toBe('user');
|
|
expect(result.authMethod).toBe('session');
|
|
});
|
|
|
|
it('throws 401 when neither auth method succeeds', async () => {
|
|
mockAuth.mockResolvedValue(null as any);
|
|
|
|
await expect(
|
|
authenticateApiRequest(createMockRequest())
|
|
).rejects.toThrow(ApiAuthError);
|
|
|
|
try {
|
|
await authenticateApiRequest(createMockRequest());
|
|
} catch (e) {
|
|
expect((e as ApiAuthError).status).toBe(401);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('requireApiAdmin', () => {
|
|
it('allows admin users', async () => {
|
|
mockValidateToken.mockResolvedValue({
|
|
token: { id: 1, name: 'test', createdBy: 1, createdAt: '', lastUsedAt: null, expiresAt: null },
|
|
user: { id: 1, role: 'admin' },
|
|
});
|
|
|
|
const result = await requireApiAdmin(createMockRequest({ authorization: 'Bearer token' }));
|
|
expect(result.role).toBe('admin');
|
|
});
|
|
|
|
it('rejects non-admin users with 403', async () => {
|
|
mockValidateToken.mockResolvedValue({
|
|
token: { id: 1, name: 'test', createdBy: 2, createdAt: '', lastUsedAt: null, expiresAt: null },
|
|
user: { id: 2, role: 'user' },
|
|
});
|
|
|
|
try {
|
|
await requireApiAdmin(createMockRequest({ authorization: 'Bearer token' }));
|
|
expect.fail('Should have thrown');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(ApiAuthError);
|
|
expect((e as ApiAuthError).status).toBe(403);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('requireApiUser', () => {
|
|
it('returns auth result for valid user', async () => {
|
|
mockAuth.mockResolvedValue({
|
|
user: { id: '5', role: 'viewer', name: 'V', email: 'v@test.com' },
|
|
expires: '',
|
|
} as any);
|
|
|
|
const result = await requireApiUser(createMockRequest());
|
|
expect(result.userId).toBe(5);
|
|
expect(result.role).toBe('viewer');
|
|
});
|
|
|
|
it('CSRF check blocks session-authenticated POST without same origin', async () => {
|
|
mockAuth.mockResolvedValue({
|
|
user: { id: '5', role: 'user', name: 'U', email: 'u@test.com' },
|
|
expires: '',
|
|
} as any);
|
|
|
|
const mockCheckSameOrigin = vi.mocked(checkSameOrigin);
|
|
mockCheckSameOrigin.mockReturnValueOnce(NextResponse.json({ error: 'Forbidden' }, { status: 403 }) as any);
|
|
|
|
await expect(
|
|
requireApiUser(createMockRequest({ method: 'POST' }))
|
|
).rejects.toThrow(ApiAuthError);
|
|
|
|
try {
|
|
mockCheckSameOrigin.mockReturnValueOnce(NextResponse.json({ error: 'Forbidden' }, { status: 403 }) as any);
|
|
mockAuth.mockResolvedValue({
|
|
user: { id: '5', role: 'user', name: 'U', email: 'u@test.com' },
|
|
expires: '',
|
|
} as any);
|
|
await requireApiUser(createMockRequest({ method: 'POST' }));
|
|
} catch (e) {
|
|
expect((e as ApiAuthError).status).toBe(403);
|
|
}
|
|
});
|
|
|
|
it('CSRF check skips for Bearer-authenticated POST', async () => {
|
|
mockValidateToken.mockResolvedValue({
|
|
token: { id: 1, name: 'test', createdBy: 42, createdAt: '', lastUsedAt: null, expiresAt: null },
|
|
user: { id: 42, role: 'admin' },
|
|
});
|
|
|
|
const mockCheckSameOrigin = vi.mocked(checkSameOrigin);
|
|
mockCheckSameOrigin.mockReturnValueOnce(NextResponse.json({ error: 'Forbidden' }, { status: 403 }) as any);
|
|
|
|
const result = await requireApiUser(createMockRequest({ authorization: 'Bearer test-token', method: 'POST' }));
|
|
expect(result.userId).toBe(42);
|
|
expect(result.authMethod).toBe('bearer');
|
|
});
|
|
});
|
|
|
|
describe('authenticateApiRequest - empty bearer', () => {
|
|
it('rejects empty Bearer token', async () => {
|
|
await expect(
|
|
authenticateApiRequest(createMockRequest({ authorization: 'Bearer ' }))
|
|
).rejects.toThrow(ApiAuthError);
|
|
|
|
try {
|
|
await authenticateApiRequest(createMockRequest({ authorization: 'Bearer ' }));
|
|
} catch (e) {
|
|
expect((e as ApiAuthError).status).toBe(401);
|
|
expect((e as ApiAuthError).message).toBe('Invalid Bearer token');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('apiErrorResponse', () => {
|
|
it('handles ApiAuthError', async () => {
|
|
const response = apiErrorResponse(new ApiAuthError('Forbidden', 403));
|
|
expect(response.status).toBe(403);
|
|
const data = await response.json();
|
|
expect(data.error).toBe('Forbidden');
|
|
});
|
|
|
|
it('handles generic Error', async () => {
|
|
const response = apiErrorResponse(new Error('Something broke'));
|
|
expect(response.status).toBe(500);
|
|
const data = await response.json();
|
|
expect(data.error).toBe('Something broke');
|
|
});
|
|
|
|
it('handles unknown error', async () => {
|
|
const response = apiErrorResponse('some string error');
|
|
expect(response.status).toBe(500);
|
|
const data = await response.json();
|
|
expect(data.error).toBe('Internal server error');
|
|
});
|
|
});
|