@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
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { Injectable, Inject, OnModuleInit } from '@nestjs/common';
|
|
2
|
+
import { HttpService } from '@nestjs/axios';
|
|
3
|
+
import { JwksClient, SigningKey } from 'jwks-rsa';
|
|
4
|
+
import * as jwt from 'jsonwebtoken';
|
|
5
|
+
import { firstValueFrom } from 'rxjs';
|
|
6
|
+
import { JWT_AUTH_HOOK_CONFIG, JwtAuthHookConfig } from './jwt-auth-hook.config';
|
|
7
|
+
import { DISMISSIBLE_LOGGER, IDismissibleLogger } from '@dismissible/nestjs-logger';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Decoded JWT payload.
|
|
11
|
+
*/
|
|
12
|
+
export interface IJwtPayload {
|
|
13
|
+
sub?: string;
|
|
14
|
+
iss?: string;
|
|
15
|
+
aud?: string | string[];
|
|
16
|
+
exp?: number;
|
|
17
|
+
iat?: number;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Result of JWT validation.
|
|
23
|
+
*/
|
|
24
|
+
export interface IJwtValidationResult {
|
|
25
|
+
valid: boolean;
|
|
26
|
+
payload?: IJwtPayload;
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Service responsible for JWT validation using JWKS.
|
|
32
|
+
*/
|
|
33
|
+
@Injectable()
|
|
34
|
+
export class JwtAuthService implements OnModuleInit {
|
|
35
|
+
private jwksClient: JwksClient | null = null;
|
|
36
|
+
private jwksUri: string | null = null;
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly httpService: HttpService,
|
|
40
|
+
@Inject(JWT_AUTH_HOOK_CONFIG)
|
|
41
|
+
private readonly config: JwtAuthHookConfig,
|
|
42
|
+
@Inject(DISMISSIBLE_LOGGER)
|
|
43
|
+
private readonly logger: IDismissibleLogger,
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
async onModuleInit(): Promise<void> {
|
|
47
|
+
if (this.config.enabled) {
|
|
48
|
+
await this.initializeJwksClient();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Initialize the JWKS client by fetching the well-known configuration.
|
|
54
|
+
*/
|
|
55
|
+
async initializeJwksClient(): Promise<void> {
|
|
56
|
+
try {
|
|
57
|
+
this.logger.debug('Fetching OpenID configuration', {
|
|
58
|
+
wellKnownUrl: this.config.wellKnownUrl,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const response = await firstValueFrom(
|
|
62
|
+
this.httpService.get<{ jwks_uri?: string }>(this.config.wellKnownUrl, {
|
|
63
|
+
timeout: this.config.requestTimeout ?? 30000,
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const openIdConfig = response.data;
|
|
68
|
+
|
|
69
|
+
if (!openIdConfig.jwks_uri) {
|
|
70
|
+
throw new Error('No jwks_uri found in OpenID configuration');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.jwksUri = openIdConfig.jwks_uri;
|
|
74
|
+
|
|
75
|
+
this.jwksClient = new JwksClient({
|
|
76
|
+
jwksUri: this.jwksUri,
|
|
77
|
+
cache: true,
|
|
78
|
+
cacheMaxAge: this.config.jwksCacheDuration ?? 600000,
|
|
79
|
+
timeout: this.config.requestTimeout ?? 30000,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
this.logger.info('JWKS client initialized successfully', {
|
|
83
|
+
jwksUri: this.jwksUri,
|
|
84
|
+
});
|
|
85
|
+
} catch (error) {
|
|
86
|
+
this.logger.error(
|
|
87
|
+
'Failed to initialize JWKS client',
|
|
88
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
89
|
+
{ wellKnownUrl: this.config.wellKnownUrl },
|
|
90
|
+
);
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Extract the bearer token from the Authorization header.
|
|
97
|
+
*/
|
|
98
|
+
extractBearerToken(authorizationHeader: string | undefined): string | null {
|
|
99
|
+
if (!authorizationHeader) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const parts = authorizationHeader.split(' ');
|
|
104
|
+
if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return parts[1];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Validate a JWT token.
|
|
113
|
+
*/
|
|
114
|
+
async validateToken(token: string): Promise<IJwtValidationResult> {
|
|
115
|
+
if (!this.jwksClient) {
|
|
116
|
+
return {
|
|
117
|
+
valid: false,
|
|
118
|
+
error: 'JWKS client not initialized',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
// Decode the token header to get the key ID (kid)
|
|
124
|
+
const decoded = jwt.decode(token, { complete: true });
|
|
125
|
+
|
|
126
|
+
if (!decoded || typeof decoded === 'string') {
|
|
127
|
+
return {
|
|
128
|
+
valid: false,
|
|
129
|
+
error: 'Invalid token format',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const kid = decoded.header.kid;
|
|
134
|
+
if (!kid) {
|
|
135
|
+
return {
|
|
136
|
+
valid: false,
|
|
137
|
+
error: 'Token missing key ID (kid)',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Get the signing key from JWKS
|
|
142
|
+
let signingKey: SigningKey;
|
|
143
|
+
try {
|
|
144
|
+
signingKey = await this.jwksClient.getSigningKey(kid);
|
|
145
|
+
} catch {
|
|
146
|
+
return {
|
|
147
|
+
valid: false,
|
|
148
|
+
error: 'Unable to find signing key',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const publicKey = signingKey.getPublicKey();
|
|
153
|
+
|
|
154
|
+
// Verify the token
|
|
155
|
+
const verifyOptions: jwt.VerifyOptions = {
|
|
156
|
+
algorithms: (this.config.algorithms as jwt.Algorithm[]) ?? ['RS256'],
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (this.config.issuer) {
|
|
160
|
+
verifyOptions.issuer = this.config.issuer;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (this.config.audience) {
|
|
164
|
+
verifyOptions.audience = this.config.audience;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const payload = jwt.verify(token, publicKey, verifyOptions) as IJwtPayload;
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
valid: true,
|
|
171
|
+
payload,
|
|
172
|
+
};
|
|
173
|
+
} catch (error) {
|
|
174
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
175
|
+
|
|
176
|
+
this.logger.debug('Token validation failed', {
|
|
177
|
+
error: errorMessage,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
valid: false,
|
|
182
|
+
error: errorMessage,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "../../dist/out-tsc",
|
|
5
|
+
"declaration": true,
|
|
6
|
+
"module": "commonjs",
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"emitDecoratorMetadata": true,
|
|
9
|
+
"experimentalDecorators": true,
|
|
10
|
+
"target": "ES2021"
|
|
11
|
+
},
|
|
12
|
+
"exclude": ["node_modules", "**/*.spec.ts", "**/*.test.ts"],
|
|
13
|
+
"include": ["src/**/*.ts"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "../../dist/out-tsc",
|
|
5
|
+
"module": "commonjs",
|
|
6
|
+
"types": ["node", "jest"],
|
|
7
|
+
"emitDecoratorMetadata": true,
|
|
8
|
+
"experimentalDecorators": true,
|
|
9
|
+
"target": "ES2021"
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
|
12
|
+
}
|