Use when a server-side route must act on a Solid pod on behalf of an authenticated caller — DPoP-bound Solid-OIDC route guarding, deriving the ONE authorized pod from the token WebID (bidirectional pim:storage binding), configuring trusted issuers / pod-origin allowlists, or wiring a client-credentials service identity for WAC-protected pod IO.
Recursos
10Instal·la
npx skillscat add jeswr/solid-sdk/solid-pod-guard Instal·la des del registre de SkillsCat.
The authenticated-caller pod-route boundary
@jeswr/solid-pod-guard is "securityCritical": true and Node-only. It is a
behavior-preserving extraction of a reviewed reference implementation:
changes here get adversarial review and are never auto-merged. Do not weaken, reorder, or make configurable any fail-closed
step described below.
The layered model
| Layer | Control | Where |
|---|---|---|
| L1 | DPoP-bound Solid-OIDC authentication (anonymous ⇒ 401) | createPodRouteGuard via @jeswr/solid-api-auth |
| L2 | Authorized pod DERIVED from the token WebID — bidirectional pim:storage binding; pod/webid request params rejected 400 |
resolveAuthorizedPod |
| L3 | credentialSubject == webid on every consumed credential |
the consumer's handler — NOT this package |
| L4 | Service identity for WAC-protected pod IO | createServicePodFetch |
The route pipeline (order FIXED, not configurable)
const guard = createPodRouteGuard({ config: configFromEnv("MYAPP") });
export async function GET(request: Request) {
return guard.handle(request, async (caller, body) => {
// caller.webid — the VERIFIED token WebID
// caller.podBase — the ONE pod that WebID is bound to (trailing slash)
return Response.json({ ok: true });
});
}- authenticate — anonymous is ALWAYS 401 (+
WWW-Authenticate), even with
malformed params; an emptytrustedOidcIssuersis 503, never open. - reject overrides — any
pod/webidin the query, or ANYWHERE in the
body (nested objects/arrays included), is 400 (param_rejected): identity
and pod are never request inputs. The scan is iterative with a fixed
structural budget (64 nesting levels / 25k nodes); a body beyond it is not
a legitimate route body and is rejected 400 (param_rejected) rather than
traversed. - validate body — optional JSON object; malformed ⇒ 400 before ANY pod
IO. DoS containment is built in and fixed: a 64 KiB size cap enforced
while the stream is consumed (⇒ 413body_too_large; aContent-Length
precheck is an optimization only, chunked bodies are capped mid-stream)
plus a 10 s read deadline (⇒ 408body_timeout). - bind pod —
resolveAuthorizedPod; any violation ⇒ 403 (pod_binding),
allowlist unset ⇒ 503, unreachable profile ⇒ 502. - only then your handler. A thrown
PodAccessErrorlowers to its status;
anything else is a generic 500 (details never leak).
Error bodies carry the fixed simulated: true watermark key (the extraction
kept the reviewed response shape verbatim).
Construct the guard ONCE at module scope: the verifier owns the jti replay
store and JWKS cache — a per-request guard would let captured DPoP proofs
replay.
The pod binding (L2) — why bidirectional
- FORWARD claim: the WebID's own profile must claim
<webid> pim:storage <pod>. - ALLOWLIST: claims filter through
allowedPodOrigins; exactly one must
survive — zero or several ⇒ 403, never pick-first. A pod base must be a
plain origin+path: a query or fragment is rejected outright, because<base>profile/cardcomposed on a fragment-carrying base dereferences —
fragment stripped on the wire — to a DIFFERENT resource than the
owner-only-writable profile card (a card-confusion attack on step 3). - BACKWARD acknowledgment:
<pod>profile/card(owner-only-writable) must
assert the SAME triple. The forward claim is attacker-authored (anyone can
put any IRI in their own profile) and an origin allowlist cannot separate
users on a shared pod host — only the victim pod's refusal to name the
attacker stops cross-pod substitution.
TRUST ASSUMPTION — an OPERATOR requirement
The backward acknowledgment is meaningful only because <pod>profile/card
is OWNER-ONLY-WRITABLE. That is a requirement on every pod host you put inallowedPodOrigins, and this code cannot verify it from outside. If an
allowlisted host lets non-owners (or anonymous callers) write the profile
card, the binding's load-bearing control is void. Operators MUST confirm this
property before allowlisting a pod origin.
L3 subject binding — a CONSUMER obligation
This package binds the caller to a pod. It does not inspect the
credentials your handler reads out of that pod. Your handler MUST enforcecredentialSubject == caller.webid on every consumed credential — a fully
valid credential about someone else must never influence a decision for this
caller. This deliberately stays the consumer's job (the guard has no knowledge
of your credential shapes).
Configuration
configFromEnv("MYAPP") reads (comma-separated lists):
MYAPP_TRUSTED_OIDC_ISSUERS— unset ⇒ every guarded route is 503.MYAPP_POD_ALLOWED_ORIGINS— SSRF allowlist; unset ⇒ 503.MYAPP_DEV_ALLOW_LOOPBACK=1— dev/e2e ONLY: permits loopback http and swaps
the DNS-pinned profile fetch for a plain redirect-refusing one. It never
relaxes any verification gate.MYAPP_TRUST_FORWARDED_HEADERS=1(auto-on underVERCEL=1) — REQUIRED
behind a TLS-terminating proxy so the DPoPhtubinding reconstructs the
public URL; anywhere else forwarded headers are attacker-controlled, leave
it off.
Next.js basePath note: the framework strips the basePath fromrequest.url, but callers mint DPoP proofs against the full public URL — passpublicRequestUrl in PodGuardOptions to re-add it.
Service identity (L4)
createServicePodFetch({ issuer, clientId, clientSecret }) returns a fetch
that authenticates as the app's OWN WebID via client_credentials: DPoP-bound
RFC 9068 token (cached until near expiry), fresh single-use RFC 9449 proof per
request, https-only, every hop refuses redirects, the secret is never echoed
into errors. Server-only — the secret lives in server env. Authorization stays
at the POD (WAC grants naming the service WebID), never in app code.
Testing
The suites in test/ run the REAL path — an in-memory sparq Solid server
(vendored @jeswr/solid-server, dev-only) plus real loopback Solid-OIDC dev
issuers; verification and binding decisions are never mocked. Extend them the
same way: transport seams (ownerSeams.profileFetch, the service fetch
seam) may observe or route traffic, but a test that stubs the verify/binding
decision itself is vacuous and will be rejected.