@solid-memo/solid-memo-client is a second implementation of the
Solid client modules the app reads and writes pods and signs in
through, built on no Inrupt library. Inrupt's clients stay the default,
and a build or a test run takes this one with
SOLID_CLIENT=solid-memo-client. This page says what it replaces, what
it's built on, how it keeps a session and grants access, where it
differs from Inrupt's, and how it's tested.
The app reaches the Solid client libraries only through modules of its
own, whose types are in client/interface.ts and name no library
(boundaries.md,
vendor-code.md). The
package, packages/solid-memo-client/,
has a module of the same name for each, with the same exports and
types:
| Module | Replaces | Built on |
|---|---|---|
| rdf.ts, with dataset.ts | packages/solid/src/client/rdf.ts: datasets and their subjects |
n3's RDF/JS terms. Datasets and subjects are the package's own frozen objects; a dataset's edits are a chain of steps that changesOf replays. |
| resources.ts | packages/solid/src/client/resources.ts: documents, containers and files, the profile lookup, and the Turtle and JSON-LD parsers |
fetch; n3 to parse and write Turtle; jsonld-streaming-parser to parse JSON-LD |
session.ts, with oidcLogin.ts, oidcSession.ts, oidcStore.ts and oidcBrowser.ts |
packages/solid/src/client/session.ts: the sign-in client |
oauth4webapi for Solid-OIDC, WebCrypto for the DPoP keys, IndexedDB for the session, Web Locks between tabs |
| access.ts | packages/solid/src/client/access.ts: the public-read grant |
fetch and n3 |
| turtleDocument.ts | packages/shacl/src/turtleDocument.ts: a shape's Turtle, read |
fetch and n3 |
The bodies of its PATCH requests come from solid's
client/sparqlUpdate.ts, as Inrupt's client's do, and a write refuses
the same terms (writableTerms.ts). The package imports solid for
these, for the interface and, in its tests, for the contract suites. It
imports no @inrupt/ package, its tests included, and no package
imports it, as npm run check:boundaries checks.
SOLID_CLIENT picks the client when a Vite or Vitest config loads:
| Value | Client |
|---|---|
unset, empty or inrupt |
Inrupt's, in packages/solid/src/client/: the default |
solid-memo-client |
this package's |
| anything else | none: the config fails to load, and its error names the values above |
The switch is a Vite plugin, solidClientPlugin in
scripts/solidClient.ts. Every package's
Vitest config has it, through vitest.shared.ts, and so do
apps/web's Vite config and e2e/pod's Vitest config. With
solid-memo-client, it resolves every import of a module its
REPLACEMENTS list names to the package's module of the same name,
the package's own imports aside. No caller changes. With Inrupt's
client, the plugin has no hooks, so a test's vi.mock of a client
module works as it does without the plugin.
SOLID_CLIENT=solid-memo-client npm run build # the site, the Studio included, on this client
SOLID_CLIENT=solid-memo-client npm run dev # the site from source, on this client
SOLID_CLIENT=solid-memo-client npm run test:pod # the end-to-end tests against real servers
SOLID_CLIENT=solid-memo-client npm run test:unit -w @solid-memo/shacl # one package's testsA package's tests run with the switch on without coverage, through
test:unit. With coverage, its test task fails: the modules the
switch replaces are never loaded, so its coverage falls below 100%.
The user journeys test the build in apps/web/dist/: a build with the
switch on, then npm run journeys. Run locally, the journeys reuse any
server already on port 4173, which may serve another build
(testing.md).
Under the switch, the tests of the four Inrupt modules this client
replaces (packages/solid/src/client/{access,rdf,resources,session,session.library,session.redirect.library}.test.ts)
test what is the library's alone, and some fail. The rest of solid
runs without them, as CI runs it:
SOLID_CLIENT=solid-memo-client npm run test:unit -w @solid-memo/solid -- --exclude 'src/client/{access,rdf,resources,session,session.library,session.redirect.library}.test.ts'Turbo hashes SOLID_CLIENT (globalEnv) and scripts/solidClient.ts
(globalDependencies) into every task, so a result cached on one
client never stands for the other. The tasks that resolve through the
plugin have the package's src/ and package.json among their
inputs: solid's test and test:roundtrip, shacl's and
composition's test, and web's test and build. They run again
when either changes, on either client, but not for a new release of
one of its dependencies that only package-lock.json names.
npm run check:exports counts an export of this package as used where
the module it replaces uses the same name, and npm run check walks
its run-time dependencies for releases before 1.0.0, as it walks the
app's (vendor-code.md).
The sign-in client (authentication.md) runs
Solid-OIDC's authorization-code flow on oauth4webapi
(oidcLogin.ts):
- Discovery reads the provider's
/.well-known/openid-configuration. A provider whose authorization, token or registration endpoint is neitherhttps:norhttp:to this machine is refused, as is one whose token endpoint is on another origin than its issuer, before the browser is sent anywhere. - The client the app logs in as is its Client ID Document, when
the page has one and the provider lists
webidinscopes_supported. Otherwise the provider registers the app dynamically, once per issuer and redirect URL, with no secret where the provider allows it (token_endpoint_auth_methodnone). - The request asks for
openid offline_access webid, with PKCE (S256), astateand anonce. A login asks withprompt=consent, which the Community Solid Server needs before it grantsoffline_access. - The answer must name its provider in
iss, even when the provider's metadata doesn't say it sends one (authentication.md). - The token request trades the code for DPoP-bound tokens, and is
sent once more when the provider asks for a DPoP nonce. The ID
token's issuer, audience and nonce are checked. Its
webidclaim is the WebID, or else itssubwhen that's anhttp:orhttps:URL.
Each login makes a new ES256 key pair with WebCrypto, its private key not extractable, and binds the tokens to it. The browser keeps (oidcStore.ts):
| Where | What |
|---|---|
IndexedDB, database solid-memo-oidc, store session |
the session a reload restores: the issuer, the client, the redirect URL, the DPoP key pair as CryptoKey objects, the WebID, and the refresh token when the provider gave one |
IndexedDB, database solid-memo-oidc, store registrations |
the clients providers registered the app as, by issuer and redirect URL; logout keeps them |
sessionStorage, solid-memo:oidc-login |
during a login, the request under way: the issuer, the client, the redirect URL, the PKCE code verifier, the state and the nonce, and for a silent restore the page it left; removed when the provider sends the learner back |
| memory | the access token and the ID token, and when each expires; the Linked Web Storage storages the session met, each with its authorization server and its token, and when that expires |
The access token, the ID token and the LWS tokens are never stored. A
logout, or a new login, drops them with the session they belong to. IndexedDB keeps the key pair as
CryptoKey objects, which localStorage, holding only text, can't.
The session's requests are in oidcSession.ts:
restore()completes the redirect back from the provider when the page is one: astateon its address and a request under way in this tab. It removes the provider's parameters from the address.- A reload trades the stored refresh token for new tokens without
leaving the page, so
restoredFromis null and the page keeps its hash. Without a refresh token, or when the provider refuses it,restore()asks the provider again withprompt=none, as Inrupt's client always does. It reports the page it left once the provider sends the learner back. When another tab logged out, or logged in as someone else or at another provider, after the reload read the stored session,restore()finds no one and asks the provider nothing. A provider that refuses the silent login ends the stored session only when it's still this provider's. sessionIssuer()names the stored session's issuer, which the gateway keeps before a silent restore leaves the page (authentication.md).forgetSession()ends the stored session and any request under way in this tab, without asking the provider anything, as the learner asks after a restore the provider left unanswered (authentication.md). A restore still under way then stores nothing.requestIssuer(state)names the issuer of the request under way in this tab whenstateis its state, and none otherwise.- A request carries
Authorization: DPoPwith the access token and a proof of its method and its URL. It's sent once more when the pod answers 401 asking for a DPoP nonce, if its body can be sent again. Both tries keep the caller's redirect mode, so authFetch'sredirect: "error"holds (authentication.md). The access token is refreshed 30 seconds before it expires. Without a refresh token, the session ends once the access token expired, and the next request goes without one (authentication.md). A request to an LWS storage carries a Bearer token instead (below). - A refresh, a login's save and the clear after a refused silent
restore hold the Web Lock
solid-memo:oidc-session. A browser without Web Locks queues them within its tab. So two tabs never spend one refresh token, and a refresh under way can't store an older session over a newer login. - Every request to the provider, discovery, registration and the token requests, is given up after 30 seconds. So a provider that doesn't answer can't hold the lock, or keep a login or restore waiting, forever. A refresh can still hold the lock for about twice that, since its token request is sent once more when the provider asks for a DPoP nonce.
- A refresh reads the stored session again, and takes its refresh token and key pair, which a login in another tab may have replaced. A provider that refuses the refresh token ends the stored session. When another tab has since logged in at another provider, the refresh sends nothing to this tab's provider, and the tab logs out. The other provider's session stays stored.
logout()forgets the session in this browser only, as Inrupt's client does: the provider's own session, and itsend_session_endpoint, are left alone (authentication.md). It doesn't wait for the lock. A refresh under way stores its tokens only while the session it read is still stored, in one IndexedDB transaction, so it can't bring back a session a logout cleared.
A storage that speaks Linked Web Storage (LWS) authorization takes
neither the DPoP-bound access token nor a DPoP proof. It takes a
Bearer token its authorization server issues for the session's ID
token, in an OAuth 2.0 Token Exchange
(RFC 8693). The session gets
one in lwsAuth.ts,
under its fetch in oidcSession.ts, so authFetch sees only the final
answer. The flow follows the
LWS Protocol 1.0
working draft:
- The challenge. A request goes with the access token, as to any
Solid server. A 401 is an LWS challenge when its
WWW-Authenticatehas aBearerchallenge withresource_metadata(RFC 9728). ItsLinkheader names the storage, with relationhttps://www.w3.org/ns/lws#storage. The requested URL must be within that storage: on its origin, its path the storage's or below it. - The checks, LWS's "Discovery Validation by a Client", before the
ID token goes anywhere. The
resource_metadataURL has the requested URL's scheme and authority, and its document names the requested URL as itsresource. Its firstauthorization_serversentry is the authorization server, and must be the session's issuer (the trust rule). The storage's description, asked for asapplication/lws+cid, is aStoragewith aStorageRootservice on the requested URL's scheme and authority. - The authorization server's metadata comes from its
/.well-known/lws-configuration, placed before the issuer's path as RFC 8414 places a well-known path. Itsissueris the one asked for, and its token endpoint ishttps:, orhttp:to this machine, on the issuer's origin. Where it lists its grant types and subject token types, the lists include the token exchange and the ID token. - The exchange posts the token exchange grant with the storage as
resourceand the ID token assubject_token, of typeurn:ietf:params:oauth:token-type:id_token. The client authenticates as it does at login, and the request carries no DPoP proof. The answer must be aBearertoken. - The request again, once, with
Authorization: Bearerand noDPoPheader, keeping the caller's method, body, headers, redirect mode and signal. A body the first try used up, aRequest's or a stream, isn't sent again. The session still learns the storage, so the next request to it goes with its token.
A failed check, or an exchange the authorization server refuses, sends nothing further: the caller gets the 401 as it came. The checks read each document as no one, follow no redirect, and refuse one past 64 KiB, since any server that answers 401 names them. A check or an exchange that can't reach its server fails the request, as a dropped connection does. The checks' requests are given up after 30 seconds, or as soon as the caller's signal aborts. The exchange is given up after 30 seconds. A caller whose signal aborts stops waiting for it, and the other requests waiting for it still get its token.
- Kept per storage, in memory, on the session: a logout or a new login drops them all. A request to a URL within a storage met before, the innermost one, goes with the storage's token first.
- Renewed 30 seconds before it expires, and one exchange at a time per storage: the requests that wait share it.
- Refused with a 401: the session drops the token, exchanges once more, and sends the request again once. A second 401 goes to the caller. A token exchanged for this request isn't exchanged again.
- Not asked for again with an ID token the authorization server refused for that storage. The session exchanges again once it has another ID token.
- Sent only within its storage. A Bearer token, unlike a DPoP one,
works for whoever holds it, and the app's fetch reaches other
servers than the user's storages. A request with it follows no
redirect: it fails when the caller asked for
redirect: "error", as authFetch does, and otherwise comes back as the redirect it got. So the token never goes to another origin, or outside the storage's path on the same server.
The ID token comes with the login's tokens, and with a refresh's when the provider sends a new one. A refresh that brings none keeps the one the session has. When the ID token is about to expire, the session refreshes first, until a refresh brings no ID token. With no ID token, or an expired one, the session exchanges nothing and the 401 goes to the caller. A reload restores the session with a refresh, so after a reload the session has an ID token only from a provider that sends one with refreshed tokens.
The session sends the ID token only to an authorization server whose issuer is the session's own issuer, but for one trailing slash. The ID token's audience is the app, so any LWS authorization server that trusts the identity provider takes it as proof of the user. A storage that named an authorization server of its own choosing would get a credential good at every storage behind that provider. The rule compares the whole issuer, path included, as one origin can serve several issuers under different paths, such as the tenants or realms of one provider, and each is a provider of its own. An LWS deployment whose authorization server has another issuer than the identity provider isn't supported yet: its storages answer 401, as to no one.
grantPublicRead
(access.ts) makes a
published release, or a public type index the app creates, readable by
everyone, on Web Access Control (WAC) or
Access Control Policies (ACP), as Inrupt's universal access does
(boundaries.md):
- The access control is the one the document's
Link rel="acl"names, from a HEAD, or a GET when the server refuses the HEAD. It's an ACR when its own response'srel="type"saysacp:AccessControlResource, or its Turtle names an ACP term, and a WAC ACL otherwise. - On WAC, the grant adds an
acl:Authorizationofacl:Readforacl:agentClass foaf:Agent, withacl:accessTothe document alone, unless the ACL has one. An ACL not written yet first gets theacl:defaultrules of the nearest folder above that has an ACL, each copied as a rule of the document alone, as Inrupt's universal access copies them. The owner keeps what the folder's ACL gave them. - On ACP, the grant adds an access control applying a policy that
allows
acl:Readto a matcher ofacp:PublicAgent, as Inrupt's library writes one. A policy is everyone's when its matchers nameacp:PublicAgentand no client, issuer or credential. A policy of everyone's that denies read wins: the grant then writes nothing and gives false. - Each write is one PATCH of
INSERT DATA, one N-Triples statement a line, and takes nothing away. The grant gives true only when the access control, read again, lets everyone read. It gives false when the server names no access control, or it can't be read.
Every request goes by URL, so the publisher's fence sees where each goes.
Both pass the same contract. Beyond it, this client differs:
- JSON-LD contexts by URL aren't fetched: a JSON-LD text that names
one fails to parse (
parseQuads). Inrupt's parser fetches it. - Plain HTTP: the session sends its token only over
https:, or overhttp:to this machine (localhost,127.0.0.1,[::1]or a.localhostname), and sends a request elsewhere without it.oauth4webapirefuses a provider on plain HTTP, except on this machine. - A reload restores the session with its refresh token, not by a silent redirect to the provider (above).
- The ID token's signature isn't checked against the provider's keys. OpenID Connect allows this for a token the client receives from the token endpoint over TLS (OpenID Connect Core 1.0, 3.1.3.7).
- Linked Web Storage works on this client only. The exchange needs
the ID token, which
@inrupt/solid-client-authn-browser5 gives the app only in the address of an identity provider's logout, and from no refresh. Inrupt's client sends a DPoP request to an LWS storage, which reads it as no one's. Using an LWS storage needs a build withSOLID_CLIENT=solid-memo-client(above). - IndexedDB is required: in a browser that refuses it, sign-in fails. Nothing falls back to a session that lasts only for the page.
- A dynamic registration is kept until the site's data is cleared. When a provider forgets it, the login fails at the provider, and nothing registers the app again.
-
The contract. Each module's test file runs the contract suite in
packages/solid/src/testing/contract/that Inrupt's module's test runs:rdf.test.ts,resources.test.ts,session.test.tsandaccess.test.ts. Beside it they test what is this client's alone: the sign-in flow against a fake provider, the session's store onfake-indexeddb's IndexedDB, the grant's policies and inherited rules, andturtleDocument.ts's reads. Its tests run inhappy-domand hold the package to 100% coverage, as every package's do, innpm run check(testing.md). -
The other packages' tests and the end-to-end tests, run with the switch on, test the app on this client.
-
CI runs the package's own tests in
check, which blocks, as every package's. Every run that tests the tree also runs, withSOLID_CLIENT=solid-memo-client, two advisory jobs (testing.md):solid-memo-client (unit):solid's unit tests without those of the four modules this client replaces,solid's round trip, and the unit tests ofshacl,compositionandweb,web's withDOCS_DIAGRAMS=skip. npm runs them, not Turbo, so no result comes from a cache, and without coverage.solid-memo-client (<server>): the end-to-end tests, one job per blocking server, on the images inpod's cache.
A failure there is reported, and fails neither the run nor
ci-ok, so it holds back no merge, release or deploy.