Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions auth.js
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;
}
}
Comment on lines +43 to +50

Copy link
Copy Markdown
Contributor Author

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.

11 changes: 8 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,9 +374,14 @@ longer than `/`; anything else is ignored.

Because the WAC hook is also what populates `request.webId`, requests under
an app path never carry it — don't rely on `request.webId` in app handlers.
Resolve identity yourself, e.g. with `getWebIdFromRequestAsync(request)`
from `src/auth/token.js` (a stable public accessor is tracked in
[#584](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/584)).
Resolve identity with the public accessor
([#584](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/584)):

```js
import { getAgent } from 'javascript-solid-server/auth.js';

const agent = await getAgent(request); // WebID or did:nostr DID, null if anonymous
```

See [#582](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/582)
for the design discussion and
Expand Down
100 changes: 100 additions & 0 deletions test/auth-agent.test.js
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');
Comment thread
Copilot marked this conversation as resolved.

const webId = await getAgent({ headers: { authorization: `Bearer ${cred.access_token}` } });
assert.strictEqual(webId, cred.webid);
});
});