haechi-auth-jwt 0.1.0 → 0.2.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 +12 -0
  2. package/index.mjs +114 -50
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -37,6 +37,18 @@ Wired via **injection** (`auth.provider: "external"`); dynamic loading stays ban
37
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
38
  - **Fail-closed everywhere:** any verification error → `authenticate` returns `null` (deny), never throws into the request path, and echoes no token detail.
39
39
 
40
+ ## `createJwtVerifier` (the reusable primitive)
41
+
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 }`:
43
+
44
+ ```js
45
+ const verifier = createJwtVerifier({ issuer, audience, jwksUri /* ... */ });
46
+ const claims = await verifier.verify(jwt); // validated claims object, or null
47
+ const claims2 = await verifier.verify(jwt, { expectedNonce }); // + OIDC nonce check
48
+ ```
49
+
50
+ `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
+
40
52
  ## Scope (0.8)
41
53
 
42
54
  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 CHANGED
@@ -1,9 +1,15 @@
1
1
  // haechi-auth-jwt — headless JWKS bearer verification for Haechi.
2
2
  //
3
+ // createJwtVerifier(...) is the standalone, audited JWS/JWKS verification
4
+ // primitive: verify(jwt) -> validated claims | null (fail-closed). It is the
5
+ // ONE verification path reused by createJwtAuthProvider here and by the future
6
+ // haechi-auth-oidc broker (see release-0.9-implementation-scope.md §2.2).
7
+ //
3
8
  // 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.
9
+ // (authenticate(request) -> identity | null, fail-closed) on top of the
10
+ // verifier, using node: builtins only (no `jose`): JWKS fetched via global
11
+ // fetch, JWK->key via crypto.createPublicKey({ format: "jwk" }), signatures
12
+ // verified via crypto.verify.
7
13
  //
8
14
  // Every security decision below is a DECISION, not implementation discretion;
9
15
  // see docs/current/release-0.8-implementation-scope.md §2.4. The verifier never
@@ -55,7 +61,10 @@ function parseHttpsUrl(value, label) {
55
61
 
56
62
  // Block literal addresses in private/loopback/link-local ranges + cloud metadata.
57
63
  // Applied to both a literal host in the URL and every DNS-resolved address.
58
- function isBlockedAddress(host) {
64
+ // Exported (additive, behavior-preserving — auth-jwt stays 0.2.0) so the
65
+ // haechi-auth-oidc broker reuses the SAME guard rather than copying the range
66
+ // logic (release-0.9-implementation-scope.md §2.2).
67
+ export function isBlockedAddress(host) {
59
68
  // A URL's .hostname keeps the brackets on an IPv6 literal ("[::1]"), and isIP
60
69
  // rejects a bracketed string — strip them first so literals are classified.
61
70
  const bare = String(host).replace(/^\[|\]$/g, "");
@@ -125,18 +134,26 @@ function rsaModulusBits(jwk) {
125
134
  return (n.length - i - 1) * 8 + topBits;
126
135
  }
127
136
 
128
- // --- the provider ----------------------------------------------------------
129
-
130
- export function createJwtAuthProvider(options = {}) {
137
+ // --- the verifier primitive ------------------------------------------------
138
+ //
139
+ // createJwtVerifier(options) is the standalone, audited JWS/JWKS verification
140
+ // path carved out of createJwtAuthProvider so the future haechi-auth-oidc broker
141
+ // reuses ONE verification path (release-0.9-implementation-scope.md §2.2).
142
+ //
143
+ // It owns ALL construction-time validation EXCEPT the cryptoProvider check and
144
+ // claimMappings/allowedLabelKeys handling (those stay in the provider), plus the
145
+ // JWKS cache machinery, publicKeyFor, verifySignature, and audienceMatches.
146
+ //
147
+ // verify(jwt, { expectedNonce } = {}) returns the validated claims OBJECT on
148
+ // success or null on ANY failure (fully fail-closed). nonce is NOT part of the
149
+ // 0.8 bearer surface: it is checked ONLY when expectedNonce !== undefined.
150
+ export function createJwtVerifier(options = {}) {
131
151
  const {
132
152
  issuer,
133
153
  audience,
134
154
  jwksUri,
135
- cryptoProvider,
136
155
  algorithms = DEFAULT_ALGORITHMS,
137
156
  clockSkewSeconds = DEFAULT_CLOCK_SKEW_SECONDS,
138
- claimMappings = {},
139
- allowedLabelKeys,
140
157
  jwksTtlMs = DEFAULT_JWKS_TTL_MS,
141
158
  jwksCooldownMs = DEFAULT_JWKS_COOLDOWN_MS,
142
159
  fetchTimeoutMs = DEFAULT_FETCH_TIMEOUT_MS,
@@ -146,15 +163,12 @@ export function createJwtAuthProvider(options = {}) {
146
163
  } = options;
147
164
 
148
165
  // ---- 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
166
  if (typeof issuer !== "string" || !issuer) {
153
- throw new Error("createJwtAuthProvider requires an issuer");
167
+ throw new Error("createJwtVerifier requires an issuer");
154
168
  }
155
169
  const issuerUrl = parseHttpsUrl(issuer, "issuer");
156
170
  if (typeof audience !== "string" || !audience) {
157
- throw new Error("createJwtAuthProvider requires a non-empty audience");
171
+ throw new Error("createJwtVerifier requires a non-empty audience");
158
172
  }
159
173
  const jwksUrl = parseHttpsUrl(jwksUri, "jwksUri");
160
174
  if (jwksUrl.hostname.toLowerCase() !== issuerUrl.hostname.toLowerCase()) {
@@ -180,10 +194,6 @@ export function createJwtAuthProvider(options = {}) {
180
194
  throw new Error("global fetch is unavailable; pass fetchImpl");
181
195
  }
182
196
 
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
197
  // ---- JWKS cache (bounded + cooldown) ----
188
198
  let cache = { keysByKid: new Map(), fetchedAt: 0 };
189
199
  let lastRefetchAt = 0;
@@ -304,28 +314,6 @@ export function createJwtAuthProvider(options = {}) {
304
314
  return cryptoVerify(spec.digest, data, keyArg, signature);
305
315
  }
306
316
 
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
317
  function audienceMatches(aud) {
330
318
  if (typeof aud === "string") return aud === audience;
331
319
  // RFC 7519: aud is a string or an array OF STRINGS — reject a heterogeneous
@@ -335,16 +323,13 @@ export function createJwtAuthProvider(options = {}) {
335
323
  }
336
324
 
337
325
  return {
338
- id: "haechi.auth.jwt",
339
- async authenticate(request) {
326
+ // verify(jwt, { expectedNonce } = {}) — the same work authenticate() does
327
+ // between parsing the JWT string and building the identity. Returns the
328
+ // validated claims OBJECT on success, or null on ANY failure (fail-closed:
329
+ // the whole body is wrapped so malformed base64url / fetch failures deny).
330
+ async verify(jwt, { expectedNonce } = {}) {
340
331
  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(".");
332
+ const parts = String(jwt).split(".");
348
333
  if (parts.length !== 3) return null;
349
334
  const [h, p, s] = parts;
350
335
 
@@ -371,6 +356,85 @@ export function createJwtAuthProvider(options = {}) {
371
356
  if (typeof claims.nbf !== "number" || t < claims.nbf - clockSkewSeconds) return null;
372
357
  if (claims.iat !== undefined && (typeof claims.iat !== "number" || claims.iat - clockSkewSeconds > t)) return null;
373
358
 
359
+ // ---- optional nonce (NOT part of the 0.8 bearer surface) ----
360
+ // Only checked when the caller passes expectedNonce; the provider's
361
+ // bearer path omits it, preserving 0.8 behavior exactly.
362
+ if (expectedNonce !== undefined) {
363
+ if (typeof claims.nonce !== "string" || claims.nonce !== expectedNonce) return null;
364
+ }
365
+
366
+ return claims;
367
+ } catch {
368
+ // Fail closed: any parse/verify error denies, with no detail.
369
+ return null;
370
+ }
371
+ }
372
+ };
373
+ }
374
+
375
+ // --- the provider ----------------------------------------------------------
376
+ //
377
+ // createJwtAuthProvider(options) is the authProvider contract reimplemented on
378
+ // top of createJwtVerifier. It keeps the cryptoProvider check FIRST (so its
379
+ // error still fires before any verifier-construction error), owns Bearer-header
380
+ // parsing + claim->identity mapping + the PII-safe identity build, and passes
381
+ // every other option through to the verifier. Observable behavior is UNCHANGED.
382
+ export function createJwtAuthProvider(options = {}) {
383
+ const {
384
+ cryptoProvider,
385
+ claimMappings = {},
386
+ allowedLabelKeys,
387
+ ...verifierOptions
388
+ } = options;
389
+
390
+ // ---- construction-time validation (fail closed) ----
391
+ // cryptoProvider FIRST so this error still fires before any verifier error.
392
+ if (typeof cryptoProvider?.hmac !== "function") {
393
+ throw new Error("createJwtAuthProvider requires a cryptoProvider with hmac() (for a PII-safe identity)");
394
+ }
395
+
396
+ const verifier = createJwtVerifier(verifierOptions);
397
+
398
+ const scopeClaim = claimMappings.scope || "scope";
399
+ const typeClaim = claimMappings.type || null;
400
+ const labelMap = claimMappings.labels && typeof claimMappings.labels === "object" ? claimMappings.labels : {};
401
+
402
+ function claimType(claims) {
403
+ if (!typeClaim) return "user";
404
+ const raw = claims[typeClaim];
405
+ return typeof raw === "string" ? raw : "user";
406
+ }
407
+
408
+ function claimScopes(claims) {
409
+ const raw = claims[scopeClaim];
410
+ if (Array.isArray(raw)) return raw.filter((s) => typeof s === "string" && s.trim());
411
+ if (typeof raw === "string") return raw.split(/\s+/).filter(Boolean);
412
+ return [];
413
+ }
414
+
415
+ function claimLabels(claims) {
416
+ const labels = {};
417
+ for (const [labelKey, claimKey] of Object.entries(labelMap)) {
418
+ const v = claims[claimKey];
419
+ if (typeof v === "string" && v) labels[labelKey] = v;
420
+ }
421
+ return labels;
422
+ }
423
+
424
+ return {
425
+ id: "haechi.auth.jwt",
426
+ async authenticate(request) {
427
+ try {
428
+ const header = request?.headers?.authorization ?? request?.headers?.Authorization;
429
+ if (typeof header !== "string") return null;
430
+ const m = /^Bearer\s+(.+)$/i.exec(header.trim());
431
+ if (!m) return null;
432
+ const jwt = m[1].trim();
433
+
434
+ // No expectedNonce — a bearer JWT has none; preserves 0.8 behavior.
435
+ const claims = await verifier.verify(jwt);
436
+ if (!claims) return null;
437
+
374
438
  return await buildExternalIdentity(
375
439
  {
376
440
  provider: "jwt",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haechi-auth-jwt",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",