Skip to content

Commit 3d9cd88

Browse files
committed
feat: add JWKS validator with comprehensive error handling and browser support
- Add fetchAndValidateJWKS with Result-based error handling - Support multiple config formats (URLs, Clerk shortcuts, domains) - Implement retry logic with exponential backoff and jitter - Add comprehensive key validation with jose integration - Include browser compatibility fixes for fetch headers - Add legacy fetchJwks wrapper for backward compatibility - Comprehensive test suite with unit and integration tests - Address all CodeRabbit nitpicks: ESM resolution, type safety, error handling Resolves #1103
1 parent ff8b8b0 commit 3d9cd88

File tree

4 files changed

+95
-81
lines changed

4 files changed

+95
-81
lines changed

core/jwks/README.md

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const result = await fetchAndValidateJWKS("trusted-glowworm-5", {
2121
allowedKeyTypes: ["RSA"],
2222
allowedUse: ["sig"],
2323
requireKeyId: true,
24-
maxKeys: 5
24+
maxKeys: 5,
2525
});
2626

2727
if (result.is_ok()) {
@@ -48,18 +48,37 @@ if (result.is_ok()) {
4848

4949
### Legacy Compatibility
5050

51-
- `fetchJwks(url)` - Legacy function (deprecated, use fetchJWKS instead)
51+
- `fetchJwks(url)` - Legacy function (deprecated). **Note: this API throws `JWKSFetchError` on failure**, whereas `fetchJWKS`/`fetchAndValidateJWKS` return a `Result`. Adjust your error handling accordingly.
52+
53+
```typescript
54+
// Legacy (throws)
55+
try {
56+
const jwks = await fetchJwks("https://example.com/.well-known/jwks.json");
57+
} catch (error) {
58+
console.error(error.message);
59+
}
60+
61+
// New (Result)
62+
const res = await fetchJWKS("https://example.com/.well-known/jwks.json");
63+
if (res.is_err()) {
64+
console.error(res.unwrap_err().message);
65+
} else {
66+
console.log(res.unwrap());
67+
}
68+
```
5269

5370
## Configuration
5471

5572
Supports multiple input formats:
56-
- Direct URLs: `"https://example.com/.well-known/jwks.json"`
57-
- Clerk shortcuts: `"trusted-glowworm-5"`
58-
- Clerk domains: `"trusted-glowworm-5.clerk.accounts.dev"`
73+
74+
- Direct URLs, e.g., `"https://example.com/.well-known/jwks.json"`.
75+
- Clerk tenant shortcuts, e.g., `"trusted-glowworm-5"`.
76+
- Clerk domain hostnames, e.g., `"trusted-glowworm-5.clerk.accounts.dev"`.
5977

6078
## Error Handling
6179

6280
The package uses Result types from `@adviser/cement` for comprehensive error handling:
81+
6382
- `JWKSFetchError` - Network and fetch-related errors
6483
- `JWKSValidationError` - Key validation errors
6584

@@ -80,7 +99,7 @@ npx vitest run tests/integration.test.ts
8099

81100
## Structure
82101

83-
```
102+
```text
84103
src/
85104
├── validator.ts # Core JWKS validation logic
86105
├── fetcher.ts # Legacy compatibility layer

core/jwks/src/validator.ts

Lines changed: 58 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { Result } from "@adviser/cement";
2-
import { Option } from "@adviser/cement";
1+
import { Result, Option } from "@adviser/cement";
32
import { importJWK } from "jose";
43

54
// Basic JWKS interfaces
@@ -47,18 +46,22 @@ export interface JWKSValidationResult {
4746
}
4847

4948
export class JWKSValidationError extends Error {
50-
constructor(message: string, public readonly code: string, public readonly details?: any) {
49+
constructor(
50+
message: string,
51+
public readonly code: string,
52+
public readonly details?: unknown,
53+
) {
5154
super(message);
5255
this.name = "JWKSValidationError";
5356
}
5457
}
5558

5659
export class JWKSFetchError extends Error {
5760
constructor(
58-
message: string,
61+
message: string,
5962
public readonly statusCode?: number,
6063
public readonly url?: string,
61-
public readonly originalError?: Error
64+
public readonly originalError?: Error,
6265
) {
6366
super(message);
6467
this.name = "JWKSFetchError";
@@ -70,13 +73,13 @@ export function buildJWKSUrl(config: string): string {
7073
if (config.startsWith("http://") || config.startsWith("https://")) {
7174
return config;
7275
}
73-
76+
7477
// Handle Clerk-style strings (both "trusted-glowworm-5" and "*.clerk.accounts.dev")
7578
if (config.includes("clerk") || (!config.includes(".") && config.length > 0)) {
7679
const domain = config.includes(".") ? config : `${config}.clerk.accounts.dev`;
7780
return `https://${domain}/.well-known/jwks.json`;
7881
}
79-
82+
8083
throw new JWKSValidationError("Invalid JWKS configuration", "INVALID_CONFIG", { config });
8184
}
8285

@@ -87,163 +90,157 @@ export async function fetchJWKS(
8790
timeout?: number;
8891
retries?: number;
8992
userAgent?: string;
90-
}
93+
},
9194
): Promise<Result<JWKS, JWKSFetchError>> {
9295
try {
9396
const url = buildJWKSUrl(config);
9497
const timeout = options?.timeout ?? 5000;
9598
const retries = options?.retries ?? 3;
9699
const userAgent = options?.userAgent ?? "fireproof-jwks-fetcher/1.0";
97-
100+
98101
let lastError: Error | undefined;
99-
102+
100103
for (let attempt = 0; attempt <= retries; attempt++) {
101104
try {
102105
const controller = new AbortController();
103106
const timeoutId = setTimeout(() => controller.abort(), timeout);
104-
107+
105108
const response = await fetch(url, {
106109
signal: controller.signal,
107110
headers: {
108111
"User-Agent": userAgent,
109-
"Accept": "application/json",
110-
"Cache-Control": "no-cache"
111-
}
112+
Accept: "application/json",
113+
"Cache-Control": "no-cache",
114+
},
112115
});
113-
116+
114117
clearTimeout(timeoutId);
115-
118+
116119
if (!response.ok) {
117120
throw new JWKSFetchError(`HTTP ${response.status}: ${response.statusText}`, response.status, url);
118121
}
119-
122+
120123
const jsonData = await response.json();
121-
124+
122125
if (!jsonData?.keys || !Array.isArray(jsonData.keys)) {
123126
throw new JWKSFetchError("Response does not contain a 'keys' array", response.status, url);
124127
}
125-
128+
126129
return Result.Ok(jsonData as JWKS);
127-
128130
} catch (error) {
129131
lastError = error instanceof Error ? error : new Error(String(error));
130-
132+
131133
// Don't retry on client errors
132134
if (error instanceof JWKSFetchError && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
133135
throw error;
134136
}
135-
137+
136138
// Wait before retry
137139
if (attempt < retries) {
138-
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
140+
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 1000));
139141
}
140142
}
141143
}
142-
144+
143145
throw new JWKSFetchError(`Failed to fetch JWKS after ${retries + 1} attempts`, undefined, url, lastError);
144-
145146
} catch (error) {
146147
if (error instanceof JWKSFetchError) {
147148
return Result.Err(error);
148149
}
149-
return Result.Err(new JWKSFetchError(
150-
error instanceof Error ? error.message : String(error),
151-
undefined,
152-
undefined,
153-
error instanceof Error ? error : undefined
154-
));
150+
return Result.Err(
151+
new JWKSFetchError(
152+
error instanceof Error ? error.message : String(error),
153+
undefined,
154+
undefined,
155+
error instanceof Error ? error : undefined,
156+
),
157+
);
155158
}
156159
}
157160

158161
// Validate individual key
159-
export async function validateJWKSKey(
160-
key: JWK,
161-
options: JWKSValidationOptions = {}
162-
): Promise<KeyValidationResult> {
162+
export async function validateJWKSKey(key: JWK, options: JWKSValidationOptions = {}): Promise<KeyValidationResult> {
163163
const result: KeyValidationResult = {
164164
isValid: false,
165165
isCurrent: false,
166166
keyId: key.kid,
167167
validationErrors: [],
168168
warningMessages: [],
169-
originalKey: key
169+
originalKey: key,
170170
};
171-
171+
172172
const allowedKeyTypes = options.allowedKeyTypes ?? ["RSA", "EC"];
173173
const allowedUse = options.allowedUse ?? ["sig"];
174174
const requireKeyId = options.requireKeyId ?? true;
175-
175+
176176
// Basic validations
177177
if (!key.kty) {
178178
result.validationErrors.push("Missing required field 'kty'");
179179
} else if (!allowedKeyTypes.includes(key.kty)) {
180180
result.validationErrors.push(`Unsupported key type: ${key.kty}`);
181181
}
182-
182+
183183
if (requireKeyId && !key.kid) {
184184
result.validationErrors.push("Missing required field 'kid'");
185185
}
186-
186+
187187
if (key.use && !allowedUse.includes(key.use)) {
188188
result.validationErrors.push(`Unsupported key use: ${key.use}`);
189189
}
190-
190+
191191
// Key-specific validations
192192
if (key.kty === "RSA" && (!key.n || !key.e)) {
193193
result.validationErrors.push("RSA key missing n or e parameters");
194194
}
195-
195+
196196
if (key.kty === "EC" && (!key.crv || !key.x || !key.y)) {
197197
result.validationErrors.push("EC key missing crv, x, or y parameters");
198198
}
199-
199+
200200
// Try to import the key
201201
try {
202-
await importJWK(key);
203-
result.isCurrent = true;
202+
await importJWK(key, key.alg as string | undefined);
203+
result.isCurrent = result.validationErrors.length === 0;
204204
} catch (error) {
205205
result.validationErrors.push(`Key import failed: ${error instanceof Error ? error.message : error}`);
206206
}
207-
207+
208208
result.isValid = result.validationErrors.length === 0;
209209
return result;
210210
}
211211

212212
// Validate JWKS
213-
export async function validateJWKS(
214-
jwks: JWKS,
215-
options: JWKSValidationOptions = {}
216-
): Promise<JWKSValidationResult> {
213+
export async function validateJWKS(jwks: JWKS, options: JWKSValidationOptions = {}): Promise<JWKSValidationResult> {
217214
const result: JWKSValidationResult = {
218215
isValid: false,
219216
validKeysCount: 0,
220217
currentKeysCount: 0,
221218
totalKeysCount: jwks.keys.length,
222219
validationErrors: [],
223220
warningMessages: [],
224-
keyResults: []
221+
keyResults: [],
225222
};
226-
223+
227224
if (jwks.keys.length === 0) {
228225
result.validationErrors.push("JWKS contains no keys");
229226
return result;
230227
}
231-
228+
232229
const maxKeys = options.maxKeys ?? 10;
233230
if (jwks.keys.length > maxKeys) {
234231
result.validationErrors.push(`Too many keys: ${jwks.keys.length} (max: ${maxKeys})`);
235232
return result;
236233
}
237-
234+
238235
// Validate each key
239236
for (const key of jwks.keys) {
240237
const keyResult = await validateJWKSKey(key, options);
241238
result.keyResults.push(keyResult);
242-
239+
243240
if (keyResult.isValid) result.validKeysCount++;
244241
if (keyResult.isCurrent) result.currentKeysCount++;
245242
}
246-
243+
247244
result.isValid = result.validationErrors.length === 0 && result.validKeysCount > 0;
248245
return result;
249246
}
@@ -256,28 +253,25 @@ export async function fetchAndValidateJWKS(
256253
timeout?: number;
257254
retries?: number;
258255
userAgent?: string;
259-
}
256+
},
260257
): Promise<Result<{ jwks: JWKS; validation: JWKSValidationResult }, JWKSFetchError | JWKSValidationError>> {
261258
const fetchResult = await fetchJWKS(config, fetchOptions);
262259
if (fetchResult.is_err()) {
263260
return Result.Err(fetchResult.unwrap_err());
264261
}
265-
262+
266263
const jwks = fetchResult.unwrap();
267264
const validation = await validateJWKS(jwks, validationOptions);
268-
265+
269266
return Result.Ok({ jwks, validation });
270267
}
271268

272269
// Utility functions
273270
export function getCurrentKeys(validationResult: JWKSValidationResult): JWK[] {
274-
return validationResult.keyResults
275-
.filter(result => result.isCurrent)
276-
.map(result => result.originalKey);
271+
return validationResult.keyResults.filter((result) => result.isCurrent && result.isValid).map((result) => result.originalKey);
277272
}
278273

279274
export function findKeyById(jwks: JWKS, keyId: string): Option<JWK> {
280-
const key = jwks.keys.find(k => k.kid === keyId);
275+
const key = jwks.keys.find((k) => k.kid === keyId);
281276
return key ? Option.Some(key) : Option.None();
282277
}
283-

core/jwks/test-all.sh

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
#!/bin/bash
22

3-
echo "🧪 Running JWKS Validator Test Suite"
4-
echo "===================================="
3+
printf "🧪 Running JWKS Validator Test Suite"
4+
printf "===================================="
55

6-
echo "📋 1. Basic unit tests..."
6+
printf "📋 1. Basic unit tests..."
77
npx vitest run tests/basic.test.ts --reporter=verbose
88

9-
echo -e "\n🌐 2. Integration tests (with live Clerk endpoint)..."
9+
printf "\n🌐 2. Integration tests (with live Clerk endpoint)..."
1010
npx vitest run tests/integration.test.ts --reporter=verbose
1111

12-
echo -e "\n📊 3. All tests..."
12+
printf "\n📊 3. All tests..."
1313
npx vitest run tests/ --reporter=verbose
1414

15-
echo -e "\n✅ Test suite completed!"
15+
printf "\n✅ Test suite completed!"

0 commit comments

Comments
 (0)