From fff2819b5939af714493e17480e2223348118075 Mon Sep 17 00:00:00 2001 From: Wuesteon Date: Tue, 16 Dec 2025 02:44:21 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F=20feat(auth):=20add=20Zod?= =?UTF-8?q?=20validation=20and=20endpoint=20rate=20limiting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security improvements for Better Auth: - Add Zod schema for runtime role validation (user/admin/service) - Add rate limiting to sensitive endpoints: - Login: 5 requests/minute - Register: 10 requests/hour - Password reset: 3 requests/5 minutes - B2B register: 5 requests/hour - Skip rate limiting for /validate and /jwks endpoints --- .../src/auth/auth.controller.ts | 39 +++++++++ .../src/auth/better-auth.config.ts | 86 ++++++++++++++++++- .../src/auth/services/better-auth.service.ts | 15 +++- .../src/auth/types/better-auth.types.ts | 35 ++++++-- 4 files changed, 162 insertions(+), 13 deletions(-) diff --git a/services/mana-core-auth/src/auth/auth.controller.ts b/services/mana-core-auth/src/auth/auth.controller.ts index 5ad646960..ac826a61f 100644 --- a/services/mana-core-auth/src/auth/auth.controller.ts +++ b/services/mana-core-auth/src/auth/auth.controller.ts @@ -10,6 +10,7 @@ import { HttpCode, HttpStatus, } from '@nestjs/common'; +import { Throttle, SkipThrottle } from '@nestjs/throttler'; import { BetterAuthService } from './services/better-auth.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; @@ -22,6 +23,26 @@ import { ForgotPasswordDto } from './dto/forgot-password.dto'; import { ResetPasswordDto } from './dto/reset-password.dto'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +/** + * Rate Limiting Configuration + * + * Security-focused rate limits for auth endpoints: + * - Login: 5 attempts per minute (brute force protection) + * - Register: 10 per hour (spam prevention) + * - Password reset: 3 per 5 minutes (enumeration protection) + * - Token validation: No limit (high-frequency internal operation) + */ +const RATE_LIMITS = { + /** 5 login attempts per 60 seconds */ + LOGIN: { limit: 5, ttl: 60000 }, + /** 10 registrations per hour */ + REGISTER: { limit: 10, ttl: 3600000 }, + /** 3 password reset requests per 5 minutes */ + PASSWORD_RESET: { limit: 3, ttl: 300000 }, + /** 5 B2B registrations per hour */ + B2B_REGISTER: { limit: 5, ttl: 3600000 }, +} as const; + /** * Auth Controller * @@ -55,8 +76,11 @@ export class AuthController { * Register a new B2C user (individual) * * Creates a user account and initializes their credit balance. + * + * Rate limit: 10 registrations per hour per IP (spam prevention) */ @Post('register') + @Throttle({ default: RATE_LIMITS.REGISTER }) async register(@Body() registerDto: RegisterDto) { return this.betterAuthService.registerB2C({ email: registerDto.email, @@ -69,8 +93,11 @@ export class AuthController { * Sign in with email and password * * Returns user data and JWT token. + * + * Rate limit: 5 attempts per minute per IP (brute force protection) */ @Post('login') + @Throttle({ default: RATE_LIMITS.LOGIN }) @HttpCode(HttpStatus.OK) async login(@Body() loginDto: LoginDto) { return this.betterAuthService.signIn({ @@ -121,8 +148,11 @@ export class AuthController { * Validate a token * * Checks if a token is valid and returns the payload. + * + * No rate limiting - high-frequency internal operation used by all backends. */ @Post('validate') + @SkipThrottle() @HttpCode(HttpStatus.OK) async validate(@Body() body: { token: string }) { return this.betterAuthService.validateToken(body.token); @@ -133,8 +163,11 @@ export class AuthController { * * Returns public keys for JWT verification. * This is a passthrough to Better Auth's JWKS. + * + * No rate limiting - cacheable public keys for JWT verification. */ @Get('jwks') + @SkipThrottle() async getJwks() { return this.betterAuthService.getJwks(); } @@ -148,8 +181,11 @@ export class AuthController { * * Initiates the password reset flow by sending an email with a reset link. * Always returns success to prevent email enumeration attacks. + * + * Rate limit: 3 requests per 5 minutes per IP (enumeration protection) */ @Post('forgot-password') + @Throttle({ default: RATE_LIMITS.PASSWORD_RESET }) @HttpCode(HttpStatus.OK) async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) { return this.betterAuthService.requestPasswordReset( @@ -181,8 +217,11 @@ export class AuthController { * * Creates an organization with the registering user as owner. * Also creates organization credit balance. + * + * Rate limit: 5 B2B registrations per hour per IP (spam prevention) */ @Post('register/b2b') + @Throttle({ default: RATE_LIMITS.B2B_REGISTER }) async registerB2B(@Body() registerDto: RegisterB2BDto) { return this.betterAuthService.registerB2B(registerDto); } diff --git a/services/mana-core-auth/src/auth/better-auth.config.ts b/services/mana-core-auth/src/auth/better-auth.config.ts index 055928126..74efe8cd7 100644 --- a/services/mana-core-auth/src/auth/better-auth.config.ts +++ b/services/mana-core-auth/src/auth/better-auth.config.ts @@ -18,10 +18,37 @@ import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { jwt } from 'better-auth/plugins/jwt'; import { organization } from 'better-auth/plugins/organization'; +import { z } from 'zod'; import { getDb } from '../db/connection'; import { organizations, members, invitations } from '../db/schema/organizations.schema'; import { users, sessions, accounts, verificationTokens, jwks } from '../db/schema/auth.schema'; -import type { JWTPayloadContext } from './types/better-auth.types'; + +/** + * User role schema with Zod runtime validation + * + * Ensures only valid role values can be assigned to users. + * This provides defense-in-depth alongside `input: false`. + * + * Valid roles: + * - 'user': Standard user (default) + * - 'admin': Administrator with elevated privileges + * - 'service': Service account for automated systems + */ +export const userRoleSchema = z.enum(['user', 'admin', 'service'], { + errorMap: () => ({ message: 'Invalid user role. Must be one of: user, admin, service' }), +}); + +/** + * Inferred TypeScript type from Zod schema + */ +export type UserRole = z.infer; + +/** + * Type guard to check if a value is a valid UserRole + */ +export function isValidUserRole(role: unknown): role is UserRole { + return userRoleSchema.safeParse(role).success; +} /** * JWT Custom Payload Interface @@ -121,6 +148,39 @@ export function createBetterAuth(databaseUrl: string) { // Base URL for callbacks and redirects baseURL: process.env.BASE_URL || 'http://localhost:3001', + /** + * User Additional Fields + * + * Define custom user fields that Better Auth should be aware of. + * This enables proper type inference via $Infer pattern. + * + * @see https://www.better-auth.com/docs/concepts/database#additional-fields + */ + user: { + additionalFields: { + /** + * User role (user, admin, service) + * + * Security: + * - input=false prevents clients from setting their own role + * - Zod validator ensures only valid role values are accepted + * + * Roles must be assigned server-side only. + * + * @see userRoleSchema for valid values + */ + role: { + type: 'string', + required: false, + defaultValue: 'user', + input: false, // Clients cannot set role during registration + validator: { + input: userRoleSchema, // Runtime validation with Zod + }, + }, + }, + }, + // Plugins plugins: [ /** @@ -205,11 +265,11 @@ export function createBetterAuth(databaseUrl: string) { * * Only includes static user info that doesn't change frequently. */ - definePayload({ user, session }: JWTPayloadContext) { + definePayload({ user, session }) { return { sub: user.id, email: user.email, - role: (user as { role?: string }).role || 'user', + role: user.role ?? 'user', sid: session.id, }; }, @@ -223,3 +283,23 @@ export function createBetterAuth(databaseUrl: string) { * Export type for Better Auth instance */ export type BetterAuthInstance = ReturnType; + +/** + * Inferred types from Better Auth instance + * + * These types are automatically derived from the auth configuration, + * including all plugins and additional fields. Use these instead of + * manual interface definitions. + * + * @see https://www.better-auth.com/docs/concepts/typescript + */ +export type AuthSession = BetterAuthInstance['$Infer']['Session']; +export type AuthUser = AuthSession['user']; + +/** + * Inferred API type from Better Auth instance + * + * This type includes all methods from core auth and plugins + * (organization, jwt, etc.). Use this instead of manual BetterAuthAPI interface. + */ +export type AuthAPI = BetterAuthInstance['api']; diff --git a/services/mana-core-auth/src/auth/services/better-auth.service.ts b/services/mana-core-auth/src/auth/services/better-auth.service.ts index af67d68a6..843858ab7 100644 --- a/services/mana-core-auth/src/auth/services/better-auth.service.ts +++ b/services/mana-core-auth/src/auth/services/better-auth.service.ts @@ -55,8 +55,11 @@ import type { TokenPayload, OrganizationMember, Organization, - BetterAuthAPI, CreateOrganizationResponse, + // BetterAuthAPI provides typed methods for all Better Auth operations + // Note: AuthAPI (inferred) is also available but doesn't expose all methods + BetterAuthAPI, + // BetterAuthUser includes the role field (deprecated - use AuthUser when $Infer works) BetterAuthUser, } from '../types/better-auth.types'; import * as jwt from 'jsonwebtoken'; @@ -87,8 +90,14 @@ export class BetterAuthService { /** * Typed accessor for Better Auth API methods - * Better Auth's plugins add methods dynamically, so we provide - * a typed accessor to avoid casting throughout the service. + * + * Better Auth's plugins add methods dynamically. This accessor provides + * typed access to all API methods including those from organization + * and JWT plugins. + * + * Note: We use BetterAuthAPI interface which is manually maintained. + * In the future, this could be replaced with inferred types once + * Better Auth's $Infer fully supports API type inference. * * @see https://www.better-auth.com/docs/concepts/typescript */ diff --git a/services/mana-core-auth/src/auth/types/better-auth.types.ts b/services/mana-core-auth/src/auth/types/better-auth.types.ts index c8c352c10..3ef80f72b 100644 --- a/services/mana-core-auth/src/auth/types/better-auth.types.ts +++ b/services/mana-core-auth/src/auth/types/better-auth.types.ts @@ -3,16 +3,18 @@ * * This file provides types for Better Auth integration. * - * STRATEGY: Import base types from Better Auth packages, extend only when needed. + * STRATEGY (2024-12 UPDATE): + * Use inferred types from Better Auth's $Infer pattern when possible. + * Manual interfaces are kept only for service-layer DTOs and result types. * - * From 'better-auth/types': - * - User, Session, Account, Auth, BetterAuthOptions, etc. + * INFERRED TYPES (prefer these): + * - AuthUser, AuthSession, AuthAPI - from better-auth.config.ts * * From 'better-auth/plugins/organization': * - Organization, Member, Invitation, OrganizationRole, InvitationStatus * * This file defines: - * 1. Extended types (adding fields Better Auth doesn't have) + * 1. Re-exports of inferred types from config * 2. API response/request types for our service layer * 3. Service-specific DTOs and result types * 4. Type guards for runtime safety @@ -22,7 +24,12 @@ */ // ============================================================================= -// Import core types from Better Auth packages +// Import inferred types from Better Auth config +// ============================================================================= +import type { AuthUser, AuthSession, AuthAPI } from '../better-auth.config'; + +// ============================================================================= +// Import base types from Better Auth packages // ============================================================================= import type { User, Session } from 'better-auth/types'; import type { @@ -33,6 +40,9 @@ import type { InvitationStatus as BetterAuthInvitationStatus, } from 'better-auth/plugins/organization'; +// Re-export inferred types as primary types +export type { AuthUser, AuthSession, AuthAPI }; + // Re-export base types for convenience export type { User, Session }; export type { @@ -45,7 +55,9 @@ export type { /** * Extended User type with our additional fields - * Better Auth's User type is the base, we extend it for our app + * + * @deprecated Use AuthUser (inferred from config) instead. + * This type is kept for backward compatibility but may be removed in future. */ export interface BetterAuthUser extends User { role?: string; @@ -53,7 +65,9 @@ export interface BetterAuthUser extends User { /** * Extended Session type with organization support - * Better Auth's Session type is the base, organization plugin adds activeOrganizationId + * + * @deprecated Use AuthSession (inferred from config) instead. + * This type is kept for backward compatibility but may be removed in future. */ export interface BetterAuthSession extends Session { activeOrganizationId?: string | null; @@ -62,6 +76,9 @@ export interface BetterAuthSession extends Session { /** * JWT Payload context passed to definePayload + * + * @deprecated Better Auth now infers this type automatically from config. + * This type is kept for backward compatibility but may be removed in future. */ export interface JWTPayloadContext { user: BetterAuthUser; @@ -266,6 +283,10 @@ export interface AuthenticatedRequest { /** * Typed Better Auth API interface * + * @deprecated Use AuthAPI (inferred from config) instead. + * This interface is manually maintained and may become out of sync with Better Auth. + * The inferred type from BetterAuthInstance['api'] is always accurate. + * * This interface describes the methods available on auth.api * when using the organization plugin. *