prime-sdk 3.0.7 → 3.0.8-beta.0
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/.eslintrc.js +4 -0
- package/.yarn/install-state.gz +0 -0
- package/README.md +307 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +25 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/entraID/customError/entraID.exception.d.ts +5 -0
- package/dist/lib/entraID/customError/entraID.exception.js +43 -0
- package/dist/lib/entraID/customError/entraID.exception.js.map +1 -0
- package/dist/lib/entraID/decorators/current-user.decorator.d.ts +1 -0
- package/dist/lib/entraID/decorators/current-user.decorator.js +9 -0
- package/dist/lib/entraID/decorators/current-user.decorator.js.map +1 -0
- package/dist/lib/entraID/decorators/public.decorator.d.ts +2 -0
- package/dist/lib/entraID/decorators/public.decorator.js +8 -0
- package/dist/lib/entraID/decorators/public.decorator.js.map +1 -0
- package/dist/lib/entraID/decorators/roles.decorator.d.ts +2 -0
- package/dist/lib/entraID/decorators/roles.decorator.js +8 -0
- package/dist/lib/entraID/decorators/roles.decorator.js.map +1 -0
- package/dist/lib/entraID/entraID.constants.d.ts +2 -0
- package/dist/lib/entraID/entraID.constants.js +6 -0
- package/dist/lib/entraID/entraID.constants.js.map +1 -0
- package/dist/lib/entraID/entraID.module.d.ts +14 -0
- package/dist/lib/entraID/entraID.module.js +56 -0
- package/dist/lib/entraID/entraID.module.js.map +1 -0
- package/dist/lib/entraID/entraID.service.d.ts +25 -0
- package/dist/lib/entraID/entraID.service.js +166 -0
- package/dist/lib/entraID/entraID.service.js.map +1 -0
- package/dist/lib/entraID/guards/entra-auth.guard.d.ts +21 -0
- package/dist/lib/entraID/guards/entra-auth.guard.js +70 -0
- package/dist/lib/entraID/guards/entra-auth.guard.js.map +1 -0
- package/dist/lib/entraID/guards/roles.guard.d.ts +7 -0
- package/dist/lib/entraID/guards/roles.guard.js +41 -0
- package/dist/lib/entraID/guards/roles.guard.js.map +1 -0
- package/dist/lib/entraID/interfaces/entraID-service.interface.d.ts +7 -0
- package/dist/lib/entraID/interfaces/entraID-service.interface.js +3 -0
- package/dist/lib/entraID/interfaces/entraID-service.interface.js.map +1 -0
- package/dist/lib/entraID/internal-token/internal-token.module.d.ts +2 -0
- package/dist/lib/entraID/internal-token/internal-token.module.js +21 -0
- package/dist/lib/entraID/internal-token/internal-token.module.js.map +1 -0
- package/dist/lib/entraID/internal-token/internal-token.service.d.ts +10 -0
- package/dist/lib/entraID/internal-token/internal-token.service.js +76 -0
- package/dist/lib/entraID/internal-token/internal-token.service.js.map +1 -0
- package/dist/lib/entraID/microsoft/microsoft.module.d.ts +2 -0
- package/dist/lib/entraID/microsoft/microsoft.module.js +21 -0
- package/dist/lib/entraID/microsoft/microsoft.module.js.map +1 -0
- package/dist/lib/entraID/microsoft/microsoft.service.d.ts +14 -0
- package/dist/lib/entraID/microsoft/microsoft.service.js +132 -0
- package/dist/lib/entraID/microsoft/microsoft.service.js.map +1 -0
- package/dist/lib/entraID/security/security.module.d.ts +2 -0
- package/dist/lib/entraID/security/security.module.js +21 -0
- package/dist/lib/entraID/security/security.module.js.map +1 -0
- package/dist/lib/entraID/security/security.service.d.ts +9 -0
- package/dist/lib/entraID/security/security.service.js +65 -0
- package/dist/lib/entraID/security/security.service.js.map +1 -0
- package/dist/lib/entraID/types/entraID-module-init.types.d.ts +18 -0
- package/dist/lib/entraID/types/entraID-module-init.types.js +3 -0
- package/dist/lib/entraID/types/entraID-module-init.types.js.map +1 -0
- package/dist/lib/entraID/types/entraID.type.d.ts +52 -0
- package/dist/lib/entraID/types/entraID.type.js +3 -0
- package/dist/lib/entraID/types/entraID.type.js.map +1 -0
- package/dist/lib/entraID/utils/cookie.utils.d.ts +7 -0
- package/dist/lib/entraID/utils/cookie.utils.js +42 -0
- package/dist/lib/entraID/utils/cookie.utils.js.map +1 -0
- package/dist/lib/entraID/utils/crypto.utils.d.ts +9 -0
- package/dist/lib/entraID/utils/crypto.utils.js +47 -0
- package/dist/lib/entraID/utils/crypto.utils.js.map +1 -0
- package/dist/lib/entraID/utils/index.d.ts +2 -0
- package/dist/lib/entraID/utils/index.js +8 -0
- package/dist/lib/entraID/utils/index.js.map +1 -0
- package/dist/lib/entraID/utils/token.utils.d.ts +6 -0
- package/dist/lib/entraID/utils/token.utils.js +24 -0
- package/dist/lib/entraID/utils/token.utils.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +4 -1
- package/src/index.ts +42 -0
- package/src/lib/entraID/customError/entraID.exception.ts +43 -0
- package/src/lib/entraID/decorators/current-user.decorator.ts +8 -0
- package/src/lib/entraID/decorators/public.decorator.ts +5 -0
- package/src/lib/entraID/decorators/roles.decorator.ts +5 -0
- package/src/lib/entraID/entraID.constants.ts +2 -0
- package/src/lib/entraID/entraID.module.ts +78 -0
- package/src/lib/entraID/entraID.service.ts +206 -0
- package/src/lib/entraID/guards/entra-auth.guard.ts +77 -0
- package/src/lib/entraID/guards/roles.guard.ts +34 -0
- package/src/lib/entraID/interfaces/entraID-service.interface.ts +12 -0
- package/src/lib/entraID/internal-token/internal-token.module.ts +8 -0
- package/src/lib/entraID/internal-token/internal-token.service.ts +66 -0
- package/src/lib/entraID/microsoft/microsoft.module.ts +8 -0
- package/src/lib/entraID/microsoft/microsoft.service.ts +158 -0
- package/src/lib/entraID/security/security.module.ts +8 -0
- package/src/lib/entraID/security/security.service.ts +68 -0
- package/src/lib/entraID/types/entraID-module-init.types.ts +25 -0
- package/src/lib/entraID/types/entraID.type.ts +57 -0
- package/src/lib/entraID/utils/cookie.utils.ts +50 -0
- package/src/lib/entraID/utils/crypto.utils.ts +53 -0
- package/src/lib/entraID/utils/index.ts +2 -0
- package/src/lib/entraID/utils/token.utils.ts +20 -0
- package/tsconfig.json +1 -0
- package/.vscode/settings.json +0 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prime-sdk",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.8-beta.0",
|
|
4
4
|
"description": "swiss army knife",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
"@nestjs/platform-express": "^11.1.17",
|
|
38
38
|
"@smithy/node-http-handler": "^3.3.1",
|
|
39
39
|
"ioredis": "^5.4.1",
|
|
40
|
+
"jsonwebtoken": "^9.0.3",
|
|
41
|
+
"jwks-rsa": "^4.0.1",
|
|
40
42
|
"reflect-metadata": "^0.2.0",
|
|
41
43
|
"rxjs": "^7.8.1"
|
|
42
44
|
},
|
|
@@ -50,6 +52,7 @@
|
|
|
50
52
|
"@nestjs/testing": "^11.1.17",
|
|
51
53
|
"@types/express": "^4.17.17",
|
|
52
54
|
"@types/jest": "^29.5.2",
|
|
55
|
+
"@types/jsonwebtoken": "^9.0.10",
|
|
53
56
|
"@types/node": "^20.3.1",
|
|
54
57
|
"@types/supertest": "^6.0.0",
|
|
55
58
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,28 @@ import {
|
|
|
33
33
|
TypeRedis,
|
|
34
34
|
PubSubRedis,
|
|
35
35
|
} from './external/redis/types/ioredis.types';
|
|
36
|
+
import { EntraIDModule, ENTRA_ID_CONFIG } from './lib/entraID/entraID.module';
|
|
37
|
+
import type { EntraIDModuleOptions } from './lib/entraID/entraID.module';
|
|
38
|
+
import { EntraIDService } from './lib/entraID/entraID.service';
|
|
39
|
+
import { EntraAuthGuard } from './lib/entraID/guards/entra-auth.guard';
|
|
40
|
+
import { RolesGuard } from './lib/entraID/guards/roles.guard';
|
|
41
|
+
import { InternalTokenService } from './lib/entraID/internal-token/internal-token.service';
|
|
42
|
+
import { CurrentUser } from './lib/entraID/decorators/current-user.decorator';
|
|
43
|
+
import { Public } from './lib/entraID/decorators/public.decorator';
|
|
44
|
+
import { Roles } from './lib/entraID/decorators/roles.decorator';
|
|
45
|
+
import { CookieUtils, TokenUtils } from './lib/entraID/utils';
|
|
46
|
+
import { SecurityService } from './lib/entraID/security/security.service';
|
|
47
|
+
import { EntraIDException } from './lib/entraID/customError/entraID.exception';
|
|
48
|
+
import type {
|
|
49
|
+
EntraUser,
|
|
50
|
+
AuthResult,
|
|
51
|
+
CallbackParams,
|
|
52
|
+
TokenEnrichmentContext,
|
|
53
|
+
} from './lib/entraID/types/entraID.type';
|
|
54
|
+
import type {
|
|
55
|
+
EntraIDSetup,
|
|
56
|
+
EntraIDAsyncOptions,
|
|
57
|
+
} from './lib/entraID/types/entraID-module-init.types';
|
|
36
58
|
|
|
37
59
|
import { SNSSetup } from './external/aws/sns/sns.provider';
|
|
38
60
|
|
|
@@ -64,4 +86,24 @@ export {
|
|
|
64
86
|
ClusterRedisSetup,
|
|
65
87
|
TypeRedis,
|
|
66
88
|
PubSubRedis,
|
|
89
|
+
EntraIDModule,
|
|
90
|
+
EntraIDModuleOptions,
|
|
91
|
+
ENTRA_ID_CONFIG,
|
|
92
|
+
EntraIDService,
|
|
93
|
+
EntraAuthGuard,
|
|
94
|
+
RolesGuard,
|
|
95
|
+
InternalTokenService,
|
|
96
|
+
CurrentUser,
|
|
97
|
+
Public,
|
|
98
|
+
Roles,
|
|
99
|
+
CookieUtils,
|
|
100
|
+
TokenUtils,
|
|
101
|
+
SecurityService,
|
|
102
|
+
EntraIDException,
|
|
103
|
+
EntraIDSetup,
|
|
104
|
+
EntraIDAsyncOptions,
|
|
105
|
+
EntraUser,
|
|
106
|
+
AuthResult,
|
|
107
|
+
CallbackParams,
|
|
108
|
+
TokenEnrichmentContext,
|
|
67
109
|
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const entraIDErrorMessages: Record<string, string> = {
|
|
2
|
+
INVALID_CONFIG: 'Configuração inválida do módulo EntraID',
|
|
3
|
+
TENANT_NOT_FOUND: 'Tenant não encontrado na configuração do EntraID',
|
|
4
|
+
INVALID_SECRET: 'Em Produção é necessário um secret válido',
|
|
5
|
+
STATE_MISMATCH: 'State OAuth inválido ou expirado',
|
|
6
|
+
TOKEN_INVALID_HEADER: 'Header da chave publica inválido',
|
|
7
|
+
PAYLOAD_ID_TOKEN_INVALID: 'Payload decodificado é inválido',
|
|
8
|
+
INVALID_NONCE: 'Nonce retornado é diferente do esperado',
|
|
9
|
+
CODE_EXCHANGE_FAILED: 'Falha ao trocar o código de autorização pelo token',
|
|
10
|
+
TOKEN_EXPIRED: 'Token de acesso expirado',
|
|
11
|
+
TOKEN_INVALID: 'Token de acesso inválido',
|
|
12
|
+
RESERVED_CLAIM_OVERRIDE:
|
|
13
|
+
'enrichTokenPayload tentou sobrescrever um claim reservado (sub/iss/aud/exp/iat/nbf/jti/groups)',
|
|
14
|
+
TOKEN_AUDIENCE_INVALID: 'Audience do token não corresponde ao esperado',
|
|
15
|
+
JWKS_FETCH_FAILED: 'Falha ao buscar chaves públicas do Azure',
|
|
16
|
+
JWKS_KEY_NOT_FOUND: 'Chave pública não encontrada no JWKS',
|
|
17
|
+
GRAPH_FETCH_FAILED: 'Falha ao buscar dados do usuário no Microsoft Graph',
|
|
18
|
+
GRAPH_USER_NOT_FOUND: 'Usuário não encontrado no Microsoft Entra ID',
|
|
19
|
+
SECURITY_PATH_TRAVERSAL: 'Tentativa de path traversal detectada',
|
|
20
|
+
SECURITY_NULL_BYTE: 'Caractere nulo detectado na entrada',
|
|
21
|
+
SECURITY_INVALID_CHARS: 'Caracteres inválidos ou perigosos na entrada',
|
|
22
|
+
SECURITY_OPEN_REDIRECT: 'Redirecionamento para URL não autorizada',
|
|
23
|
+
SECURITY_INVALID_URL: 'URL inválida',
|
|
24
|
+
OAUTH_PROVIDER_ERROR: 'Erro retornado pelo provedor OAuth',
|
|
25
|
+
OAUTH_MISSING_PARAMS: 'Parâmetros obrigatórios ausentes no callback OAuth',
|
|
26
|
+
REFRESH_TOKEN_FAILED: 'Falha ao renovar o token de acesso',
|
|
27
|
+
REVOKE_SESSIONS_FAILED:
|
|
28
|
+
'Falha ao revogar sessões do usuário no Microsoft Entra ID',
|
|
29
|
+
UNKNOWN_ERROR: 'Erro desconhecido',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export class EntraIDException extends Error {
|
|
33
|
+
public readonly message: string;
|
|
34
|
+
|
|
35
|
+
constructor(public readonly code: string) {
|
|
36
|
+
const message =
|
|
37
|
+
entraIDErrorMessages[code] ?? entraIDErrorMessages['UNKNOWN_ERROR'];
|
|
38
|
+
super(message);
|
|
39
|
+
this.message = message;
|
|
40
|
+
this.name = 'EntraIDException';
|
|
41
|
+
Object.setPrototypeOf(this, EntraIDException.prototype);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
export const CurrentUser = createParamDecorator(
|
|
4
|
+
<T = Record<string, unknown>>(_data: unknown, ctx: ExecutionContext): T => {
|
|
5
|
+
const request = ctx.switchToHttp().getRequest();
|
|
6
|
+
return request.user as T;
|
|
7
|
+
},
|
|
8
|
+
);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DynamicModule,
|
|
3
|
+
Module,
|
|
4
|
+
ModuleMetadata,
|
|
5
|
+
Provider,
|
|
6
|
+
Type,
|
|
7
|
+
} from '@nestjs/common';
|
|
8
|
+
import { EntraIDService } from './entraID.service';
|
|
9
|
+
import { EntraAuthGuard } from './guards/entra-auth.guard';
|
|
10
|
+
import { InternalTokenService } from './internal-token/internal-token.service';
|
|
11
|
+
import { MicrosoftModule } from './microsoft/microsoft.module';
|
|
12
|
+
import { SecurityModule } from './security/security.module';
|
|
13
|
+
import { ENTRA_ID_CONFIG } from './entraID.constants';
|
|
14
|
+
import {
|
|
15
|
+
EntraIDAsyncOptions,
|
|
16
|
+
EntraIDSetup,
|
|
17
|
+
} from './types/entraID-module-init.types';
|
|
18
|
+
|
|
19
|
+
export { ENTRA_ID_CONFIG };
|
|
20
|
+
|
|
21
|
+
export type EntraIDModuleOptions = {
|
|
22
|
+
internalTokenService?: Type<InternalTokenService>;
|
|
23
|
+
imports?: ModuleMetadata['imports'];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
@Module({})
|
|
27
|
+
export class EntraIDModule {
|
|
28
|
+
private static buildModule(
|
|
29
|
+
configProvider: Provider,
|
|
30
|
+
imports: ModuleMetadata['imports'] = [],
|
|
31
|
+
options: EntraIDModuleOptions = {},
|
|
32
|
+
): DynamicModule {
|
|
33
|
+
const tokenServiceClass =
|
|
34
|
+
options.internalTokenService ?? InternalTokenService;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
global: true,
|
|
38
|
+
module: EntraIDModule,
|
|
39
|
+
imports: [...imports, ...(options.imports ?? []), MicrosoftModule, SecurityModule],
|
|
40
|
+
providers: [
|
|
41
|
+
configProvider,
|
|
42
|
+
tokenServiceClass,
|
|
43
|
+
{ provide: InternalTokenService, useExisting: tokenServiceClass },
|
|
44
|
+
EntraIDService,
|
|
45
|
+
EntraAuthGuard,
|
|
46
|
+
],
|
|
47
|
+
exports: [
|
|
48
|
+
ENTRA_ID_CONFIG,
|
|
49
|
+
InternalTokenService,
|
|
50
|
+
EntraIDService,
|
|
51
|
+
EntraAuthGuard,
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static forRoot(params: EntraIDSetup, options?: EntraIDModuleOptions): DynamicModule {
|
|
57
|
+
return this.buildModule(
|
|
58
|
+
{ provide: ENTRA_ID_CONFIG, useValue: params },
|
|
59
|
+
[],
|
|
60
|
+
options,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static forRootAsync(
|
|
65
|
+
paramsAsync: EntraIDAsyncOptions,
|
|
66
|
+
options?: EntraIDModuleOptions,
|
|
67
|
+
): DynamicModule {
|
|
68
|
+
return this.buildModule(
|
|
69
|
+
{
|
|
70
|
+
provide: ENTRA_ID_CONFIG,
|
|
71
|
+
useFactory: paramsAsync.useFactory,
|
|
72
|
+
inject: paramsAsync.inject ?? [],
|
|
73
|
+
},
|
|
74
|
+
paramsAsync.imports ?? [],
|
|
75
|
+
options,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
|
|
2
|
+
import jwt, { Jwt } from 'jsonwebtoken';
|
|
3
|
+
import jwksClient from 'jwks-rsa';
|
|
4
|
+
import { ENTRA_ID_CONFIG, MICROSOFT_AUTHORITY_URL } from './entraID.constants';
|
|
5
|
+
import { InternalTokenService } from './internal-token/internal-token.service';
|
|
6
|
+
import { MicrosoftService } from './microsoft/microsoft.service';
|
|
7
|
+
import { SecurityService } from './security/security.service';
|
|
8
|
+
import {
|
|
9
|
+
AuthResult,
|
|
10
|
+
CallbackParams,
|
|
11
|
+
CreateStateNonce,
|
|
12
|
+
EntraUser,
|
|
13
|
+
IdTokenPayload,
|
|
14
|
+
UrlRedirectSso,
|
|
15
|
+
} from './types/entraID.type';
|
|
16
|
+
import { EntraIDException } from './customError/entraID.exception';
|
|
17
|
+
import { EntraIDSetup } from './types/entraID-module-init.types';
|
|
18
|
+
import { CryptoUtils } from './utils/crypto.utils';
|
|
19
|
+
import { IEntraIDService } from './interfaces/entraID-service.interface';
|
|
20
|
+
|
|
21
|
+
const STATE_TTL_MS = 600_000;
|
|
22
|
+
|
|
23
|
+
@Injectable()
|
|
24
|
+
export class EntraIDService implements OnModuleInit, IEntraIDService {
|
|
25
|
+
private jwksClientRsa: ReturnType<typeof jwksClient>;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
@Inject(ENTRA_ID_CONFIG) protected readonly config: EntraIDSetup,
|
|
29
|
+
protected readonly microsoftService: MicrosoftService,
|
|
30
|
+
protected readonly securityService: SecurityService,
|
|
31
|
+
protected readonly internalTokenService: InternalTokenService,
|
|
32
|
+
) {}
|
|
33
|
+
|
|
34
|
+
onModuleInit() {
|
|
35
|
+
const { tenantID, clientID, clientSecret, stateSecret, redirectUri } =
|
|
36
|
+
this.config;
|
|
37
|
+
|
|
38
|
+
if (
|
|
39
|
+
!tenantID ||
|
|
40
|
+
!clientID ||
|
|
41
|
+
!clientSecret ||
|
|
42
|
+
!stateSecret ||
|
|
43
|
+
!redirectUri
|
|
44
|
+
) {
|
|
45
|
+
throw new EntraIDException('INVALID_CONFIG');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const authorityUrl = this.config.authorityUrl ?? MICROSOFT_AUTHORITY_URL;
|
|
49
|
+
|
|
50
|
+
this.jwksClientRsa = jwksClient({
|
|
51
|
+
jwksUri: `${authorityUrl}/${tenantID}/discovery/v2.0/keys`,
|
|
52
|
+
cache: true,
|
|
53
|
+
cacheMaxAge: 10 * 60 * 1000,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async handleCallback(params: CallbackParams): Promise<AuthResult> {
|
|
58
|
+
this.securityService.validateCallbackParams(params);
|
|
59
|
+
|
|
60
|
+
const nonce = this.verifyStateAndReturnNonce(params.state!);
|
|
61
|
+
const tokens = await this.microsoftService.exchangeCode(params.code!);
|
|
62
|
+
const payload = await this.verifyIdToken(tokens.id_token, nonce);
|
|
63
|
+
|
|
64
|
+
const email = payload.preferred_username ?? payload.email ?? payload.upn;
|
|
65
|
+
if (!email) {
|
|
66
|
+
throw new EntraIDException('PAYLOAD_ID_TOKEN_INVALID');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const result = await this.buildUserJwt(
|
|
70
|
+
tokens.access_token,
|
|
71
|
+
email,
|
|
72
|
+
payload.name,
|
|
73
|
+
);
|
|
74
|
+
return { ...result, refreshToken: tokens.refresh_token };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async refresh(refreshToken: string): Promise<AuthResult> {
|
|
78
|
+
const tokens = await this.microsoftService.refreshToken(refreshToken);
|
|
79
|
+
const result = await this.buildUserJwt(tokens.access_token, '', '');
|
|
80
|
+
return { ...result, refreshToken: tokens.refresh_token };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async logout(userId: string): Promise<void> {
|
|
84
|
+
await this.microsoftService.revokeUserSessions(userId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async initiateLogin(): Promise<UrlRedirectSso> {
|
|
88
|
+
const { state, nonce } = this.createSignedState();
|
|
89
|
+
const { clientID, redirectUri, tenantID, scope, prompt } = this.config;
|
|
90
|
+
const authorityUrl = this.config.authorityUrl ?? MICROSOFT_AUTHORITY_URL;
|
|
91
|
+
|
|
92
|
+
const params = new URLSearchParams({
|
|
93
|
+
client_id: clientID,
|
|
94
|
+
response_type: 'code',
|
|
95
|
+
redirect_uri: redirectUri,
|
|
96
|
+
response_mode: 'form_post',
|
|
97
|
+
scope: scope ?? 'openid profile email User.Read GroupMember.Read.All',
|
|
98
|
+
state,
|
|
99
|
+
nonce,
|
|
100
|
+
prompt: prompt ?? 'select_account',
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return `${authorityUrl}/${tenantID}/oauth2/v2.0/authorize?${params.toString()}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
decodeLocalToken(token: string): EntraUser {
|
|
107
|
+
return this.internalTokenService.verify(token);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
protected async verifyIdToken(
|
|
111
|
+
idToken: string,
|
|
112
|
+
nonce: string,
|
|
113
|
+
): Promise<IdTokenPayload> {
|
|
114
|
+
const decoded = jwt.decode(idToken, { complete: true }) as Jwt | null;
|
|
115
|
+
|
|
116
|
+
if (!decoded?.header) {
|
|
117
|
+
throw new EntraIDException('TOKEN_INVALID_HEADER');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const publicKey = await CryptoUtils.getPublicKeyRsa(
|
|
121
|
+
decoded.header.kid,
|
|
122
|
+
this.jwksClientRsa,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const expectedIssuers: [string, ...string[]] = [
|
|
126
|
+
`https://login.microsoftonline.com/${this.config.tenantID}/v2.0`,
|
|
127
|
+
`https://sts.windows.net/${this.config.tenantID}/`,
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
const payload = jwt.verify(idToken, publicKey, {
|
|
131
|
+
algorithms: ['RS256'],
|
|
132
|
+
audience: this.config.clientID!,
|
|
133
|
+
issuer: expectedIssuers,
|
|
134
|
+
}) as unknown as IdTokenPayload;
|
|
135
|
+
|
|
136
|
+
if (payload.nonce !== nonce) {
|
|
137
|
+
throw new EntraIDException('INVALID_NONCE');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return payload;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
protected async buildUserJwt(
|
|
144
|
+
accessToken: string,
|
|
145
|
+
email: string,
|
|
146
|
+
name: string,
|
|
147
|
+
): Promise<AuthResult> {
|
|
148
|
+
const msUser = await this.microsoftService.getMe(accessToken);
|
|
149
|
+
|
|
150
|
+
let groups: { displayName: string }[] = [];
|
|
151
|
+
try {
|
|
152
|
+
const raw = await this.microsoftService.getMemberOf(accessToken);
|
|
153
|
+
groups = raw.filter((g) => !!g.displayName);
|
|
154
|
+
} catch {}
|
|
155
|
+
|
|
156
|
+
const user: EntraUser = {
|
|
157
|
+
entraId: msUser.id,
|
|
158
|
+
email: msUser.mail ?? msUser.userPrincipalName ?? email,
|
|
159
|
+
name: msUser.displayName ?? name,
|
|
160
|
+
tenantId: this.config.tenantID,
|
|
161
|
+
matricula: msUser.employeeId,
|
|
162
|
+
groups,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const token = await this.internalTokenService.sign({ user, accessToken });
|
|
166
|
+
|
|
167
|
+
return { token, user };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
protected createSignedState(): CreateStateNonce {
|
|
171
|
+
const { stateSecret } = this.config;
|
|
172
|
+
const nonce = CryptoUtils.generateHash();
|
|
173
|
+
const payload = Buffer.from(
|
|
174
|
+
JSON.stringify({ nonce, ts: Date.now() }),
|
|
175
|
+
).toString('base64url');
|
|
176
|
+
const state = CryptoUtils.createSignedPayloadWithHMAC(payload, stateSecret);
|
|
177
|
+
return { nonce, state };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
protected verifyStateAndReturnNonce(state: string): string {
|
|
181
|
+
const { stateSecret } = this.config;
|
|
182
|
+
const parts = state.split('.');
|
|
183
|
+
|
|
184
|
+
CryptoUtils.verifyPayloadWithHMAC(parts, stateSecret);
|
|
185
|
+
|
|
186
|
+
const payload = parts[0];
|
|
187
|
+
|
|
188
|
+
let nonce: string;
|
|
189
|
+
let ts: number;
|
|
190
|
+
try {
|
|
191
|
+
const parsed = JSON.parse(
|
|
192
|
+
Buffer.from(payload, 'base64url').toString(),
|
|
193
|
+
) as { nonce: string; ts: number };
|
|
194
|
+
nonce = parsed.nonce;
|
|
195
|
+
ts = parsed.ts;
|
|
196
|
+
} catch {
|
|
197
|
+
throw new EntraIDException('STATE_MISMATCH');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (Date.now() - ts > STATE_TTL_MS) {
|
|
201
|
+
throw new EntraIDException('STATE_MISMATCH');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return nonce;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CanActivate,
|
|
3
|
+
ExecutionContext,
|
|
4
|
+
Injectable,
|
|
5
|
+
UnauthorizedException,
|
|
6
|
+
} from '@nestjs/common';
|
|
7
|
+
import type { Request } from 'express';
|
|
8
|
+
import { InternalTokenService } from '../internal-token/internal-token.service';
|
|
9
|
+
import { EntraIDException } from '../customError/entraID.exception';
|
|
10
|
+
import { EntraUser } from '../types/entraID.type';
|
|
11
|
+
import { Reflector } from '@nestjs/core';
|
|
12
|
+
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
|
13
|
+
|
|
14
|
+
@Injectable()
|
|
15
|
+
export class EntraAuthGuard implements CanActivate {
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly internalTokenService: InternalTokenService,
|
|
18
|
+
private readonly reflector: Reflector,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
22
|
+
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
23
|
+
context.getHandler(),
|
|
24
|
+
context.getClass(),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
if (isPublic) return true;
|
|
28
|
+
|
|
29
|
+
const request = context.switchToHttp().getRequest();
|
|
30
|
+
const token = this.extractToken(request);
|
|
31
|
+
|
|
32
|
+
if (!token) throw new UnauthorizedException();
|
|
33
|
+
|
|
34
|
+
let payload: EntraUser;
|
|
35
|
+
try {
|
|
36
|
+
payload = this.internalTokenService.verify(token);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error instanceof EntraIDException) {
|
|
39
|
+
throw new UnauthorizedException(error.message);
|
|
40
|
+
}
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const revoke = await this.isTokenRevoked(payload, request);
|
|
45
|
+
|
|
46
|
+
if (revoke) {
|
|
47
|
+
throw new UnauthorizedException('TOKEN_REVOKED');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
request.user = await this.transformPayload(payload);
|
|
51
|
+
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
protected async isTokenRevoked(
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
57
|
+
_payload: EntraUser,
|
|
58
|
+
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
60
|
+
_request: Request,
|
|
61
|
+
): Promise<boolean> {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
protected async transformPayload(payload: EntraUser): Promise<unknown> {
|
|
66
|
+
return payload;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
protected extractToken(request: {
|
|
70
|
+
headers: { authorization?: string };
|
|
71
|
+
cookies?: { auth_token?: string };
|
|
72
|
+
}): string | undefined {
|
|
73
|
+
const auth = request.headers.authorization;
|
|
74
|
+
if (auth?.startsWith('Bearer ')) return auth.slice(7);
|
|
75
|
+
return request.cookies?.auth_token;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CanActivate,
|
|
3
|
+
ExecutionContext,
|
|
4
|
+
ForbiddenException,
|
|
5
|
+
Injectable,
|
|
6
|
+
} from '@nestjs/common';
|
|
7
|
+
import { Reflector } from '@nestjs/core';
|
|
8
|
+
import { ROLES_KEY } from '../decorators/roles.decorator';
|
|
9
|
+
|
|
10
|
+
@Injectable()
|
|
11
|
+
export class RolesGuard implements CanActivate {
|
|
12
|
+
constructor(private readonly reflector: Reflector) {}
|
|
13
|
+
|
|
14
|
+
canActivate(context: ExecutionContext): boolean {
|
|
15
|
+
const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
|
16
|
+
context.getHandler(),
|
|
17
|
+
context.getClass(),
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
if (!required?.length) return true;
|
|
21
|
+
|
|
22
|
+
const { user } = context.switchToHttp().getRequest();
|
|
23
|
+
const userGroups: string[] = (user?.groups ?? []).map(
|
|
24
|
+
(g: { displayName: string }) => g.displayName,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const allowed = required.some((role) => userGroups.includes(role));
|
|
28
|
+
if (!allowed) {
|
|
29
|
+
throw new ForbiddenException('INVALID_GROUP');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthResult,
|
|
3
|
+
CallbackParams,
|
|
4
|
+
UrlRedirectSso,
|
|
5
|
+
} from '../types/entraID.type';
|
|
6
|
+
|
|
7
|
+
export interface IEntraIDService {
|
|
8
|
+
handleCallback(params: CallbackParams): Promise<AuthResult>;
|
|
9
|
+
initiateLogin(): Promise<UrlRedirectSso>;
|
|
10
|
+
refresh(refreshToken: string): Promise<AuthResult>;
|
|
11
|
+
logout(userId: string): Promise<void>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Inject, Injectable } from '@nestjs/common';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import jwt from 'jsonwebtoken';
|
|
4
|
+
import { ENTRA_ID_CONFIG } from '../entraID.constants';
|
|
5
|
+
import { EntraIDException } from '../customError/entraID.exception';
|
|
6
|
+
import { EntraIDSetup } from '../types/entraID-module-init.types';
|
|
7
|
+
import { EntraUser, TokenEnrichmentContext } from '../types/entraID.type';
|
|
8
|
+
|
|
9
|
+
@Injectable()
|
|
10
|
+
export class InternalTokenService {
|
|
11
|
+
constructor(@Inject(ENTRA_ID_CONFIG) private readonly config: EntraIDSetup) {}
|
|
12
|
+
|
|
13
|
+
async sign(context: TokenEnrichmentContext): Promise<string> {
|
|
14
|
+
const customClaims = await this.enrichTokenPayload(context);
|
|
15
|
+
|
|
16
|
+
this.assertNoReservedClaimCollision(customClaims);
|
|
17
|
+
|
|
18
|
+
return jwt.sign(
|
|
19
|
+
{ jti: randomUUID(), ...context.user, ...customClaims },
|
|
20
|
+
this.config.jwtSecret,
|
|
21
|
+
{
|
|
22
|
+
algorithm: 'HS256',
|
|
23
|
+
expiresIn: (this.config.jwtExpiresIn ??
|
|
24
|
+
'8h') as jwt.SignOptions['expiresIn'],
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
protected async enrichTokenPayload(
|
|
30
|
+
_context: TokenEnrichmentContext,
|
|
31
|
+
): Promise<Record<string, unknown>> {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
verify(token: string): EntraUser {
|
|
36
|
+
try {
|
|
37
|
+
return jwt.verify(token, this.config.jwtSecret, {
|
|
38
|
+
algorithms: ['HS256'],
|
|
39
|
+
}) as unknown as EntraUser;
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err instanceof jwt.TokenExpiredError) {
|
|
42
|
+
throw new EntraIDException('TOKEN_EXPIRED');
|
|
43
|
+
}
|
|
44
|
+
throw new EntraIDException('TOKEN_INVALID');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private assertNoReservedClaimCollision(
|
|
49
|
+
customClaims: Record<string, unknown>,
|
|
50
|
+
) {
|
|
51
|
+
const reserved = [
|
|
52
|
+
'sub',
|
|
53
|
+
'iss',
|
|
54
|
+
'aud',
|
|
55
|
+
'exp',
|
|
56
|
+
'iat',
|
|
57
|
+
'nbf',
|
|
58
|
+
'jti',
|
|
59
|
+
'groups',
|
|
60
|
+
];
|
|
61
|
+
const collision = reserved.find((claim) => claim in customClaims);
|
|
62
|
+
if (collision) {
|
|
63
|
+
throw new EntraIDException('RESERVED_CLAIM_OVERRIDE');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|