@nauth-toolkit/core 0.1.0 → 0.1.3

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.
Files changed (184) hide show
  1. package/LICENSE +90 -0
  2. package/README.md +30 -0
  3. package/package.json +7 -2
  4. package/jest.config.js +0 -15
  5. package/jest.setup.ts +0 -6
  6. package/src/adapters/database-columns.ts +0 -165
  7. package/src/adapters/express.adapter.ts +0 -385
  8. package/src/adapters/fastify.adapter.ts +0 -416
  9. package/src/adapters/index.ts +0 -16
  10. package/src/adapters/storage.factory.ts +0 -143
  11. package/src/bootstrap.ts +0 -374
  12. package/src/dto/auth-challenge.dto.ts +0 -231
  13. package/src/dto/auth-response.dto.ts +0 -253
  14. package/src/dto/challenge-response.dto.ts +0 -234
  15. package/src/dto/change-password-request.dto.ts +0 -50
  16. package/src/dto/change-password-response.dto.ts +0 -29
  17. package/src/dto/change-password.dto.ts +0 -57
  18. package/src/dto/error-response.dto.ts +0 -136
  19. package/src/dto/get-available-methods.dto.ts +0 -55
  20. package/src/dto/get-challenge-data-response.dto.ts +0 -28
  21. package/src/dto/get-challenge-data.dto.ts +0 -69
  22. package/src/dto/get-client-info.dto.ts +0 -104
  23. package/src/dto/get-device-token-response.dto.ts +0 -25
  24. package/src/dto/get-events-by-type.dto.ts +0 -76
  25. package/src/dto/get-ip-address-response.dto.ts +0 -24
  26. package/src/dto/get-mfa-status.dto.ts +0 -94
  27. package/src/dto/get-risk-assessment-history.dto.ts +0 -39
  28. package/src/dto/get-session-id-response.dto.ts +0 -25
  29. package/src/dto/get-setup-data-response.dto.ts +0 -31
  30. package/src/dto/get-setup-data.dto.ts +0 -75
  31. package/src/dto/get-suspicious-activity.dto.ts +0 -42
  32. package/src/dto/get-user-agent-response.dto.ts +0 -23
  33. package/src/dto/get-user-auth-history.dto.ts +0 -95
  34. package/src/dto/get-user-by-email.dto.ts +0 -61
  35. package/src/dto/get-user-by-id.dto.ts +0 -46
  36. package/src/dto/get-user-devices.dto.ts +0 -53
  37. package/src/dto/get-user-response.dto.ts +0 -17
  38. package/src/dto/has-provider.dto.ts +0 -56
  39. package/src/dto/index.ts +0 -57
  40. package/src/dto/is-trusted-device-response.dto.ts +0 -34
  41. package/src/dto/list-providers-response.dto.ts +0 -23
  42. package/src/dto/login.dto.ts +0 -95
  43. package/src/dto/logout-all-response.dto.ts +0 -24
  44. package/src/dto/logout-all.dto.ts +0 -65
  45. package/src/dto/logout-response.dto.ts +0 -25
  46. package/src/dto/logout.dto.ts +0 -64
  47. package/src/dto/refresh-token.dto.ts +0 -36
  48. package/src/dto/remove-devices.dto.ts +0 -85
  49. package/src/dto/resend-code-response.dto.ts +0 -32
  50. package/src/dto/resend-code.dto.ts +0 -51
  51. package/src/dto/reset-password.dto.ts +0 -115
  52. package/src/dto/respond-challenge.dto.ts +0 -272
  53. package/src/dto/set-mfa-exemption.dto.ts +0 -112
  54. package/src/dto/set-must-change-password-response.dto.ts +0 -27
  55. package/src/dto/set-must-change-password.dto.ts +0 -46
  56. package/src/dto/set-preferred-method.dto.ts +0 -80
  57. package/src/dto/setup-mfa.dto.ts +0 -98
  58. package/src/dto/signup.dto.ts +0 -174
  59. package/src/dto/social-auth.dto.ts +0 -422
  60. package/src/dto/trust-device-response.dto.ts +0 -30
  61. package/src/dto/trust-device.dto.ts +0 -9
  62. package/src/dto/update-user-attributes-request.dto.ts +0 -51
  63. package/src/dto/user-response.dto.ts +0 -138
  64. package/src/dto/user-update.dto.ts +0 -222
  65. package/src/dto/verify-email.dto.ts +0 -313
  66. package/src/dto/verify-mfa-code.dto.ts +0 -103
  67. package/src/dto/verify-phone-by-sub.dto.ts +0 -78
  68. package/src/dto/verify-phone.dto.ts +0 -245
  69. package/src/entities/auth-audit.entity.ts +0 -232
  70. package/src/entities/challenge-session.entity.ts +0 -116
  71. package/src/entities/index.ts +0 -29
  72. package/src/entities/login-attempt.entity.ts +0 -64
  73. package/src/entities/mfa-device.entity.ts +0 -151
  74. package/src/entities/rate-limit.entity.ts +0 -44
  75. package/src/entities/session.entity.ts +0 -180
  76. package/src/entities/social-account.entity.ts +0 -96
  77. package/src/entities/storage-lock.entity.ts +0 -39
  78. package/src/entities/trusted-device.entity.ts +0 -112
  79. package/src/entities/user.entity.ts +0 -243
  80. package/src/entities/verification-token.entity.ts +0 -141
  81. package/src/enums/auth-audit-event-type.enum.ts +0 -360
  82. package/src/enums/error-codes.enum.ts +0 -420
  83. package/src/enums/mfa-method.enum.ts +0 -97
  84. package/src/enums/risk-factor.enum.ts +0 -111
  85. package/src/exceptions/nauth.exception.ts +0 -231
  86. package/src/handlers/auth.handler.ts +0 -260
  87. package/src/handlers/client-info.handler.ts +0 -101
  88. package/src/handlers/csrf.handler.ts +0 -156
  89. package/src/handlers/token-delivery.handler.ts +0 -118
  90. package/src/index.ts +0 -118
  91. package/src/interfaces/client-info.interface.ts +0 -85
  92. package/src/interfaces/config.interface.ts +0 -2135
  93. package/src/interfaces/entities.interface.ts +0 -226
  94. package/src/interfaces/index.ts +0 -15
  95. package/src/interfaces/logger.interface.ts +0 -283
  96. package/src/interfaces/mfa-provider.interface.ts +0 -154
  97. package/src/interfaces/oauth.interface.ts +0 -148
  98. package/src/interfaces/provider.interface.ts +0 -47
  99. package/src/interfaces/social-auth-provider.interface.ts +0 -131
  100. package/src/interfaces/storage-adapter.interface.ts +0 -82
  101. package/src/interfaces/template.interface.ts +0 -510
  102. package/src/interfaces/token-verifier.interface.ts +0 -110
  103. package/src/internal.ts +0 -178
  104. package/src/platform/interfaces.ts +0 -299
  105. package/src/schemas/auth-config.schema.ts +0 -646
  106. package/src/services/adaptive-mfa-decision.service.spec.ts +0 -1058
  107. package/src/services/adaptive-mfa-decision.service.ts +0 -457
  108. package/src/services/auth-audit.service.spec.ts +0 -675
  109. package/src/services/auth-audit.service.ts +0 -558
  110. package/src/services/auth-challenge-helper.service.spec.ts +0 -3227
  111. package/src/services/auth-challenge-helper.service.ts +0 -825
  112. package/src/services/auth-flow-context-builder.service.ts +0 -520
  113. package/src/services/auth-flow-rules.ts +0 -202
  114. package/src/services/auth-flow-state-definitions.ts +0 -190
  115. package/src/services/auth-flow-state-machine.service.ts +0 -207
  116. package/src/services/auth-flow-state-machine.types.ts +0 -316
  117. package/src/services/auth.service.spec.ts +0 -4195
  118. package/src/services/auth.service.ts +0 -3727
  119. package/src/services/challenge.service.spec.ts +0 -1363
  120. package/src/services/challenge.service.ts +0 -696
  121. package/src/services/client-info.service.spec.ts +0 -572
  122. package/src/services/client-info.service.ts +0 -374
  123. package/src/services/csrf.service.ts +0 -54
  124. package/src/services/email-verification.service.spec.ts +0 -1229
  125. package/src/services/email-verification.service.ts +0 -578
  126. package/src/services/geo-location.service.spec.ts +0 -603
  127. package/src/services/geo-location.service.ts +0 -599
  128. package/src/services/index.ts +0 -13
  129. package/src/services/jwt.service.spec.ts +0 -882
  130. package/src/services/jwt.service.ts +0 -621
  131. package/src/services/mfa-base.service.spec.ts +0 -246
  132. package/src/services/mfa-base.service.ts +0 -611
  133. package/src/services/mfa.service.spec.ts +0 -693
  134. package/src/services/mfa.service.ts +0 -960
  135. package/src/services/password.service.spec.ts +0 -166
  136. package/src/services/password.service.ts +0 -309
  137. package/src/services/phone-verification.service.spec.ts +0 -1120
  138. package/src/services/phone-verification.service.ts +0 -751
  139. package/src/services/risk-detection.service.spec.ts +0 -1292
  140. package/src/services/risk-detection.service.ts +0 -1012
  141. package/src/services/risk-scoring.service.spec.ts +0 -204
  142. package/src/services/risk-scoring.service.ts +0 -131
  143. package/src/services/session.service.spec.ts +0 -1293
  144. package/src/services/session.service.ts +0 -803
  145. package/src/services/social-account.service.spec.ts +0 -725
  146. package/src/services/social-auth-base.service.spec.ts +0 -418
  147. package/src/services/social-auth-base.service.ts +0 -581
  148. package/src/services/social-auth.service.spec.ts +0 -238
  149. package/src/services/social-auth.service.ts +0 -436
  150. package/src/services/social-provider-registry.service.spec.ts +0 -238
  151. package/src/services/social-provider-registry.service.ts +0 -122
  152. package/src/services/trusted-device.service.spec.ts +0 -505
  153. package/src/services/trusted-device.service.ts +0 -339
  154. package/src/storage/account-lockout-storage.service.spec.ts +0 -310
  155. package/src/storage/account-lockout-storage.service.ts +0 -89
  156. package/src/storage/index.ts +0 -3
  157. package/src/storage/memory-storage.adapter.ts +0 -443
  158. package/src/storage/rate-limit-storage.service.spec.ts +0 -247
  159. package/src/storage/rate-limit-storage.service.ts +0 -38
  160. package/src/templates/html-template.engine.spec.ts +0 -161
  161. package/src/templates/html-template.engine.ts +0 -688
  162. package/src/templates/index.ts +0 -7
  163. package/src/utils/common-passwords.spec.ts +0 -230
  164. package/src/utils/common-passwords.ts +0 -170
  165. package/src/utils/context-storage.ts +0 -188
  166. package/src/utils/cookie-names.util.ts +0 -67
  167. package/src/utils/cookies.util.ts +0 -94
  168. package/src/utils/index.ts +0 -12
  169. package/src/utils/ip-extractor.spec.ts +0 -330
  170. package/src/utils/ip-extractor.ts +0 -220
  171. package/src/utils/nauth-logger.spec.ts +0 -388
  172. package/src/utils/nauth-logger.ts +0 -215
  173. package/src/utils/pii-redactor.spec.ts +0 -130
  174. package/src/utils/pii-redactor.ts +0 -288
  175. package/src/utils/setup/get-repositories.ts +0 -140
  176. package/src/utils/setup/init-services.ts +0 -422
  177. package/src/utils/setup/init-social.ts +0 -189
  178. package/src/utils/setup/init-storage.ts +0 -94
  179. package/src/utils/setup/register-mfa.ts +0 -165
  180. package/src/utils/setup/run-nauth-migrations.ts +0 -61
  181. package/src/utils/token-delivery-policy.ts +0 -38
  182. package/src/validators/template.validator.ts +0 -219
  183. package/tsconfig.json +0 -37
  184. package/tsconfig.lint.json +0 -6
@@ -1,803 +0,0 @@
1
- import { ISession } from '../interfaces/entities.interface';
2
- import { Repository, LessThan, MoreThan, In } from 'typeorm';
3
- import { BaseSession } from '../entities';
4
- import { StorageAdapter } from '../interfaces/storage-adapter.interface';
5
- import { InternalAuthAuditService as AuthAuditService } from './auth-audit.service';
6
- import { AuthAuditEventType } from '../enums/auth-audit-event-type.enum';
7
- import { ClientInfoService } from './client-info.service';
8
- import { NAuthLogger } from '../utils/nauth-logger';
9
- import { NAuthConfig } from '../interfaces/config.interface';
10
-
11
- /**
12
- * Session Service
13
- *
14
- * Manages user sessions and device tracking including:
15
- * - Creating new sessions with device information
16
- * - Finding sessions by ID or refresh token
17
- * - Updating session activity and tokens (rotation)
18
- * - Revoking individual or all user sessions
19
- * - Token family management for reuse detection
20
- * - Token reuse detection with storage tracking
21
- * - Cleanup of expired sessions
22
- *
23
- * Security Features:
24
- * - Token family tracking for reuse detection
25
- * - Used refresh token tracking (prevents reuse attacks)
26
- * - Session expiration management
27
- * - Device fingerprinting support
28
- * - Revocation with reason tracking
29
- * - Activity timestamp updates
30
- *
31
- * @example
32
- * ```typescript
33
- * // Create session
34
- * const session = await sessionService.createSession({
35
- * userId: user.id, // Internal ID (integer)
36
- * accessTokenHash: 'hash1',
37
- * refreshTokenHash: 'hash2',
38
- * tokenFamily: 'family-abc',
39
- * // Client info (ipAddress, userAgent, etc.) automatically extracted from ClientInfoService
40
- * expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
41
- * });
42
- *
43
- * // Revoke all user sessions (global signout)
44
- * const revokedCount = await sessionService.revokeAllUserSessions(
45
- * user.id, // Internal ID (integer)
46
- * 'User requested global signout'
47
- * );
48
- * ```
49
- */
50
- export class SessionService {
51
- constructor(
52
- private readonly sessionRepository: Repository<BaseSession>,
53
- private readonly storageAdapter: StorageAdapter,
54
- private readonly clientInfoService: ClientInfoService,
55
- private readonly config: NAuthConfig,
56
- private readonly logger: NAuthLogger,
57
- private readonly auditService?: AuthAuditService, // Optional - audit trail service (enabled via config.auditLogs.enabled)
58
- ) {}
59
-
60
- /**
61
- * Calculate session expiration date from config
62
- *
63
- * Parses session.maxLifetime config (e.g., '30d', '7d', '5h') and returns
64
- * the expiration Date. Defaults to 30 days if not configured.
65
- *
66
- * @returns Session expiration date
67
- */
68
- getSessionExpirationDate(): Date {
69
- const maxLifetime = this.config.session?.maxLifetime || '30d';
70
- const expiresInSeconds = this.parseMaxLifetime(maxLifetime);
71
- return new Date(Date.now() + expiresInSeconds * 1000);
72
- }
73
-
74
- /**
75
- * Parse maxLifetime from string or number
76
- *
77
- * @param maxLifetime - Max lifetime (e.g., '30d', '7d', '5h', 2592000)
78
- * @returns Max lifetime in seconds
79
- * @private
80
- */
81
- private parseMaxLifetime(maxLifetime: string | number): number {
82
- if (typeof maxLifetime === 'number') {
83
- return maxLifetime;
84
- }
85
-
86
- // Parse time strings (e.g., '15m', '1h', '30d')
87
- const units: Record<string, number> = {
88
- s: 1,
89
- m: 60,
90
- h: 3600,
91
- d: 86400,
92
- };
93
-
94
- const match = maxLifetime.match(/^(\d+)([smhd])$/);
95
- if (!match) {
96
- this.logger?.warn?.(`Invalid session.maxLifetime format: ${maxLifetime}. Using default 30 days.`);
97
- return 30 * 86400; // Default to 30 days in seconds
98
- }
99
-
100
- const [, value, unit] = match;
101
- return parseInt(value, 10) * units[unit];
102
- }
103
-
104
- /**
105
- * Create a new session
106
- *
107
- * Creates a session record with token hashes, device information,
108
- * and expiration time. Used during login and signup.
109
- *
110
- * @param data - Session creation data
111
- * @param data.userId - Internal user ID (integer, not sub)
112
- * @param data.accessTokenHash - SHA-256 hash of access token
113
- * @param data.refreshTokenHash - SHA-256 hash of refresh token
114
- * @param data.tokenFamily - Token family ID for rotation detection
115
- * @param data.deviceId - Optional device identifier (UUID). Auto-generated if not provided.
116
- * @param data.deviceName - Optional device name. Falls back to parsed value from ClientInfoService if not provided.
117
- * @param data.deviceType - Optional device type (mobile, desktop, tablet). Falls back to parsed value from ClientInfoService if not provided.
118
- * @param data.expiresAt - Session expiration date
119
- * @remarks Client info (ipAddress, ipCountry, ipCity, userAgent, platform, browser) is automatically extracted from ClientInfoService context
120
- * @param data.isRemembered - Whether session is from "remember me"
121
- * @param data.authMethod - Authentication method: 'password', 'google', 'facebook', 'github', etc.
122
- * @returns Created session
123
- *
124
- * @example
125
- * ```typescript
126
- * const session = await sessionService.createSession({
127
- * userId: user.id, // Internal ID (integer)
128
- * accessTokenHash: jwtService.hashToken(accessToken),
129
- * refreshTokenHash: jwtService.hashToken(refreshToken),
130
- * tokenFamily: jwtService.generateTokenFamily(),
131
- * // Client info (ipAddress, userAgent, etc.) automatically extracted from ClientInfoService
132
- * expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
133
- * });
134
- * ```
135
- */
136
- async createSession(data: {
137
- userId: number; // Internal user ID (integer)
138
- accessTokenHash: string;
139
- refreshTokenHash: string;
140
- tokenFamily: string;
141
- deviceId?: string;
142
- deviceName?: string; // Optional - falls back to parsed value from ClientInfoService
143
- deviceType?: string; // Optional - falls back to parsed value from ClientInfoService
144
- // Client info (ipAddress, ipCountry, ipCity, userAgent) automatically extracted from ClientInfoService
145
- expiresAt: Date;
146
- isRemembered?: boolean;
147
- authMethod?: string; // Authentication method: 'password', 'google', 'facebook', etc.
148
- }): Promise<ISession> {
149
- // ============================================================================
150
- // Session Limit Enforcement (maxConcurrent)
151
- // ============================================================================
152
- const maxConcurrent = this.config.session?.maxConcurrent;
153
- if (maxConcurrent && maxConcurrent > 0) {
154
- // Count active sessions for this user (not revoked and not expired)
155
- const now = new Date();
156
- // Fetch only IDs of active sessions ordered by oldest activity
157
- const activeIds: Array<{ id: number }> = (await this.sessionRepository.find({
158
- select: ['id'],
159
- where: { userId: data.userId, isRevoked: false, expiresAt: MoreThan(now) },
160
- order: { lastActivityAt: 'ASC' },
161
- })) as unknown as Array<{ id: number }>;
162
-
163
- if (activeIds.length >= maxConcurrent) {
164
- // Revoke oldest sessions to make room for new one (bulk update)
165
- const toRevokeCount = activeIds.length - maxConcurrent + 1;
166
- const idsToRevoke = activeIds.slice(0, toRevokeCount).map((s) => s.id);
167
-
168
- if (idsToRevoke.length > 0) {
169
- const nowTs = new Date();
170
- const result = await this.sessionRepository.update(
171
- { id: In(idsToRevoke) },
172
- { isRevoked: true, revokedAt: nowTs, revokeReason: 'Max concurrent sessions exceeded' },
173
- );
174
-
175
- const affected = result.affected || idsToRevoke.length;
176
- if (affected > 0) {
177
- // Single summary audit event
178
- try {
179
- await this.auditService?.recordEvent({
180
- userId: data.userId,
181
- eventType: AuthAuditEventType.SESSION_REVOKED,
182
- eventStatus: 'INFO',
183
- reason: 'Max concurrent sessions exceeded',
184
- description: `Revoked ${affected} session(s) due to max concurrent sessions limit`,
185
- metadata: {
186
- revokedCount: affected,
187
- sessionIds: idsToRevoke,
188
- },
189
- });
190
- } catch (auditError) {
191
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
192
- this.logger?.error?.(`Failed to record SESSION_REVOKED summary event: ${errorMessage}`, {
193
- error: auditError,
194
- userId: data.userId,
195
- });
196
- }
197
- }
198
- }
199
- }
200
- }
201
- // ============================================================================
202
- // Get client info from context (transparent access - no parameters needed)
203
- // ============================================================================
204
- // Client info is automatically extracted by ClientInfoInterceptor and stored in context
205
- // This includes parsed user agent info (platform, browser, deviceType, deviceName)
206
- // This allows SessionService to access IP, userAgent, etc. without requiring them as parameters
207
- const clientInfo = this.clientInfoService.get();
208
-
209
- // ============================================================================
210
- // Use parsed device information from ClientInfoService (already parsed by interceptor)
211
- // ============================================================================
212
- // ClientInfoService already parsed the user agent in the interceptor
213
- // Use provided values or fall back to parsed values from context
214
- const deviceType = data.deviceType || clientInfo.deviceType || null;
215
- const deviceName = data.deviceName || clientInfo.deviceName || null;
216
- const platform = clientInfo.platform || null;
217
- const browser = clientInfo.browser || null;
218
-
219
- // ============================================================================
220
- // Generate deviceId if not provided
221
- // ============================================================================
222
- // DeviceId is a unique UUID that identifies a specific browser/device
223
- // It's used for trusted device tracking and MFA "remember device" features
224
- // In browser contexts, this should be stored in localStorage/sessionStorage
225
- // and sent with each login request to maintain continuity
226
- let deviceId = data.deviceId;
227
- if (!deviceId) {
228
- const crypto = await import('crypto');
229
- deviceId = crypto.randomUUID();
230
- }
231
-
232
- // Debug: Log what we're about to save
233
- if (!clientInfo.ipLatitude || !clientInfo.ipLongitude) {
234
- this.logger?.warn?.(
235
- `[SessionService] Creating session WITHOUT coordinates: ` +
236
- `IP=${clientInfo.ipAddress}, country=${clientInfo.ipCountry}, city=${clientInfo.ipCity}, ` +
237
- `lat=${clientInfo.ipLatitude}, lon=${clientInfo.ipLongitude}`,
238
- );
239
- } else {
240
- this.logger?.debug?.(
241
- `[SessionService] Creating session WITH coordinates: ` +
242
- `IP=${clientInfo.ipAddress}, ${clientInfo.ipCity}, ${clientInfo.ipCountry} ` +
243
- `(${clientInfo.ipLatitude}, ${clientInfo.ipLongitude})`,
244
- );
245
- }
246
-
247
- const session = this.sessionRepository.create({
248
- userId: data.userId,
249
- accessTokenHash: data.accessTokenHash,
250
- refreshTokenHash: data.refreshTokenHash,
251
- tokenFamily: data.tokenFamily,
252
- deviceId,
253
- deviceName,
254
- deviceType,
255
- // Client info automatically extracted from ClientInfoService (transparent access)
256
- ipAddress: clientInfo.ipAddress || null,
257
- ipCountry: clientInfo.ipCountry || null,
258
- ipCity: clientInfo.ipCity || null,
259
- ipLatitude: clientInfo.ipLatitude || null,
260
- ipLongitude: clientInfo.ipLongitude || null,
261
- userAgent: clientInfo.userAgent || null,
262
- platform,
263
- browser,
264
- authMethod: data.authMethod || null,
265
- expiresAt: data.expiresAt,
266
- isRemembered: data.isRemembered || false,
267
- lastActivityAt: new Date(),
268
- });
269
-
270
- const savedSession = (await this.sessionRepository.save(session)) as unknown as ISession;
271
-
272
- // ============================================================================
273
- // Audit: Record session creation
274
- // ============================================================================
275
- try {
276
- await this.auditService?.recordEvent({
277
- userId: data.userId,
278
- eventType: AuthAuditEventType.SESSION_CREATED,
279
- eventStatus: 'INFO',
280
- sessionId: savedSession.id,
281
- authMethod: data.authMethod || null,
282
- // Client info automatically included from context
283
- metadata: {
284
- deviceId: savedSession.deviceId,
285
- deviceName: savedSession.deviceName,
286
- deviceType: savedSession.deviceType,
287
- isRemembered: savedSession.isRemembered,
288
- },
289
- });
290
- } catch (auditError) {
291
- // Non-blocking: Log but continue
292
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
293
- this.logger?.error?.(`Failed to record SESSION_CREATED audit event: ${errorMessage}`, {
294
- error: auditError,
295
- userId: data.userId,
296
- sessionId: savedSession.id,
297
- });
298
- }
299
-
300
- return savedSession;
301
- }
302
-
303
- /**
304
- * Find session by ID
305
- * @param sessionId - Session ID (can be string from JWT or number)
306
- */
307
- async findById(sessionId: string | number): Promise<ISession | null> {
308
- return (await this.sessionRepository.findOne({
309
- where: { id: typeof sessionId === 'string' ? parseInt(sessionId, 10) : sessionId },
310
- })) as ISession | null;
311
- }
312
-
313
- /**
314
- * Find session by ID with minimal fields (hot-path)
315
- * @param sessionId - Session ID (string or number)
316
- */
317
- async findByIdLight(
318
- sessionId: string | number,
319
- ): Promise<Pick<ISession, 'id' | 'version' | 'isRevoked' | 'expiresAt' | 'userId'> | null> {
320
- const id = typeof sessionId === 'string' ? parseInt(sessionId, 10) : sessionId;
321
- // Select minimal session fields to reduce DB payload
322
- const record = (await this.sessionRepository.findOne({
323
- select: ['id', 'version', 'isRevoked', 'expiresAt', 'userId'],
324
- where: { id },
325
- })) as unknown as ISession | null;
326
-
327
- if (!record) return null;
328
- const versionValue = (record as unknown as { version?: number }).version ?? (undefined as unknown as number);
329
- const light = {
330
- id: record.id,
331
- version: versionValue,
332
- isRevoked: record.isRevoked,
333
- expiresAt: record.expiresAt,
334
- userId: record.userId,
335
- } as unknown as Pick<ISession, 'id' | 'version' | 'isRevoked' | 'expiresAt' | 'userId'>;
336
- return light;
337
- }
338
-
339
- /**
340
- * Find session by refresh token hash
341
- */
342
- async findByRefreshToken(refreshTokenHash: string): Promise<ISession | null> {
343
- return (await this.sessionRepository.findOne({
344
- select: ['id', 'userId', 'isRevoked', 'tokenFamily', 'expiresAt'],
345
- where: { refreshTokenHash, isRevoked: false },
346
- })) as ISession | null;
347
- }
348
-
349
- /**
350
- * Find all active sessions for a user
351
- * @param userId - Internal user ID (integer)
352
- * @returns Array of active sessions
353
- */
354
- async findUserSessions(userId: number): Promise<ISession[]> {
355
- return (await this.sessionRepository.find({
356
- where: { userId, isRevoked: false },
357
- order: { createdAt: 'DESC' },
358
- })) as unknown as ISession[];
359
- }
360
-
361
- /**
362
- * Update session activity timestamp
363
- * @param sessionId - Session ID (can be string from JWT or number)
364
- */
365
- async updateActivity(sessionId: string | number): Promise<void> {
366
- const id = typeof sessionId === 'string' ? parseInt(sessionId, 10) : sessionId;
367
- await this.sessionRepository.update(id, {
368
- lastActivityAt: new Date(),
369
- });
370
- }
371
-
372
- /**
373
- * Update session with new tokens (for rotation)
374
- * @param sessionId - Session ID (can be string from JWT or number)
375
- */
376
- async updateTokens(sessionId: string | number, accessTokenHash: string, refreshTokenHash: string): Promise<void> {
377
- const id = typeof sessionId === 'string' ? parseInt(sessionId, 10) : sessionId;
378
- await this.sessionRepository.update(id, {
379
- accessTokenHash,
380
- refreshTokenHash,
381
- lastActivityAt: new Date(),
382
- });
383
- }
384
-
385
- /**
386
- * Create a session and update token hashes atomically within one transaction
387
- *
388
- * Uses a callback to generate token hashes after obtaining the session ID.
389
- * This allows callers to embed sessionId in JWTs, then persist hashes atomically.
390
- */
391
- async createSessionAtomic<T>(
392
- data: {
393
- userId: number;
394
- tokenFamily: string;
395
- deviceId?: string;
396
- deviceName?: string; // Optional - falls back to parsed value from ClientInfoService
397
- deviceType?: string; // Optional - falls back to parsed value from ClientInfoService
398
- // Client info (ipAddress, ipCountry, ipCity, userAgent) automatically extracted from ClientInfoService
399
- expiresAt: Date;
400
- isRemembered?: boolean;
401
- authMethod?: string;
402
- },
403
- generateHashes: (sessionId: number) => Promise<{ accessTokenHash: string; refreshTokenHash: string; extra?: T }>,
404
- ): Promise<{ session: ISession; extra?: T }> {
405
- // Get client info from context (transparent access)
406
- // ClientInfoService already parsed the user agent in the interceptor
407
- const clientInfo = this.clientInfoService.get();
408
-
409
- const result = await this.sessionRepository.manager.transaction(async (trx) => {
410
- // Use parsed device information from ClientInfoService (already parsed by interceptor)
411
- // Use provided values or fall back to parsed values from context
412
- const deviceType = data.deviceType || clientInfo.deviceType || null;
413
- const deviceName = data.deviceName || clientInfo.deviceName || null;
414
- const platform = clientInfo.platform || null;
415
- const browser = clientInfo.browser || null;
416
-
417
- // Generate deviceId if missing
418
- let deviceId = data.deviceId;
419
- if (!deviceId) {
420
- const crypto = await import('crypto');
421
- deviceId = crypto.randomUUID();
422
- }
423
-
424
- // Create with placeholder hashes (non-nullable columns)
425
- const sessionEntity = this.sessionRepository.create({
426
- userId: data.userId,
427
- accessTokenHash: '',
428
- refreshTokenHash: '',
429
- tokenFamily: data.tokenFamily,
430
- deviceId,
431
- deviceName,
432
- deviceType,
433
- // Client info automatically extracted from ClientInfoService (transparent access)
434
- ipAddress: clientInfo.ipAddress || null,
435
- ipCountry: clientInfo.ipCountry || null,
436
- ipCity: clientInfo.ipCity || null,
437
- ipLatitude: clientInfo.ipLatitude || null,
438
- ipLongitude: clientInfo.ipLongitude || null,
439
- userAgent: clientInfo.userAgent || null,
440
- platform,
441
- browser,
442
- authMethod: data.authMethod || null,
443
- expiresAt: data.expiresAt,
444
- isRemembered: data.isRemembered || false,
445
- lastActivityAt: new Date(),
446
- });
447
-
448
- // Debug: Log what we're about to save in atomic transaction
449
- if (!clientInfo.ipLatitude || !clientInfo.ipLongitude) {
450
- this.logger?.warn?.(
451
- `[SessionService.createSessionAtomic] Creating session WITHOUT coordinates: ` +
452
- `IP=${clientInfo.ipAddress}, country=${clientInfo.ipCountry}, city=${clientInfo.ipCity}, ` +
453
- `lat=${clientInfo.ipLatitude}, lon=${clientInfo.ipLongitude}`,
454
- );
455
- } else {
456
- this.logger?.debug?.(
457
- `[SessionService.createSessionAtomic] Creating session WITH coordinates: ` +
458
- `IP=${clientInfo.ipAddress}, ${clientInfo.ipCity}, ${clientInfo.ipCountry} ` +
459
- `(${clientInfo.ipLatitude}, ${clientInfo.ipLongitude})`,
460
- );
461
- }
462
-
463
- const saved = await trx.save(sessionEntity);
464
- const savedId = saved.id as number;
465
-
466
- const { accessTokenHash, refreshTokenHash, extra } = await generateHashes(savedId);
467
-
468
- await trx
469
- .createQueryBuilder()
470
- .update(this.sessionRepository.target)
471
- .set({ accessTokenHash, refreshTokenHash, lastActivityAt: new Date() })
472
- .where({ id: savedId })
473
- .execute();
474
-
475
- // Re-fetch minimal session fields to return
476
- const sessionLight = (await trx.findOne(this.sessionRepository.target, {
477
- where: { id: savedId },
478
- })) as unknown as ISession | null;
479
-
480
- if (!sessionLight) {
481
- throw new Error('Failed to load session after creation');
482
- }
483
-
484
- return { session: sessionLight, extra } as { session: ISession; extra?: T };
485
- });
486
-
487
- // ============================================================================
488
- // Audit: Record session creation
489
- // ============================================================================
490
- try {
491
- await this.auditService?.recordEvent({
492
- userId: data.userId,
493
- eventType: AuthAuditEventType.SESSION_CREATED,
494
- eventStatus: 'INFO',
495
- sessionId: result.session.id,
496
- authMethod: data.authMethod || null,
497
- // Client info automatically included from context
498
- metadata: {
499
- deviceId: result.session.deviceId,
500
- deviceName: result.session.deviceName,
501
- deviceType: result.session.deviceType,
502
- isRemembered: result.session.isRemembered,
503
- },
504
- });
505
- } catch (auditError) {
506
- // Non-blocking: Log but continue
507
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
508
- this.logger?.error?.(`Failed to record SESSION_CREATED audit event: ${errorMessage}`, {
509
- error: auditError,
510
- userId: data.userId,
511
- sessionId: result.session.id,
512
- });
513
- }
514
-
515
- return result;
516
- }
517
-
518
- /**
519
- * Revoke a single session
520
- * @param sessionId - Session ID (can be string from JWT or number)
521
- * @param reason - Optional reason for revocation
522
- * @param metadata - Optional metadata to include in audit trail
523
- */
524
- async revokeSession(sessionId: string | number, reason?: string, metadata?: Record<string, unknown>): Promise<void> {
525
- const id = typeof sessionId === 'string' ? parseInt(sessionId, 10) : sessionId;
526
-
527
- // Get session to retrieve userId for audit logging
528
- const session = await this.findById(id);
529
- if (!session) {
530
- return; // Session doesn't exist, nothing to revoke
531
- }
532
-
533
- await this.sessionRepository.update(id, {
534
- isRevoked: true,
535
- revokedAt: new Date(),
536
- revokeReason: reason,
537
- });
538
-
539
- // ============================================================================
540
- // Audit: Record session revocation
541
- // ============================================================================
542
- try {
543
- await this.auditService?.recordEvent({
544
- userId: session.userId,
545
- eventType: AuthAuditEventType.SESSION_REVOKED,
546
- eventStatus: 'INFO',
547
- sessionId: id,
548
- reason: reason || 'User logout',
549
- description: `Session revoked: ${reason || 'User logout'}`,
550
- // Client info automatically included from context
551
- metadata: metadata || undefined,
552
- });
553
- } catch (auditError) {
554
- // Non-blocking: Log but continue
555
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
556
- this.logger?.error?.(`Failed to record SESSION_REVOKED audit event: ${errorMessage}`, {
557
- error: auditError,
558
- userId: session.userId,
559
- sessionId: id,
560
- });
561
- }
562
- }
563
-
564
- /**
565
- * Revoke all sessions for a user (global signout)
566
- * @param userId - Internal user ID (integer)
567
- * @param reason - Optional reason for revocation
568
- * @returns Number of sessions revoked
569
- */
570
- async revokeAllUserSessions(userId: number, reason?: string): Promise<number> {
571
- // Get sessions before revoking for audit logging
572
- const sessions = await this.findUserSessions(userId);
573
-
574
- const result = await this.sessionRepository.update(
575
- { userId, isRevoked: false },
576
- {
577
- isRevoked: true,
578
- revokedAt: new Date(),
579
- revokeReason: reason,
580
- },
581
- );
582
-
583
- const revokedCount = result.affected || 0;
584
-
585
- // ============================================================================
586
- // Audit: Record session revocations (one event per session for global signout)
587
- // ============================================================================
588
- if (revokedCount > 0) {
589
- try {
590
- const isGlobalSignout = reason === 'Global signout';
591
-
592
- if (isGlobalSignout) {
593
- // For global signout, record individual SESSION_REVOKED event for each session
594
- // AuthService.logoutAll() will record a GLOBAL_SIGNOUT event separately
595
- for (const session of sessions) {
596
- try {
597
- await this.auditService?.recordEvent({
598
- userId,
599
- eventType: AuthAuditEventType.SESSION_REVOKED,
600
- eventStatus: 'INFO',
601
- reason: 'Global signout',
602
- description: `Session revoked by global signout`,
603
- sessionId: session.id,
604
- // Client info automatically included from context
605
- metadata: {
606
- revokedBy: 'global_signout',
607
- },
608
- });
609
- } catch (sessionAuditError) {
610
- // Non-blocking: Log but continue with other sessions
611
- const errorMessage = sessionAuditError instanceof Error ? sessionAuditError.message : 'Unknown error';
612
- this.logger?.error?.(
613
- `Failed to record SESSION_REVOKED audit event for session ${session.id}: ${errorMessage}`,
614
- {
615
- error: sessionAuditError,
616
- userId,
617
- sessionId: session.id,
618
- },
619
- );
620
- }
621
- }
622
- } else {
623
- // For other reasons (e.g., "Login from new session"), record one summary event
624
- await this.auditService?.recordEvent({
625
- userId,
626
- eventType: AuthAuditEventType.SESSION_REVOKED,
627
- eventStatus: 'INFO',
628
- reason: reason || 'Session revocation',
629
- description: `All user sessions revoked (${revokedCount} session(s))`,
630
- // Client info automatically included from context
631
- metadata: {
632
- revokedCount,
633
- sessionIds: sessions.map((s) => s.id),
634
- },
635
- });
636
- }
637
- } catch (auditError) {
638
- // Non-blocking: Log but continue
639
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
640
- this.logger?.error?.(`Failed to record SESSION_REVOKED audit event (all sessions): ${errorMessage}`, {
641
- error: auditError,
642
- userId,
643
- revokedCount,
644
- });
645
- }
646
- }
647
-
648
- return revokedCount;
649
- }
650
-
651
- /**
652
- * Revoke all sessions in a token family (for reuse detection)
653
- */
654
- async revokeTokenFamily(tokenFamily: string, reason?: string): Promise<number> {
655
- const result = await this.sessionRepository.update(
656
- { tokenFamily, isRevoked: false },
657
- {
658
- isRevoked: true,
659
- revokedAt: new Date(),
660
- revokeReason: reason || 'Token reuse detected',
661
- },
662
- );
663
-
664
- return result.affected || 0;
665
- }
666
-
667
- /**
668
- * Cleanup expired sessions
669
- */
670
- async cleanupExpiredSessions(): Promise<number> {
671
- const result = await this.sessionRepository.delete({
672
- expiresAt: LessThan(new Date()),
673
- });
674
-
675
- return result.affected || 0;
676
- }
677
-
678
- /**
679
- * Count active sessions for a user
680
- * @param userId - Internal user ID (integer)
681
- * @returns Number of active sessions
682
- */
683
- async countUserSessions(userId: number): Promise<number> {
684
- return await this.sessionRepository.count({
685
- where: { userId, isRevoked: false },
686
- });
687
- }
688
-
689
- // ============================================================================
690
- // Token Reuse Detection (Security Feature)
691
- // ============================================================================
692
-
693
- /**
694
- * Mark a refresh token as used
695
- *
696
- * Stores the token hash in cache with expiration matching the refresh token TTL.
697
- * Used to detect token reuse attacks where stolen tokens are reused multiple times.
698
- *
699
- * ⚠️ SECURITY CRITICAL: This prevents token replay attacks
700
- *
701
- * @param tokenHash - SHA-256 hash of the refresh token
702
- * @param ttlSeconds - Time to live in seconds (should match refresh token expiry)
703
- *
704
- * @example
705
- * ```typescript
706
- * // Mark token as used during refresh
707
- * await sessionService.markRefreshTokenAsUsed(tokenHash, 30 * 24 * 60 * 60);
708
- * ```
709
- */
710
- async markRefreshTokenAsUsed(tokenHash: string, ttlSeconds: number): Promise<boolean> {
711
- const key = `used-token:${tokenHash}`;
712
-
713
- // Use atomic set-if-not-exists operation
714
- // Returns null if key already exists (NX failed), string if key was set
715
- const result = await this.storageAdapter.set(key, 'true', ttlSeconds, { nx: true });
716
-
717
- return result !== null; // True if successfully set, false if already existed
718
- }
719
-
720
- /**
721
- * Check if a refresh token has been used before
722
- *
723
- * If token has been used, it indicates a token reuse attack and the entire
724
- * token family should be revoked immediately.
725
- *
726
- * @param tokenHash - SHA-256 hash of the refresh token
727
- * @returns True if token has been used before, false otherwise
728
- *
729
- * @example
730
- * ```typescript
731
- * const isReused = await sessionService.isRefreshTokenUsed(tokenHash);
732
- * if (isReused) {
733
- * // TOKEN REUSE DETECTED - SECURITY BREACH!
734
- * await sessionService.revokeTokenFamily(session.tokenFamily);
735
- * throw new UnauthorizedException('Token reuse detected');
736
- * }
737
- * ```
738
- */
739
- async isRefreshTokenUsed(tokenHash: string): Promise<boolean> {
740
- const key = `used-token:${tokenHash}`;
741
- return await this.storageAdapter.exists(key);
742
- }
743
-
744
- /**
745
- * Acquire a distributed lock for token refresh
746
- *
747
- * Uses atomic set-if-not-exists (NX) operation to prevent concurrent refresh attempts.
748
- * Lock is automatically released after TTL expires, or manually via releaseRefreshLock.
749
- *
750
- * @param lockKey - Lock key (e.g., `session-refresh:${sessionId}` or `refresh-lock:${tokenHash}`)
751
- * @param ttlMs - Lock TTL in milliseconds (default: 10000ms)
752
- * @returns True if lock was acquired, false if already locked by another request
753
- *
754
- * @example
755
- * ```typescript
756
- * const lockKey = `session-refresh:${sessionId}`;
757
- * const lockAcquired = await sessionService.acquireRefreshLock(lockKey, 10000);
758
- * if (!lockAcquired) {
759
- * throw new Error('Refresh already in progress');
760
- * }
761
- * try {
762
- * // ... perform refresh ...
763
- * } finally {
764
- * await sessionService.releaseRefreshLock(lockKey);
765
- * }
766
- * ```
767
- */
768
- async acquireRefreshLock(lockKey: string, ttlMs: number = 10000): Promise<boolean> {
769
- // ============================================================================
770
- // CRITICAL FIX: Use atomic set-if-not-exists (NX) for lock acquisition
771
- // ============================================================================
772
- // The set() method with nx: true uses a transaction with pessimistic locking
773
- // to atomically check and insert. This ensures only one request can acquire
774
- // the lock even when multiple requests arrive simultaneously.
775
- // Increased default TTL to 10 seconds to handle slower database operations
776
- // Add small jitter (±5%) to reduce synchronized expirations under load
777
- const baseTtlSeconds = Math.max(1, Math.ceil(ttlMs / 1000));
778
- const jitterMax = Math.max(1, Math.floor(baseTtlSeconds * 0.05));
779
- const jitter = Math.floor(Math.random() * (jitterMax * 2 + 1)) - jitterMax; // [-jitterMax, +jitterMax]
780
- const ttlWithJitter = Math.max(1, baseTtlSeconds + jitter);
781
-
782
- const result = await this.storageAdapter.set(lockKey, 'locked', ttlWithJitter, { nx: true });
783
-
784
- const acquired = result !== null;
785
-
786
- // Debug logging to help diagnose lock issues
787
- if (!acquired) {
788
- // Lock acquisition failed - another request has it
789
- // This is expected behavior when multiple requests try to refresh simultaneously
790
- }
791
-
792
- return acquired;
793
- }
794
-
795
- /**
796
- * Release a distributed lock for token refresh
797
- *
798
- * @param lockKey - Lock key (must match the key used in acquireRefreshLock)
799
- */
800
- async releaseRefreshLock(lockKey: string): Promise<void> {
801
- await this.storageAdapter.del(lockKey);
802
- }
803
- }