Commit 65c8e41
authored
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
0 commit comments