vouchly 0.0.0 → 0.1.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/README.md +323 -3
- package/dist/index.d.mts +349 -0
- package/dist/index.mjs +715 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +54 -4
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
import { createLocalJWKSet, createRemoteJWKSet, customFetch, decodeJwt, decodeProtectedHeader, errors, importJWK, jwtVerify } from "jose";
|
|
2
|
+
//#region src/cache/hash.ts
|
|
3
|
+
async function hashToken(token) {
|
|
4
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
5
|
+
let output = "";
|
|
6
|
+
for (const byte of new Uint8Array(digest)) output += byte.toString(16).padStart(2, "0");
|
|
7
|
+
return output;
|
|
8
|
+
}
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region src/cache/memory.ts
|
|
11
|
+
var MemoryTokenCache = class {
|
|
12
|
+
max;
|
|
13
|
+
items;
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
const max = options.max ?? 1e4;
|
|
16
|
+
if (!Number.isSafeInteger(max) || max < 1) throw new TypeError("The token cache max must be an integer of at least 1.");
|
|
17
|
+
this.max = max;
|
|
18
|
+
this.items = /* @__PURE__ */ new Map();
|
|
19
|
+
}
|
|
20
|
+
async get(key) {
|
|
21
|
+
const item = this.items.get(key);
|
|
22
|
+
if (!item) return;
|
|
23
|
+
this.items.delete(key);
|
|
24
|
+
if (item.expiresAt <= Date.now()) return;
|
|
25
|
+
this.items.set(key, item);
|
|
26
|
+
return structuredClone(item.entry);
|
|
27
|
+
}
|
|
28
|
+
async set(key, entry, ttl) {
|
|
29
|
+
this.items.delete(key);
|
|
30
|
+
if (!(ttl > 0)) return;
|
|
31
|
+
this.items.set(key, {
|
|
32
|
+
entry: structuredClone(entry),
|
|
33
|
+
expiresAt: Date.now() + ttl * 1e3
|
|
34
|
+
});
|
|
35
|
+
while (this.items.size > this.max) {
|
|
36
|
+
const oldest = this.items.keys().next();
|
|
37
|
+
if (oldest.done) break;
|
|
38
|
+
this.items.delete(oldest.value);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/discovery/module.ts
|
|
44
|
+
const CACHE_MAX_AGE = 600;
|
|
45
|
+
const TIMEOUT$1 = 1e4;
|
|
46
|
+
const FAILURE_COOLDOWN = 3e4;
|
|
47
|
+
const items = /* @__PURE__ */ new WeakMap();
|
|
48
|
+
const flights = /* @__PURE__ */ new WeakMap();
|
|
49
|
+
const failures = /* @__PURE__ */ new WeakMap();
|
|
50
|
+
function scope(store, fetchFn) {
|
|
51
|
+
let map = store.get(fetchFn);
|
|
52
|
+
if (!map) {
|
|
53
|
+
map = /* @__PURE__ */ new Map();
|
|
54
|
+
store.set(fetchFn, map);
|
|
55
|
+
}
|
|
56
|
+
return map;
|
|
57
|
+
}
|
|
58
|
+
function isRecord(input) {
|
|
59
|
+
return typeof input === "object" && input !== null && !Array.isArray(input);
|
|
60
|
+
}
|
|
61
|
+
function isURL(input) {
|
|
62
|
+
return typeof input === "string" && URL.canParse(input);
|
|
63
|
+
}
|
|
64
|
+
async function load(issuer, fetchFn) {
|
|
65
|
+
const response = await fetchFn(`${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`, {
|
|
66
|
+
headers: { accept: "application/json" },
|
|
67
|
+
signal: AbortSignal.timeout(TIMEOUT$1)
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok) throw new Error(`The provider metadata request answered ${response.status}.`);
|
|
70
|
+
const body = await response.json();
|
|
71
|
+
if (!isRecord(body)) throw new Error("The provider metadata is not a JSON object.");
|
|
72
|
+
if (body.issuer !== issuer) throw new Error("The provider metadata names another issuer.");
|
|
73
|
+
const metadata = { issuer: body.issuer };
|
|
74
|
+
if (typeof body.jwks_uri !== "undefined") {
|
|
75
|
+
if (!isURL(body.jwks_uri)) throw new Error("The provider metadata has an invalid jwks_uri.");
|
|
76
|
+
metadata.jwks_uri = body.jwks_uri;
|
|
77
|
+
}
|
|
78
|
+
if (typeof body.introspection_endpoint !== "undefined") {
|
|
79
|
+
if (!isURL(body.introspection_endpoint)) throw new Error("The provider metadata has an invalid introspection_endpoint.");
|
|
80
|
+
metadata.introspection_endpoint = body.introspection_endpoint;
|
|
81
|
+
}
|
|
82
|
+
return metadata;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Reads `<issuer>/.well-known/openid-configuration`. The issuer must come
|
|
86
|
+
* from configuration, never from a token. Answers are reused for
|
|
87
|
+
* cacheMaxAge seconds; after a failure, the issuer is not asked again for
|
|
88
|
+
* 30 seconds and calls fail fast instead.
|
|
89
|
+
*/
|
|
90
|
+
async function fetchProviderMetadata(issuer, fetchFn, options = {}) {
|
|
91
|
+
const cacheMaxAge = options.cacheMaxAge ?? CACHE_MAX_AGE;
|
|
92
|
+
const cached = scope(items, fetchFn);
|
|
93
|
+
const item = cached.get(issuer);
|
|
94
|
+
if (item && Date.now() - item.fetchedAt < cacheMaxAge * 1e3) return structuredClone(item.metadata);
|
|
95
|
+
const failed = scope(failures, fetchFn);
|
|
96
|
+
const failedAt = failed.get(issuer);
|
|
97
|
+
if (typeof failedAt === "number") {
|
|
98
|
+
if (Date.now() - failedAt < FAILURE_COOLDOWN) throw new Error("The provider metadata failed to load recently, not trying again yet.");
|
|
99
|
+
failed.delete(issuer);
|
|
100
|
+
}
|
|
101
|
+
const pending = scope(flights, fetchFn);
|
|
102
|
+
let flight = pending.get(issuer);
|
|
103
|
+
if (!flight) {
|
|
104
|
+
flight = load(issuer, fetchFn).then((metadata) => {
|
|
105
|
+
cached.set(issuer, {
|
|
106
|
+
metadata,
|
|
107
|
+
fetchedAt: Date.now()
|
|
108
|
+
});
|
|
109
|
+
return metadata;
|
|
110
|
+
}, (e) => {
|
|
111
|
+
failed.set(issuer, Date.now());
|
|
112
|
+
throw e;
|
|
113
|
+
}).finally(() => {
|
|
114
|
+
pending.delete(issuer);
|
|
115
|
+
});
|
|
116
|
+
pending.set(issuer, flight);
|
|
117
|
+
}
|
|
118
|
+
return structuredClone(await flight);
|
|
119
|
+
}
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/error/constants.ts
|
|
122
|
+
let VouchlyErrorCode = /* @__PURE__ */ function(VouchlyErrorCode) {
|
|
123
|
+
VouchlyErrorCode["TOKEN_MALFORMED"] = "token_malformed";
|
|
124
|
+
VouchlyErrorCode["TOKEN_EXPIRED"] = "token_expired";
|
|
125
|
+
VouchlyErrorCode["TOKEN_INACTIVE"] = "token_inactive";
|
|
126
|
+
VouchlyErrorCode["ISSUER_UNTRUSTED"] = "issuer_untrusted";
|
|
127
|
+
VouchlyErrorCode["KEY_UNAVAILABLE"] = "key_unavailable";
|
|
128
|
+
VouchlyErrorCode["SIGNATURE_INVALID"] = "signature_invalid";
|
|
129
|
+
VouchlyErrorCode["CLAIM_INVALID"] = "claim_invalid";
|
|
130
|
+
VouchlyErrorCode["INTROSPECTION_FAILED"] = "introspection_failed";
|
|
131
|
+
VouchlyErrorCode["BINDING_MISMATCH"] = "binding_mismatch";
|
|
132
|
+
return VouchlyErrorCode;
|
|
133
|
+
}({});
|
|
134
|
+
const VOUCHLY_ERROR_MARKER = Symbol.for("vouchly.error");
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/error/module.ts
|
|
137
|
+
var VouchlyError = class extends Error {
|
|
138
|
+
code;
|
|
139
|
+
[VOUCHLY_ERROR_MARKER] = true;
|
|
140
|
+
constructor(code, message, options) {
|
|
141
|
+
super(message || code, options);
|
|
142
|
+
this.name = "VouchlyError";
|
|
143
|
+
this.code = code;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
function isVouchlyError(input) {
|
|
147
|
+
if (typeof input !== "object" || input === null) return false;
|
|
148
|
+
return input[VOUCHLY_ERROR_MARKER] === true;
|
|
149
|
+
}
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/introspection/module.ts
|
|
152
|
+
const TIMEOUT = 1e4;
|
|
153
|
+
function fail(message, cause) {
|
|
154
|
+
return new VouchlyError("introspection_failed", message, typeof cause === "undefined" ? void 0 : { cause });
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* application/x-www-form-urlencoded, as RFC 6749 2.3.1 asks for client credentials.
|
|
158
|
+
*/
|
|
159
|
+
function formEncode(value) {
|
|
160
|
+
return new URLSearchParams([["", value]]).toString().slice(1);
|
|
161
|
+
}
|
|
162
|
+
async function resolveEndpoint(issuer, fetchFn) {
|
|
163
|
+
if (issuer.introspection?.endpoint) return issuer.introspection.endpoint;
|
|
164
|
+
if (typeof issuer.issuer !== "string") throw fail("The issuer has no introspection endpoint.");
|
|
165
|
+
let endpoint;
|
|
166
|
+
try {
|
|
167
|
+
endpoint = (await fetchProviderMetadata(issuer.issuer, fetchFn)).introspection_endpoint;
|
|
168
|
+
} catch (e) {
|
|
169
|
+
throw fail("The introspection endpoint could not be discovered.", e);
|
|
170
|
+
}
|
|
171
|
+
if (!endpoint) throw fail("The issuer advertises no introspection endpoint.");
|
|
172
|
+
return endpoint;
|
|
173
|
+
}
|
|
174
|
+
async function resolveAuthorization(issuer) {
|
|
175
|
+
const authorization = issuer.introspection?.authorization;
|
|
176
|
+
if (!authorization) return;
|
|
177
|
+
if (typeof authorization === "function") try {
|
|
178
|
+
return await authorization();
|
|
179
|
+
} catch (e) {
|
|
180
|
+
throw fail("The introspection authorization could not be resolved.", e);
|
|
181
|
+
}
|
|
182
|
+
return `Basic ${btoa(`${formEncode(authorization.clientId)}:${formEncode(authorization.clientSecret)}`)}`;
|
|
183
|
+
}
|
|
184
|
+
function isResponse(input) {
|
|
185
|
+
return typeof input === "object" && input !== null && !Array.isArray(input);
|
|
186
|
+
}
|
|
187
|
+
async function request(token, issuer, fetchFn) {
|
|
188
|
+
const endpoint = await resolveEndpoint(issuer, fetchFn);
|
|
189
|
+
const authorization = await resolveAuthorization(issuer);
|
|
190
|
+
const headers = {
|
|
191
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
192
|
+
accept: "application/json"
|
|
193
|
+
};
|
|
194
|
+
if (typeof authorization === "string") headers.authorization = authorization;
|
|
195
|
+
let response;
|
|
196
|
+
try {
|
|
197
|
+
response = await fetchFn(endpoint, {
|
|
198
|
+
method: "POST",
|
|
199
|
+
headers,
|
|
200
|
+
body: new URLSearchParams({ token }).toString(),
|
|
201
|
+
signal: AbortSignal.timeout(TIMEOUT)
|
|
202
|
+
});
|
|
203
|
+
} catch (e) {
|
|
204
|
+
throw fail("The introspection request failed.", e);
|
|
205
|
+
}
|
|
206
|
+
if (!response.ok) {
|
|
207
|
+
await response.body?.cancel().catch(() => void 0);
|
|
208
|
+
throw fail(`The introspection endpoint answered ${response.status}.`);
|
|
209
|
+
}
|
|
210
|
+
let body;
|
|
211
|
+
try {
|
|
212
|
+
body = JSON.parse(await response.text());
|
|
213
|
+
} catch (e) {
|
|
214
|
+
throw fail("The introspection response is not JSON.", e);
|
|
215
|
+
}
|
|
216
|
+
if (!isResponse(body)) throw fail("The introspection response is not a JSON object.");
|
|
217
|
+
if (body.active !== true) throw new VouchlyError("token_inactive", "The token is not active.");
|
|
218
|
+
return body;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* RFC 7662: asks the issuer's introspection endpoint about a token and
|
|
222
|
+
* answers the response as its claims. Claim rules are the caller's.
|
|
223
|
+
*/
|
|
224
|
+
async function introspect(token, issuer, fetchFn) {
|
|
225
|
+
try {
|
|
226
|
+
return await request(token, issuer, fetchFn);
|
|
227
|
+
} catch (e) {
|
|
228
|
+
if (isVouchlyError(e)) throw e;
|
|
229
|
+
throw fail("The introspection failed.", e);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region src/issuer/match.ts
|
|
234
|
+
/**
|
|
235
|
+
* Whether an issuer config trusts a token by its UNVERIFIED claims. This only
|
|
236
|
+
* selects a config: verification still runs against it afterwards. A
|
|
237
|
+
* function matcher counts only when it returns exactly `true`; one that
|
|
238
|
+
* throws, or returns anything else (a Promise included), does not match.
|
|
239
|
+
*/
|
|
240
|
+
function matchIssuer(issuer, claims) {
|
|
241
|
+
if (typeof issuer.issuer === "string") return typeof claims.iss === "string" && claims.iss === issuer.issuer;
|
|
242
|
+
try {
|
|
243
|
+
return issuer.issuer(claims) === true;
|
|
244
|
+
} catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* The index of the first issuer that trusts the claims, or -1.
|
|
250
|
+
*/
|
|
251
|
+
function selectIssuer(issuers, claims) {
|
|
252
|
+
for (const [index, issuer] of issuers.entries()) if (matchIssuer(issuer, claims)) return index;
|
|
253
|
+
return -1;
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/key/cached.ts
|
|
257
|
+
function cachedKeys(load, options) {
|
|
258
|
+
const ttl = options.ttl ?? 300;
|
|
259
|
+
const timeout = options.timeout ?? 10;
|
|
260
|
+
const max = options.max ?? 100;
|
|
261
|
+
if (!Number.isFinite(ttl) || ttl < 0) throw new TypeError("The key cache ttl must be a finite, non-negative number of seconds.");
|
|
262
|
+
if (!Number.isSafeInteger(max) || max < 1) throw new TypeError("The key cache max must be an integer of at least 1.");
|
|
263
|
+
if (!Number.isFinite(timeout) || timeout <= 0 || timeout * 1e3 > 2147483647) throw new TypeError("The key cache timeout must be a positive number of seconds, at most 2147483.");
|
|
264
|
+
const items = /* @__PURE__ */ new Map();
|
|
265
|
+
const flights = /* @__PURE__ */ new Map();
|
|
266
|
+
return async (context) => {
|
|
267
|
+
const key = options.key(context);
|
|
268
|
+
const item = items.get(key);
|
|
269
|
+
if (item) {
|
|
270
|
+
if (item.expiresAt > Date.now()) return structuredClone(item.jwk);
|
|
271
|
+
items.delete(key);
|
|
272
|
+
}
|
|
273
|
+
const pending = flights.get(key);
|
|
274
|
+
if (pending) return structuredClone(await pending);
|
|
275
|
+
let timer;
|
|
276
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
277
|
+
timer = setTimeout(() => {
|
|
278
|
+
reject(/* @__PURE__ */ new Error("Loading the key timed out."));
|
|
279
|
+
}, timeout * 1e3);
|
|
280
|
+
});
|
|
281
|
+
const flight = Promise.race([Promise.resolve().then(() => load(context)), deadline]).then((jwk) => {
|
|
282
|
+
if (ttl > 0) {
|
|
283
|
+
items.delete(key);
|
|
284
|
+
items.set(key, {
|
|
285
|
+
jwk: structuredClone(jwk),
|
|
286
|
+
expiresAt: Date.now() + ttl * 1e3
|
|
287
|
+
});
|
|
288
|
+
while (items.size > max) {
|
|
289
|
+
const oldest = items.keys().next();
|
|
290
|
+
if (oldest.done) break;
|
|
291
|
+
items.delete(oldest.value);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return jwk;
|
|
295
|
+
}).finally(() => {
|
|
296
|
+
clearTimeout(timer);
|
|
297
|
+
if (flights.get(key) === flight) flights.delete(key);
|
|
298
|
+
});
|
|
299
|
+
flights.set(key, flight);
|
|
300
|
+
return structuredClone(await flight);
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region src/key/jwks.ts
|
|
305
|
+
function jwks(url, options = {}) {
|
|
306
|
+
const target = new URL(url);
|
|
307
|
+
const sets = /* @__PURE__ */ new WeakMap();
|
|
308
|
+
return async (context) => {
|
|
309
|
+
let set = sets.get(context.fetch);
|
|
310
|
+
if (!set) {
|
|
311
|
+
set = createRemoteJWKSet(target, {
|
|
312
|
+
cacheMaxAge: options.cacheMaxAge,
|
|
313
|
+
cooldownDuration: options.cooldownDuration,
|
|
314
|
+
timeoutDuration: options.timeoutDuration,
|
|
315
|
+
[customFetch]: context.fetch
|
|
316
|
+
});
|
|
317
|
+
sets.set(context.fetch, set);
|
|
318
|
+
}
|
|
319
|
+
return set(context.token.header);
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
//#endregion
|
|
323
|
+
//#region src/key/discovery.ts
|
|
324
|
+
/**
|
|
325
|
+
* Resolves keys through the configured issuer's OIDC discovery document.
|
|
326
|
+
* A function issuer is refused: its only URL would be the token's own `iss`.
|
|
327
|
+
*/
|
|
328
|
+
function discovery(options = {}) {
|
|
329
|
+
if (typeof options.cacheMaxAge !== "undefined" && (!Number.isFinite(options.cacheMaxAge) || options.cacheMaxAge < 0)) throw new TypeError("The discovery cacheMaxAge must be a finite, non-negative number of seconds.");
|
|
330
|
+
const sources = /* @__PURE__ */ new Map();
|
|
331
|
+
return async (context) => {
|
|
332
|
+
const { issuer } = context.issuer;
|
|
333
|
+
if (typeof issuer !== "string") throw new VouchlyError("key_unavailable", "Discovery needs a configured issuer string, a function issuer cannot be discovered.");
|
|
334
|
+
let metadata;
|
|
335
|
+
try {
|
|
336
|
+
metadata = await fetchProviderMetadata(issuer, context.fetch, options);
|
|
337
|
+
} catch (e) {
|
|
338
|
+
throw new VouchlyError("key_unavailable", e instanceof Error && e.message ? e.message : "The provider metadata is unavailable.", { cause: e });
|
|
339
|
+
}
|
|
340
|
+
const { jwks_uri: url } = metadata;
|
|
341
|
+
if (!url) throw new VouchlyError("key_unavailable", "The provider metadata has no jwks_uri.");
|
|
342
|
+
let source = sources.get(url);
|
|
343
|
+
if (!source) {
|
|
344
|
+
source = jwks(url);
|
|
345
|
+
sources.set(url, source);
|
|
346
|
+
}
|
|
347
|
+
return source(context);
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
//#endregion
|
|
351
|
+
//#region src/key/policy.ts
|
|
352
|
+
const RSA_MIN_MODULUS_LENGTH = 2048;
|
|
353
|
+
/**
|
|
354
|
+
* Throws an Error when a JWK must never be used to verify a signature.
|
|
355
|
+
*/
|
|
356
|
+
function assertJWKPolicy(jwk) {
|
|
357
|
+
if (jwk.kty === "oct") throw new Error("A symmetric key (kty \"oct\") is not accepted.");
|
|
358
|
+
if (typeof jwk.use !== "undefined" && jwk.use !== "sig") throw new Error(`A key with use "${jwk.use}" is not a signature key.`);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Throws an Error when a CryptoKey must never be used to verify a signature.
|
|
362
|
+
*/
|
|
363
|
+
function assertCryptoKeyPolicy(key) {
|
|
364
|
+
if (key.type === "secret") throw new Error("A symmetric key is not accepted.");
|
|
365
|
+
const { modulusLength } = key.algorithm;
|
|
366
|
+
if (typeof modulusLength === "number" && modulusLength < RSA_MIN_MODULUS_LENGTH) throw new Error(`An RSA key of ${modulusLength} bits is too small, at least ${RSA_MIN_MODULUS_LENGTH} are required.`);
|
|
367
|
+
}
|
|
368
|
+
//#endregion
|
|
369
|
+
//#region src/key/module.ts
|
|
370
|
+
function isJWK(input) {
|
|
371
|
+
return typeof input.kty === "string";
|
|
372
|
+
}
|
|
373
|
+
async function resolve(source, context) {
|
|
374
|
+
const output = await source(context);
|
|
375
|
+
let key;
|
|
376
|
+
if (isJWK(output)) {
|
|
377
|
+
assertJWKPolicy(output);
|
|
378
|
+
key = await importJWK(output, output.alg ?? context.token.header.alg);
|
|
379
|
+
} else key = output;
|
|
380
|
+
if (key instanceof Uint8Array) throw new Error("A symmetric key is not accepted.");
|
|
381
|
+
assertCryptoKeyPolicy(key);
|
|
382
|
+
return key;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Resolves the key a token is verified with. Every failure, including a key
|
|
386
|
+
* the policy refuses, is a KEY_UNAVAILABLE VouchlyError.
|
|
387
|
+
*/
|
|
388
|
+
async function resolveVerificationKey(source, context) {
|
|
389
|
+
try {
|
|
390
|
+
return await resolve(source, context);
|
|
391
|
+
} catch (e) {
|
|
392
|
+
if (isVouchlyError(e) && e.code === "key_unavailable") throw e;
|
|
393
|
+
throw new VouchlyError("key_unavailable", e instanceof Error && e.message ? `The verification key is unavailable: ${e.message}` : "The verification key is unavailable.", { cause: e });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
//#endregion
|
|
397
|
+
//#region src/key/static.ts
|
|
398
|
+
function staticKeys(keys) {
|
|
399
|
+
const set = createLocalJWKSet({ keys: structuredClone(keys) });
|
|
400
|
+
return async (context) => set(context.token.header);
|
|
401
|
+
}
|
|
402
|
+
//#endregion
|
|
403
|
+
//#region src/token/module.ts
|
|
404
|
+
const COMPACT_TOKEN_REGEX = /^[\w-]+\.[\w-]+\.[\w-]+$/;
|
|
405
|
+
function isCompactToken(token) {
|
|
406
|
+
return COMPACT_TOKEN_REGEX.test(token);
|
|
407
|
+
}
|
|
408
|
+
function isPlainObject(input) {
|
|
409
|
+
return typeof input === "object" && input !== null && !Array.isArray(input);
|
|
410
|
+
}
|
|
411
|
+
function parseToken(token) {
|
|
412
|
+
if (typeof token !== "string" || !isCompactToken(token)) throw new VouchlyError("token_malformed", "The token is not a compact JWS.");
|
|
413
|
+
let header;
|
|
414
|
+
let claims;
|
|
415
|
+
try {
|
|
416
|
+
header = decodeProtectedHeader(token);
|
|
417
|
+
claims = decodeJwt(token);
|
|
418
|
+
} catch (e) {
|
|
419
|
+
throw new VouchlyError("token_malformed", "The token could not be decoded.", { cause: e });
|
|
420
|
+
}
|
|
421
|
+
if (!isPlainObject(header) || !isPlainObject(claims)) throw new VouchlyError("token_malformed", "The token header or claims are not an object.");
|
|
422
|
+
return {
|
|
423
|
+
raw: token,
|
|
424
|
+
header,
|
|
425
|
+
claims
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
//#endregion
|
|
429
|
+
//#region src/verifier/binding.ts
|
|
430
|
+
/**
|
|
431
|
+
* RFC 8705: a token carrying `cnf` is certificate-bound and fails closed
|
|
432
|
+
* unless the presented thumbprint equals `cnf.x5t#S256`. A `cnf` this check
|
|
433
|
+
* cannot satisfy (no `x5t#S256`, e.g. a DPoP `jkt`) fails too.
|
|
434
|
+
*/
|
|
435
|
+
function assertCertificateBinding(claims, thumbprint) {
|
|
436
|
+
if (typeof claims.cnf === "undefined" || claims.cnf === null) return;
|
|
437
|
+
const expected = typeof claims.cnf === "object" ? claims.cnf["x5t#S256"] : void 0;
|
|
438
|
+
if (typeof expected !== "string" || expected.length === 0 || typeof thumbprint !== "string" || thumbprint !== expected) throw new VouchlyError("binding_mismatch", "The token is bound to a certificate that was not presented.");
|
|
439
|
+
}
|
|
440
|
+
function isBound(claims) {
|
|
441
|
+
return typeof claims.cnf !== "undefined" && claims.cnf !== null;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Checks the binding of every claim set given: a token verified by
|
|
445
|
+
* introspection passes its own unverified claims next to the response, since
|
|
446
|
+
* an unverified `cnf` may only add a requirement, never lift one.
|
|
447
|
+
*/
|
|
448
|
+
async function resolveCertificateBinding(claims, options) {
|
|
449
|
+
const bound = (Array.isArray(claims) ? claims : [claims]).filter(isBound);
|
|
450
|
+
if (bound.length === 0) return;
|
|
451
|
+
let thumbprint;
|
|
452
|
+
if (typeof options.certificateThumbprint === "function") try {
|
|
453
|
+
thumbprint = await options.certificateThumbprint();
|
|
454
|
+
} catch (e) {
|
|
455
|
+
throw new VouchlyError("binding_mismatch", "The certificate thumbprint could not be resolved.", { cause: e });
|
|
456
|
+
}
|
|
457
|
+
else thumbprint = options.certificateThumbprint;
|
|
458
|
+
for (const item of bound) assertCertificateBinding(item, thumbprint);
|
|
459
|
+
}
|
|
460
|
+
//#endregion
|
|
461
|
+
//#region src/verifier/module.ts
|
|
462
|
+
/**
|
|
463
|
+
* One function for every verifier without a fetch of its own: discovery and
|
|
464
|
+
* jwks() memoise per fetch, so their caches are shared between such
|
|
465
|
+
* verifiers. It reads the global at call time, so a later stub applies.
|
|
466
|
+
*/
|
|
467
|
+
const defaultFetch = (input, init) => fetch(input, init);
|
|
468
|
+
const EC_ALGORITHMS = {
|
|
469
|
+
"P-256": "ES256",
|
|
470
|
+
"P-384": "ES384",
|
|
471
|
+
"P-521": "ES512"
|
|
472
|
+
};
|
|
473
|
+
/**
|
|
474
|
+
* The JWS algorithms a resolved key verifies. A JWK without `alg` was
|
|
475
|
+
* imported with the header's `alg`, so for it this is the header's choice
|
|
476
|
+
* among the algorithms its key type supports; Issuer.algorithms pins it.
|
|
477
|
+
*/
|
|
478
|
+
function deriveAlgorithms(key) {
|
|
479
|
+
const algorithm = key.algorithm;
|
|
480
|
+
const bits = algorithm.hash?.name.replace("SHA-", "");
|
|
481
|
+
switch (algorithm.name) {
|
|
482
|
+
case "RSASSA-PKCS1-v1_5": return bits ? [`RS${bits}`] : [];
|
|
483
|
+
case "RSA-PSS": return bits ? [`PS${bits}`] : [];
|
|
484
|
+
case "ECDSA": {
|
|
485
|
+
const alg = algorithm.namedCurve ? EC_ALGORITHMS[algorithm.namedCurve] : void 0;
|
|
486
|
+
return alg ? [alg] : [];
|
|
487
|
+
}
|
|
488
|
+
case "Ed25519": return ["EdDSA", "Ed25519"];
|
|
489
|
+
default: return [];
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function mapJoseError(e) {
|
|
493
|
+
if (isVouchlyError(e)) return e;
|
|
494
|
+
const message = e instanceof Error && e.message ? e.message : void 0;
|
|
495
|
+
if (e instanceof errors.JWTExpired) return new VouchlyError("token_expired", message, { cause: e });
|
|
496
|
+
if (e instanceof errors.JWSSignatureVerificationFailed) return new VouchlyError("signature_invalid", message, { cause: e });
|
|
497
|
+
if (e instanceof errors.JWTClaimValidationFailed || e instanceof errors.JOSEAlgNotAllowed || e instanceof errors.JWTInvalid) return new VouchlyError("claim_invalid", message, { cause: e });
|
|
498
|
+
return new VouchlyError("signature_invalid", message, { cause: e });
|
|
499
|
+
}
|
|
500
|
+
function assertAudience(issuer, claims) {
|
|
501
|
+
if (typeof issuer.audience === "undefined") return;
|
|
502
|
+
const expected = Array.isArray(issuer.audience) ? issuer.audience : [issuer.audience];
|
|
503
|
+
if ((Array.isArray(claims.aud) ? claims.aud : [claims.aud]).every((value) => typeof value !== "string" || !expected.includes(value))) throw new VouchlyError("claim_invalid", "The token audience is not accepted.");
|
|
504
|
+
}
|
|
505
|
+
function normalizeTyp(value) {
|
|
506
|
+
const normalized = value.toLowerCase();
|
|
507
|
+
return value.includes("/") ? normalized : `application/${normalized}`;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* The rules jose applies to the header and claims of a local token that do
|
|
511
|
+
* not need the signature, so a cache hit is held to them too.
|
|
512
|
+
*/
|
|
513
|
+
function assertLocalClaims(issuer, entry) {
|
|
514
|
+
for (const claim of issuer.requiredClaims ?? ["exp"]) if (!(claim in entry.claims)) throw new VouchlyError("claim_invalid", `The token has no "${claim}" claim.`);
|
|
515
|
+
if (typeof issuer.typ === "string" && (typeof entry.typ !== "string" || normalizeTyp(entry.typ) !== normalizeTyp(issuer.typ))) throw new VouchlyError("claim_invalid", "The token typ is not accepted.");
|
|
516
|
+
if (issuer.algorithms && (typeof entry.alg !== "string" || !issuer.algorithms.includes(entry.alg))) throw new VouchlyError("claim_invalid", "The token algorithm is not accepted.");
|
|
517
|
+
const { nbf } = entry.claims;
|
|
518
|
+
if (typeof nbf === "number" && nbf > Date.now() / 1e3 + (issuer.clockTolerance ?? 0)) throw new VouchlyError("claim_invalid", "The token is not yet valid.");
|
|
519
|
+
}
|
|
520
|
+
function buildRecords(options) {
|
|
521
|
+
if (!Array.isArray(options.issuers) || options.issuers.length === 0) throw new TypeError("A verifier needs at least one issuer.");
|
|
522
|
+
const names = /* @__PURE__ */ new Set();
|
|
523
|
+
return options.issuers.map((issuer, index) => {
|
|
524
|
+
const name = issuer.id ?? String(index);
|
|
525
|
+
if (names.has(name)) throw new TypeError(`The issuer id "${name}" is used twice (an issuer without id is named by its index).`);
|
|
526
|
+
names.add(name);
|
|
527
|
+
const verification = issuer.verification ?? "local";
|
|
528
|
+
if (verification !== "local" && verification !== "remote") throw new TypeError(`The issuer "${name}" has an unknown verification "${String(verification)}".`);
|
|
529
|
+
let { keys } = issuer;
|
|
530
|
+
if (!keys && verification === "local") {
|
|
531
|
+
if (typeof issuer.issuer !== "string") throw new TypeError(`The issuer "${name}" matches by function and needs a key source, since it cannot be discovered.`);
|
|
532
|
+
keys = discovery();
|
|
533
|
+
}
|
|
534
|
+
return {
|
|
535
|
+
name,
|
|
536
|
+
issuer,
|
|
537
|
+
keys
|
|
538
|
+
};
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
var Verifier = class {
|
|
542
|
+
records;
|
|
543
|
+
cache;
|
|
544
|
+
fetch;
|
|
545
|
+
opaque;
|
|
546
|
+
cacheNamespace;
|
|
547
|
+
constructor(options) {
|
|
548
|
+
this.records = buildRecords(options);
|
|
549
|
+
if (typeof options.opaque !== "undefined") {
|
|
550
|
+
this.opaque = this.records.find((record) => record.issuer.id === options.opaque);
|
|
551
|
+
if (!this.opaque) throw new TypeError(`The opaque issuer "${options.opaque}" names no configured issuer.`);
|
|
552
|
+
}
|
|
553
|
+
if (options.cache && !options.cacheNamespace) throw new TypeError("A cache passed in needs a cacheNamespace; verifiers sharing one namespace must have identical issuer configurations.");
|
|
554
|
+
if (typeof options.cacheNamespace === "string" && options.cacheNamespace.includes("\0")) throw new TypeError("The cacheNamespace must not contain a NUL character.");
|
|
555
|
+
this.cache = options.cache === false ? void 0 : options.cache ?? new MemoryTokenCache();
|
|
556
|
+
this.cacheNamespace = options.cacheNamespace ?? "";
|
|
557
|
+
this.fetch = options.fetch ?? defaultFetch;
|
|
558
|
+
}
|
|
559
|
+
async verify(token, options = {}) {
|
|
560
|
+
const compact = typeof token === "string" && isCompactToken(token);
|
|
561
|
+
if (!compact && (!this.opaque || typeof token !== "string" || token.length === 0)) throw new VouchlyError("token_malformed", "The token is not a compact JWS.");
|
|
562
|
+
const parsed = compact ? parseToken(token) : void 0;
|
|
563
|
+
const key = await hashToken(`${this.cacheNamespace}\u0000${token}`);
|
|
564
|
+
const cached = await this.readCache(key, parsed);
|
|
565
|
+
if (cached) {
|
|
566
|
+
await resolveCertificateBinding(this.bindingClaims(cached.entry.claims, cached.entry.source, parsed), options);
|
|
567
|
+
return {
|
|
568
|
+
claims: structuredClone(cached.entry.claims),
|
|
569
|
+
issuer: cached.record.issuer,
|
|
570
|
+
source: cached.entry.source
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
if (!compact) return this.introspect(token, key, this.opaque, options, parsed);
|
|
574
|
+
const record = this.selectRecord(parsed);
|
|
575
|
+
if (record.issuer.verification === "remote") return this.introspect(token, key, record, options, parsed);
|
|
576
|
+
const claims = await this.verifyLocal(parsed, record);
|
|
577
|
+
const entry = {
|
|
578
|
+
claims,
|
|
579
|
+
issuer: record.name,
|
|
580
|
+
source: "local"
|
|
581
|
+
};
|
|
582
|
+
if (typeof parsed.header.typ === "string") entry.typ = parsed.header.typ;
|
|
583
|
+
if (typeof parsed.header.alg === "string") entry.alg = parsed.header.alg;
|
|
584
|
+
await this.writeCache(key, entry, claims.exp);
|
|
585
|
+
await resolveCertificateBinding(claims, options);
|
|
586
|
+
return {
|
|
587
|
+
claims: structuredClone(claims),
|
|
588
|
+
issuer: record.issuer,
|
|
589
|
+
source: "local"
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* The claim sets whose `cnf` a result must satisfy: an introspected
|
|
594
|
+
* compact token is held to its own `cnf` as well as the response's.
|
|
595
|
+
*/
|
|
596
|
+
bindingClaims(claims, source, token) {
|
|
597
|
+
return source === "remote" && token ? [claims, token.claims] : [claims];
|
|
598
|
+
}
|
|
599
|
+
selectRecord(token) {
|
|
600
|
+
const index = selectIssuer(this.records.map((record) => record.issuer), token.claims);
|
|
601
|
+
if (index === -1) throw new VouchlyError("issuer_untrusted", "The token issuer is not trusted.");
|
|
602
|
+
return this.records[index];
|
|
603
|
+
}
|
|
604
|
+
async verifyLocal(token, record) {
|
|
605
|
+
const { issuer } = record;
|
|
606
|
+
const key = await resolveVerificationKey(record.keys, {
|
|
607
|
+
token,
|
|
608
|
+
issuer,
|
|
609
|
+
fetch: this.fetch
|
|
610
|
+
});
|
|
611
|
+
const algorithms = issuer.algorithms ?? deriveAlgorithms(key);
|
|
612
|
+
if (algorithms.length === 0) throw new VouchlyError("key_unavailable", "The verification key has no supported algorithm.");
|
|
613
|
+
let payload;
|
|
614
|
+
try {
|
|
615
|
+
payload = (await jwtVerify(token.raw, key, {
|
|
616
|
+
algorithms,
|
|
617
|
+
audience: issuer.audience,
|
|
618
|
+
typ: issuer.typ,
|
|
619
|
+
clockTolerance: issuer.clockTolerance ?? 0,
|
|
620
|
+
requiredClaims: issuer.requiredClaims ?? ["exp"],
|
|
621
|
+
issuer: typeof issuer.issuer === "string" ? issuer.issuer : void 0
|
|
622
|
+
})).payload;
|
|
623
|
+
} catch (e) {
|
|
624
|
+
throw mapJoseError(e);
|
|
625
|
+
}
|
|
626
|
+
await this.assertClaimsPredicate(issuer, payload);
|
|
627
|
+
return payload;
|
|
628
|
+
}
|
|
629
|
+
async assertClaimsPredicate(issuer, claims) {
|
|
630
|
+
if (!issuer.claims) return;
|
|
631
|
+
let passed;
|
|
632
|
+
try {
|
|
633
|
+
passed = await issuer.claims(structuredClone(claims)) === true;
|
|
634
|
+
} catch (e) {
|
|
635
|
+
throw new VouchlyError("claim_invalid", "The claims predicate failed.", { cause: e });
|
|
636
|
+
}
|
|
637
|
+
if (!passed) throw new VouchlyError("claim_invalid", "The claims predicate refused the token.");
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Verifies a token by RFC 7662 introspection and applies the issuer's
|
|
641
|
+
* audience and claims predicate to the response.
|
|
642
|
+
*/
|
|
643
|
+
async verifyIntrospection(token, record) {
|
|
644
|
+
const claims = await introspect(token, record.issuer, this.fetch);
|
|
645
|
+
assertAudience(record.issuer, claims);
|
|
646
|
+
await this.assertClaimsPredicate(record.issuer, claims);
|
|
647
|
+
return claims;
|
|
648
|
+
}
|
|
649
|
+
async introspect(token, key, record, options, parsed) {
|
|
650
|
+
const claims = await this.verifyIntrospection(token, record);
|
|
651
|
+
await this.writeCache(key, {
|
|
652
|
+
claims,
|
|
653
|
+
issuer: record.name,
|
|
654
|
+
source: "remote"
|
|
655
|
+
}, claims.exp, record.issuer.introspection?.maxCacheTTL);
|
|
656
|
+
await resolveCertificateBinding(this.bindingClaims(claims, "remote", parsed), options);
|
|
657
|
+
return {
|
|
658
|
+
claims: structuredClone(claims),
|
|
659
|
+
issuer: record.issuer,
|
|
660
|
+
source: "remote"
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* A cache failure is a miss: the token is verified again instead. An entry
|
|
665
|
+
* is only served under the record an uncached verify would pick, and only
|
|
666
|
+
* after this verifier's own rules that need no signature passed on it:
|
|
667
|
+
* a verifier sharing the cache may have applied other ones.
|
|
668
|
+
*/
|
|
669
|
+
async readCache(key, token) {
|
|
670
|
+
if (!this.cache) return;
|
|
671
|
+
let entry;
|
|
672
|
+
try {
|
|
673
|
+
entry = await this.cache.get(key);
|
|
674
|
+
} catch {
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (!entry) return;
|
|
678
|
+
const record = token ? this.selectRecord(token) : this.opaque;
|
|
679
|
+
const source = record.issuer.verification ?? "local";
|
|
680
|
+
if (entry.issuer !== record.name || entry.source !== source) return;
|
|
681
|
+
if (source === "local") {
|
|
682
|
+
if (!matchIssuer(record.issuer, entry.claims)) throw new VouchlyError("issuer_untrusted", "The token issuer is not trusted.");
|
|
683
|
+
assertLocalClaims(record.issuer, entry);
|
|
684
|
+
}
|
|
685
|
+
const { exp } = entry.claims;
|
|
686
|
+
if (typeof exp === "number" && exp <= Date.now() / 1e3 - (record.issuer.clockTolerance ?? 0)) throw new VouchlyError("token_expired", "The token has expired.");
|
|
687
|
+
assertAudience(record.issuer, entry.claims);
|
|
688
|
+
await this.assertClaimsPredicate(record.issuer, entry.claims);
|
|
689
|
+
return {
|
|
690
|
+
entry,
|
|
691
|
+
record
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Caches until `exp`, measured to the millisecond, or for at most `maxTTL` seconds when given. A token
|
|
696
|
+
* without `exp` is cached only under a `maxTTL`. A cache failure is ignored.
|
|
697
|
+
*/
|
|
698
|
+
async writeCache(key, entry, exp, maxTTL) {
|
|
699
|
+
if (!this.cache) return;
|
|
700
|
+
let ttl;
|
|
701
|
+
if (typeof exp === "number" && Number.isFinite(exp)) ttl = exp - Date.now() / 1e3;
|
|
702
|
+
if (typeof maxTTL === "number") ttl = typeof ttl === "number" ? Math.min(ttl, maxTTL) : maxTTL;
|
|
703
|
+
if (typeof ttl !== "number" || !(ttl > 0)) return;
|
|
704
|
+
try {
|
|
705
|
+
await this.cache.set(key, structuredClone(entry), ttl);
|
|
706
|
+
} catch {}
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
function createVerifier(options) {
|
|
710
|
+
return new Verifier(options);
|
|
711
|
+
}
|
|
712
|
+
//#endregion
|
|
713
|
+
export { MemoryTokenCache, VOUCHLY_ERROR_MARKER, Verifier, VouchlyError, VouchlyErrorCode, assertCertificateBinding, cachedKeys, createVerifier, discovery, fetchProviderMetadata, hashToken, introspect, isCompactToken, isVouchlyError, jwks, matchIssuer, parseToken, resolveVerificationKey, selectIssuer, staticKeys };
|
|
714
|
+
|
|
715
|
+
//# sourceMappingURL=index.mjs.map
|