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
1 change: 1 addition & 0 deletions .claude/rules/configurable-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ This pattern is currently applied to:
| BetterAuth JWT Plugin | `betterAuth.jwt` | Boolean Shorthand | `expiresIn: '15m'` |
| BetterAuth 2FA Plugin | `betterAuth.twoFactor` | Boolean Shorthand | `appName: 'Nest Server'` |
| BetterAuth Passkey Plugin | `betterAuth.passkey` | Boolean Shorthand | `rpName: 'Nest Server'` |
| BetterAuth Disable Sign-Up | `betterAuth.emailAndPassword.disableSignUp` | Explicit Boolean | `false` (sign-up enabled) |

## Checklist for New Configurable Features

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.3",
"version": "11.13.4",
"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.3
version: 11.13.4
contact:
name: lenne.Tech GmbH
url: https://lenne.tech
Expand Down
17 changes: 10 additions & 7 deletions src/core/common/interfaces/server-options.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1693,6 +1693,14 @@ interface IBetterAuthBase {
* Set `enabled: false` to explicitly disable email/password auth.
*/
emailAndPassword?: {
/**
* Disable user registration (sign-up) via BetterAuth.
* Passed through to better-auth's native emailAndPassword.disableSignUp.
* Custom endpoints (GraphQL + REST) also check this flag early.
* @default false
*/
disableSignUp?: boolean;

/**
* Whether email/password authentication is enabled.
* @default true
Expand Down Expand Up @@ -1993,10 +2001,7 @@ interface IBetterAuthBase {
* };
* ```
*/
type IBetterAuthPasskeyDisabled =
| false
| (Omit<IBetterAuthPasskeyConfig, 'enabled'> & { enabled: false })
| undefined;
type IBetterAuthPasskeyDisabled = false | (Omit<IBetterAuthPasskeyConfig, 'enabled'> & { enabled: false }) | undefined;

/**
* Passkey configuration that is considered "enabled".
Expand All @@ -2006,9 +2011,7 @@ type IBetterAuthPasskeyDisabled =
* - `{ enabled: true, ... }` (explicit enabled)
* - `{ rpName: 'My App', ... }` (config without explicit enabled = defaults to true)
*/
type IBetterAuthPasskeyEnabled =
| (Omit<IBetterAuthPasskeyConfig, 'enabled'> & { enabled?: true })
| true;
type IBetterAuthPasskeyEnabled = (Omit<IBetterAuthPasskeyConfig, 'enabled'> & { enabled?: true }) | true;

/**
* BetterAuth configuration WITHOUT Passkey (or Passkey disabled).
Expand Down
6 changes: 6 additions & 0 deletions src/core/modules/better-auth/INTEGRATION-CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,12 @@ After integration, verify:
- [ ] Passkey login redirects to dashboard after successful authentication
- [ ] Passkey can be registered, listed, and deleted from security settings

### Optional: Disable Sign-Up (`emailAndPassword.disableSignUp: true`)
- [ ] REST `POST /iam/sign-up/email` returns `400` with error `LTNS_0026`
- [ ] GraphQL `betterAuthSignUp` returns error `LTNS_0026`
- [ ] `GET /iam/features` reports `signUpEnabled: false`
- [ ] Sign-in still works for existing users

### Additional checks for Migration scenario:
- [ ] Sign-in via Legacy Auth works for BetterAuth-created users
- [ ] Sign-in via BetterAuth works for Legacy-created users
Expand Down
32 changes: 32 additions & 0 deletions src/core/modules/better-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,32 @@ const config = {
};
```

### Disable Sign-Up

Disable user registration while keeping sign-in active (e.g., invite-only apps, admin-created accounts):

```typescript
const config = {
betterAuth: {
emailAndPassword: {
disableSignUp: true, // Block new registrations (REST + GraphQL)
},
},
};
```

When disabled:
- REST `POST /iam/sign-up/email` returns `400 Bad Request` with error `LTNS_0026`
- GraphQL `betterAuthSignUp` mutation returns error `LTNS_0026`
- `betterAuthFeatures` reports `signUpEnabled: false`
- Sign-in continues to work for existing users

**Defense in Depth:** The flag is enforced at two layers:
1. **Custom check** (`CoreBetterAuthService.ensureSignUpEnabled()`) runs in Controller/Resolver *before* any BetterAuth API call and returns a structured `LTNS_0026` error.
2. **Native BetterAuth** `emailAndPassword.disableSignUp` acts as a safety net for any direct API access that bypasses the custom check.

**Default:** `false` (sign-up enabled) - fully backward compatible.

### Additional User Fields

Add custom fields to the Better-Auth user schema:
Expand Down Expand Up @@ -1049,6 +1075,7 @@ type CoreBetterAuthFeaturesModel {
jwt: Boolean!
twoFactor: Boolean!
passkey: Boolean!
signUpEnabled: Boolean!
socialProviders: [String!]!
}
```
Expand Down Expand Up @@ -1100,6 +1127,7 @@ query {
jwt
twoFactor
passkey
signUpEnabled
socialProviders
}
}
Expand Down Expand Up @@ -1247,6 +1275,7 @@ export class MyService {
| `isJwtEnabled()` | Check if JWT plugin is enabled |
| `isTwoFactorEnabled()` | Check if 2FA is enabled |
| `isPasskeyEnabled()` | Check if Passkey is enabled |
| `isSignUpEnabled()` | Check if sign-up is enabled |
| `getEnabledSocialProviders()` | Get list of enabled social providers |
| `getBasePath()` | Get the base path for endpoints |
| `getBaseUrl()` | Get the base URL |
Expand Down Expand Up @@ -1970,6 +1999,9 @@ These protected methods are available for use in your custom resolver:
// Check if Better-Auth is enabled (throws if not)
this.ensureEnabled();

// Check if sign-up is enabled (throws if not) - delegated to service
this.betterAuthService.ensureSignUpEnabled();

// Convert Express headers to Web API Headers
const headers = this.convertHeaders(ctx.req.headers);

Expand Down
14 changes: 12 additions & 2 deletions src/core/modules/better-auth/better-auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,12 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Bett
// Enable email/password authentication by default (required by Better-Auth 1.x)
// Can be disabled by setting config.emailAndPassword.enabled = false
emailAndPassword: {
// Defense in Depth: This native Better-Auth flag is the second layer.
// The first layer is CoreBetterAuthService.ensureSignUpEnabled() which
// runs in Controller/Resolver BEFORE the BetterAuth API is called and
// returns a structured LTNS_0026 error. The native flag acts as a safety
// net in case the custom check is bypassed (e.g., direct API calls).
disableSignUp: config.emailAndPassword?.disableSignUp === true,
enabled: config.emailAndPassword?.enabled !== false,
password: {
hash: nativeScryptHash,
Expand Down Expand Up @@ -426,7 +432,7 @@ function buildEmailVerificationConfig(
_request?: Request,
) => {
// Don't await to prevent timing attacks (as recommended by Better-Auth docs)

sendVerificationEmail(data);
};
}
Expand Down Expand Up @@ -918,7 +924,11 @@ function normalizePasskeyConfig(
// Resolve values: explicit config > resolved URLs
const finalRpId = rawConfig.rpId || resolvedUrls.rpId;
const finalOrigin = rawConfig.origin || resolvedUrls.appUrl;
const finalTrustedOrigins = config.trustedOrigins?.length ? config.trustedOrigins : resolvedUrls.appUrl ? [resolvedUrls.appUrl] : undefined;
const finalTrustedOrigins = config.trustedOrigins?.length
? config.trustedOrigins
: resolvedUrls.appUrl
? [resolvedUrls.appUrl]
: undefined;

// Check if we have all required values for Passkey
const hasRequiredConfig = finalRpId && finalOrigin && finalTrustedOrigins?.length;
Expand Down
3 changes: 3 additions & 0 deletions src/core/modules/better-auth/core-better-auth-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ export class CoreBetterAuthFeaturesModel {
@Field(() => Boolean, { description: 'Whether Passkey is enabled' })
passkey: boolean;

@Field(() => Boolean, { description: 'Whether sign-up is enabled' })
signUpEnabled: boolean;

@Field(() => [String], { description: 'List of enabled social providers' })
socialProviders: string[];

Expand Down
60 changes: 44 additions & 16 deletions src/core/modules/better-auth/core-better-auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@ import {
Res,
UnauthorizedException,
} from '@nestjs/common';
import { ApiBody, ApiCreatedResponse, ApiExcludeEndpoint, ApiOkResponse, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger';
import {
ApiBody,
ApiCreatedResponse,
ApiExcludeEndpoint,
ApiOkResponse,
ApiOperation,
ApiProperty,
ApiTags,
} from '@nestjs/swagger';
import { Request, Response } from 'express';

import { Roles } from '../../common/decorators/roles.decorator';
Expand Down Expand Up @@ -251,7 +259,10 @@ export class CoreBetterAuthController {
* @since 11.13.0
*/
@ApiOkResponse({ description: 'Better-Auth feature flags' })
@ApiOperation({ description: 'Get enabled Better-Auth features for client-side feature detection', summary: 'Get Features' })
@ApiOperation({
description: 'Get enabled Better-Auth features for client-side feature detection',
summary: 'Get Features',
})
@Get('features')
@Roles(RoleEnum.S_EVERYONE)
getFeatures(): Record<string, boolean | number | string[]> {
Expand All @@ -262,6 +273,7 @@ export class CoreBetterAuthController {
passkey: this.betterAuthService.isPasskeyEnabled(),
resendCooldownSeconds: this.emailVerificationService?.getConfig()?.resendCooldownSeconds ?? 60,
signUpChecks: this.signUpValidator?.isEnabled() ?? false,
signUpEnabled: this.betterAuthService.isSignUpEnabled(),
socialProviders: this.betterAuthService.getEnabledSocialProviders(),
twoFactor: this.betterAuthService.isTwoFactorEnabled(),
};
Expand Down Expand Up @@ -345,7 +357,9 @@ export class CoreBetterAuthController {
// Without this, users with 2FA enabled but unverified email could bypass verification
await this.checkEmailVerificationByEmail(input.email);

this.logger.debug(`2FA required for ${maskEmail(input.email)}, forwarding to native handler for cookie handling`);
this.logger.debug(
`2FA required for ${maskEmail(input.email)}, forwarding to native handler for cookie handling`,
);

// Forward to native Better Auth handler which sets the session cookie correctly
// We need to modify the request body to use the normalized password
Expand All @@ -370,7 +384,7 @@ export class CoreBetterAuthController {
body: modifiedBody,
headers: new Headers({
'Content-Type': 'application/json',
'Origin': req.headers.origin || baseUrl,
Origin: req.headers.origin || baseUrl,
}),
method: 'POST',
});
Expand Down Expand Up @@ -407,7 +421,8 @@ export class CoreBetterAuthController {
const mappedUser = await this.userMapper.mapSessionUser(response.user);

// Get token: JWT accessToken > top-level token > session.token
const rawToken = responseAny.accessToken || responseAny.token || (hasSession(response) ? response.session.token : undefined);
const rawToken =
responseAny.accessToken || responseAny.token || (hasSession(response) ? response.session.token : undefined);
const token = await this.resolveJwtToken(rawToken);

const result: CoreBetterAuthResponse = {
Expand Down Expand Up @@ -464,6 +479,7 @@ export class CoreBetterAuthController {
@Body() input: CoreBetterAuthSignUpInput,
): Promise<CoreBetterAuthResponse> {
this.ensureEnabled();
this.betterAuthService.ensureSignUpEnabled();

// Validate sign-up input (termsAndPrivacyAccepted is required by default)
if (this.signUpValidator) {
Expand Down Expand Up @@ -494,7 +510,9 @@ export class CoreBetterAuthController {
if (hasUser(response)) {
// Link or create user in our database
// Pass termsAndPrivacyAccepted to store the acceptance timestamp
await this.userMapper.linkOrCreateUser(response.user, { termsAndPrivacyAccepted: input.termsAndPrivacyAccepted });
await this.userMapper.linkOrCreateUser(response.user, {
termsAndPrivacyAccepted: input.termsAndPrivacyAccepted,
});

// Sync password to legacy (enables IAM Sign-Up → Legacy Sign-In)
// Pass the plain password so it can be hashed with bcrypt for Legacy Auth
Expand All @@ -506,7 +524,8 @@ export class CoreBetterAuthController {
// Without this, no session cookies are set after sign-up, causing 401 on
// subsequent authenticated requests (e.g., Passkey, 2FA, /token)
const responseAny = response as any;
const rawToken = responseAny.accessToken || responseAny.token || (hasSession(response) ? response.session.token : undefined);
const rawToken =
responseAny.accessToken || responseAny.token || (hasSession(response) ? response.session.token : undefined);
const token = await this.resolveJwtToken(rawToken);

// If email verification is enabled, revoke the session and don't return session data
Expand All @@ -518,7 +537,9 @@ export class CoreBetterAuthController {
await this.betterAuthService.revokeSession(sessionToken);
}
this.clearAuthCookies(res);
this.logger.debug(`[SignUp] Email verification required for ${maskEmail(response.user.email)}, session revoked`);
this.logger.debug(
`[SignUp] Email verification required for ${maskEmail(response.user.email)}, session revoked`,
);
return {
emailVerificationRequired: true,
requiresTwoFactor: false,
Expand Down Expand Up @@ -692,10 +713,7 @@ export class CoreBetterAuthController {
* @throws UnauthorizedException if email is not verified and verification is required
*/
protected checkEmailVerification(sessionUser: BetterAuthSessionUser): void {
if (
this.emailVerificationService?.isEnabled()
&& !sessionUser.emailVerified
) {
if (this.emailVerificationService?.isEnabled() && !sessionUser.emailVerified) {
this.logger.debug(`[SignIn] Email not verified for ${maskEmail(sessionUser.email)}, blocking login`);
throw new UnauthorizedException(ErrorCode.EMAIL_VERIFICATION_REQUIRED);
}
Expand Down Expand Up @@ -764,7 +782,9 @@ export class CoreBetterAuthController {
* NOTE: The session token is intentionally NOT included in the response.
* It is set as an httpOnly cookie for security.
*/
protected mapSession(session: null | undefined | { expiresAt: Date; id: string; token?: string }): CoreBetterAuthSessionInfo | undefined {
protected mapSession(
session: null | undefined | { expiresAt: Date; id: string; token?: string },
): CoreBetterAuthSessionInfo | undefined {
if (!session) return undefined;
return {
expiresAt: session.expiresAt instanceof Date ? session.expiresAt.toISOString() : String(session.expiresAt),
Expand All @@ -778,7 +798,7 @@ export class CoreBetterAuthController {
* @param sessionUser - The user from Better-Auth session
* @param _mappedUser - The synced user from legacy system (available for override customization)
*/

protected mapUser(sessionUser: BetterAuthSessionUser, _mappedUser: any): CoreBetterAuthUserResponse {
return {
email: sessionUser.email,
Expand All @@ -802,7 +822,11 @@ export class CoreBetterAuthController {
* @param result - The CoreBetterAuthResponse to return
* @param sessionToken - Optional session token to set in cookies (if not provided, uses result.token)
*/
protected processCookies(res: Response, result: CoreBetterAuthResponse, sessionToken?: string): CoreBetterAuthResponse {
protected processCookies(
res: Response,
result: CoreBetterAuthResponse,
sessionToken?: string,
): CoreBetterAuthResponse {
const cookiesEnabled = this.configService.getFastButReadOnly('cookies') !== false;

// If a specific session token is provided, use it directly
Expand Down Expand Up @@ -883,7 +907,11 @@ export class CoreBetterAuthController {
this.logger.error(`Better Auth handler error: ${error instanceof Error ? error.message : 'Unknown error'}`);

// Re-throw NestJS exceptions
if (error instanceof BadRequestException || error instanceof UnauthorizedException || error instanceof InternalServerErrorException) {
if (
error instanceof BadRequestException ||
error instanceof UnauthorizedException ||
error instanceof InternalServerErrorException
) {
throw error;
}

Expand Down
Loading