Skip to content

Commit 65c8e41

Browse files
auth: LWS10-CID JWT verifier with ES256K (JavaScriptSolidServer#397) (JavaScriptSolidServer#398)
* auth: LWS10-CID JWT verifier with ES256K (JavaScriptSolidServer#397) Implements the verifier side of the LWS 1.0 SSI-via-CID FPWD (2026-04-23) — incoming HTTP requests carrying a Bearer JWT whose `kid` references a verificationMethod in the subject's WebID profile authenticate as that WebID once the JWT signature checks against the VM's publicKeyJwk. ES256K (RFC8812 — ECDSA over secp256k1) is the focus algorithm: same private key Nostr users already have, signed as ECDSA for spec conformance. ES256 / EdDSA / RS256 also accepted via jose. Detection is unambiguous — LWS-CID kids are URLs with a fragment (the VM's id field), while IDP-issued JWTs use opaque fingerprints — so the new path slots cleanly between Solid-OIDC and NIP-98 in getWebIdFromRequestAsync without conflicting with the existing Bearer fallback. The verifier enforces FPWD §4 (sub === iss === client_id, exp/iat sane, aud includes server origin), CID 1.0 §3.3 (kid resolves to a VM that's in `authentication`), and the self-control rule (VM controller agrees with profile.controller, with @id fallback) that the doctor's lws-cid validator already uses on the client side. Tests cover the happy path plus 10 distinct rejection cases: "none" alg, sub/iss/client_id mismatch, cross-document kid, expired exp, missing VM, VM absent from authentication, audience mismatch, tampered signature, key-mismatch between profile and JWT, profile fetch failure. Pairs with JavaScriptSolidServer/doctor#3 (client-side: derive JsonWebKey VM from Nostr key, PATCH into profile, sign JWTs). Refs JavaScriptSolidServer#386 (Phase 3a), JavaScriptSolidServer#319 (JavaScriptSolidServer#3 CID sub-bullet). Closes JavaScriptSolidServer#397. * Address copilot pass 1 on JavaScriptSolidServer#398 Six findings, all real: 1. SSRF (line 287): the verifier fetched docUrl from untrusted JWT claims before signature verification. Now routed through validateExternalUrl (the same guard used by solid-oidc.js, cors-proxy.js, idp/provider.js), with manual redirect handling that re-validates every Location and a hop cap to defeat redirect-based bypasses. 2. aud silently optional (line 165): per FPWD §4 "aud claim MUST include the target authorization server"; missing/empty aud is now an explicit reject rather than silently passing through. 3. getRequestOrigin trusted raw Host header (line 260): now prefers x-forwarded-proto / x-forwarded-host (the convention used in src/ap/*), falls back to fastify's protocol/hostname. New test exercises the proxy-headers path. 4. Time-claim validation (line 152): the ES256K branch skips jose, so it has to validate claims itself. Now rejects non-numeric exp / iat / nbf, enforces nbf, rejects iat too far in the future. New tests for each. 5. No profile cache (line 173): per-request HTTP fetch on the auth hot path is unacceptable. Added a small TTL cache (5 min hits / 1 min misses) mirroring did-nostr.js's pattern. Exposed _clearProfileCacheForTests so unit tests can avoid cross-test bleed. 6. Tests only covered ES256K (line 172): added happy-path tests for ES256, EdDSA, RS256 (the jose-driven branch), plus a tampered-payload test on RS256 to confirm signature verification actually runs. Plus an explicit SSRF test (localhost kid → reject). Test count for this module: 17 → 29. Full suite: 655 → 667, all pass. * Address copilot pass 2 on JavaScriptSolidServer#398 Three findings, all real: 1. Time-claim validation (line 189): the previous code rejected only when BOTH iat and exp were missing, allowing tokens with exp far in the future and no iat (bypassing the freshness check). FPWD §4 makes both required — now enforced. Also added a MAX_LIFETIME cap (3600s = 1h, configurable constant) so a leaked token has a bounded replay window; and an explicit `exp > iat` check. 2. Redirect off-by-one (line 362): `hop <= MAX_REDIRECTS` allowed one more redirect than the error message claimed. Refactored so the cap matches the message: original request + up to MAX_REDIRECTS subsequent redirects, then refuse. Threshold check moved inside the redirect branch with a clear `isLastAllowedHop` flag. 3. Unused `vm` parameter in isInProofPurpose (line 414): removed. The function only ever needed kid + baseUrl + the predicate name on the profile. New tests: rejects missing exp, rejects missing iat, rejects lifetime > 1h, rejects exp <= iat. Existing non-numeric-exp/iat test regexes loosened slightly since the error message is now "exp claim is required and must be a number" instead of just "exp claim must be a number" (more accurate, since it covers both required and type). Test count: 29 → 33 in this module. Full suite: 667 → 671 pass. * Address copilot pass 3 on JavaScriptSolidServer#398 Three findings: 1. aud check silently accepted when origin couldn't be determined (line 219). Per FPWD, aud MUST include the target server — if we can't compute our own origin (no Host, no x-forwarded-host, no fastify hostname), failing closed is the safe default. Now returns an explicit error. New test exercises this path. 2. Accept header was JSON-LD-only (line 393). Some hosts serve `card.jsonld` as `application/json`. Broadened to `application/ld+json, application/json;q=0.9` since the parser doesn't perform JSON-LD-specific processing here. 3. ES256K error message claimed only crv:secp256k1 was accepted, but the code also accepts the legacy `P-256K` alias (line 486). Message updated. Test count: 33 → 34 in this module. * Address copilot pass 4 on JavaScriptSolidServer#398 Five findings, three of them security-critical. 1. Vacuous controller check (line 267): if a profile had no controller, no @id, AND no id, normalizeControllers returned an empty list and the VM-controller check passed silently. A malformed profile could authenticate any VM. Fail closed when no expected controller can be derived. 2. Comma-separated x-forwarded-host (line 325): chains of proxies produce values like "public.example, internal.lan", and we were feeding the whole string into the origin builder. Now split on "," and take the leftmost (the original client-facing front-end). Also handle array-valued forwarded headers. 3. Cross-origin redirect during profile fetch (line 420): manual redirects were re-validated through the SSRF guard but allowed to land on any public origin. An open redirect on the WebID's host would let an attacker substitute a CID document of their choosing. Now refuse any redirect whose target origin differs from the original docUrl's origin. 4. Unbounded body size (line 427): the verifier read the entire response into memory before parsing, with no cap. Untrusted hosts could OOM us with multi-GB JSON. Two-layer guard: trust Content-Length when present, then enforce a 256 KB cap while reading via the streaming reader (cancel on overage so we don't buffer the whole body). 5. Unbounded profile cache (line 64): cache grew without limit. An attacker sending tokens with many distinct sub URLs could exhaust memory. Added simple LRU bound: 1000 entries, oldest evicted on insert. Touch-on-hit (delete-then-set) keeps recency working without an extra structure. New tests: vacuous-controller bypass rejected, multi-proxy chain parsed correctly, cross-origin redirect refused, oversize body rejected (both via Content-Length and via streaming cap). Test count: 34 → 39 in this module. Full suite: 671 → 677 pass. * Address copilot pass 5 on JavaScriptSolidServer#398 Audience comparison was asymmetric: each `aud` entry went through normalizeOrigin (which uses the WHATWG URL parser to strip default ports and lowercase the host), but `reqOrigin` was a raw string concat. So a token with `aud: 'https://example.com:443'` would fail against `reqOrigin = 'https://example.com'`, and case differences in the proxy headers would silently reject otherwise-valid tokens. Fix: run the assembled reqOrigin through the same normalizeOrigin helper. Now both sides produce canonical origin strings. New test exercises default-port-and-case normalization end-to-end (`HTTPS://Example.COM:443` aud entry vs lowercase host header → match). Test count: 39 → 40 in this module. * Address copilot pass 6 on JavaScriptSolidServer#398 Two findings: 1. hasLwsCidAuth too permissive (line 105). Any Bearer JWT with a URL-fragment kid was being routed into the verifier, regardless of alg or scheme. Tightened: also require alg in our accepted set (ES256K/ES256/ES384/EdDSA/RS256) and require the kid to use http(s) so non-LWS-CID Bearer JWTs (HS256-signed, urn:-keyed, etc.) fall through to the existing IDP / simple-token paths. 2. Subject-identity check missing (line 271). The verifier authenticated as `sub` but never confirmed the fetched profile actually identifies itself as that subject. A document with multiple `@id`/`id` values (or one returning a different fragment than claimed) could let a JWT claim WebID `#alice` while leveraging a VM controlled by `#bob` in the same document. Now compare absolutized profile['@id'] (or .id) against the JWT's sub before any VM/controller checks. New tests cover detector-side rejections (unaccepted alg, urn: kid) and verifier-side rejections (no subject in document, subject ≠ sub). The pre-existing "vacuous controller" test was relabelled — the subject-identity check now catches that case earlier, but the controller check stays as defense-in-depth for profiles that declare an @id but no controller. Test count: 40 → 43. * Address copilot pass 7 on JavaScriptSolidServer#398 One finding addressed, one rejected: - alg validation conflated missing/empty with explicit "none" (line 139). They're different conditions and should produce different error messages. Now: missing alg → "JWT header missing alg"; alg === "none" → "MUST NOT use 'none' as the signing algorithm". New test (callable directly bypassing the detector for defense-in-depth) verifies the messages don't conflate. - Rejected: claim that normalizeOrigin doesn't strip default ports. Verified: Node's URL parser already strips :443/:80 from `host` per WHATWG URL — `new URL('https://example.com:443').host` returns `'example.com'`. The default-port + case test from pass 5 already exercises this end-to-end and continues to pass. The existing implementation is correct. * Address copilot pass 8 on JavaScriptSolidServer#398 The verifier parsed `header.kid` into a URL once for validation but then used the raw string from the JWT header throughout — for the kid-document equality check, findVerificationMethod, and isInProofPurpose. VM ids get absolutized through new URL(), which canonicalizes (lowercased scheme/host, default ports stripped, percent-encoding). So a semantically equivalent but non-canonical kid in the JWT (e.g. `HTTPS://Example.COM:443/...#k1`) would fail to match a canonical VM id (`https://example.com/...#k1`). Fix: normalize kid once at the top of verifyLwsCidAuth (kidUrl.toString()), then use that canonical value in all downstream comparisons and error messages. New test exercises a non-canonical kid against a canonical VM id and confirms the match. Test count: 44 → 45. * Address copilot pass 9 on JavaScriptSolidServer#398 Two findings: 1. WebID not canonicalized (line 183). The verifier took `sub` from the JWT raw and compared it against an absolutized profile @id. A semantically equal but textually different sub (uppercase scheme/host, explicit default port) would fail the subject-identity check, AND the returned webId would be non-canonical — which breaks downstream WAC ACL string equality against agent entries. Fix: canonicalize sub/iss/client_id via `new URL(...).toString()`, compare and return the canonical form. New test: non-canonical sub matches canonical profile @id and returns the canonical webId. 2. http: kid mismatch between detector and verifier (line 110). hasLwsCidAuth accepts http:, but in production the SSRF guard requires https — so an http kid would be detected as LWS-CID, then die later with a generic "could not fetch / SSRF protection" message. Fail loud and early in the verifier with "kid must use https" so debugging is unambiguous (the SSRF guard's own production check stays as defense-in-depth). New test confirms the early rejection message. Test count: 45 → 47.
1 parent 660bcdf commit 65c8e41

3 files changed

Lines changed: 1396 additions & 1 deletion

File tree

0 commit comments

Comments
 (0)