haechi-auth-jwt 0.2.1 → 0.3.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 +41 -4
  2. package/index.mjs +91 -6
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
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
4
 
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install haechi haechi-auth-jwt # peer: haechi >=0.8.0 <2.0.0
9
+ ```
10
+
11
+ **`haechi` (the core) must be installed** — it is a peer dependency, not bundled. This satellite imports `haechi/runtime` and reuses your installed `haechi` instance (single crypto/identity surface), so install the core alongside it.
12
+
5
13
  ## Usage
6
14
 
7
15
  ```js
@@ -32,14 +40,14 @@ Wired via **injection** (`auth.provider: "external"`); dynamic loading stays ban
32
40
  - **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
41
  - **`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
42
  - **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.
43
+ - **JWKS fetching is SSRF-hardened:** `issuer` and `jwksUri` must be **HTTPS**, and the JWKS host must equal the issuer host **or** be listed in `trustedEndpointHosts` (multi-origin / CDN-fronted IdPs — see below; empty by default ⇒ strict single-origin); requests to private/loopback/link-local/metadata addresses are refused (literal host + resolved IPs) regardless of the allowlist; fetch has a timeout and a 1 MiB response cap; JSON parsing is depth-bounded; JWT segments are strict base64url.
36
44
  - **JWKS cache is bounded:** TTL-cached; an unknown `kid` triggers at most one refetch per cooldown (no fetch-storm against the IdP).
37
45
  - **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
46
  - **Fail-closed everywhere:** any verification error → `authenticate` returns `null` (deny), never throws into the request path, and echoes no token detail.
39
47
 
40
48
  ## `createJwtVerifier` (the reusable primitive)
41
49
 
42
- `createJwtVerifier(options)` is the standalone, audited JWS/JWKS verification path that `createJwtAuthProvider` is built on. It takes the verification-only options (`issuer`, `audience`, `jwksUri`, `algorithms`, `clockSkewSeconds`, JWKS cache/fetch knobs, `now`) — **no `cryptoProvider`, `claimMappings`, or `allowedLabelKeys`** (those stay in the provider) — and returns `{ verify }`:
50
+ `createJwtVerifier(options)` is the standalone, audited JWS/JWKS verification path that `createJwtAuthProvider` is built on. It takes the verification-only options (`issuer`, `audience`, `jwksUri`, `trustedEndpointHosts`, `algorithms`, `clockSkewSeconds`, JWKS cache/fetch knobs, `now`) — **no `cryptoProvider`, `claimMappings`, or `allowedLabelKeys`** (those stay in the provider) — and returns `{ verify }`:
43
51
 
44
52
  ```js
45
53
  const verifier = createJwtVerifier({ issuer, audience, jwksUri /* ... */ });
@@ -49,6 +57,35 @@ const claims2 = await verifier.verify(jwt, { expectedNonce }); // + OIDC nonce c
49
57
 
50
58
  `verify(jwt)` does exactly the 0.8 bearer work — signature + `alg`/`kid`/RSA-bits + `iss`/`aud`/`exp`/`nbf` — and returns the **validated claims object** (not an identity) or `null` on any failure (fully fail-closed). `nonce` is **not** part of the bearer surface: it is checked only when `expectedNonce` is passed, and is a no-op when omitted. This is the single verification path reused by the `haechi-auth-oidc` broker (0.9).
51
59
 
52
- ## Scope (0.8)
60
+ ### `trustedEndpointHosts` (multi-origin / CDN-fronted IdPs)
61
+
62
+ `trustedEndpointHosts` (an array of **bare hostnames**, default `[]`) lets an operator pin additional hosts that are allowed to serve this IdP's JWKS when the JWKS host differs from the issuer host — the common shape for a CDN-fronted or custom-domain IdP (Azure AD B2C, Auth0). A JWKS host is accepted **iff** it equals the issuer host **OR** it is listed in `trustedEndpointHosts`:
63
+
64
+ ```js
65
+ const verifier = createJwtVerifier({
66
+ issuer: "https://login.contoso.com", // issuer host: login.contoso.com
67
+ audience: "haechi-gateway",
68
+ jwksUri: "https://contoso.b2clogin.com/contoso.onmicrosoft.com/b2c_1_signin/discovery/v2.0/keys",
69
+ trustedEndpointHosts: ["contoso.b2clogin.com"] // pin the differing JWKS host
70
+ });
71
+ ```
72
+
73
+ This option **relaxes ONLY the same-host string check**. Every other guard still runs **unconditionally**:
74
+
75
+ - `jwksUri` must still be **`https`**.
76
+ - The `isBlockedAddress` **SSRF guard** still refuses a private/loopback/link-local/metadata host (literal host at construction + every DNS-resolved address before each fetch) — you cannot allowlist `169.254.169.254` or a loopback host.
77
+ - The set is **config-only**: it is built exclusively from the operator-supplied array, **never** from discovery- or JWKS-document content, so an attacker who controls the JWKS payload cannot introduce a new host.
78
+
79
+ Each entry must be a bare hostname (no scheme, path, port, or whitespace) or construction throws. **Empty/absent ⇒ strict single-origin** (the JWKS host must equal the issuer host — the default, zero behavior change).
80
+
81
+ ## 한국어 (요약)
82
+
83
+ 이 위성에는 별도 `README.ko.md` 형제 파일이 없습니다(저장소의 다른 위성 README도 모두 영문 단독입니다). 영문-주 + 한국어-형제 관례에 따라 핵심 내용을 아래에 요약합니다.
84
+
85
+ `createJwtVerifier`의 `trustedEndpointHosts`(bare 호스트명 배열, 기본 `[]`)는 운영자가 issuer 호스트와 다른 JWKS 호스트를 허용하도록 핀하는 옵션입니다(CDN-fronted / 커스텀 도메인 IdP — Azure AD B2C, Auth0). JWKS 호스트는 issuer 호스트와 같거나 `trustedEndpointHosts`에 포함될 때**만** 허용됩니다. 예: issuer `https://login.contoso.com`, jwksUri `https://contoso.b2clogin.com/.../keys`, `trustedEndpointHosts: ["contoso.b2clogin.com"]`.
86
+
87
+ 이 옵션은 **same-host 문자열 검사만 완화**합니다. `https` 요구, `isBlockedAddress` SSRF 가드(private/loopback/link-local/metadata 호스트 거부 — `169.254.169.254`나 loopback은 allowlist에 추가해도 거부됨)는 **무조건** 실행됩니다. 이 집합은 **설정 전용**으로 discovery/JWKS 문서 내용에서는 절대 만들어지지 않으므로, JWKS 페이로드를 장악한 공격자가 새 호스트를 주입할 수 없습니다. 비어 있거나 없으면 **엄격한 single-origin**(JWKS 호스트 == issuer 호스트)이 기본값입니다.
88
+
89
+ ## Scope
53
90
 
54
- Single-origin issuers only (issuer host == JWKS host). Multi-origin/CDN-fronted JWKS and full interactive OIDC (`haechi-auth-oidc`) are 0.9.
91
+ Multi-origin / CDN-fronted JWKS is supported via `trustedEndpointHosts` (auth-jwt 0.3.0; see above). Full interactive OIDC (`haechi-auth-oidc`) is a separate satellite.
package/index.mjs CHANGED
@@ -59,6 +59,66 @@ function parseHttpsUrl(value, label) {
59
59
  return url;
60
60
  }
61
61
 
62
+ // Parse an IPv6 literal into its 16 octets (or null when it is not a valid IPv6
63
+ // text form). This is the SOUND way to recognise an IPv4-mapped IPv6 address in
64
+ // EVERY textual form: dotted (::ffff:127.0.0.1), HEX (::ffff:7f00:1), bracketed
65
+ // ([::ffff:7f00:1], stripped by the caller), leading-zero (::ffff:7f00:0001),
66
+ // mixed `::` compression, and case-insensitive ffff. We classify the last 32
67
+ // bits as the embedded IPv4 ONLY when bytes 0..9 are zero and bytes 10..11 are
68
+ // 0xffff (the ::ffff:0:0/96 IPv4-mapped prefix), so a genuinely public mapped
69
+ // address (::ffff:8.8.8.8 == ::ffff:808:808) stays allowed and a non-mapped v6
70
+ // (::ffff:0:7f00:1, NAT64 64:ff9b::…) is NOT mistaken for an embedded IPv4.
71
+ //
72
+ // Kept byte-for-behavior identical to packages/ssrf/index.mjs and
73
+ // satellites/crypto-kms/vault.mjs (parity-tested) — see this module's header and
74
+ // crypto-kms/ssrf-parity.test.mjs. The DELIBERATE 1.1 decoupling means each copy
75
+ // carries this logic rather than importing haechi/ssrf.
76
+ function ipv6ToBytes(str) {
77
+ let s = str;
78
+ // A trailing dotted IPv4 quad (::ffff:127.0.0.1) — peel it off into the final
79
+ // two hextets so the remaining text is pure hex groups.
80
+ let tailV4 = null;
81
+ if (s.includes(".")) {
82
+ const idx = s.lastIndexOf(":");
83
+ if (idx === -1) return null;
84
+ const quad = s.slice(idx + 1).split(".");
85
+ if (quad.length !== 4) return null;
86
+ const oct = quad.map(Number);
87
+ if (oct.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
88
+ tailV4 = oct;
89
+ s = `${s.slice(0, idx + 1)}0:0`; // placeholder hextets; overwritten below
90
+ }
91
+ const halves = s.split("::");
92
+ if (halves.length > 2) return null; // at most one "::"
93
+ const toGroups = (g) => (g === "" ? [] : g.split(":").map((h) => (/^[0-9a-fA-F]{1,4}$/.test(h) ? parseInt(h, 16) : NaN)));
94
+ const head = toGroups(halves[0]);
95
+ const tail = halves.length === 2 ? toGroups(halves[1]) : null;
96
+ if (head.some(Number.isNaN) || (tail && tail.some(Number.isNaN))) return null;
97
+ let groups;
98
+ if (tail === null) {
99
+ if (head.length !== 8) return null;
100
+ groups = head;
101
+ } else {
102
+ const missing = 8 - head.length - tail.length;
103
+ if (missing < 0) return null;
104
+ groups = [...head, ...Array(missing).fill(0), ...tail];
105
+ }
106
+ if (groups.length !== 8) return null;
107
+ const bytes = [];
108
+ for (const g of groups) bytes.push((g >> 8) & 0xff, g & 0xff);
109
+ if (tailV4) { bytes[12] = tailV4[0]; bytes[13] = tailV4[1]; bytes[14] = tailV4[2]; bytes[15] = tailV4[3]; }
110
+ return bytes;
111
+ }
112
+
113
+ // Return the embedded IPv4 dotted quad of an IPv4-mapped IPv6 address, or null.
114
+ function mappedIpv4(bare) {
115
+ const b = ipv6ToBytes(bare);
116
+ if (!b) return null;
117
+ for (let i = 0; i < 10; i += 1) if (b[i] !== 0) return null; // bytes 0..9 must be zero
118
+ if (b[10] !== 0xff || b[11] !== 0xff) return null; // bytes 10..11 must be 0xffff
119
+ return `${b[12]}.${b[13]}.${b[14]}.${b[15]}`;
120
+ }
121
+
62
122
  // Block literal addresses in private/loopback/link-local ranges + cloud metadata.
63
123
  // Applied to both a literal host in the URL and every DNS-resolved address.
64
124
  // Exported (additive, behavior-preserving — auth-jwt stays 0.2.0) so the
@@ -82,10 +142,11 @@ export function isBlockedAddress(host) {
82
142
  if (v === 6) {
83
143
  const h = bare.toLowerCase();
84
144
  if (h === "::1" || h === "::") return true; // loopback / unspecified
85
- if (h.startsWith("::ffff:")) { // IPv4-mapped
86
- const mapped = h.slice("::ffff:".length);
87
- if (isIP(mapped) === 4) return isBlockedAddress(mapped);
88
- }
145
+ // IPv4-mapped IPv6 — normalise to the embedded IPv4 (handles dotted AND hex
146
+ // forms, e.g. ::ffff:127.0.0.1 and ::ffff:7f00:1) and run the v4 check, so a
147
+ // private/loopback/metadata target can't slip past as hex (P1-CR-002).
148
+ const mapped = mappedIpv4(bare);
149
+ if (mapped !== null) return isBlockedAddress(mapped);
89
150
  // Range-check the first hextet (startsWith("fe80") wrongly let fe81–febf
90
151
  // through): fe80::/10 link-local, fc00::/7 ULA, ff00::/8 multicast.
91
152
  const firstHextet = parseInt(h.split(":")[0] || "", 16);
@@ -152,6 +213,7 @@ export function createJwtVerifier(options = {}) {
152
213
  issuer,
153
214
  audience,
154
215
  jwksUri,
216
+ trustedEndpointHosts = [],
155
217
  algorithms = DEFAULT_ALGORITHMS,
156
218
  clockSkewSeconds = DEFAULT_CLOCK_SKEW_SECONDS,
157
219
  jwksTtlMs = DEFAULT_JWKS_TTL_MS,
@@ -171,9 +233,32 @@ export function createJwtVerifier(options = {}) {
171
233
  throw new Error("createJwtVerifier requires a non-empty audience");
172
234
  }
173
235
  const jwksUrl = parseHttpsUrl(jwksUri, "jwksUri");
174
- if (jwksUrl.hostname.toLowerCase() !== issuerUrl.hostname.toLowerCase()) {
175
- throw new Error("jwksUri host must equal the issuer host (single-origin issuers only in 0.8)");
236
+ // Operator-declared PINNED allowlist of additional hostnames permitted to
237
+ // serve this IdP's endpoints (e.g. an Azure AD B2C / Auth0 custom-domain JWKS
238
+ // host that differs from the issuer host). It RELAXES the same-host
239
+ // requirement ONLY — never the https or SSRF guards below — and is a fixed
240
+ // operator pin, so an attacker controlling the discovery/JWKS content cannot
241
+ // introduce a new host (the mix-up / SSRF defence stands). Empty/absent =>
242
+ // today's strict single-origin behavior (zero behavior change by default).
243
+ if (!Array.isArray(trustedEndpointHosts)) {
244
+ throw new Error("trustedEndpointHosts must be an array of bare hostnames");
245
+ }
246
+ const trustedHostSet = new Set();
247
+ for (const entry of trustedEndpointHosts) {
248
+ if (typeof entry !== "string" || !entry.trim()) {
249
+ throw new Error("each trustedEndpointHosts entry must be a non-empty hostname string");
250
+ }
251
+ if (/[\s/:]/.test(entry) || entry.includes("://")) {
252
+ throw new Error("each trustedEndpointHosts entry must be a bare hostname (no scheme, path, port, or whitespace)");
253
+ }
254
+ trustedHostSet.add(entry.toLowerCase());
255
+ }
256
+ const jwksHost = jwksUrl.hostname.toLowerCase();
257
+ if (jwksHost !== issuerUrl.hostname.toLowerCase() && !trustedHostSet.has(jwksHost)) {
258
+ throw new Error("jwksUri host must equal the issuer host or be listed in trustedEndpointHosts");
176
259
  }
260
+ // https (above) and SSRF (here) run UNCONDITIONALLY — the allowlist never
261
+ // bypasses them: an operator cannot allowlist 169.254.169.254 / loopback.
177
262
  if (isBlockedAddress(jwksUrl.hostname)) {
178
263
  throw new Error("jwksUri host resolves to a blocked (private/loopback/link-local/metadata) address");
179
264
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haechi-auth-jwt",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Headless JWKS bearer (JWT) authProvider satellite for Haechi — node: builtins only, PII-safe identity.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",