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.
- package/README.md +42 -0
- package/index.mjs +392 -0
- 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
|
+
}
|