@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.
- package/README.md +84 -36
- package/dist/auth/auth.controller.d.ts +0 -3
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +4 -7
- package/dist/auth/auth.controller.js.map +1 -1
- package/dist/auth/auth.service.d.ts.map +1 -1
- package/dist/auth/auth.service.js +6 -3
- package/dist/auth/auth.service.js.map +1 -1
- package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts +9 -0
- package/dist/dashboard/dashboard-core/dashboard-core.controller.d.ts.map +1 -1
- package/dist/dashboard/dashboard-core/dashboard-core.controller.js +12 -0
- package/dist/dashboard/dashboard-core/dashboard-core.controller.js.map +1 -1
- package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts +7 -0
- package/dist/dashboard/dashboard-core/dashboard-core.service.d.ts.map +1 -1
- package/dist/dashboard/dashboard-core/dashboard-core.service.js +45 -1
- package/dist/dashboard/dashboard-core/dashboard-core.service.js.map +1 -1
- package/dist/mail-sent/dto/query-mail-sent-list.dto.d.ts +9 -0
- package/dist/mail-sent/dto/query-mail-sent-list.dto.d.ts.map +1 -0
- package/dist/mail-sent/dto/query-mail-sent-list.dto.js +49 -0
- package/dist/mail-sent/dto/query-mail-sent-list.dto.js.map +1 -0
- package/dist/mail-sent/mail-sent.controller.d.ts +24 -2
- package/dist/mail-sent/mail-sent.controller.d.ts.map +1 -1
- package/dist/mail-sent/mail-sent.controller.js +5 -3
- package/dist/mail-sent/mail-sent.controller.js.map +1 -1
- package/dist/mail-sent/mail-sent.service.d.ts +28 -3
- package/dist/mail-sent/mail-sent.service.d.ts.map +1 -1
- package/dist/mail-sent/mail-sent.service.js +108 -3
- package/dist/mail-sent/mail-sent.service.js.map +1 -1
- package/dist/oauth/oauth-callback-coordinator.service.d.ts +18 -0
- package/dist/oauth/oauth-callback-coordinator.service.d.ts.map +1 -0
- package/dist/oauth/oauth-callback-coordinator.service.js +137 -0
- package/dist/oauth/oauth-callback-coordinator.service.js.map +1 -0
- package/dist/oauth/oauth.controller.d.ts +4 -2
- package/dist/oauth/oauth.controller.d.ts.map +1 -1
- package/dist/oauth/oauth.controller.js +39 -11
- package/dist/oauth/oauth.controller.js.map +1 -1
- package/dist/oauth/oauth.errors.d.ts +16 -0
- package/dist/oauth/oauth.errors.d.ts.map +1 -0
- package/dist/oauth/oauth.errors.js +30 -0
- package/dist/oauth/oauth.errors.js.map +1 -0
- package/dist/oauth/oauth.module.d.ts.map +1 -1
- package/dist/oauth/oauth.module.js +10 -1
- package/dist/oauth/oauth.module.js.map +1 -1
- package/dist/oauth/oauth.service.d.ts +9 -1
- package/dist/oauth/oauth.service.d.ts.map +1 -1
- package/dist/oauth/oauth.service.js +97 -24
- package/dist/oauth/oauth.service.js.map +1 -1
- package/dist/oauth/providers/abstract.provider.d.ts +6 -0
- package/dist/oauth/providers/abstract.provider.d.ts.map +1 -1
- package/dist/oauth/providers/abstract.provider.js +34 -0
- package/dist/oauth/providers/abstract.provider.js.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +3 -0
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.js +40 -22
- package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
- package/dist/security/security.service.d.ts +1 -0
- package/dist/security/security.service.d.ts.map +1 -1
- package/dist/security/security.service.js +9 -10
- package/dist/security/security.service.js.map +1 -1
- package/dist/session/session.service.d.ts +5 -1
- package/dist/session/session.service.d.ts.map +1 -1
- package/dist/session/session.service.js +76 -10
- package/dist/session/session.service.js.map +1 -1
- package/dist/setting/setting.service.js +4 -4
- package/dist/setting/setting.service.js.map +1 -1
- package/dist/token/token.service.d.ts +1 -0
- package/dist/token/token.service.d.ts.map +1 -1
- package/dist/token/token.service.js +23 -12
- package/dist/token/token.service.js.map +1 -1
- package/dist/user/user.service.d.ts +1 -1
- package/dist/user/user.service.d.ts.map +1 -1
- package/dist/user/user.service.js +3 -0
- package/dist/user/user.service.js.map +1 -1
- package/hedhog/data/dashboard.yaml +2 -2
- package/hedhog/data/route.yaml +8 -0
- package/hedhog/frontend/app/account/components/change-email-form.tsx.ejs +87 -8
- package/hedhog/frontend/app/account/components/profile-form.tsx.ejs +90 -10
- package/hedhog/frontend/app/ai_agent/page.tsx.ejs +145 -31
- package/hedhog/frontend/app/configurations/layout.tsx.ejs +1 -1
- package/hedhog/frontend/app/dashboard/[slug]/dashboard-content.tsx.ejs +67 -2
- package/hedhog/frontend/app/dashboard/components/add-widget-selector-dialog.tsx.ejs +13 -9
- package/hedhog/frontend/app/dashboard/dashboard-home-tabs.tsx.ejs +250 -45
- package/hedhog/frontend/app/dashboard/management/tabs/component-roles-tab.tsx.ejs +111 -15
- package/hedhog/frontend/app/dashboard/management/tabs/dashboard-roles-tab.tsx.ejs +110 -15
- package/hedhog/frontend/app/dashboard/management/tabs/items-tab.tsx.ejs +113 -15
- package/hedhog/frontend/app/mail/log/page.tsx.ejs +481 -70
- package/hedhog/frontend/app/menu/page.tsx.ejs +202 -30
- package/hedhog/frontend/app/roles/menus.tsx.ejs +1 -1
- package/hedhog/frontend/app/roles/page.tsx.ejs +288 -104
- package/hedhog/frontend/app/users/page.tsx.ejs +168 -23
- package/hedhog/frontend/messages/en.json +45 -6
- package/hedhog/frontend/messages/pt.json +45 -6
- package/package.json +5 -5
- package/src/auth/auth.controller.ts +15 -19
- package/src/auth/auth.service.ts +13 -8
- package/src/dashboard/dashboard-core/dashboard-core.controller.ts +22 -9
- package/src/dashboard/dashboard-core/dashboard-core.service.ts +75 -1
- package/src/mail-sent/dto/query-mail-sent-list.dto.ts +30 -0
- package/src/mail-sent/mail-sent.controller.ts +14 -2
- package/src/mail-sent/mail-sent.service.ts +159 -4
- package/src/oauth/oauth-callback-coordinator.service.ts +158 -0
- package/src/oauth/oauth.controller.ts +58 -1
- package/src/oauth/oauth.errors.ts +32 -0
- package/src/oauth/oauth.module.ts +10 -1
- package/src/oauth/oauth.service.ts +178 -28
- package/src/oauth/providers/abstract.provider.ts +64 -0
- package/src/oauth/providers/microsoft-entra-id.provider.ts +92 -56
- package/src/security/security.service.ts +12 -10
- package/src/session/session.service.ts +84 -9
- package/src/setting/setting.service.ts +10 -10
- package/src/token/token.service.ts +29 -11
- 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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
96
|
+
if (type === 'disconnect') {
|
|
97
|
+
return this.handleDisconnect(res, locale, ipAddress, userAgent, provider, email);
|
|
98
|
+
}
|
|
85
99
|
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
41
|
-
const
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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 =
|
|
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)))
|