Skip to content

Latest commit

 

History

History
398 lines (352 loc) · 22.4 KB

solid-memo-client.md

File metadata and controls

398 lines (352 loc) · 22.4 KB

The solid-memo-client

@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.

What it replaces

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.

Switching clients

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 tests

A 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).

Sign-in

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 neither https: nor http: 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 webid in scopes_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_method none).
  • The request asks for openid offline_access webid, with PKCE (S256), a state and a nonce. A login asks with prompt=consent, which the Community Solid Server needs before it grants offline_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 webid claim is the WebID, or else its sub when that's an http: or https: URL.

What a session keeps

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.

Restore, refresh and logout

The session's requests are in oidcSession.ts:

  • restore() completes the redirect back from the provider when the page is one: a state on 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 restoredFrom is null and the page keeps its hash. Without a refresh token, or when the provider refuses it, restore() asks the provider again with prompt=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 when state is its state, and none otherwise.
  • A request carries Authorization: DPoP with 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's redirect: "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 its end_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.

Linked Web Storage

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:

  1. The challenge. A request goes with the access token, as to any Solid server. A 401 is an LWS challenge when its WWW-Authenticate has a Bearer challenge with resource_metadata (RFC 9728). Its Link header names the storage, with relation https://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.
  2. The checks, LWS's "Discovery Validation by a Client", before the ID token goes anywhere. The resource_metadata URL has the requested URL's scheme and authority, and its document names the requested URL as its resource. Its first authorization_servers entry is the authorization server, and must be the session's issuer (the trust rule). The storage's description, asked for as application/lws+cid, is a Storage with a StorageRoot service on the requested URL's scheme and authority.
  3. 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. Its issuer is the one asked for, and its token endpoint is https:, or http: 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.
  4. The exchange posts the token exchange grant with the storage as resource and the ID token as subject_token, of type urn: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 a Bearer token.
  5. The request again, once, with Authorization: Bearer and no DPoP header, keeping the caller's method, body, headers, redirect mode and signal. A body the first try used up, a Request'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.

The storage's 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

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 trust rule

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.

The public-read grant

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's rel="type" says acp:AccessControlResource, or its Turtle names an ACP term, and a WAC ACL otherwise.
  • On WAC, the grant adds an acl:Authorization of acl:Read for acl:agentClass foaf:Agent, with acl:accessTo the document alone, unless the ACL has one. An ACL not written yet first gets the acl:default rules 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:Read to a matcher of acp:PublicAgent, as Inrupt's library writes one. A policy is everyone's when its matchers name acp:PublicAgent and 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.

Differences from Inrupt's client

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 over http: to this machine (localhost, 127.0.0.1, [::1] or a .localhost name), and sends a request elsewhere without it. oauth4webapi refuses 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-browser 5 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 with SOLID_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.

Tests

  • 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.ts and access.test.ts. Beside it they test what is this client's alone: the sign-in flow against a fake provider, the session's store on fake-indexeddb's IndexedDB, the grant's policies and inherited rules, and turtleDocument.ts's reads. Its tests run in happy-dom and hold the package to 100% coverage, as every package's do, in npm 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, with SOLID_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 of shacl, composition and web, web's with DOCS_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 in pod's cache.

    A failure there is reported, and fails neither the run nor ci-ok, so it holds back no merge, release or deploy.