@peac/jwks-cache 0.10.8 → 0.10.10
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/LICENSE +1 -1
- package/dist/cache.d.ts +20 -1
- package/dist/cache.d.ts.map +1 -1
- package/dist/index.cjs +458 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +443 -13
- package/dist/index.js.map +1 -1
- package/dist/resolver.d.ts +5 -2
- package/dist/resolver.d.ts.map +1 -1
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +16 -9
- package/dist/cache.js +0 -69
- package/dist/cache.js.map +0 -1
- package/dist/errors.js +0 -48
- package/dist/errors.js.map +0 -1
- package/dist/resolver.js +0 -266
- package/dist/resolver.js.map +0 -1
- package/dist/security.js +0 -88
- package/dist/security.js.map +0 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +0 -1
package/LICENSE
CHANGED
|
@@ -175,7 +175,7 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
|
175
175
|
|
|
176
176
|
END OF TERMS AND CONDITIONS
|
|
177
177
|
|
|
178
|
-
Copyright 2025 PEAC Protocol Contributors
|
|
178
|
+
Copyright 2025-2026 PEAC Protocol Contributors
|
|
179
179
|
|
|
180
180
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
181
181
|
you may not use this file except in compliance with the License.
|
package/dist/cache.d.ts
CHANGED
|
@@ -2,12 +2,27 @@
|
|
|
2
2
|
* In-memory cache implementation.
|
|
3
3
|
*/
|
|
4
4
|
import type { CacheBackend, CacheEntry } from './types.js';
|
|
5
|
+
export interface InMemoryCacheOptions {
|
|
6
|
+
/** Max entries before LRU eviction (default: 1000). */
|
|
7
|
+
maxEntries?: number;
|
|
8
|
+
}
|
|
5
9
|
/**
|
|
6
|
-
*
|
|
10
|
+
* In-memory cache with TTL, bounded size, and stale-if-error support.
|
|
11
|
+
*
|
|
12
|
+
* Uses Map insertion order for LRU eviction: oldest entries are evicted
|
|
13
|
+
* first when maxEntries is exceeded. On get(), entries are re-inserted
|
|
14
|
+
* to refresh their position (most-recently-used).
|
|
7
15
|
*/
|
|
8
16
|
export declare class InMemoryCache implements CacheBackend {
|
|
9
17
|
private readonly cache;
|
|
18
|
+
private readonly maxEntries;
|
|
19
|
+
constructor(options?: InMemoryCacheOptions);
|
|
10
20
|
get(key: string): Promise<CacheEntry | null>;
|
|
21
|
+
/**
|
|
22
|
+
* Get entry even if expired (for stale-if-error fallback).
|
|
23
|
+
* Returns null only if key was never cached.
|
|
24
|
+
*/
|
|
25
|
+
getStale(key: string): Promise<CacheEntry | null>;
|
|
11
26
|
set(key: string, value: CacheEntry): Promise<void>;
|
|
12
27
|
delete(key: string): Promise<void>;
|
|
13
28
|
/**
|
|
@@ -18,6 +33,10 @@ export declare class InMemoryCache implements CacheBackend {
|
|
|
18
33
|
* Get current cache size.
|
|
19
34
|
*/
|
|
20
35
|
get size(): number;
|
|
36
|
+
/**
|
|
37
|
+
* Evict oldest entries (by Map insertion order) when over capacity.
|
|
38
|
+
*/
|
|
39
|
+
private evictIfNeeded;
|
|
21
40
|
}
|
|
22
41
|
/**
|
|
23
42
|
* Build cache key for a specific key ID.
|
package/dist/cache.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAI3D,MAAM,WAAW,oBAAoB;IACnC,uDAAuD;IACvD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;GAMG;AACH,qBAAa,aAAc,YAAW,YAAY;IAChD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiC;IACvD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;gBAExB,OAAO,CAAC,EAAE,oBAAoB;IAIpC,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAoBlD;;;OAGG;IACG,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAIjD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IASlD,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIxC;;OAEG;IACH,KAAK,IAAI,IAAI;IAIb;;OAEG;IACH,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED;;OAEG;IACH,OAAO,CAAC,aAAa;CAUtB;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAEvE;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAWlF"}
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/cache.ts
|
|
4
|
+
var DEFAULT_MAX_ENTRIES = 1e3;
|
|
5
|
+
var InMemoryCache = class {
|
|
6
|
+
cache = /* @__PURE__ */ new Map();
|
|
7
|
+
maxEntries;
|
|
8
|
+
constructor(options) {
|
|
9
|
+
this.maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
10
|
+
}
|
|
11
|
+
async get(key) {
|
|
12
|
+
const entry = this.cache.get(key);
|
|
13
|
+
if (!entry) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
17
|
+
if (now >= entry.expiresAt) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
this.cache.delete(key);
|
|
21
|
+
this.cache.set(key, entry);
|
|
22
|
+
return entry;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Get entry even if expired (for stale-if-error fallback).
|
|
26
|
+
* Returns null only if key was never cached.
|
|
27
|
+
*/
|
|
28
|
+
async getStale(key) {
|
|
29
|
+
return this.cache.get(key) ?? null;
|
|
30
|
+
}
|
|
31
|
+
async set(key, value) {
|
|
32
|
+
if (this.cache.has(key)) {
|
|
33
|
+
this.cache.delete(key);
|
|
34
|
+
}
|
|
35
|
+
this.cache.set(key, value);
|
|
36
|
+
this.evictIfNeeded();
|
|
37
|
+
}
|
|
38
|
+
async delete(key) {
|
|
39
|
+
this.cache.delete(key);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Clear all entries.
|
|
43
|
+
*/
|
|
44
|
+
clear() {
|
|
45
|
+
this.cache.clear();
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Get current cache size.
|
|
49
|
+
*/
|
|
50
|
+
get size() {
|
|
51
|
+
return this.cache.size;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Evict oldest entries (by Map insertion order) when over capacity.
|
|
55
|
+
*/
|
|
56
|
+
evictIfNeeded() {
|
|
57
|
+
while (this.cache.size > this.maxEntries) {
|
|
58
|
+
const oldestKey = this.cache.keys().next().value;
|
|
59
|
+
if (oldestKey !== void 0) {
|
|
60
|
+
this.cache.delete(oldestKey);
|
|
61
|
+
} else {
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
function buildCacheKey(issuerOrigin, kid) {
|
|
68
|
+
return `${issuerOrigin}:${kid}`;
|
|
69
|
+
}
|
|
70
|
+
function buildJwksCacheKey(issuerOrigin) {
|
|
71
|
+
return `${issuerOrigin}:__jwks__`;
|
|
72
|
+
}
|
|
73
|
+
function parseCacheControlMaxAge(cacheControl) {
|
|
74
|
+
if (!cacheControl) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const match = cacheControl.match(/max-age=(\d+)/);
|
|
78
|
+
if (!match) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return parseInt(match[1], 10);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/errors.ts
|
|
85
|
+
var ErrorCodes = {
|
|
86
|
+
/** Network error fetching JWKS */
|
|
87
|
+
JWKS_FETCH_FAILED: "E_JWKS_FETCH_FAILED",
|
|
88
|
+
/** Fetch timeout */
|
|
89
|
+
JWKS_TIMEOUT: "E_JWKS_TIMEOUT",
|
|
90
|
+
/** Invalid JSON or structure */
|
|
91
|
+
JWKS_INVALID: "E_JWKS_INVALID",
|
|
92
|
+
/** Response > 1MB */
|
|
93
|
+
JWKS_TOO_LARGE: "E_JWKS_TOO_LARGE",
|
|
94
|
+
/** keys.length > 100 */
|
|
95
|
+
JWKS_TOO_MANY_KEYS: "E_JWKS_TOO_MANY_KEYS",
|
|
96
|
+
/** Private IP or metadata URL blocked */
|
|
97
|
+
SSRF_BLOCKED: "E_SSRF_BLOCKED",
|
|
98
|
+
/** Requested kid not in JWKS */
|
|
99
|
+
KEY_NOT_FOUND: "E_KEY_NOT_FOUND",
|
|
100
|
+
/** All discovery paths failed */
|
|
101
|
+
ALL_PATHS_FAILED: "E_ALL_PATHS_FAILED"
|
|
102
|
+
};
|
|
103
|
+
var ErrorHttpStatus = {
|
|
104
|
+
[ErrorCodes.JWKS_FETCH_FAILED]: 502,
|
|
105
|
+
[ErrorCodes.JWKS_TIMEOUT]: 504,
|
|
106
|
+
[ErrorCodes.JWKS_INVALID]: 502,
|
|
107
|
+
[ErrorCodes.JWKS_TOO_LARGE]: 502,
|
|
108
|
+
[ErrorCodes.JWKS_TOO_MANY_KEYS]: 502,
|
|
109
|
+
[ErrorCodes.SSRF_BLOCKED]: 403,
|
|
110
|
+
[ErrorCodes.KEY_NOT_FOUND]: 401,
|
|
111
|
+
[ErrorCodes.ALL_PATHS_FAILED]: 502
|
|
112
|
+
};
|
|
113
|
+
var JwksError = class extends Error {
|
|
114
|
+
code;
|
|
115
|
+
httpStatus;
|
|
116
|
+
constructor(code, message) {
|
|
117
|
+
super(message);
|
|
118
|
+
this.name = "JwksError";
|
|
119
|
+
this.code = code;
|
|
120
|
+
this.httpStatus = ErrorHttpStatus[code];
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// src/security.ts
|
|
125
|
+
function validateUrl(url, options = {}) {
|
|
126
|
+
let parsed;
|
|
127
|
+
try {
|
|
128
|
+
parsed = new URL(url);
|
|
129
|
+
} catch {
|
|
130
|
+
throw new JwksError(ErrorCodes.SSRF_BLOCKED, `Invalid URL: ${url}`);
|
|
131
|
+
}
|
|
132
|
+
if (parsed.protocol !== "https:") {
|
|
133
|
+
if (parsed.protocol === "http:" && options.allowLocalhost && isLocalhostHost(parsed.hostname)) ; else {
|
|
134
|
+
throw new JwksError(ErrorCodes.SSRF_BLOCKED, `HTTPS required, got ${parsed.protocol}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (isLocalhostHost(parsed.hostname) && !options.allowLocalhost) {
|
|
138
|
+
throw new JwksError(ErrorCodes.SSRF_BLOCKED, `Localhost blocked: ${parsed.hostname}`);
|
|
139
|
+
}
|
|
140
|
+
if (isLiteralIp(parsed.hostname)) {
|
|
141
|
+
throw new JwksError(
|
|
142
|
+
ErrorCodes.SSRF_BLOCKED,
|
|
143
|
+
`Literal IP addresses blocked: ${parsed.hostname}`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (options.isAllowedHost && !options.isAllowedHost(parsed.hostname)) {
|
|
147
|
+
throw new JwksError(ErrorCodes.SSRF_BLOCKED, `Host not in allowlist: ${parsed.hostname}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function isLocalhostHost(hostname) {
|
|
151
|
+
const lower = hostname.toLowerCase();
|
|
152
|
+
return lower === "localhost" || lower === "127.0.0.1" || lower === "::1" || lower === "[::1]" || lower.endsWith(".localhost");
|
|
153
|
+
}
|
|
154
|
+
function isLiteralIp(hostname) {
|
|
155
|
+
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (hostname.includes(":")) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
function isMetadataIp(hostname) {
|
|
167
|
+
if (hostname === "169.254.169.254") {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
if (hostname.startsWith("169.254.")) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// src/resolver.ts
|
|
177
|
+
var DEFAULT_TTL_SECONDS = 3600;
|
|
178
|
+
var MAX_TTL_SECONDS = 86400;
|
|
179
|
+
var MIN_TTL_SECONDS = 60;
|
|
180
|
+
var DEFAULT_TIMEOUT_MS = 5e3;
|
|
181
|
+
var DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
182
|
+
var DEFAULT_MAX_KEYS = 100;
|
|
183
|
+
var DEFAULT_MAX_STALE_AGE_SECONDS = 172800;
|
|
184
|
+
function createResolver(options = {}) {
|
|
185
|
+
const cache = options.cache ?? new InMemoryCache();
|
|
186
|
+
const {
|
|
187
|
+
defaultTtlSeconds = DEFAULT_TTL_SECONDS,
|
|
188
|
+
maxTtlSeconds = MAX_TTL_SECONDS,
|
|
189
|
+
minTtlSeconds = MIN_TTL_SECONDS,
|
|
190
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
191
|
+
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES,
|
|
192
|
+
maxKeys = DEFAULT_MAX_KEYS,
|
|
193
|
+
isAllowedHost,
|
|
194
|
+
allowLocalhost = false,
|
|
195
|
+
allowStale = false,
|
|
196
|
+
maxStaleAgeSeconds = DEFAULT_MAX_STALE_AGE_SECONDS
|
|
197
|
+
} = options;
|
|
198
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
199
|
+
return async (issuer, keyid) => {
|
|
200
|
+
const flightKey = `${issuer}:${keyid}`;
|
|
201
|
+
const existing = inflight.get(flightKey);
|
|
202
|
+
if (existing) {
|
|
203
|
+
const resolvedKey2 = await existing;
|
|
204
|
+
return resolvedKey2 ? createJwkVerifier(resolvedKey2.jwk) : null;
|
|
205
|
+
}
|
|
206
|
+
const promise = resolveKey(issuer, keyid, {
|
|
207
|
+
cache,
|
|
208
|
+
defaultTtlSeconds,
|
|
209
|
+
maxTtlSeconds,
|
|
210
|
+
minTtlSeconds,
|
|
211
|
+
timeoutMs,
|
|
212
|
+
maxResponseBytes,
|
|
213
|
+
maxKeys,
|
|
214
|
+
isAllowedHost,
|
|
215
|
+
allowLocalhost,
|
|
216
|
+
allowStale,
|
|
217
|
+
maxStaleAgeSeconds
|
|
218
|
+
}).finally(() => {
|
|
219
|
+
inflight.delete(flightKey);
|
|
220
|
+
});
|
|
221
|
+
inflight.set(flightKey, promise);
|
|
222
|
+
const resolvedKey = await promise;
|
|
223
|
+
if (!resolvedKey) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
return createJwkVerifier(resolvedKey.jwk);
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
async function resolveKey(issuer, keyid, options) {
|
|
230
|
+
const { cache, isAllowedHost, allowLocalhost, allowStale, maxStaleAgeSeconds } = options;
|
|
231
|
+
const issuerOrigin = new URL(issuer).origin;
|
|
232
|
+
const cacheKey = buildCacheKey(issuerOrigin, keyid);
|
|
233
|
+
const cached = await cache.get(cacheKey);
|
|
234
|
+
if (cached) {
|
|
235
|
+
return {
|
|
236
|
+
jwk: cached.jwk,
|
|
237
|
+
source: "/.well-known/jwks",
|
|
238
|
+
cached: true
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const paths = [
|
|
242
|
+
{
|
|
243
|
+
url: `${issuerOrigin}/.well-known/jwks`,
|
|
244
|
+
source: "/.well-known/jwks",
|
|
245
|
+
isSingleKey: false
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
url: `${issuerOrigin}/keys?keyID=${encodeURIComponent(keyid)}`,
|
|
249
|
+
source: "/keys",
|
|
250
|
+
isSingleKey: true
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
url: `${issuerOrigin}/.well-known/jwks.json`,
|
|
254
|
+
source: "/.well-known/jwks.json",
|
|
255
|
+
isSingleKey: false
|
|
256
|
+
}
|
|
257
|
+
];
|
|
258
|
+
const errors = [];
|
|
259
|
+
for (const path of paths) {
|
|
260
|
+
try {
|
|
261
|
+
validateUrl(path.url, { isAllowedHost, allowLocalhost });
|
|
262
|
+
const result = await fetchWithTimeout(path.url, options.timeoutMs);
|
|
263
|
+
const contentLength = result.headers.get("content-length");
|
|
264
|
+
if (contentLength && parseInt(contentLength, 10) > options.maxResponseBytes) {
|
|
265
|
+
throw new JwksError(
|
|
266
|
+
ErrorCodes.JWKS_TOO_LARGE,
|
|
267
|
+
`Response too large: ${contentLength} bytes`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
const text = await result.text();
|
|
271
|
+
if (text.length > options.maxResponseBytes) {
|
|
272
|
+
throw new JwksError(ErrorCodes.JWKS_TOO_LARGE, `Response too large: ${text.length} bytes`);
|
|
273
|
+
}
|
|
274
|
+
let data;
|
|
275
|
+
try {
|
|
276
|
+
data = JSON.parse(text);
|
|
277
|
+
} catch {
|
|
278
|
+
throw new JwksError(ErrorCodes.JWKS_INVALID, "Invalid JSON response");
|
|
279
|
+
}
|
|
280
|
+
let jwk = null;
|
|
281
|
+
if (path.isSingleKey) {
|
|
282
|
+
jwk = validateJwk(data);
|
|
283
|
+
} else {
|
|
284
|
+
const jwks = validateJwks(data, options.maxKeys);
|
|
285
|
+
jwk = findKey(jwks, keyid);
|
|
286
|
+
}
|
|
287
|
+
if (!jwk) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const cacheControlMaxAge = parseCacheControlMaxAge(result.headers.get("cache-control"));
|
|
291
|
+
const ttl = calculateTtl(
|
|
292
|
+
cacheControlMaxAge,
|
|
293
|
+
options.defaultTtlSeconds,
|
|
294
|
+
options.minTtlSeconds,
|
|
295
|
+
options.maxTtlSeconds
|
|
296
|
+
);
|
|
297
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
298
|
+
await cache.set(cacheKey, {
|
|
299
|
+
jwk,
|
|
300
|
+
expiresAt: now + ttl,
|
|
301
|
+
etag: result.headers.get("etag") ?? void 0
|
|
302
|
+
});
|
|
303
|
+
return {
|
|
304
|
+
jwk,
|
|
305
|
+
source: path.source,
|
|
306
|
+
cached: false
|
|
307
|
+
};
|
|
308
|
+
} catch (error) {
|
|
309
|
+
errors.push(error);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (errors.length > 0) {
|
|
313
|
+
const allTransient = errors.every(
|
|
314
|
+
(e) => e instanceof JwksError && (e.code === ErrorCodes.JWKS_FETCH_FAILED || e.code === ErrorCodes.JWKS_TIMEOUT)
|
|
315
|
+
);
|
|
316
|
+
if (allowStale && allTransient && "getStale" in cache && typeof cache.getStale === "function") {
|
|
317
|
+
const staleEntry = await cache.getStale(cacheKey);
|
|
318
|
+
if (staleEntry) {
|
|
319
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
320
|
+
const staleAge = now - staleEntry.expiresAt;
|
|
321
|
+
if (staleAge >= 0 && staleAge <= maxStaleAgeSeconds) {
|
|
322
|
+
return {
|
|
323
|
+
jwk: staleEntry.jwk,
|
|
324
|
+
source: "/.well-known/jwks",
|
|
325
|
+
cached: true,
|
|
326
|
+
stale: true,
|
|
327
|
+
staleAgeSeconds: staleAge,
|
|
328
|
+
keyExpiredAt: staleEntry.expiresAt
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const lastError = errors[errors.length - 1];
|
|
334
|
+
if (lastError instanceof JwksError) {
|
|
335
|
+
throw lastError;
|
|
336
|
+
}
|
|
337
|
+
throw new JwksError(
|
|
338
|
+
ErrorCodes.ALL_PATHS_FAILED,
|
|
339
|
+
`All discovery paths failed for ${issuerOrigin}`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
async function fetchWithTimeout(url, timeoutMs) {
|
|
345
|
+
const controller = new AbortController();
|
|
346
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
347
|
+
try {
|
|
348
|
+
const response = await fetch(url, {
|
|
349
|
+
signal: controller.signal,
|
|
350
|
+
redirect: "error",
|
|
351
|
+
// No redirect following (fail-closed)
|
|
352
|
+
headers: {
|
|
353
|
+
Accept: "application/json"
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
if (!response.ok) {
|
|
357
|
+
throw new JwksError(
|
|
358
|
+
ErrorCodes.JWKS_FETCH_FAILED,
|
|
359
|
+
`HTTP ${response.status}: ${response.statusText}`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
return response;
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (error.name === "AbortError") {
|
|
365
|
+
throw new JwksError(ErrorCodes.JWKS_TIMEOUT, `Fetch timeout after ${timeoutMs}ms`);
|
|
366
|
+
}
|
|
367
|
+
if (error instanceof JwksError) {
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
throw new JwksError(ErrorCodes.JWKS_FETCH_FAILED, `Fetch failed: ${error.message}`);
|
|
371
|
+
} finally {
|
|
372
|
+
clearTimeout(timeout);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function validateJwk(data) {
|
|
376
|
+
if (!data || typeof data !== "object") {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
const jwk = data;
|
|
380
|
+
if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || typeof jwk.x !== "string") {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
kty: jwk.kty,
|
|
385
|
+
crv: jwk.crv,
|
|
386
|
+
x: jwk.x,
|
|
387
|
+
kid: typeof jwk.kid === "string" ? jwk.kid : void 0,
|
|
388
|
+
use: typeof jwk.use === "string" ? jwk.use : void 0,
|
|
389
|
+
alg: typeof jwk.alg === "string" ? jwk.alg : void 0
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function validateJwks(data, maxKeys) {
|
|
393
|
+
if (!data || typeof data !== "object") {
|
|
394
|
+
throw new JwksError(ErrorCodes.JWKS_INVALID, "Invalid JWKS structure");
|
|
395
|
+
}
|
|
396
|
+
const jwks = data;
|
|
397
|
+
if (!Array.isArray(jwks.keys)) {
|
|
398
|
+
throw new JwksError(ErrorCodes.JWKS_INVALID, "JWKS must have keys array");
|
|
399
|
+
}
|
|
400
|
+
if (jwks.keys.length > maxKeys) {
|
|
401
|
+
throw new JwksError(
|
|
402
|
+
ErrorCodes.JWKS_TOO_MANY_KEYS,
|
|
403
|
+
`Too many keys: ${jwks.keys.length} > ${maxKeys}`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
return { keys: jwks.keys };
|
|
407
|
+
}
|
|
408
|
+
function findKey(jwks, keyid) {
|
|
409
|
+
for (const key of jwks.keys) {
|
|
410
|
+
if (key.kid === keyid) {
|
|
411
|
+
return validateJwk(key);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
function calculateTtl(cacheControlMaxAge, defaultTtl, minTtl, maxTtl) {
|
|
417
|
+
let ttl = cacheControlMaxAge ?? defaultTtl;
|
|
418
|
+
ttl = Math.max(minTtl, ttl);
|
|
419
|
+
ttl = Math.min(maxTtl, ttl);
|
|
420
|
+
return ttl;
|
|
421
|
+
}
|
|
422
|
+
async function importJwkAsEd25519(jwk) {
|
|
423
|
+
return globalThis.crypto.subtle.importKey(
|
|
424
|
+
"jwk",
|
|
425
|
+
{
|
|
426
|
+
kty: jwk.kty,
|
|
427
|
+
crv: jwk.crv,
|
|
428
|
+
x: jwk.x
|
|
429
|
+
},
|
|
430
|
+
{ name: "Ed25519" },
|
|
431
|
+
false,
|
|
432
|
+
["verify"]
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
async function createJwkVerifier(jwk) {
|
|
436
|
+
const key = await importJwkAsEd25519(jwk);
|
|
437
|
+
return async (data, signature) => {
|
|
438
|
+
const sigBuffer = new Uint8Array(signature).buffer;
|
|
439
|
+
const dataBuffer = new Uint8Array(data).buffer;
|
|
440
|
+
return globalThis.crypto.subtle.verify("Ed25519", key, sigBuffer, dataBuffer);
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
exports.ErrorCodes = ErrorCodes;
|
|
445
|
+
exports.ErrorHttpStatus = ErrorHttpStatus;
|
|
446
|
+
exports.InMemoryCache = InMemoryCache;
|
|
447
|
+
exports.JwksError = JwksError;
|
|
448
|
+
exports.buildCacheKey = buildCacheKey;
|
|
449
|
+
exports.buildJwksCacheKey = buildJwksCacheKey;
|
|
450
|
+
exports.createJwkVerifier = createJwkVerifier;
|
|
451
|
+
exports.createResolver = createResolver;
|
|
452
|
+
exports.importJwkAsEd25519 = importJwkAsEd25519;
|
|
453
|
+
exports.isMetadataIp = isMetadataIp;
|
|
454
|
+
exports.parseCacheControlMaxAge = parseCacheControlMaxAge;
|
|
455
|
+
exports.resolveKey = resolveKey;
|
|
456
|
+
exports.validateUrl = validateUrl;
|
|
457
|
+
//# sourceMappingURL=index.cjs.map
|
|
458
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cache.ts","../src/errors.ts","../src/security.ts","../src/resolver.ts"],"names":["resolvedKey"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * In-memory cache implementation.\n */\n\nimport type { CacheBackend, CacheEntry } from './types.js';\n\nconst DEFAULT_MAX_ENTRIES = 1000;\n\nexport interface InMemoryCacheOptions {\n /** Max entries before LRU eviction (default: 1000). */\n maxEntries?: number;\n}\n\n/**\n * In-memory cache with TTL, bounded size, and stale-if-error support.\n *\n * Uses Map insertion order for LRU eviction: oldest entries are evicted\n * first when maxEntries is exceeded. On get(), entries are re-inserted\n * to refresh their position (most-recently-used).\n */\nexport class InMemoryCache implements CacheBackend {\n private readonly cache = new Map<string, CacheEntry>();\n private readonly maxEntries: number;\n\n constructor(options?: InMemoryCacheOptions) {\n this.maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;\n }\n\n async get(key: string): Promise<CacheEntry | null> {\n const entry = this.cache.get(key);\n\n if (!entry) {\n return null;\n }\n\n // Check expiration -- return null but KEEP entry for stale fallback\n const now = Math.floor(Date.now() / 1000);\n if (now >= entry.expiresAt) {\n return null;\n }\n\n // Refresh position in Map for LRU ordering (move to end)\n this.cache.delete(key);\n this.cache.set(key, entry);\n\n return entry;\n }\n\n /**\n * Get entry even if expired (for stale-if-error fallback).\n * Returns null only if key was never cached.\n */\n async getStale(key: string): Promise<CacheEntry | null> {\n return this.cache.get(key) ?? null;\n }\n\n async set(key: string, value: CacheEntry): Promise<void> {\n // If key already exists, delete first to refresh insertion order\n if (this.cache.has(key)) {\n this.cache.delete(key);\n }\n this.cache.set(key, value);\n this.evictIfNeeded();\n }\n\n async delete(key: string): Promise<void> {\n this.cache.delete(key);\n }\n\n /**\n * Clear all entries.\n */\n clear(): void {\n this.cache.clear();\n }\n\n /**\n * Get current cache size.\n */\n get size(): number {\n return this.cache.size;\n }\n\n /**\n * Evict oldest entries (by Map insertion order) when over capacity.\n */\n private evictIfNeeded(): void {\n while (this.cache.size > this.maxEntries) {\n const oldestKey = this.cache.keys().next().value;\n if (oldestKey !== undefined) {\n this.cache.delete(oldestKey);\n } else {\n break;\n }\n }\n }\n}\n\n/**\n * Build cache key for a specific key ID.\n */\nexport function buildCacheKey(issuerOrigin: string, kid: string): string {\n return `${issuerOrigin}:${kid}`;\n}\n\n/**\n * Build cache key for JWKS set.\n */\nexport function buildJwksCacheKey(issuerOrigin: string): string {\n return `${issuerOrigin}:__jwks__`;\n}\n\n/**\n * Parse Cache-Control header for max-age.\n *\n * @param cacheControl - Cache-Control header value\n * @returns max-age in seconds or null if not found\n */\nexport function parseCacheControlMaxAge(cacheControl: string | null): number | null {\n if (!cacheControl) {\n return null;\n }\n\n const match = cacheControl.match(/max-age=(\\d+)/);\n if (!match) {\n return null;\n }\n\n return parseInt(match[1], 10);\n}\n","/**\n * JWKS Cache error codes per execution pack specification.\n */\n\nexport const ErrorCodes = {\n /** Network error fetching JWKS */\n JWKS_FETCH_FAILED: 'E_JWKS_FETCH_FAILED',\n /** Fetch timeout */\n JWKS_TIMEOUT: 'E_JWKS_TIMEOUT',\n /** Invalid JSON or structure */\n JWKS_INVALID: 'E_JWKS_INVALID',\n /** Response > 1MB */\n JWKS_TOO_LARGE: 'E_JWKS_TOO_LARGE',\n /** keys.length > 100 */\n JWKS_TOO_MANY_KEYS: 'E_JWKS_TOO_MANY_KEYS',\n /** Private IP or metadata URL blocked */\n SSRF_BLOCKED: 'E_SSRF_BLOCKED',\n /** Requested kid not in JWKS */\n KEY_NOT_FOUND: 'E_KEY_NOT_FOUND',\n /** All discovery paths failed */\n ALL_PATHS_FAILED: 'E_ALL_PATHS_FAILED',\n} as const;\n\nexport type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];\n\n/**\n * HTTP status codes for each error.\n */\nexport const ErrorHttpStatus: Record<ErrorCode, number> = {\n [ErrorCodes.JWKS_FETCH_FAILED]: 502,\n [ErrorCodes.JWKS_TIMEOUT]: 504,\n [ErrorCodes.JWKS_INVALID]: 502,\n [ErrorCodes.JWKS_TOO_LARGE]: 502,\n [ErrorCodes.JWKS_TOO_MANY_KEYS]: 502,\n [ErrorCodes.SSRF_BLOCKED]: 403,\n [ErrorCodes.KEY_NOT_FOUND]: 401,\n [ErrorCodes.ALL_PATHS_FAILED]: 502,\n};\n\n/**\n * JWKS error with code and HTTP status.\n */\nexport class JwksError extends Error {\n readonly code: ErrorCode;\n readonly httpStatus: number;\n\n constructor(code: ErrorCode, message: string) {\n super(message);\n this.name = 'JwksError';\n this.code = code;\n this.httpStatus = ErrorHttpStatus[code];\n }\n}\n","/**\n * SSRF protection for JWKS fetching.\n *\n * Edge runtimes cannot reliably block private IPs via DNS resolution.\n * This module implements what IS possible at the edge.\n */\n\nimport { ErrorCodes, JwksError } from './errors.js';\n\n/**\n * Validate URL for SSRF protection.\n *\n * @param url - URL to validate\n * @param options - Validation options\n * @throws JwksError if URL is blocked\n */\nexport function validateUrl(\n url: string,\n options: {\n allowLocalhost?: boolean;\n isAllowedHost?: (host: string) => boolean;\n } = {}\n): void {\n let parsed: URL;\n\n try {\n parsed = new URL(url);\n } catch {\n throw new JwksError(ErrorCodes.SSRF_BLOCKED, `Invalid URL: ${url}`);\n }\n\n // HTTPS required (except localhost in dev)\n if (parsed.protocol !== 'https:') {\n if (parsed.protocol === 'http:' && options.allowLocalhost && isLocalhostHost(parsed.hostname)) {\n // Allow http://localhost in dev mode\n } else {\n throw new JwksError(ErrorCodes.SSRF_BLOCKED, `HTTPS required, got ${parsed.protocol}`);\n }\n }\n\n // Block localhost variants (unless explicitly allowed)\n if (isLocalhostHost(parsed.hostname) && !options.allowLocalhost) {\n throw new JwksError(ErrorCodes.SSRF_BLOCKED, `Localhost blocked: ${parsed.hostname}`);\n }\n\n // Block literal IP addresses in URL\n if (isLiteralIp(parsed.hostname)) {\n throw new JwksError(\n ErrorCodes.SSRF_BLOCKED,\n `Literal IP addresses blocked: ${parsed.hostname}`\n );\n }\n\n // Check enterprise allowlist if provided\n if (options.isAllowedHost && !options.isAllowedHost(parsed.hostname)) {\n throw new JwksError(ErrorCodes.SSRF_BLOCKED, `Host not in allowlist: ${parsed.hostname}`);\n }\n}\n\n/**\n * Check if hostname is localhost variant.\n */\nfunction isLocalhostHost(hostname: string): boolean {\n const lower = hostname.toLowerCase();\n return (\n lower === 'localhost' ||\n lower === '127.0.0.1' ||\n lower === '::1' ||\n lower === '[::1]' ||\n lower.endsWith('.localhost')\n );\n}\n\n/**\n * Check if hostname is a literal IP address.\n */\nfunction isLiteralIp(hostname: string): boolean {\n // IPv4 pattern\n if (/^(\\d{1,3}\\.){3}\\d{1,3}$/.test(hostname)) {\n return true;\n }\n\n // IPv6 pattern (with or without brackets)\n if (hostname.startsWith('[') && hostname.endsWith(']')) {\n return true;\n }\n\n // Colon indicates IPv6 (no brackets)\n if (hostname.includes(':')) {\n return true;\n }\n\n return false;\n}\n\n/**\n * Check if hostname is a metadata IP.\n */\nexport function isMetadataIp(hostname: string): boolean {\n // AWS/GCP/Azure metadata service\n if (hostname === '169.254.169.254') {\n return true;\n }\n\n // Link-local range (169.254.x.x)\n if (hostname.startsWith('169.254.')) {\n return true;\n }\n\n return false;\n}\n","/**\n * JWKS resolver with multi-path discovery and caching.\n */\n\nimport type { SignatureVerifier } from '@peac/http-signatures';\nimport type {\n JWK,\n JWKS,\n CacheBackend,\n ResolverOptions,\n ResolvedKey,\n JwksKeyResolver,\n} from './types.js';\nimport { ErrorCodes, JwksError } from './errors.js';\nimport { validateUrl } from './security.js';\nimport { InMemoryCache, buildCacheKey, parseCacheControlMaxAge } from './cache.js';\n\nconst DEFAULT_TTL_SECONDS = 3600; // 1 hour\nconst MAX_TTL_SECONDS = 86400; // 24 hours\nconst MIN_TTL_SECONDS = 60;\nconst DEFAULT_TIMEOUT_MS = 5000;\nconst DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024; // 1MB\nconst DEFAULT_MAX_KEYS = 100;\nconst DEFAULT_MAX_STALE_AGE_SECONDS = 172800; // 48 hours\n\n/**\n * Create a JWKS key resolver with singleflight dedup.\n *\n * Concurrent calls for the same issuer+keyid coalesce into a single\n * network request, preventing thundering herd on cache miss.\n *\n * @param options - Resolver options\n * @returns Key resolver function\n */\nexport function createResolver(options: ResolverOptions = {}): JwksKeyResolver {\n const cache = options.cache ?? new InMemoryCache();\n const {\n defaultTtlSeconds = DEFAULT_TTL_SECONDS,\n maxTtlSeconds = MAX_TTL_SECONDS,\n minTtlSeconds = MIN_TTL_SECONDS,\n timeoutMs = DEFAULT_TIMEOUT_MS,\n maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES,\n maxKeys = DEFAULT_MAX_KEYS,\n isAllowedHost,\n allowLocalhost = false,\n allowStale = false,\n maxStaleAgeSeconds = DEFAULT_MAX_STALE_AGE_SECONDS,\n } = options;\n\n // Singleflight: dedup concurrent resolves for the same issuer+keyid\n const inflight = new Map<string, Promise<ResolvedKey | null>>();\n\n return async (issuer: string, keyid: string): Promise<SignatureVerifier | null> => {\n const flightKey = `${issuer}:${keyid}`;\n const existing = inflight.get(flightKey);\n if (existing) {\n const resolvedKey = await existing;\n return resolvedKey ? createJwkVerifier(resolvedKey.jwk) : null;\n }\n\n const promise = resolveKey(issuer, keyid, {\n cache,\n defaultTtlSeconds,\n maxTtlSeconds,\n minTtlSeconds,\n timeoutMs,\n maxResponseBytes,\n maxKeys,\n isAllowedHost,\n allowLocalhost,\n allowStale,\n maxStaleAgeSeconds,\n }).finally(() => {\n inflight.delete(flightKey);\n });\n\n inflight.set(flightKey, promise);\n\n const resolvedKey = await promise;\n if (!resolvedKey) {\n return null;\n }\n\n return createJwkVerifier(resolvedKey.jwk);\n };\n}\n\n/**\n * Resolve a key by issuer and key ID.\n *\n * Discovery order (per TAP spec):\n * 1. /.well-known/jwks\n * 2. /keys?keyID=<kid>\n * 3. /.well-known/jwks.json (fallback)\n */\nexport async function resolveKey(\n issuer: string,\n keyid: string,\n options: Required<\n Pick<\n ResolverOptions,\n | 'cache'\n | 'defaultTtlSeconds'\n | 'maxTtlSeconds'\n | 'minTtlSeconds'\n | 'timeoutMs'\n | 'maxResponseBytes'\n | 'maxKeys'\n | 'allowLocalhost'\n | 'allowStale'\n | 'maxStaleAgeSeconds'\n >\n > &\n Pick<ResolverOptions, 'isAllowedHost'>\n): Promise<ResolvedKey | null> {\n const { cache, isAllowedHost, allowLocalhost, allowStale, maxStaleAgeSeconds } = options;\n\n // Normalize issuer to origin\n const issuerOrigin = new URL(issuer).origin;\n const cacheKey = buildCacheKey(issuerOrigin, keyid);\n\n // Check cache first\n const cached = await cache.get(cacheKey);\n if (cached) {\n return {\n jwk: cached.jwk,\n source: '/.well-known/jwks',\n cached: true,\n };\n }\n\n // Discovery paths in order\n const paths: Array<{\n url: string;\n source: ResolvedKey['source'];\n isSingleKey: boolean;\n }> = [\n {\n url: `${issuerOrigin}/.well-known/jwks`,\n source: '/.well-known/jwks',\n isSingleKey: false,\n },\n {\n url: `${issuerOrigin}/keys?keyID=${encodeURIComponent(keyid)}`,\n source: '/keys',\n isSingleKey: true,\n },\n {\n url: `${issuerOrigin}/.well-known/jwks.json`,\n source: '/.well-known/jwks.json',\n isSingleKey: false,\n },\n ];\n\n const errors: Error[] = [];\n\n for (const path of paths) {\n try {\n // Validate URL for SSRF\n validateUrl(path.url, { isAllowedHost, allowLocalhost });\n\n const result = await fetchWithTimeout(path.url, options.timeoutMs);\n\n // Check response size\n const contentLength = result.headers.get('content-length');\n if (contentLength && parseInt(contentLength, 10) > options.maxResponseBytes) {\n throw new JwksError(\n ErrorCodes.JWKS_TOO_LARGE,\n `Response too large: ${contentLength} bytes`\n );\n }\n\n // Parse response\n const text = await result.text();\n if (text.length > options.maxResponseBytes) {\n throw new JwksError(ErrorCodes.JWKS_TOO_LARGE, `Response too large: ${text.length} bytes`);\n }\n\n let data: unknown;\n try {\n data = JSON.parse(text);\n } catch {\n throw new JwksError(ErrorCodes.JWKS_INVALID, 'Invalid JSON response');\n }\n\n // Extract JWK\n let jwk: JWK | null = null;\n\n if (path.isSingleKey) {\n // Single key endpoint returns JWK directly\n jwk = validateJwk(data);\n } else {\n // JWKS endpoint returns key set\n const jwks = validateJwks(data, options.maxKeys);\n jwk = findKey(jwks, keyid);\n }\n\n if (!jwk) {\n continue; // Try next path\n }\n\n // Calculate TTL\n const cacheControlMaxAge = parseCacheControlMaxAge(result.headers.get('cache-control'));\n const ttl = calculateTtl(\n cacheControlMaxAge,\n options.defaultTtlSeconds,\n options.minTtlSeconds,\n options.maxTtlSeconds\n );\n\n // Cache the key\n const now = Math.floor(Date.now() / 1000);\n await cache.set(cacheKey, {\n jwk,\n expiresAt: now + ttl,\n etag: result.headers.get('etag') ?? undefined,\n });\n\n return {\n jwk,\n source: path.source,\n cached: false,\n };\n } catch (error) {\n errors.push(error as Error);\n // Continue to next path\n }\n }\n\n // All paths failed -- try stale fallback if allowed.\n // Only fall back on transient network errors. Fail closed on security policy\n // violations (SSRF), semantic errors (invalid JWKS, too many keys, too large),\n // and parse failures -- these indicate the server is misconfigured or\n // compromised, not that the network is temporarily unreachable.\n if (errors.length > 0) {\n // Fail closed: stale only if every error is a known-transient JwksError.\n // Non-JwksError (bugs, unexpected throws) must NOT widen stale eligibility.\n const allTransient = errors.every(\n (e) =>\n e instanceof JwksError &&\n (e.code === ErrorCodes.JWKS_FETCH_FAILED || e.code === ErrorCodes.JWKS_TIMEOUT)\n );\n if (allowStale && allTransient && 'getStale' in cache && typeof cache.getStale === 'function') {\n const staleEntry = await (\n cache as { getStale: (k: string) => Promise<typeof cached> }\n ).getStale(cacheKey);\n if (staleEntry) {\n const now = Math.floor(Date.now() / 1000);\n const staleAge = now - staleEntry.expiresAt;\n if (staleAge >= 0 && staleAge <= maxStaleAgeSeconds) {\n return {\n jwk: staleEntry.jwk,\n source: '/.well-known/jwks',\n cached: true,\n stale: true,\n staleAgeSeconds: staleAge,\n keyExpiredAt: staleEntry.expiresAt,\n };\n }\n }\n }\n\n const lastError = errors[errors.length - 1];\n if (lastError instanceof JwksError) {\n throw lastError;\n }\n throw new JwksError(\n ErrorCodes.ALL_PATHS_FAILED,\n `All discovery paths failed for ${issuerOrigin}`\n );\n }\n\n return null;\n}\n\n/**\n * Fetch with timeout.\n */\nasync function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), timeoutMs);\n\n try {\n const response = await fetch(url, {\n signal: controller.signal,\n redirect: 'error', // No redirect following (fail-closed)\n headers: {\n Accept: 'application/json',\n },\n });\n\n if (!response.ok) {\n throw new JwksError(\n ErrorCodes.JWKS_FETCH_FAILED,\n `HTTP ${response.status}: ${response.statusText}`\n );\n }\n\n return response;\n } catch (error) {\n if ((error as Error).name === 'AbortError') {\n throw new JwksError(ErrorCodes.JWKS_TIMEOUT, `Fetch timeout after ${timeoutMs}ms`);\n }\n if (error instanceof JwksError) {\n throw error;\n }\n throw new JwksError(ErrorCodes.JWKS_FETCH_FAILED, `Fetch failed: ${(error as Error).message}`);\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * Validate and extract JWK from response data.\n */\nfunction validateJwk(data: unknown): JWK | null {\n if (!data || typeof data !== 'object') {\n return null;\n }\n\n const jwk = data as Record<string, unknown>;\n\n if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || typeof jwk.x !== 'string') {\n return null;\n }\n\n return {\n kty: jwk.kty,\n crv: jwk.crv,\n x: jwk.x,\n kid: typeof jwk.kid === 'string' ? jwk.kid : undefined,\n use: typeof jwk.use === 'string' ? jwk.use : undefined,\n alg: typeof jwk.alg === 'string' ? jwk.alg : undefined,\n };\n}\n\n/**\n * Validate JWKS structure.\n */\nfunction validateJwks(data: unknown, maxKeys: number): JWKS {\n if (!data || typeof data !== 'object') {\n throw new JwksError(ErrorCodes.JWKS_INVALID, 'Invalid JWKS structure');\n }\n\n const jwks = data as Record<string, unknown>;\n\n if (!Array.isArray(jwks.keys)) {\n throw new JwksError(ErrorCodes.JWKS_INVALID, 'JWKS must have keys array');\n }\n\n if (jwks.keys.length > maxKeys) {\n throw new JwksError(\n ErrorCodes.JWKS_TOO_MANY_KEYS,\n `Too many keys: ${jwks.keys.length} > ${maxKeys}`\n );\n }\n\n return { keys: jwks.keys as JWK[] };\n}\n\n/**\n * Find key by ID in JWKS.\n */\nfunction findKey(jwks: JWKS, keyid: string): JWK | null {\n for (const key of jwks.keys) {\n if (key.kid === keyid) {\n return validateJwk(key);\n }\n }\n return null;\n}\n\n/**\n * Calculate TTL from Cache-Control and options.\n */\nfunction calculateTtl(\n cacheControlMaxAge: number | null,\n defaultTtl: number,\n minTtl: number,\n maxTtl: number\n): number {\n let ttl = cacheControlMaxAge ?? defaultTtl;\n ttl = Math.max(minTtl, ttl);\n ttl = Math.min(maxTtl, ttl);\n return ttl;\n}\n\n/**\n * Import a JWK as Ed25519 public key.\n *\n * Returns an opaque key object (runtime-neutral).\n * Use createJwkVerifier() for a complete SignatureVerifier.\n */\nexport async function importJwkAsEd25519(jwk: JWK): Promise<unknown> {\n return globalThis.crypto.subtle.importKey(\n 'jwk',\n {\n kty: jwk.kty,\n crv: jwk.crv,\n x: jwk.x,\n },\n { name: 'Ed25519' },\n false,\n ['verify']\n );\n}\n\n/**\n * Create a SignatureVerifier from a JWK.\n *\n * Convenience function for TAP integration.\n *\n * @param jwk - Ed25519 JWK\n * @returns SignatureVerifier function\n */\nexport async function createJwkVerifier(jwk: JWK): Promise<SignatureVerifier> {\n const key = await importJwkAsEd25519(jwk);\n\n return async (data: Uint8Array, signature: Uint8Array): Promise<boolean> => {\n // Create proper ArrayBuffer views to satisfy TypeScript\n const sigBuffer = new Uint8Array(signature).buffer;\n const dataBuffer = new Uint8Array(data).buffer;\n\n // Use type from the actual WebCrypto API to avoid DOM type dependency\n type VerifyKey = Parameters<typeof globalThis.crypto.subtle.verify>[1];\n\n return globalThis.crypto.subtle.verify('Ed25519', key as VerifyKey, sigBuffer, dataBuffer);\n };\n}\n"]}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Edge-safe JWKS fetch and cache with SSRF protection.
|
|
5
5
|
*/
|
|
6
6
|
export type { JWK, JWKS, CacheBackend, CacheEntry, ResolverOptions, ResolvedKey, JwksKeyResolver, } from './types.js';
|
|
7
|
+
export type { InMemoryCacheOptions } from './cache.js';
|
|
7
8
|
export { InMemoryCache, buildCacheKey, buildJwksCacheKey, parseCacheControlMaxAge, } from './cache.js';
|
|
8
9
|
export { validateUrl, isMetadataIp } from './security.js';
|
|
9
10
|
export { createResolver, resolveKey, importJwkAsEd25519, createJwkVerifier } from './resolver.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,YAAY,EACV,GAAG,EACH,IAAI,EACJ,YAAY,EACZ,UAAU,EACV,eAAe,EACf,WAAW,EACX,eAAe,GAChB,MAAM,YAAY,CAAC;AAGpB,OAAO,EACL,aAAa,EACb,aAAa,EACb,iBAAiB,EACjB,uBAAuB,GACxB,MAAM,YAAY,CAAC;AAGpB,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAG1D,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAGlG,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACrE,YAAY,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,YAAY,EACV,GAAG,EACH,IAAI,EACJ,YAAY,EACZ,UAAU,EACV,eAAe,EACf,WAAW,EACX,eAAe,GAChB,MAAM,YAAY,CAAC;AAGpB,YAAY,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AACvD,OAAO,EACL,aAAa,EACb,aAAa,EACb,iBAAiB,EACjB,uBAAuB,GACxB,MAAM,YAAY,CAAC;AAGpB,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAG1D,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAGlG,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACrE,YAAY,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC"}
|