@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.
Files changed (115) 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 +22 -0
  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 +50 -0
  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/oauth/interfaces/OAuthProvider.d.ts +1 -1
  63. package/dist/oauth/interfaces/OAuthProvider.d.ts.map +1 -1
  64. package/dist/oauth/oauth.controller.d.ts +26 -0
  65. package/dist/oauth/oauth.controller.d.ts.map +1 -1
  66. package/dist/oauth/oauth.controller.js +94 -0
  67. package/dist/oauth/oauth.controller.js.map +1 -1
  68. package/dist/oauth/oauth.service.d.ts +39 -1
  69. package/dist/oauth/oauth.service.d.ts.map +1 -1
  70. package/dist/oauth/oauth.service.js +115 -7
  71. package/dist/oauth/oauth.service.js.map +1 -1
  72. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +5 -1
  73. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
  74. package/dist/oauth/providers/microsoft-entra-id.provider.js +37 -6
  75. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  76. package/dist/profile/profile.controller.d.ts +1 -1
  77. package/dist/profile/profile.service.d.ts +1 -1
  78. package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -1
  79. package/dist/webhook-integration/webhook-integration.service.js +7 -1
  80. package/dist/webhook-integration/webhook-integration.service.js.map +1 -1
  81. package/hedhog/data/integration_provider.yaml +8 -0
  82. package/hedhog/data/integration_type.yaml +6 -0
  83. package/hedhog/data/mail.yaml +39 -0
  84. package/hedhog/data/route.yaml +12 -0
  85. package/hedhog/data/setting_group.yaml +15 -1
  86. package/hedhog/frontend/app/account/components/regenerate-codes-dialog.tsx.ejs +11 -7
  87. package/hedhog/frontend/app/account/components/verify-before-add-dialog.tsx.ejs +9 -7
  88. package/hedhog/frontend/app/account/hooks/use-mfa-setup.ts.ejs +6 -2
  89. package/hedhog/frontend/app/dashboard/dashboard-home-tabs.tsx.ejs +130 -34
  90. package/hedhog/frontend/messages/en.json +33 -1
  91. package/hedhog/frontend/messages/pt.json +33 -1
  92. package/package.json +10 -5
  93. package/src/app-command/app-command.registry.ts +2 -0
  94. package/src/auth/auth.controller.ts +21 -0
  95. package/src/auth/auth.service.ts +133 -0
  96. package/src/auth/dto/complete-account.dto.ts +24 -0
  97. package/src/authorize/authorize.controller.ts +99 -0
  98. package/src/authorize/authorize.module.ts +23 -0
  99. package/src/authorize/authorize.service.spec.ts +307 -0
  100. package/src/authorize/authorize.service.ts +245 -0
  101. package/src/authorize/dto/authorize-info.dto.ts +23 -0
  102. package/src/authorize/dto/consent.dto.ts +23 -0
  103. package/src/authorize/dto/token.dto.ts +23 -0
  104. package/src/challenge/challenge.service.ts +28 -0
  105. package/src/core.module.ts +2 -0
  106. package/src/file/file.service.ts +19 -0
  107. package/src/integration-profile/integration-profile.secrets.spec.ts +114 -0
  108. package/src/integration-profile/integration-profile.secrets.ts +11 -2
  109. package/src/integration-profile/integration-profile.service.ts +19 -0
  110. package/src/integration-profile/integration-profile.utils.ts +40 -0
  111. package/src/oauth/interfaces/OAuthProvider.ts +5 -1
  112. package/src/oauth/oauth.controller.ts +89 -0
  113. package/src/oauth/oauth.service.ts +190 -8
  114. package/src/oauth/providers/microsoft-entra-id.provider.ts +45 -5
  115. package/src/webhook-integration/webhook-integration.service.ts +18 -2
@@ -0,0 +1,307 @@
1
+ import { beforeEach, describe, expect, it, jest } from '@jest/globals';
2
+ import { BadRequestException } from '@nestjs/common';
3
+ import { createHash, randomBytes } from 'crypto';
4
+
5
+ // Mocka o MESMO módulo de onde o service importa (@hed-hog/api-prisma). Mockar
6
+ // '@prisma/client' aqui deixaria o teste verde com um import que quebra em
7
+ // runtime — foi exatamente o que aconteceu antes de o e2e pegar.
8
+ jest.mock('@hed-hog/api-prisma', () => ({
9
+ PrismaService: class {},
10
+ Prisma: {
11
+ sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ strings: [...strings], values }),
12
+ },
13
+ }));
14
+
15
+ // eslint-disable-next-line import/first
16
+ import { AuthorizeService } from './authorize.service';
17
+
18
+ const CLIENT_ID = 'hedhog-extension';
19
+ const REDIRECT_URI = 'https://gcjdclnalkkapoajodppjbcmifnadgok.chromiumapp.org/';
20
+
21
+ /** base64url(sha256(v)) — o mesmo que SecurityService.hashSha256 produz. */
22
+ const sha256b64url = (value: string) =>
23
+ createHash('sha256').update(value).digest('base64url');
24
+
25
+ const makeVerifier = () => randomBytes(32).toString('base64url');
26
+
27
+ const makeClient = (overrides: Record<string, unknown> = {}) => ({
28
+ client_id: CLIENT_ID,
29
+ name: 'HedHog Vaults Extension',
30
+ client_type: 'public',
31
+ redirect_uris: REDIRECT_URI,
32
+ scopes: 'vaults',
33
+ enabled: true,
34
+ ...overrides,
35
+ });
36
+
37
+ const makeDeps = () => {
38
+ const prisma = {
39
+ oauth_app_client: { findUnique: jest.fn() },
40
+ oauth_authorization_code: { create: jest.fn() },
41
+ $queryRaw: jest.fn(),
42
+ };
43
+ // Cripto real: é justamente o que estes testes precisam exercitar.
44
+ const security = {
45
+ randomOpaque: (size = 32) => randomBytes(size).toString('base64url'),
46
+ hashSha256: (value: string) => sha256b64url(value),
47
+ };
48
+ const auth = {
49
+ getAuthenticationPayload: jest.fn(async () => ({
50
+ accessToken: 'ACCESS',
51
+ refreshToken: 'REFRESH',
52
+ session: { id: 1 },
53
+ })),
54
+ };
55
+ return { prisma, security, auth };
56
+ };
57
+
58
+ const makeService = (deps: ReturnType<typeof makeDeps>) =>
59
+ new AuthorizeService(deps.prisma as never, deps.security as never, deps.auth as never);
60
+
61
+ const validIssueParams = (challenge: string) => ({
62
+ userId: 7,
63
+ clientId: CLIENT_ID,
64
+ redirectUri: REDIRECT_URI,
65
+ scope: 'vaults',
66
+ codeChallenge: challenge,
67
+ codeChallengeMethod: 'S256',
68
+ });
69
+
70
+ const validExchangeParams = (code: string, verifier: string) => ({
71
+ code,
72
+ codeVerifier: verifier,
73
+ clientId: CLIENT_ID,
74
+ redirectUri: REDIRECT_URI,
75
+ locale: 'en',
76
+ ipAddress: '127.0.0.1',
77
+ userAgent: 'jest',
78
+ });
79
+
80
+ describe('AuthorizeService', () => {
81
+ let deps: ReturnType<typeof makeDeps>;
82
+ let service: AuthorizeService;
83
+
84
+ beforeEach(() => {
85
+ deps = makeDeps();
86
+ service = makeService(deps);
87
+ deps.prisma.oauth_app_client.findUnique.mockResolvedValue(makeClient() as never);
88
+ });
89
+
90
+ describe('issueCode', () => {
91
+ it('persiste apenas o hash do code, e devolve o code em claro', async () => {
92
+ const verifier = makeVerifier();
93
+ const code = await service.issueCode(validIssueParams(sha256b64url(verifier)));
94
+
95
+ const created = deps.prisma.oauth_authorization_code.create.mock.calls[0][0] as {
96
+ data: Record<string, unknown>;
97
+ };
98
+
99
+ expect(created.data.code_hash).toBe(sha256b64url(code));
100
+ expect(created.data.code_hash).not.toBe(code);
101
+ expect(created.data.user_id).toBe(7);
102
+ expect(created.data.code_challenge_method).toBe('S256');
103
+ });
104
+
105
+ it('dá ao code um TTL curto (60s)', async () => {
106
+ const before = Date.now();
107
+ await service.issueCode(validIssueParams(sha256b64url(makeVerifier())));
108
+
109
+ const created = deps.prisma.oauth_authorization_code.create.mock.calls[0][0] as {
110
+ data: { expires_at: Date };
111
+ };
112
+ const ttl = created.data.expires_at.getTime() - before;
113
+
114
+ expect(ttl).toBeGreaterThan(0);
115
+ expect(ttl).toBeLessThanOrEqual(60_000);
116
+ });
117
+
118
+ it('rejeita redirect_uri não registrado', async () => {
119
+ await expect(
120
+ service.issueCode({
121
+ ...validIssueParams(sha256b64url(makeVerifier())),
122
+ redirectUri: 'https://evil.example.com/',
123
+ }),
124
+ ).rejects.toThrow(BadRequestException);
125
+ });
126
+
127
+ it('rejeita redirect_uri que só difere por sufixo (sem match parcial)', async () => {
128
+ await expect(
129
+ service.issueCode({
130
+ ...validIssueParams(sha256b64url(makeVerifier())),
131
+ redirectUri: `${REDIRECT_URI}../evil`,
132
+ }),
133
+ ).rejects.toThrow(BadRequestException);
134
+ });
135
+
136
+ it('rejeita code_challenge_method plain', async () => {
137
+ await expect(
138
+ service.issueCode({
139
+ ...validIssueParams(sha256b64url(makeVerifier())),
140
+ codeChallengeMethod: 'plain',
141
+ }),
142
+ ).rejects.toThrow(BadRequestException);
143
+ });
144
+
145
+ it('rejeita code_challenge curto demais', async () => {
146
+ await expect(
147
+ service.issueCode({ ...validIssueParams('curto'), codeChallengeMethod: 'S256' }),
148
+ ).rejects.toThrow(BadRequestException);
149
+ });
150
+
151
+ it('rejeita scope fora do que o client tem', async () => {
152
+ await expect(
153
+ service.issueCode({
154
+ ...validIssueParams(sha256b64url(makeVerifier())),
155
+ scope: 'vaults admin',
156
+ }),
157
+ ).rejects.toThrow(BadRequestException);
158
+ });
159
+
160
+ it('rejeita client desabilitado', async () => {
161
+ deps.prisma.oauth_app_client.findUnique.mockResolvedValue(
162
+ makeClient({ enabled: false }) as never,
163
+ );
164
+ await expect(
165
+ service.issueCode(validIssueParams(sha256b64url(makeVerifier()))),
166
+ ).rejects.toThrow(BadRequestException);
167
+ });
168
+
169
+ it('rejeita client inexistente', async () => {
170
+ deps.prisma.oauth_app_client.findUnique.mockResolvedValue(null as never);
171
+ await expect(
172
+ service.issueCode(validIssueParams(sha256b64url(makeVerifier()))),
173
+ ).rejects.toThrow(BadRequestException);
174
+ });
175
+ });
176
+
177
+ describe('exchangeCode', () => {
178
+ it('troca o code por tokens quando o verifier confere', async () => {
179
+ const verifier = makeVerifier();
180
+ deps.prisma.$queryRaw.mockResolvedValue([
181
+ { user_id: 7, code_challenge: sha256b64url(verifier), scope: 'vaults' },
182
+ ] as never);
183
+
184
+ const result = await service.exchangeCode(validExchangeParams('CODE', verifier));
185
+
186
+ expect(result.accessToken).toBe('ACCESS');
187
+ expect(result.refreshToken).toBe('REFRESH');
188
+ expect(result.tokenType).toBe('Bearer');
189
+ expect(deps.auth.getAuthenticationPayload).toHaveBeenCalledWith(
190
+ 'en',
191
+ 7,
192
+ '127.0.0.1',
193
+ 'jest',
194
+ );
195
+ });
196
+
197
+ it('rejeita verifier errado, e não emite token', async () => {
198
+ deps.prisma.$queryRaw.mockResolvedValue([
199
+ { user_id: 7, code_challenge: sha256b64url(makeVerifier()), scope: 'vaults' },
200
+ ] as never);
201
+
202
+ await expect(
203
+ service.exchangeCode(validExchangeParams('CODE', makeVerifier())),
204
+ ).rejects.toThrow(BadRequestException);
205
+ expect(deps.auth.getAuthenticationPayload).not.toHaveBeenCalled();
206
+ });
207
+
208
+ it('rejeita verifier curto demais (piso do RFC 7636)', async () => {
209
+ deps.prisma.$queryRaw.mockResolvedValue([
210
+ { user_id: 7, code_challenge: sha256b64url('curto'), scope: 'vaults' },
211
+ ] as never);
212
+
213
+ await expect(service.exchangeCode(validExchangeParams('CODE', 'curto'))).rejects.toThrow(
214
+ BadRequestException,
215
+ );
216
+ });
217
+
218
+ it('rejeita code já consumido, expirado ou inexistente', async () => {
219
+ // O UPDATE ... WHERE consumed_at IS NULL não devolve linha nesses casos.
220
+ deps.prisma.$queryRaw.mockResolvedValue([] as never);
221
+
222
+ await expect(
223
+ service.exchangeCode(validExchangeParams('CODE', makeVerifier())),
224
+ ).rejects.toThrow(BadRequestException);
225
+ expect(deps.auth.getAuthenticationPayload).not.toHaveBeenCalled();
226
+ });
227
+
228
+ it('consome o code no mesmo UPDATE que o lê (replay não tem segunda chance)', async () => {
229
+ const verifier = makeVerifier();
230
+ deps.prisma.$queryRaw.mockResolvedValue([
231
+ { user_id: 7, code_challenge: sha256b64url(verifier), scope: 'vaults' },
232
+ ] as never);
233
+
234
+ await service.exchangeCode(validExchangeParams('CODE', verifier));
235
+
236
+ const sql = JSON.stringify(deps.prisma.$queryRaw.mock.calls[0][0]);
237
+ expect(sql).toContain('consumed_at = NOW()');
238
+ expect(sql).toContain('consumed_at IS NULL');
239
+ expect(sql).toContain('expires_at > NOW()');
240
+ });
241
+
242
+ it('busca o code pelo hash, nunca pelo valor em claro', async () => {
243
+ const verifier = makeVerifier();
244
+ deps.prisma.$queryRaw.mockResolvedValue([
245
+ { user_id: 7, code_challenge: sha256b64url(verifier), scope: 'vaults' },
246
+ ] as never);
247
+
248
+ await service.exchangeCode(validExchangeParams('CODE-EM-CLARO', verifier));
249
+
250
+ const params = JSON.stringify(deps.prisma.$queryRaw.mock.calls[0][0]);
251
+ expect(params).toContain(sha256b64url('CODE-EM-CLARO'));
252
+ expect(params).not.toContain('CODE-EM-CLARO');
253
+ });
254
+
255
+ it('amarra o code ao redirect_uri usado no authorize', async () => {
256
+ const verifier = makeVerifier();
257
+ deps.prisma.$queryRaw.mockResolvedValue([] as never);
258
+
259
+ await expect(
260
+ service.exchangeCode({
261
+ ...validExchangeParams('CODE', verifier),
262
+ redirectUri: 'https://evil.example.com/',
263
+ }),
264
+ ).rejects.toThrow(BadRequestException);
265
+
266
+ const params = JSON.stringify(deps.prisma.$queryRaw.mock.calls[0][0]);
267
+ expect(params).toContain('https://evil.example.com/');
268
+ });
269
+
270
+ it('recusa client confidencial neste fluxo', async () => {
271
+ deps.prisma.oauth_app_client.findUnique.mockResolvedValue(
272
+ makeClient({ client_type: 'confidential' }) as never,
273
+ );
274
+
275
+ await expect(
276
+ service.exchangeCode(validExchangeParams('CODE', makeVerifier())),
277
+ ).rejects.toThrow(BadRequestException);
278
+ expect(deps.prisma.$queryRaw).not.toHaveBeenCalled();
279
+ });
280
+ });
281
+
282
+ describe('getAuthorizationInfo', () => {
283
+ it('devolve o nome do app e os escopos para a tela de consent', async () => {
284
+ const info = await service.getAuthorizationInfo({
285
+ clientId: CLIENT_ID,
286
+ redirectUri: REDIRECT_URI,
287
+ scope: 'vaults',
288
+ codeChallenge: sha256b64url(makeVerifier()),
289
+ codeChallengeMethod: 'S256',
290
+ });
291
+
292
+ expect(info).toEqual({ clientName: 'HedHog Vaults Extension', scopes: ['vaults'] });
293
+ });
294
+
295
+ it('valida antes de a página redirecionar para qualquer lugar', async () => {
296
+ await expect(
297
+ service.getAuthorizationInfo({
298
+ clientId: CLIENT_ID,
299
+ redirectUri: 'https://evil.example.com/',
300
+ scope: 'vaults',
301
+ codeChallenge: sha256b64url(makeVerifier()),
302
+ codeChallengeMethod: 'S256',
303
+ }),
304
+ ).rejects.toThrow(BadRequestException);
305
+ });
306
+ });
307
+ });
@@ -0,0 +1,245 @@
1
+ // `Prisma` vem de @hed-hog/api-prisma, não de '@prisma/client': o tsconfig do
2
+ // monorepo mapeia '@prisma/client' para o client gerado (.prisma/client), que
3
+ // não expõe o helper `Prisma.sql`. É a convenção do repo inteiro.
4
+ import { Prisma, PrismaService } from '@hed-hog/api-prisma';
5
+ import {
6
+ BadRequestException,
7
+ Inject,
8
+ Injectable,
9
+ Logger,
10
+ forwardRef,
11
+ } from '@nestjs/common';
12
+ import { timingSafeEqual } from 'crypto';
13
+ import { AuthService } from '../auth/auth.service';
14
+ import { SecurityService } from '../security/security.service';
15
+
16
+ /**
17
+ * Authorization Code + PKCE — o HedHog como OAuth *provider* para apps
18
+ * first-party (a extensão de navegador; e o que vier depois).
19
+ *
20
+ * Modelado no padrão `web ref` do OAuthService: token opaco de que só o hash é
21
+ * persistido, TTL curto e consumo single-use atômico via UPDATE ... RETURNING.
22
+ *
23
+ * Por que existe, em vez de reusar GET /auth/desktop-token: aquele fluxo entrega
24
+ * accessToken e refreshToken na URL do redirect, e aceita `?scheme=` sem
25
+ * allowlist (open redirect). Para um gerenciador de senhas isso não serve.
26
+ */
27
+ @Injectable()
28
+ export class AuthorizeService {
29
+ private readonly logger = new Logger(AuthorizeService.name);
30
+
31
+ /** Curto de propósito: o code viaja na URL e só precisa sobreviver ao redirect. */
32
+ private readonly codeTtlMs = 60_000;
33
+
34
+ constructor(
35
+ @Inject(forwardRef(() => PrismaService))
36
+ private readonly prisma: PrismaService,
37
+ @Inject(forwardRef(() => SecurityService))
38
+ private readonly security: SecurityService,
39
+ @Inject(forwardRef(() => AuthService))
40
+ private readonly auth: AuthService,
41
+ ) {}
42
+
43
+ private async getClient(clientId: string) {
44
+ const client = await this.prisma.oauth_app_client.findUnique({
45
+ where: { client_id: clientId },
46
+ });
47
+
48
+ if (!client || !client.enabled) {
49
+ throw new BadRequestException('Aplicativo desconhecido ou desabilitado.');
50
+ }
51
+
52
+ return client;
53
+ }
54
+
55
+ /**
56
+ * A allowlist é comparada por igualdade de string, não por parsing de URL:
57
+ * normalizar/parsear abriria espaço para bypass (barra final, case do host,
58
+ * userinfo, `origin` valendo "null" em esquemas não-http).
59
+ */
60
+ private assertRedirectUri(client: { redirect_uris: string }, redirectUri: string) {
61
+ const allowed = client.redirect_uris
62
+ .split('\n')
63
+ .map((uri) => uri.trim())
64
+ .filter(Boolean);
65
+
66
+ if (!allowed.includes(redirectUri)) {
67
+ throw new BadRequestException('redirect_uri não registrado para este aplicativo.');
68
+ }
69
+ }
70
+
71
+ private assertScope(client: { scopes: string }, scope: string) {
72
+ const granted = new Set(client.scopes.split(/[\s,]+/).filter(Boolean));
73
+ const requested = scope.split(/[\s,]+/).filter(Boolean);
74
+
75
+ if (requested.length === 0) {
76
+ throw new BadRequestException('scope é obrigatório.');
77
+ }
78
+ if (!requested.every((s) => granted.has(s))) {
79
+ throw new BadRequestException('scope não autorizado para este aplicativo.');
80
+ }
81
+ }
82
+
83
+ /** Metadados para a tela de consent. Também valida antes de qualquer redirect. */
84
+ async getAuthorizationInfo(params: {
85
+ clientId: string;
86
+ redirectUri: string;
87
+ scope: string;
88
+ codeChallenge: string;
89
+ codeChallengeMethod: string;
90
+ }) {
91
+ const client = await this.getClient(params.clientId);
92
+ this.assertRedirectUri(client, params.redirectUri);
93
+ this.assertScope(client, params.scope);
94
+ this.assertPkce(params.codeChallenge, params.codeChallengeMethod);
95
+
96
+ return {
97
+ clientName: client.name,
98
+ scopes: params.scope.split(/[\s,]+/).filter(Boolean),
99
+ };
100
+ }
101
+
102
+ /**
103
+ * `plain` é rejeitado sem exceção: com ele o "verifier" viaja igual ao
104
+ * challenge e o PKCE não protege nada. O RFC 7636 permite; nós não.
105
+ */
106
+ private assertPkce(codeChallenge: string, method: string) {
107
+ if (method !== 'S256') {
108
+ throw new BadRequestException('code_challenge_method deve ser S256.');
109
+ }
110
+ if (
111
+ !codeChallenge ||
112
+ codeChallenge.length < 43 ||
113
+ codeChallenge.length > 128 ||
114
+ !/^[A-Za-z0-9\-._~]+$/.test(codeChallenge)
115
+ ) {
116
+ throw new BadRequestException('code_challenge inválido.');
117
+ }
118
+ }
119
+
120
+ /** Emite o code já vinculado ao usuário da sessão que aprovou o consent. */
121
+ async issueCode(params: {
122
+ userId: number;
123
+ clientId: string;
124
+ redirectUri: string;
125
+ scope: string;
126
+ codeChallenge: string;
127
+ codeChallengeMethod: string;
128
+ }): Promise<string> {
129
+ const client = await this.getClient(params.clientId);
130
+ this.assertRedirectUri(client, params.redirectUri);
131
+ this.assertScope(client, params.scope);
132
+ this.assertPkce(params.codeChallenge, params.codeChallengeMethod);
133
+
134
+ const code = this.security.randomOpaque(32);
135
+ const codeHash = this.security.hashSha256(code);
136
+ const expiresAt = new Date(Date.now() + this.codeTtlMs);
137
+
138
+ await this.prisma.oauth_authorization_code.create({
139
+ data: {
140
+ code_hash: codeHash,
141
+ client_id: params.clientId,
142
+ user_id: params.userId,
143
+ redirect_uri: params.redirectUri,
144
+ scope: params.scope,
145
+ code_challenge: params.codeChallenge,
146
+ code_challenge_method: 'S256',
147
+ expires_at: expiresAt,
148
+ },
149
+ });
150
+
151
+ this.logger.debug(
152
+ `[AuthorizeService] code emitido client=${params.clientId} userId=${params.userId}`,
153
+ );
154
+
155
+ return code;
156
+ }
157
+
158
+ /**
159
+ * Troca o code por tokens. O consumo é atômico (UPDATE ... WHERE consumed_at
160
+ * IS NULL RETURNING): dois requests concorrentes com o mesmo code — o caso de
161
+ * replay — só podem ter um vencedor, decidido pelo banco.
162
+ */
163
+ async exchangeCode(params: {
164
+ code: string;
165
+ codeVerifier: string;
166
+ clientId: string;
167
+ redirectUri: string;
168
+ locale: string;
169
+ ipAddress: string;
170
+ userAgent: string;
171
+ }) {
172
+ const client = await this.getClient(params.clientId);
173
+
174
+ if (client.client_type !== 'public') {
175
+ throw new BadRequestException('Fluxo indisponível para este aplicativo.');
176
+ }
177
+
178
+ const codeHash = this.security.hashSha256(params.code);
179
+
180
+ const rows = await this.prisma.$queryRaw<
181
+ Array<{ user_id: number; code_challenge: string; scope: string }>
182
+ >(
183
+ Prisma.sql`
184
+ UPDATE oauth_authorization_code
185
+ SET consumed_at = NOW()
186
+ WHERE code_hash = ${codeHash}
187
+ AND client_id = ${params.clientId}
188
+ AND redirect_uri = ${params.redirectUri}
189
+ AND expires_at > NOW()
190
+ AND consumed_at IS NULL
191
+ RETURNING user_id, code_challenge, scope
192
+ `,
193
+ );
194
+
195
+ const row = rows[0];
196
+ if (!row) {
197
+ throw new BadRequestException(
198
+ 'Código inválido, expirado ou já utilizado. Refaça a autorização.',
199
+ );
200
+ }
201
+
202
+ if (!this.verifyPkce(params.codeVerifier, row.code_challenge)) {
203
+ // O code já foi consumido acima — um verifier errado não dá segunda chance.
204
+ this.logger.warn(
205
+ `[AuthorizeService] PKCE falhou client=${params.clientId} userId=${row.user_id}`,
206
+ );
207
+ throw new BadRequestException('code_verifier inválido.');
208
+ }
209
+
210
+ const { accessToken, refreshToken } = await this.auth.getAuthenticationPayload(
211
+ params.locale,
212
+ row.user_id,
213
+ params.ipAddress,
214
+ params.userAgent,
215
+ );
216
+
217
+ return {
218
+ accessToken,
219
+ refreshToken,
220
+ tokenType: 'Bearer',
221
+ // Informativo para o cliente agendar refresh; a verdade é o exp do JWT.
222
+ expiresIn: 3600,
223
+ scope: row.scope,
224
+ };
225
+ }
226
+
227
+ /** base64url(sha256(verifier)) === challenge, em tempo constante. */
228
+ private verifyPkce(codeVerifier: string, codeChallenge: string): boolean {
229
+ if (!codeVerifier || codeVerifier.length < 43 || codeVerifier.length > 128) {
230
+ return false;
231
+ }
232
+
233
+ // hashSha256 já devolve base64url — o mesmo formato do S256.
234
+ const expected = Buffer.from(this.security.hashSha256(codeVerifier));
235
+ const provided = Buffer.from(codeChallenge);
236
+
237
+ if (expected.length !== provided.length) return false;
238
+
239
+ try {
240
+ return timingSafeEqual(expected, provided);
241
+ } catch {
242
+ return false;
243
+ }
244
+ }
245
+ }
@@ -0,0 +1,23 @@
1
+ import { IsNotEmpty, IsString } from 'class-validator';
2
+
3
+ export class AuthorizeInfoDTO {
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 { 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