@hed-hog/core 0.0.384 → 0.0.386

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 (123) hide show
  1. package/dist/app-command/app-command.registry.d.ts +2 -0
  2. package/dist/app-command/app-command.registry.d.ts.map +1 -1
  3. package/dist/app-command/app-command.registry.js.map +1 -1
  4. package/dist/auth/auth.controller.d.ts +23 -1
  5. package/dist/auth/auth.controller.d.ts.map +1 -1
  6. package/dist/auth/auth.controller.js +27 -0
  7. package/dist/auth/auth.controller.js.map +1 -1
  8. package/dist/auth/auth.service.d.ts +56 -6
  9. package/dist/auth/auth.service.d.ts.map +1 -1
  10. package/dist/auth/auth.service.js +108 -0
  11. package/dist/auth/auth.service.js.map +1 -1
  12. package/dist/auth/dto/complete-account.dto.d.ts +9 -0
  13. package/dist/auth/dto/complete-account.dto.d.ts.map +1 -0
  14. package/dist/auth/dto/complete-account.dto.js +43 -0
  15. package/dist/auth/dto/complete-account.dto.js.map +1 -0
  16. package/dist/authorize/authorize.controller.d.ts +45 -0
  17. package/dist/authorize/authorize.controller.d.ts.map +1 -0
  18. package/dist/authorize/authorize.controller.js +120 -0
  19. package/dist/authorize/authorize.controller.js.map +1 -0
  20. package/dist/authorize/authorize.module.d.ts +8 -0
  21. package/dist/authorize/authorize.module.d.ts.map +1 -0
  22. package/dist/authorize/authorize.module.js +36 -0
  23. package/dist/authorize/authorize.module.js.map +1 -0
  24. package/dist/authorize/authorize.service.d.ts +79 -0
  25. package/dist/authorize/authorize.service.d.ts.map +1 -0
  26. package/dist/authorize/authorize.service.js +196 -0
  27. package/dist/authorize/authorize.service.js.map +1 -0
  28. package/dist/authorize/dto/authorize-info.dto.d.ts +8 -0
  29. package/dist/authorize/dto/authorize-info.dto.d.ts.map +1 -0
  30. package/dist/authorize/dto/authorize-info.dto.js +42 -0
  31. package/dist/authorize/dto/authorize-info.dto.js.map +1 -0
  32. package/dist/authorize/dto/consent.dto.d.ts +8 -0
  33. package/dist/authorize/dto/consent.dto.d.ts.map +1 -0
  34. package/dist/authorize/dto/consent.dto.js +42 -0
  35. package/dist/authorize/dto/consent.dto.js.map +1 -0
  36. package/dist/authorize/dto/token.dto.d.ts +8 -0
  37. package/dist/authorize/dto/token.dto.d.ts.map +1 -0
  38. package/dist/authorize/dto/token.dto.js +42 -0
  39. package/dist/authorize/dto/token.dto.js.map +1 -0
  40. package/dist/challenge/challenge.service.d.ts +7 -0
  41. package/dist/challenge/challenge.service.d.ts.map +1 -1
  42. package/dist/challenge/challenge.service.js +22 -0
  43. package/dist/challenge/challenge.service.js.map +1 -1
  44. package/dist/core.module.d.ts.map +1 -1
  45. package/dist/core.module.js +2 -0
  46. package/dist/core.module.js.map +1 -1
  47. package/dist/file/file.service.d.ts +7 -0
  48. package/dist/file/file.service.d.ts.map +1 -1
  49. package/dist/file/file.service.js +16 -0
  50. package/dist/file/file.service.js.map +1 -1
  51. package/dist/integration-profile/integration-profile.secrets.d.ts +6 -2
  52. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  53. package/dist/integration-profile/integration-profile.secrets.js +11 -2
  54. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  55. package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
  56. package/dist/integration-profile/integration-profile.service.js +18 -2
  57. package/dist/integration-profile/integration-profile.service.js.map +1 -1
  58. package/dist/integration-profile/integration-profile.utils.d.ts +10 -0
  59. package/dist/integration-profile/integration-profile.utils.d.ts.map +1 -1
  60. package/dist/integration-profile/integration-profile.utils.js +24 -1
  61. package/dist/integration-profile/integration-profile.utils.js.map +1 -1
  62. package/dist/notification/notification.controller.d.ts +12 -12
  63. package/dist/notification/notification.service.d.ts +12 -12
  64. package/dist/oauth/interfaces/OAuthProvider.d.ts +1 -1
  65. package/dist/oauth/interfaces/OAuthProvider.d.ts.map +1 -1
  66. package/dist/oauth/oauth.controller.d.ts +26 -0
  67. package/dist/oauth/oauth.controller.d.ts.map +1 -1
  68. package/dist/oauth/oauth.controller.js +94 -0
  69. package/dist/oauth/oauth.controller.js.map +1 -1
  70. package/dist/oauth/oauth.service.d.ts +39 -1
  71. package/dist/oauth/oauth.service.d.ts.map +1 -1
  72. package/dist/oauth/oauth.service.js +115 -7
  73. package/dist/oauth/oauth.service.js.map +1 -1
  74. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +5 -1
  75. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
  76. package/dist/oauth/providers/microsoft-entra-id.provider.js +37 -6
  77. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  78. package/dist/profile/profile.controller.d.ts +1 -1
  79. package/dist/profile/profile.service.d.ts +1 -1
  80. package/dist/session/session.controller.d.ts +1 -1
  81. package/dist/session/session.service.d.ts +3 -3
  82. package/dist/user/user.controller.d.ts +1 -1
  83. package/dist/user/user.service.d.ts +3 -3
  84. package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -1
  85. package/dist/webhook-integration/webhook-integration.service.js +7 -1
  86. package/dist/webhook-integration/webhook-integration.service.js.map +1 -1
  87. package/hedhog/data/integration_provider.yaml +8 -0
  88. package/hedhog/data/integration_type.yaml +6 -0
  89. package/hedhog/data/mail.yaml +39 -0
  90. package/hedhog/data/route.yaml +12 -0
  91. package/hedhog/data/setting_group.yaml +15 -1
  92. package/hedhog/frontend/app/account/components/regenerate-codes-dialog.tsx.ejs +11 -7
  93. package/hedhog/frontend/app/account/components/verify-before-add-dialog.tsx.ejs +9 -7
  94. package/hedhog/frontend/app/account/hooks/use-mfa-setup.ts.ejs +6 -2
  95. package/hedhog/frontend/app/dashboard/dashboard-home-tabs.tsx.ejs +130 -34
  96. package/hedhog/frontend/app/roles/page.tsx.ejs +13 -12
  97. package/hedhog/frontend/messages/en.json +35 -2
  98. package/hedhog/frontend/messages/pt.json +35 -2
  99. package/package.json +10 -5
  100. package/src/app-command/app-command.registry.ts +2 -0
  101. package/src/auth/auth.controller.ts +21 -0
  102. package/src/auth/auth.service.ts +133 -0
  103. package/src/auth/dto/complete-account.dto.ts +24 -0
  104. package/src/authorize/authorize.controller.ts +99 -0
  105. package/src/authorize/authorize.module.ts +23 -0
  106. package/src/authorize/authorize.service.spec.ts +307 -0
  107. package/src/authorize/authorize.service.ts +245 -0
  108. package/src/authorize/dto/authorize-info.dto.ts +23 -0
  109. package/src/authorize/dto/consent.dto.ts +23 -0
  110. package/src/authorize/dto/token.dto.ts +23 -0
  111. package/src/challenge/challenge.service.ts +28 -0
  112. package/src/core.module.ts +2 -0
  113. package/src/file/file.service.ts +19 -0
  114. package/src/integration-profile/integration-profile.secrets.spec.ts +114 -0
  115. package/src/integration-profile/integration-profile.secrets.ts +11 -2
  116. package/src/integration-profile/integration-profile.service.ts +19 -0
  117. package/src/integration-profile/integration-profile.utils.ts +40 -0
  118. package/src/mcp/decorators/allow-mcp-token.decorator.ts +7 -7
  119. package/src/oauth/interfaces/OAuthProvider.ts +5 -1
  120. package/src/oauth/oauth.controller.ts +89 -0
  121. package/src/oauth/oauth.service.ts +190 -8
  122. package/src/oauth/providers/microsoft-entra-id.provider.ts +45 -5
  123. package/src/webhook-integration/webhook-integration.service.ts +18 -2
@@ -0,0 +1,23 @@
1
+ import { IsNotEmpty, IsString } from 'class-validator';
2
+
3
+ export class ConsentDTO {
4
+ @IsString()
5
+ @IsNotEmpty()
6
+ client_id: string;
7
+
8
+ @IsString()
9
+ @IsNotEmpty()
10
+ redirect_uri: string;
11
+
12
+ @IsString()
13
+ @IsNotEmpty()
14
+ scope: string;
15
+
16
+ @IsString()
17
+ @IsNotEmpty()
18
+ code_challenge: string;
19
+
20
+ @IsString()
21
+ @IsNotEmpty()
22
+ code_challenge_method: string;
23
+ }
@@ -0,0 +1,23 @@
1
+ import { IsIn, IsNotEmpty, IsString } from 'class-validator';
2
+
3
+ export class TokenDTO {
4
+ @IsString()
5
+ @IsIn(['authorization_code'])
6
+ grant_type: string;
7
+
8
+ @IsString()
9
+ @IsNotEmpty()
10
+ code: string;
11
+
12
+ @IsString()
13
+ @IsNotEmpty()
14
+ code_verifier: string;
15
+
16
+ @IsString()
17
+ @IsNotEmpty()
18
+ client_id: string;
19
+
20
+ @IsString()
21
+ @IsNotEmpty()
22
+ redirect_uri: string;
23
+ }
@@ -239,6 +239,34 @@ export class ChallengeService {
239
239
  return challenge;
240
240
  }
241
241
 
242
+ /**
243
+ * Cria um challenge de convite de criação de conta vinculado a um
244
+ * `user_identifier` de email já existente e retorna o token em base64
245
+ * (mesmo formato consumido pelo fluxo de reset de senha). A validade usa a
246
+ * setting `account-invite-expiration-minutes` (padrão 7 dias).
247
+ */
248
+ async createAccountInviteChallenge(userIdentifierId: number): Promise<string> {
249
+ const settings = await this.setting.getSettingValues([
250
+ 'account-invite-expiration-minutes',
251
+ ]);
252
+
253
+ const token = await this.token.createOpaqueToken(16);
254
+ const hash = this.security.hashWithPepper(token);
255
+
256
+ await this.prisma.user_identifier_challenge.create({
257
+ data: {
258
+ hash,
259
+ expires_at: new Date(
260
+ Date.now() +
261
+ (settings['account-invite-expiration-minutes'] || 10080) * 60000,
262
+ ),
263
+ user_identifier_id: userIdentifierId,
264
+ },
265
+ });
266
+
267
+ return Buffer.from(token).toString('base64');
268
+ }
269
+
242
270
  private resolveAppUrl(settings: Record<string, any>, app?: string): string {
243
271
  const appUrls: string[] = Array.isArray(settings['app-urls']) ? settings['app-urls'] : [];
244
272
 
@@ -42,6 +42,7 @@ import { IsPinCodeWithSettingConstraint } from './validators/is-pin-code-with-se
42
42
  import { IsStrongPasswordWithSettingsConstraint } from './validators/is-strong-password-with-settings.validator';
43
43
  import { ValidatorServiceLocator } from './validators/service-locator';
44
44
  import { AiInstructionModule } from './ai-instruction/ai-instruction.module';
45
+ import { AuthorizeModule } from './authorize/authorize.module';
45
46
  import { IntegrationProfileModule } from './integration-profile/integration-profile.module';
46
47
  import { WebhookIntegrationModule } from './webhook-integration/webhook-integration.module';
47
48
 
@@ -53,6 +54,7 @@ import { WebhookIntegrationModule } from './webhook-integration/webhook-integrat
53
54
  forwardRef(() => AiModule),
54
55
  forwardRef(() => InstallModule),
55
56
  forwardRef(() => AuthModule),
57
+ forwardRef(() => AuthorizeModule),
56
58
  forwardRef(() => DashboardModule),
57
59
  forwardRef(() => MailSendModule),
58
60
  forwardRef(() => MenuModule),
@@ -1041,6 +1041,25 @@ export class FileService implements OnModuleInit {
1041
1041
  return (await this.getProvider()).tempURL(filepath, expires);
1042
1042
  }
1043
1043
 
1044
+ /**
1045
+ * URL temporária a partir do id do arquivo.
1046
+ * Prefira este método a `tempURL(file.path)`: quando `path` é apenas o
1047
+ * basename, a chave real no provider inclui o prefixo `location`, e passar
1048
+ * `path` cru gera uma URL que resolve para NoSuchKey.
1049
+ */
1050
+ async tempURLById(fileId: number, expires = 3600) {
1051
+ const file = await this.prismaService.file.findFirst({
1052
+ where: { id: fileId },
1053
+ select: { location: true, path: true },
1054
+ });
1055
+
1056
+ if (!file) {
1057
+ throw new NotFoundException(`Not found: ${fileId}`);
1058
+ }
1059
+
1060
+ return this.tempURL(this.resolveProviderPath(file), expires);
1061
+ }
1062
+
1044
1063
  async tempOpenURL(filepath: string, expires = 3600) {
1045
1064
  const url = await this.tempURL(filepath, expires);
1046
1065
 
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { INTEGRATION_SECRET_KEYS, getSensitiveConfigKeys } from './integration-profile.secrets';
5
+
6
+ /**
7
+ * TRAVA DE SEGURANÇA: PROVIDER_FIELDS (admin) e INTEGRATION_SECRET_KEYS (aqui)
8
+ * são dois mapas hardcoded que precisam ficar em sincronia manual. Se um campo
9
+ * sensível do form não estiver no allowlist, ele é gravado NO BANCO EM TEXTO
10
+ * CLARO — sem erro, sem aviso.
11
+ *
12
+ * Esse sync já derrapou antes (o docblock do .secrets.ts apontava para um
13
+ * arquivo onde PROVIDER_FIELDS não existe mais). Este teste transforma o
14
+ * combinado em falha de CI.
15
+ *
16
+ * Lê o fonte do admin em vez de importá-lo: o arquivo é um componente React/TSX
17
+ * com imports de UI que não carregam sob o jest de backend.
18
+ */
19
+ const SHEET_PATH = join(
20
+ __dirname,
21
+ '../../../../apps/admin/src/components/integration-profile/integration-profile-sheet.tsx',
22
+ );
23
+
24
+ /** Extrai { providerSlug: [chaves sensíveis] } do PROVIDER_FIELDS do admin. */
25
+ function parseSensitiveFieldsFromAdmin(): Record<string, string[]> {
26
+ const source = readFileSync(SHEET_PATH, 'utf8');
27
+
28
+ const start = source.indexOf('const PROVIDER_FIELDS');
29
+ expect(start).toBeGreaterThan(-1);
30
+
31
+ // Vai até o fecho do objeto no nível zero: `\n};`
32
+ const end = source.indexOf('\n};', start);
33
+ expect(end).toBeGreaterThan(start);
34
+
35
+ const block = source.slice(start, end);
36
+ const result: Record<string, string[]> = {};
37
+
38
+ // Cada provider começa com ` slug: [` ou ` 'slug': [` na indentação de 2.
39
+ const providerRegex = /\n {2}'?([a-z0-9_-]+)'?: \[/g;
40
+ const providers: Array<{ slug: string; from: number }> = [];
41
+ let match: RegExpExecArray | null;
42
+
43
+ while ((match = providerRegex.exec(block)) !== null) {
44
+ providers.push({ slug: match[1], from: match.index });
45
+ }
46
+
47
+ providers.forEach((provider, index) => {
48
+ const chunk = block.slice(
49
+ provider.from,
50
+ index + 1 < providers.length ? providers[index + 1].from : block.length,
51
+ );
52
+
53
+ const fields: string[] = [];
54
+ // Cada field é um objeto com key e type; casa os dois em qualquer ordem.
55
+ const fieldRegex = /key:\s*'([^']+)'[\s\S]*?type:\s*'([^']+)'/g;
56
+ let fieldMatch: RegExpExecArray | null;
57
+
58
+ while ((fieldMatch = fieldRegex.exec(chunk)) !== null) {
59
+ const [, key, type] = fieldMatch;
60
+ if (type === 'password' || type === 'file') {
61
+ fields.push(key);
62
+ }
63
+ }
64
+
65
+ result[provider.slug] = fields;
66
+ });
67
+
68
+ return result;
69
+ }
70
+
71
+ describe('INTEGRATION_SECRET_KEYS is in sync with the admin form', () => {
72
+ const adminSensitive = parseSensitiveFieldsFromAdmin();
73
+
74
+ it('parses the admin PROVIDER_FIELDS map', () => {
75
+ // Se o parser quebrar, o resto passaria vacuamente. Fixa um piso conhecido.
76
+ expect(Object.keys(adminSensitive).length).toBeGreaterThan(5);
77
+ expect(adminSensitive.smtp).toContain('password');
78
+ });
79
+
80
+ it.each(Object.entries(adminSensitive).filter(([, fields]) => fields.length))(
81
+ 'provider "%s" has every password/file field in the allowlist',
82
+ (providerSlug, fields) => {
83
+ const allowlisted = getSensitiveConfigKeys(providerSlug);
84
+ const missing = (fields as string[]).filter((key) => !allowlisted.includes(key));
85
+
86
+ // Uma chave listada aqui seria gravada em texto claro no banco.
87
+ expect(missing).toEqual([]);
88
+ },
89
+ );
90
+
91
+ it('keeps the A1 certificate and its password encrypted', () => {
92
+ // O caso que mais dói: quem tem o PFX + a senha emite nota como a empresa.
93
+ expect(getSensitiveConfigKeys('nfse-national')).toEqual(
94
+ expect.arrayContaining(['certificate_pfx_base64', 'certificate_password']),
95
+ );
96
+ });
97
+
98
+ it('does not treat public keys as secrets', () => {
99
+ // site_key/public_key/access_key_id contêm "key" mas são públicos. Cifrá-los
100
+ // por heurística de nome quebraria integrações silenciosamente.
101
+ expect(getSensitiveConfigKeys('recaptcha')).not.toContain('site_key');
102
+ expect(getSensitiveConfigKeys('s3')).not.toContain('access_key_id');
103
+ });
104
+
105
+ it('returns an empty list for an unknown provider', () => {
106
+ expect(getSensitiveConfigKeys('does-not-exist')).toEqual([]);
107
+ });
108
+
109
+ it('has no duplicate keys per provider', () => {
110
+ for (const [slug, keys] of Object.entries(INTEGRATION_SECRET_KEYS)) {
111
+ expect(new Set(keys).size).toBe(keys.length);
112
+ }
113
+ });
114
+ });
@@ -4,8 +4,12 @@
4
4
  * SEGURANÇA-CRÍTICO: este mapa é a fonte única de verdade do que é cifrado em repouso
5
5
  * (integration-credential-crypto.service) e mascarado na exportação/UI. Qualquer chave
6
6
  * esquecida aqui NÃO será cifrada. Deve ser mantido em sincronia com os campos
7
- * `type: 'password'` do form do admin (PROVIDER_FIELDS em
8
- * apps/admin/src/app/(app)/(libraries)/core/integration/profiles/page.tsx).
7
+ * `type: 'password'` e `type: 'file'` do form do admin (PROVIDER_FIELDS em
8
+ * apps/admin/src/components/integration-profile/integration-profile-sheet.tsx).
9
+ *
10
+ * O sync é manual e já derrapou uma vez (este comentário apontava para um arquivo
11
+ * onde PROVIDER_FIELDS não existe mais). Por isso existe um teste que falha se um
12
+ * campo sensível do form não tiver entrada aqui: integration-profile.secrets.spec.ts.
9
13
  *
10
14
  * Não usar heurística por nome de chave: campos públicos como `site_key`, `public_key` e
11
15
  * `access_key_id` contêm "key" mas NÃO são segredos.
@@ -15,6 +19,7 @@ export const INTEGRATION_SECRET_KEYS: Record<string, string[]> = {
15
19
  smtp: ['password'],
16
20
  gmail: ['client_secret', 'refresh_token'],
17
21
  ses: ['secret_access_key'],
22
+ resend: ['api_token', 'webhook_secret'],
18
23
  // Mensageria
19
24
  'evolution-api': ['token'],
20
25
  'whatsapp-official': ['access_token'],
@@ -47,6 +52,10 @@ export const INTEGRATION_SECRET_KEYS: Record<string, string[]> = {
47
52
  // Infraestrutura (autoscaling)
48
53
  digitalocean: ['api_token'],
49
54
  kubernetes: ['token', 'ca_cert'],
55
+ // Fiscal
56
+ // O certificado A1 é a chave privada da empresa: quem tem o PFX + a senha
57
+ // emite nota em nome dela. Os dois DEVEM estar aqui.
58
+ 'nfse-national': ['certificate_pfx_base64', 'certificate_password'],
50
59
  };
51
60
 
52
61
  /** Chaves sensíveis de um provider (vazio se desconhecido). */
@@ -472,6 +472,25 @@ export class IntegrationProfileService {
472
472
  ) as Record<string, unknown>;
473
473
  }
474
474
 
475
+ // Resend não usa SMTP/from_email: valida credenciais/domínio próprios.
476
+ if (provider.slug === 'resend') {
477
+ const resendCfg = (effectiveConfig as Record<string, unknown> | null) ?? {};
478
+ const apiToken = String(resendCfg.api_token ?? '').trim();
479
+ const domain = String(resendCfg.domain ?? '').trim();
480
+
481
+ if (!apiToken) {
482
+ throw new BadRequestException('Resend API token is required.');
483
+ }
484
+ if (!domain) {
485
+ throw new BadRequestException('Resend sending domain is required.');
486
+ }
487
+
488
+ return {
489
+ success: true,
490
+ destination: domain,
491
+ };
492
+ }
493
+
475
494
  buildMailConfigFromIntegration(provider.slug, effectiveConfig);
476
495
 
477
496
  const destination = String(
@@ -38,11 +38,51 @@ export function buildMailConfigFromIntegration(
38
38
  secretAccessKey: String(cfg.secret_access_key ?? ''),
39
39
  from: String(cfg.from_email ?? ''),
40
40
  };
41
+ case 'resend':
42
+ // Resend usa a API HTTP própria (não nodemailer). Retornamos um marcador
43
+ // tipado para os consumidores ramificarem; o inbox usa
44
+ // `buildResendConfigFromIntegration` diretamente.
45
+ return {
46
+ global: true,
47
+ type: 'RESEND' as const,
48
+ apiToken: String(cfg.api_token ?? ''),
49
+ domain: String(cfg.domain ?? ''),
50
+ region: String(cfg.region ?? ''),
51
+ from: String(cfg.from_email ?? ''),
52
+ };
41
53
  default:
42
54
  throw new BadRequestException(`Unsupported mail provider: ${providerSlug}`);
43
55
  }
44
56
  }
45
57
 
58
+ export type ResendIntegrationConfig = {
59
+ apiToken: string;
60
+ domain: string;
61
+ region?: string;
62
+ webhookSecret?: string;
63
+ providerDomainId?: string;
64
+ fromName?: string;
65
+ };
66
+
67
+ /** Maps an integration_profile config JSON to the Resend credentials/domain. */
68
+ export function buildResendConfigFromIntegration(
69
+ config: unknown,
70
+ ): ResendIntegrationConfig {
71
+ const cfg = (config as Cfg) ?? {};
72
+ return {
73
+ apiToken: String(cfg.api_token ?? '').trim(),
74
+ domain: String(cfg.domain ?? '').trim(),
75
+ ...(cfg.region ? { region: String(cfg.region).trim() } : {}),
76
+ ...(cfg.webhook_secret
77
+ ? { webhookSecret: String(cfg.webhook_secret).trim() }
78
+ : {}),
79
+ ...(cfg.provider_domain_id
80
+ ? { providerDomainId: String(cfg.provider_domain_id).trim() }
81
+ : {}),
82
+ ...(cfg.from_name ? { fromName: String(cfg.from_name).trim() } : {}),
83
+ };
84
+ }
85
+
46
86
  export function getFromAddress(config: unknown): string {
47
87
  const cfg = (config as Cfg) ?? {};
48
88
  const fromEmail = String(cfg.from_email ?? '');
@@ -1,7 +1,7 @@
1
- import { SetMetadata } from '@nestjs/common';
2
-
3
- export const ALLOW_MCP_TOKEN = 'allowMcpToken';
4
-
5
- export function AllowMcpToken() {
6
- return SetMetadata(ALLOW_MCP_TOKEN, true);
7
- }
1
+ import { SetMetadata } from '@nestjs/common';
2
+
3
+ export const ALLOW_MCP_TOKEN = 'allowMcpToken';
4
+
5
+ export function AllowMcpToken() {
6
+ return SetMetadata(ALLOW_MCP_TOKEN, true);
7
+ }
@@ -1,5 +1,9 @@
1
1
  export interface OAuthProvider {
2
- getAuthUrl(callbackPath: string): Promise<string>;
2
+ getAuthUrl(
3
+ callbackPath: string,
4
+ flow?: 'login' | 'register' | 'connect',
5
+ app?: string,
6
+ ): Promise<string>;
3
7
  getProfile(code: string, type?: string, redirectUri?: string): Promise<any>;
4
8
  getProviderType(): string;
5
9
  }
@@ -97,6 +97,95 @@ export class OAuthController {
97
97
  return res.redirect(redirectURL);
98
98
  }
99
99
 
100
+ /**
101
+ * Server-side bounce for Microsoft Entra ID. Entra's authorization `code` is ~2KB
102
+ * (forced `offline_access`), which trips front-end ingress buffers when forwarded
103
+ * in an app URL. So Entra's web `redirect_uri` points here: we exchange the code
104
+ * server-side and redirect the browser to the initiating app with only a short
105
+ * single-use `ref` (never the giant code).
106
+ */
107
+ @Public()
108
+ @Get('microsoft-entra-id/callback')
109
+ async microsoftEntraIdCallback(
110
+ @Query('code') code: string,
111
+ @Query('state') state: string,
112
+ @Headers('user-agent') userAgent: string,
113
+ @Ip() ipAddress: string,
114
+ @Res() res,
115
+ ) {
116
+ this.logOAuthRequest('callback-web-ref', 'microsoft-entra-id', {
117
+ ipAddress,
118
+ userAgent,
119
+ code,
120
+ });
121
+
122
+ const forward = await this.service.resolveWebForward(state);
123
+ const flow = (forward?.flow ?? 'login') as 'login' | 'register' | 'connect';
124
+ const { url: frontendURL = '' } = await this.settingService.getSettingValues(['url']);
125
+ const base = forward?.origin || frontendURL;
126
+
127
+ const ref = await this.service.beginWebRefCallback({
128
+ provider: 'microsoft-entra-id' as user_account_provider_52222e2ecb_enum,
129
+ code,
130
+ state,
131
+ flow,
132
+ appOrigin: forward?.origin ?? null,
133
+ });
134
+
135
+ return res.redirect(
136
+ `${base}/callback/microsoft-entra-id/${flow}?ref=${encodeURIComponent(ref)}`,
137
+ );
138
+ }
139
+
140
+ /** Redeems a login/register `ref` from the Entra server-side bounce (public). */
141
+ @Public()
142
+ @Get('microsoft-entra-id/redeem')
143
+ async microsoftEntraIdRedeem(
144
+ @Locale() locale: string,
145
+ @Ip() ipAddress: string,
146
+ @Headers('user-agent') userAgent: string,
147
+ @Headers('origin') origin: string,
148
+ @Headers('referer') referer: string,
149
+ @Query('ref') ref: string,
150
+ @Res({ passthrough: true }) res,
151
+ ) {
152
+ return this.service.redeemWebRef({
153
+ res,
154
+ ref,
155
+ provider: 'microsoft-entra-id' as user_account_provider_52222e2ecb_enum,
156
+ locale,
157
+ ipAddress,
158
+ userAgent,
159
+ requestOrigin: origin,
160
+ requestReferer: referer,
161
+ });
162
+ }
163
+
164
+ /** Redeems a connect `ref` (authenticated: connect binds to the current user). */
165
+ @Get('microsoft-entra-id/redeem/connect')
166
+ async microsoftEntraIdRedeemConnect(
167
+ @Locale() locale: string,
168
+ @Ip() ipAddress: string,
169
+ @Headers('user-agent') userAgent: string,
170
+ @Headers('origin') origin: string,
171
+ @Headers('referer') referer: string,
172
+ @User() { id },
173
+ @Query('ref') ref: string,
174
+ @Res({ passthrough: true }) res,
175
+ ) {
176
+ return this.service.redeemWebRef({
177
+ res,
178
+ ref,
179
+ provider: 'microsoft-entra-id' as user_account_provider_52222e2ecb_enum,
180
+ locale,
181
+ ipAddress,
182
+ userAgent,
183
+ userId: id,
184
+ requestOrigin: origin,
185
+ requestReferer: referer,
186
+ });
187
+ }
188
+
100
189
  @Public()
101
190
  @Get(':provider/login')
102
191
  async login(