@lenne.tech/nest-server 11.10.2 → 11.10.4

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 (67) hide show
  1. package/dist/config.env.js +16 -133
  2. package/dist/config.env.js.map +1 -1
  3. package/dist/core/common/interfaces/server-options.interface.d.ts +4 -0
  4. package/dist/core/modules/auth/core-auth.module.js +8 -4
  5. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  6. package/dist/core/modules/auth/guards/roles-guard-registry.d.ts +9 -0
  7. package/dist/core/modules/auth/guards/roles-guard-registry.js +30 -0
  8. package/dist/core/modules/auth/guards/roles-guard-registry.js.map +1 -0
  9. package/dist/core/modules/better-auth/better-auth.config.d.ts +3 -0
  10. package/dist/core/modules/better-auth/better-auth.config.js +176 -47
  11. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  12. package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +5 -1
  13. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +101 -8
  14. package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
  15. package/dist/core/modules/better-auth/core-better-auth-challenge.service.d.ts +20 -0
  16. package/dist/core/modules/better-auth/core-better-auth-challenge.service.js +142 -0
  17. package/dist/core/modules/better-auth/core-better-auth-challenge.service.js.map +1 -0
  18. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +1 -1
  19. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  20. package/dist/core/modules/better-auth/core-better-auth-web.helper.d.ts +2 -0
  21. package/dist/core/modules/better-auth/core-better-auth-web.helper.js +29 -1
  22. package/dist/core/modules/better-auth/core-better-auth-web.helper.js.map +1 -1
  23. package/dist/core/modules/better-auth/core-better-auth.controller.js +5 -13
  24. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  25. package/dist/core/modules/better-auth/core-better-auth.middleware.d.ts +0 -1
  26. package/dist/core/modules/better-auth/core-better-auth.middleware.js +6 -19
  27. package/dist/core/modules/better-auth/core-better-auth.middleware.js.map +1 -1
  28. package/dist/core/modules/better-auth/core-better-auth.module.d.ts +5 -1
  29. package/dist/core/modules/better-auth/core-better-auth.module.js +74 -27
  30. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  31. package/dist/core/modules/better-auth/core-better-auth.resolver.js +7 -6
  32. package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
  33. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +0 -2
  34. package/dist/core/modules/better-auth/core-better-auth.service.js +23 -37
  35. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  36. package/dist/core.module.js +10 -1
  37. package/dist/core.module.js.map +1 -1
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +1 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/server/modules/better-auth/better-auth.module.d.ts +4 -1
  42. package/dist/server/modules/better-auth/better-auth.module.js +4 -1
  43. package/dist/server/modules/better-auth/better-auth.module.js.map +1 -1
  44. package/dist/server/server.module.js +1 -4
  45. package/dist/server/server.module.js.map +1 -1
  46. package/dist/tsconfig.build.tsbuildinfo +1 -1
  47. package/package.json +1 -1
  48. package/src/config.env.ts +24 -174
  49. package/src/core/common/interfaces/server-options.interface.ts +288 -35
  50. package/src/core/modules/auth/core-auth.module.ts +11 -5
  51. package/src/core/modules/auth/guards/roles-guard-registry.ts +57 -0
  52. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +85 -56
  53. package/src/core/modules/better-auth/README.md +132 -35
  54. package/src/core/modules/better-auth/better-auth.config.ts +402 -70
  55. package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +158 -18
  56. package/src/core/modules/better-auth/core-better-auth-challenge.service.ts +254 -0
  57. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +1 -1
  58. package/src/core/modules/better-auth/core-better-auth-web.helper.ts +64 -1
  59. package/src/core/modules/better-auth/core-better-auth.controller.ts +6 -14
  60. package/src/core/modules/better-auth/core-better-auth.middleware.ts +7 -20
  61. package/src/core/modules/better-auth/core-better-auth.module.ts +173 -38
  62. package/src/core/modules/better-auth/core-better-auth.resolver.ts +7 -6
  63. package/src/core/modules/better-auth/core-better-auth.service.ts +27 -48
  64. package/src/core.module.ts +21 -3
  65. package/src/index.ts +1 -0
  66. package/src/server/modules/better-auth/better-auth.module.ts +40 -10
  67. package/src/server/server.module.ts +2 -4
@@ -1,8 +1,9 @@
1
- import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
1
+ import { Injectable, Logger, NestMiddleware, Optional } from '@nestjs/common';
2
2
  import { NextFunction, Request, Response } from 'express';
3
3
 
4
4
  import { isProduction } from '../../common/helpers/logging.helper';
5
- import { extractSessionToken, sendWebResponse, toWebRequest } from './core-better-auth-web.helper';
5
+ import { CoreBetterAuthChallengeService } from './core-better-auth-challenge.service';
6
+ import { extractSessionToken, sendWebResponse, signCookieValue, toWebRequest } from './core-better-auth-web.helper';
6
7
  import { CoreBetterAuthService } from './core-better-auth.service';
7
8
 
8
9
  /**
@@ -25,6 +26,22 @@ const CONTROLLER_HANDLED_PATHS = [
25
26
  '/session',
26
27
  ];
27
28
 
29
+ /**
30
+ * Passkey paths that generate challenges
31
+ */
32
+ const PASSKEY_GENERATE_PATHS = [
33
+ '/passkey/generate-register-options',
34
+ '/passkey/generate-authenticate-options',
35
+ ];
36
+
37
+ /**
38
+ * Passkey paths that verify challenges
39
+ */
40
+ const PASSKEY_VERIFY_PATHS = [
41
+ '/passkey/verify-registration',
42
+ '/passkey/verify-authentication',
43
+ ];
44
+
28
45
  /**
29
46
  * Middleware that forwards Better Auth API requests to the native Better Auth handler.
30
47
  *
@@ -35,22 +52,35 @@ const CONTROLLER_HANDLED_PATHS = [
35
52
  * - Magic link authentication
36
53
  * - Email verification
37
54
  *
38
- * The middleware:
39
- * 1. Checks if the request path starts with the Better Auth base path (e.g., /iam)
40
- * 2. Skips paths that need nest-server-specific logic (sign-in, sign-up, session)
41
- * 3. Extracts session token and signs cookies for Better Auth compatibility
42
- * 4. Converts the Express request to a Web Standard Request
43
- * 5. Calls Better Auth's native handler and sends the response
55
+ * For JWT mode (cookieless), this middleware provides an adapter for Passkey challenges:
56
+ * 1. On generate: Extracts Better Auth's verificationToken from Set-Cookie and stores mapping
57
+ * 2. On verify: Injects verificationToken as cookie so Better Auth can find the challenge
44
58
  *
45
- * IMPORTANT: Cookie signing is handled here to ensure Better Auth receives
46
- * properly signed session cookies for all plugin endpoints.
59
+ * This approach maintains full compatibility with Better Auth's internal mechanisms.
47
60
  */
48
61
  @Injectable()
49
62
  export class CoreBetterAuthApiMiddleware implements NestMiddleware {
50
63
  private readonly logger = new Logger(CoreBetterAuthApiMiddleware.name);
51
64
  private readonly isProd = isProduction();
65
+ private loggedChallengeStorageMode = false;
52
66
 
53
- constructor(private readonly betterAuthService: CoreBetterAuthService) {}
67
+ constructor(
68
+ private readonly betterAuthService: CoreBetterAuthService,
69
+ @Optional() private readonly challengeService?: CoreBetterAuthChallengeService,
70
+ ) {}
71
+
72
+ /**
73
+ * Check if database challenge storage should be used.
74
+ * This is checked dynamically because the ChallengeService initializes in onModuleInit.
75
+ */
76
+ private useDbChallengeStorage(): boolean {
77
+ const enabled = this.challengeService?.isEnabled() ?? false;
78
+ if (enabled && !this.loggedChallengeStorageMode) {
79
+ this.logger.log('Passkey challenge storage: database (JWT mode compatible)');
80
+ this.loggedChallengeStorageMode = true;
81
+ }
82
+ return enabled;
83
+ }
54
84
 
55
85
  async use(req: Request, res: Response, next: NextFunction) {
56
86
  // Skip if Better-Auth is not enabled
@@ -59,13 +89,17 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
59
89
  }
60
90
 
61
91
  const basePath = this.betterAuthService.getBasePath();
62
- const requestPath = req.path;
92
+ // Use originalUrl to get full path for IAM endpoints, but fallback to req.path
93
+ // The originalUrl contains the original request path as sent by client
94
+ const requestPath = req.originalUrl?.split('?')[0] || req.path;
63
95
 
64
96
  // Only handle requests that start with the Better Auth base path
65
97
  if (!requestPath.startsWith(basePath)) {
66
98
  return next();
67
99
  }
68
100
 
101
+ this.logger.debug(`API Middleware handling: ${req.method} ${requestPath}`);
102
+
69
103
  // Get the path relative to the base path
70
104
  const relativePath = requestPath.slice(basePath.length);
71
105
 
@@ -81,19 +115,49 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
81
115
  return next();
82
116
  }
83
117
 
84
- if (!this.isProd) {
85
- this.logger.debug(`Forwarding to Better Auth handler: ${req.method} ${requestPath}`);
86
- }
118
+ this.logger.debug(`Forwarding to Better Auth handler: ${req.method} ${requestPath}`);
87
119
 
88
120
  try {
121
+ // Check if this is a passkey request that needs DB challenge handling
122
+ const useDbStorage = this.useDbChallengeStorage();
123
+ const isPasskeyGenerate = useDbStorage && PASSKEY_GENERATE_PATHS.some((p) => relativePath === p);
124
+ const isPasskeyVerify = useDbStorage && PASSKEY_VERIFY_PATHS.some((p) => relativePath === p);
125
+
89
126
  // Extract session token from cookies or Authorization header
90
127
  const sessionToken = extractSessionToken(req, basePath);
91
128
 
92
129
  // Get config for cookie signing
93
130
  const config = this.betterAuthService.getConfig();
131
+ const cookieName = this.challengeService?.getCookieName() || 'better-auth.better-auth-passkey';
132
+
133
+ // For passkey verify requests with DB storage, inject the verificationToken as a cookie
134
+ let challengeIdToDelete: string | undefined;
135
+ if (isPasskeyVerify && this.challengeService) {
136
+ const challengeId = req.body?.challengeId;
137
+ this.logger.debug(`Passkey verify: challengeId=${challengeId ? `${challengeId.substring(0, 8)}...` : 'MISSING'}, body keys=${Object.keys(req.body || {}).join(', ')}`);
138
+ if (challengeId) {
139
+ const verificationToken = await this.challengeService.getVerificationToken(challengeId);
140
+ if (verificationToken) {
141
+ // Sign the verificationToken and inject it as a cookie
142
+ const signedToken = signCookieValue(verificationToken, config.secret || '');
143
+
144
+ // Add the challenge cookie to the request headers
145
+ const existingCookies = req.headers.cookie || '';
146
+ req.headers.cookie = existingCookies
147
+ ? `${existingCookies}; ${cookieName}=${signedToken}`
148
+ : `${cookieName}=${signedToken}`;
149
+
150
+ challengeIdToDelete = challengeId;
151
+
152
+ this.logger.debug(`Injected verificationToken for passkey verification`);
153
+ } else {
154
+ // Challenge mapping not found - let Better Auth handle the error
155
+ this.logger.debug(`Challenge mapping not found: ${challengeId.substring(0, 8)}...`);
156
+ }
157
+ }
158
+ }
94
159
 
95
160
  // Convert Express request to Web Standard Request with proper cookie signing
96
- // This ensures Better Auth receives signed cookies for session validation
97
161
  const webRequest = await toWebRequest(req, {
98
162
  basePath,
99
163
  baseUrl: this.betterAuthService.getBaseUrl(),
@@ -105,8 +169,84 @@ export class CoreBetterAuthApiMiddleware implements NestMiddleware {
105
169
  // Call Better Auth's native handler
106
170
  const response = await authInstance.handler(webRequest);
107
171
 
108
- if (!this.isProd) {
109
- this.logger.debug(`Better Auth handler response: ${response.status}`);
172
+ this.logger.debug(`Better Auth handler response: ${response.status}`);
173
+
174
+ // For passkey generate requests with DB storage, extract verificationToken and store mapping
175
+ if (isPasskeyGenerate && response.ok && this.challengeService) {
176
+ // Extract verificationToken from Set-Cookie header
177
+ const setCookieHeaders = response.headers.getSetCookie?.() || [];
178
+ let verificationToken: null | string = null;
179
+
180
+ for (const cookieHeader of setCookieHeaders) {
181
+ if (cookieHeader.startsWith(`${cookieName}=`)) {
182
+ // Extract the cookie value (before the first semicolon and after the equals sign)
183
+ const cookieValue = cookieHeader.split(';')[0].split('=')[1];
184
+ // URL decode and extract the token part (before the signature dot)
185
+ const decodedValue = decodeURIComponent(cookieValue);
186
+ // The signed cookie format is: value.signature
187
+ verificationToken = decodedValue.split('.')[0];
188
+ break;
189
+ }
190
+ }
191
+
192
+ if (verificationToken) {
193
+ // Clone the response to read the body
194
+ const responseClone = response.clone();
195
+ const responseBody = await responseClone.json();
196
+
197
+ // Get user ID from response or session
198
+ const userId = responseBody?.user?.id || sessionToken || 'anonymous';
199
+ const type = relativePath.includes('register') ? 'registration' : 'authentication';
200
+
201
+ // Store the mapping: challengeId → verificationToken
202
+ const challengeId = await this.challengeService.storeChallengeMapping(
203
+ verificationToken,
204
+ userId,
205
+ type as 'authentication' | 'registration',
206
+ );
207
+
208
+ // Add challengeId to the response body
209
+ const enhancedBody = {
210
+ ...responseBody,
211
+ challengeId,
212
+ };
213
+
214
+ // Create new headers WITHOUT the Set-Cookie for the passkey challenge
215
+ // (we don't want cookies in JWT mode)
216
+ const newHeaders = new Headers();
217
+ response.headers.forEach((value, key) => {
218
+ if (key.toLowerCase() !== 'set-cookie') {
219
+ newHeaders.set(key, value);
220
+ }
221
+ });
222
+ // Re-add non-passkey Set-Cookie headers
223
+ for (const cookieHeader of setCookieHeaders) {
224
+ if (!cookieHeader.startsWith(`${cookieName}=`)) {
225
+ newHeaders.append('Set-Cookie', cookieHeader);
226
+ }
227
+ }
228
+
229
+ // Create a new response with the enhanced body and filtered headers
230
+ const enhancedResponse = new Response(JSON.stringify(enhancedBody), {
231
+ headers: newHeaders,
232
+ status: response.status,
233
+ statusText: response.statusText,
234
+ });
235
+
236
+ this.logger.debug(`Stored challenge mapping with ID: ${challengeId.substring(0, 8)}...`);
237
+
238
+ // Send the enhanced response
239
+ await sendWebResponse(res, enhancedResponse);
240
+
241
+ return;
242
+ } else {
243
+ this.logger.warn('Could not extract verificationToken from Set-Cookie header');
244
+ }
245
+ }
246
+
247
+ // Clean up the used challenge mapping after verification (success or failure)
248
+ if (challengeIdToDelete && this.challengeService) {
249
+ await this.challengeService.deleteChallengeMapping(challengeIdToDelete);
110
250
  }
111
251
 
112
252
  // Convert Web Standard Response to Express response using shared helper
@@ -0,0 +1,254 @@
1
+ import { Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common';
2
+ import { InjectConnection } from '@nestjs/mongoose';
3
+ import * as crypto from 'crypto';
4
+ import { Collection, Document } from 'mongodb';
5
+ import { Connection } from 'mongoose';
6
+
7
+ import { IBetterAuth } from '../../common/interfaces/server-options.interface';
8
+ import { ConfigService } from '../../common/services/config.service';
9
+
10
+ /**
11
+ * WebAuthn challenge mapping document structure.
12
+ *
13
+ * This stores a mapping from our challengeId to Better Auth's verificationToken.
14
+ * Better Auth stores the actual challenge in its verification collection.
15
+ */
16
+ interface WebAuthnChallengeMappingDocument extends Document {
17
+ /** Our unique challenge ID (returned to client) */
18
+ challengeId: string;
19
+ /** Creation timestamp */
20
+ createdAt: Date;
21
+ /** Expiration timestamp (TTL index) */
22
+ expiresAt: Date;
23
+ /** Type of operation */
24
+ type: 'authentication' | 'registration';
25
+ /** User ID this challenge belongs to */
26
+ userId: string;
27
+ /** Better Auth's verification token (from their cookie) */
28
+ verificationToken: string;
29
+ }
30
+
31
+ /**
32
+ * Service for managing WebAuthn challenge mappings in MongoDB.
33
+ *
34
+ * This service provides an alternative to cookie-based challenge storage,
35
+ * enabling Passkey authentication in JWT-only (cookieless) mode.
36
+ *
37
+ * ## How it works (Adapter approach):
38
+ * 1. When `generateRegisterOptions` or `generateAuthenticateOptions` is called,
39
+ * Better Auth stores its verificationToken in a cookie and the challenge in its DB
40
+ * 2. We extract the verificationToken from the Set-Cookie header
41
+ * 3. We store a mapping: challengeId → verificationToken in our collection
42
+ * 4. We return challengeId to the client (not the verificationToken for security)
43
+ * 5. When `verifyRegistration` or `verifyAuthentication` is called,
44
+ * we inject the verificationToken as a cookie so Better Auth can find the challenge
45
+ *
46
+ * ## Why this approach?
47
+ * - Better Auth stores challenges in its `verification` collection using verificationToken as key
48
+ * - We don't duplicate the challenge storage, we just bridge the cookie gap
49
+ * - Full compatibility with Better Auth updates
50
+ * - Better Auth handles all WebAuthn logic natively
51
+ *
52
+ * ## Security considerations:
53
+ * - Challenges expire automatically via MongoDB TTL index
54
+ * - Each challengeId can only be used once (deleted after use)
55
+ * - verificationToken is never exposed to the client (only challengeId)
56
+ * - Challenges are bound to a specific user
57
+ */
58
+ @Injectable()
59
+ export class CoreBetterAuthChallengeService implements OnModuleInit {
60
+ private readonly logger = new Logger(CoreBetterAuthChallengeService.name);
61
+ private collection: Collection<WebAuthnChallengeMappingDocument> | null = null;
62
+ private ttlSeconds: number = 300; // 5 minutes default
63
+ private enabled: boolean = false;
64
+
65
+ constructor(
66
+ @Optional() @InjectConnection() private readonly connection: Connection,
67
+ @Optional() private readonly configService?: ConfigService,
68
+ ) {}
69
+
70
+ /**
71
+ * Initialize the collection and ensure TTL index exists
72
+ */
73
+ async onModuleInit() {
74
+ // ConfigService may not be available in test environments
75
+ if (!this.configService) {
76
+ return;
77
+ }
78
+
79
+ // Read config in onModuleInit to ensure it's fully loaded
80
+ const config = this.configService.get<IBetterAuth>('betterAuth') || {};
81
+ const passkeyConfig = typeof config.passkey === 'object' ? config.passkey : null;
82
+
83
+ // Database challenge storage is the default because:
84
+ // 1. Works everywhere (same-origin, cross-origin, JWT mode)
85
+ // 2. No cookie handling issues
86
+ // 3. Enables cookieless passkey authentication
87
+ //
88
+ // Disable database storage when:
89
+ // - Passkey is explicitly disabled (passkey: false OR passkey: { enabled: false })
90
+ // - Cookie storage is explicitly configured (passkey.challengeStorage: 'cookie')
91
+ const isPasskeyDisabled = config.passkey === false || passkeyConfig?.enabled === false;
92
+ const useCookieStorage = passkeyConfig?.challengeStorage === 'cookie';
93
+
94
+ this.enabled = !isPasskeyDisabled && !useCookieStorage;
95
+
96
+ if (useCookieStorage) {
97
+ this.logger.log('Using cookie-based challenge storage (explicitly configured)');
98
+ }
99
+ this.ttlSeconds = passkeyConfig?.challengeTtlSeconds || 300;
100
+
101
+ if (!this.enabled) {
102
+ return;
103
+ }
104
+
105
+ try {
106
+ if (!this.connection) {
107
+ this.logger.warn('MongoDB connection not available, challenge storage disabled');
108
+ this.enabled = false;
109
+ return;
110
+ }
111
+
112
+ const db = this.connection.db;
113
+ if (!db) {
114
+ this.logger.warn('Database not available, challenge storage disabled');
115
+ this.enabled = false;
116
+ return;
117
+ }
118
+
119
+ // Get or create the collection
120
+ this.collection = db.collection<WebAuthnChallengeMappingDocument>('webauthn_challenge_mappings');
121
+
122
+ // Ensure TTL index exists for automatic cleanup
123
+ await this.collection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
124
+
125
+ // Index for fast lookups
126
+ await this.collection.createIndex({ challengeId: 1 }, { unique: true });
127
+ await this.collection.createIndex({ verificationToken: 1 });
128
+
129
+ this.logger.log('WebAuthn challenge storage initialized (database mode)');
130
+ } catch (error) {
131
+ this.logger.error(`Failed to initialize challenge storage: ${error instanceof Error ? error.message : 'Unknown error'}`);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Check if database challenge storage is enabled
137
+ */
138
+ isEnabled(): boolean {
139
+ return this.enabled && this.collection !== null;
140
+ }
141
+
142
+ /**
143
+ * Store a mapping from challengeId to Better Auth's verificationToken.
144
+ *
145
+ * @param verificationToken - Better Auth's verification token from the cookie
146
+ * @param userId - User ID this challenge belongs to
147
+ * @param type - Type of operation (registration or authentication)
148
+ * @returns Challenge ID to be passed to the client
149
+ */
150
+ async storeChallengeMapping(
151
+ verificationToken: string,
152
+ userId: string,
153
+ type: 'authentication' | 'registration',
154
+ ): Promise<string> {
155
+ if (!this.collection) {
156
+ throw new Error('Challenge storage not initialized');
157
+ }
158
+
159
+ // Generate a unique challenge ID for the client
160
+ const challengeId = crypto.randomBytes(32).toString('base64url');
161
+
162
+ const now = new Date();
163
+ const expiresAt = new Date(now.getTime() + this.ttlSeconds * 1000);
164
+
165
+ await this.collection.insertOne({
166
+ challengeId,
167
+ createdAt: now,
168
+ expiresAt,
169
+ type,
170
+ userId,
171
+ verificationToken,
172
+ } as WebAuthnChallengeMappingDocument);
173
+
174
+ this.logger.debug(`Stored ${type} challenge mapping for user ${userId.substring(0, 8)}...`);
175
+
176
+ return challengeId;
177
+ }
178
+
179
+ /**
180
+ * Retrieve the verificationToken for a given challengeId.
181
+ *
182
+ * @param challengeId - The challenge ID returned from storeChallengeMapping
183
+ * @returns The verificationToken or null if not found/expired
184
+ */
185
+ async getVerificationToken(challengeId: string): Promise<null | string> {
186
+ if (!this.collection) {
187
+ return null;
188
+ }
189
+
190
+ const doc = await this.collection.findOne({ challengeId });
191
+
192
+ if (!doc) {
193
+ this.logger.debug(`Challenge mapping not found: ${challengeId.substring(0, 8)}...`);
194
+ return null;
195
+ }
196
+
197
+ // Check if expired (shouldn't happen with TTL, but double-check)
198
+ if (doc.expiresAt < new Date()) {
199
+ this.logger.debug(`Challenge mapping expired: ${challengeId.substring(0, 8)}...`);
200
+ return null;
201
+ }
202
+
203
+ return doc.verificationToken;
204
+ }
205
+
206
+ /**
207
+ * Delete a challenge mapping (after use or on error)
208
+ *
209
+ * @param challengeId - The challenge ID to delete
210
+ */
211
+ async deleteChallengeMapping(challengeId: string): Promise<void> {
212
+ if (!this.collection) {
213
+ return;
214
+ }
215
+
216
+ await this.collection.deleteOne({ challengeId });
217
+ this.logger.debug(`Deleted challenge mapping: ${challengeId.substring(0, 8)}...`);
218
+ }
219
+
220
+ /**
221
+ * Delete all challenge mappings for a user (e.g., on logout or account deletion)
222
+ *
223
+ * @param userId - User ID whose challenge mappings should be deleted
224
+ */
225
+ async deleteUserChallengeMappings(userId: string): Promise<void> {
226
+ if (!this.collection) {
227
+ return;
228
+ }
229
+
230
+ const result = await this.collection.deleteMany({ userId });
231
+ if (result.deletedCount > 0) {
232
+ this.logger.debug(`Deleted ${result.deletedCount} challenge mappings for user ${userId.substring(0, 8)}...`);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Get the TTL in seconds for challenge mappings
238
+ */
239
+ getTtlSeconds(): number {
240
+ return this.ttlSeconds;
241
+ }
242
+
243
+ /**
244
+ * Get the cookie name used by Better Auth for passkey challenges
245
+ */
246
+ getCookieName(): string {
247
+ if (!this.configService) {
248
+ return 'better-auth.better-auth-passkey';
249
+ }
250
+ const config = this.configService.get<IBetterAuth>('betterAuth') || {};
251
+ const passkeyConfig = typeof config.passkey === 'object' ? config.passkey : null;
252
+ return passkeyConfig?.webAuthnChallengeCookie || 'better-auth.better-auth-passkey';
253
+ }
254
+ }
@@ -163,7 +163,7 @@ export class CoreBetterAuthUserMapper {
163
163
  // User doesn't exist in our database yet
164
164
  // This can happen if they signed up through Better-Auth but not legacy auth
165
165
  // Return a user with default roles (S_USER since they're authenticated)
166
- this.logger.debug(`Better-Auth user ${sessionUser.email} not found in users collection`);
166
+ this.logger.debug(`Better-Auth user ${maskEmail(sessionUser.email)} not found in users collection`);
167
167
 
168
168
  return this.createMappedUser({
169
169
  email: sessionUser.email,
@@ -77,6 +77,49 @@ export function extractSessionToken(req: Request, basePath: string = 'iam'): nul
77
77
  return null;
78
78
  }
79
79
 
80
+ /**
81
+ * Checks if a cookie value appears to be already signed.
82
+ *
83
+ * A signed cookie has the format: `value.base64signature` where the signature
84
+ * is a base64-encoded string. This function checks if the value contains a dot
85
+ * followed by what looks like a base64 signature (not a JWT which has 2 dots).
86
+ *
87
+ * Note: This also handles URL-encoded signed cookies.
88
+ *
89
+ * @param value - The cookie value to check
90
+ * @returns true if the value appears to be already signed
91
+ */
92
+ export function isAlreadySigned(value: string): boolean {
93
+ if (!value) {
94
+ return false;
95
+ }
96
+
97
+ // First, try to URL-decode the value (signed cookies from signCookieValue are URL-encoded)
98
+ let decodedValue = value;
99
+ try {
100
+ decodedValue = decodeURIComponent(value);
101
+ } catch {
102
+ // If decoding fails, use the original value
103
+ }
104
+
105
+ // A JWT has exactly 2 dots (header.payload.signature)
106
+ // A signed cookie has exactly 1 dot (value.signature)
107
+ const dotCount = (decodedValue.match(/\./g) || []).length;
108
+
109
+ if (dotCount !== 1) {
110
+ return false;
111
+ }
112
+
113
+ // Check if the part after the dot looks like a base64 signature
114
+ const lastDotIndex = decodedValue.lastIndexOf('.');
115
+ const potentialSignature = decodedValue.substring(lastDotIndex + 1);
116
+
117
+ // Base64 signature should be non-empty and contain only valid base64 characters
118
+ // HMAC-SHA256 base64 signatures are typically 44 characters (32 bytes -> 44 base64 chars with padding)
119
+ const base64Regex = /^[A-Za-z0-9+/]+=*$/;
120
+ return potentialSignature.length >= 20 && base64Regex.test(potentialSignature);
121
+ }
122
+
80
123
  /**
81
124
  * Parses a Cookie header string into an object.
82
125
  *
@@ -165,6 +208,25 @@ export function signCookieValue(value: string, secret: string): string {
165
208
  return encodeURIComponent(signedValue);
166
209
  }
167
210
 
211
+ /**
212
+ * Signs a cookie value only if it's not already signed.
213
+ *
214
+ * This prevents double-signing which would make the cookie invalid.
215
+ *
216
+ * @param value - The cookie value to potentially sign
217
+ * @param secret - The secret to use for signing
218
+ * @param logger - Optional logger for debug output
219
+ * @returns The signed cookie value (URL-encoded) or the original if already signed
220
+ */
221
+ export function signCookieValueIfNeeded(value: string, secret: string, logger?: Logger): string {
222
+ if (isAlreadySigned(value)) {
223
+ logger?.debug?.('Cookie value appears to be already signed, skipping signing');
224
+ // Return URL-encoded to match signCookieValue behavior
225
+ return value.includes('%') ? value : encodeURIComponent(value);
226
+ }
227
+ return signCookieValue(value, secret);
228
+ }
229
+
168
230
  /**
169
231
  * Converts an Express Request to a Web Standard Request.
170
232
  *
@@ -201,9 +263,10 @@ export async function toWebRequest(req: Request, options: ToWebRequestOptions):
201
263
  const existingCookieString = headers.get('cookie') || '';
202
264
 
203
265
  // Sign the session token for Better Auth (if secret is provided)
266
+ // IMPORTANT: Only sign if not already signed to prevent double-signing
204
267
  let signedToken: string;
205
268
  if (secret) {
206
- signedToken = signCookieValue(sessionToken, secret);
269
+ signedToken = signCookieValueIfNeeded(sessionToken, secret, logger);
207
270
  } else {
208
271
  logger?.warn('No Better Auth secret configured - cookies will not be signed');
209
272
  signedToken = sessionToken;
@@ -18,7 +18,7 @@ import { Request, Response } from 'express';
18
18
 
19
19
  import { Roles } from '../../common/decorators/roles.decorator';
20
20
  import { RoleEnum } from '../../common/enums/role.enum';
21
- import { isProduction, maskToken } from '../../common/helpers/logging.helper';
21
+ import { maskEmail, maskToken } from '../../common/helpers/logging.helper';
22
22
  import { ConfigService } from '../../common/services/config.service';
23
23
  import { BetterAuthSignInResponse, hasSession, hasUser, requires2FA } from './better-auth.types';
24
24
  import { BetterAuthSessionUser, CoreBetterAuthUserMapper } from './core-better-auth-user.mapper';
@@ -235,7 +235,7 @@ export class CoreBetterAuthController {
235
235
  try {
236
236
  const migrated = await this.userMapper.migrateAccountToIam(input.email, input.password);
237
237
  if (migrated) {
238
- this.logger.debug(`Migrated legacy user ${input.email} to IAM`);
238
+ this.logger.debug(`Migrated legacy user ${maskEmail(input.email)} to IAM`);
239
239
  }
240
240
  } catch (error) {
241
241
  // Migration failure is not fatal - user might not exist in legacy or already migrated
@@ -262,9 +262,7 @@ export class CoreBetterAuthController {
262
262
  // When 2FA is required, we need to use the native Better Auth handler
263
263
  // because api.signInEmail() doesn't return the session token needed for 2FA verification
264
264
  if (requires2FA(response)) {
265
- if (!isProduction()) {
266
- this.logger.debug(`2FA required for ${input.email}, forwarding to native handler for cookie handling`);
267
- }
265
+ this.logger.debug(`2FA required for ${maskEmail(input.email)}, forwarding to native handler for cookie handling`);
268
266
 
269
267
  // Forward to native Better Auth handler which sets the session cookie correctly
270
268
  // We need to modify the request body to use the normalized password
@@ -740,17 +738,13 @@ export class CoreBetterAuthController {
740
738
  throw new InternalServerErrorException('Better-Auth not initialized');
741
739
  }
742
740
 
743
- if (!isProduction()) {
744
- this.logger.debug(`Forwarding to Better Auth: ${req.method} ${req.path}`);
745
- }
741
+ this.logger.debug(`Forwarding to Better Auth: ${req.method} ${req.path}`);
746
742
 
747
743
  try {
748
744
  // Extract session token from the validated middleware session or cookies
749
745
  const sessionToken = this.getSessionTokenFromRequest(req);
750
746
 
751
- if (!isProduction()) {
752
- this.logger.debug(`Session token for forwarding: ${maskToken(sessionToken)}`);
753
- }
747
+ this.logger.debug(`Session token for forwarding: ${maskToken(sessionToken)}`);
754
748
 
755
749
  // Get config for signing cookies
756
750
  const config = this.betterAuthService.getConfig();
@@ -767,9 +761,7 @@ export class CoreBetterAuthController {
767
761
  // Call Better Auth's native handler
768
762
  const response = await authInstance.handler(webRequest);
769
763
 
770
- if (!isProduction()) {
771
- this.logger.debug(`Better Auth handler response status: ${response.status}`);
772
- }
764
+ this.logger.debug(`Better Auth handler response status: ${response.status}`);
773
765
 
774
766
  // Send the response back
775
767
  await sendWebResponse(res, response);