@hed-hog/core 0.0.385 → 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.
- package/dist/app-command/app-command.registry.d.ts +2 -0
- package/dist/app-command/app-command.registry.d.ts.map +1 -1
- package/dist/app-command/app-command.registry.js.map +1 -1
- package/dist/auth/auth.controller.d.ts +22 -0
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +27 -0
- package/dist/auth/auth.controller.js.map +1 -1
- package/dist/auth/auth.service.d.ts +50 -0
- package/dist/auth/auth.service.d.ts.map +1 -1
- package/dist/auth/auth.service.js +108 -0
- package/dist/auth/auth.service.js.map +1 -1
- package/dist/auth/dto/complete-account.dto.d.ts +9 -0
- package/dist/auth/dto/complete-account.dto.d.ts.map +1 -0
- package/dist/auth/dto/complete-account.dto.js +43 -0
- package/dist/auth/dto/complete-account.dto.js.map +1 -0
- package/dist/authorize/authorize.controller.d.ts +45 -0
- package/dist/authorize/authorize.controller.d.ts.map +1 -0
- package/dist/authorize/authorize.controller.js +120 -0
- package/dist/authorize/authorize.controller.js.map +1 -0
- package/dist/authorize/authorize.module.d.ts +8 -0
- package/dist/authorize/authorize.module.d.ts.map +1 -0
- package/dist/authorize/authorize.module.js +36 -0
- package/dist/authorize/authorize.module.js.map +1 -0
- package/dist/authorize/authorize.service.d.ts +79 -0
- package/dist/authorize/authorize.service.d.ts.map +1 -0
- package/dist/authorize/authorize.service.js +196 -0
- package/dist/authorize/authorize.service.js.map +1 -0
- package/dist/authorize/dto/authorize-info.dto.d.ts +8 -0
- package/dist/authorize/dto/authorize-info.dto.d.ts.map +1 -0
- package/dist/authorize/dto/authorize-info.dto.js +42 -0
- package/dist/authorize/dto/authorize-info.dto.js.map +1 -0
- package/dist/authorize/dto/consent.dto.d.ts +8 -0
- package/dist/authorize/dto/consent.dto.d.ts.map +1 -0
- package/dist/authorize/dto/consent.dto.js +42 -0
- package/dist/authorize/dto/consent.dto.js.map +1 -0
- package/dist/authorize/dto/token.dto.d.ts +8 -0
- package/dist/authorize/dto/token.dto.d.ts.map +1 -0
- package/dist/authorize/dto/token.dto.js +42 -0
- package/dist/authorize/dto/token.dto.js.map +1 -0
- package/dist/challenge/challenge.service.d.ts +7 -0
- package/dist/challenge/challenge.service.d.ts.map +1 -1
- package/dist/challenge/challenge.service.js +22 -0
- package/dist/challenge/challenge.service.js.map +1 -1
- package/dist/core.module.d.ts.map +1 -1
- package/dist/core.module.js +2 -0
- package/dist/core.module.js.map +1 -1
- package/dist/file/file.service.d.ts +7 -0
- package/dist/file/file.service.d.ts.map +1 -1
- package/dist/file/file.service.js +16 -0
- package/dist/file/file.service.js.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.d.ts +6 -2
- package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.js +11 -2
- package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
- package/dist/integration-profile/integration-profile.service.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.service.js +18 -2
- package/dist/integration-profile/integration-profile.service.js.map +1 -1
- package/dist/integration-profile/integration-profile.utils.d.ts +10 -0
- package/dist/integration-profile/integration-profile.utils.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.utils.js +24 -1
- package/dist/integration-profile/integration-profile.utils.js.map +1 -1
- package/dist/oauth/interfaces/OAuthProvider.d.ts +1 -1
- package/dist/oauth/interfaces/OAuthProvider.d.ts.map +1 -1
- package/dist/oauth/oauth.controller.d.ts +26 -0
- package/dist/oauth/oauth.controller.d.ts.map +1 -1
- package/dist/oauth/oauth.controller.js +94 -0
- package/dist/oauth/oauth.controller.js.map +1 -1
- package/dist/oauth/oauth.service.d.ts +39 -1
- package/dist/oauth/oauth.service.d.ts.map +1 -1
- package/dist/oauth/oauth.service.js +115 -7
- package/dist/oauth/oauth.service.js.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +5 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.js +37 -6
- package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
- package/dist/profile/profile.controller.d.ts +1 -1
- package/dist/profile/profile.service.d.ts +1 -1
- package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -1
- package/dist/webhook-integration/webhook-integration.service.js +7 -1
- package/dist/webhook-integration/webhook-integration.service.js.map +1 -1
- package/hedhog/data/integration_provider.yaml +8 -0
- package/hedhog/data/integration_type.yaml +6 -0
- package/hedhog/data/mail.yaml +39 -0
- package/hedhog/data/route.yaml +12 -0
- package/hedhog/data/setting_group.yaml +15 -1
- package/hedhog/frontend/app/account/components/regenerate-codes-dialog.tsx.ejs +11 -7
- package/hedhog/frontend/app/account/components/verify-before-add-dialog.tsx.ejs +9 -7
- package/hedhog/frontend/app/account/hooks/use-mfa-setup.ts.ejs +6 -2
- package/hedhog/frontend/app/dashboard/dashboard-home-tabs.tsx.ejs +130 -34
- package/hedhog/frontend/messages/en.json +33 -1
- package/hedhog/frontend/messages/pt.json +33 -1
- package/package.json +10 -5
- package/src/app-command/app-command.registry.ts +2 -0
- package/src/auth/auth.controller.ts +21 -0
- package/src/auth/auth.service.ts +133 -0
- package/src/auth/dto/complete-account.dto.ts +24 -0
- package/src/authorize/authorize.controller.ts +99 -0
- package/src/authorize/authorize.module.ts +23 -0
- package/src/authorize/authorize.service.spec.ts +307 -0
- package/src/authorize/authorize.service.ts +245 -0
- package/src/authorize/dto/authorize-info.dto.ts +23 -0
- package/src/authorize/dto/consent.dto.ts +23 -0
- package/src/authorize/dto/token.dto.ts +23 -0
- package/src/challenge/challenge.service.ts +28 -0
- package/src/core.module.ts +2 -0
- package/src/file/file.service.ts +19 -0
- package/src/integration-profile/integration-profile.secrets.spec.ts +114 -0
- package/src/integration-profile/integration-profile.secrets.ts +11 -2
- package/src/integration-profile/integration-profile.service.ts +19 -0
- package/src/integration-profile/integration-profile.utils.ts +40 -0
- package/src/oauth/interfaces/OAuthProvider.ts +5 -1
- package/src/oauth/oauth.controller.ts +89 -0
- package/src/oauth/oauth.service.ts +190 -8
- package/src/oauth/providers/microsoft-entra-id.provider.ts +45 -5
- package/src/webhook-integration/webhook-integration.service.ts +18 -2
|
@@ -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/
|
|
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,5 +1,9 @@
|
|
|
1
1
|
export interface OAuthProvider {
|
|
2
|
-
getAuthUrl(
|
|
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(
|
|
@@ -89,12 +89,17 @@ export class OAuthService {
|
|
|
89
89
|
this.providers.set(linkedin.getProviderType(), linkedin);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
async getAuthUrl(
|
|
92
|
+
async getAuthUrl(
|
|
93
|
+
provider: user_account_provider_52222e2ecb_enum,
|
|
94
|
+
callbackPath: string,
|
|
95
|
+
flow?: 'login' | 'register' | 'connect',
|
|
96
|
+
app?: string,
|
|
97
|
+
) {
|
|
93
98
|
const enabled = await this.isProviderEnabled(provider);
|
|
94
99
|
if (!enabled) {
|
|
95
100
|
throw new BadRequestException(`OAuth provider ${provider} is not enabled.`);
|
|
96
101
|
}
|
|
97
|
-
return this.getProvider(provider).getAuthUrl(callbackPath);
|
|
102
|
+
return this.getProvider(provider).getAuthUrl(callbackPath, flow, app);
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
async getMobileAuthUrl(
|
|
@@ -137,15 +142,16 @@ export class OAuthService {
|
|
|
137
142
|
flow: 'login' | 'register' | 'connect',
|
|
138
143
|
redirectApp?: string,
|
|
139
144
|
) {
|
|
145
|
+
// `self` means "the hub handles it locally"; a resolvable app key means the hub
|
|
146
|
+
// forwards the callback back to that app (origin-bound).
|
|
147
|
+
const app = this.sanitizeAppKey(redirectApp) ?? OAuthService.WEB_STATE_SELF_APP;
|
|
148
|
+
|
|
140
149
|
// Single, flow-less callback per provider: the flow (and the initiating app)
|
|
141
150
|
// travel in the signed state, so only ONE redirect URI needs to be registered
|
|
142
|
-
// per provider in its OAuth console.
|
|
151
|
+
// per provider in its OAuth console. `flow`/`app` are forwarded so providers that
|
|
152
|
+
// need them (e.g. Microsoft Entra ID's backend bounce + `prompt=consent`) can adapt.
|
|
143
153
|
const callbackPath = `/callback/${String(provider)}`;
|
|
144
|
-
const authUrl = await this.getAuthUrl(provider, callbackPath);
|
|
145
|
-
|
|
146
|
-
// Always carry the flow. `self` means "the hub handles it locally"; a resolvable
|
|
147
|
-
// app key means the hub forwards the callback back to that app (origin-bound).
|
|
148
|
-
const app = this.sanitizeAppKey(redirectApp) ?? OAuthService.WEB_STATE_SELF_APP;
|
|
154
|
+
const authUrl = await this.getAuthUrl(provider, callbackPath, flow, app);
|
|
149
155
|
|
|
150
156
|
const url = new URL(authUrl);
|
|
151
157
|
url.searchParams.set('state', this.buildWebState(app, flow));
|
|
@@ -549,6 +555,182 @@ export class OAuthService {
|
|
|
549
555
|
return record?.redirect_uri ?? null;
|
|
550
556
|
}
|
|
551
557
|
|
|
558
|
+
/** TTL for the short web-ref bounce token (Entra). Short: it briefly holds an encrypted profile. */
|
|
559
|
+
private readonly webRefTokenTtlMs = 2 * 60 * 1000;
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Server-side bounce for providers whose authorization `code` is too large to
|
|
563
|
+
* safely ride a front-end app URL (Microsoft Entra ID). Exchanges the code here
|
|
564
|
+
* (once, under the callback lock), stores the resulting profile encrypted under
|
|
565
|
+
* an opaque single-use `ref`, and returns that `ref`. The app later redeems it via
|
|
566
|
+
* `redeemWebRef`, so the giant code never reaches a front-end ingress.
|
|
567
|
+
*/
|
|
568
|
+
async beginWebRefCallback({
|
|
569
|
+
provider,
|
|
570
|
+
code,
|
|
571
|
+
state,
|
|
572
|
+
flow,
|
|
573
|
+
appOrigin,
|
|
574
|
+
}: {
|
|
575
|
+
provider: user_account_provider_52222e2ecb_enum;
|
|
576
|
+
code: string;
|
|
577
|
+
state?: string;
|
|
578
|
+
flow: 'login' | 'register' | 'connect';
|
|
579
|
+
appOrigin: string | null;
|
|
580
|
+
}): Promise<string> {
|
|
581
|
+
if (!code) {
|
|
582
|
+
throw new BadRequestException('Authorization code is required.');
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Match the redirect_uri used at authorize time (the API callback).
|
|
586
|
+
const settings = await this.setting.getSettingValues(['api-url']);
|
|
587
|
+
const apiRedirectUri = new URL(
|
|
588
|
+
'/oauth/microsoft-entra-id/callback',
|
|
589
|
+
String(settings['api-url'] ?? ''),
|
|
590
|
+
).toString();
|
|
591
|
+
|
|
592
|
+
const callbackKey = this.buildCallbackKey(provider, flow, code);
|
|
593
|
+
const profile = await this.callbackCoordinator.execute(callbackKey, async () =>
|
|
594
|
+
this.getProvider(provider).getProfile(code, flow, apiRedirectUri),
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
const token = this.security.randomOpaque(24);
|
|
598
|
+
const signature = this.security.signHmacSha256(`oauth-web-ref:${token}`);
|
|
599
|
+
const tokenHash = this.security.hashSha256(token);
|
|
600
|
+
const expiresAt = new Date(Date.now() + this.webRefTokenTtlMs);
|
|
601
|
+
const profileEnc = this.security.encrypt(JSON.stringify(profile));
|
|
602
|
+
|
|
603
|
+
await this.prisma.$executeRaw(
|
|
604
|
+
Prisma.sql`
|
|
605
|
+
INSERT INTO oauth_web_ref_token (
|
|
606
|
+
token_hash, provider, flow_type, app_origin, profile_enc, expires_at, created_at
|
|
607
|
+
) VALUES (
|
|
608
|
+
${tokenHash},
|
|
609
|
+
${String(provider).toLowerCase()},
|
|
610
|
+
${flow},
|
|
611
|
+
${appOrigin},
|
|
612
|
+
${profileEnc},
|
|
613
|
+
${expiresAt},
|
|
614
|
+
NOW()
|
|
615
|
+
)
|
|
616
|
+
`,
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
return `${token}.${signature}`;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Redeems a `ref` issued by `beginWebRefCallback`: single-use consume, origin-bind
|
|
624
|
+
* to the initiating app, then finalize the flow (sets the refresh cookie via `res`
|
|
625
|
+
* and returns `{ accessToken }`) — byte-for-byte the same response as `callback/login`.
|
|
626
|
+
*/
|
|
627
|
+
async redeemWebRef({
|
|
628
|
+
res,
|
|
629
|
+
ref,
|
|
630
|
+
provider,
|
|
631
|
+
locale,
|
|
632
|
+
ipAddress,
|
|
633
|
+
userAgent,
|
|
634
|
+
userId,
|
|
635
|
+
requestOrigin,
|
|
636
|
+
requestReferer,
|
|
637
|
+
}: {
|
|
638
|
+
res: Response;
|
|
639
|
+
ref: string;
|
|
640
|
+
provider: user_account_provider_52222e2ecb_enum;
|
|
641
|
+
locale: string;
|
|
642
|
+
ipAddress: string;
|
|
643
|
+
userAgent: string;
|
|
644
|
+
userId?: number;
|
|
645
|
+
requestOrigin?: string;
|
|
646
|
+
requestReferer?: string;
|
|
647
|
+
}) {
|
|
648
|
+
const parts = String(ref ?? '').split('.');
|
|
649
|
+
const token = parts[0];
|
|
650
|
+
const signature = parts[1];
|
|
651
|
+
|
|
652
|
+
if (!token || !signature) {
|
|
653
|
+
throw new BadRequestException(
|
|
654
|
+
getLocaleText('oauth.callback.invalidState', locale, 'OAuth reference is invalid or expired. Start the sign-in flow again.'),
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (!this.security.verifyHmacSha256(`oauth-web-ref:${token}`, signature)) {
|
|
659
|
+
throw new BadRequestException(
|
|
660
|
+
getLocaleText('oauth.callback.invalidState', locale, 'OAuth reference is invalid or expired. Start the sign-in flow again.'),
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const tokenHash = this.security.hashSha256(token);
|
|
665
|
+
const rows = await this.prisma.$queryRaw<
|
|
666
|
+
Array<{ profile_enc: string; flow_type: string; app_origin: string | null }>
|
|
667
|
+
>(
|
|
668
|
+
Prisma.sql`
|
|
669
|
+
UPDATE oauth_web_ref_token
|
|
670
|
+
SET consumed_at = NOW()
|
|
671
|
+
WHERE token_hash = ${tokenHash}
|
|
672
|
+
AND provider = ${String(provider).toLowerCase()}
|
|
673
|
+
AND expires_at > NOW()
|
|
674
|
+
AND consumed_at IS NULL
|
|
675
|
+
RETURNING profile_enc, flow_type, app_origin
|
|
676
|
+
`,
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
const row = rows[0];
|
|
680
|
+
if (!row) {
|
|
681
|
+
throw new BadRequestException(
|
|
682
|
+
getLocaleText('oauth.callback.alreadyProcessed', locale, 'This OAuth callback was already processed. Start the sign-in flow again.'),
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const flow = row.flow_type as 'login' | 'register' | 'connect';
|
|
687
|
+
|
|
688
|
+
// Origin-bind at redeem (the backend callback's Origin was Microsoft, so the
|
|
689
|
+
// usual handleCallback binding can't run there). Skip only when no app origin
|
|
690
|
+
// was recorded (the hub's own "self" flow).
|
|
691
|
+
if (row.app_origin) {
|
|
692
|
+
const expectedOrigin = this.normalizeOrigin(row.app_origin);
|
|
693
|
+
const boundOrigin =
|
|
694
|
+
this.normalizeOrigin(requestOrigin) ?? this.normalizeOrigin(requestReferer);
|
|
695
|
+
if (!boundOrigin || expectedOrigin !== boundOrigin) {
|
|
696
|
+
throw new BadRequestException(
|
|
697
|
+
getLocaleText('oauth.callback.invalidState', locale, 'OAuth callback origin is invalid. Start the sign-in flow again.'),
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (flow === 'connect' && !userId) {
|
|
703
|
+
throw new BadRequestException(
|
|
704
|
+
getLocaleText('oauth.callback.invalidState', locale, 'OAuth reference is invalid or expired. Start the sign-in flow again.'),
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
let profile: any;
|
|
709
|
+
try {
|
|
710
|
+
profile = JSON.parse(this.security.decrypt(row.profile_enc));
|
|
711
|
+
} catch {
|
|
712
|
+
throw new BadRequestException(
|
|
713
|
+
getLocaleText('oauth.callback.invalidState', locale, 'OAuth reference is invalid or expired. Start the sign-in flow again.'),
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
return await this.finalizeCallback({
|
|
719
|
+
res,
|
|
720
|
+
locale,
|
|
721
|
+
ipAddress,
|
|
722
|
+
userAgent,
|
|
723
|
+
provider,
|
|
724
|
+
type: flow,
|
|
725
|
+
profile,
|
|
726
|
+
userId,
|
|
727
|
+
includeRefreshTokenInBody: false,
|
|
728
|
+
});
|
|
729
|
+
} catch (error) {
|
|
730
|
+
throw this.mapCallbackError(locale, provider, flow, error);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
552
734
|
private async finalizeCallback({
|
|
553
735
|
res,
|
|
554
736
|
locale,
|
|
@@ -35,10 +35,22 @@ export class MicrosoftEntraIdProvider extends BaseOAuthProvider {
|
|
|
35
35
|
return 'MICROSOFT-ENTRA-ID';
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Fixed API callback used by the web flow so the ~2KB Entra `code` is received
|
|
39
|
+
// and exchanged server-side (never forwarded into a front-end app URL).
|
|
40
|
+
private static readonly API_CALLBACK_PATH = '/oauth/microsoft-entra-id/callback';
|
|
41
|
+
|
|
42
|
+
// Apps whose front-end callback pages know how to redeem the server-side bounce
|
|
43
|
+
// `ref`. Only these use the API bounce; every other web app (and mobile) keeps the
|
|
44
|
+
// legacy front-end redirect, so this change is additive and non-regressing. Add an
|
|
45
|
+
// app here once its callback pages handle `?ref=`. `self` is the hub (admin), which
|
|
46
|
+
// initiates Entra without a `redirectApp` and thus resolves to the sentinel `self`.
|
|
47
|
+
private static readonly API_BOUNCE_APPS = new Set(['class', 'self', 'training', 'partners']);
|
|
48
|
+
|
|
38
49
|
private async loadConfig() {
|
|
39
50
|
const settings = await this.setting.getSettingValues([
|
|
40
51
|
'oauth-microsoft-entra-id-profile-id',
|
|
41
52
|
'url',
|
|
53
|
+
'api-url',
|
|
42
54
|
]);
|
|
43
55
|
const profileSlug = String(settings['oauth-microsoft-entra-id-profile-id'] ?? '').trim();
|
|
44
56
|
if (!profileSlug) throw new BadRequestException('Microsoft Entra ID OAuth profile not configured.');
|
|
@@ -56,19 +68,47 @@ export class MicrosoftEntraIdProvider extends BaseOAuthProvider {
|
|
|
56
68
|
if (!tenantId) {
|
|
57
69
|
throw new BadRequestException('Microsoft Entra ID OAuth profile is missing tenant_id.');
|
|
58
70
|
}
|
|
59
|
-
return {
|
|
71
|
+
return {
|
|
72
|
+
clientId,
|
|
73
|
+
clientSecret,
|
|
74
|
+
tenantId,
|
|
75
|
+
baseUrl: String(settings['url'] ?? ''),
|
|
76
|
+
apiUrl: String(settings['api-url'] ?? ''),
|
|
77
|
+
};
|
|
60
78
|
}
|
|
61
79
|
|
|
62
|
-
|
|
63
|
-
|
|
80
|
+
/** Absolute redirect URI the web flow registers/exchanges against (the API). */
|
|
81
|
+
private buildApiRedirectUri(apiUrl: string) {
|
|
82
|
+
if (!String(apiUrl).trim()) {
|
|
83
|
+
throw new BadRequestException(
|
|
84
|
+
'Microsoft Entra ID OAuth requires the `api-url` setting for the web callback.',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return this.buildRedirectUri(apiUrl, MicrosoftEntraIdProvider.API_CALLBACK_PATH);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async getAuthUrl(
|
|
91
|
+
callbackPath: string,
|
|
92
|
+
flow?: 'login' | 'register' | 'connect',
|
|
93
|
+
app?: string,
|
|
94
|
+
) {
|
|
95
|
+
const { clientId, tenantId, baseUrl, apiUrl } = await this.loadConfig();
|
|
64
96
|
const settings = await this.setting.getSettingValues(['oauth-microsoft-entra-id-scopes']);
|
|
65
97
|
const baseScopes: string[] = settings['oauth-microsoft-entra-id-scopes'] ?? ['openid', 'profile', 'email'];
|
|
66
98
|
const scopes = baseScopes.includes('offline_access')
|
|
67
99
|
? baseScopes
|
|
68
100
|
: [...baseScopes, 'offline_access'];
|
|
69
101
|
|
|
70
|
-
|
|
71
|
-
|
|
102
|
+
// Only apps that can redeem a `ref` bounce through the API (where the ~2KB code
|
|
103
|
+
// is exchanged server-side); every other web app and mobile keep the front-end
|
|
104
|
+
// callback, so this stays additive and non-regressing.
|
|
105
|
+
const useApiBounce = !!app && MicrosoftEntraIdProvider.API_BOUNCE_APPS.has(app);
|
|
106
|
+
const redirectURI = useApiBounce
|
|
107
|
+
? this.buildApiRedirectUri(apiUrl)
|
|
108
|
+
: this.buildRedirectUri(baseUrl, callbackPath);
|
|
109
|
+
const isConnectFlow = useApiBounce
|
|
110
|
+
? flow === 'connect'
|
|
111
|
+
: callbackPath.includes('/connect');
|
|
72
112
|
const params = new URLSearchParams({
|
|
73
113
|
client_id: clientId,
|
|
74
114
|
redirect_uri: redirectURI,
|