Skip to content

feat: Support Linked Web Storage (on versions/next-major) - #126

Draft
jeswr wants to merge 12 commits into
lws-next-major-basefrom
feat/lws-next-major
Draft

jeswr wants to merge 12 commits into
lws-next-major-basefrom
feat/lws-next-major

Conversation

@jeswr

@jeswr jeswr commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Requested by Jesse · project thread

📁 Related issues

Same work as #124, rebased onto upstream versions/next-major (requested in #124 (comment)). The base branch lws-next-major-base is a copy of upstream versions/next-major at 9ebe8c4, so the diff only shows the LWS changes. #124 and its branch are unchanged.

✍️ Description

Before: Linked Web Storage support only existed on top of upstream main (v7), so projects built on versions/next-major (v8) could not use it.

After: The same LWS features (the lws, lws-acp, solid-lws and solid-lws-acp presets, access tokens, access grants and requests, webhook notifications, pagination, Depth: infinity delete, problem details) run on versions/next-major.

Commits:

  1. and 2. The two LWS commits from feat: Support Linked Web Storage, alone or together with Solid #124, replayed as they were (conflicts in DataAccessorBasedStore, ResourceUtil, index.ts and the release notes resolved in favour of next-major's code).
  2. Upstream b7225aa ("Return children metadata from memory backend", by Noel De Martin), which is on main but not yet on versions/next-major. LWS container listings need it to report each item's format with the memory backend.
  3. The port to v8:
    • Logging, handlers and vocabulary types come from global-logger-factory, asynchronous-handlers and rdf-vocabulary.
    • Permissions use the policy engine's PERMISSIONS (Read, Append, Create, Modify, Delete) instead of the removed AccessMode enum, and PermissionMap/MultiPermissionMap instead of PermissionSet/PermissionMap.
    • PathBasedReader no longer unions readers on next-major, so lws-access.json now uses a Components.js Override on ReadDeleteReader to put the AccessGrantReader in a UnionPermissionReader next to the PolicyEngineReader. Internal resources are still routed away by the PathBasedReader before this point.
    • storage/middleware/lws.json follows the new base + cache layout (new storage/middleware/base/lws.json), so LWS presets also get the backend cache.
    • Configs use the v8 context, StatusWaterfallHandler, and the asynchronous-handlers context where needed.

🔁 Follow-up to the Solid Memo report

Commits 5 to 9 address the findings in #126 (comment), apart from rel=up on 401/404, which the discussion below it agreed to drop (a 404 has no parent to point to, and a 401 should not disclose one).

  1. ID token replay. The token endpoint now verifies a DPoP proof (RFC 9449) when the client sends one. An ID token with a cnf.jkt claim is only exchanged if the proof is signed with that key. A new requireRestrictedTokens option on OidcSubjectTokenVerifier also rejects ID tokens that are neither DPoP-bound nor have this authorization server in aud. It is off by default: most identity providers, including CSS's own, can't add the AS to an ID token's audience, so turning it on would lock those users out. The issued access token is still a Bearer token, as LWS requires.
    client_id as a URI. Client identifiers that are not URIs (dynamically registered clients) become <issuer>#<id>, for example https://idp.example/#abc123. Client ID Metadata Documents can't have a fragment, so these never clash with a real client ID URL.
  2. 401 challenge. The challenge now starts with resource_metadata (RFC 9728), pointing to a new protected resource metadata document under /.well-known/oauth-protected-resource/<path> with resource and authorization_servers, as in w3c/lws-protocol@9420b5d6. as_uri and realm stay for clients of earlier drafts.
  3. JSON Patch. JSON resources and linksets accept application/json-patch+json (RFC 6902), next to JSON Merge Patch, and Accept-Patch lists it first. Paths refer to the linkset as GET returns it. Server-managed links, up included, still can't be changed, so moving resources through the linkset isn't supported (MoveResource is optional). A patch that changes up is rejected with a 409, as the 19 October draft requires. The patchers and modes extractor are renamed JsonPatcher, LinksetPatcher and JsonPatchModesExtractor.
  4. LWS profile. profile="https://www.w3.org/ns/lws/v1" on application/ld+json, text/turtle, application/n-triples, application/trig or application/n-quads now returns the LWS RDF of the container, with the same profile in Content-Type. The mapping follows the spec's isomorphic example tabs (lws:items, lws:totalItems, dcterms:format, schema:size, dcterms:modified). This works in both LWS-only and dual presets. Core changes: AcceptPreferenceParser keeps profile parameters in a new profiles preference, and ContentType now quotes parameter values that are not tokens.
  5. Components build and lint fixes, plus a test for an untested AccessGrantIndex branch.

🧪 Conformance (commits 10 to 12)

Run on 6d7e9df against the latest harnesses of the Dagger workspace (lws-contrib/dagger-workspace):

  • Touchstone (ebremer/touchstone 76d22ce), with the CSS target server setup (pods, WAC, harness-issued tokens): 162 passed, 0 failed, 59 not applicable (SAML, type index, MoveResource and similar optional features). The first run found 23 failures, fixed here:
    • Commit 10, webhook subscriptions on pod-based servers. The subscription endpoint is not in a storage, so every access token was refused there. It now accepts a token for any storage of the server, and topics must be in that storage. The service path is checked before the storage strategy, because the pod strategy derives a "storage" from any URL. A 401 for an unreadable topic now targets the topic, so it points to the topic's storage and authorization server.
    • Commit 11. CID credentials only accept keys of the authentication relationship (embedded or referenced) and refuse revoked or expired verification methods (CID 1.0 §2.2, §3.3). Linkset patches that change up are refused. LWS-profile RDF leaves out modified of contained containers, which the LWS context only defines on data resources.
    • Commit 12, docs.
  • LWS.net (langsamu/LWS.net 1fe26a1) against a public root storage: 278 of 292 passed. The 14 failures are not server issues: one test fails on purpose, 12 look for an AuthorizationServer service in the storage description or a container type link on the storage description (both from earlier drafts; the current draft uses RFC 9728 metadata and returns the storage description at the storage URI), and one treats a multi-valued Vary header as an error. The workspace's default lws-net input (tests.yaml on the yaml branch of elf-pavlik/lws-test-suite) no longer loads in current LWS.net, so LWS.net's own suite was used.

Notes for reviewers:

  • Because of the override, the LWS handler configs expect the WAC or ACP authorization configs (ReadDeleteReader must exist). They will not work with ldp/authorization/allow-all.json.
  • Checks run locally on 6d7e9df: npm run build, tsc (src and test), ESLint and markdownlint clean; unit tests 2767 passed at 100% coverage; integration tests 925 passed, 29 skipped (the usual Docker-only suites), including LinkedWebStorage.test.ts 120/120 across LWS and Solid+LWS with WAC and ACP.

✅ PR check list

  • this PR is labeled with the correct semver label (semver.minor: new configs and features, existing configs are not affected)
  • the correct branch is targeted (versions/next-major content)
  • the RELEASE_NOTES.md document in case of relevant feature or config changes
  • any relevant documentation was updated to reflect the changes in this PR

🤖 Generated with Claude Code

https://claude.ai/code/session_01KLrUeu4Nbafj3Pc2bedyRD


Generated by Claude Code

claude and others added 4 commits October 7, 2026 08:32
Adds components and configurations to serve the Linked Web Storage (LWS)
protocol, based on the LWS editor's draft of 28 September 2026,
either on its own or together with the Solid Protocol, using WAC or ACP.

- Storage description on the storage root (application/lws+cid)
- LWS container representation (application/lws+json)
- LWS Link headers: storage, up, type, and linkset
- Linksets (RFC 9264) backed by description resources, with JSON Merge Patch
- JSON Merge Patch (RFC 7386) for JSON resources
- 204 responses after successful modifications
- LWS authorization server: metadata, JWKS, and token exchange (RFC 8693)
  accepting did:key, controlled identifier, and OpenID Connect credentials
- Validation of LWS access tokens and the LWS WWW-Authenticate challenge
- New lws, lws-acp, solid-lws, and solid-lws-acp configurations
- GET/HEAD requests with a failing If-Match or If-Unmodified-Since now get 412

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QzAV2EGR8JGsCnqErKMUWx
… delete and problem details

Container listings are paginated with first/last/prev/next links. Every storage
gets access request and access grant containers that are validated against the
LWS Access Profile, and grants add permissions next to WAC or ACP. Webhook
subscriptions deliver signed LWS notifications with delivery-time authorization.
DELETE with Depth: infinity removes containers recursively, and errors fall back
to application/problem+json.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QzAV2EGR8JGsCnqErKMUWx
- Use the external global-logger-factory, asynchronous-handlers, rdf-vocabulary
  and @solidlab/policy-engine libraries instead of the removed internal modules.
- Use the policy engine permissions (Read, Append, Create, Modify, Delete)
  instead of the removed AccessMode enum.
- Combine the access grants with the policy engine reader by overriding the
  reader of the ReadDeleteReader, as the PathBasedReader no longer combines readers.
- Base the LWS storage middleware on the new base and cache configurations.
- Update the LWS configurations to the v8 context and handler names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NLdD5cDKAKmvebyW2Esqu
@ixuz

ixuz commented Nov 6, 2026

Copy link
Copy Markdown

I've tested Solid Memo against this branch at 311b3677, on both solid-lws and lws, for solid-memo/website#594. Solid Memo works on both, with two changes needed on lws: asking for Turtle first on HEADs that read links, and signing in through the token exchange. The token exchange itself worked well. The full report is on solid-memo/website#594.

These are the findings for this branch, most important first. All were seen on local test servers.

ID token replay. The token endpoint accepts an ID token whose audience is only the client, and it does no client authentication (OidcSubjectTokenVerifier.ts, LwsTokenHttpHandler.ts). So it can't tell who presents a token, and a malicious storage could point a client at its own AS, collect the ID token, and try it at an AS that accepts that issuer for that user.

  • Tested: such a token was exchanged twice, each with a 200, with no client authentication.
  • Not tested: the full attack. It also needs a client that sends its ID token to whatever AS a storage names.
  • CVSS 3.1 estimate: 6.8 (AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N), or 8.1 for a client with no AS trust policy.
  • Spec: it only says aud SHOULD include the AS, and touches on the risk in its non-normative "Untrusted Metadata" text.
  • Possible fix: tie the exchanged ID token to the AS or to whoever presents it. Rejecting tokens without the AS in aud would refuse ordinary ID tokens.

Differences from the current editor's draft:

  • 401 challenge: it sends Bearer as_uri, realm with no resource_metadata, and there's no protected resource metadata endpoint (LwsWwwAuthMetadataWriter.ts). This is spec drift rather than a mistake: w3c/lws-protocol@9420b5d6 changed it on 2026-10-14, after this commit, and the section is marked at risk.
  • JSON Patch: application/json-patch+json gets 415, and Accept-Patch lists only n3, sparql-update and merge-patch (patching-lws.json). The draft asks for it on data and linkset resources.
  • Issued tokens: client_id is the opaque registration id, not a URI.
  • LWS profile: Turtle requested with profile="https://www.w3.org/ns/lws/v1" comes back without the profile, and ld+json with the profile lacks it in Content-Type.
  • rel=up: missing on 401 and 404 answers.

We run this exact commit as two advisory servers in solid-memo/website#604, and can move the pin when the branch updates.

@elf-pavlik

Copy link
Copy Markdown

rel=up: missing on 401 and 404 answers.

I don't think up on 404 is even possible since non-existing resource has no parent. As for 401, I'm not sure if parent should be disclosed in that case. Where would solid-memo need up on 4xx responses?

@ixuz

ixuz commented Nov 6, 2026 •

Copy link
Copy Markdown

rel=up: missing on 401 and 404 answers.

I don't think up on 404 is even possible since non-existing resource has no parent. As for 401, I'm not sure if parent should be disclosed in that case. Where would solid-memo need up on 4xx responses?

Indeed, Solid Memo doesn't need up on 4xx answers. I listed it as a difference between this PR and the draft because of this sentence in the spec: "All responses MUST integrate with metadata as defined in Section 8.1, including Link headers for key relations such as rel="linkset", rel="up", and rel="type"". No workaround was needed in this case (P16).

Edit: I took that to include error answers, though it may not be meant to, and a 404 has no resource to name a parent for. Please disregard this item.

Edit 2: Of the remaining differences, Solid Memo doesn't send JSON Patch, read the issued token's client_id or request the LWS profile, so these are conformance notes rather than things Solid Memo need. Perhaps I should leave spec conformance to the conformance suites, and only report what actually affects Solid Memo on this branch, so the two don't get mixed up 🤔

The token endpoint now verifies a DPoP proof when a client sends one.
Sender-constrained ID tokens (with a cnf claim) are only exchanged when
the request proves possession of their key, and the new
requireRestrictedTokens option of the OidcSubjectTokenVerifier rejects
ID tokens that are neither restricted to this authorization server nor
sender-constrained. It is off by default, as most identity providers can
not restrict the audience of their ID tokens.

Client identifiers that are not URIs, such as those of dynamically
registered clients, are put in the fragment of the issuer URI, so the
client_id claim of issued access tokens is always a URI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLrUeu4Nbafj3Pc2bedyRD
The LWS editor's draft now requires the WWW-Authenticate challenge of a
401 response to include the resource_metadata parameter of RFC 9728,
pointing to a protected resource metadata document for the requested
resource. The LWS presets now serve these documents under
/.well-known/oauth-protected-resource, and the challenge links to them.
The as_uri and realm parameters are kept for clients of earlier drafts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLrUeu4Nbafj3Pc2bedyRD
The LWS editor's draft now requires JSON Patch (RFC 6902) for updates,
on data resources and on linkset resources. JSON resources and linksets
now accept application/json-patch+json, next to JSON Merge Patch, and
Accept-Patch advertises it. The patchers and modes extractor are renamed
to JsonPatcher, LinksetPatcher and JsonPatchModesExtractor, as they now
handle both formats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLrUeu4Nbafj3Pc2bedyRD
A client can request a container in the LWS profile by adding
profile="https://www.w3.org/ns/lws/v1" to an RDF media type. The
response then has to be RDF isomorphic to the application/lws+json
representation, and its Content-Type has to carry the same profile.
Turtle requests got the LDP triples without the profile, and JSON-LD
requests lacked the profile in Content-Type.

The Accept parser now keeps profile parameters in a new profiles
preference, the LWS container converter serializes the LWS RDF in
Turtle, N-Triples, TriG and N-Quads, and Content-Type parameters that
are not tokens are now quoted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLrUeu4Nbafj3Pc2bedyRD
The nested index type of the new profiles preference could not be
handled by the Components.js generator, so it is now a named type that
the generator ignores. Also covers a branch of the AccessGrantIndex that
was not tested yet, so unit test coverage is back at 100%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLrUeu4Nbafj3Pc2bedyRD
In pod mode the subscription endpoint is not inside any storage, so every
access token was rejected there and clients tried to exchange a token for
the endpoint itself, which the authorization server refused.
The token extractor now accepts tokens for any storage of the server on
configured service paths and records the storage in the credentials.
Subscriptions are limited to topics in that storage, and a 401 for an
unreadable topic now targets the topic so the response points to its
storage and authorization server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLrUeu4Nbafj3Pc2bedyRD

jeswr commented Nov 6, 2026

Copy link
Copy Markdown
Owner Author

Thanks @ixuz, and @elf-pavlik for the up point. Agreed, I've dropped rel=up on 4xx.

The other findings are addressed in 90848b3 (details in the PR description):

  • ID token replay: the token endpoint now checks DPoP proofs, so an ID token with cnf.jkt is only exchanged with that key. There's also an opt-in requireRestrictedTokens that only accepts ID tokens with the AS in aud (or DPoP-bound ones). It's off by default because most IdPs, CSS included, can't put the AS in an ID token's audience. Full protection still depends on client-side AS checks, so I'll raise that with the WG.
  • client_id is now always a URI. Non-URI client ids become <issuer>#<id>.
  • 401 challenges now include resource_metadata, and the server serves protected resource metadata under /.well-known/oauth-protected-resource/.
  • JSON Patch works on JSON and linkset resources, and Accept-Patch lists it.
  • Turtle, N-Triples, TriG, N-Quads and JSON-LD with the LWS profile now return the LWS representation, with the profile in Content-Type.

ef6d664 also fixes webhook subscriptions on pod-based servers: the subscription endpoint now accepts an access token for any of the server's storages, and topics must be in that storage.

You can move the pin in solid-memo/website#604 to ef6d664.


Generated by Claude Code

- Check service paths before the storage of the target when validating
  LWS access tokens: the pod strategy derives a storage from any URL,
  so the notification endpoint seemed to be in a storage of its own.
- CID credentials: only accept keys of the authentication verification
  relationship (embedded or referenced, relative identifiers resolved),
  and refuse revoked or expired verification methods (CID 1.0, 2.2, 3.3).
- Linksets: refuse patches that change the up link, as moving resources
  is not supported, instead of ignoring them.
- LWS profile RDF: leave out format, size and modified of contained
  containers, which the LWS context only defines on data resources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLrUeu4Nbafj3Pc2bedyRD
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants