@hed-hog/core 0.0.304 → 0.0.305

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 (112) hide show
  1. package/README.md +84 -36
  2. package/dist/auth/auth.controller.d.ts +0 -3
  3. package/dist/auth/auth.controller.d.ts.map +1 -1
  4. package/dist/auth/auth.controller.js +4 -7
  5. package/dist/auth/auth.controller.js.map +1 -1
  6. package/dist/auth/auth.service.d.ts.map +1 -1
  7. package/dist/auth/auth.service.js +6 -3
  8. package/dist/auth/auth.service.js.map +1 -1
  9. package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts +9 -0
  10. package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts.map +1 -1
  11. package/dist/dashboard/dashboard-core/dashboard-core.controller.js +12 -0
  12. package/dist/dashboard/dashboard-core/dashboard-core.controller.js.map +1 -1
  13. package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts +7 -0
  14. package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts.map +1 -1
  15. package/dist/dashboard/dashboard-core/dashboard-core.service.js +45 -1
  16. package/dist/dashboard/dashboard-core/dashboard-core.service.js.map +1 -1
  17. package/dist/mail-sent/dto/query-mail-sent-list.dto.d.ts +9 -0
  18. package/dist/mail-sent/dto/query-mail-sent-list.dto.d.ts.map +1 -0
  19. package/dist/mail-sent/dto/query-mail-sent-list.dto.js +49 -0
  20. package/dist/mail-sent/dto/query-mail-sent-list.dto.js.map +1 -0
  21. package/dist/mail-sent/mail-sent.controller.d.ts +24 -2
  22. package/dist/mail-sent/mail-sent.controller.d.ts.map +1 -1
  23. package/dist/mail-sent/mail-sent.controller.js +5 -3
  24. package/dist/mail-sent/mail-sent.controller.js.map +1 -1
  25. package/dist/mail-sent/mail-sent.service.d.ts +28 -3
  26. package/dist/mail-sent/mail-sent.service.d.ts.map +1 -1
  27. package/dist/mail-sent/mail-sent.service.js +108 -3
  28. package/dist/mail-sent/mail-sent.service.js.map +1 -1
  29. package/dist/oauth/oauth-callback-coordinator.service.d.ts +18 -0
  30. package/dist/oauth/oauth-callback-coordinator.service.d.ts.map +1 -0
  31. package/dist/oauth/oauth-callback-coordinator.service.js +137 -0
  32. package/dist/oauth/oauth-callback-coordinator.service.js.map +1 -0
  33. package/dist/oauth/oauth.controller.d.ts +4 -2
  34. package/dist/oauth/oauth.controller.d.ts.map +1 -1
  35. package/dist/oauth/oauth.controller.js +39 -11
  36. package/dist/oauth/oauth.controller.js.map +1 -1
  37. package/dist/oauth/oauth.errors.d.ts +16 -0
  38. package/dist/oauth/oauth.errors.d.ts.map +1 -0
  39. package/dist/oauth/oauth.errors.js +30 -0
  40. package/dist/oauth/oauth.errors.js.map +1 -0
  41. package/dist/oauth/oauth.module.d.ts.map +1 -1
  42. package/dist/oauth/oauth.module.js +10 -1
  43. package/dist/oauth/oauth.module.js.map +1 -1
  44. package/dist/oauth/oauth.service.d.ts +9 -1
  45. package/dist/oauth/oauth.service.d.ts.map +1 -1
  46. package/dist/oauth/oauth.service.js +97 -24
  47. package/dist/oauth/oauth.service.js.map +1 -1
  48. package/dist/oauth/providers/abstract.provider.d.ts +6 -0
  49. package/dist/oauth/providers/abstract.provider.d.ts.map +1 -1
  50. package/dist/oauth/providers/abstract.provider.js +34 -0
  51. package/dist/oauth/providers/abstract.provider.js.map +1 -1
  52. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +3 -0
  53. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
  54. package/dist/oauth/providers/microsoft-entra-id.provider.js +40 -22
  55. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  56. package/dist/security/security.service.d.ts +1 -0
  57. package/dist/security/security.service.d.ts.map +1 -1
  58. package/dist/security/security.service.js +9 -10
  59. package/dist/security/security.service.js.map +1 -1
  60. package/dist/session/session.service.d.ts +5 -1
  61. package/dist/session/session.service.d.ts.map +1 -1
  62. package/dist/session/session.service.js +76 -10
  63. package/dist/session/session.service.js.map +1 -1
  64. package/dist/setting/setting.service.js +4 -4
  65. package/dist/setting/setting.service.js.map +1 -1
  66. package/dist/token/token.service.d.ts +1 -0
  67. package/dist/token/token.service.d.ts.map +1 -1
  68. package/dist/token/token.service.js +23 -12
  69. package/dist/token/token.service.js.map +1 -1
  70. package/dist/user/user.service.d.ts +1 -1
  71. package/dist/user/user.service.d.ts.map +1 -1
  72. package/dist/user/user.service.js +3 -0
  73. package/dist/user/user.service.js.map +1 -1
  74. package/hedhog/data/dashboard.yaml +2 -2
  75. package/hedhog/data/route.yaml +8 -0
  76. package/hedhog/frontend/app/account/components/change-email-form.tsx.ejs +87 -8
  77. package/hedhog/frontend/app/account/components/profile-form.tsx.ejs +90 -10
  78. package/hedhog/frontend/app/ai_agent/page.tsx.ejs +145 -31
  79. package/hedhog/frontend/app/configurations/layout.tsx.ejs +1 -1
  80. package/hedhog/frontend/app/dashboard/[slug]/dashboard-content.tsx.ejs +67 -2
  81. package/hedhog/frontend/app/dashboard/components/add-widget-selector-dialog.tsx.ejs +13 -9
  82. package/hedhog/frontend/app/dashboard/dashboard-home-tabs.tsx.ejs +250 -45
  83. package/hedhog/frontend/app/dashboard/management/tabs/component-roles-tab.tsx.ejs +111 -15
  84. package/hedhog/frontend/app/dashboard/management/tabs/dashboard-roles-tab.tsx.ejs +110 -15
  85. package/hedhog/frontend/app/dashboard/management/tabs/items-tab.tsx.ejs +113 -15
  86. package/hedhog/frontend/app/mail/log/page.tsx.ejs +481 -70
  87. package/hedhog/frontend/app/menu/page.tsx.ejs +202 -30
  88. package/hedhog/frontend/app/roles/menus.tsx.ejs +1 -1
  89. package/hedhog/frontend/app/roles/page.tsx.ejs +288 -104
  90. package/hedhog/frontend/app/users/page.tsx.ejs +168 -23
  91. package/hedhog/frontend/messages/en.json +45 -6
  92. package/hedhog/frontend/messages/pt.json +45 -6
  93. package/package.json +5 -5
  94. package/src/auth/auth.controller.ts +15 -19
  95. package/src/auth/auth.service.ts +13 -8
  96. package/src/dashboard/dashboard-core/dashboard-core.controller.ts +22 -9
  97. package/src/dashboard/dashboard-core/dashboard-core.service.ts +75 -1
  98. package/src/mail-sent/dto/query-mail-sent-list.dto.ts +30 -0
  99. package/src/mail-sent/mail-sent.controller.ts +14 -2
  100. package/src/mail-sent/mail-sent.service.ts +159 -4
  101. package/src/oauth/oauth-callback-coordinator.service.ts +158 -0
  102. package/src/oauth/oauth.controller.ts +58 -1
  103. package/src/oauth/oauth.errors.ts +32 -0
  104. package/src/oauth/oauth.module.ts +10 -1
  105. package/src/oauth/oauth.service.ts +178 -28
  106. package/src/oauth/providers/abstract.provider.ts +64 -0
  107. package/src/oauth/providers/microsoft-entra-id.provider.ts +92 -56
  108. package/src/security/security.service.ts +12 -10
  109. package/src/session/session.service.ts +84 -9
  110. package/src/setting/setting.service.ts +10 -10
  111. package/src/token/token.service.ts +29 -11
  112. package/src/user/user.service.ts +10 -6
@@ -1,11 +1,14 @@
1
+ import { getLocaleText } from '@hed-hog/api-locale';
1
2
  import { PrismaService, user_account_provider_52222e2ecb_enum } from '@hed-hog/api-prisma';
2
3
  import {
3
- BadRequestException,
4
- ConflictException,
5
- Inject,
6
- Injectable,
7
- NotFoundException,
8
- forwardRef
4
+ BadRequestException,
5
+ ConflictException,
6
+ Inject,
7
+ Injectable,
8
+ Logger,
9
+ NotFoundException,
10
+ ServiceUnavailableException,
11
+ forwardRef
9
12
  } from '@nestjs/common';
10
13
  import { AuthService } from '../auth/auth.service';
11
14
  import { FileService } from '../file/file.service';
@@ -16,6 +19,12 @@ import { TokenService } from '../token/token.service';
16
19
  import { USER_AVATAR_UPLOAD_DESTINATION } from '../user/constants/user.constants';
17
20
  import { UserService } from '../user/user.service';
18
21
  import { OAuthProvider } from './interfaces/OAuthProvider';
22
+ import { OAuthCallbackCoordinatorService } from './oauth-callback-coordinator.service';
23
+ import {
24
+ OAuthCallbackAlreadyProcessedError,
25
+ OAuthCallbackInProgressError,
26
+ OAuthProviderException,
27
+ } from './oauth.errors';
19
28
  import { FacebookProvider } from './providers/facebook.provider';
20
29
  import { GithubProvider } from './providers/github.provider';
21
30
  import { GoogleProvider } from './providers/google.provider';
@@ -36,6 +45,7 @@ type HandleCallbackProps = {
36
45
 
37
46
  @Injectable()
38
47
  export class OAuthService {
48
+ private readonly logger = new Logger(OAuthService.name);
39
49
  private providers: Map<string, OAuthProvider> = new Map();
40
50
 
41
51
  constructor(
@@ -44,6 +54,7 @@ export class OAuthService {
44
54
  microsoft: MicrosoftProvider,
45
55
  github: GithubProvider,
46
56
  microsoftEntraId: MicrosoftEntraIdProvider,
57
+ private readonly callbackCoordinator: OAuthCallbackCoordinatorService,
47
58
  @Inject(forwardRef(() => AuthService))
48
59
  private readonly auth: AuthService,
49
60
  private readonly file: FileService,
@@ -77,23 +88,51 @@ export class OAuthService {
77
88
  userId,
78
89
  email,
79
90
  }: HandleCallbackProps) {
80
- let profile;
91
+ try {
92
+ this.logger.log(
93
+ `OAuth callback received: provider=${provider} type=${type} locale=${locale} ip=${ipAddress || 'n/a'} hasCode=${code ? 'yes' : 'no'} codeLength=${code?.length ?? 0} userAgent="${this.summarizeUserAgent(userAgent)}"`,
94
+ );
81
95
 
82
- if (type !== 'disconnect') {
83
- profile = await this.getProvider(provider).getProfile(code, type);
84
- }
96
+ if (type === 'disconnect') {
97
+ return this.handleDisconnect(res, locale, ipAddress, userAgent, provider, email);
98
+ }
85
99
 
86
- console.log('[OAuthService] Callback received for provider:', provider, 'with type:', type, 'and profile:', profile);
100
+ if (!code) {
101
+ throw new BadRequestException(
102
+ getLocaleText(
103
+ 'oauth.callback.codeRequired',
104
+ locale,
105
+ 'Authorization code is required to complete OAuth authentication.',
106
+ ),
107
+ );
108
+ }
87
109
 
88
- switch (type) {
89
- case 'login':
90
- return this.handleLogin(res, locale, ipAddress, userAgent, provider, profile);
91
- case 'register':
92
- return this.handleRegister(res, locale, ipAddress, userAgent, provider, profile);
93
- case 'connect':
94
- return this.handleConnect(res, locale, ipAddress, userAgent, provider, profile, userId);
95
- case 'disconnect':
96
- return this.handleDisconnect(res, locale, ipAddress, userAgent, provider, email);
110
+ const callbackKey = this.buildCallbackKey(provider, type, code);
111
+
112
+ return await this.callbackCoordinator.execute(callbackKey, async () => {
113
+ const profile = await this.getProvider(provider).getProfile(code, type);
114
+ const result = await this.finalizeCallback({
115
+ res,
116
+ locale,
117
+ ipAddress,
118
+ userAgent,
119
+ provider,
120
+ type,
121
+ profile,
122
+ userId,
123
+ });
124
+
125
+ this.logger.log(
126
+ `OAuth callback completed: provider=${provider} type=${type} profileEmailPresent=${profile?.email ? 'yes' : 'no'} userId=${userId ?? 'n/a'}`,
127
+ );
128
+
129
+ return result;
130
+ });
131
+ } catch (error) {
132
+ this.logger.warn(
133
+ `OAuth callback failed before response mapping: provider=${provider} type=${type} message=${error instanceof Error ? error.message : 'unknown error'}`,
134
+ );
135
+ throw this.mapCallbackError(locale, provider, type, error);
97
136
  }
98
137
  }
99
138
 
@@ -119,10 +158,123 @@ export class OAuthService {
119
158
  return settings[settingKey].join(',') || '';
120
159
  }
121
160
 
122
- private async handleLogin(res: Response, locale: string, ipAddress: string, userAgent: string, provider: user_account_provider_52222e2ecb_enum, profile: any) {
161
+ private buildCallbackKey(
162
+ provider: user_account_provider_52222e2ecb_enum,
163
+ type: HandleCallbackProps['type'],
164
+ code: string,
165
+ ) {
166
+ return `${String(provider).toLowerCase()}:${type}:${this.security.hashSha256(code)}`;
167
+ }
168
+
169
+ private summarizeUserAgent(userAgent?: string) {
170
+ if (!userAgent) {
171
+ return 'n/a';
172
+ }
173
+
174
+ return userAgent.length > 120
175
+ ? `${userAgent.slice(0, 117)}...`
176
+ : userAgent;
177
+ }
178
+
179
+ private async finalizeCallback({
180
+ res,
181
+ locale,
182
+ ipAddress,
183
+ userAgent,
184
+ provider,
185
+ type,
186
+ profile,
187
+ userId,
188
+ }: Omit<HandleCallbackProps, 'code' | 'email'> & { profile: any }) {
189
+ switch (type) {
190
+ case 'login':
191
+ return this.handleLogin(res, locale, ipAddress, userAgent, provider, profile);
192
+ case 'register':
193
+ return this.handleRegister(res, locale, ipAddress, userAgent, provider, profile);
194
+ case 'connect':
195
+ return this.handleConnect(res, locale, ipAddress, userAgent, provider, profile, userId);
196
+ default:
197
+ throw new BadRequestException(
198
+ getLocaleText('oauth.callback.invalidType', locale, 'Unsupported OAuth callback type.'),
199
+ );
200
+ }
201
+ }
202
+
203
+ private resolveProfileScopes(provider: user_account_provider_52222e2ecb_enum, profile: any) {
204
+ if (typeof profile?.oauth_scopes === 'string') {
205
+ return profile.oauth_scopes;
206
+ }
207
+
208
+ return this.getProviderScopes(provider);
209
+ }
210
+
211
+ private mapCallbackError(
212
+ locale: string,
213
+ provider: user_account_provider_52222e2ecb_enum,
214
+ type: HandleCallbackProps['type'],
215
+ error: unknown,
216
+ ) {
217
+ if (
218
+ error instanceof BadRequestException ||
219
+ error instanceof ConflictException ||
220
+ error instanceof NotFoundException ||
221
+ error instanceof ServiceUnavailableException
222
+ ) {
223
+ return error;
224
+ }
225
+
226
+ if (error instanceof OAuthCallbackAlreadyProcessedError) {
227
+ return new BadRequestException(
228
+ getLocaleText(
229
+ 'oauth.callback.alreadyProcessed',
230
+ locale,
231
+ 'This OAuth callback has already been processed. Start the sign-in flow again.',
232
+ ),
233
+ );
234
+ }
235
+
236
+ if (error instanceof OAuthCallbackInProgressError) {
237
+ return new ConflictException(
238
+ getLocaleText(
239
+ 'oauth.callback.inProgress',
240
+ locale,
241
+ 'This OAuth callback is already being processed. Wait a moment and try again.',
242
+ ),
243
+ );
244
+ }
245
+
246
+ if (error instanceof OAuthProviderException) {
247
+ if (error.reason === 'callback_consumed' || error.reason === 'invalid_callback') {
248
+ return new BadRequestException(getLocaleText('oauth.callback.invalid', locale, error.message));
249
+ }
250
+
251
+ this.logger.warn(
252
+ `OAuth callback upstream failure: provider=${provider} type=${type} status=${error.status ?? 'unknown'} reason=${error.reason} providerError=${error.providerError ?? 'unknown'} providerCode=${error.providerErrorCode ?? 'n/a'}`,
253
+ );
254
+
255
+ return new ServiceUnavailableException(
256
+ getLocaleText(
257
+ 'oauth.callback.unavailable',
258
+ locale,
259
+ 'Unable to complete OAuth authentication right now. Please try again.',
260
+ ),
261
+ );
262
+ }
123
263
 
124
- console.log('[OAuthService] Handling login for provider:', provider);
264
+ this.logger.error(
265
+ `Unexpected OAuth callback failure: provider=${provider} type=${type} error=${error instanceof Error ? error.message : 'unknown'}`,
266
+ );
125
267
 
268
+ return new ServiceUnavailableException(
269
+ getLocaleText(
270
+ 'oauth.callback.unavailable',
271
+ locale,
272
+ 'Unable to complete OAuth authentication right now. Please try again.',
273
+ ),
274
+ );
275
+ }
276
+
277
+ private async handleLogin(res: Response, locale: string, ipAddress: string, userAgent: string, provider: user_account_provider_52222e2ecb_enum, profile: any) {
126
278
  const userAccount = await this.prisma.user_account.findFirst({
127
279
  where: {
128
280
  provider: String(provider).toLowerCase().replace(/-/g, '_') as user_account_provider_52222e2ecb_enum,
@@ -133,8 +285,6 @@ export class OAuthService {
133
285
  },
134
286
  });
135
287
 
136
- console.log('[OAuthService] Found user account:', userAccount);
137
-
138
288
  if (!userAccount) {
139
289
  const userIdentifier = await this.prisma.user_identifier.findFirst({
140
290
  where: {
@@ -153,7 +303,7 @@ export class OAuthService {
153
303
  } else {
154
304
  if (profile.oauth_tokens?.refresh_token) {
155
305
  const encryptedRefreshToken = this.security.encrypt(profile.oauth_tokens.refresh_token);
156
- const scopes = await this.getProviderScopes(provider);
306
+ const scopes = await this.resolveProfileScopes(provider, profile);
157
307
  await this.prisma.user_account.update({
158
308
  where: { id: userAccount.id },
159
309
  data: {
@@ -193,7 +343,7 @@ export class OAuthService {
193
343
  }
194
344
 
195
345
  const encryptedRefreshToken = profile.oauth_tokens.refresh_token ? this.security.encrypt(profile.oauth_tokens.refresh_token) : null;
196
- const scopes = await this.getProviderScopes(provider);
346
+ const scopes = await this.resolveProfileScopes(provider, profile);
197
347
 
198
348
  const settings = await this.setting.getSettingValues([
199
349
  'oauth-role-assignment',
@@ -289,14 +439,14 @@ export class OAuthService {
289
439
  }
290
440
 
291
441
  const encryptedRefreshToken = profile.oauth_tokens.refresh_token ? this.security.encrypt(profile.oauth_tokens.refresh_token) : null;
292
- const scopes = await this.getProviderScopes(provider);
442
+ const scopes = await this.resolveProfileScopes(provider, profile);
293
443
  await this.prisma.user_account.create({
294
444
  data: {
295
445
  user_id: userId,
296
446
  provider: String(provider).toLowerCase().replace(/-/g, '_') as user_account_provider_52222e2ecb_enum,
297
447
  provider_user_id: profile.id,
298
448
  email: profile.email,
299
- refresh_token: Buffer.from(encryptedRefreshToken),
449
+ refresh_token: encryptedRefreshToken ? Buffer.from(encryptedRefreshToken) : null,
300
450
  token_expires_at: profile.oauth_tokens?.expires_in
301
451
  ? new Date(Date.now() + profile.oauth_tokens.expires_in * 1000)
302
452
  : null,
@@ -1,6 +1,7 @@
1
1
  import { HttpService } from '@nestjs/axios';
2
2
  import { lastValueFrom } from 'rxjs';
3
3
  import { OAuthProvider } from '../interfaces/OAuthProvider';
4
+ import { OAuthProviderException } from '../oauth.errors';
4
5
 
5
6
  export abstract class BaseOAuthProvider implements OAuthProvider {
6
7
  constructor(protected readonly http: HttpService) {}
@@ -47,4 +48,67 @@ export abstract class BaseOAuthProvider implements OAuthProvider {
47
48
  );
48
49
  return response.data;
49
50
  }
51
+
52
+ protected mapProviderError(provider: string, error: any) {
53
+ const providerError = this.extractProviderError(error);
54
+ const providerErrorCode = this.extractProviderErrorCode(error);
55
+ const status = error?.response?.status;
56
+
57
+ if (this.isConsumedAuthorizationCodeError(error)) {
58
+ return new OAuthProviderException(
59
+ provider,
60
+ 'callback_consumed',
61
+ 'OAuth callback is no longer valid. Start the sign-in flow again.',
62
+ status,
63
+ providerError,
64
+ providerErrorCode,
65
+ );
66
+ }
67
+
68
+ if (this.isInvalidAuthorizationCodeError(error)) {
69
+ return new OAuthProviderException(
70
+ provider,
71
+ 'invalid_callback',
72
+ 'OAuth callback is invalid or expired. Start the sign-in flow again.',
73
+ status,
74
+ providerError,
75
+ providerErrorCode,
76
+ );
77
+ }
78
+
79
+ return new OAuthProviderException(
80
+ provider,
81
+ 'upstream_failure',
82
+ `Unable to complete ${provider} OAuth authentication.`,
83
+ status,
84
+ providerError,
85
+ providerErrorCode,
86
+ );
87
+ }
88
+
89
+ protected extractProviderError(error: any) {
90
+ return error?.response?.data?.error || error?.code || 'unknown_error';
91
+ }
92
+
93
+ protected extractProviderErrorCode(error: any) {
94
+ const description = String(error?.response?.data?.error_description || '');
95
+ const match = description.match(/AADSTS\d+/i);
96
+ return match?.[0] || undefined;
97
+ }
98
+
99
+ protected isConsumedAuthorizationCodeError(error: any) {
100
+ const providerError = this.extractProviderError(error);
101
+ const description = String(error?.response?.data?.error_description || '');
102
+
103
+ return (
104
+ providerError === 'invalid_grant' &&
105
+ /(AADSTS54005|already redeemed|already been redeemed|authorization code was already redeemed)/i.test(
106
+ description,
107
+ )
108
+ );
109
+ }
110
+
111
+ protected isInvalidAuthorizationCodeError(error: any) {
112
+ return this.extractProviderError(error) === 'invalid_grant';
113
+ }
50
114
  }
@@ -1,10 +1,12 @@
1
1
  import { HttpService } from '@nestjs/axios';
2
- import { forwardRef, Inject, Injectable } from '@nestjs/common';
2
+ import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
3
3
  import { SettingService } from '../../setting/setting.service';
4
4
  import { BaseOAuthProvider } from './abstract.provider';
5
5
 
6
6
  @Injectable()
7
7
  export class MicrosoftEntraIdProvider extends BaseOAuthProvider {
8
+ private readonly logger = new Logger(MicrosoftEntraIdProvider.name);
9
+
8
10
  constructor(
9
11
  http: HttpService,
10
12
  @Inject(forwardRef(() => SettingService))
@@ -13,6 +15,19 @@ export class MicrosoftEntraIdProvider extends BaseOAuthProvider {
13
15
  super(http);
14
16
  }
15
17
 
18
+ private normalizeBaseUrl(url: string) {
19
+ return String(url).trim().replace(/\/+$/, '');
20
+ }
21
+
22
+ private buildRedirectUri(baseUrl: string, callbackPath: string) {
23
+ const normalizedBaseUrl = this.normalizeBaseUrl(baseUrl);
24
+ const normalizedCallbackPath = callbackPath.startsWith('/')
25
+ ? callbackPath
26
+ : `/${callbackPath}`;
27
+
28
+ return new URL(normalizedCallbackPath, `${normalizedBaseUrl}/`).toString();
29
+ }
30
+
16
31
  getProviderType() {
17
32
  return 'MICROSOFT-ENTRA-ID';
18
33
  }
@@ -33,82 +48,103 @@ export class MicrosoftEntraIdProvider extends BaseOAuthProvider {
33
48
  !settings['microsoft_entra_id_tenant_id'] ||
34
49
  !settings['url']
35
50
  ) {
51
+ this.logger.error(
52
+ 'Microsoft Entra ID OAuth is missing required settings. Verify client id, client secret, scopes, tenant id, and url.',
53
+ );
36
54
  throw new Error('Microsoft Entra ID OAuth is not configured properly. Please configure microsoft_entra_id_client_id, microsoft_entra_id_client_secret, microsoft_entra_id_scopes, microsoft_entra_id_tenant_id, and url settings.');
37
55
  }
38
56
 
39
57
  const tenantId = settings['microsoft_entra_id_tenant_id'];
40
- const redirectURI = new URL(callbackPath, settings['url']).toString();
41
- const scopes = settings['microsoft_entra_id_scopes'];
58
+ const redirectURI = this.buildRedirectUri(settings['url'], callbackPath);
59
+ const baseScopes = Array.isArray(settings['microsoft_entra_id_scopes'])
60
+ ? settings['microsoft_entra_id_scopes']
61
+ : [];
62
+ const scopes = baseScopes.includes('offline_access')
63
+ ? baseScopes
64
+ : [...baseScopes, 'offline_access'];
65
+ const isConnectFlow = callbackPath.includes('/connect');
42
66
  const params = new URLSearchParams({
43
67
  client_id: settings['microsoft_entra_id_client_id'],
44
68
  redirect_uri: redirectURI,
45
69
  response_type: 'code',
46
70
  scope: scopes.join(' '),
47
71
  response_mode: 'query',
48
- prompt: 'consent',
49
72
  });
50
73
 
51
- const authUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?${params.toString()}`;
52
- console.log('[MicrosoftEntraIdProvider] Generated auth URL:', authUrl);
74
+ this.logger.log(
75
+ `OAuth auth URL requested: provider=MICROSOFT-ENTRA-ID redirectUri=${redirectURI} hasOfflineAccess=${scopes.includes('offline_access') ? 'yes' : 'no'} connectFlow=${isConnectFlow ? 'yes' : 'no'} tenantConfigured=${tenantId ? 'yes' : 'no'}`,
76
+ );
77
+
78
+ // Force consent only for explicit account connect, not for routine login.
79
+ if (isConnectFlow) {
80
+ params.set('prompt', 'consent');
81
+ }
53
82
 
83
+ const authUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?${params.toString()}`;
54
84
  return authUrl;
55
85
  }
56
86
 
57
87
  async getProfile(code: string, type: string): Promise<any> {
88
+ let redirectUri = '';
58
89
 
59
- console.log('[MicrosoftEntraIdProvider] Fetching profile with code:', code, 'and type:', type);
60
90
  try {
61
- const settings = await this.setting.getSettingValues([
62
- 'microsoft_entra_id_client_id',
63
- 'microsoft_entra_id_client_secret',
64
- 'microsoft_entra_id_scopes',
65
- 'microsoft_entra_id_tenant_id',
66
- 'url',
67
- ]);
68
- const tenantId = settings['microsoft_entra_id_tenant_id'];
91
+ const settings = await this.setting.getSettingValues([
92
+ 'microsoft_entra_id_client_id',
93
+ 'microsoft_entra_id_client_secret',
94
+ 'microsoft_entra_id_scopes',
95
+ 'microsoft_entra_id_tenant_id',
96
+ 'url',
97
+ ]);
98
+ const tenantId = settings['microsoft_entra_id_tenant_id'];
99
+ const scopes = settings['microsoft_entra_id_scopes'] ?? [];
100
+ redirectUri = this.buildRedirectUri(
101
+ settings['url'],
102
+ `/callback/microsoft-entra-id/${type}`,
103
+ );
69
104
 
70
- console.log({
71
- url: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
72
- clientId: settings['microsoft_entra_id_client_id'],
73
- clientSecret: settings['microsoft_entra_id_client_secret'],
74
- redirectUri: `${settings['url']}/callback/microsoft-entra-id/${type}`,
75
- })
76
-
77
- const token = await this.fetchToken({
78
- code,
79
- url: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
80
- clientId: settings['microsoft_entra_id_client_id'],
81
- clientSecret: settings['microsoft_entra_id_client_secret'],
82
- redirectUri: `${settings['url']}/callback/microsoft-entra-id/${type}`,
83
- });
84
- console.log('[MicrosoftEntraIdProvider] Fetched token:', token);
105
+ this.logger.log(
106
+ `OAuth token exchange started: provider=MICROSOFT-ENTRA-ID type=${type} redirectUri=${redirectUri} hasCode=${code ? 'yes' : 'no'} codeLength=${code?.length ?? 0}`,
107
+ );
85
108
 
86
- const profile = await this.fetchProfile(
87
- token.access_token,
88
- 'https://graph.microsoft.com/v1.0/me',
89
- );
109
+ const token = await this.fetchToken({
110
+ code,
111
+ url: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
112
+ clientId: settings['microsoft_entra_id_client_id'],
113
+ clientSecret: settings['microsoft_entra_id_client_secret'],
114
+ redirectUri,
115
+ });
90
116
 
91
- console.log('[MicrosoftEntraIdProvider] Fetched profile:', profile);
92
-
93
- const pictureUrl = 'https://graph.microsoft.com/v1.0/me/photo/$value';
94
- return {
95
- id: profile.id,
96
- email: profile.mail || profile.userPrincipalName,
97
- name: profile.displayName,
98
- picture: pictureUrl,
99
- oauth_tokens: {
100
- access_token: token.access_token,
101
- refresh_token: token.refresh_token,
102
- expires_in: token.expires_in,
103
- token_type: token.token_type,
104
- },
105
- };
106
-
107
- } catch (err: any) {
108
- console.log('URL:', err?.config?.url);
109
- console.log('STATUS:', err?.response?.status);
110
- console.log('DATA:', err?.response?.data);
111
- throw err;
112
- }
117
+ const profile = await this.fetchProfile(
118
+ token.access_token,
119
+ 'https://graph.microsoft.com/v1.0/me',
120
+ );
121
+
122
+ this.logger.log(
123
+ `OAuth profile fetched: provider=MICROSOFT-ENTRA-ID type=${type} profileIdPresent=${profile?.id ? 'yes' : 'no'} emailPresent=${profile?.mail || profile?.userPrincipalName ? 'yes' : 'no'}`,
124
+ );
125
+
126
+ const pictureUrl = 'https://graph.microsoft.com/v1.0/me/photo/$value';
127
+ return {
128
+ id: profile.id,
129
+ email: profile.mail || profile.userPrincipalName,
130
+ name: profile.displayName,
131
+ picture: pictureUrl,
132
+ oauth_scopes: scopes.join(','),
133
+ oauth_tokens: {
134
+ access_token: token.access_token,
135
+ refresh_token: token.refresh_token,
136
+ expires_in: token.expires_in,
137
+ token_type: token.token_type,
138
+ },
139
+ };
140
+ } catch (error: any) {
141
+ const mappedError = this.mapProviderError('Microsoft Entra ID', error);
142
+
143
+ this.logger.warn(
144
+ `OAuth callback failed: provider=MICROSOFT-ENTRA-ID status=${mappedError.status ?? 'unknown'} error=${mappedError.providerError ?? 'unknown'} providerCode=${mappedError.providerErrorCode ?? 'n/a'} reason=${mappedError.reason} redirectUri=${redirectUri || 'unresolved'}`,
145
+ );
146
+
147
+ throw mappedError;
148
+ }
113
149
  }
114
150
  }
@@ -59,14 +59,7 @@ export class SecurityService {
59
59
  }
60
60
 
61
61
  encrypt(value: string, secret?: string): string {
62
-
63
- console.log('Encrypting:', {
64
- value,
65
- secret
66
- });
67
-
68
- const encryptionSecret = secret || this.configService.get('ENCRYPTION_SECRET');
69
- if (!encryptionSecret) throw new Error('Missing encryption secret');
62
+ const encryptionSecret = this.resolveEncryptionSecret(secret);
70
63
  const salt = randomBytes(16);
71
64
  const key = scryptSync(encryptionSecret, salt, 32);
72
65
  const iv = randomBytes(12);
@@ -84,8 +77,7 @@ export class SecurityService {
84
77
 
85
78
  decrypt(encryptedValue: string, secret?: string): string {
86
79
  try {
87
- const encryptionSecret = secret || this.configService.get('ENCRYPTION_SECRET');
88
- if (!encryptionSecret) throw new Error('Missing encryption secret');
80
+ const encryptionSecret = this.resolveEncryptionSecret(secret);
89
81
 
90
82
  const parts = encryptedValue.split(':');
91
83
  if (parts.length !== 4) {
@@ -106,6 +98,16 @@ export class SecurityService {
106
98
  }
107
99
  }
108
100
 
101
+ private resolveEncryptionSecret(secret?: string): string {
102
+ const encryptionSecret = secret || this.configService.get<string>('ENCRYPTION_SECRET');
103
+
104
+ if (!encryptionSecret || !String(encryptionSecret).trim()) {
105
+ throw new Error('ENCRYPTION_SECRET is not configured.');
106
+ }
107
+
108
+ return encryptionSecret;
109
+ }
110
+
109
111
  generateCode(length: number): string {
110
112
 
111
113
  return Array.from(crypto.getRandomValues(new Uint8Array(length || 6)))