A Manifest V3 browser extension that brings Solid sign-in to the browser itself. It
injects a window.solid API into every page (a DPoP-authenticated fetch, the user's
webId, login/logout/setClientId) and pins a top-right account menu whose icon is the
signed-in WebID's avatar. The UI is built with @jeswr/solid-elements
so it is visually consistent with Pod Manager
and the rest of the @jeswr Solid app suite.
Scope: this repo is the extension CORE — auth,
window.solid, and the account popup. Access management (an access-request JS API, consent/data-type UI, queued requests) is a separate, design-first track and is intentionally not implemented here; only a clearly-marked, feature-detectable seam is left (see below).
Four contexts, with the access token held in exactly one of them (the service worker):
Page (MAIN world) Content script (ISOLATED) Service worker (background)
window.solid ⇄ content-script.ts ⇄ service-worker.ts
inject.ts postMessage chrome.runtime ├─ auth-flow.ts (auth-code + PKCE + DPoP)
(no credential) (trust boundary: (sole token ├─ core/authenticated-fetch.ts (the boundary)
stamps real origin) holder) ├─ core/dpop.ts (RFC 9449 proofs, Web Crypto)
├─ core/origin-policy.ts (fail-closed gate)
Popup = the account UI (@jeswr/solid-elements) ⇄ chrome.runtime ├─ session-store.ts (chrome.storage)
└─ action-icon.ts (avatar toolbar icon)
- The page never sees a credential.
window.solid(MAIN world) can onlypostMessageto the content script, which relays to the worker. The page gets back the WebID and proxiedResponses — never the access or refresh token. - The service worker is the sole token holder. Tokens + the DPoP keypair live in
chrome.storage.local, which is unreachable from any web page — satisfying the suite's "DPoP refresh token in non-page-reachable secure storage" invariant.
Because solid.fetch is callable from any page, the worker must never hand a foreign
origin the user's token. core/origin-policy.ts + core/authenticated-fetch.ts enforce,
fail-closed:
- The token is attached only to an origin in the allowed set (the WebID's origin ∪ the
issuer's origin ∪ user-configured pod origins). A request to any other origin
(
solid.fetch("https://evil.example/")) is sent as a plain, credential-free fetch — the token-leak attack is impossible. - Cleartext guard: the token never rides over
http:(loopback excepted, for dev CSS). - The resource token is never attached to the issuer's
/tokenendpoint. - Page-supplied
Authorization/DPoPheaders are stripped (no header injection). - DPoP proofs follow RFC 9449 §4.2 (the same proof shape as
@jeswr/solid-dpop, reimplemented on Web Crypto because a service worker has nonode:crypto), with the §8use_dpop_noncesingle retry.
These invariants are pinned by an adversarial unit suite (test/), including a
WebID/origin-mismatch test that genuinely fails without the guard.
interface SolidExtension {
readonly webId: string | null;
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
setClientId(clientId: string): void; // declare this origin's Client ID Document
login(webId: string): Promise<void>;
logout(): Promise<void>;
requestAccess?(request: unknown): Promise<never>; // SEAM — not implemented (see below)
}- Login: Solid-OIDC authorization-code + PKCE + DPoP, driven by
chrome.identity.launchWebAuthFlow(a service worker has nowindow). The WebID'ssolid:oidcIssueris resolved by proper RDF parsing (N3.js, not regex). A published Solid-OIDC Client Identifier Document is used as theclient_id(stable consent-screen name), with dynamic registration as the dev fallback. A page may declare its own Client ID Document viasolid.setClientId(...). - Session: the access + refresh token + the DPoP keypair (as JWK — an MV3 worker is
killed aggressively and a non-extractable key would be lost on suspension, breaking the
jkt-bound refresh) are persisted inchrome.storage.local. The worker proactively refreshes ~30 s before expiry via the DPoP refresh grant, reusing the same keypair. - Silent restore: reopening the browser re-hydrates the session from the persisted
refresh token (no popup). The restore decision logic is the suite's audited
@jeswr/solid-session-restore(fail-closed, WebID-scoped).
The chrome.action icon is rendered (off-DOM, OffscreenCanvas) to the signed-in WebID's
avatar — the profile photo (circular crop) or coloured initials — with a green status
badge. The popup is the account UI: @jeswr/solid-elements' jeswr-account-menu +
jeswr-theme-toggle, a recent-accounts affordance, a pod shortcut, a "restoring" state, and
a first-run pin nudge (extensions can't self-pin). Light/dark themes the popup chrome and
the web components in lockstep via the app-shell OKLCH tokens.
Offline of arbitrary third-party pods is out of scope (it is a fork-only concern for
this extension — a generic offline layer for any pod is the @jeswr/solid-offline
SW track, not this one). The extension does no forced caching of third-party pod data; the
worker's in-memory session/nonce caches are best-effort accelerators only.
The access-request JS API, the consent / data-type UI, and queued-request handling are a
separate, design-first track and are deliberately excluded from this core. The only thing
left here is a non-breaking seam: window.solid.requestAccess? is declared (so it is
feature-detectable) but throws "not implemented". Do not wire access management onto
this stub without the access-management design — adding the real method later is non-breaking.
npm install # @jeswr deps are pinned git+https (keyless npm ci); ignore-scripts=true
npm run build # webpack -> dist/Then in Chrome:
- Open
chrome://extensions, toggle Developer mode (top-right). - Load unpacked → select this repo's
dist/folder. - Pin it (puzzle-piece icon → pin) — the popup is the account menu.
- Click the icon, enter your WebID / Pod URL, sign in.
npm run gate # lint (biome) + typecheck (tsc) + test (vitest) + build (webpack)
npm run lint # biome over src test e2e scripts
npm run typecheck # tsc --noEmit
npm test # vitest — the security-critical core (51 cases), no server needed
npm run build # webpack bundle to dist/
npm run test:e2e # build + Playwright against a LOCAL Community Solid ServerThe unit suite stubs fetch / chrome.* and needs no server. The Playwright e2e suite
boots a local Community Solid Server (e2e/setup) and a local test site — never the
live deploy — and drives the real extension in headed Chromium.
MIT © Jesse Wright