@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.
- 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 +23 -1
- 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 +56 -6
- 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/notification/notification.controller.d.ts +12 -12
- package/dist/notification/notification.service.d.ts +12 -12
- 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/session/session.controller.d.ts +1 -1
- package/dist/session/session.service.d.ts +3 -3
- package/dist/user/user.controller.d.ts +1 -1
- package/dist/user/user.service.d.ts +3 -3
- 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/app/roles/page.tsx.ejs +13 -12
- package/hedhog/frontend/messages/en.json +35 -2
- package/hedhog/frontend/messages/pt.json +35 -2
- 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/mcp/decorators/allow-mcp-token.decorator.ts +7 -7
- 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,99 @@
|
|
|
1
|
+
import { NoRole, Public, User } from '@hed-hog/api';
|
|
2
|
+
import { Locale } from '@hed-hog/api-locale';
|
|
3
|
+
import {
|
|
4
|
+
Body,
|
|
5
|
+
Controller,
|
|
6
|
+
Get,
|
|
7
|
+
Headers,
|
|
8
|
+
Inject,
|
|
9
|
+
Ip,
|
|
10
|
+
Post,
|
|
11
|
+
Query,
|
|
12
|
+
UseGuards,
|
|
13
|
+
forwardRef,
|
|
14
|
+
} from '@nestjs/common';
|
|
15
|
+
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
|
16
|
+
import { AuthorizeService } from './authorize.service';
|
|
17
|
+
import { AuthorizeInfoDTO } from './dto/authorize-info.dto';
|
|
18
|
+
import { ConsentDTO } from './dto/consent.dto';
|
|
19
|
+
import { TokenDTO } from './dto/token.dto';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Endpoints do fluxo Authorization Code + PKCE.
|
|
23
|
+
*
|
|
24
|
+
* Note o que NÃO existe aqui: um `GET /auth/authorize` que redireciona. O
|
|
25
|
+
* redirect final é feito pelo browser, na página de consent do admin, depois do
|
|
26
|
+
* POST /auth/authorize/consent. Assim toda a validação fica no servidor e o
|
|
27
|
+
* `code` não passa pelo mecanismo de redirect do Nest (nem pelos seus logs).
|
|
28
|
+
*/
|
|
29
|
+
@Controller('auth')
|
|
30
|
+
export class AuthorizeController {
|
|
31
|
+
constructor(
|
|
32
|
+
@Inject(forwardRef(() => AuthorizeService))
|
|
33
|
+
private readonly service: AuthorizeService,
|
|
34
|
+
) {}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Metadados para a tela de consent. Público: a página precisa validar os
|
|
38
|
+
* parâmetros e mostrar de quem é o app ANTES de exigir login.
|
|
39
|
+
*/
|
|
40
|
+
@Public()
|
|
41
|
+
@UseGuards(ThrottlerGuard)
|
|
42
|
+
@Throttle({ default: { limit: 30, ttl: 60000 } })
|
|
43
|
+
@Get('authorize/info')
|
|
44
|
+
async authorizeInfo(@Query() query: AuthorizeInfoDTO) {
|
|
45
|
+
return this.service.getAuthorizationInfo({
|
|
46
|
+
clientId: query.client_id,
|
|
47
|
+
redirectUri: query.redirect_uri,
|
|
48
|
+
scope: query.scope,
|
|
49
|
+
codeChallenge: query.code_challenge,
|
|
50
|
+
codeChallengeMethod: query.code_challenge_method,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** O usuário logado aprova. O code sai vinculado a ele. */
|
|
55
|
+
@NoRole()
|
|
56
|
+
@UseGuards(ThrottlerGuard)
|
|
57
|
+
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
|
58
|
+
@Post('authorize/consent')
|
|
59
|
+
async consent(@User() { id }, @Body() body: ConsentDTO) {
|
|
60
|
+
const code = await this.service.issueCode({
|
|
61
|
+
userId: id,
|
|
62
|
+
clientId: body.client_id,
|
|
63
|
+
redirectUri: body.redirect_uri,
|
|
64
|
+
scope: body.scope,
|
|
65
|
+
codeChallenge: body.code_challenge,
|
|
66
|
+
codeChallengeMethod: body.code_challenge_method,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return { code };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Troca o code por tokens. Público por definição (o cliente ainda não tem
|
|
74
|
+
* token) — quem autentica é a posse do code + do code_verifier.
|
|
75
|
+
*
|
|
76
|
+
* Sem @Res() e sem cookie `rt`: o refreshToken vai no body. Um Set-Cookie aqui
|
|
77
|
+
* seria ruído para um cliente não-browser e um vetor de CSRF a mais.
|
|
78
|
+
*/
|
|
79
|
+
@Public()
|
|
80
|
+
@UseGuards(ThrottlerGuard)
|
|
81
|
+
@Throttle({ default: { limit: 20, ttl: 60000 } })
|
|
82
|
+
@Post('token')
|
|
83
|
+
async token(
|
|
84
|
+
@Body() body: TokenDTO,
|
|
85
|
+
@Locale() locale: string,
|
|
86
|
+
@Ip() ipAddress: string,
|
|
87
|
+
@Headers('user-agent') userAgent: string,
|
|
88
|
+
) {
|
|
89
|
+
return this.service.exchangeCode({
|
|
90
|
+
code: body.code,
|
|
91
|
+
codeVerifier: body.code_verifier,
|
|
92
|
+
clientId: body.client_id,
|
|
93
|
+
redirectUri: body.redirect_uri,
|
|
94
|
+
locale,
|
|
95
|
+
ipAddress,
|
|
96
|
+
userAgent,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { PrismaModule } from '@hed-hog/api-prisma';
|
|
2
|
+
import { Module, forwardRef } from '@nestjs/common';
|
|
3
|
+
import { AuthModule } from '../auth/auth.module';
|
|
4
|
+
import { SecurityModule } from '../security/security.module';
|
|
5
|
+
import { AuthorizeController } from './authorize.controller';
|
|
6
|
+
import { AuthorizeService } from './authorize.service';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Separado de OAuthModule de propósito: lá o HedHog é *client* de IdPs externos
|
|
10
|
+
* (Google, Apple...). Aqui ele é o *provider*. Misturar os dois confundiria dois
|
|
11
|
+
* papéis opostos no mesmo arquivo.
|
|
12
|
+
*/
|
|
13
|
+
@Module({
|
|
14
|
+
imports: [
|
|
15
|
+
forwardRef(() => PrismaModule),
|
|
16
|
+
forwardRef(() => SecurityModule),
|
|
17
|
+
forwardRef(() => AuthModule),
|
|
18
|
+
],
|
|
19
|
+
controllers: [AuthorizeController],
|
|
20
|
+
providers: [AuthorizeService],
|
|
21
|
+
exports: [AuthorizeService],
|
|
22
|
+
})
|
|
23
|
+
export class AuthorizeModule {}
|
|
@@ -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
|
+
}
|