A comprehensive Better Auth integration for NestJS with Fastify adapter.
English | δΈζ
- Features
- Installation
- Quick Start
- Decorators Reference
- Hook System
- AuthService API
- Type Inference
- Configuration
- Multi-Context Support
- Utility Functions
- Request Extension
- Testing
- Requirements
- Contributing
- License
- π 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
# 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-authInstall these based on your needs:
# For GraphQL support
pnpm add @nestjs/graphql graphql
# For WebSocket support
pnpm add @nestjs/websockets @nestjs/platform-socket.io// 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;// 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 {}// 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}` };
}
}| 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 |
// 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' })// 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() {}Requires
admin()plugin frombetter-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() {}Requires
bearer()plugin frombetter-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/dataRequires
apiKey()plugin frombetter-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/externalNote: API keys must be sent via dedicated headers (default:
x-api-key). Custom headers can be configured via Better Auth'sapiKeypluginapiKeyHeadersoption. Do NOT useAuthorization: Bearerfor API keys - that's reserved for session tokens.
Requires
organization()plugin frombetter-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| 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 };
}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);
}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);
}
}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;
}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 |
Use @OrgRequired() or @OptionalOrg() |
|
orgMember |
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 |
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()usesRequireAuth()to ensure authentication regardless ofdefaultAuthBehaviorsetting. This makes the decorator behavior predictable and independent of global configuration.
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) { ... }The hook system allows you to execute custom logic before and after Better Auth processes authentication requests.
// 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);
}
}// src/app.module.ts
@Module({
imports: [AuthModule.forRoot({ auth })],
providers: [SignUpHook], // Register hook provider
})
export class AppModule {}| 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 provides programmatic access to Better Auth functionality.
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),
});
}
}// 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);// 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
}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'],
});// Get active organization
const org = await this.authService.getActiveOrganization(request);
// Check organization permission
const hasPermission = await this.authService.hasOrgPermission(request, {
resource: 'member',
action: 'create',
});const jwt = await this.authService.getJwtToken(request);
if (jwt) {
// Use JWT for service-to-service communication
}// Get the complete Better Auth instance
const authInstance = this.authService.instance;
// Get the configured basePath
const basePath = this.authService.basePath;The library supports full type inference from your Better Auth configuration.
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;import { InferSession, InferUser } from '@sapix/nestjs-better-auth-fastify';
import type { Auth } from './auth/auth.config';
type MySession = InferSession<Auth>;
type MyUser = InferUser<Auth>;interface CustomUser extends BaseUser {
role: string;
permissions: string[];
department: string;
}
@Get('profile')
getProfile(@Session() session: UserSession<CustomUser>) {
return session.user.department; // Type-safe
}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'] },
},
});// 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,
});Control how routes behave 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
}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
}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';
}
}Works out of the box with Fastify HTTP adapter.
// 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();
}
}// 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 };
}
}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);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,
};
}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);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();- Node.js >= 18.0.0
- NestJS >= 10.0.0
- Fastify >= 4.0.0
- Better Auth >= 1.0.0
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
MIT
Made with β€οΈ for the NestJS community
