@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.
- package/dist/config.env.js +16 -133
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +4 -0
- package/dist/core/modules/auth/core-auth.module.js +8 -4
- package/dist/core/modules/auth/core-auth.module.js.map +1 -1
- package/dist/core/modules/auth/guards/roles-guard-registry.d.ts +9 -0
- package/dist/core/modules/auth/guards/roles-guard-registry.js +30 -0
- package/dist/core/modules/auth/guards/roles-guard-registry.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth.config.d.ts +3 -0
- package/dist/core/modules/better-auth/better-auth.config.js +176 -47
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.d.ts +5 -1
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js +101 -8
- package/dist/core/modules/better-auth/core-better-auth-api.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-challenge.service.d.ts +20 -0
- package/dist/core/modules/better-auth/core-better-auth-challenge.service.js +142 -0
- package/dist/core/modules/better-auth/core-better-auth-challenge.service.js.map +1 -0
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +1 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-web.helper.d.ts +2 -0
- package/dist/core/modules/better-auth/core-better-auth-web.helper.js +29 -1
- package/dist/core/modules/better-auth/core-better-auth-web.helper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.controller.js +5 -13
- package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.middleware.d.ts +0 -1
- package/dist/core/modules/better-auth/core-better-auth.middleware.js +6 -19
- package/dist/core/modules/better-auth/core-better-auth.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.d.ts +5 -1
- package/dist/core/modules/better-auth/core-better-auth.module.js +74 -27
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.resolver.js +7 -6
- package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.service.d.ts +0 -2
- package/dist/core/modules/better-auth/core-better-auth.service.js +23 -37
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core.module.js +10 -1
- 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/server/modules/better-auth/better-auth.module.d.ts +4 -1
- package/dist/server/modules/better-auth/better-auth.module.js +4 -1
- package/dist/server/modules/better-auth/better-auth.module.js.map +1 -1
- package/dist/server/server.module.js +1 -4
- package/dist/server/server.module.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/config.env.ts +24 -174
- package/src/core/common/interfaces/server-options.interface.ts +288 -35
- package/src/core/modules/auth/core-auth.module.ts +11 -5
- package/src/core/modules/auth/guards/roles-guard-registry.ts +57 -0
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +85 -56
- package/src/core/modules/better-auth/README.md +132 -35
- package/src/core/modules/better-auth/better-auth.config.ts +402 -70
- package/src/core/modules/better-auth/core-better-auth-api.middleware.ts +158 -18
- package/src/core/modules/better-auth/core-better-auth-challenge.service.ts +254 -0
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +1 -1
- package/src/core/modules/better-auth/core-better-auth-web.helper.ts +64 -1
- package/src/core/modules/better-auth/core-better-auth.controller.ts +6 -14
- package/src/core/modules/better-auth/core-better-auth.middleware.ts +7 -20
- package/src/core/modules/better-auth/core-better-auth.module.ts +173 -38
- package/src/core/modules/better-auth/core-better-auth.resolver.ts +7 -6
- package/src/core/modules/better-auth/core-better-auth.service.ts +27 -48
- package/src/core.module.ts +21 -3
- package/src/index.ts +1 -0
- package/src/server/modules/better-auth/better-auth.module.ts +40 -10
- 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 {
|
|
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
|
-
*
|
|
39
|
-
* 1.
|
|
40
|
-
* 2.
|
|
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
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|