haechi-auth-jwt 0.1.1 → 0.2.1
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 +12 -0
- package/index.mjs +114 -50
- package/package.json +2 -2
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)
|
|
5
|
-
// only (no `jose`): JWKS fetched via global
|
|
6
|
-
// crypto.createPublicKey({ format: "jwk" }), signatures
|
|
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
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
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("
|
|
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("
|
|
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
|
-
|
|
339
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"test": "node --test"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"haechi": ">=0.8.0 <
|
|
40
|
+
"haechi": ">=0.8.0 <2.0.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"haechi": "*"
|