haechi-auth-jwt 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.
Files changed (3) hide show
  1. package/README.md +42 -0
  2. package/index.mjs +392 -0
  3. package/package.json +48 -0
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # `haechi-auth-jwt`
2
+
3
+ A **headless** JWKS bearer (JWT) `authProvider` for Haechi. It verifies an `Authorization: Bearer <jwt>` against an issuer's JWKS and resolves a **PII-safe identity** — using `node:` builtins only (no `jose`). Published independently as `haechi-auth-jwt`; it adds **no runtime dependency** to core.
4
+
5
+ ## Usage
6
+
7
+ ```js
8
+ import { createRuntime } from "haechi/runtime";
9
+ import { createJwtAuthProvider } from "haechi-auth-jwt";
10
+
11
+ const runtime = createRuntime(
12
+ { auth: { provider: "external" }, /* ... */ },
13
+ {
14
+ cryptoProvider, // required (also satisfies the PII-safe identity hmac)
15
+ authProvider: createJwtAuthProvider({
16
+ issuer: "https://idp.example.com",
17
+ audience: "haechi-gateway",
18
+ jwksUri: "https://idp.example.com/.well-known/jwks.json",
19
+ cryptoProvider,
20
+ algorithms: ["RS256", "ES256"], // server-side allowlist (default)
21
+ clockSkewSeconds: 60, // max 300
22
+ claimMappings: { scope: "scp", labels: { team: "groups" } }
23
+ })
24
+ }
25
+ );
26
+ ```
27
+
28
+ Wired via **injection** (`auth.provider: "external"`); dynamic loading stays banned until the 1.0 plugin sandbox.
29
+
30
+ ## Security (these are guarantees, not options)
31
+
32
+ - **The token never picks the algorithm.** The verifier uses the configured `algorithms` allowlist and the JWK type. `alg: "none"` is rejected; HMAC (`HS*`) is not allowed (alg-confusion defence); a JWKS public key is only ever used with its matching asymmetric algorithm. ES256 uses `dsaEncoding: "ieee-p1363"` (a JWS ES256 signature is raw R‖S, which `node:crypto` otherwise mis-verifies).
33
+ - **`kid` required**, key selected by `kid`. **RSA ≥ 2048 bits.** JWK `use` must be `sig`; `key_ops` must not include `encrypt`/`decrypt`. Only JWS is accepted (`typ: "JWE"` rejected).
34
+ - **Claims fully validated:** `iss` exact match; `aud` (string or array) must contain the configured audience; `sub` required non-empty; `exp`/`nbf` required and checked with a bounded `clockSkewSeconds` (default 60, **max 300**).
35
+ - **JWKS fetching is SSRF-hardened:** `issuer` and `jwksUri` must be **HTTPS** and share a host (single-origin issuers only in 0.8); requests to private/loopback/link-local/metadata addresses are refused (literal host + resolved IPs); fetch has a timeout and a 1 MiB response cap; JSON parsing is depth-bounded; JWT segments are strict base64url.
36
+ - **JWKS cache is bounded:** TTL-cached; an unknown `kid` triggers at most one refetch per cooldown (no fetch-storm against the IdP).
37
+ - **Identity is PII-safe (fail-closed):** a `cryptoProvider` with `hmac()` is required; `subjectHash`/`issuerHash` are keyed HMAC-SHA-256 (`haechi:identity:hash:v1`, built by core's `buildExternalIdentity`) — raw `sub`/`iss` are never stored or logged. `scopes` from the configured scope claim; `labels` from an allowlisted claim mapping.
38
+ - **Fail-closed everywhere:** any verification error → `authenticate` returns `null` (deny), never throws into the request path, and echoes no token detail.
39
+
40
+ ## Scope (0.8)
41
+
42
+ Single-origin issuers only (issuer host == JWKS host). Multi-origin/CDN-fronted JWKS and full interactive OIDC (`haechi-auth-oidc`) are 0.9.
package/index.mjs ADDED
@@ -0,0 +1,392 @@
1
+ // haechi-auth-jwt — headless JWKS bearer verification for Haechi.
2
+ //
3
+ // createJwtAuthProvider(...) implements the authProvider contract
4
+ // (authenticate(request) -> identity | null, fail-closed) using node: builtins
5
+ // only (no `jose`): JWKS fetched via global fetch, JWK->key via
6
+ // crypto.createPublicKey({ format: "jwk" }), signatures verified via crypto.verify.
7
+ //
8
+ // Every security decision below is a DECISION, not implementation discretion;
9
+ // see docs/current/release-0.8-implementation-scope.md §2.4. The verifier never
10
+ // trusts the token to pick the algorithm, rejects alg:"none" and HMAC/JWE,
11
+ // requires kid, enforces RSA>=2048 and EC P-256, validates iss/aud/sub/exp/nbf
12
+ // with a bounded clock skew, SSRF-guards JWKS fetching, bounds the JWKS cache,
13
+ // and builds a PII-safe identity via the injected cryptoProvider.
14
+
15
+ import { createPublicKey, verify as cryptoVerify } from "node:crypto";
16
+ import { lookup } from "node:dns/promises";
17
+ import { isIP } from "node:net";
18
+ import { buildExternalIdentity } from "haechi/auth";
19
+
20
+ // --- constants (decisions) -------------------------------------------------
21
+
22
+ const SUPPORTED = {
23
+ // alg -> how to verify it. NEVER includes HS* (alg-confusion) or "none".
24
+ RS256: { kty: "RSA", digest: "sha256" },
25
+ RS384: { kty: "RSA", digest: "sha384" },
26
+ RS512: { kty: "RSA", digest: "sha512" },
27
+ ES256: { kty: "EC", crv: "P-256", digest: "sha256", dsaEncoding: "ieee-p1363" },
28
+ ES384: { kty: "EC", crv: "P-384", digest: "sha384", dsaEncoding: "ieee-p1363" },
29
+ ES512: { kty: "EC", crv: "P-521", digest: "sha512", dsaEncoding: "ieee-p1363" }
30
+ };
31
+ const DEFAULT_ALGORITHMS = ["RS256", "ES256"];
32
+ const MAX_CLOCK_SKEW_SECONDS = 300;
33
+ const DEFAULT_CLOCK_SKEW_SECONDS = 60;
34
+ const DEFAULT_JWKS_TTL_MS = 300_000; // cache JWKS for 5 min
35
+ const DEFAULT_JWKS_COOLDOWN_MS = 60_000; // >=60s between unknown-kid refetches
36
+ const DEFAULT_FETCH_TIMEOUT_MS = 5_000;
37
+ const MAX_JWKS_BYTES = 1024 * 1024; // 1 MiB
38
+ const MAX_JSON_DEPTH = 32;
39
+ const MIN_RSA_MODULUS_BITS = 2048;
40
+
41
+ // --- small utilities -------------------------------------------------------
42
+
43
+ function parseHttpsUrl(value, label) {
44
+ let url;
45
+ try {
46
+ url = new URL(value);
47
+ } catch {
48
+ throw new Error(`${label} must be a valid URL`);
49
+ }
50
+ if (url.protocol !== "https:") {
51
+ throw new Error(`${label} must be https`);
52
+ }
53
+ return url;
54
+ }
55
+
56
+ // Block literal addresses in private/loopback/link-local ranges + cloud metadata.
57
+ // Applied to both a literal host in the URL and every DNS-resolved address.
58
+ function isBlockedAddress(host) {
59
+ // A URL's .hostname keeps the brackets on an IPv6 literal ("[::1]"), and isIP
60
+ // rejects a bracketed string — strip them first so literals are classified.
61
+ const bare = String(host).replace(/^\[|\]$/g, "");
62
+ const v = isIP(bare);
63
+ if (v === 4) {
64
+ const o = bare.split(".").map(Number);
65
+ if (o[0] === 127) return true; // 127.0.0.0/8 loopback
66
+ if (o[0] === 10) return true; // 10.0.0.0/8
67
+ if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return true; // 172.16/12
68
+ if (o[0] === 192 && o[1] === 168) return true; // 192.168/16
69
+ if (o[0] === 169 && o[1] === 254) return true; // 169.254/16 link-local incl. metadata
70
+ if (o[0] === 0) return true; // 0.0.0.0/8
71
+ return false;
72
+ }
73
+ if (v === 6) {
74
+ const h = bare.toLowerCase();
75
+ if (h === "::1" || h === "::") return true; // loopback / unspecified
76
+ if (h.startsWith("::ffff:")) { // IPv4-mapped
77
+ const mapped = h.slice("::ffff:".length);
78
+ if (isIP(mapped) === 4) return isBlockedAddress(mapped);
79
+ }
80
+ // Range-check the first hextet (startsWith("fe80") wrongly let fe81–febf
81
+ // through): fe80::/10 link-local, fc00::/7 ULA, ff00::/8 multicast.
82
+ const firstHextet = parseInt(h.split(":")[0] || "", 16);
83
+ if (Number.isFinite(firstHextet)) {
84
+ if (firstHextet >= 0xfe80 && firstHextet <= 0xfebf) return true; // link-local
85
+ if (firstHextet >= 0xfc00 && firstHextet <= 0xfdff) return true; // unique local
86
+ if (firstHextet >= 0xff00 && firstHextet <= 0xffff) return true; // multicast
87
+ }
88
+ return false;
89
+ }
90
+ return false; // not a literal IP; resolved addresses are checked separately
91
+ }
92
+
93
+ // Strict base64url: only [A-Za-z0-9_-], no padding, no whitespace.
94
+ function decodeBase64UrlStrict(segment) {
95
+ if (typeof segment !== "string" || segment.length === 0 || !/^[A-Za-z0-9_-]+$/.test(segment)) {
96
+ throw new Error("invalid base64url segment");
97
+ }
98
+ return Buffer.from(segment, "base64url");
99
+ }
100
+
101
+ // JSON.parse with a recursion-depth bound (guards against stack-exhaustion via
102
+ // deeply nested JWKS/claims). Operates on already-size-bounded input.
103
+ function parseJsonBounded(text) {
104
+ const value = JSON.parse(text);
105
+ const check = (node, depth) => {
106
+ if (depth > MAX_JSON_DEPTH) throw new Error("JSON nesting too deep");
107
+ if (Array.isArray(node)) {
108
+ for (const item of node) check(item, depth + 1);
109
+ } else if (node && typeof node === "object") {
110
+ for (const key of Object.keys(node)) check(node[key], depth + 1);
111
+ }
112
+ };
113
+ check(value, 0);
114
+ return value;
115
+ }
116
+
117
+ function rsaModulusBits(jwk) {
118
+ let n = decodeBase64UrlStrict(jwk.n);
119
+ let i = 0;
120
+ while (i < n.length && n[i] === 0) i += 1; // strip leading zero bytes
121
+ if (i >= n.length) return 0;
122
+ const topByte = n[i];
123
+ let topBits = 8;
124
+ for (let mask = 0x80; mask > 0 && (topByte & mask) === 0; mask >>= 1) topBits -= 1;
125
+ return (n.length - i - 1) * 8 + topBits;
126
+ }
127
+
128
+ // --- the provider ----------------------------------------------------------
129
+
130
+ export function createJwtAuthProvider(options = {}) {
131
+ const {
132
+ issuer,
133
+ audience,
134
+ jwksUri,
135
+ cryptoProvider,
136
+ algorithms = DEFAULT_ALGORITHMS,
137
+ clockSkewSeconds = DEFAULT_CLOCK_SKEW_SECONDS,
138
+ claimMappings = {},
139
+ allowedLabelKeys,
140
+ jwksTtlMs = DEFAULT_JWKS_TTL_MS,
141
+ jwksCooldownMs = DEFAULT_JWKS_COOLDOWN_MS,
142
+ fetchTimeoutMs = DEFAULT_FETCH_TIMEOUT_MS,
143
+ fetchImpl,
144
+ lookupImpl = lookup,
145
+ now = () => Date.now()
146
+ } = options;
147
+
148
+ // ---- construction-time validation (fail closed) ----
149
+ if (typeof cryptoProvider?.hmac !== "function") {
150
+ throw new Error("createJwtAuthProvider requires a cryptoProvider with hmac() (for a PII-safe identity)");
151
+ }
152
+ if (typeof issuer !== "string" || !issuer) {
153
+ throw new Error("createJwtAuthProvider requires an issuer");
154
+ }
155
+ const issuerUrl = parseHttpsUrl(issuer, "issuer");
156
+ if (typeof audience !== "string" || !audience) {
157
+ throw new Error("createJwtAuthProvider requires a non-empty audience");
158
+ }
159
+ const jwksUrl = parseHttpsUrl(jwksUri, "jwksUri");
160
+ if (jwksUrl.hostname.toLowerCase() !== issuerUrl.hostname.toLowerCase()) {
161
+ throw new Error("jwksUri host must equal the issuer host (single-origin issuers only in 0.8)");
162
+ }
163
+ if (isBlockedAddress(jwksUrl.hostname)) {
164
+ throw new Error("jwksUri host resolves to a blocked (private/loopback/link-local/metadata) address");
165
+ }
166
+ if (!Array.isArray(algorithms) || algorithms.length === 0) {
167
+ throw new Error("algorithms must be a non-empty array");
168
+ }
169
+ for (const alg of algorithms) {
170
+ if (!Object.prototype.hasOwnProperty.call(SUPPORTED, alg)) {
171
+ throw new Error(`Unsupported or unsafe algorithm: ${alg} (allowed: ${Object.keys(SUPPORTED).join(", ")})`);
172
+ }
173
+ }
174
+ if (!Number.isFinite(clockSkewSeconds) || clockSkewSeconds < 0 || clockSkewSeconds > MAX_CLOCK_SKEW_SECONDS) {
175
+ throw new Error(`clockSkewSeconds must be between 0 and ${MAX_CLOCK_SKEW_SECONDS}`);
176
+ }
177
+ const algorithmSet = new Set(algorithms);
178
+ const doFetch = fetchImpl || globalThis.fetch;
179
+ if (typeof doFetch !== "function") {
180
+ throw new Error("global fetch is unavailable; pass fetchImpl");
181
+ }
182
+
183
+ const scopeClaim = claimMappings.scope || "scope";
184
+ const typeClaim = claimMappings.type || null;
185
+ const labelMap = claimMappings.labels && typeof claimMappings.labels === "object" ? claimMappings.labels : {};
186
+
187
+ // ---- JWKS cache (bounded + cooldown) ----
188
+ let cache = { keysByKid: new Map(), fetchedAt: 0 };
189
+ let lastRefetchAt = 0;
190
+ let inflight = null;
191
+
192
+ async function fetchJwks() {
193
+ // SSRF: resolve the host and refuse if any address is blocked (catches a
194
+ // hostname that maps to a private/metadata IP). Residual DNS-rebinding
195
+ // between this check and the fetch is acceptable for a single-origin,
196
+ // operator-configured jwksUri.
197
+ const records = await lookupImpl(jwksUrl.hostname, { all: true });
198
+ for (const { address } of records) {
199
+ if (isBlockedAddress(address)) {
200
+ throw new Error("jwksUri resolved to a blocked address");
201
+ }
202
+ }
203
+ const controller = new AbortController();
204
+ const timer = setTimeout(() => controller.abort(), fetchTimeoutMs);
205
+ let res;
206
+ try {
207
+ res = await doFetch(jwksUrl.href, { signal: controller.signal, redirect: "error" });
208
+ } finally {
209
+ clearTimeout(timer);
210
+ }
211
+ if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);
212
+
213
+ // Bound the body to MAX_JWKS_BYTES while reading.
214
+ const reader = res.body?.getReader?.();
215
+ let text;
216
+ if (reader) {
217
+ const chunks = [];
218
+ let total = 0;
219
+ for (;;) {
220
+ const { done, value } = await reader.read();
221
+ if (done) break;
222
+ total += value.byteLength;
223
+ if (total > MAX_JWKS_BYTES) {
224
+ await reader.cancel();
225
+ throw new Error("JWKS response exceeds the size limit");
226
+ }
227
+ chunks.push(Buffer.from(value));
228
+ }
229
+ text = Buffer.concat(chunks).toString("utf8");
230
+ } else {
231
+ // Fallback for a non-streaming fetchImpl (global fetch always provides a
232
+ // reader, so the streaming branch above is the real path). Reject early on
233
+ // a declared oversize before buffering, then re-check the actual bytes.
234
+ const declared = Number(res.headers?.get?.("content-length"));
235
+ if (Number.isFinite(declared) && declared > MAX_JWKS_BYTES) {
236
+ throw new Error("JWKS response exceeds the size limit");
237
+ }
238
+ text = await res.text();
239
+ if (Buffer.byteLength(text, "utf8") > MAX_JWKS_BYTES) {
240
+ throw new Error("JWKS response exceeds the size limit");
241
+ }
242
+ }
243
+
244
+ const jwks = parseJsonBounded(text);
245
+ const keysByKid = new Map();
246
+ for (const jwk of Array.isArray(jwks.keys) ? jwks.keys : []) {
247
+ if (jwk && typeof jwk.kid === "string") keysByKid.set(jwk.kid, jwk);
248
+ }
249
+ cache = { keysByKid, fetchedAt: now() };
250
+ return cache;
251
+ }
252
+
253
+ function refreshOnce() {
254
+ if (!inflight) {
255
+ inflight = fetchJwks().finally(() => { inflight = null; });
256
+ }
257
+ return inflight;
258
+ }
259
+
260
+ // Resolve a kid to a JWK with a SINGLE cooldown-gated refetch per call. ONE
261
+ // rule governs every refetch trigger — stale cache, empty cache, and unknown
262
+ // kid (rotation) — so neither a rotation nor an attacker flood can exceed one
263
+ // JWKS fetch per cooldown, even when the cache is stale or the IdP is failing
264
+ // (lastRefetchAt is set BEFORE the await, so a throwing fetch still burns the
265
+ // cooldown — no fetch storm). A known kid is served from cache even if stale.
266
+ async function resolveJwk(kid) {
267
+ const fresh = now() - cache.fetchedAt < jwksTtlMs;
268
+ if (cache.keysByKid.has(kid) && fresh) {
269
+ return cache.keysByKid.get(kid);
270
+ }
271
+ const cooldownElapsed = cache.fetchedAt === 0 || now() - lastRefetchAt >= jwksCooldownMs;
272
+ if (cooldownElapsed) {
273
+ lastRefetchAt = now();
274
+ await refreshOnce();
275
+ if (cache.keysByKid.has(kid)) return cache.keysByKid.get(kid);
276
+ }
277
+ return cache.keysByKid.has(kid) ? cache.keysByKid.get(kid) : null;
278
+ }
279
+
280
+ function publicKeyFor(jwk, spec) {
281
+ if (jwk.kty !== spec.kty) throw new Error("JWK kty does not match the algorithm");
282
+ if (spec.crv && jwk.crv !== spec.crv) throw new Error("JWK crv does not match the algorithm");
283
+ if (jwk.use && jwk.use !== "sig") throw new Error("JWK use is not 'sig'");
284
+ if (Array.isArray(jwk.key_ops)) {
285
+ if (jwk.key_ops.some((op) => op === "encrypt" || op === "decrypt")) {
286
+ throw new Error("JWK key_ops include encrypt/decrypt");
287
+ }
288
+ if (!jwk.key_ops.some((op) => op === "verify" || op === "sign")) {
289
+ throw new Error("JWK key_ops do not include verify/sign");
290
+ }
291
+ }
292
+ if (spec.kty === "RSA") {
293
+ const bits = rsaModulusBits(jwk);
294
+ if (bits < MIN_RSA_MODULUS_BITS) {
295
+ throw new Error(`RSA modulus ${bits} bits is below the ${MIN_RSA_MODULUS_BITS}-bit floor`);
296
+ }
297
+ }
298
+ return createPublicKey({ key: jwk, format: "jwk" });
299
+ }
300
+
301
+ function verifySignature(alg, spec, signingInput, signature, key) {
302
+ const data = Buffer.from(signingInput, "utf8");
303
+ const keyArg = spec.dsaEncoding ? { key, dsaEncoding: spec.dsaEncoding } : key;
304
+ return cryptoVerify(spec.digest, data, keyArg, signature);
305
+ }
306
+
307
+ function claimType(claims) {
308
+ if (!typeClaim) return "user";
309
+ const raw = claims[typeClaim];
310
+ return typeof raw === "string" ? raw : "user";
311
+ }
312
+
313
+ function claimScopes(claims) {
314
+ const raw = claims[scopeClaim];
315
+ if (Array.isArray(raw)) return raw.filter((s) => typeof s === "string" && s.trim());
316
+ if (typeof raw === "string") return raw.split(/\s+/).filter(Boolean);
317
+ return [];
318
+ }
319
+
320
+ function claimLabels(claims) {
321
+ const labels = {};
322
+ for (const [labelKey, claimKey] of Object.entries(labelMap)) {
323
+ const v = claims[claimKey];
324
+ if (typeof v === "string" && v) labels[labelKey] = v;
325
+ }
326
+ return labels;
327
+ }
328
+
329
+ function audienceMatches(aud) {
330
+ if (typeof aud === "string") return aud === audience;
331
+ // RFC 7519: aud is a string or an array OF STRINGS — reject a heterogeneous
332
+ // array (defence-in-depth; a spec-violating token never authenticates).
333
+ if (Array.isArray(aud)) return aud.every((a) => typeof a === "string") && aud.includes(audience);
334
+ return false;
335
+ }
336
+
337
+ return {
338
+ id: "haechi.auth.jwt",
339
+ async authenticate(request) {
340
+ try {
341
+ const header = request?.headers?.authorization ?? request?.headers?.Authorization;
342
+ if (typeof header !== "string") return null;
343
+ const m = /^Bearer\s+(.+)$/i.exec(header.trim());
344
+ if (!m) return null;
345
+ const jwt = m[1].trim();
346
+
347
+ const parts = jwt.split(".");
348
+ if (parts.length !== 3) return null;
349
+ const [h, p, s] = parts;
350
+
351
+ const head = parseJsonBounded(decodeBase64UrlStrict(h).toString("utf8"));
352
+ if (head.typ && String(head.typ).toUpperCase() !== "JWT") return null; // reject JWE/other
353
+ if (typeof head.alg !== "string" || !algorithmSet.has(head.alg)) return null; // none/HS*/unlisted
354
+ if (typeof head.kid !== "string" || !head.kid) return null; // kid required
355
+ const spec = SUPPORTED[head.alg];
356
+
357
+ const signature = decodeBase64UrlStrict(s);
358
+ const claims = parseJsonBounded(decodeBase64UrlStrict(p).toString("utf8"));
359
+
360
+ const jwk = await resolveJwk(head.kid);
361
+ if (!jwk) return null;
362
+ const key = publicKeyFor(jwk, spec);
363
+ if (!verifySignature(head.alg, spec, `${h}.${p}`, signature, key)) return null;
364
+
365
+ // ---- claim validation (all mandatory) ----
366
+ if (claims.iss !== issuer) return null;
367
+ if (!audienceMatches(claims.aud)) return null;
368
+ if (typeof claims.sub !== "string" || !claims.sub.trim()) return null;
369
+ const t = now() / 1000;
370
+ if (typeof claims.exp !== "number" || t > claims.exp + clockSkewSeconds) return null;
371
+ if (typeof claims.nbf !== "number" || t < claims.nbf - clockSkewSeconds) return null;
372
+ if (claims.iat !== undefined && (typeof claims.iat !== "number" || claims.iat - clockSkewSeconds > t)) return null;
373
+
374
+ return await buildExternalIdentity(
375
+ {
376
+ provider: "jwt",
377
+ subject: claims.sub,
378
+ issuer: claims.iss,
379
+ type: claimType(claims),
380
+ scopes: claimScopes(claims),
381
+ labels: claimLabels(claims),
382
+ ...(allowedLabelKeys ? { allowedLabelKeys } : {})
383
+ },
384
+ cryptoProvider
385
+ );
386
+ } catch {
387
+ // Fail closed: any parse/verify/identity error denies, with no detail.
388
+ return null;
389
+ }
390
+ }
391
+ };
392
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "haechi-auth-jwt",
3
+ "version": "0.1.0",
4
+ "description": "Headless JWKS bearer (JWT) authProvider satellite for Haechi — node: builtins only, PII-safe identity.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/raeseoklee/haechi.git",
10
+ "directory": "satellites/auth-jwt"
11
+ },
12
+ "homepage": "https://github.com/raeseoklee/haechi/tree/main/satellites/auth-jwt#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/raeseoklee/haechi/issues"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public",
18
+ "provenance": true
19
+ },
20
+ "keywords": [
21
+ "haechi",
22
+ "jwt",
23
+ "jwks",
24
+ "auth-provider",
25
+ "bearer",
26
+ "oidc"
27
+ ],
28
+ "main": "index.mjs",
29
+ "exports": {
30
+ ".": "./index.mjs"
31
+ },
32
+ "files": [
33
+ "index.mjs",
34
+ "README.md"
35
+ ],
36
+ "scripts": {
37
+ "test": "node --test"
38
+ },
39
+ "peerDependencies": {
40
+ "haechi": ">=0.8.0 <1.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "haechi": "*"
44
+ },
45
+ "engines": {
46
+ "node": ">=22"
47
+ }
48
+ }