Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions .claude/rules/better-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,146 @@ When changes affect existing test expectations:
3. **Update test** to match new correct behavior
4. **Document** why the test was changed in commit message

## 4. Customization Patterns

When a project needs custom BetterAuth behavior, follow these patterns:

### Module Registration Patterns

| Pattern | Use When | Configuration |
|---------|----------|---------------|
| **Zero-Config** | No customization needed | `CoreModule.forRoot(envConfig)` |
| **Config-based** | Custom Controller/Resolver | `betterAuth: { controller, resolver }` in config |
| **Separate Module** | Full control, additional providers | `betterAuth: { autoRegister: false }` |

### Pattern Selection Decision Tree

1. Does the project need custom Controller or Resolver?
- No → Use Zero-Config (Pattern 1)
- Yes → Continue to 2

2. Does the project need additional providers or complex module structure?
- No → Use Config-based (Pattern 2) - add `controller`/`resolver` to `betterAuth` config
- Yes → Use Separate Module (Pattern 3) - set `autoRegister: false`

### Critical: Resolver Decorator Re-declaration

When customizing the Resolver, **ALL decorators MUST be re-declared**:

```typescript
// WRONG - method won't appear in GraphQL schema!
override async betterAuthSignUp(...) {
return super.betterAuthSignUp(...);
}

// CORRECT - all decorators re-declared
@Mutation(() => BetterAuthAuthModel)
@Roles(RoleEnum.S_EVERYONE)
override async betterAuthSignUp(...) {
return super.betterAuthSignUp(...);
}
```

**Why:** GraphQL schema is built from decorators at compile time. Parent class is `isAbstract: true`.

### Email Template Customization

Templates are resolved in order:
1. `<template>-<locale>.ejs` in project templates
2. `<template>.ejs` in project templates
3. `<template>-<locale>.ejs` in nest-server (fallback)
4. `<template>.ejs` in nest-server (fallback)

To override: Create `src/templates/email-verification-de.ejs` in the project.

### Avoiding "forRoot() called twice" Warning

If you see this warning, the project has duplicate registration:

**Solutions:**
1. Move `controller`/`resolver` to `config.betterAuth` (Pattern 2)
2. Set `betterAuth.autoRegister: false` (Pattern 3)

**See:** `src/core/modules/better-auth/CUSTOMIZATION.md` for complete documentation.

## 5. RolesGuard Architecture

### Two Guard Implementations

The BetterAuth module provides two RolesGuard implementations:

| Guard | Used In | Key Characteristics |
|-------|---------|---------------------|
| `RolesGuard` | Legacy + Hybrid Mode | Extends `AuthGuard(JWT)`, supports Passport |
| `BetterAuthRolesGuard` | IAM-Only Mode | No Passport, no constructor dependencies |

### Why BetterAuthRolesGuard Exists

**Problem:** `AuthGuard()` from `@nestjs/passport` is a **mixin** that generates `design:paramtypes` metadata. When `RolesGuard extends AuthGuard(JWT)` is registered as `APP_GUARD` in a dynamic module (Pattern 3: `autoRegister: false`), NestJS DI fails to inject `Reflector` and `ModuleRef`.

**Error:** `Reflector not available - RolesGuard cannot function without it`

**Solution:** `BetterAuthRolesGuard` with NO constructor dependencies:

```typescript
@Injectable()
export class BetterAuthRolesGuard implements CanActivate {
// NO constructor dependencies - avoids mixin DI conflict

async canActivate(context: ExecutionContext): Promise<boolean> {
// Use Reflect.getMetadata directly (not NestJS Reflector)
const roles = Reflect.getMetadata('roles', context.getHandler());

// Access services via static module reference
const tokenService = CoreBetterAuthModule.getTokenServiceInstance();

// ... role checking logic identical to RolesGuard
}
}
```

### Guard Selection Logic

In `CoreBetterAuthModule.createDeferredModule()`:

```typescript
// IAM-Only Mode: Use BetterAuthRolesGuard (no Passport dependency)
providers: [
BetterAuthRolesGuard,
{ provide: APP_GUARD, useExisting: BetterAuthRolesGuard },
]
```

In `CoreAuthModule` (Legacy Mode):

```typescript
// Legacy Mode: Use RolesGuard (extends AuthGuard for Passport support)
providers: [
{ provide: APP_GUARD, useClass: RolesGuard },
]
```

### Security Equivalence

Both guards implement identical security logic:
- Same `@Roles()` decorator processing
- Same role checks (S_USER, S_EVERYONE, S_VERIFIED, S_SELF, S_CREATOR, S_NO_ONE)
- Same token verification (via BetterAuthTokenService)
- Same error responses (401 Unauthorized, 403 Forbidden)

### When Working on Guards

1. **Changes to role logic** → Update BOTH guards
2. **New system roles** → Add to BOTH guards
3. **Token verification changes** → Update `BetterAuthTokenService` (shared by both)
4. **Testing** → Test both Legacy Mode and IAM-Only Mode

## Summary

| Principle | Requirement |
|-----------|-------------|
| Standard Compliance | Stay close to Better-Auth, minimize custom code |
| Security | Maximum security, thorough review before completion |
| Testing | Full coverage, all tests pass, security tests included |
| Customization | Use correct registration pattern, re-declare Resolver decorators |
| Guards | Maintain both RolesGuard and BetterAuthRolesGuard in sync |
16 changes: 16 additions & 0 deletions migration-guides/11.12.x-to-11.13.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -581,11 +581,27 @@ Full reference for REST, GraphQL, and cookie-based testing:

- **README:** [src/core/modules/better-auth/README.md](../src/core/modules/better-auth/README.md)
- **Integration Checklist:** [src/core/modules/better-auth/INTEGRATION-CHECKLIST.md](../src/core/modules/better-auth/INTEGRATION-CHECKLIST.md)
- **Customization Guide:** [src/core/modules/better-auth/CUSTOMIZATION.md](../src/core/modules/better-auth/CUSTOMIZATION.md)
- **Reference Implementation:** `src/server/modules/iam/`
- **Key Files:**
- `core-better-auth-email-verification.service.ts` - Email verification logic and template resolution
- `core-better-auth-signup-validator.service.ts` - Sign-up validation with configurable required fields

### Module Registration Patterns

When customizing BetterAuth, use the appropriate registration pattern:

| Pattern | Use When | Configuration |
|---------|----------|---------------|
| **Zero-Config** | No customization | `CoreModule.forRoot(envConfig)` |
| **Config-based** | Custom Controller/Resolver | `betterAuth: { controller, resolver }` |
| **Separate Module** | Full control, additional providers | `betterAuth: { autoRegister: false }` |

**See [CUSTOMIZATION.md](../src/core/modules/better-auth/CUSTOMIZATION.md) for detailed guidance on:**
- Customizing Controller, Resolver, and Services
- Email template customization (EJS and Brevo)
- Avoiding the "forRoot() called twice" warning

---

## References
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lenne.tech/nest-server",
"version": "11.13.1",
"version": "11.13.2",
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
"keywords": [
"node",
Expand Down
2 changes: 1 addition & 1 deletion spectaql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ servers:
info:
title: lT Nest Server
description: Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).
version: 11.13.1
version: 11.13.2
contact:
name: lenne.Tech GmbH
url: https://lenne.tech
Expand Down
15 changes: 12 additions & 3 deletions src/config.env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,11 +265,20 @@ const config: { [env: string]: IServerOptions } = {
automaticObjectIdFiltering: true,
baseUrl: process.env.BASE_URL,
betterAuth: {
rateLimit: { enabled: process.env.RATE_LIMIT_ENABLED !== 'false', max: parseInt(process.env.RATE_LIMIT_MAX || '10', 10) },
rateLimit: {
enabled: process.env.RATE_LIMIT_ENABLED !== 'false',
max: parseInt(process.env.RATE_LIMIT_MAX || '10', 10),
},
secret: process.env.BETTER_AUTH_SECRET,
socialProviders: {
github: { clientId: process.env.SOCIAL_GITHUB_CLIENT_ID || '', clientSecret: process.env.SOCIAL_GITHUB_CLIENT_SECRET || '' },
google: { clientId: process.env.SOCIAL_GOOGLE_CLIENT_ID || '', clientSecret: process.env.SOCIAL_GOOGLE_CLIENT_SECRET || '' },
github: {
clientId: process.env.SOCIAL_GITHUB_CLIENT_ID || '',
clientSecret: process.env.SOCIAL_GITHUB_CLIENT_SECRET || '',
},
google: {
clientId: process.env.SOCIAL_GOOGLE_CLIENT_ID || '',
clientSecret: process.env.SOCIAL_GOOGLE_CLIENT_SECRET || '',
},
},
twoFactor: { appName: process.env.TWO_FACTOR_APP_NAME || 'Nest Server' },
},
Expand Down
122 changes: 120 additions & 2 deletions src/core.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,15 @@ export class CoreModule implements NestModule {
};
}

// Check if autoRegister: false for IAM-only mode (project imports its own BetterAuth module)
const rawBetterAuth = options?.betterAuth;
const isAutoRegisterDisabledEarly = typeof rawBetterAuth === 'object' && rawBetterAuth?.autoRegister === false;

// Build GraphQL driver configuration based on auth mode
const graphQlDriverConfig = isIamOnlyMode
? this.buildIamOnlyGraphQlDriver(cors, options)
? (isAutoRegisterDisabledEarly
? this.buildLazyIamGraphQlDriver(cors, options)
: this.buildIamOnlyGraphQlDriver(cors, options))
: this.buildLegacyGraphQlDriver(AuthService, AuthModule, cors, options);

const config: IServerOptions = merge(
Expand Down Expand Up @@ -267,17 +273,27 @@ export class CoreModule implements NestModule {
: isExplicitlyEnabled;

const isAutoRegister = typeof betterAuthConfig === 'object' && betterAuthConfig?.autoRegister === true;
// autoRegister: false means the project imports its own BetterAuthModule separately
const isAutoRegisterDisabled = typeof betterAuthConfig === 'object' && betterAuthConfig?.autoRegister === false;

// Extract custom controller/resolver from config (Pattern 2: Config-based)
const configController = typeof betterAuthConfig === 'object' ? betterAuthConfig?.controller : undefined;
const configResolver = typeof betterAuthConfig === 'object' ? betterAuthConfig?.resolver : undefined;

if (isBetterAuthEnabled) {
if (isIamOnlyMode || isAutoRegister) {
if ((isIamOnlyMode && !isAutoRegisterDisabled) || isAutoRegister) {
imports.push(
CoreBetterAuthModule.forRoot({
config: betterAuthConfig === true ? {} : betterAuthConfig || {},
// Pass custom controller/resolver from config (Pattern 2)
controller: configController,
// Pass JWT secrets for backwards compatibility fallback
fallbackSecrets: [config.jwt?.secret, config.jwt?.refresh?.secret],
// In IAM-only mode, register RolesGuard globally to enforce @Roles() decorators
// In Legacy mode (autoRegister), RolesGuard is already registered via CoreAuthModule
registerRolesGuardGlobally: isIamOnlyMode,
// Pass custom resolver from config (Pattern 2)
resolver: configResolver,
// Pass server-level URLs for Passkey auto-detection
// When env: 'local', defaults are: baseUrl=localhost:3000, appUrl=localhost:3001
serverAppUrl: config.appUrl,
Expand Down Expand Up @@ -399,6 +415,108 @@ export class CoreModule implements NestModule {
};
}

/**
* Build a lazy GraphQL driver for IAM-only mode with autoRegister: false.
*
* When autoRegister: false, CoreBetterAuthModule is NOT imported by CoreModule,
* so we cannot use `imports` or `inject` to get BetterAuth services.
* Instead, we resolve them lazily via static getters on CoreBetterAuthModule.
* This is safe because `onConnect` is only called when a WebSocket connection is made,
* which happens after all modules are initialized.
*/
private static buildLazyIamGraphQlDriver(cors: object, options: Partial<IServerOptions>) {
return {
useFactory: async () =>
Object.assign(
{
autoSchemaFile: 'schema.gql',
context: ({ req, res }) => ({ req, res }),
cors,
installSubscriptionHandlers: true,
subscriptions: {
'graphql-ws': {
context: ({ extra }) => extra,
onConnect: async (context: Context<any>) => {
const { connectionParams, extra } = context;
const enableAuth = options?.graphQl?.enableSubscriptionAuth ?? true;

if (enableAuth) {
const betterAuthService = CoreBetterAuthModule.getServiceInstance();
const userMapper = CoreBetterAuthModule.getUserMapperInstance();

if (!betterAuthService || !userMapper) {
throw new UnauthorizedException('BetterAuth not initialized');
}

const headers = CoreModule.getHeaderFromArray(extra.request?.rawHeaders);
const authToken: string =
connectionParams?.Authorization?.split(' ')[1] ?? headers.Authorization?.split(' ')[1];

if (authToken) {
const { session, user: sessionUser } = await betterAuthService.getSession({
headers: { authorization: `Bearer ${authToken}` },
});

if (!session || !sessionUser) {
throw new UnauthorizedException('Invalid or expired session');
}

const user = await userMapper.mapSessionUser(sessionUser);
if (!user) {
throw new UnauthorizedException('User not found');
}

extra.user = user;
extra.headers = connectionParams ?? headers;
return extra;
}

throw new UnauthorizedException('Missing authentication token');
}
},
},
'subscriptions-transport-ws': {
onConnect: async (connectionParams) => {
const enableAuth = options?.graphQl?.enableSubscriptionAuth ?? true;

if (enableAuth) {
const betterAuthService = CoreBetterAuthModule.getServiceInstance();
const userMapper = CoreBetterAuthModule.getUserMapperInstance();

if (!betterAuthService || !userMapper) {
throw new UnauthorizedException('BetterAuth not initialized');
}

const authToken: string = connectionParams?.Authorization?.split(' ')[1];

if (authToken) {
const { session, user: sessionUser } = await betterAuthService.getSession({
headers: { authorization: `Bearer ${authToken}` },
});

if (!session || !sessionUser) {
throw new UnauthorizedException('Invalid or expired session');
}

const user = await userMapper.mapSessionUser(sessionUser);
if (!user) {
throw new UnauthorizedException('User not found');
}

return { headers: connectionParams, user };
}

throw new UnauthorizedException('Missing authentication token');
}
},
},
},
},
options?.graphQl?.driver,
),
};
}

/**
* Build GraphQL driver configuration for Legacy Auth mode
*
Expand Down
Loading