diff --git a/.claude/rules/configurable-features.md b/.claude/rules/configurable-features.md index af206a2..e979b63 100644 --- a/.claude/rules/configurable-features.md +++ b/.claude/rules/configurable-features.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 9bf39c0..ed0011a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lenne.tech/nest-server", - "version": "11.13.3", + "version": "11.13.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lenne.tech/nest-server", - "version": "11.13.3", + "version": "11.13.4", "license": "MIT", "dependencies": { "@apollo/server": "5.4.0", diff --git a/package.json b/package.json index fa95a21..4cd7f22 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/spectaql.yml b/spectaql.yml index 5fa7cf5..05906cb 100644 --- a/spectaql.yml +++ b/spectaql.yml @@ -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 diff --git a/src/core/common/interfaces/server-options.interface.ts b/src/core/common/interfaces/server-options.interface.ts index f272d7c..6e8fc00 100644 --- a/src/core/common/interfaces/server-options.interface.ts +++ b/src/core/common/interfaces/server-options.interface.ts @@ -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 @@ -1993,10 +2001,7 @@ interface IBetterAuthBase { * }; * ``` */ -type IBetterAuthPasskeyDisabled = - | false - | (Omit & { enabled: false }) - | undefined; +type IBetterAuthPasskeyDisabled = false | (Omit & { enabled: false }) | undefined; /** * Passkey configuration that is considered "enabled". @@ -2006,9 +2011,7 @@ type IBetterAuthPasskeyDisabled = * - `{ enabled: true, ... }` (explicit enabled) * - `{ rpName: 'My App', ... }` (config without explicit enabled = defaults to true) */ -type IBetterAuthPasskeyEnabled = - | (Omit & { enabled?: true }) - | true; +type IBetterAuthPasskeyEnabled = (Omit & { enabled?: true }) | true; /** * BetterAuth configuration WITHOUT Passkey (or Passkey disabled). diff --git a/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md b/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md index 909bbfe..45ea0dd 100644 --- a/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +++ b/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md @@ -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 diff --git a/src/core/modules/better-auth/README.md b/src/core/modules/better-auth/README.md index b2c7873..2a11da0 100644 --- a/src/core/modules/better-auth/README.md +++ b/src/core/modules/better-auth/README.md @@ -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: @@ -1049,6 +1075,7 @@ type CoreBetterAuthFeaturesModel { jwt: Boolean! twoFactor: Boolean! passkey: Boolean! + signUpEnabled: Boolean! socialProviders: [String!]! } ``` @@ -1100,6 +1127,7 @@ query { jwt twoFactor passkey + signUpEnabled socialProviders } } @@ -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 | @@ -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); diff --git a/src/core/modules/better-auth/better-auth.config.ts b/src/core/modules/better-auth/better-auth.config.ts index a4121a0..6a8bb98 100644 --- a/src/core/modules/better-auth/better-auth.config.ts +++ b/src/core/modules/better-auth/better-auth.config.ts @@ -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, @@ -426,7 +432,7 @@ function buildEmailVerificationConfig( _request?: Request, ) => { // Don't await to prevent timing attacks (as recommended by Better-Auth docs) - + sendVerificationEmail(data); }; } @@ -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; diff --git a/src/core/modules/better-auth/core-better-auth-models.ts b/src/core/modules/better-auth/core-better-auth-models.ts index 36c017a..03174aa 100644 --- a/src/core/modules/better-auth/core-better-auth-models.ts +++ b/src/core/modules/better-auth/core-better-auth-models.ts @@ -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[]; diff --git a/src/core/modules/better-auth/core-better-auth.controller.ts b/src/core/modules/better-auth/core-better-auth.controller.ts index cbc1220..b945679 100644 --- a/src/core/modules/better-auth/core-better-auth.controller.ts +++ b/src/core/modules/better-auth/core-better-auth.controller.ts @@ -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'; @@ -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 { @@ -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(), }; @@ -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 @@ -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', }); @@ -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 = { @@ -464,6 +479,7 @@ export class CoreBetterAuthController { @Body() input: CoreBetterAuthSignUpInput, ): Promise { this.ensureEnabled(); + this.betterAuthService.ensureSignUpEnabled(); // Validate sign-up input (termsAndPrivacyAccepted is required by default) if (this.signUpValidator) { @@ -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 @@ -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 @@ -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, @@ -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); } @@ -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), @@ -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, @@ -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 @@ -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; } diff --git a/src/core/modules/better-auth/core-better-auth.module.ts b/src/core/modules/better-auth/core-better-auth.module.ts index 72c982d..1f66388 100644 --- a/src/core/modules/better-auth/core-better-auth.module.ts +++ b/src/core/modules/better-auth/core-better-auth.module.ts @@ -313,15 +313,17 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { if (CoreBetterAuthModule.currentConfig) { const globalConfig = ConfigService.configFastButReadOnly; const cookiesDisabled = globalConfig?.cookies === false; - const jwtExplicitlyDisabled = CoreBetterAuthModule.currentConfig.jwt === false - || (typeof CoreBetterAuthModule.currentConfig.jwt === 'object' && CoreBetterAuthModule.currentConfig.jwt?.enabled === false); + const jwtExplicitlyDisabled = + CoreBetterAuthModule.currentConfig.jwt === false || + (typeof CoreBetterAuthModule.currentConfig.jwt === 'object' && + CoreBetterAuthModule.currentConfig.jwt?.enabled === false); if (cookiesDisabled && jwtExplicitlyDisabled) { CoreBetterAuthModule.logger.warn( 'CONFIGURATION WARNING: cookies is set to false, but betterAuth.jwt is not enabled. ' + - 'Without cookies, BetterAuth cannot establish sessions via Set-Cookie headers. ' + - 'Enable betterAuth.jwt (set jwt: true in betterAuth config) to use Bearer token authentication, ' + - 'or set cookies: true to use cookie-based sessions.', + 'Without cookies, BetterAuth cannot establish sessions via Set-Cookie headers. ' + + 'Enable betterAuth.jwt (set jwt: true in betterAuth config) to use Bearer token authentication, ' + + 'or set cookies: true to use cookie-based sessions.', ); } } @@ -331,8 +333,8 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { if (CoreBetterAuthModule.rolesGuardExplicitlyDisabled && !RolesGuardRegistry.isRegistered()) { CoreBetterAuthModule.logger.warn( '⚠️ SECURITY WARNING: registerRolesGuardGlobally is explicitly set to false, ' + - 'but no RolesGuard is registered globally. @Roles() decorators will NOT enforce access control! ' + - 'Either set registerRolesGuardGlobally: true, or ensure CoreAuthModule (Legacy) is imported.', + 'but no RolesGuard is registered globally. @Roles() decorators will NOT enforce access control! ' + + 'Either set registerRolesGuardGlobally: true, or ensure CoreAuthModule (Legacy) is imported.', ); } } @@ -422,10 +424,10 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { if (this.forRootCalled && !process.env.VITEST) { this.logger.warn( 'CoreBetterAuthModule.forRoot() was called more than once. ' + - 'The second call is ignored by NestJS (DynamicModule deduplication). ' + - 'Custom controller/resolver from the second call will NOT be registered. ' + - 'Solutions: (1) Use betterAuth.controller/resolver in config, or ' + - '(2) Set betterAuth.autoRegister: false and import your module separately.', + 'The second call is ignored by NestJS (DynamicModule deduplication). ' + + 'Custom controller/resolver from the second call will NOT be registered. ' + + 'Solutions: (1) Use betterAuth.controller/resolver in config, or ' + + '(2) Set betterAuth.autoRegister: false and import your module separately.', ); if (this.cachedDynamicModule) { return this.cachedDynamicModule; @@ -453,11 +455,9 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { const effectiveRawConfig = rawConfig ?? globalConfig?.betterAuth; // Auto-detect fallbackSecrets from ConfigService if not explicitly provided - const effectiveFallbackSecrets = fallbackSecrets ?? ( - globalConfig?.jwt - ? [globalConfig.jwt.secret, globalConfig.jwt.refresh?.secret].filter(Boolean) - : undefined - ); + const effectiveFallbackSecrets = + fallbackSecrets ?? + (globalConfig?.jwt ? [globalConfig.jwt.secret, globalConfig.jwt.refresh?.secret].filter(Boolean) : undefined); // Auto-detect server URLs from ConfigService if not explicitly provided const effectiveServerAppUrl = serverAppUrl ?? globalConfig?.appUrl; @@ -489,7 +489,14 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { this.logger.debug('BetterAuth is disabled - skipping initialization'); this.betterAuthEnabled = false; this.cachedDynamicModule = { - exports: [BETTER_AUTH_INSTANCE, CoreBetterAuthService, CoreBetterAuthUserMapper, CoreBetterAuthRateLimiter, BetterAuthTokenService, CoreBetterAuthChallengeService], + exports: [ + BETTER_AUTH_INSTANCE, + CoreBetterAuthService, + CoreBetterAuthUserMapper, + CoreBetterAuthRateLimiter, + BetterAuthTokenService, + CoreBetterAuthChallengeService, + ], module: CoreBetterAuthModule, providers: [ { @@ -537,7 +544,16 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { static forRootAsync(): DynamicModule { return { controllers: [this.getControllerClass()], - exports: [BETTER_AUTH_INSTANCE, CoreBetterAuthService, CoreBetterAuthUserMapper, CoreBetterAuthRateLimiter, BetterAuthTokenService, CoreBetterAuthChallengeService, CoreBetterAuthEmailVerificationService, CoreBetterAuthSignUpValidatorService], + exports: [ + BETTER_AUTH_INSTANCE, + CoreBetterAuthService, + CoreBetterAuthUserMapper, + CoreBetterAuthRateLimiter, + BetterAuthTokenService, + CoreBetterAuthChallengeService, + CoreBetterAuthEmailVerificationService, + CoreBetterAuthSignUpValidatorService, + ], imports: [], module: CoreBetterAuthModule, providers: [ @@ -559,7 +575,10 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { { inject: [ConfigService, CoreBetterAuthEmailVerificationService], provide: BETTER_AUTH_INSTANCE, - useFactory: async (configService: ConfigService, emailVerificationService: CoreBetterAuthEmailVerificationService) => { + useFactory: async ( + configService: ConfigService, + emailVerificationService: CoreBetterAuthEmailVerificationService, + ) => { // Set static reference for callbacks BEFORE creating Better-Auth instance this.setEmailVerificationService(emailVerificationService); @@ -608,9 +627,8 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { // The original config object may be frozen (from ConfigService), so we // create a shallow copy with the resolved fallback secret applied. const resolvedSecret = config.secret || fallbackSecrets?.find((s) => s && s.length >= 32); - this.currentConfig = resolvedSecret && resolvedSecret !== config.secret - ? { ...config, secret: resolvedSecret } - : config; + this.currentConfig = + resolvedSecret && resolvedSecret !== config.secret ? { ...config, secret: resolvedSecret } : config; if (this.authInstance) { this.logger.log('BetterAuth initialized successfully'); @@ -725,7 +743,11 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { */ private static createEmailVerificationCallbacks(): { onEmailVerified: (userId: string) => Promise; - sendVerificationEmail: (options: { token: string; url: string; user: { email: string; id: string; name?: null | string } }) => Promise; + sendVerificationEmail: (options: { + token: string; + url: string; + user: { email: string; id: string; name?: null | string }; + }) => Promise; } { return { onEmailVerified: async (userId: string) => { @@ -735,16 +757,17 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { const db = this.mongoConnection?.db; if (db) { const { ObjectId } = await import('mongodb'); - await db.collection('users').updateOne( - { _id: new ObjectId(userId) }, - { $set: { verified: true, verifiedAt: new Date() } }, - ); + await db + .collection('users') + .updateOne({ _id: new ObjectId(userId) }, { $set: { verified: true, verifiedAt: new Date() } }); this.logger.debug(`Email verified for user ${userId} - synced verified/verifiedAt`); } else { this.logger.warn(`Cannot sync verifiedAt for user ${userId} - no database connection`); } } catch (error) { - this.logger.error(`Failed to sync verifiedAt for user ${userId}: ${error instanceof Error ? error.message : 'Unknown error'}`); + this.logger.error( + `Failed to sync verifiedAt for user ${userId}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); } }, sendVerificationEmail: async (options) => { @@ -778,7 +801,16 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { ): DynamicModule { return { controllers: [this.getControllerClass()], - exports: [BETTER_AUTH_INSTANCE, CoreBetterAuthService, CoreBetterAuthUserMapper, CoreBetterAuthRateLimiter, BetterAuthTokenService, CoreBetterAuthChallengeService, CoreBetterAuthEmailVerificationService, CoreBetterAuthSignUpValidatorService], + exports: [ + BETTER_AUTH_INSTANCE, + CoreBetterAuthService, + CoreBetterAuthUserMapper, + CoreBetterAuthRateLimiter, + BetterAuthTokenService, + CoreBetterAuthChallengeService, + CoreBetterAuthEmailVerificationService, + CoreBetterAuthSignUpValidatorService, + ], module: CoreBetterAuthModule, providers: [ // Optional BrevoService: uses factory to avoid constructor error when brevo config is missing @@ -801,7 +833,10 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { // Also inject EmailVerificationService to set static reference before Better-Auth init inject: [getConnectionToken(), CoreBetterAuthEmailVerificationService], provide: BETTER_AUTH_INSTANCE, - useFactory: async (connection: Connection, emailVerificationService: CoreBetterAuthEmailVerificationService) => { + useFactory: async ( + connection: Connection, + emailVerificationService: CoreBetterAuthEmailVerificationService, + ) => { // Set static references for callbacks BEFORE creating Better-Auth instance this.setEmailVerificationService(emailVerificationService); this.mongoConnection = connection; @@ -843,9 +878,8 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { // Store a config copy with the resolved secret (same as first forRoot variant) const fallbacks = options?.fallbackSecrets; const resolvedSecret2 = config.secret || fallbacks?.find((s) => s && s.length >= 32); - this.currentConfig = resolvedSecret2 && resolvedSecret2 !== config.secret - ? { ...config, secret: resolvedSecret2 } - : config; + this.currentConfig = + resolvedSecret2 && resolvedSecret2 !== config.secret ? { ...config, secret: resolvedSecret2 } : config; // Keep static betterAuthEnabled in sync with the authInstance state. // This is important because forRoot() sets it synchronously, but reset() @@ -907,10 +941,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { ...(this.shouldRegisterRolesGuardGlobally && !RolesGuardRegistry.isRegistered() ? (() => { RolesGuardRegistry.markRegistered('CoreBetterAuthModule'); - return [ - BetterAuthRolesGuard, - { provide: APP_GUARD, useExisting: BetterAuthRolesGuard }, - ]; + return [BetterAuthRolesGuard, { provide: APP_GUARD, useExisting: BetterAuthRolesGuard }]; })() : []), ], @@ -979,6 +1010,10 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit { features.push('Sign-Up Checks'); } + if (config.emailAndPassword?.disableSignUp) { + features.push('Sign-Up Disabled'); + } + if (features.length > 0) { this.logger.log(`Enabled features: ${features.join(', ')}`); } diff --git a/src/core/modules/better-auth/core-better-auth.resolver.ts b/src/core/modules/better-auth/core-better-auth.resolver.ts index 2421c7e..212afab 100644 --- a/src/core/modules/better-auth/core-better-auth.resolver.ts +++ b/src/core/modules/better-auth/core-better-auth.resolver.ts @@ -107,10 +107,7 @@ export class CoreBetterAuthResolver { * @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); } @@ -193,6 +190,7 @@ export class CoreBetterAuthResolver { enabled: this.betterAuthService.isEnabled(), jwt: this.betterAuthService.isJwtEnabled(), passkey: this.betterAuthService.isPasskeyEnabled(), + signUpEnabled: this.betterAuthService.isSignUpEnabled(), socialProviders: this.betterAuthService.getEnabledSocialProviders(), twoFactor: this.betterAuthService.isTwoFactorEnabled(), }; @@ -303,12 +301,16 @@ export class CoreBetterAuthResolver { body: { email, password }, })) as BetterAuthSignInResponse | null; - this.logger.debug(`[SignIn] API response for ${maskEmail(email)}: ${JSON.stringify(response)?.substring(0, 200)}`); + this.logger.debug( + `[SignIn] API response for ${maskEmail(email)}: ${JSON.stringify(response)?.substring(0, 200)}`, + ); // Check if response indicates an error (Better-Auth returns error objects, not throws) const responseAny = response as any; if (responseAny?.error || responseAny?.code === 'CREDENTIAL_ACCOUNT_NOT_FOUND') { - this.logger.debug(`[SignIn] API returned error for ${maskEmail(email)}: ${responseAny?.error || responseAny?.code}`); + this.logger.debug( + `[SignIn] API returned error for ${maskEmail(email)}: ${responseAny?.error || responseAny?.code}`, + ); throw new Error(responseAny?.error || responseAny?.code || 'Credential account not found'); } @@ -341,7 +343,8 @@ export class CoreBetterAuthResolver { // 2. token (top-level, some BetterAuth versions) // 3. session.token (session-based fallback) 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); return { @@ -410,7 +413,8 @@ export class CoreBetterAuthResolver { // 2. token (top-level, some BetterAuth versions) // 3. session.token (session-based fallback) 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); return { @@ -448,6 +452,7 @@ export class CoreBetterAuthResolver { @Args('termsAndPrivacyAccepted', { nullable: true }) termsAndPrivacyAccepted?: boolean, ): Promise { this.ensureEnabled(); + this.betterAuthService.ensureSignUpEnabled(); // Validate sign-up input (termsAndPrivacyAccepted is required by default) if (this.signUpValidator) { @@ -487,7 +492,9 @@ export class CoreBetterAuthResolver { if (sessionToken) { await this.betterAuthService.revokeSession(sessionToken); } - this.logger.debug(`[SignUp] Email verification required for ${maskEmail(sessionUser.email)}, session revoked`); + this.logger.debug( + `[SignUp] Email verification required for ${maskEmail(sessionUser.email)}, session revoked`, + ); return { emailVerificationRequired: true, requiresTwoFactor: false, diff --git a/src/core/modules/better-auth/core-better-auth.service.ts b/src/core/modules/better-auth/core-better-auth.service.ts index f6d61a8..4f32e37 100644 --- a/src/core/modules/better-auth/core-better-auth.service.ts +++ b/src/core/modules/better-auth/core-better-auth.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable, Logger, Optional } from '@nestjs/common'; import { InjectConnection } from '@nestjs/mongoose'; import { Request } from 'express'; import { importJWK, jwtVerify } from 'jose'; @@ -7,6 +7,7 @@ import { Connection } from 'mongoose'; import { maskEmail, maskToken } from '../../common/helpers/logging.helper'; import { IBetterAuth } from '../../common/interfaces/server-options.interface'; import { ConfigService } from '../../common/services/config.service'; +import { ErrorCode } from '../error-code/error-codes'; import { BetterAuthInstance } from './better-auth.config'; import { BetterAuthSessionUser } from './core-better-auth-user.mapper'; import { convertExpressHeaders, parseCookieHeader, signCookieValueIfNeeded } from './core-better-auth-web.helper'; @@ -155,6 +156,26 @@ export class CoreBetterAuthService { return true; } + /** + * Checks if sign-up is enabled. + * Sign-up is enabled by default unless explicitly disabled via + * emailAndPassword.disableSignUp: true + */ + isSignUpEnabled(): boolean { + if (!this.isEnabled()) return false; + return this.config.emailAndPassword?.disableSignUp !== true; + } + + /** + * Throws BadRequestException if sign-up is disabled. + * Used by Controller and Resolver as a guard before sign-up logic. + */ + ensureSignUpEnabled(): void { + if (!this.isSignUpEnabled()) { + throw new BadRequestException(ErrorCode.SIGNUP_DISABLED); + } + } + /** * Gets the list of enabled social providers * Dynamically iterates over all configured providers. @@ -593,10 +614,7 @@ export class CoreBetterAuthService { { $match: { $expr: { - $or: [ - { $eq: ['$userId', userId] }, - { $eq: [{ $toString: '$userId' }, userId] }, - ], + $or: [{ $eq: ['$userId', userId] }, { $eq: [{ $toString: '$userId' }, userId] }], }, expiresAt: { $gt: new Date() }, }, @@ -690,7 +708,9 @@ export class CoreBetterAuthService { } return !!result.user.emailVerified; } catch (error) { - this.logger.debug(`isUserEmailVerified error for ${maskEmail(email)}: ${error instanceof Error ? error.message : 'Unknown error'}`); + this.logger.debug( + `isUserEmailVerified error for ${maskEmail(email)}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); return null; } } @@ -867,7 +887,10 @@ export class CoreBetterAuthService { * @param token - Optional JWT token to verify directly (bypasses header extraction) * @returns The JWT payload with user info, or null if no valid token */ - async verifyJwtFromRequest(req: Request, token?: string): Promise { + let app; + let testHelper: TestHelper; + let betterAuthService: CoreBetterAuthService; + + // Test data tracking for cleanup + const testEmails: string[] = []; + + // Helper to generate unique test emails + const generateTestEmail = (prefix: string): string => { + const email = `disable-signup-${prefix}-${Date.now()}-${Math.random().toString(36).substring(2, 8)}@test.com`; + testEmails.push(email); + return email; + }; + + // =================================================================================================================== + // Setup and Teardown + // =================================================================================================================== + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [DisableSignUpTestModule], + providers: [{ provide: 'PUB_SUB', useValue: new PubSub() }], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalFilters(new HttpExceptionLogFilter()); + app.setBaseViewsDir(disableSignUpConfig.templates.path); + app.setViewEngine(disableSignUpConfig.templates.engine); + await app.init(); + testHelper = new TestHelper(app); + + betterAuthService = moduleFixture.get(CoreBetterAuthService); + }); + + afterAll(async () => { + if (app) await app.close(); + + CoreBetterAuthModule.reset(); + }); + + // =================================================================================================================== + // Service State + // =================================================================================================================== + + describe('Service State', () => { + it('should have BetterAuth enabled', () => { + expect(betterAuthService.isEnabled()).toBe(true); + }); + + it('should report sign-up as disabled', () => { + expect(betterAuthService.isSignUpEnabled()).toBe(false); + }); + }); + + // =================================================================================================================== + // Features Endpoint + // =================================================================================================================== + + describe('Features Endpoint', () => { + it('should report signUpEnabled: false via GraphQL', async () => { + const result = await testHelper.graphQl({ + fields: ['enabled', 'signUpEnabled'], + name: 'betterAuthFeatures', + type: TestGraphQLType.QUERY, + }); + + expect(result.enabled).toBe(true); + expect(result.signUpEnabled).toBe(false); + }); + + it('should report signUpEnabled: false via REST', async () => { + const result = await testHelper.rest('/iam/features', { + method: 'GET', + statusCode: 200, + }); + + expect(result.enabled).toBe(true); + expect(result.signUpEnabled).toBe(false); + }); + }); + + // =================================================================================================================== + // Sign-Up Blocked + // =================================================================================================================== + + describe('Sign-Up Blocked', () => { + it('should reject REST sign-up with 400 and SIGNUP_DISABLED error', async () => { + const email = generateTestEmail('rest-blocked'); + + const result: any = await testHelper.rest('/iam/sign-up/email', { + method: 'POST', + payload: { email, name: 'Blocked User', password: 'TestPassword123!', termsAndPrivacyAccepted: true }, + statusCode: 400, + }); + + expect(result.message).toContain('LTNS_0026'); + expect(result.message.toLowerCase()).toContain('sign-up'); + }); + + it('should reject GraphQL sign-up with SIGNUP_DISABLED error', async () => { + const email = generateTestEmail('graphql-blocked'); + + const result = await testHelper.graphQl( + { + arguments: { email, name: 'Blocked User', password: 'TestPassword123!', termsAndPrivacyAccepted: true }, + fields: ['success'], + name: 'betterAuthSignUp', + type: TestGraphQLType.MUTATION, + }, + { statusCode: 200 }, + ); + + expect(result.errors).toBeDefined(); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0].message).toContain('LTNS_0026'); + }); + + it('should not create user in database when sign-up is blocked', async () => { + const email = generateTestEmail('no-user'); + const password = 'TestPassword123!'; + + // Attempt sign-up (will fail with LTNS_0026) + await testHelper.rest('/iam/sign-up/email', { + method: 'POST', + payload: { email, name: 'No User', password, termsAndPrivacyAccepted: true }, + statusCode: 400, + }); + + // Verify no user was created: sign-in with same credentials should fail with 401 + const signInResult: any = await testHelper.rest('/iam/sign-in/email', { + method: 'POST', + payload: { email, password }, + statusCode: 401, + }); + expect(signInResult.message).not.toContain('LTNS_0026'); + }); + }); + + // =================================================================================================================== + // Sign-In Still Works (Endpoint Not Blocked by disableSignUp) + // =================================================================================================================== + + describe('Sign-In Still Works', () => { + it('should return 401 for invalid credentials via REST (not "disabled" error)', async () => { + const result: any = await testHelper.rest('/iam/sign-in/email', { + method: 'POST', + payload: { email: 'nonexistent@test.com', password: 'WrongPassword123!' }, + statusCode: 401, + }); + + // Sign-in returns a credential error, NOT a "sign-up disabled" error + // This proves the sign-in endpoint is active and not affected by disableSignUp + expect(result.message).not.toContain('LTNS_0026'); + }); + + it('should return error for invalid credentials via GraphQL (not "disabled" error)', async () => { + const result = await testHelper.graphQl( + { + arguments: { email: 'nonexistent@test.com', password: 'WrongPassword123!' }, + fields: ['success', { user: ['email'] }], + name: 'betterAuthSignIn', + type: TestGraphQLType.MUTATION, + }, + { statusCode: 200 }, + ); + + // GraphQL sign-in should return an error for invalid credentials, not a disabled error + if (result.errors) { + expect(result.errors[0].message).not.toContain('LTNS_0026'); + } else { + // If betterAuthSignIn returns data instead of errors, success should be false + expect(result.success).toBeFalsy(); + } + }); + }); +});