@lenne.tech/nest-server 11.6.1 → 11.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.env.js +141 -0
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/decorators/graphql-populate.decorator.d.ts +2 -2
- package/dist/core/common/decorators/restricted.decorator.d.ts +1 -0
- package/dist/core/common/decorators/restricted.decorator.js +1 -1
- package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
- package/dist/core/common/helpers/input.helper.d.ts +1 -0
- package/dist/core/common/helpers/input.helper.js +1 -1
- package/dist/core/common/helpers/input.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +50 -0
- package/dist/core/modules/auth/auth-guard-strategy.enum.d.ts +1 -0
- package/dist/core/modules/auth/auth-guard-strategy.enum.js +1 -0
- package/dist/core/modules/auth/auth-guard-strategy.enum.js.map +1 -1
- package/dist/core/modules/auth/guards/auth.guard.js +11 -5
- package/dist/core/modules/auth/guards/auth.guard.js.map +1 -1
- package/dist/core/modules/auth/tokens.decorator.d.ts +1 -1
- package/dist/core/modules/better-auth/better-auth-auth.model.d.ts +9 -0
- package/dist/core/modules/better-auth/better-auth-auth.model.js +63 -0
- package/dist/core/modules/better-auth/better-auth-auth.model.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth-models.d.ts +44 -0
- package/dist/core/modules/better-auth/better-auth-models.js +185 -0
- package/dist/core/modules/better-auth/better-auth-models.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth-rate-limit.middleware.d.ts +12 -0
- package/dist/core/modules/better-auth/better-auth-rate-limit.middleware.js +70 -0
- package/dist/core/modules/better-auth/better-auth-rate-limit.middleware.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth-rate-limiter.service.d.ts +32 -0
- package/dist/core/modules/better-auth/better-auth-rate-limiter.service.js +173 -0
- package/dist/core/modules/better-auth/better-auth-rate-limiter.service.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth-user.mapper.d.ts +43 -0
- package/dist/core/modules/better-auth/better-auth-user.mapper.js +159 -0
- package/dist/core/modules/better-auth/better-auth-user.mapper.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth.config.d.ts +9 -0
- package/dist/core/modules/better-auth/better-auth.config.js +251 -0
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth.middleware.d.ts +20 -0
- package/dist/core/modules/better-auth/better-auth.middleware.js +79 -0
- package/dist/core/modules/better-auth/better-auth.middleware.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth.module.d.ts +30 -0
- package/dist/core/modules/better-auth/better-auth.module.js +265 -0
- package/dist/core/modules/better-auth/better-auth.module.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth.resolver.d.ts +49 -0
- package/dist/core/modules/better-auth/better-auth.resolver.js +539 -0
- package/dist/core/modules/better-auth/better-auth.resolver.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth.service.d.ts +38 -0
- package/dist/core/modules/better-auth/better-auth.service.js +151 -0
- package/dist/core/modules/better-auth/better-auth.service.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth.types.d.ts +38 -0
- package/dist/core/modules/better-auth/better-auth.types.js +15 -0
- package/dist/core/modules/better-auth/better-auth.types.js.map +1 -0
- package/dist/core/modules/better-auth/index.d.ts +11 -0
- package/dist/core/modules/better-auth/index.js +28 -0
- package/dist/core/modules/better-auth/index.js.map +1 -0
- package/dist/core/modules/user/core-user.model.d.ts +2 -0
- package/dist/core/modules/user/core-user.model.js +21 -0
- package/dist/core/modules/user/core-user.model.js.map +1 -1
- package/dist/core.module.js +7 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +9 -1
- package/src/config.env.ts +148 -1
- package/src/core/common/decorators/restricted.decorator.ts +2 -2
- package/src/core/common/helpers/input.helper.ts +2 -2
- package/src/core/common/interfaces/server-options.interface.ts +344 -20
- package/src/core/modules/auth/auth-guard-strategy.enum.ts +1 -0
- package/src/core/modules/auth/guards/auth.guard.ts +20 -6
- package/src/core/modules/better-auth/README.md +1096 -0
- package/src/core/modules/better-auth/better-auth-auth.model.ts +69 -0
- package/src/core/modules/better-auth/better-auth-models.ts +143 -0
- package/src/core/modules/better-auth/better-auth-rate-limit.middleware.ts +113 -0
- package/src/core/modules/better-auth/better-auth-rate-limiter.service.ts +326 -0
- package/src/core/modules/better-auth/better-auth-user.mapper.ts +269 -0
- package/src/core/modules/better-auth/better-auth.config.ts +483 -0
- package/src/core/modules/better-auth/better-auth.middleware.ts +111 -0
- package/src/core/modules/better-auth/better-auth.module.ts +433 -0
- package/src/core/modules/better-auth/better-auth.resolver.ts +678 -0
- package/src/core/modules/better-auth/better-auth.service.ts +323 -0
- package/src/core/modules/better-auth/better-auth.types.ts +75 -0
- package/src/core/modules/better-auth/index.ts +25 -0
- package/src/core/modules/user/core-user.model.ts +29 -0
- package/src/core.module.ts +12 -0
- package/src/index.ts +6 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Field, ObjectType } from '@nestjs/graphql';
|
|
2
|
+
|
|
3
|
+
import { Restricted } from '../../common/decorators/restricted.decorator';
|
|
4
|
+
import { RoleEnum } from '../../common/enums/role.enum';
|
|
5
|
+
import { BetterAuthSessionInfoModel, BetterAuthUserModel } from './better-auth-models';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Better-Auth Authentication Response Model
|
|
9
|
+
*
|
|
10
|
+
* This model is returned by Better-Auth sign-in/sign-up mutations
|
|
11
|
+
* and provides a structure compatible with the existing auth system
|
|
12
|
+
* while supporting Better-Auth specific features like 2FA.
|
|
13
|
+
*/
|
|
14
|
+
@ObjectType({ description: 'Better-Auth Authentication Response' })
|
|
15
|
+
@Restricted(RoleEnum.S_EVERYONE)
|
|
16
|
+
export class BetterAuthAuthModel {
|
|
17
|
+
/**
|
|
18
|
+
* Whether the authentication was successful
|
|
19
|
+
*/
|
|
20
|
+
@Field(() => Boolean, { description: 'Whether authentication was successful' })
|
|
21
|
+
success: boolean;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether 2FA verification is required
|
|
25
|
+
* When true, the client should prompt for 2FA code and call betterAuthVerify2FA
|
|
26
|
+
*/
|
|
27
|
+
@Field(() => Boolean, {
|
|
28
|
+
description: 'Whether 2FA verification is required to complete sign-in',
|
|
29
|
+
nullable: true,
|
|
30
|
+
})
|
|
31
|
+
requiresTwoFactor?: boolean;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* JWT token (only present if JWT plugin is enabled)
|
|
35
|
+
* Use this for Bearer token authentication
|
|
36
|
+
*/
|
|
37
|
+
@Field(() => String, {
|
|
38
|
+
description: 'JWT token for Bearer authentication (if JWT plugin enabled)',
|
|
39
|
+
nullable: true,
|
|
40
|
+
})
|
|
41
|
+
token?: string;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Authenticated user
|
|
45
|
+
*/
|
|
46
|
+
@Field(() => BetterAuthUserModel, {
|
|
47
|
+
description: 'Authenticated user',
|
|
48
|
+
nullable: true,
|
|
49
|
+
})
|
|
50
|
+
user?: BetterAuthUserModel;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Session information
|
|
54
|
+
*/
|
|
55
|
+
@Field(() => BetterAuthSessionInfoModel, {
|
|
56
|
+
description: 'Session information',
|
|
57
|
+
nullable: true,
|
|
58
|
+
})
|
|
59
|
+
session?: BetterAuthSessionInfoModel;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Error message if authentication failed
|
|
63
|
+
*/
|
|
64
|
+
@Field(() => String, {
|
|
65
|
+
description: 'Error message if authentication failed',
|
|
66
|
+
nullable: true,
|
|
67
|
+
})
|
|
68
|
+
error?: string;
|
|
69
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Field, ObjectType } from '@nestjs/graphql';
|
|
2
|
+
|
|
3
|
+
import { Restricted } from '../../common/decorators/restricted.decorator';
|
|
4
|
+
import { RoleEnum } from '../../common/enums/role.enum';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Better-Auth User Model for GraphQL
|
|
8
|
+
*/
|
|
9
|
+
@ObjectType({ description: 'Better-Auth User' })
|
|
10
|
+
@Restricted(RoleEnum.S_EVERYONE)
|
|
11
|
+
export class BetterAuthUserModel {
|
|
12
|
+
@Field(() => String, { description: 'User ID' })
|
|
13
|
+
id: string;
|
|
14
|
+
|
|
15
|
+
@Field(() => String, { description: 'IAM provider user ID (e.g., Better-Auth)', nullable: true })
|
|
16
|
+
iamId?: string;
|
|
17
|
+
|
|
18
|
+
@Field(() => String, { description: 'Email address' })
|
|
19
|
+
email: string;
|
|
20
|
+
|
|
21
|
+
@Field(() => String, { description: 'Display name', nullable: true })
|
|
22
|
+
name?: string;
|
|
23
|
+
|
|
24
|
+
@Field(() => Boolean, { description: 'Email verified status', nullable: true })
|
|
25
|
+
emailVerified?: boolean;
|
|
26
|
+
|
|
27
|
+
@Field(() => Boolean, { description: 'User verified status', nullable: true })
|
|
28
|
+
verified?: boolean;
|
|
29
|
+
|
|
30
|
+
@Field(() => [String], { description: 'User roles', nullable: true })
|
|
31
|
+
roles?: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Better-Auth Session Model for GraphQL
|
|
36
|
+
*/
|
|
37
|
+
@ObjectType({ description: 'Better-Auth Session' })
|
|
38
|
+
@Restricted(RoleEnum.S_USER)
|
|
39
|
+
export class BetterAuthSessionModel {
|
|
40
|
+
@Field(() => String, { description: 'Session ID' })
|
|
41
|
+
id: string;
|
|
42
|
+
|
|
43
|
+
@Field(() => Date, { description: 'Session expiration date' })
|
|
44
|
+
expiresAt: Date;
|
|
45
|
+
|
|
46
|
+
@Field(() => BetterAuthUserModel, { description: 'Session user' })
|
|
47
|
+
user: BetterAuthUserModel;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Better-Auth Session Info (simplified for responses)
|
|
52
|
+
*/
|
|
53
|
+
@ObjectType({ description: 'Better-Auth Session Info' })
|
|
54
|
+
@Restricted(RoleEnum.S_EVERYONE)
|
|
55
|
+
export class BetterAuthSessionInfoModel {
|
|
56
|
+
@Field(() => String, { description: 'Session ID', nullable: true })
|
|
57
|
+
id?: string;
|
|
58
|
+
|
|
59
|
+
@Field(() => String, { description: 'Session token', nullable: true })
|
|
60
|
+
token?: string;
|
|
61
|
+
|
|
62
|
+
@Field(() => Date, { description: 'Session expiration date', nullable: true })
|
|
63
|
+
expiresAt?: Date;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Better-Auth 2FA Setup Model (returned when enabling 2FA)
|
|
68
|
+
*/
|
|
69
|
+
@ObjectType({ description: 'Better-Auth 2FA setup data' })
|
|
70
|
+
@Restricted(RoleEnum.S_USER)
|
|
71
|
+
export class BetterAuth2FASetupModel {
|
|
72
|
+
@Field(() => Boolean, { description: 'Whether the operation was successful' })
|
|
73
|
+
success: boolean;
|
|
74
|
+
|
|
75
|
+
@Field(() => String, { description: 'TOTP URI for QR code generation', nullable: true })
|
|
76
|
+
totpUri?: string;
|
|
77
|
+
|
|
78
|
+
@Field(() => [String], { description: 'Backup codes for account recovery', nullable: true })
|
|
79
|
+
backupCodes?: string[];
|
|
80
|
+
|
|
81
|
+
@Field(() => String, { description: 'Error message if failed', nullable: true })
|
|
82
|
+
error?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Better-Auth Passkey Model
|
|
87
|
+
*/
|
|
88
|
+
@ObjectType({ description: 'Better-Auth Passkey' })
|
|
89
|
+
@Restricted(RoleEnum.S_USER)
|
|
90
|
+
export class BetterAuthPasskeyModel {
|
|
91
|
+
@Field(() => String, { description: 'Passkey ID' })
|
|
92
|
+
id: string;
|
|
93
|
+
|
|
94
|
+
@Field(() => String, { description: 'Passkey name', nullable: true })
|
|
95
|
+
name?: string;
|
|
96
|
+
|
|
97
|
+
@Field(() => String, { description: 'Credential ID' })
|
|
98
|
+
credentialId: string;
|
|
99
|
+
|
|
100
|
+
@Field(() => Date, { description: 'Creation date' })
|
|
101
|
+
createdAt: Date;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Better-Auth Passkey Registration Challenge
|
|
106
|
+
*/
|
|
107
|
+
@ObjectType({ description: 'Better-Auth Passkey registration challenge' })
|
|
108
|
+
@Restricted(RoleEnum.S_USER)
|
|
109
|
+
export class BetterAuthPasskeyChallengeModel {
|
|
110
|
+
@Field(() => Boolean, { description: 'Whether the operation was successful' })
|
|
111
|
+
success: boolean;
|
|
112
|
+
|
|
113
|
+
@Field(() => String, { description: 'Challenge data (JSON)', nullable: true })
|
|
114
|
+
challenge?: string;
|
|
115
|
+
|
|
116
|
+
@Field(() => String, { description: 'Error message if failed', nullable: true })
|
|
117
|
+
error?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Better-Auth features status
|
|
122
|
+
*/
|
|
123
|
+
@ObjectType({ description: 'Better-Auth features status' })
|
|
124
|
+
@Restricted(RoleEnum.S_EVERYONE)
|
|
125
|
+
export class BetterAuthFeaturesModel {
|
|
126
|
+
@Field(() => Boolean, { description: 'Whether Better-Auth is enabled' })
|
|
127
|
+
enabled: boolean;
|
|
128
|
+
|
|
129
|
+
@Field(() => Boolean, { description: 'Whether JWT plugin is enabled' })
|
|
130
|
+
jwt: boolean;
|
|
131
|
+
|
|
132
|
+
@Field(() => Boolean, { description: 'Whether 2FA is enabled' })
|
|
133
|
+
twoFactor: boolean;
|
|
134
|
+
|
|
135
|
+
@Field(() => Boolean, { description: 'Whether Passkey is enabled' })
|
|
136
|
+
passkey: boolean;
|
|
137
|
+
|
|
138
|
+
@Field(() => Boolean, { description: 'Whether legacy password handling is enabled' })
|
|
139
|
+
legacyPassword: boolean;
|
|
140
|
+
|
|
141
|
+
@Field(() => [String], { description: 'List of enabled social providers' })
|
|
142
|
+
socialProviders: string[];
|
|
143
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { HttpException, HttpStatus, Injectable, NestMiddleware } from '@nestjs/common';
|
|
2
|
+
import { NextFunction, Request, Response } from 'express';
|
|
3
|
+
|
|
4
|
+
import { BetterAuthRateLimiter, RateLimitResult } from './better-auth-rate-limiter.service';
|
|
5
|
+
import { BetterAuthService } from './better-auth.service';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Middleware that applies rate limiting to Better-Auth endpoints
|
|
9
|
+
*
|
|
10
|
+
* This middleware:
|
|
11
|
+
* 1. Checks if rate limiting is enabled
|
|
12
|
+
* 2. Extracts the client IP address
|
|
13
|
+
* 3. Checks the rate limit for the current request
|
|
14
|
+
* 4. Returns 429 Too Many Requests if limit exceeded
|
|
15
|
+
* 5. Adds rate limit headers to the response
|
|
16
|
+
*
|
|
17
|
+
* Configuration is done via betterAuth.rateLimit in environment config.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* // In config.env.ts
|
|
22
|
+
* betterAuth: {
|
|
23
|
+
* enabled: true,
|
|
24
|
+
* rateLimit: {
|
|
25
|
+
* enabled: true,
|
|
26
|
+
* max: 10,
|
|
27
|
+
* windowSeconds: 60,
|
|
28
|
+
* },
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
@Injectable()
|
|
33
|
+
export class BetterAuthRateLimitMiddleware implements NestMiddleware {
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly rateLimiter: BetterAuthRateLimiter,
|
|
36
|
+
private readonly betterAuthService: BetterAuthService,
|
|
37
|
+
) {}
|
|
38
|
+
|
|
39
|
+
use(req: Request, res: Response, next: NextFunction) {
|
|
40
|
+
// Skip if Better-Auth is not enabled
|
|
41
|
+
if (!this.betterAuthService.isEnabled()) {
|
|
42
|
+
return next();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Skip if rate limiting is not enabled
|
|
46
|
+
if (!this.rateLimiter.isEnabled()) {
|
|
47
|
+
return next();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Get client IP
|
|
51
|
+
const ip = this.getClientIp(req);
|
|
52
|
+
|
|
53
|
+
// Get the path relative to basePath
|
|
54
|
+
const basePath = this.betterAuthService.getBasePath();
|
|
55
|
+
const path = req.path.startsWith(basePath) ? req.path.substring(basePath.length) : req.path;
|
|
56
|
+
|
|
57
|
+
// Check rate limit
|
|
58
|
+
const result = this.rateLimiter.check(ip, path);
|
|
59
|
+
|
|
60
|
+
// Add rate limit headers
|
|
61
|
+
this.addRateLimitHeaders(res, result);
|
|
62
|
+
|
|
63
|
+
// If not allowed, return 429
|
|
64
|
+
if (!result.allowed) {
|
|
65
|
+
throw new HttpException(
|
|
66
|
+
{
|
|
67
|
+
error: 'Too Many Requests',
|
|
68
|
+
message: this.rateLimiter.getMessage(),
|
|
69
|
+
retryAfter: result.resetIn,
|
|
70
|
+
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
|
71
|
+
},
|
|
72
|
+
HttpStatus.TOO_MANY_REQUESTS,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
next();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extract client IP address from request
|
|
81
|
+
* Handles proxied requests via X-Forwarded-For header
|
|
82
|
+
*/
|
|
83
|
+
private getClientIp(req: Request): string {
|
|
84
|
+
// Check X-Forwarded-For header (for proxied requests)
|
|
85
|
+
const forwardedFor = req.headers['x-forwarded-for'];
|
|
86
|
+
if (forwardedFor) {
|
|
87
|
+
const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor.split(',')[0];
|
|
88
|
+
return ips.trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check X-Real-IP header (nginx)
|
|
92
|
+
const realIp = req.headers['x-real-ip'];
|
|
93
|
+
if (realIp) {
|
|
94
|
+
return Array.isArray(realIp) ? realIp[0] : realIp;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Fall back to connection remote address
|
|
98
|
+
return req.ip || req.socket?.remoteAddress || 'unknown';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Add standard rate limit headers to response
|
|
103
|
+
*/
|
|
104
|
+
private addRateLimitHeaders(res: Response, result: RateLimitResult): void {
|
|
105
|
+
res.setHeader('X-RateLimit-Limit', result.limit.toString());
|
|
106
|
+
res.setHeader('X-RateLimit-Remaining', result.remaining.toString());
|
|
107
|
+
res.setHeader('X-RateLimit-Reset', result.resetIn.toString());
|
|
108
|
+
|
|
109
|
+
if (!result.allowed) {
|
|
110
|
+
res.setHeader('Retry-After', result.resetIn.toString());
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { Injectable, Logger } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { IBetterAuthRateLimit } from '../../common/interfaces/server-options.interface';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Result of a rate limit check
|
|
7
|
+
*/
|
|
8
|
+
export interface RateLimitResult {
|
|
9
|
+
/**
|
|
10
|
+
* Whether the request is allowed
|
|
11
|
+
*/
|
|
12
|
+
allowed: boolean;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Current request count in the window
|
|
16
|
+
*/
|
|
17
|
+
current: number;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Maximum requests allowed
|
|
21
|
+
*/
|
|
22
|
+
limit: number;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Number of remaining requests in the window
|
|
26
|
+
*/
|
|
27
|
+
remaining: number;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Seconds until the rate limit resets
|
|
31
|
+
*/
|
|
32
|
+
resetIn: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Rate limit entry for tracking requests
|
|
37
|
+
*/
|
|
38
|
+
interface RateLimitEntry {
|
|
39
|
+
count: number;
|
|
40
|
+
resetTime: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Default rate limiting configuration
|
|
45
|
+
*/
|
|
46
|
+
const DEFAULT_CONFIG: Required<IBetterAuthRateLimit> = {
|
|
47
|
+
enabled: false,
|
|
48
|
+
max: 10,
|
|
49
|
+
message: 'Too many requests, please try again later.',
|
|
50
|
+
skipEndpoints: ['/session', '/callback'],
|
|
51
|
+
strictEndpoints: ['/sign-in', '/sign-up', '/forgot-password', '/reset-password'],
|
|
52
|
+
windowSeconds: 60,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* In-memory rate limiter for Better-Auth endpoints
|
|
57
|
+
*
|
|
58
|
+
* This service provides rate limiting to protect against brute-force attacks
|
|
59
|
+
* on authentication endpoints. It uses an in-memory store with automatic cleanup.
|
|
60
|
+
*
|
|
61
|
+
* Features:
|
|
62
|
+
* - Configurable request limits and time windows
|
|
63
|
+
* - Stricter limits for sensitive endpoints (sign-in, sign-up, etc.)
|
|
64
|
+
* - Skip list for endpoints that don't need rate limiting
|
|
65
|
+
* - Automatic cleanup of expired entries
|
|
66
|
+
* - IP-based tracking
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```typescript
|
|
70
|
+
* const result = rateLimiter.check('192.168.1.1', '/iam/sign-in');
|
|
71
|
+
* if (!result.allowed) {
|
|
72
|
+
* throw new TooManyRequestsException(rateLimiter.getMessage());
|
|
73
|
+
* }
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
@Injectable()
|
|
77
|
+
export class BetterAuthRateLimiter {
|
|
78
|
+
private readonly logger = new Logger(BetterAuthRateLimiter.name);
|
|
79
|
+
private readonly store = new Map<string, RateLimitEntry>();
|
|
80
|
+
private config: Required<IBetterAuthRateLimit> = DEFAULT_CONFIG;
|
|
81
|
+
private cleanupInterval: NodeJS.Timeout | null = null;
|
|
82
|
+
|
|
83
|
+
constructor() {
|
|
84
|
+
// Start cleanup interval (every 5 minutes)
|
|
85
|
+
this.startCleanup();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Configure the rate limiter
|
|
90
|
+
*
|
|
91
|
+
* @param config - Rate limiting configuration
|
|
92
|
+
*/
|
|
93
|
+
configure(config: IBetterAuthRateLimit | undefined): void {
|
|
94
|
+
this.config = {
|
|
95
|
+
...DEFAULT_CONFIG,
|
|
96
|
+
...config,
|
|
97
|
+
// Ensure arrays are properly merged
|
|
98
|
+
skipEndpoints: config?.skipEndpoints ?? DEFAULT_CONFIG.skipEndpoints,
|
|
99
|
+
strictEndpoints: config?.strictEndpoints ?? DEFAULT_CONFIG.strictEndpoints,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
if (this.config.enabled) {
|
|
103
|
+
this.logger.log(`Rate limiting enabled: ${this.config.max} requests per ${this.config.windowSeconds}s`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Check if a request is allowed under the rate limit
|
|
109
|
+
*
|
|
110
|
+
* @param ip - Client IP address
|
|
111
|
+
* @param path - Request path (relative to basePath)
|
|
112
|
+
* @returns Rate limit check result
|
|
113
|
+
*/
|
|
114
|
+
check(ip: string, path: string): RateLimitResult {
|
|
115
|
+
// If rate limiting is disabled, always allow
|
|
116
|
+
if (!this.config.enabled) {
|
|
117
|
+
return {
|
|
118
|
+
allowed: true,
|
|
119
|
+
current: 0,
|
|
120
|
+
limit: Infinity,
|
|
121
|
+
remaining: Infinity,
|
|
122
|
+
resetIn: 0,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check if this endpoint should skip rate limiting
|
|
127
|
+
if (this.shouldSkip(path)) {
|
|
128
|
+
return {
|
|
129
|
+
allowed: true,
|
|
130
|
+
current: 0,
|
|
131
|
+
limit: Infinity,
|
|
132
|
+
remaining: Infinity,
|
|
133
|
+
resetIn: 0,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Determine the limit for this endpoint
|
|
138
|
+
const limit = this.getLimit(path);
|
|
139
|
+
const key = this.getKey(ip, path);
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
|
|
142
|
+
// Get or create entry
|
|
143
|
+
let entry = this.store.get(key);
|
|
144
|
+
|
|
145
|
+
if (!entry || now >= entry.resetTime) {
|
|
146
|
+
// Create new entry or reset expired one
|
|
147
|
+
entry = {
|
|
148
|
+
count: 1,
|
|
149
|
+
resetTime: now + this.config.windowSeconds * 1000,
|
|
150
|
+
};
|
|
151
|
+
this.store.set(key, entry);
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
allowed: true,
|
|
155
|
+
current: 1,
|
|
156
|
+
limit,
|
|
157
|
+
remaining: limit - 1,
|
|
158
|
+
resetIn: this.config.windowSeconds,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Increment count
|
|
163
|
+
entry.count++;
|
|
164
|
+
|
|
165
|
+
const resetIn = Math.ceil((entry.resetTime - now) / 1000);
|
|
166
|
+
const allowed = entry.count <= limit;
|
|
167
|
+
const remaining = Math.max(0, limit - entry.count);
|
|
168
|
+
|
|
169
|
+
if (!allowed) {
|
|
170
|
+
this.logger.warn(`Rate limit exceeded for IP ${this.maskIp(ip)} on ${path}: ${entry.count}/${limit}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
allowed,
|
|
175
|
+
current: entry.count,
|
|
176
|
+
limit,
|
|
177
|
+
remaining,
|
|
178
|
+
resetIn,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Get the configured error message
|
|
184
|
+
*/
|
|
185
|
+
getMessage(): string {
|
|
186
|
+
return this.config.message;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Check if rate limiting is enabled
|
|
191
|
+
*/
|
|
192
|
+
isEnabled(): boolean {
|
|
193
|
+
return this.config.enabled;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Reset rate limit for a specific IP (useful for testing or admin override)
|
|
198
|
+
*
|
|
199
|
+
* @param ip - Client IP address
|
|
200
|
+
*/
|
|
201
|
+
reset(ip: string): void {
|
|
202
|
+
// Remove all entries for this IP
|
|
203
|
+
for (const key of this.store.keys()) {
|
|
204
|
+
if (key.startsWith(`${ip}:`)) {
|
|
205
|
+
this.store.delete(key);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Clear all rate limit entries (useful for testing)
|
|
212
|
+
*/
|
|
213
|
+
clear(): void {
|
|
214
|
+
this.store.clear();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get statistics about the rate limiter
|
|
219
|
+
*/
|
|
220
|
+
getStats(): { activeEntries: number; enabled: boolean } {
|
|
221
|
+
return {
|
|
222
|
+
activeEntries: this.store.size,
|
|
223
|
+
enabled: this.config.enabled,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Stop the cleanup interval (for graceful shutdown)
|
|
229
|
+
*/
|
|
230
|
+
onModuleDestroy(): void {
|
|
231
|
+
if (this.cleanupInterval) {
|
|
232
|
+
clearInterval(this.cleanupInterval);
|
|
233
|
+
this.cleanupInterval = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Determine if an endpoint should skip rate limiting
|
|
239
|
+
*/
|
|
240
|
+
private shouldSkip(path: string): boolean {
|
|
241
|
+
return this.config.skipEndpoints.some((skip) => path === skip || path.endsWith(skip) || path.includes(skip));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Get the rate limit for an endpoint
|
|
246
|
+
* Strict endpoints get half the normal limit
|
|
247
|
+
*/
|
|
248
|
+
private getLimit(path: string): number {
|
|
249
|
+
const isStrict = this.config.strictEndpoints.some(
|
|
250
|
+
(strict) => path === strict || path.endsWith(strict) || path.includes(strict),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
return isStrict ? Math.ceil(this.config.max / 2) : this.config.max;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Generate a unique key for rate limiting
|
|
258
|
+
* Uses IP + endpoint category to allow different limits per endpoint type
|
|
259
|
+
*/
|
|
260
|
+
private getKey(ip: string, path: string): string {
|
|
261
|
+
// Group similar endpoints together
|
|
262
|
+
const endpoint = this.normalizeEndpoint(path);
|
|
263
|
+
return `${ip}:${endpoint}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Normalize endpoint path for consistent grouping
|
|
268
|
+
*/
|
|
269
|
+
private normalizeEndpoint(path: string): string {
|
|
270
|
+
// Remove query strings
|
|
271
|
+
const cleanPath = path.split('?')[0];
|
|
272
|
+
|
|
273
|
+
// Group callback endpoints
|
|
274
|
+
if (cleanPath.includes('/callback/')) {
|
|
275
|
+
return 'callback';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Extract the last segment as the endpoint identifier
|
|
279
|
+
const segments = cleanPath.split('/').filter(Boolean);
|
|
280
|
+
return segments[segments.length - 1] || 'root';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Mask IP address for logging (privacy)
|
|
285
|
+
*/
|
|
286
|
+
private maskIp(ip: string): string {
|
|
287
|
+
if (ip.includes('.')) {
|
|
288
|
+
// IPv4: show first two octets
|
|
289
|
+
const parts = ip.split('.');
|
|
290
|
+
return `${parts[0]}.${parts[1]}.*.*`;
|
|
291
|
+
}
|
|
292
|
+
// IPv6: show first segment
|
|
293
|
+
const parts = ip.split(':');
|
|
294
|
+
return `${parts[0]}:****`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Start periodic cleanup of expired entries
|
|
299
|
+
*/
|
|
300
|
+
private startCleanup(): void {
|
|
301
|
+
// Clean up every 5 minutes
|
|
302
|
+
this.cleanupInterval = setInterval(
|
|
303
|
+
() => {
|
|
304
|
+
const now = Date.now();
|
|
305
|
+
let cleaned = 0;
|
|
306
|
+
|
|
307
|
+
for (const [key, entry] of this.store.entries()) {
|
|
308
|
+
if (now >= entry.resetTime) {
|
|
309
|
+
this.store.delete(key);
|
|
310
|
+
cleaned++;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (cleaned > 0) {
|
|
315
|
+
this.logger.debug(`Cleaned up ${cleaned} expired rate limit entries`);
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
5 * 60 * 1000,
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
// Prevent the interval from keeping the process alive
|
|
322
|
+
if (this.cleanupInterval.unref) {
|
|
323
|
+
this.cleanupInterval.unref();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|