Initial commit
This commit is contained in:
57
apps/api/src/lib/auth.ts
Normal file
57
apps/api/src/lib/auth.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
||||
import { magicLink, oidcProvider } from 'better-auth/plugins';
|
||||
import { prisma } from '@dwellops/db';
|
||||
import { env } from './env';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* Plugins array, conditionally extended with OIDC if enabled.
|
||||
*
|
||||
* NOTE: Passkey/WebAuthn support is not yet available in better-auth 1.5.x.
|
||||
* Track https://github.com/better-auth/better-auth for availability.
|
||||
* When released, import from 'better-auth/plugins' and add to this array.
|
||||
*/
|
||||
const plugins: Parameters<typeof betterAuth>[0]['plugins'] = [
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
// In production, wire this to your transactional email provider (e.g. nodemailer via SMTP).
|
||||
// In development, Mailpit captures the email at http://localhost:8025.
|
||||
logger.info({ email, url }, 'Magic link requested');
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
if (env.OIDC_ENABLED) {
|
||||
if (!env.OIDC_ISSUER || !env.OIDC_CLIENT_ID || !env.OIDC_CLIENT_SECRET) {
|
||||
throw new Error(
|
||||
'OIDC_ENABLED=true but OIDC_ISSUER, OIDC_CLIENT_ID, or OIDC_CLIENT_SECRET is missing.',
|
||||
);
|
||||
}
|
||||
|
||||
plugins.push(
|
||||
oidcProvider({
|
||||
loginPage: '/auth/login',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configured Better Auth instance.
|
||||
*
|
||||
* Supported flows:
|
||||
* - Magic link (always enabled)
|
||||
* - OIDC (optional, controlled by OIDC_ENABLED env var)
|
||||
*
|
||||
* Passkey/WebAuthn: planned, pending better-auth plugin availability.
|
||||
* Email/password login is intentionally disabled.
|
||||
*/
|
||||
export const auth = betterAuth({
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
baseURL: env.BETTER_AUTH_URL,
|
||||
basePath: '/api/auth',
|
||||
database: prismaAdapter(prisma, { provider: 'postgresql' }),
|
||||
emailAndPassword: { enabled: false },
|
||||
plugins,
|
||||
trustedOrigins: [env.CORS_ORIGIN],
|
||||
});
|
||||
46
apps/api/src/lib/env.ts
Normal file
46
apps/api/src/lib/env.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(3001),
|
||||
HOST: z.string().default('0.0.0.0'),
|
||||
|
||||
DATABASE_URL: z.string().url(),
|
||||
|
||||
BETTER_AUTH_SECRET: z.string().min(32),
|
||||
BETTER_AUTH_URL: z.string().url(),
|
||||
|
||||
CORS_ORIGIN: z.string().default('http://localhost:3000'),
|
||||
|
||||
SMTP_HOST: z.string().default('localhost'),
|
||||
SMTP_PORT: z.coerce.number().int().default(1025),
|
||||
SMTP_FROM: z.string().email().default('noreply@dwellops.local'),
|
||||
|
||||
OIDC_ENABLED: z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform((v) => v === 'true'),
|
||||
OIDC_ISSUER: z.string().url().optional(),
|
||||
OIDC_CLIENT_ID: z.string().optional(),
|
||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
/**
|
||||
* Validates and returns the current environment variables.
|
||||
* Throws a descriptive error on startup if required variables are missing.
|
||||
*/
|
||||
export function validateEnv(): Env {
|
||||
const result = envSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Invalid environment variables:\n${result.error.issues
|
||||
.map((i) => ` ${i.path.join('.')}: ${i.message}`)
|
||||
.join('\n')}`,
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export const env = validateEnv();
|
||||
54
apps/api/src/lib/errors.ts
Normal file
54
apps/api/src/lib/errors.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Typed application error hierarchy.
|
||||
* Use these instead of raw Error objects so callers can identify error kinds.
|
||||
*/
|
||||
|
||||
export class AppError extends Error {
|
||||
/** HTTP status code to return. */
|
||||
readonly statusCode: number;
|
||||
/** Machine-readable error code. */
|
||||
readonly code: string;
|
||||
|
||||
constructor(message: string, statusCode = 500, code = 'INTERNAL_ERROR') {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
this.statusCode = statusCode;
|
||||
this.code = code;
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
/** 400 — caller sent invalid data. */
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message: string, code = 'VALIDATION_ERROR') {
|
||||
super(message, 400, code);
|
||||
}
|
||||
}
|
||||
|
||||
/** 401 — request is not authenticated. */
|
||||
export class UnauthenticatedError extends AppError {
|
||||
constructor(message = 'Authentication required') {
|
||||
super(message, 401, 'UNAUTHENTICATED');
|
||||
}
|
||||
}
|
||||
|
||||
/** 403 — authenticated but not permitted. */
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor(message = 'You do not have permission to perform this action') {
|
||||
super(message, 403, 'FORBIDDEN');
|
||||
}
|
||||
}
|
||||
|
||||
/** 404 — requested resource does not exist. */
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(resource: string, id?: string) {
|
||||
super(id ? `${resource} '${id}' not found` : `${resource} not found`, 404, 'NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
/** 409 — conflict with existing data. */
|
||||
export class ConflictError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, 409, 'CONFLICT');
|
||||
}
|
||||
}
|
||||
20
apps/api/src/lib/logger.ts
Normal file
20
apps/api/src/lib/logger.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import pino from 'pino';
|
||||
import { env } from './env';
|
||||
|
||||
/**
|
||||
* Application-level Pino logger.
|
||||
* Uses pretty-printing in development, structured JSON in production.
|
||||
*/
|
||||
export const logger = pino({
|
||||
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
...(env.NODE_ENV !== 'production' && {
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'SYS:standard',
|
||||
ignore: 'pid,hostname',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
17
apps/api/src/lib/require-auth.ts
Normal file
17
apps/api/src/lib/require-auth.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { UnauthenticatedError } from './errors';
|
||||
|
||||
/**
|
||||
* Fastify preHandler that enforces authentication.
|
||||
* Throws UnauthenticatedError (401) if no valid session is present.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* fastify.get('/me', { preHandler: [requireAuth] }, handler);
|
||||
* ```
|
||||
*/
|
||||
export async function requireAuth(request: FastifyRequest, _reply: FastifyReply): Promise<void> {
|
||||
if (!request.session) {
|
||||
throw new UnauthenticatedError();
|
||||
}
|
||||
}
|
||||
10
apps/api/src/lib/test-setup.ts
Normal file
10
apps/api/src/lib/test-setup.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Vitest global setup for apps/api.
|
||||
* Sets minimum required env vars before any test module is loaded.
|
||||
*/
|
||||
|
||||
process.env['NODE_ENV'] = 'test';
|
||||
process.env['DATABASE_URL'] = 'postgresql://test:test@localhost:5432/dwellops_test';
|
||||
process.env['BETTER_AUTH_SECRET'] = 'test-secret-at-least-32-characters-long!';
|
||||
process.env['BETTER_AUTH_URL'] = 'http://localhost:3001';
|
||||
process.env['PORT'] = '3001';
|
||||
Reference in New Issue
Block a user