@lenne.tech/nest-server 11.7.1 → 11.7.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.
- package/dist/core/common/interfaces/server-options.interface.d.ts +18 -15
- package/dist/core/modules/auth/core-auth.controller.js +2 -2
- package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
- package/dist/core/modules/auth/core-auth.resolver.js +2 -2
- package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
- package/dist/core/modules/auth/guards/roles.guard.d.ts +12 -2
- package/dist/core/modules/auth/guards/roles.guard.js +192 -5
- package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +1 -1
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth-rate-limiter.service.js +1 -1
- package/dist/core/modules/better-auth/better-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth-user.mapper.js +7 -55
- package/dist/core/modules/better-auth/better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.js +29 -10
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.middleware.d.ts +1 -0
- package/dist/core/modules/better-auth/better-auth.middleware.js +55 -1
- package/dist/core/modules/better-auth/better-auth.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.module.d.ts +1 -1
- package/dist/core/modules/better-auth/better-auth.module.js +46 -18
- package/dist/core/modules/better-auth/better-auth.module.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.resolver.js +0 -11
- package/dist/core/modules/better-auth/better-auth.resolver.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.service.d.ts +22 -1
- package/dist/core/modules/better-auth/better-auth.service.js +209 -8
- package/dist/core/modules/better-auth/better-auth.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.types.d.ts +2 -0
- package/dist/core/modules/better-auth/better-auth.types.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.resolver.d.ts +5 -0
- package/dist/core/modules/better-auth/core-better-auth.resolver.js +58 -12
- package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
- package/dist/core/modules/user/core-user.service.d.ts +1 -0
- package/dist/core/modules/user/core-user.service.js +12 -0
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/core.module.js +6 -3
- package/dist/core.module.js.map +1 -1
- package/dist/server/modules/better-auth/better-auth.module.d.ts +1 -1
- package/dist/server/modules/better-auth/better-auth.module.js +2 -1
- package/dist/server/modules/better-auth/better-auth.module.js.map +1 -1
- package/dist/server/modules/better-auth/better-auth.resolver.d.ts +3 -0
- package/dist/server/modules/better-auth/better-auth.resolver.js +14 -11
- package/dist/server/modules/better-auth/better-auth.resolver.js.map +1 -1
- package/dist/server/modules/user/user.controller.js +0 -8
- package/dist/server/modules/user/user.controller.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/core/common/interfaces/server-options.interface.ts +129 -58
- package/src/core/modules/auth/core-auth.controller.ts +2 -2
- package/src/core/modules/auth/core-auth.resolver.ts +2 -2
- package/src/core/modules/auth/guards/roles.guard.ts +298 -5
- package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +1 -1
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +12 -11
- package/src/core/modules/better-auth/README.md +82 -43
- package/src/core/modules/better-auth/better-auth-rate-limiter.service.ts +1 -1
- package/src/core/modules/better-auth/better-auth-user.mapper.ts +9 -77
- package/src/core/modules/better-auth/better-auth.config.ts +45 -15
- package/src/core/modules/better-auth/better-auth.middleware.ts +85 -2
- package/src/core/modules/better-auth/better-auth.module.ts +83 -27
- package/src/core/modules/better-auth/better-auth.resolver.ts +0 -11
- package/src/core/modules/better-auth/better-auth.service.ts +367 -12
- package/src/core/modules/better-auth/better-auth.types.ts +16 -0
- package/src/core/modules/better-auth/core-better-auth.resolver.ts +111 -16
- package/src/core/modules/user/core-user.service.ts +27 -0
- package/src/core.module.ts +9 -3
- package/src/server/modules/better-auth/better-auth.module.ts +9 -3
- package/src/server/modules/better-auth/better-auth.resolver.ts +9 -11
- package/src/server/modules/user/user.controller.ts +1 -9
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
2
|
+
import { InjectConnection } from '@nestjs/mongoose';
|
|
2
3
|
import { Request } from 'express';
|
|
4
|
+
import { importJWK, jwtVerify } from 'jose';
|
|
5
|
+
import { Connection } from 'mongoose';
|
|
3
6
|
|
|
4
7
|
import { IBetterAuth } from '../../common/interfaces/server-options.interface';
|
|
5
8
|
import { ConfigService } from '../../common/services/config.service';
|
|
@@ -43,6 +46,11 @@ export interface SessionResult {
|
|
|
43
46
|
* }
|
|
44
47
|
* ```
|
|
45
48
|
*/
|
|
49
|
+
/**
|
|
50
|
+
* Injection token for resolved BetterAuth configuration
|
|
51
|
+
*/
|
|
52
|
+
export const BETTER_AUTH_CONFIG = 'BETTER_AUTH_CONFIG';
|
|
53
|
+
|
|
46
54
|
@Injectable()
|
|
47
55
|
export class BetterAuthService {
|
|
48
56
|
private readonly logger = new Logger(BetterAuthService.name);
|
|
@@ -50,10 +58,14 @@ export class BetterAuthService {
|
|
|
50
58
|
|
|
51
59
|
constructor(
|
|
52
60
|
@Optional() @Inject(BETTER_AUTH_INSTANCE) private readonly authInstance: BetterAuthInstance | null,
|
|
61
|
+
@Optional() @InjectConnection() private readonly connection?: Connection,
|
|
62
|
+
@Inject(BETTER_AUTH_CONFIG) @Optional() private readonly resolvedConfig?: IBetterAuth | null,
|
|
63
|
+
// ConfigService is last because it's only needed as fallback when resolvedConfig is not provided
|
|
53
64
|
@Optional() private readonly configService?: ConfigService,
|
|
54
65
|
) {
|
|
66
|
+
// Use resolvedConfig if provided (has fallback secret applied), otherwise get fresh from ConfigService
|
|
55
67
|
// Better-Auth is enabled by default (zero-config) - only disabled if explicitly set to false
|
|
56
|
-
this.config = this.configService?.get<IBetterAuth>('betterAuth') || {};
|
|
68
|
+
this.config = this.resolvedConfig || this.configService?.get<IBetterAuth>('betterAuth') || {};
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
/**
|
|
@@ -98,29 +110,49 @@ export class BetterAuthService {
|
|
|
98
110
|
|
|
99
111
|
/**
|
|
100
112
|
* Checks if JWT plugin is enabled.
|
|
101
|
-
* JWT is enabled by default when
|
|
102
|
-
*
|
|
113
|
+
* JWT is enabled by default when BetterAuth is enabled.
|
|
114
|
+
* Only returns false if explicitly disabled:
|
|
115
|
+
* - `jwt: false` → disabled
|
|
116
|
+
* - `jwt: { enabled: false }` → disabled
|
|
117
|
+
* - `undefined`, `true`, `{}`, or `{ expiresIn: '...' }` → enabled
|
|
103
118
|
*/
|
|
104
119
|
isJwtEnabled(): boolean {
|
|
105
|
-
|
|
120
|
+
if (!this.isEnabled()) return false;
|
|
121
|
+
// JWT is enabled by default unless explicitly disabled
|
|
122
|
+
const jwtConfig = this.config.jwt;
|
|
123
|
+
if (jwtConfig === false) return false;
|
|
124
|
+
if (typeof jwtConfig === 'object' && jwtConfig?.enabled === false) return false;
|
|
125
|
+
return true;
|
|
106
126
|
}
|
|
107
127
|
|
|
108
128
|
/**
|
|
109
129
|
* Checks if 2FA is enabled.
|
|
110
|
-
*
|
|
111
|
-
*
|
|
130
|
+
* Supports both boolean and object configuration:
|
|
131
|
+
* - `true` or `{}` → enabled
|
|
132
|
+
* - `false` or `{ enabled: false }` → disabled
|
|
112
133
|
*/
|
|
113
134
|
isTwoFactorEnabled(): boolean {
|
|
114
|
-
return this.isEnabled() &&
|
|
135
|
+
return this.isEnabled() && this.isPluginEnabled(this.config.twoFactor);
|
|
115
136
|
}
|
|
116
137
|
|
|
117
138
|
/**
|
|
118
139
|
* Checks if Passkey/WebAuthn is enabled.
|
|
119
|
-
*
|
|
120
|
-
*
|
|
140
|
+
* Supports both boolean and object configuration:
|
|
141
|
+
* - `true` or `{}` → enabled
|
|
142
|
+
* - `false` or `{ enabled: false }` → disabled
|
|
121
143
|
*/
|
|
122
144
|
isPasskeyEnabled(): boolean {
|
|
123
|
-
return this.isEnabled() &&
|
|
145
|
+
return this.isEnabled() && this.isPluginEnabled(this.config.passkey);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Helper to check if a plugin configuration is enabled.
|
|
150
|
+
* Supports both boolean and object configuration.
|
|
151
|
+
*/
|
|
152
|
+
private isPluginEnabled<T extends { enabled?: boolean }>(config: boolean | T | undefined): boolean {
|
|
153
|
+
if (config === undefined) return false;
|
|
154
|
+
if (typeof config === 'boolean') return config;
|
|
155
|
+
return config.enabled !== false;
|
|
124
156
|
}
|
|
125
157
|
|
|
126
158
|
/**
|
|
@@ -167,6 +199,69 @@ export class BetterAuthService {
|
|
|
167
199
|
return this.config.baseUrl || 'http://localhost:3000';
|
|
168
200
|
}
|
|
169
201
|
|
|
202
|
+
// ===================================================================================================================
|
|
203
|
+
// JWT Token Methods
|
|
204
|
+
// ===================================================================================================================
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Gets a fresh JWT token for the current session.
|
|
208
|
+
*
|
|
209
|
+
* Use this when your JWT has expired but your session is still valid.
|
|
210
|
+
* The JWT can be used for stateless authentication with other services
|
|
211
|
+
* that verify tokens via JWKS (`/iam/jwks`).
|
|
212
|
+
*
|
|
213
|
+
* @param req - Express request object with session cookie/header
|
|
214
|
+
* @returns Fresh JWT token or null if no valid session or JWT is disabled
|
|
215
|
+
*
|
|
216
|
+
* @example
|
|
217
|
+
* ```typescript
|
|
218
|
+
* const token = await betterAuthService.getToken(req);
|
|
219
|
+
* if (token) {
|
|
220
|
+
* // Use token for microservice calls
|
|
221
|
+
* await fetch('https://api.example.com/data', {
|
|
222
|
+
* headers: { Authorization: `Bearer ${token}` }
|
|
223
|
+
* });
|
|
224
|
+
* }
|
|
225
|
+
* ```
|
|
226
|
+
*/
|
|
227
|
+
async getToken(req: Request | { headers: Record<string, string | string[] | undefined> }): Promise<null | string> {
|
|
228
|
+
if (!this.isEnabled() || !this.isJwtEnabled()) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const api = this.getApi();
|
|
233
|
+
if (!api) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
// Convert headers to the format Better-Auth expects
|
|
239
|
+
const headers = new Headers();
|
|
240
|
+
const reqHeaders = 'headers' in req ? req.headers : {};
|
|
241
|
+
|
|
242
|
+
for (const [key, value] of Object.entries(reqHeaders)) {
|
|
243
|
+
if (typeof value === 'string') {
|
|
244
|
+
headers.set(key, value);
|
|
245
|
+
} else if (Array.isArray(value)) {
|
|
246
|
+
headers.set(key, value.join(', '));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Call the token endpoint via Better-Auth API
|
|
251
|
+
// The jwt plugin adds a getToken method to the API
|
|
252
|
+
const response = await (api as any).getToken({ headers });
|
|
253
|
+
|
|
254
|
+
if (response && typeof response === 'object' && 'token' in response) {
|
|
255
|
+
return response.token as string;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return null;
|
|
259
|
+
} catch (error) {
|
|
260
|
+
this.logger.debug(`getToken error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
170
265
|
// ===================================================================================================================
|
|
171
266
|
// Session Management Methods
|
|
172
267
|
// ===================================================================================================================
|
|
@@ -258,8 +353,6 @@ export class BetterAuthService {
|
|
|
258
353
|
|
|
259
354
|
// Call Better-Auth's signOut endpoint
|
|
260
355
|
await api.signOut({ headers });
|
|
261
|
-
|
|
262
|
-
this.logger.debug('Session revoked successfully');
|
|
263
356
|
return true;
|
|
264
357
|
} catch (error) {
|
|
265
358
|
this.logger.debug(`revokeSession error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
@@ -311,4 +404,266 @@ export class BetterAuthService {
|
|
|
311
404
|
|
|
312
405
|
return remaining;
|
|
313
406
|
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Gets a session by token directly from the database.
|
|
410
|
+
*
|
|
411
|
+
* This method looks up the session in Better-Auth's session collection
|
|
412
|
+
* and returns the associated user. Useful for verifying session tokens
|
|
413
|
+
* passed via Authorization header.
|
|
414
|
+
*
|
|
415
|
+
* @param token - The session token to look up
|
|
416
|
+
* @returns Session and user data, or null if not found/expired
|
|
417
|
+
*/
|
|
418
|
+
async getSessionByToken(token: string): Promise<SessionResult> {
|
|
419
|
+
if (!this.isEnabled() || !this.connection) {
|
|
420
|
+
return { session: null, user: null };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
const db = this.connection.db;
|
|
425
|
+
if (!db) {
|
|
426
|
+
return { session: null, user: null };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const sessionsCollection = db.collection('session');
|
|
430
|
+
// Better-Auth is configured to use 'users' (plural) as the model name
|
|
431
|
+
// This matches the existing Legacy users collection for migration compatibility
|
|
432
|
+
const usersCollection = db.collection('users');
|
|
433
|
+
|
|
434
|
+
// Find session by token
|
|
435
|
+
const session = await sessionsCollection.findOne({ token });
|
|
436
|
+
|
|
437
|
+
if (!session) {
|
|
438
|
+
return { session: null, user: null };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Check if session is expired
|
|
442
|
+
if (session.expiresAt && new Date(session.expiresAt) < new Date()) {
|
|
443
|
+
return { session: null, user: null };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Get user from the user collection
|
|
447
|
+
// Better-Auth stores userId as a string, try both string id and ObjectId
|
|
448
|
+
const { ObjectId } = require('mongodb');
|
|
449
|
+
|
|
450
|
+
let user = await usersCollection.findOne({ id: session.userId });
|
|
451
|
+
if (!user) {
|
|
452
|
+
// Try with ObjectId conversion
|
|
453
|
+
try {
|
|
454
|
+
const objectId = new ObjectId(session.userId);
|
|
455
|
+
user = await usersCollection.findOne({ _id: objectId });
|
|
456
|
+
} catch {
|
|
457
|
+
// userId might not be a valid ObjectId, try string match
|
|
458
|
+
user = await usersCollection.findOne({ _id: session.userId });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (!user) {
|
|
463
|
+
return { session: null, user: null };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
session: {
|
|
468
|
+
expiresAt: session.expiresAt,
|
|
469
|
+
id: session.id || session._id?.toString(),
|
|
470
|
+
userId: session.userId?.toString(),
|
|
471
|
+
...session,
|
|
472
|
+
},
|
|
473
|
+
user: {
|
|
474
|
+
email: user.email,
|
|
475
|
+
emailVerified: user.emailVerified,
|
|
476
|
+
id: user.id || user._id?.toString(),
|
|
477
|
+
name: user.name,
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
} catch (error) {
|
|
481
|
+
this.logger.debug(`getSessionByToken error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
482
|
+
return { session: null, user: null };
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ===================================================================================================================
|
|
487
|
+
// JWT Token Verification (for BetterAuth JWT tokens using JWKS)
|
|
488
|
+
// ===================================================================================================================
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Verifies a BetterAuth JWT token using JWKS public keys from the database.
|
|
492
|
+
*
|
|
493
|
+
* BetterAuth JWT tokens are signed with asymmetric keys (EdDSA/RSA/EC) stored in the
|
|
494
|
+
* `jwks` collection. This method verifies the token signature using the public key
|
|
495
|
+
* and returns the payload if valid.
|
|
496
|
+
*
|
|
497
|
+
* This enables stateless JWT verification without requiring a session cookie.
|
|
498
|
+
*
|
|
499
|
+
* @param token - The JWT token to verify (from Authorization header)
|
|
500
|
+
* @returns The JWT payload with user info, or null if verification fails
|
|
501
|
+
*
|
|
502
|
+
* @example
|
|
503
|
+
* ```typescript
|
|
504
|
+
* const token = req.headers.authorization?.replace('Bearer ', '');
|
|
505
|
+
* if (token) {
|
|
506
|
+
* const payload = await betterAuthService.verifyJwtToken(token);
|
|
507
|
+
* if (payload) {
|
|
508
|
+
* console.log('User ID:', payload.sub);
|
|
509
|
+
* }
|
|
510
|
+
* }
|
|
511
|
+
* ```
|
|
512
|
+
*/
|
|
513
|
+
async verifyJwtToken(token: string): Promise<null | {
|
|
514
|
+
[key: string]: any;
|
|
515
|
+
email?: string;
|
|
516
|
+
sub: string;
|
|
517
|
+
}> {
|
|
518
|
+
if (!this.isEnabled() || !this.isJwtEnabled()) {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
// Parse JWT header to determine algorithm
|
|
524
|
+
const parts = token.split('.');
|
|
525
|
+
if (parts.length !== 3) {
|
|
526
|
+
this.logger.debug('Invalid JWT format');
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Decode header (base64url)
|
|
531
|
+
const headerStr = Buffer.from(parts[0], 'base64url').toString('utf-8');
|
|
532
|
+
const header = JSON.parse(headerStr);
|
|
533
|
+
const alg = header.alg;
|
|
534
|
+
const kid = header.kid;
|
|
535
|
+
|
|
536
|
+
// For HS256 (symmetric), verify with the BetterAuth secret
|
|
537
|
+
if (alg === 'HS256') {
|
|
538
|
+
return this.verifyHs256Token(token);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// For asymmetric algorithms (EdDSA, RS256, ES256), use JWKS
|
|
542
|
+
if (kid && this.connection) {
|
|
543
|
+
return this.verifyJwksToken(token, kid, alg);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
this.logger.debug(`JWT verification: unsupported algorithm=${alg} or missing kid`);
|
|
547
|
+
return null;
|
|
548
|
+
} catch (error) {
|
|
549
|
+
this.logger.debug(`JWT verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Verifies an HS256 JWT token using the BetterAuth secret
|
|
556
|
+
*/
|
|
557
|
+
private async verifyHs256Token(token: string): Promise<null | {
|
|
558
|
+
[key: string]: any;
|
|
559
|
+
email?: string;
|
|
560
|
+
sub: string;
|
|
561
|
+
}> {
|
|
562
|
+
try {
|
|
563
|
+
const secret = this.config.secret;
|
|
564
|
+
|
|
565
|
+
if (!secret) {
|
|
566
|
+
this.logger.debug('HS256 verification failed: no secret configured');
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Create secret key from the BetterAuth secret
|
|
571
|
+
const secretKey = new TextEncoder().encode(secret);
|
|
572
|
+
|
|
573
|
+
// Verify the token
|
|
574
|
+
const { payload } = await jwtVerify(token, secretKey);
|
|
575
|
+
|
|
576
|
+
if (!payload.sub) {
|
|
577
|
+
this.logger.debug('JWT payload missing sub claim');
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return payload as { [key: string]: any; email?: string; sub: string };
|
|
582
|
+
} catch (error) {
|
|
583
|
+
this.logger.debug(`HS256 verification error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Verifies a JWT token using JWKS public keys from the database
|
|
590
|
+
*/
|
|
591
|
+
private async verifyJwksToken(
|
|
592
|
+
token: string,
|
|
593
|
+
kid: string,
|
|
594
|
+
alg: string,
|
|
595
|
+
): Promise<null | {
|
|
596
|
+
[key: string]: any;
|
|
597
|
+
email?: string;
|
|
598
|
+
sub: string;
|
|
599
|
+
}> {
|
|
600
|
+
if (!this.connection) {
|
|
601
|
+
this.logger.debug('JWKS verification failed: no database connection');
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
try {
|
|
606
|
+
// Fetch the JWKS public key from database
|
|
607
|
+
const jwksCollection = this.connection.collection('jwks');
|
|
608
|
+
let keyRecord = await jwksCollection.findOne({ id: kid });
|
|
609
|
+
|
|
610
|
+
if (!keyRecord) {
|
|
611
|
+
// Try with _id as fallback (MongoDB ObjectId)
|
|
612
|
+
const allKeys = await jwksCollection.find({}).toArray();
|
|
613
|
+
const matchingKey = allKeys.find((k) => k.id === kid || k._id?.toString() === kid);
|
|
614
|
+
if (!matchingKey) {
|
|
615
|
+
this.logger.debug(`No JWKS key found for kid: ${kid}`);
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
keyRecord = matchingKey;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (!keyRecord?.publicKey) {
|
|
622
|
+
this.logger.debug('JWKS key has no public key');
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Parse the public key and import it
|
|
627
|
+
const publicKey = JSON.parse(keyRecord.publicKey);
|
|
628
|
+
const algorithm = alg || keyRecord.alg || 'EdDSA';
|
|
629
|
+
const key = await importJWK(publicKey, algorithm);
|
|
630
|
+
|
|
631
|
+
// Verify the JWT - issuer and audience default to baseUrl in Better-Auth
|
|
632
|
+
const baseUrl = this.getBaseUrl();
|
|
633
|
+
const { payload } = await jwtVerify(token, key, {
|
|
634
|
+
audience: baseUrl,
|
|
635
|
+
issuer: baseUrl,
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
if (!payload.sub) {
|
|
639
|
+
this.logger.debug('JWT payload missing sub claim');
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return payload as { [key: string]: any; email?: string; sub: string };
|
|
644
|
+
} catch (error) {
|
|
645
|
+
this.logger.debug(`JWKS verification error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Extracts and verifies a JWT token from a request's Authorization header.
|
|
652
|
+
*
|
|
653
|
+
* @param req - Express request object
|
|
654
|
+
* @returns The JWT payload with user info, or null if no valid token
|
|
655
|
+
*/
|
|
656
|
+
async verifyJwtFromRequest(req: Request): Promise<null | {
|
|
657
|
+
[key: string]: any;
|
|
658
|
+
email?: string;
|
|
659
|
+
sub: string;
|
|
660
|
+
}> {
|
|
661
|
+
const authHeader = req.headers.authorization;
|
|
662
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const token = authHeader.substring(7);
|
|
667
|
+
return this.verifyJwtToken(token);
|
|
668
|
+
}
|
|
314
669
|
}
|
|
@@ -11,7 +11,14 @@ import { BetterAuthSessionUser } from './better-auth-user.mapper';
|
|
|
11
11
|
* Better-Auth 2FA verification response
|
|
12
12
|
*/
|
|
13
13
|
export interface BetterAuth2FAResponse {
|
|
14
|
+
/**
|
|
15
|
+
* JWT access token (only when JWT plugin is enabled)
|
|
16
|
+
*/
|
|
17
|
+
accessToken?: string;
|
|
14
18
|
session?: BetterAuthSessionResponse['session'];
|
|
19
|
+
/**
|
|
20
|
+
* Session token (random string, not a JWT)
|
|
21
|
+
*/
|
|
15
22
|
token?: string;
|
|
16
23
|
user?: BetterAuthSessionUser;
|
|
17
24
|
}
|
|
@@ -35,7 +42,16 @@ export interface BetterAuthSessionResponse {
|
|
|
35
42
|
* Better-Auth sign-in response
|
|
36
43
|
*/
|
|
37
44
|
export interface BetterAuthSignInResponse {
|
|
45
|
+
/**
|
|
46
|
+
* JWT access token (only when JWT plugin is enabled)
|
|
47
|
+
* This is the actual JWT token to use for API authentication
|
|
48
|
+
*/
|
|
49
|
+
accessToken?: string;
|
|
38
50
|
session?: BetterAuthSessionResponse['session'];
|
|
51
|
+
/**
|
|
52
|
+
* Session token (random string, not a JWT)
|
|
53
|
+
* This is the session identifier, not used for API authentication
|
|
54
|
+
*/
|
|
39
55
|
token?: string;
|
|
40
56
|
twoFactorRedirect?: boolean;
|
|
41
57
|
user?: BetterAuthSessionUser;
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import { BadRequestException, Logger, UnauthorizedException
|
|
1
|
+
import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
|
|
2
2
|
import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
|
|
3
3
|
import { Request, Response } from 'express';
|
|
4
4
|
|
|
5
5
|
import { Roles } from '../../common/decorators/roles.decorator';
|
|
6
6
|
import { RoleEnum } from '../../common/enums/role.enum';
|
|
7
|
-
import { AuthGuardStrategy } from '../auth/auth-guard-strategy.enum';
|
|
8
|
-
import { AuthGuard } from '../auth/guards/auth.guard';
|
|
9
7
|
import { BetterAuthAuthModel } from './better-auth-auth.model';
|
|
10
8
|
import { BetterAuthMigrationStatusModel } from './better-auth-migration-status.model';
|
|
11
9
|
import {
|
|
@@ -83,7 +81,6 @@ export class CoreBetterAuthResolver {
|
|
|
83
81
|
nullable: true,
|
|
84
82
|
})
|
|
85
83
|
@Roles(RoleEnum.S_USER)
|
|
86
|
-
@UseGuards(AuthGuard(AuthGuardStrategy.JWT))
|
|
87
84
|
async betterAuthSession(@Context() ctx: { req: Request }): Promise<BetterAuthSessionModel | null> {
|
|
88
85
|
if (!this.betterAuthService.isEnabled()) {
|
|
89
86
|
return null;
|
|
@@ -131,6 +128,27 @@ export class CoreBetterAuthResolver {
|
|
|
131
128
|
};
|
|
132
129
|
}
|
|
133
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Get a fresh JWT token for the current session.
|
|
133
|
+
*
|
|
134
|
+
* Use this when your JWT has expired but your session is still valid.
|
|
135
|
+
* The JWT can be used for stateless authentication with other services
|
|
136
|
+
* that verify tokens via JWKS (`/iam/jwks`).
|
|
137
|
+
*
|
|
138
|
+
* Returns null if:
|
|
139
|
+
* - Better-Auth is not enabled
|
|
140
|
+
* - JWT plugin is not enabled
|
|
141
|
+
* - No valid session exists
|
|
142
|
+
*/
|
|
143
|
+
@Query(() => String, {
|
|
144
|
+
description: 'Get fresh JWT token for the current session (requires valid session)',
|
|
145
|
+
nullable: true,
|
|
146
|
+
})
|
|
147
|
+
@Roles(RoleEnum.S_USER)
|
|
148
|
+
async betterAuthToken(@Context() ctx: { req: Request }): Promise<null | string> {
|
|
149
|
+
return this.betterAuthService.getToken(ctx.req);
|
|
150
|
+
}
|
|
151
|
+
|
|
134
152
|
/**
|
|
135
153
|
* Get migration status from Legacy Auth to Better-Auth (IAM)
|
|
136
154
|
*
|
|
@@ -165,6 +183,9 @@ export class CoreBetterAuthResolver {
|
|
|
165
183
|
* This mutation wraps Better-Auth's sign-in endpoint and returns a response
|
|
166
184
|
* compatible with the existing auth system.
|
|
167
185
|
*
|
|
186
|
+
* Features automatic legacy user migration: If a user exists in Legacy Auth
|
|
187
|
+
* but not in IAM, they will be automatically migrated on first IAM sign-in.
|
|
188
|
+
*
|
|
168
189
|
* Override this method to add custom pre/post sign-in logic.
|
|
169
190
|
*/
|
|
170
191
|
@Mutation(() => BetterAuthAuthModel, {
|
|
@@ -184,12 +205,38 @@ export class CoreBetterAuthResolver {
|
|
|
184
205
|
throw new BadRequestException('Better-Auth API not available');
|
|
185
206
|
}
|
|
186
207
|
|
|
208
|
+
// Try to sign in, with automatic legacy user migration
|
|
209
|
+
return this.attemptSignIn(email, password, api, true);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Attempt sign-in with optional legacy user migration
|
|
214
|
+
* @param email - User email
|
|
215
|
+
* @param password - User password (plain or SHA256)
|
|
216
|
+
* @param api - Better-Auth API instance
|
|
217
|
+
* @param allowMigration - Whether to attempt legacy migration on failure
|
|
218
|
+
*/
|
|
219
|
+
protected async attemptSignIn(
|
|
220
|
+
email: string,
|
|
221
|
+
password: string,
|
|
222
|
+
api: ReturnType<BetterAuthService['getApi']>,
|
|
223
|
+
allowMigration: boolean,
|
|
224
|
+
): Promise<BetterAuthAuthModel> {
|
|
187
225
|
try {
|
|
188
|
-
//
|
|
189
|
-
const response = (await api
|
|
226
|
+
// Try sign-in with original password first (for native IAM users)
|
|
227
|
+
const response = (await api!.signInEmail({
|
|
190
228
|
body: { email, password },
|
|
191
229
|
})) as BetterAuthSignInResponse | null;
|
|
192
230
|
|
|
231
|
+
this.logger.debug(`[SignIn] API response for ${email}: ${JSON.stringify(response)?.substring(0, 200)}`);
|
|
232
|
+
|
|
233
|
+
// Check if response indicates an error (Better-Auth returns error objects, not throws)
|
|
234
|
+
const responseAny = response as any;
|
|
235
|
+
if (responseAny?.error || responseAny?.code === 'CREDENTIAL_ACCOUNT_NOT_FOUND') {
|
|
236
|
+
this.logger.debug(`[SignIn] API returned error for ${email}: ${responseAny?.error || responseAny?.code}`);
|
|
237
|
+
throw new Error(responseAny?.error || responseAny?.code || 'Credential account not found');
|
|
238
|
+
}
|
|
239
|
+
|
|
193
240
|
if (!response) {
|
|
194
241
|
throw new UnauthorizedException('Invalid credentials');
|
|
195
242
|
}
|
|
@@ -208,8 +255,11 @@ export class CoreBetterAuthResolver {
|
|
|
208
255
|
const sessionUser: BetterAuthSessionUser = response.user;
|
|
209
256
|
const mappedUser = await this.userMapper.mapSessionUser(sessionUser);
|
|
210
257
|
|
|
211
|
-
//
|
|
212
|
-
|
|
258
|
+
// Return the session token for session-based authentication
|
|
259
|
+
// Note: If JWT plugin is enabled, accessToken may be in response or in set-auth-jwt header
|
|
260
|
+
// For GraphQL responses, we return the session token and let clients use it for session auth
|
|
261
|
+
const responseAny = response as any;
|
|
262
|
+
const token = responseAny.accessToken || responseAny.token;
|
|
213
263
|
|
|
214
264
|
return {
|
|
215
265
|
requiresTwoFactor: false,
|
|
@@ -222,9 +272,61 @@ export class CoreBetterAuthResolver {
|
|
|
222
272
|
|
|
223
273
|
throw new UnauthorizedException('Invalid credentials');
|
|
224
274
|
} catch (error) {
|
|
225
|
-
this.logger.debug(
|
|
275
|
+
this.logger.debug(
|
|
276
|
+
`[SignIn] Sign-in failed for ${email}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
// If migration is allowed, try to migrate legacy user and retry
|
|
280
|
+
if (allowMigration) {
|
|
281
|
+
this.logger.debug(`[SignIn] Attempting migration for ${email}...`);
|
|
282
|
+
// Pass the original password for legacy verification
|
|
283
|
+
const migrated = await this.userMapper.migrateAccountToIam(email, password);
|
|
284
|
+
this.logger.debug(`[SignIn] Migration result for ${email}: ${migrated}`);
|
|
285
|
+
if (migrated) {
|
|
286
|
+
this.logger.debug(`[SignIn] Migrated legacy user ${email} to IAM, retrying sign-in`);
|
|
287
|
+
// Retry sign-in after migration with normalized password (as migrateAccountToIam stores it)
|
|
288
|
+
const normalizedPassword = this.userMapper.normalizePasswordForIam(password);
|
|
289
|
+
return this.attemptSignInDirect(email, normalizedPassword, api);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
throw new UnauthorizedException('Invalid credentials');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Direct sign-in attempt without migration logic (used after migration)
|
|
299
|
+
*/
|
|
300
|
+
private async attemptSignInDirect(
|
|
301
|
+
email: string,
|
|
302
|
+
password: string,
|
|
303
|
+
api: ReturnType<BetterAuthService['getApi']>,
|
|
304
|
+
): Promise<BetterAuthAuthModel> {
|
|
305
|
+
const response = (await api!.signInEmail({
|
|
306
|
+
body: { email, password },
|
|
307
|
+
})) as BetterAuthSignInResponse | null;
|
|
308
|
+
|
|
309
|
+
if (!response || !hasUser(response)) {
|
|
226
310
|
throw new UnauthorizedException('Invalid credentials');
|
|
227
311
|
}
|
|
312
|
+
|
|
313
|
+
if (requires2FA(response)) {
|
|
314
|
+
return { requiresTwoFactor: true, success: false, user: null };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const sessionUser: BetterAuthSessionUser = response.user;
|
|
318
|
+
const mappedUser = await this.userMapper.mapSessionUser(sessionUser);
|
|
319
|
+
// Return accessToken if available (JWT), otherwise fall back to session token
|
|
320
|
+
const responseAny = response as any;
|
|
321
|
+
const token = responseAny.accessToken || responseAny.token;
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
requiresTwoFactor: false,
|
|
325
|
+
session: hasSession(response) ? this.mapSessionInfo(response.session) : null,
|
|
326
|
+
success: true,
|
|
327
|
+
token,
|
|
328
|
+
user: mappedUser ? this.mapToUserModel(mappedUser) : null,
|
|
329
|
+
};
|
|
228
330
|
}
|
|
229
331
|
|
|
230
332
|
/**
|
|
@@ -292,7 +394,6 @@ export class CoreBetterAuthResolver {
|
|
|
292
394
|
*/
|
|
293
395
|
@Mutation(() => Boolean, { description: 'Sign out via Better-Auth' })
|
|
294
396
|
@Roles(RoleEnum.S_USER)
|
|
295
|
-
@UseGuards(AuthGuard(AuthGuardStrategy.JWT))
|
|
296
397
|
async betterAuthSignOut(@Context() ctx: { req: Request }): Promise<boolean> {
|
|
297
398
|
if (!this.betterAuthService.isEnabled()) {
|
|
298
399
|
return false;
|
|
@@ -386,7 +487,6 @@ export class CoreBetterAuthResolver {
|
|
|
386
487
|
description: 'Enable 2FA for the current user',
|
|
387
488
|
})
|
|
388
489
|
@Roles(RoleEnum.S_USER)
|
|
389
|
-
@UseGuards(AuthGuard(AuthGuardStrategy.JWT))
|
|
390
490
|
async betterAuthEnable2FA(
|
|
391
491
|
@Args('password') password: string,
|
|
392
492
|
@Context() ctx: { req: Request },
|
|
@@ -441,7 +541,6 @@ export class CoreBetterAuthResolver {
|
|
|
441
541
|
description: 'Disable 2FA for the current user',
|
|
442
542
|
})
|
|
443
543
|
@Roles(RoleEnum.S_USER)
|
|
444
|
-
@UseGuards(AuthGuard(AuthGuardStrategy.JWT))
|
|
445
544
|
async betterAuthDisable2FA(@Args('password') password: string, @Context() ctx: { req: Request }): Promise<boolean> {
|
|
446
545
|
this.ensureEnabled();
|
|
447
546
|
|
|
@@ -487,7 +586,6 @@ export class CoreBetterAuthResolver {
|
|
|
487
586
|
nullable: true,
|
|
488
587
|
})
|
|
489
588
|
@Roles(RoleEnum.S_USER)
|
|
490
|
-
@UseGuards(AuthGuard(AuthGuardStrategy.JWT))
|
|
491
589
|
async betterAuthGenerateBackupCodes(@Context() ctx: { req: Request }): Promise<null | string[]> {
|
|
492
590
|
this.ensureEnabled();
|
|
493
591
|
|
|
@@ -534,7 +632,6 @@ export class CoreBetterAuthResolver {
|
|
|
534
632
|
description: 'Get passkey registration challenge for WebAuthn',
|
|
535
633
|
})
|
|
536
634
|
@Roles(RoleEnum.S_USER)
|
|
537
|
-
@UseGuards(AuthGuard(AuthGuardStrategy.JWT))
|
|
538
635
|
async betterAuthGetPasskeyChallenge(@Context() ctx: { req: Request }): Promise<BetterAuthPasskeyChallengeModel> {
|
|
539
636
|
this.ensureEnabled();
|
|
540
637
|
|
|
@@ -580,7 +677,6 @@ export class CoreBetterAuthResolver {
|
|
|
580
677
|
nullable: true,
|
|
581
678
|
})
|
|
582
679
|
@Roles(RoleEnum.S_USER)
|
|
583
|
-
@UseGuards(AuthGuard(AuthGuardStrategy.JWT))
|
|
584
680
|
async betterAuthListPasskeys(@Context() ctx: { req: Request }): Promise<BetterAuthPasskeyModel[] | null> {
|
|
585
681
|
if (!this.betterAuthService.isEnabled() || !this.betterAuthService.isPasskeyEnabled()) {
|
|
586
682
|
return null;
|
|
@@ -627,7 +723,6 @@ export class CoreBetterAuthResolver {
|
|
|
627
723
|
description: 'Delete a passkey by ID',
|
|
628
724
|
})
|
|
629
725
|
@Roles(RoleEnum.S_USER)
|
|
630
|
-
@UseGuards(AuthGuard(AuthGuardStrategy.JWT))
|
|
631
726
|
async betterAuthDeletePasskey(
|
|
632
727
|
@Args('passkeyId') passkeyId: string,
|
|
633
728
|
@Context() ctx: { req: Request },
|