🔒️ feat(auth): add Zod validation and endpoint rate limiting

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
This commit is contained in:
Wuesteon 2025-12-16 02:44:21 +01:00
parent 26ca921158
commit fff2819b59
4 changed files with 162 additions and 13 deletions

View file

@ -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);
}

View file

@ -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<typeof userRoleSchema>;
/**
* 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<typeof createBetterAuth>;
/**
* 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'];

View file

@ -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
*/

View file

@ -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<TBody = unknown, TQuery = unknown> {
/**
* 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.
*