Skip to content

AIEPhoenix/nestjs-better-auth-fastify

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

16 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

NestJS Logo + Better Auth Logo + Fastify Logo

nestjs-better-auth-fastify

A comprehensive Better Auth integration for NestJS with Fastify adapter.

NPM Version Package License NPM Downloads

English | δΈ­ζ–‡

Table of Contents

✨ Features

  • πŸ” Seamless Integration - Drop-in Better Auth support for NestJS + Fastify
  • 🎯 Decorator-based - Intuitive decorators for authentication & authorization
  • πŸ“¦ Plugin Support - Full support for Better Auth plugins (Admin, Organization, API Key, Bearer, etc.)
  • πŸ”„ Multi-Context - Works with HTTP, GraphQL, and WebSocket
  • πŸͺ Hook System - NestJS-native hooks for auth lifecycle events
  • 🎨 Type-Safe - Full TypeScript support with type inference from your auth config
  • ⚑ Performance - Optimized with lazy loading for optional dependencies
  • 🌍 i18n Ready - Customizable error messages for internationalization

πŸ“¦ Installation

# npm
npm install @sapix/nestjs-better-auth-fastify better-auth

# pnpm
pnpm add @sapix/nestjs-better-auth-fastify better-auth

# yarn
yarn add @sapix/nestjs-better-auth-fastify better-auth

Optional Dependencies

Install these based on your needs:

# For GraphQL support
pnpm add @nestjs/graphql graphql

# For WebSocket support
pnpm add @nestjs/websockets @nestjs/platform-socket.io

πŸš€ Quick Start

1. Create Better Auth Configuration

// src/auth/auth.config.ts
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from '../db';

export const auth = betterAuth({
  basePath: '/api/auth',
  database: drizzleAdapter(db, { provider: 'postgresql' }),
  emailAndPassword: { enabled: true },
  // Add more plugins as needed
});

// Export type for type inference
export type Auth = typeof auth;

2. Import AuthModule

// src/app.module.ts
import { Module } from '@nestjs/common';
import { AuthModule } from '@sapix/nestjs-better-auth-fastify';
import { auth } from './auth/auth.config';

@Module({
  imports: [
    AuthModule.forRoot({ auth }),
  ],
})
export class AppModule {}

3. Use Decorators in Controllers

// src/user/user.controller.ts
import { Controller, Get, Post } from '@nestjs/common';
import {
  AllowAnonymous,
  Session,
  CurrentUser,
  Roles,
  UserSession,
} from '@sapix/nestjs-better-auth-fastify';

@Controller('user')
export class UserController {
  // All routes are protected by default
  @Get('profile')
  getProfile(@Session() session: UserSession) {
    return session;
  }

  // Public route - no authentication required
  @Get('public')
  @AllowAnonymous()
  getPublicData() {
    return { message: 'This is public' };
  }

  // Role-based access control
  @Get('admin')
  @Roles(['admin'])
  getAdminData(@CurrentUser() user: UserSession['user']) {
    return { message: `Hello admin ${user.name}` };
  }
}

πŸ“š Decorators Reference

Access Control Decorators

Decorator Description Example
@AllowAnonymous() Mark route as public (overrides defaultAuthBehavior) Public endpoints
@RequireAuth() Require authentication (overrides defaultAuthBehavior) Protected endpoints
@OptionalAuth() Auth optional, session injected if present Mixed-access endpoints
@Roles(['admin']) Require specific roles Admin-only routes
@Permissions(['read']) Require specific permissions Permission-based access
@RequireFreshSession() Require recently authenticated session Sensitive operations

Roles & Permissions Examples

// OR logic (default): user needs ANY of the roles
@Roles(['admin', 'moderator'])

// AND logic: user needs ALL roles
@Roles(['admin', 'verified'], { mode: 'all' })

// Custom error message
@Roles(['admin'], { message: 'Administrator access required' })

// Permission-based (same options available)
@Permissions(['user:read', 'user:write'], { mode: 'any' })
@Permissions(['read:posts', 'write:posts', 'delete:posts'], { mode: 'all' })

Fresh Session Examples

// Use default freshAge (from auth config, defaults to 1 day)
@RequireFreshSession()
@Post('change-password')
changePassword() {}

// Custom freshAge (5 minutes = 300 seconds)
@RequireFreshSession({ maxAge: 300 })
@Post('enable-2fa')
enable2FA() {}

// Custom error message
@RequireFreshSession({ message: 'Please re-authenticate to continue' })
@Delete('account')
deleteAccount() {}

Admin Plugin Decorators

Requires admin() plugin from better-auth/plugins

import { admin } from 'better-auth/plugins';

export const auth = betterAuth({
  plugins: [admin()],
});
Decorator Description
@AdminOnly() Admin role required
@BanCheck() Real-time ban check (Better Auth only checks at session creation)
@DisallowImpersonation() Block impersonated sessions
@SecureAdminOnly() Combined: Admin + Fresh + No Impersonation
// High-security admin operation
@SecureAdminOnly()
@Delete('admin/users/:id')
deleteUser() {
  // Only real admins with fresh sessions can execute
}

// Real-time ban check - useful for users banned after session creation
@BanCheck()
@Post('comments')
createComment() {}

// Prevent impersonated sessions from sensitive operations
@DisallowImpersonation()
@Post('transfer-funds')
transferFunds() {}

// Custom error message
@AdminOnly('Administrator privileges required')
@Get('admin/dashboard')
getDashboard() {}

Alternative Auth Methods

Bearer Token Authentication

Requires bearer() plugin from better-auth/plugins

Bearer Token authentication is automatically supported when you add the bearer() plugin to Better Auth. No special decorator is needed - the default session auth will accept Bearer Token in the Authorization header.

import { bearer } from 'better-auth/plugins';

export const auth = betterAuth({
  plugins: [bearer()],
});

Client usage:

curl -H "Authorization: Bearer <session-token>" /api/mobile/data

API Key Authentication

Requires apiKey() plugin from better-auth/plugins

import { apiKey } from 'better-auth/plugins';

export const auth = betterAuth({
  plugins: [apiKey()],
});
// API Key only
@ApiKeyAuth()
@Get('api/external')
externalApi(@ApiKey() apiKey: ApiKeyValidation['key']) {
  return { keyId: apiKey.id, permissions: apiKey.permissions };
}

// API Key or Session (flexible mode)
@ApiKeyAuth({ allowSession: true })
@Get('api/flexible')
flexibleApi() {}

// With permission requirements
@ApiKeyAuth({
  permissions: {
    permissions: { files: ['read', 'write'] },
    message: 'Requires files read/write permissions',
  },
})
@Post('api/files')
uploadFile() {}

Client usage:

curl -H "x-api-key: <api-key>" /api/external

Note: API keys must be sent via dedicated headers (default: x-api-key). Custom headers can be configured via Better Auth's apiKey plugin apiKeyHeaders option. Do NOT use Authorization: Bearer for API keys - that's reserved for session tokens.

Organization Plugin Decorators

Requires organization() plugin from better-auth/plugins

import { organization } from 'better-auth/plugins';

export const auth = betterAuth({
  plugins: [
    organization({
      roles: {
        owner: { inherit: ['admin'] },
        admin: { inherit: ['member'] },
        member: { permissions: ['read'] },
      },
    }),
  ],
});
Decorator Description
@OrgRequired() Require organization context
@OptionalOrg() Load org if available (not required)
@OrgRoles(['owner']) Require organization roles
@OrgPermission({...}) Require organization permissions
// Require organization context
@OrgRequired()
@Get('org/dashboard')
getOrgDashboard(@CurrentOrg() org: Organization) {
  return { name: org.name };
}

// Require owner or admin role
@OrgRoles(['owner', 'admin'])
@Put('org/settings')
updateOrgSettings() {}

// Multiple roles with AND logic
@OrgRoles(['admin', 'billing'], { mode: 'all' })
@Post('org/billing')
manageBilling() {}

// Fine-grained permission check
@OrgPermission({ resource: 'member', action: 'create' })
@Post('org/members')
inviteMember() {}

// Multiple actions with AND logic
@OrgPermission({ resource: 'member', action: ['read', 'update'], mode: 'all' })
@Put('org/members/:id')
updateMember() {}

// Custom error message
@OrgPermission({
  resource: 'invite',
  action: 'create',
  message: 'You do not have permission to invite members',
})
@Post('org/invitations')
createInvitation() {}

Client usage (must include organization ID):

curl -H "x-organization-id: <org-id>" /org/dashboard

Parameter Decorators

Decorator Description Type
@Session() Full session object UserSession
@SessionProperty('id') Specific session property string
@CurrentUser() Current user UserSession['user']
@UserProperty('id') Specific user property string
@ApiKey() API Key info ApiKeyValidation['key']
@CurrentOrg() Current organization Organization
@OrgMember() Organization membership OrganizationMember
@IsImpersonating() Impersonation status boolean
@ImpersonatedBy() Impersonator admin ID string | null
@Get('me')
getMe(
  @CurrentUser() user: UserSession['user'],
  @UserProperty('email') email: string,
  @UserProperty('id') userId: string,
  @IsImpersonating() isImpersonating: boolean,
  @ImpersonatedBy() adminId: string | null,
) {
  return { user, email, userId, isImpersonating, adminId };
}

@OrgRequired()
@Get('org/context')
getOrgContext(
  @CurrentOrg() org: Organization,
  @OrgMember() member: OrganizationMember,
) {
  return { org, member };
}

Custom Auth Context Decorators

Create reusable parameter decorators with createAuthParamDecorator to reduce boilerplate and standardize auth context extraction across your application.

Before - repetitive parameter injection:

@Get(':id')
findOne(
  @Session() session: UserSession,
  @CurrentOrg() org: Organization | null,
  @OrgMember() member: OrganizationMember | null,
  @Param('id') id: string,
) {
  const ctx = this.buildContext(session, org, member); // manual mapping every time
  return this.resourceService.findOne(id, ctx);
}

After - clean and reusable:

@Get(':id')
findOne(@RequestCtx() ctx: RequestContext, @Param('id') id: string) {
  return this.resourceService.findOne(id, ctx);
}

Basic Usage

import {
  createAuthParamDecorator,
  AuthContext,
} from '@sapix/nestjs-better-auth-fastify';

// Define your context interface
interface RequestContext {
  userId: string;
  userEmail: string;
  isAdmin: boolean;
  organizationId: string | null;
}

// Create a reusable decorator
const RequestCtx = createAuthParamDecorator<RequestContext>(
  (auth: AuthContext) => ({
    userId: auth.user?.id ?? 'anonymous',
    userEmail: auth.user?.email ?? '',
    isAdmin: (auth.user as any)?.role === 'admin',
    organizationId: auth.organization?.id ?? null,
  }),
);

// Use in controllers - clean and consistent
@Controller('resources')
export class ResourceController {
  @Get(':id')
  findOne(@RequestCtx() ctx: RequestContext, @Param('id') id: string) {
    return this.resourceService.findOne(id, ctx);
  }

  @Post()
  create(@RequestCtx() ctx: RequestContext, @Body() dto: CreateDto) {
    return this.resourceService.create(dto, ctx);
  }
}

AuthContext Properties

The AuthContext object provides access to all auth-related data:

interface AuthContext {
  session: UserSession | null;
  user: UserSession['user'] | null;
  organization: Organization | null;
  orgMember: OrganizationMember | null;
  isImpersonating: boolean;
  impersonatedBy: string | null;
  apiKey: ApiKeyValidation['key'] | null;
}

Data Availability by Decorator

Important: Not all AuthContext properties are populated by default. Data availability depends on authentication method and decorators used:

Session Authentication (default):

AuthContext Property Availability Notes
session βœ… Always Full session object
user βœ… Always User from session
isImpersonating βœ… Always From session data
impersonatedBy βœ… Always Admin ID if impersonating
organization ⚠️ Requires decorator Use @OrgRequired() or @OptionalOrg()
orgMember ⚠️ Requires decorator Use @OrgRequired() or @OptionalOrg()
apiKey ❌ null Not applicable for session auth

API Key Authentication (@ApiKeyAuth()):

AuthContext Property Availability Notes
session ❌ null API Keys don't have sessions
user βœ… Always Loaded via key.userId
isImpersonating ❌ false Not applicable for API Keys
impersonatedBy ❌ null Not applicable for API Keys
organization ❌ null Not loaded for API Key auth
orgMember ❌ null Not loaded for API Key auth
apiKey βœ… Always Full API Key info

Creating Paired Decorators

When creating a custom param decorator that uses organization data, create a paired method decorator to ensure proper data loading. The naming convention helps clarify the purpose:

  • Method decorator (@ResourceAuth): Declares auth requirements - what authorization is needed
  • Param decorator (@ResourceAuthState): Extracts auth state - the authorization context data
import { applyDecorators } from '@nestjs/common';
import {
  createAuthParamDecorator,
  OptionalOrg,
  OrgRequired,
  OrgRoles,
  RequireAuth,
  AuthContext,
} from '@sapix/nestjs-better-auth-fastify';

// 1. Define your auth state interface (use "Info" suffix to avoid naming conflict)
interface ResourceAuthStateInfo {
  userId: string;
  organizationId: string | null;
  orgRole: string | null;
  isOrgAdmin: boolean;
}

// 2. Create the param decorator: @ResourceAuthState() - extracts auth state
export const ResourceAuthState = createAuthParamDecorator<ResourceAuthStateInfo>(
  (auth) => ({
    userId: auth.user?.id ?? '',
    organizationId: auth.organization?.id ?? null,
    orgRole: auth.orgMember?.role ?? null,
    isOrgAdmin:
      auth.orgMember?.role === 'owner' || auth.orgMember?.role === 'admin',
  }),
);

// 3. Create the paired method decorator: @ResourceAuth() - declares auth requirements
export interface ResourceAuthOptions {
  requireOrg?: boolean;
  orgRoles?: string[];
}

export function ResourceAuth(options: ResourceAuthOptions = {}) {
  const { requireOrg = false, orgRoles } = options;

  // With org roles -> requires org + specific roles
  if (orgRoles?.length) {
    return applyDecorators(OrgRequired(), OrgRoles(orgRoles));
  }

  // Requires org context
  if (requireOrg) {
    return OrgRequired();
  }

  // Default: requires auth, loads org if available
  // RequireAuth() ensures auth even when defaultAuthBehavior is 'public'
  return applyDecorators(RequireAuth(), OptionalOrg());
}

Usage - pair @ResourceAuth() (requirements) with @ResourceAuthState() (state):

@Controller('resources')
export class ResourceController {
  // Default: requires auth, org loaded if available
  @ResourceAuth()
  @Get('my')
  getMyResources(@ResourceAuthState() state: ResourceAuthStateInfo) {
    if (state.organizationId) {
      return this.service.getOrgResources(state.organizationId);
    }
    return this.service.getUserResources(state.userId);
  }

  // Requires auth + org context
  @ResourceAuth({ requireOrg: true })
  @Get('org')
  getOrgResources(@ResourceAuthState() state: ResourceAuthStateInfo) {
    return this.service.getOrgResources(state.organizationId!);
  }

  // Requires auth + org + admin role
  @ResourceAuth({ orgRoles: ['owner', 'admin'] })
  @Put('org/settings')
  updateOrgSettings(@ResourceAuthState() state: ResourceAuthStateInfo) {
    return this.service.updateSettings(state.organizationId!);
  }
}

Note: The default @ResourceAuth() uses RequireAuth() to ensure authentication regardless of defaultAuthBehavior setting. This makes the decorator behavior predictable and independent of global configuration.

Real-World Examples

Multi-Tenant:

// State interface
interface TenantAuthStateInfo {
  userId: string;
  tenantId: string | null;
  tenantRole: string;
  isTenantAdmin: boolean;
}

// Param decorator: extracts tenant state
const TenantAuthState = createAuthParamDecorator<TenantAuthStateInfo>((auth) => ({
  userId: auth.user?.id ?? 'anonymous',
  tenantId: auth.organization?.id ?? null,
  tenantRole: auth.orgMember?.role ?? 'none',
  isTenantAdmin:
    auth.orgMember?.role === 'owner' || auth.orgMember?.role === 'admin',
}));

// Method decorator: declares tenant auth requirements
interface TenantAuthOptions {
  requireTenant?: boolean;
  roles?: string[];
}

function TenantAuth(options: TenantAuthOptions = {}) {
  const { requireTenant = true, roles } = options;
  if (roles?.length) {
    return applyDecorators(OrgRequired(), OrgRoles(roles));
  }
  return requireTenant ? OrgRequired() : applyDecorators(RequireAuth(), OptionalOrg());
}

// Usage
@TenantAuth()
@Get('tenant/dashboard')
getDashboard(@TenantAuthState() tenant: TenantAuthStateInfo) { ... }

Audit Trail:

// State interface
interface AuditAuthStateInfo {
  actorId: string;
  actorType: 'user' | 'apiKey' | 'system';
  impersonatorId: string | null;
  timestamp: string;
}

// Param decorator: extracts audit context
const AuditAuthState = createAuthParamDecorator<AuditAuthStateInfo>((auth) => ({
  actorId: auth.apiKey?.userId ?? auth.user?.id ?? 'system',
  actorType: auth.apiKey ? 'apiKey' : auth.user ? 'user' : 'system',
  impersonatorId: auth.impersonatedBy,
  timestamp: new Date().toISOString(),
}));

// Method decorator: audit routes allow optional auth (system actions are also audited)
function AuditAuth() {
  return OptionalAuth();
}

// Usage
@AuditAuth()
@Post('events')
logEvent(@AuditAuthState() audit: AuditAuthStateInfo) { ... }

Service Layer:

// State interface
interface ServiceAuthStateInfo {
  requesterId: string;
  scope: {
    orgId: string | null;
    permissions: string[];
  };
}

// Param decorator: extracts service context
const ServiceAuthState = createAuthParamDecorator<ServiceAuthStateInfo>((auth) => {
  const permissions = ['read'];
  if ((auth.user as any)?.role === 'admin') {
    permissions.push('write', 'delete');
  }
  return {
    requesterId: auth.user?.id ?? 'anonymous',
    scope: {
      orgId: auth.organization?.id ?? null,
      permissions,
    },
  };
});

// Method decorator: service routes require auth with optional org
function ServiceAuth() {
  return applyDecorators(RequireAuth(), OptionalOrg());
}

// Usage
@ServiceAuth()
@Get('data')
getData(@ServiceAuthState() ctx: ServiceAuthStateInfo) { ... }

πŸͺ Hook System

The hook system allows you to execute custom logic before and after Better Auth processes authentication requests.

Creating a Hook Provider

// src/hooks/sign-up.hook.ts
import { Injectable } from '@nestjs/common';
import {
  Hook,
  BeforeHook,
  AfterHook,
  AuthHookContext,
} from '@sapix/nestjs-better-auth-fastify';

@Hook()
@Injectable()
export class SignUpHook {
  constructor(
    private readonly emailService: EmailService,
    private readonly crmService: CrmService,
  ) {}

  // Validate before sign-up
  @BeforeHook('/sign-up/email')
  async validateBeforeSignUp(ctx: AuthHookContext) {
    const { email } = ctx.body as { email: string };
    if (email.endsWith('@blocked-domain.com')) {
      throw new Error('This email domain is not allowed');
    }
  }

  // Send welcome email after sign-up
  @AfterHook('/sign-up/email')
  async sendWelcomeEmail(ctx: AuthHookContext) {
    const user = ctx.context?.user;
    if (user) {
      await this.emailService.sendWelcome(user.email);
      await this.crmService.createContact(user);
    }
  }

  // Log all auth requests (no path = matches all routes)
  @BeforeHook()
  async logAuthRequest(ctx: AuthHookContext) {
    console.log('Auth request:', ctx.path);
  }
}

Registering Hook Providers

// src/app.module.ts
@Module({
  imports: [AuthModule.forRoot({ auth })],
  providers: [SignUpHook], // Register hook provider
})
export class AppModule {}

Common Hook Paths

Path Description
/sign-up/email Email sign-up
/sign-in/email Email sign-in
/sign-out Sign out
/forget-password Forgot password
/reset-password Reset password
/verify-email Email verification

πŸ›  AuthService API

AuthService provides programmatic access to Better Auth functionality.

Basic Usage

import { Injectable } from '@nestjs/common';
import { AuthService, UserSession } from '@sapix/nestjs-better-auth-fastify';
import type { Auth } from './auth/auth.config';

@Injectable()
export class MyService {
  constructor(private readonly authService: AuthService<Auth>) {}

  async someMethod(request: FastifyRequest) {
    // Get session from request
    const session = await this.authService.getSessionFromRequest(request);

    // Validate session (throws UnauthorizedException if invalid)
    const validSession = await this.authService.validateSession(request);

    // Check roles
    if (this.authService.hasRole(session, ['admin'])) {
      // User is admin
    }

    // Check permissions
    if (
      this.authService.hasPermission(
        session,
        ['user:read', 'user:write'],
        'all',
      )
    ) {
      // User has all required permissions
    }

    // Check session freshness
    if (!this.authService.isSessionFresh(session)) {
      // Require re-authentication
    }

    // Access Better Auth API directly
    const accounts = await this.authService.api.listUserAccounts({
      headers: getWebHeadersFromRequest(request),
    });
  }
}

Session Management

// Revoke a specific session
await this.authService.revokeSession(sessionToken, request);

// Revoke all user sessions
await this.authService.revokeAllSessions(request);

// List all user sessions
const sessions = await this.authService.listUserSessions(request);

Admin Features

// Check if user is banned
if (this.authService.isUserBanned(session.user)) {
  throw new ForbiddenException('User is banned');
}

// Check impersonation status
if (this.authService.isImpersonating(session)) {
  const adminId = this.authService.getImpersonatedBy(session);
  // Log for audit
}

API Key Verification

const result = await this.authService.verifyApiKey(apiKey);
if (result.valid) {
  console.log('Key belongs to user:', result.key?.userId);
  console.log('Permissions:', result.key?.permissions);
}

// With permission requirements
const result = await this.authService.verifyApiKey(apiKey, {
  files: ['read', 'write'],
});

Organization Features

// Get active organization
const org = await this.authService.getActiveOrganization(request);

// Check organization permission
const hasPermission = await this.authService.hasOrgPermission(request, {
  resource: 'member',
  action: 'create',
});

JWT Token (Requires JWT Plugin)

const jwt = await this.authService.getJwtToken(request);
if (jwt) {
  // Use JWT for service-to-service communication
}

Accessing the Auth Instance

// Get the complete Better Auth instance
const authInstance = this.authService.instance;

// Get the configured basePath
const basePath = this.authService.basePath;

🎨 Type Inference

The library supports full type inference from your Better Auth configuration.

Using $Infer Pattern

import { AuthService } from '@sapix/nestjs-better-auth-fastify';
import type { Auth } from './auth/auth.config';

@Injectable()
export class MyService {
  constructor(private readonly authService: AuthService<Auth>) {}

  async getUser(request: FastifyRequest) {
    // Session type is automatically inferred from your auth config
    const session = await this.authService.getSessionFromRequest(request);
    // session.user includes all fields from your auth config
  }
}

// Get types directly (compile-time only)
type Session = typeof authService.$Infer.Session;
type User = typeof authService.$Infer.User;

Using InferSession and InferUser

import { InferSession, InferUser } from '@sapix/nestjs-better-auth-fastify';
import type { Auth } from './auth/auth.config';

type MySession = InferSession<Auth>;
type MyUser = InferUser<Auth>;

Custom User Types

interface CustomUser extends BaseUser {
  role: string;
  permissions: string[];
  department: string;
}

@Get('profile')
getProfile(@Session() session: UserSession<CustomUser>) {
  return session.user.department; // Type-safe
}

βš™οΈ Configuration

Full Configuration Options

AuthModule.forRoot({
  // Required: Better Auth instance
  // Auth routes path is read from auth.options.basePath (defaults to '/api/auth')
  auth,

  // Optional: Default authentication behavior
  // - 'require' (default): All routes require auth. Use @AllowAnonymous() for public routes.
  // - 'optional': All routes have optional auth. Session injected if present.
  // - 'public': All routes are public. Use @RequireAuth() for protected routes.
  defaultAuthBehavior: 'require',

  // Optional: Enable debug logging
  debug: false,

  // Optional: Custom middleware wrapping the auth handler
  // Useful for ORM contexts (e.g., MikroORM RequestContext)
  middleware: async (req, reply, next) => {
    await next();
  },

  // Optional: Custom error messages (useful for i18n)
  errorMessages: {
    unauthorized: 'Please sign in to continue',
    forbidden: 'You do not have permission to perform this action',
    sessionNotFresh: 'Please sign in again to continue',
    userBanned: 'Your account has been suspended',
    orgRequired: 'Please select an organization to continue',
    orgRoleRequired: 'Your organization role cannot perform this action',
    orgPermissionRequired: 'You lack the required organization permission',
    apiKeyRequired: 'A valid API key is required',
    apiKeyInvalidPermissions: 'Your API key lacks the required permissions',
    adminRequired: 'This action requires administrator privileges',
    impersonationNotAllowed: 'This action cannot be performed while impersonating',
    roleRequired: 'Your role cannot perform this action',
    permissionRequired: 'You lack the required permission',
    orgMembershipRequired: 'You must be a member of this organization',
  },

  // Optional: Custom organization role permissions
  // Override the default role-permission mapping
  orgRolePermissions: {
    owner: { organization: 'all', member: 'all' },
    admin: { organization: ['read', 'update'], member: ['read', 'create'] },
    member: { organization: ['read'] },
  },
});

Asynchronous Configuration

// Using useFactory
AuthModule.forRootAsync({
  imports: [ConfigModule],
  useFactory: (config: ConfigService) => ({
    auth: createAuth(config.get('AUTH_SECRET')),
  }),
  inject: [ConfigService],
});

// Using useClass
AuthModule.forRootAsync({
  useClass: AuthConfigService,
});

// Using useExisting
AuthModule.forRootAsync({
  imports: [ConfigModule],
  useExisting: ConfigService,
});

Default Auth Behavior

Control how routes behave by default:

'require' (default) - Secure by default

All routes require authentication. Use @AllowAnonymous() for public routes:

@Controller('api')
export class ApiController {
  @Get('protected')
  protectedRoute() {} // Requires auth

  @AllowAnonymous()
  @Get('public')
  publicRoute() {} // No auth required
}

'public' - Open by default

All routes are public. Use @RequireAuth() for protected routes:

AuthModule.forRoot({
  auth,
  defaultAuthBehavior: 'public',
});

@Controller('api')
export class ApiController {
  @Get('public')
  publicRoute() {} // No auth required

  @RequireAuth()
  @Get('protected')
  protectedRoute() {} // Requires auth
}

'optional' - Flexible auth

All routes accept both authenticated and anonymous requests:

AuthModule.forRoot({
  auth,
  defaultAuthBehavior: 'optional',
});

@Controller('api')
export class ApiController {
  @Get('greeting')
  greet(@CurrentUser() user: User | null) {
    return user ? `Hello ${user.name}` : 'Hello guest';
  }
}

πŸ”Œ Multi-Context Support

HTTP (Default)

Works out of the box with Fastify HTTP adapter.

GraphQL

// Install dependencies
pnpm add @nestjs/graphql graphql

// Decorators work the same way in resolvers
@Resolver()
export class UserResolver {
  @Query(() => User)
  @Roles(['admin'])
  async users(@CurrentUser() user: UserSession['user']) {
    return this.userService.findAll();
  }
}

WebSocket

// Install dependencies
pnpm add @nestjs/websockets @nestjs/platform-socket.io

// Decorators work in gateways
@WebSocketGateway()
export class EventsGateway {
  @SubscribeMessage('events')
  handleEvent(@Session() session: UserSession) {
    return { user: session.user };
  }
}

πŸ”§ Utility Functions

The library exports utility functions for working with Fastify and Web standard APIs:

import {
  toWebHeaders,
  toWebRequest,
  getHeadersFromRequest,
  getWebHeadersFromRequest,
  writeWebResponseToReply,
  normalizeBasePath,
  getRequestFromContext,
} from '@sapix/nestjs-better-auth-fastify';

// Convert Fastify headers to Web standard Headers
const webHeaders = toWebHeaders(request.headers);

// Get Web standard Headers from Fastify Request
const headers = getWebHeadersFromRequest(request);

// Build Web standard Request from Fastify Request
const webRequest = toWebRequest(request);

// Write Web Response to Fastify Reply
await writeWebResponseToReply(response, reply);

// Normalize basePath (ensures starts with /, no trailing /)
const path = normalizeBasePath('api/auth/'); // '/api/auth'

// Get FastifyRequest from NestJS ExecutionContext (supports HTTP, GraphQL, WebSocket)
const request = getRequestFromContext(context);

πŸ“ Request Extension

The library extends FastifyRequest with auth-related properties:

declare module 'fastify' {
  interface FastifyRequest {
    session: UserSession | null;
    user: UserSession['user'] | null;
    apiKey?: ApiKeyValidation['key'] | null;
    organization?: Organization | null;
    organizationMember?: OrganizationMember | null;
    isImpersonating?: boolean;
    impersonatedBy?: string | null;
  }
}

Access directly in route handlers:

@Get('profile')
getProfile(@Req() request: FastifyRequest) {
  return {
    user: request.user,
    session: request.session,
    org: request.organization,
    isImpersonating: request.isImpersonating,
  };
}

πŸ§ͺ Testing

Unit Testing

import { Test } from '@nestjs/testing';
import {
  AuthModule,
  AuthService,
  AUTH_MODULE_OPTIONS,
} from '@sapix/nestjs-better-auth-fastify';

const module = await Test.createTestingModule({
  imports: [AuthModule.forRoot({ auth })],
}).compile();

const authService = module.get(AuthService);

Mocking AuthService

const mockAuthService = {
  getSessionFromRequest: jest.fn().mockResolvedValue(mockSession),
  validateSession: jest.fn().mockResolvedValue(mockSession),
  hasRole: jest.fn().mockReturnValue(true),
  hasPermission: jest.fn().mockReturnValue(true),
  isSessionFresh: jest.fn().mockReturnValue(true),
  isUserBanned: jest.fn().mockReturnValue(false),
  isImpersonating: jest.fn().mockReturnValue(false),
};

const module = await Test.createTestingModule({
  providers: [MyService, { provide: AuthService, useValue: mockAuthService }],
}).compile();

πŸ“‹ Requirements

  • Node.js >= 18.0.0
  • NestJS >= 10.0.0
  • Fastify >= 4.0.0
  • Better Auth >= 1.0.0

🀝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add some amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

πŸ“„ License

MIT

πŸ”— Links


Made with ❀️ for the NestJS community

About

Better Auth integration for NestJS with Fastify adapter

Topics

Resources

License

Stars

82 stars

Watchers

8 watching

Forks

Packages

 
 
 

Contributors