feat(auth): public getAgent accessor — request → WebID (#584)#586
Conversation
auth.js at the package root is the stable import path for apps that authenticate their own traffic (appPaths mounts from #582, external compositions): import { getAgent } from 'javascript-solid-server/auth.js'; const webId = await getAgent(request); // string | null Thin wrapper over getWebIdFromRequestAsync, so it covers every token scheme the server accepts (IdP Bearer, Solid-OIDC DPoP, Nostr NIP-98, LWS10-CID) and never throws on bad credentials. Authentication only, deliberately not authorization — apps under an app path own their own permissioning. Everything in src/ stays internal and free to move; this file is the contract, pinned by test/auth-agent.test.js. Pre-loader shape of the seam: when #206's loader lands, api.auth.getAgent is this function. Docs: the appPaths section now shows the public import instead of the internal path. Full suite 1006 green.
There was a problem hiding this comment.
Pull request overview
This PR introduces a stable, root-level public authentication seam (javascript-solid-server/auth.js) so external apps/plugins can map an incoming request to an authenticated WebID without importing internal src/auth/* modules.
Changes:
- Adds
auth.jsat the package root exportinggetAgent(request) -> Promise<string|null>as a thin wrapper overgetWebIdFromRequestAsync. - Adds a contract test (
test/auth-agent.test.js) to pin behavior (anonymous/malformed →null, valid IdP Bearer → WebID). - Updates
docs/configuration.mdto recommendgetAgentfor identity resolution underappPathsmounts.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| auth.js | New public export surface providing getAgent(request) wrapper around internal auth resolution. |
| test/auth-agent.test.js | New contract/integration test for the public getAgent behavior. |
| docs/configuration.md | Docs updated to point app-path handlers at the new public accessor instead of internal imports. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| it('resolves a real IdP Bearer token to the account WebID', async () => { | ||
| await fs.emptyDir(TEST_DATA_DIR); | ||
| server = createServer({ | ||
| logger: false, | ||
| forceCloseConnections: true, | ||
| root: TEST_DATA_DIR, | ||
| idp: true, | ||
| idpIssuer: 'http://127.0.0.1:0', // patched after listen below | ||
| }); | ||
| await server.listen({ port: 0, host: '127.0.0.1' }); | ||
| baseUrl = `http://127.0.0.1:${server.server.address().port}`; | ||
|
|
There was a problem hiding this comment.
Fixed — port reserved up front and passed as the real idpIssuer (adopted the well-known-did-nostr pattern you pointed at). The token/WebID pair the test asserts is now issued against the actual listening origin.
| * Resolve the authenticated agent of a request. | ||
| * @param {object} request - Fastify request (or any object with `headers`) | ||
| * @returns {Promise<string|null>} verified WebID, or null when anonymous / |
There was a problem hiding this comment.
Agreed, the doc oversold it. JSDoc now states the split explicitly: Bearer reads only headers; DPoP/NIP-98/LWS-CID verification also reads method/url/protocol/hostname — so pass the real Fastify request.
The idpIssuer was a bogus http://127.0.0.1:0 with a stale 'patched later' comment — now a port is reserved up front and the IdP gets its real issuer (the well-known-did-nostr test's pattern). getAgent's JSDoc no longer claims 'any object with headers': Bearer only reads headers, but DPoP/NIP-98/LWS-CID verification also reads method, url, protocol and hostname — pass the real request.
| export async function getAgent(request) { | ||
| try { | ||
| const { webId } = await getWebIdFromRequestAsync(request); | ||
| return webId || null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } |
There was a problem hiding this comment.
Right diagnosis, deliberate behavior: did:nostr agents are first-class in JSS, so filtering them to null would silently lock Nostr-authenticated users out of every app built on this seam — the plugin-zero consumer (Tideholm) explicitly wants them as players. What was wrong was the doc claiming 'WebID'. The contract now states it plainly: getAgent returns the verified agent identifier — an HTTP(S) WebID, or a did:nostr DID for NIP-98 agents without a WebID mapping — which is why it's named getAgent rather than getWebId. Callers who need strictly-HTTP WebIDs can filter on the prefix; the seam won't decide that for them.
Copilot correctly spotted that NIP-98 can yield did:nostr identifiers where no WebID mapping exists. That's intended, not filtered: DID agents are first-class in JSS, and locking them out would silently exclude Nostr-authenticated users from every app. The contract docs now say what the function actually promises — an HTTP(S) WebID or a did:nostr DID — which is why the seam is named getAgent, not getWebId. Apps key users on the identifier string either way.
| * Covers every token scheme the server itself accepts, uniformly: | ||
| * IdP-issued Bearer tokens, Solid-OIDC DPoP, Nostr NIP-98 signatures, | ||
| * and LWS10-CID. The returned identifier is usually an HTTP(S) WebID; | ||
| * for NIP-98 it can be a `did:nostr:...` DID when no WebID mapping |
There was a problem hiding this comment.
Added — the list now includes WebID-TLS client certificates (and says 'credential scheme' rather than 'token scheme', since TLS isn't a token). Verified against token.js: the async path does try webIdTlsAuth when a client certificate is present.
| @@ -0,0 +1,97 @@ | |||
| /** | |||
| * getAgent — public request → WebID accessor (#584). | |||
There was a problem hiding this comment.
Updated — the test header now says agent-identifier and spells out the WebID-or-did:nostr contract, matching auth.js and the PR description.
| const port = await new Promise((resolve) => { | ||
| const probe = net.createServer(); | ||
| probe.listen(0, '127.0.0.1', () => { | ||
| const p = probe.address().port; | ||
| probe.close(() => resolve(p)); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Fixed — the probe now rejects on 'error' so a failed listen fails the test with the actual error instead of hanging. Also ported the same fix to the Tideholm demo where this pattern originated.
Public auth seam (#584, #586): import { getAgent } from 'javascript-solid-server/auth.js' resolves a request to its verified agent identifier — an HTTP(S) WebID or a did:nostr DID — across every credential scheme (IdP Bearer, Solid-OIDC DPoP, Nostr NIP-98, LWS10-CID, WebID-TLS). The stable contract for apps that authenticate their own traffic under appPaths mounts (#582, shipped in 0.0.213); everything in src/ remains internal. Second plugin-system seam from #206; remaining: #583 raw-body mode, the #206/#564 loader.
Closes #584. Second seam from the plugin-zero work (#206), following the pattern #582/#585 set: smallest change, contract test, real consumer waiting.
What
auth.jsat the package root — the stable public import for applications that authenticate their own traffic:Thin wrapper over
getWebIdFromRequestAsync, so it uniformly covers every token scheme the server itself accepts (IdP Bearer, Solid-OIDC DPoP, Nostr NIP-98, LWS10-CID) and never throws on bad credentials. Authentication only, deliberately not authorization — apps under anappPathsprefix (#582) own their own permissioning.Why a root file
main(src/index.js) starts a server on import — unusable as an export surfaceexportsmap would lock down every existing deep import — too invasive for thisfilesis unset, so the root file ships in the npm tarball, andjavascript-solid-server/auth.jsresolves everywhereEverything under
src/stays internal and free to refactor;auth.jsis the contract. When the #206 loader lands,api.auth.getAgenthands plugins this same function.Tests
test/auth-agent.test.jspins the contract: anonymous →null, malformed credentials →null(never a throw), real IdP Bearer token → the account's WebID. Scheme-specific coverage stays with the existing auth suites. Full suite: 1006 pass.Consumer
The Tideholm plugin-zero adapter migrates onto this import the moment it's published (per #584's acceptance list) — removing the last internal-path import in the reference plugin.