feat(#4): Implement Authentik OIDC authentication with BetterAuth

- Integrated BetterAuth library for modern authentication
- Added Session, Account, and Verification database tables
- Created complete auth module with service, controller, guards, and decorators
- Implemented shared authentication types in @mosaic/shared package
- Added comprehensive test coverage (26 tests passing)
- Documented type sharing strategy for monorepo
- Updated environment configuration with OIDC and JWT settings

Key architectural decisions:
- BetterAuth over Passport.js for better TypeScript support
- Separation of User (DB entity) vs AuthUser (client-safe subset)
- Shared types package to prevent FE/BE drift
- Factory pattern for auth config to use shared Prisma instance

Ready for frontend integration (Issue #6).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

Fixes #4
This commit is contained in:
Jason Woltje
2026-01-28 17:26:34 -06:00
parent 139a16648d
commit 6a038d093b
22 changed files with 2616 additions and 7 deletions

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { ExecutionContext, UnauthorizedException } from "@nestjs/common";
import { AuthGuard } from "./auth.guard";
import { AuthService } from "../auth.service";
describe("AuthGuard", () => {
let guard: AuthGuard;
let authService: AuthService;
const mockAuthService = {
verifySession: vi.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthGuard,
{
provide: AuthService,
useValue: mockAuthService,
},
],
}).compile();
guard = module.get<AuthGuard>(AuthGuard);
authService = module.get<AuthService>(AuthService);
vi.clearAllMocks();
});
const createMockExecutionContext = (headers: any = {}): ExecutionContext => {
const mockRequest = {
headers,
};
return {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as ExecutionContext;
};
describe("canActivate", () => {
it("should return true for valid session", async () => {
const mockSessionData = {
user: {
id: "user-123",
email: "test@example.com",
name: "Test User",
},
session: {
id: "session-123",
},
};
mockAuthService.verifySession.mockResolvedValue(mockSessionData);
const context = createMockExecutionContext({
authorization: "Bearer valid-token",
});
const result = await guard.canActivate(context);
expect(result).toBe(true);
expect(mockAuthService.verifySession).toHaveBeenCalledWith("valid-token");
});
it("should throw UnauthorizedException if no token provided", async () => {
const context = createMockExecutionContext({});
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(context)).rejects.toThrow("No authentication token provided");
});
it("should throw UnauthorizedException if session is invalid", async () => {
mockAuthService.verifySession.mockResolvedValue(null);
const context = createMockExecutionContext({
authorization: "Bearer invalid-token",
});
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(context)).rejects.toThrow("Invalid or expired session");
});
it("should throw UnauthorizedException if session verification fails", async () => {
mockAuthService.verifySession.mockRejectedValue(new Error("Verification failed"));
const context = createMockExecutionContext({
authorization: "Bearer error-token",
});
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
await expect(guard.canActivate(context)).rejects.toThrow("Authentication failed");
});
});
});