@dismissible/nestjs-jwt-auth-hook 0.0.1
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/README.md +171 -0
- package/jest.config.ts +27 -0
- package/package.json +60 -0
- package/project.json +42 -0
- package/src/index.ts +4 -0
- package/src/jwt-auth-hook.config.spec.ts +158 -0
- package/src/jwt-auth-hook.config.ts +94 -0
- package/src/jwt-auth-hook.module.ts +79 -0
- package/src/jwt-auth.hook.spec.ts +283 -0
- package/src/jwt-auth.hook.ts +99 -0
- package/src/jwt-auth.service.spec.ts +566 -0
- package/src/jwt-auth.service.ts +186 -0
- package/tsconfig.json +16 -0
- package/tsconfig.lib.json +14 -0
- package/tsconfig.spec.json +12 -0
package/README.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# @dismissible/nestjs-jwt-auth-hook
|
|
2
|
+
|
|
3
|
+
JWT authentication hook for Dismissible applications using OpenID Connect (OIDC) well-known discovery.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This library provides a lifecycle hook that integrates with the `@dismissible/nestjs-dismissible` module to authenticate requests using JWT bearer tokens. It validates tokens using JWKS (JSON Web Key Set) fetched from an OIDC well-known endpoint.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @dismissible/nestjs-jwt-auth-hook @nestjs/axios axios
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
### Basic Setup
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { Module } from '@nestjs/common';
|
|
21
|
+
import { DismissibleModule } from '@dismissible/nestjs-dismissible';
|
|
22
|
+
import { JwtAuthHookModule, JwtAuthHook } from '@dismissible/nestjs-jwt-auth-hook';
|
|
23
|
+
|
|
24
|
+
@Module({
|
|
25
|
+
imports: [
|
|
26
|
+
// Configure the JWT auth hook module
|
|
27
|
+
JwtAuthHookModule.forRoot({
|
|
28
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
29
|
+
issuer: 'https://auth.example.com',
|
|
30
|
+
audience: 'my-api',
|
|
31
|
+
}),
|
|
32
|
+
|
|
33
|
+
// Pass the hook to the DismissibleModule
|
|
34
|
+
DismissibleModule.forRoot({
|
|
35
|
+
hooks: [JwtAuthHook],
|
|
36
|
+
// ... other options
|
|
37
|
+
}),
|
|
38
|
+
],
|
|
39
|
+
})
|
|
40
|
+
export class AppModule {}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Async Configuration
|
|
44
|
+
|
|
45
|
+
When configuration values come from environment variables or other async sources:
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
import { Module } from '@nestjs/common';
|
|
49
|
+
import { ConfigService } from '@nestjs/config';
|
|
50
|
+
import { DismissibleModule } from '@dismissible/nestjs-dismissible';
|
|
51
|
+
import { JwtAuthHookModule, JwtAuthHook } from '@dismissible/nestjs-jwt-auth-hook';
|
|
52
|
+
|
|
53
|
+
@Module({
|
|
54
|
+
imports: [
|
|
55
|
+
JwtAuthHookModule.forRootAsync({
|
|
56
|
+
useFactory: (configService: ConfigService) => ({
|
|
57
|
+
wellKnownUrl: configService.getOrThrow('OIDC_WELL_KNOWN_URL'),
|
|
58
|
+
issuer: configService.get('OIDC_ISSUER'),
|
|
59
|
+
audience: configService.get('OIDC_AUDIENCE'),
|
|
60
|
+
}),
|
|
61
|
+
inject: [ConfigService],
|
|
62
|
+
}),
|
|
63
|
+
|
|
64
|
+
DismissibleModule.forRoot({
|
|
65
|
+
hooks: [JwtAuthHook],
|
|
66
|
+
}),
|
|
67
|
+
],
|
|
68
|
+
})
|
|
69
|
+
export class AppModule {}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Configuration Options
|
|
73
|
+
|
|
74
|
+
| Option | Type | Required | Default | Description |
|
|
75
|
+
| ------------------- | ---------- | -------- | ----------- | ------------------------------------------------------------------------------------------- |
|
|
76
|
+
| `enabled` | `boolean` | Yes | `true` | Whether JWT authentication is enabled |
|
|
77
|
+
| `wellKnownUrl` | `string` | Yes\* | - | The OIDC well-known URL (e.g., `https://auth.example.com/.well-known/openid-configuration`) |
|
|
78
|
+
| `issuer` | `string` | No | - | Expected issuer (`iss`) claim. If not provided, issuer validation is skipped. |
|
|
79
|
+
| `audience` | `string` | No | - | Expected audience (`aud`) claim. If not provided, audience validation is skipped. |
|
|
80
|
+
| `algorithms` | `string[]` | No | `['RS256']` | Allowed algorithms for JWT verification |
|
|
81
|
+
| `jwksCacheDuration` | `number` | No | `600000` | JWKS cache duration in milliseconds (10 minutes) |
|
|
82
|
+
| `requestTimeout` | `number` | No | `30000` | Request timeout in milliseconds (30 seconds) |
|
|
83
|
+
| `priority` | `number` | No | `-100` | Hook priority (lower numbers run first) |
|
|
84
|
+
|
|
85
|
+
\* `wellKnownUrl` is only required when `enabled` is `true`.
|
|
86
|
+
|
|
87
|
+
## Environment Variables
|
|
88
|
+
|
|
89
|
+
When using the Dismissible API Docker image or the standalone API, these environment variables configure JWT authentication:
|
|
90
|
+
|
|
91
|
+
| Variable | Description | Default |
|
|
92
|
+
| ------------------------------------------ | -------------------------------------- | -------- |
|
|
93
|
+
| `DISMISSIBLE_JWT_AUTH_ENABLED` | Enable JWT authentication | `true` |
|
|
94
|
+
| `DISMISSIBLE_JWT_AUTH_WELL_KNOWN_URL` | OIDC well-known URL for JWKS discovery | `""` |
|
|
95
|
+
| `DISMISSIBLE_JWT_AUTH_ISSUER` | Expected issuer claim (optional) | `""` |
|
|
96
|
+
| `DISMISSIBLE_JWT_AUTH_AUDIENCE` | Expected audience claim (optional) | `""` |
|
|
97
|
+
| `DISMISSIBLE_JWT_AUTH_ALGORITHMS` | Allowed algorithms (comma-separated) | `RS256` |
|
|
98
|
+
| `DISMISSIBLE_JWT_AUTH_JWKS_CACHE_DURATION` | JWKS cache duration in ms | `600000` |
|
|
99
|
+
| `DISMISSIBLE_JWT_AUTH_REQUEST_TIMEOUT` | Request timeout in ms | `30000` |
|
|
100
|
+
| `DISMISSIBLE_JWT_AUTH_PRIORITY` | Hook priority (lower runs first) | `-100` |
|
|
101
|
+
|
|
102
|
+
### Example: Disabling JWT Auth for Development
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
docker run -p 3001:3001 \
|
|
106
|
+
-e DISMISSIBLE_JWT_AUTH_ENABLED=false \
|
|
107
|
+
-e DISMISSIBLE_POSTGRES_STORAGE_CONNECTION_STRING="postgresql://..." \
|
|
108
|
+
dismissibleio/dismissible-api:latest
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Example: Enabling JWT Auth with Auth0
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
docker run -p 3001:3001 \
|
|
115
|
+
-e DISMISSIBLE_JWT_AUTH_ENABLED=true \
|
|
116
|
+
-e DISMISSIBLE_JWT_AUTH_WELL_KNOWN_URL="https://your-tenant.auth0.com/.well-known/openid-configuration" \
|
|
117
|
+
-e DISMISSIBLE_JWT_AUTH_ISSUER="https://your-tenant.auth0.com/" \
|
|
118
|
+
-e DISMISSIBLE_JWT_AUTH_AUDIENCE="your-api-identifier" \
|
|
119
|
+
-e DISMISSIBLE_POSTGRES_STORAGE_CONNECTION_STRING="postgresql://..." \
|
|
120
|
+
dismissibleio/dismissible-api:latest
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## How It Works
|
|
124
|
+
|
|
125
|
+
1. **Initialization**: On module initialization, the hook fetches the OIDC configuration from the well-known URL to discover the JWKS endpoint.
|
|
126
|
+
|
|
127
|
+
2. **Token Extraction**: For each request, the hook extracts the bearer token from the `Authorization` header.
|
|
128
|
+
|
|
129
|
+
3. **Token Validation**: The token is validated by:
|
|
130
|
+
- Decoding the JWT to get the key ID (`kid`)
|
|
131
|
+
- Fetching the corresponding public key from JWKS
|
|
132
|
+
- Verifying the signature
|
|
133
|
+
- Validating claims (expiration, issuer, audience)
|
|
134
|
+
|
|
135
|
+
4. **Request Handling**:
|
|
136
|
+
- If valid: The request proceeds
|
|
137
|
+
- If invalid: The request is blocked with a `403 Forbidden` response
|
|
138
|
+
|
|
139
|
+
## Error Responses
|
|
140
|
+
|
|
141
|
+
When authentication fails, the hook returns a structured error:
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{
|
|
145
|
+
"statusCode": 403,
|
|
146
|
+
"message": "Authorization failed: Token expired",
|
|
147
|
+
"error": "Forbidden"
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Common error messages:
|
|
152
|
+
|
|
153
|
+
- `Authorization required: Missing or invalid bearer token`
|
|
154
|
+
- `Authorization failed: Token expired`
|
|
155
|
+
- `Authorization failed: Invalid signature`
|
|
156
|
+
- `Authorization failed: Unable to find signing key`
|
|
157
|
+
|
|
158
|
+
## Supported OIDC Providers
|
|
159
|
+
|
|
160
|
+
This hook works with any OIDC-compliant identity provider, including:
|
|
161
|
+
|
|
162
|
+
- Auth0
|
|
163
|
+
- Okta
|
|
164
|
+
- Keycloak
|
|
165
|
+
- Azure AD
|
|
166
|
+
- Google Identity Platform
|
|
167
|
+
- AWS Cognito
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT
|
package/jest.config.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
displayName: 'jwt-auth-hook',
|
|
3
|
+
preset: '../../jest.preset.js',
|
|
4
|
+
testEnvironment: 'node',
|
|
5
|
+
transform: {
|
|
6
|
+
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.json' }],
|
|
7
|
+
},
|
|
8
|
+
moduleFileExtensions: ['ts', 'js', 'html'],
|
|
9
|
+
coverageDirectory: '../../coverage/libs/jwt-auth-hook',
|
|
10
|
+
collectCoverageFrom: [
|
|
11
|
+
'src/**/*.ts',
|
|
12
|
+
'!src/**/*.spec.ts',
|
|
13
|
+
'!src/**/*.interface.ts',
|
|
14
|
+
'!src/**/*.dto.ts',
|
|
15
|
+
'!src/**/*.enum.ts',
|
|
16
|
+
'!src/**/index.ts',
|
|
17
|
+
'!src/**/*.module.ts',
|
|
18
|
+
],
|
|
19
|
+
coverageThreshold: {
|
|
20
|
+
global: {
|
|
21
|
+
branches: 80,
|
|
22
|
+
functions: 80,
|
|
23
|
+
lines: 80,
|
|
24
|
+
statements: 80,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dismissible/nestjs-jwt-auth-hook",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "JWT authentication hook for Dismissible applications using OIDC well-known discovery",
|
|
5
|
+
"main": "./src/index.js",
|
|
6
|
+
"types": "./src/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./src/index.mjs",
|
|
10
|
+
"require": "./src/index.js",
|
|
11
|
+
"types": "./src/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"jwks-rsa": "^3.1.0",
|
|
16
|
+
"jsonwebtoken": "^9.0.0"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@nestjs/axios": "^4.0.0",
|
|
20
|
+
"@nestjs/common": "^11.0.0",
|
|
21
|
+
"@nestjs/core": "^11.0.0",
|
|
22
|
+
"@dismissible/nestjs-dismissible": "*",
|
|
23
|
+
"@dismissible/nestjs-logger": "*"
|
|
24
|
+
},
|
|
25
|
+
"peerDependenciesMeta": {
|
|
26
|
+
"@nestjs/axios": {
|
|
27
|
+
"optional": false
|
|
28
|
+
},
|
|
29
|
+
"@nestjs/common": {
|
|
30
|
+
"optional": false
|
|
31
|
+
},
|
|
32
|
+
"@nestjs/core": {
|
|
33
|
+
"optional": false
|
|
34
|
+
},
|
|
35
|
+
"@dismissible/nestjs-dismissible": {
|
|
36
|
+
"optional": false
|
|
37
|
+
},
|
|
38
|
+
"@dismissible/nestjs-logger": {
|
|
39
|
+
"optional": false
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"nestjs",
|
|
44
|
+
"jwt",
|
|
45
|
+
"authentication",
|
|
46
|
+
"jwks",
|
|
47
|
+
"oidc",
|
|
48
|
+
"dismissible",
|
|
49
|
+
"hook"
|
|
50
|
+
],
|
|
51
|
+
"author": "",
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"repository": {
|
|
54
|
+
"type": "git",
|
|
55
|
+
"url": "https://github.com/DismissibleIo/dismissible-api"
|
|
56
|
+
},
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/project.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jwt-auth-hook",
|
|
3
|
+
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
4
|
+
"sourceRoot": "libs/jwt-auth-hook/src",
|
|
5
|
+
"projectType": "library",
|
|
6
|
+
"tags": [],
|
|
7
|
+
"targets": {
|
|
8
|
+
"build": {
|
|
9
|
+
"executor": "@nx/js:tsc",
|
|
10
|
+
"outputs": ["{options.outputPath}"],
|
|
11
|
+
"options": {
|
|
12
|
+
"outputPath": "dist/libs/jwt-auth-hook",
|
|
13
|
+
"main": "libs/jwt-auth-hook/src/index.ts",
|
|
14
|
+
"tsConfig": "libs/jwt-auth-hook/tsconfig.lib.json",
|
|
15
|
+
"assets": ["libs/jwt-auth-hook/package.json", "libs/jwt-auth-hook/README.md"],
|
|
16
|
+
"generatePackageJson": true
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"lint": {
|
|
20
|
+
"executor": "@nx/eslint:lint",
|
|
21
|
+
"outputs": ["{options.outputFile}"],
|
|
22
|
+
"options": {
|
|
23
|
+
"lintFilePatterns": ["libs/jwt-auth-hook/**/*.ts"]
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"test": {
|
|
27
|
+
"executor": "@nx/jest:jest",
|
|
28
|
+
"outputs": ["{workspaceRoot}/coverage/libs/jwt-auth-hook"],
|
|
29
|
+
"options": {
|
|
30
|
+
"jestConfig": "libs/jwt-auth-hook/jest.config.ts",
|
|
31
|
+
"passWithNoTests": true
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"npm-publish": {
|
|
35
|
+
"executor": "nx:run-commands",
|
|
36
|
+
"options": {
|
|
37
|
+
"command": "npm publish --access public",
|
|
38
|
+
"cwd": "dist/libs/jwt-auth-hook"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { plainToInstance } from 'class-transformer';
|
|
3
|
+
import { validate } from 'class-validator';
|
|
4
|
+
import { JwtAuthHookConfig } from './jwt-auth-hook.config';
|
|
5
|
+
|
|
6
|
+
describe('JwtAuthHookConfig', () => {
|
|
7
|
+
describe('enabled property', () => {
|
|
8
|
+
it('should transform boolean true to true', async () => {
|
|
9
|
+
const config = plainToInstance(JwtAuthHookConfig, {
|
|
10
|
+
enabled: true,
|
|
11
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
expect(config.enabled).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should transform boolean false to false', async () => {
|
|
18
|
+
const config = plainToInstance(JwtAuthHookConfig, {
|
|
19
|
+
enabled: false,
|
|
20
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(config.enabled).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should transform string "true" to boolean true', async () => {
|
|
27
|
+
const config = plainToInstance(JwtAuthHookConfig, {
|
|
28
|
+
enabled: 'true',
|
|
29
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(config.enabled).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should transform string "false" to boolean false', async () => {
|
|
36
|
+
const config = plainToInstance(JwtAuthHookConfig, {
|
|
37
|
+
enabled: 'false',
|
|
38
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(config.enabled).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should transform string "True" (case insensitive) to boolean true', async () => {
|
|
45
|
+
const config = plainToInstance(JwtAuthHookConfig, {
|
|
46
|
+
enabled: 'True',
|
|
47
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(config.enabled).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should convert non-true string values to false', async () => {
|
|
54
|
+
const config = plainToInstance(JwtAuthHookConfig, {
|
|
55
|
+
enabled: 'other',
|
|
56
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// The transform converts string values: 'true' -> true, anything else -> false
|
|
60
|
+
expect(config.enabled).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('wellKnownUrl validation', () => {
|
|
65
|
+
it('should require wellKnownUrl when enabled is true', async () => {
|
|
66
|
+
const config = plainToInstance(JwtAuthHookConfig, {
|
|
67
|
+
enabled: true,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const errors = await validate(config);
|
|
71
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
72
|
+
expect(errors.some((e) => e.property === 'wellKnownUrl')).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should not require wellKnownUrl when enabled is false', async () => {
|
|
76
|
+
const config = plainToInstance(JwtAuthHookConfig, {
|
|
77
|
+
enabled: false,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const errors = await validate(config);
|
|
81
|
+
// wellKnownUrl should not be required when enabled is false
|
|
82
|
+
expect(errors.some((e) => e.property === 'wellKnownUrl')).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should validate wellKnownUrl is a valid URL', async () => {
|
|
86
|
+
const config = plainToInstance(JwtAuthHookConfig, {
|
|
87
|
+
enabled: true,
|
|
88
|
+
wellKnownUrl: 'not-a-valid-url',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const errors = await validate(config);
|
|
92
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
93
|
+
expect(errors.some((e) => e.property === 'wellKnownUrl')).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('optional properties', () => {
|
|
98
|
+
it('should accept optional issuer', async () => {
|
|
99
|
+
const config = plainToInstance(JwtAuthHookConfig, {
|
|
100
|
+
enabled: true,
|
|
101
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
102
|
+
issuer: 'https://auth.example.com',
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(config.issuer).toBe('https://auth.example.com');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should accept optional audience', async () => {
|
|
109
|
+
const config = plainToInstance(JwtAuthHookConfig, {
|
|
110
|
+
enabled: true,
|
|
111
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
112
|
+
audience: 'my-api',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(config.audience).toBe('my-api');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should accept optional algorithms array', async () => {
|
|
119
|
+
const config = plainToInstance(JwtAuthHookConfig, {
|
|
120
|
+
enabled: true,
|
|
121
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
122
|
+
algorithms: ['RS256', 'RS384'],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(config.algorithms).toEqual(['RS256', 'RS384']);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should transform jwksCacheDuration to number', async () => {
|
|
129
|
+
const config = plainToInstance(JwtAuthHookConfig, {
|
|
130
|
+
enabled: true,
|
|
131
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
132
|
+
jwksCacheDuration: '600000',
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(config.jwksCacheDuration).toBe(600000);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should transform requestTimeout to number', async () => {
|
|
139
|
+
const config = plainToInstance(JwtAuthHookConfig, {
|
|
140
|
+
enabled: true,
|
|
141
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
142
|
+
requestTimeout: '30000',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(config.requestTimeout).toBe(30000);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should transform priority to number', async () => {
|
|
149
|
+
const config = plainToInstance(JwtAuthHookConfig, {
|
|
150
|
+
enabled: true,
|
|
151
|
+
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
152
|
+
priority: '-100',
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(config.priority).toBe(-100);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IsString,
|
|
3
|
+
IsUrl,
|
|
4
|
+
IsOptional,
|
|
5
|
+
IsArray,
|
|
6
|
+
IsNumber,
|
|
7
|
+
IsBoolean,
|
|
8
|
+
ValidateIf,
|
|
9
|
+
} from 'class-validator';
|
|
10
|
+
import { Type } from 'class-transformer';
|
|
11
|
+
import { TransformBoolean } from '@dismissible/nestjs-validation';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Injection token for JWT auth hook configuration.
|
|
15
|
+
*/
|
|
16
|
+
export const JWT_AUTH_HOOK_CONFIG = Symbol('JWT_AUTH_HOOK_CONFIG');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Configuration options for JWT authentication hook.
|
|
20
|
+
*/
|
|
21
|
+
export class JwtAuthHookConfig {
|
|
22
|
+
@IsBoolean()
|
|
23
|
+
@TransformBoolean()
|
|
24
|
+
public readonly enabled!: boolean;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The OpenID Connect well-known URL (e.g., https://auth.example.com/.well-known/openid-configuration).
|
|
28
|
+
* The JWKS URI will be fetched from this endpoint.
|
|
29
|
+
*/
|
|
30
|
+
@ValidateIf((o) => o.enabled === true)
|
|
31
|
+
@IsUrl()
|
|
32
|
+
public readonly wellKnownUrl!: string;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Optional: Expected issuer claim (iss) to validate.
|
|
36
|
+
* If not provided, issuer validation is skipped.
|
|
37
|
+
*/
|
|
38
|
+
@IsOptional()
|
|
39
|
+
@IsString()
|
|
40
|
+
public readonly issuer?: string;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Optional: Expected audience claim (aud) to validate.
|
|
44
|
+
* If not provided, audience validation is skipped.
|
|
45
|
+
*/
|
|
46
|
+
@IsOptional()
|
|
47
|
+
@IsString()
|
|
48
|
+
public readonly audience?: string;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Optional: Allowed algorithms for JWT verification.
|
|
52
|
+
* Defaults to ['RS256'].
|
|
53
|
+
*/
|
|
54
|
+
@IsOptional()
|
|
55
|
+
@IsArray()
|
|
56
|
+
@IsString({ each: true })
|
|
57
|
+
public readonly algorithms?: string[];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Optional: Cache duration in milliseconds for JWKS.
|
|
61
|
+
* Defaults to 600000 (10 minutes).
|
|
62
|
+
*/
|
|
63
|
+
@IsOptional()
|
|
64
|
+
@IsNumber()
|
|
65
|
+
@Type(() => Number)
|
|
66
|
+
public readonly jwksCacheDuration?: number;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Optional: Request timeout in milliseconds.
|
|
70
|
+
* Defaults to 30000 (30 seconds).
|
|
71
|
+
*/
|
|
72
|
+
@IsOptional()
|
|
73
|
+
@IsNumber()
|
|
74
|
+
@Type(() => Number)
|
|
75
|
+
public readonly requestTimeout?: number;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Optional: Hook priority (lower numbers run first).
|
|
79
|
+
* Defaults to -100 (runs early for authentication).
|
|
80
|
+
*/
|
|
81
|
+
@IsOptional()
|
|
82
|
+
@IsNumber()
|
|
83
|
+
@Type(() => Number)
|
|
84
|
+
public readonly priority?: number;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Optional: Verify that the userId parameter matches the JWT subject (sub) claim.
|
|
88
|
+
* Defaults to true for security. Set to false for service-to-service scenarios.
|
|
89
|
+
*/
|
|
90
|
+
@IsOptional()
|
|
91
|
+
@IsBoolean()
|
|
92
|
+
@TransformBoolean(true) // Default to true if not provided
|
|
93
|
+
public readonly verifyUserIdMatch?: boolean;
|
|
94
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Module, DynamicModule, InjectionToken } from '@nestjs/common';
|
|
2
|
+
import { HttpModule } from '@nestjs/axios';
|
|
3
|
+
import { JwtAuthHook } from './jwt-auth.hook';
|
|
4
|
+
import { JwtAuthService } from './jwt-auth.service';
|
|
5
|
+
import { JWT_AUTH_HOOK_CONFIG, JwtAuthHookConfig } from './jwt-auth-hook.config';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Async module options for JWT auth hook.
|
|
9
|
+
*/
|
|
10
|
+
export interface IJwtAuthHookModuleAsyncOptions {
|
|
11
|
+
useFactory: (...args: unknown[]) => JwtAuthHookConfig | Promise<JwtAuthHookConfig>;
|
|
12
|
+
inject?: InjectionToken[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Module that provides JWT authentication hook for Dismissible.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* import { DismissibleModule } from '@dismissible/nestjs-dismissible';
|
|
21
|
+
* import { JwtAuthHookModule, JwtAuthHook } from '@dismissible/nestjs-jwt-auth-hook';
|
|
22
|
+
*
|
|
23
|
+
* @Module({
|
|
24
|
+
* imports: [
|
|
25
|
+
* JwtAuthHookModule.forRoot({
|
|
26
|
+
* wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
27
|
+
* issuer: 'https://auth.example.com',
|
|
28
|
+
* audience: 'my-api',
|
|
29
|
+
* }),
|
|
30
|
+
* DismissibleModule.forRoot({
|
|
31
|
+
* hooks: [JwtAuthHook],
|
|
32
|
+
* // ... other options
|
|
33
|
+
* }),
|
|
34
|
+
* ],
|
|
35
|
+
* })
|
|
36
|
+
* export class AppModule {}
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
@Module({})
|
|
40
|
+
export class JwtAuthHookModule {
|
|
41
|
+
static forRoot(config: JwtAuthHookConfig): DynamicModule {
|
|
42
|
+
return {
|
|
43
|
+
module: JwtAuthHookModule,
|
|
44
|
+
imports: [HttpModule],
|
|
45
|
+
providers: [
|
|
46
|
+
{
|
|
47
|
+
provide: JWT_AUTH_HOOK_CONFIG,
|
|
48
|
+
useValue: config,
|
|
49
|
+
},
|
|
50
|
+
JwtAuthService,
|
|
51
|
+
JwtAuthHook,
|
|
52
|
+
],
|
|
53
|
+
exports: [JwtAuthHook, JwtAuthService, JWT_AUTH_HOOK_CONFIG],
|
|
54
|
+
global: true,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create module with async configuration.
|
|
60
|
+
* Useful when config values come from environment or other async sources.
|
|
61
|
+
*/
|
|
62
|
+
static forRootAsync(options: IJwtAuthHookModuleAsyncOptions): DynamicModule {
|
|
63
|
+
return {
|
|
64
|
+
module: JwtAuthHookModule,
|
|
65
|
+
imports: [HttpModule],
|
|
66
|
+
providers: [
|
|
67
|
+
{
|
|
68
|
+
provide: JWT_AUTH_HOOK_CONFIG,
|
|
69
|
+
useFactory: options.useFactory,
|
|
70
|
+
inject: options.inject ?? [],
|
|
71
|
+
},
|
|
72
|
+
JwtAuthService,
|
|
73
|
+
JwtAuthHook,
|
|
74
|
+
],
|
|
75
|
+
exports: [JwtAuthHook, JwtAuthService, JWT_AUTH_HOOK_CONFIG],
|
|
76
|
+
global: true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|