-
Notifications
You must be signed in to change notification settings - Fork 9
feat(auth): public getAgent accessor — request → WebID (#584) #586
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+158
−3
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
033926f
feat(auth): public getAgent accessor — request → WebID (#584)
melvincarvalho 096288b
review: real issuer in the IdP test; JSDoc states full request shape
melvincarvalho 6be1ffa
review: getAgent contract is agent identifier, not strictly WebID
melvincarvalho aea29b5
review: doc lists WebID-TLS; credentials test asserts status before p…
melvincarvalho cee5b5f
review: test header matches the agent-identifier contract
melvincarvalho a0f212e
review: port probe rejects on listen error instead of hanging
melvincarvalho File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| /** | ||
| * Public authentication api (#584). | ||
| * | ||
| * The stable import path for applications that authenticate their own | ||
| * traffic — app plugins mounted via `appPaths` (#582), external Fastify | ||
| * compositions, MCP-adjacent tooling. Everything in src/ is internal and | ||
| * may move; this file is the contract. | ||
| * | ||
| * import { getAgent } from 'javascript-solid-server/auth.js'; | ||
| * | ||
| * const agent = await getAgent(request); // string | null | ||
| * | ||
| * Covers every credential scheme the server itself accepts, uniformly: | ||
| * IdP-issued Bearer tokens, Solid-OIDC DPoP, Nostr NIP-98 signatures, | ||
| * LWS10-CID, and WebID-TLS client certificates. | ||
| * The returned identifier is usually an HTTP(S) WebID; | ||
| * for NIP-98 it can be a `did:nostr:...` DID when no WebID mapping | ||
| * exists — DID agents are first-class here, which is why this is | ||
| * getAgent and not getWebId. Key your app's users on the string. | ||
| * | ||
| * Authentication only — deliberately not authorization: | ||
| * apps under an appPaths prefix own their own permissioning (WAC stays | ||
| * out of their jurisdiction, and `request.webId` is never set there). | ||
| * | ||
| * This is the pre-loader shape of the seam; when the plugin loader (#206) | ||
| * lands, `api.auth.getAgent` will be this same function handed to | ||
| * `activate(api)`. | ||
| */ | ||
|
|
||
| import { getWebIdFromRequestAsync } from './src/auth/token.js'; | ||
|
|
||
| /** | ||
| * Resolve the authenticated agent of a request. | ||
| * @param {object} request - a Fastify request. Pass the real request object: | ||
| * Bearer verification only reads `headers`, but DPoP (Solid-OIDC), | ||
| * NIP-98 and LWS-CID verification also read `method`, `url`, `protocol` | ||
| * and `hostname` to check what the credential was signed over. | ||
| * @returns {Promise<string|null>} the verified agent identifier — an | ||
| * HTTP(S) WebID, or a `did:nostr:` DID for NIP-98 agents without a | ||
| * WebID mapping — or null when anonymous / invalid. Never throws on | ||
| * bad credentials. | ||
| */ | ||
| export async function getAgent(request) { | ||
| try { | ||
| const { webId } = await getWebIdFromRequestAsync(request); | ||
| return webId || null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| /** | ||
| * getAgent — public request → agent-identifier accessor (#584). | ||
| * | ||
| * Apps that own their own auth (appPaths mounts, #582) need to ask "who is | ||
| * this?" without reaching into src/auth internals. auth.js at the package | ||
| * root is the stable contract; these tests pin its shape so auth refactors | ||
| * keep the seam: | ||
| * | ||
| * - anonymous / malformed credentials -> null, never a throw | ||
| * - a real IdP-issued Bearer token -> the account's WebID | ||
| * | ||
| * The identifier is an HTTP(S) WebID or, for NIP-98 agents without a WebID | ||
| * mapping, a did:nostr DID. The individual credential schemes (Bearer, | ||
| * DPoP, NIP-98, LWS10-CID, WebID-TLS) are exercised by their own suites; | ||
| * this one pins the public wrapper. | ||
| */ | ||
|
|
||
| import { describe, it, before, after, afterEach } from 'node:test'; | ||
| import assert from 'node:assert'; | ||
| import { createServer } from '../src/server.js'; | ||
| import { getAgent } from '../auth.js'; | ||
| import fs from 'fs-extra'; | ||
|
|
||
| const TEST_DATA_DIR = './test-data-auth-agent'; | ||
|
|
||
| let server; | ||
| let baseUrl; | ||
| let originalDataRoot; | ||
|
|
||
| describe('public getAgent accessor (#584)', () => { | ||
| before(() => { | ||
| originalDataRoot = process.env.DATA_ROOT; | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| if (server) { | ||
| await server.close(); | ||
| server = null; | ||
| } | ||
| await fs.remove(TEST_DATA_DIR); | ||
| }); | ||
|
|
||
| after(() => { | ||
| if (originalDataRoot === undefined) delete process.env.DATA_ROOT; | ||
| else process.env.DATA_ROOT = originalDataRoot; | ||
| }); | ||
|
|
||
| it('returns null for anonymous requests', async () => { | ||
| assert.strictEqual(await getAgent({ headers: {} }), null); | ||
| }); | ||
|
|
||
| it('returns null (not a throw) for malformed credentials', async () => { | ||
| assert.strictEqual(await getAgent({ headers: { authorization: 'Bearer garbage' } }), null); | ||
| assert.strictEqual(await getAgent({ headers: { authorization: 'Nonsense scheme' } }), null); | ||
| assert.strictEqual(await getAgent({ headers: { authorization: '' } }), null); | ||
| }); | ||
|
|
||
| it('resolves a real IdP Bearer token to the account WebID', async () => { | ||
| await fs.emptyDir(TEST_DATA_DIR); | ||
| // The IdP needs its real issuer up front — reserve a port first (same | ||
| // pattern as test/well-known-did-nostr.test.js). | ||
| const net = await import('node:net'); | ||
| const port = await new Promise((resolve, reject) => { | ||
| const probe = net.createServer(); | ||
| probe.once('error', reject); // fail loudly, don't hang, if listen errors | ||
| probe.listen(0, '127.0.0.1', () => { | ||
| const p = probe.address().port; | ||
| probe.close(() => resolve(p)); | ||
| }); | ||
| }); | ||
| baseUrl = `http://127.0.0.1:${port}`; | ||
| server = createServer({ | ||
| logger: false, | ||
| forceCloseConnections: true, | ||
| root: TEST_DATA_DIR, | ||
| idp: true, | ||
| idpIssuer: baseUrl, | ||
| }); | ||
| await server.listen({ port, host: '127.0.0.1' }); | ||
|
|
||
| let res = await fetch(`${baseUrl}/idp/register`, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ username: 'agent', password: 'secret-word', confirmPassword: 'secret-word' }), | ||
| }); | ||
| assert.ok(res.status < 400, `register failed: ${res.status}`); | ||
|
|
||
| res = await fetch(`${baseUrl}/idp/credentials`, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ username: 'agent', password: 'secret-word' }), | ||
| }); | ||
| assert.strictEqual(res.status, 200, `credentials failed: ${res.status}`); | ||
| const cred = await res.json(); | ||
| assert.ok(cred.access_token, 'no token issued'); | ||
|
Copilot marked this conversation as resolved.
|
||
|
|
||
| const webId = await getAgent({ headers: { authorization: `Bearer ${cred.access_token}` } }); | ||
| assert.strictEqual(webId, cred.webid); | ||
| }); | ||
| }); | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.