javascript-solid-server 0.0.21 → 0.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -67,7 +67,12 @@
67
67
  "Bash(pm2 save:*)",
68
68
  "Bash(gh issue create:*)",
69
69
  "Bash(gh issue view:*)",
70
- "Bash(gh issue edit:*)"
70
+ "Bash(gh issue edit:*)",
71
+ "WebFetch(domain:nostrcg.github.io)",
72
+ "WebFetch(domain:melvincarvalho.github.io)",
73
+ "WebFetch(domain:dev.to)",
74
+ "WebFetch(domain:solidproject.org)",
75
+ "WebFetch(domain:www.w3.org)"
71
76
  ]
72
77
  }
73
78
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.21",
3
+ "version": "0.0.22",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -31,6 +31,7 @@
31
31
  "fs-extra": "^11.2.0",
32
32
  "jose": "^6.1.3",
33
33
  "n3": "^1.26.0",
34
+ "nostr-tools": "^2.19.4",
34
35
  "oidc-provider": "^9.6.0"
35
36
  },
36
37
  "engines": {
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Nostr NIP-98 Authentication
3
+ *
4
+ * Implements HTTP authentication using Schnorr signatures as defined in:
5
+ * - NIP-98: https://nips.nostr.com/98
6
+ * - JIP-0001: https://github.com/JavaScriptSolidServer/jips/blob/main/jip-0001.md
7
+ *
8
+ * Authorization header format: "Nostr <base64-encoded-event>"
9
+ *
10
+ * The authenticated identity is returned as a did:nostr URI:
11
+ * did:nostr:<64-char-hex-pubkey>
12
+ */
13
+
14
+ import { verifyEvent } from 'nostr-tools';
15
+ import crypto from 'crypto';
16
+
17
+ // NIP-98 event kind (references RFC 7235)
18
+ const HTTP_AUTH_KIND = 27235;
19
+
20
+ // Timestamp tolerance in seconds
21
+ const TIMESTAMP_TOLERANCE = 60;
22
+
23
+ /**
24
+ * Check if request has Nostr authentication
25
+ * @param {object} request - Fastify request object
26
+ * @returns {boolean}
27
+ */
28
+ export function hasNostrAuth(request) {
29
+ const authHeader = request.headers.authorization;
30
+ return authHeader && authHeader.startsWith('Nostr ');
31
+ }
32
+
33
+ /**
34
+ * Extract token from Nostr authorization header
35
+ * @param {string} authHeader - Authorization header value
36
+ * @returns {string|null}
37
+ */
38
+ export function extractNostrToken(authHeader) {
39
+ if (!authHeader || !authHeader.startsWith('Nostr ')) {
40
+ return null;
41
+ }
42
+ return authHeader.slice(6).trim();
43
+ }
44
+
45
+ /**
46
+ * Decode NIP-98 event from base64 token
47
+ * @param {string} token - Base64 encoded event
48
+ * @returns {object|null} Decoded event or null
49
+ */
50
+ function decodeEvent(token) {
51
+ try {
52
+ const decoded = Buffer.from(token, 'base64').toString('utf8');
53
+ return JSON.parse(decoded);
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Get tag value from event
61
+ * @param {object} event - Nostr event
62
+ * @param {string} tagName - Tag name (e.g., 'u', 'method')
63
+ * @returns {string|null} Tag value or null
64
+ */
65
+ function getTagValue(event, tagName) {
66
+ if (!event.tags || !Array.isArray(event.tags)) {
67
+ return null;
68
+ }
69
+ const tag = event.tags.find(t => Array.isArray(t) && t[0] === tagName);
70
+ return tag ? tag[1] : null;
71
+ }
72
+
73
+ /**
74
+ * Convert Nostr pubkey to did:nostr URI
75
+ * @param {string} pubkey - 64-char hex public key
76
+ * @returns {string} did:nostr URI
77
+ */
78
+ export function pubkeyToDidNostr(pubkey) {
79
+ return `did:nostr:${pubkey.toLowerCase()}`;
80
+ }
81
+
82
+ /**
83
+ * Verify NIP-98 authentication and return agent identity
84
+ * @param {object} request - Fastify request object
85
+ * @returns {Promise<{webId: string|null, error: string|null}>}
86
+ */
87
+ export async function verifyNostrAuth(request) {
88
+ const token = extractNostrToken(request.headers.authorization);
89
+
90
+ if (!token) {
91
+ return { webId: null, error: 'Missing Nostr token' };
92
+ }
93
+
94
+ // Decode the event
95
+ const event = decodeEvent(token);
96
+ if (!event) {
97
+ return { webId: null, error: 'Invalid token format: could not decode base64 JSON' };
98
+ }
99
+
100
+ // Validate event kind (must be 27235)
101
+ if (event.kind !== HTTP_AUTH_KIND) {
102
+ return { webId: null, error: `Invalid event kind: expected ${HTTP_AUTH_KIND}, got ${event.kind}` };
103
+ }
104
+
105
+ // Validate timestamp (within ±60 seconds)
106
+ const now = Math.floor(Date.now() / 1000);
107
+ const eventTime = event.created_at;
108
+ if (!eventTime || Math.abs(now - eventTime) > TIMESTAMP_TOLERANCE) {
109
+ return { webId: null, error: 'Event timestamp outside acceptable window (±60s)' };
110
+ }
111
+
112
+ // Build full URL for validation
113
+ const protocol = request.protocol || 'http';
114
+ const host = request.headers.host || request.hostname;
115
+ const fullUrl = `${protocol}://${host}${request.url}`;
116
+
117
+ // Validate URL tag matches request URL
118
+ const eventUrl = getTagValue(event, 'u');
119
+ if (!eventUrl) {
120
+ return { webId: null, error: 'Missing URL tag in event' };
121
+ }
122
+
123
+ // Compare URLs (normalize by removing trailing slashes)
124
+ const normalizedEventUrl = eventUrl.replace(/\/$/, '');
125
+ const normalizedRequestUrl = fullUrl.replace(/\/$/, '');
126
+ const normalizedRequestUrlNoQuery = fullUrl.split('?')[0].replace(/\/$/, '');
127
+
128
+ if (normalizedEventUrl !== normalizedRequestUrl && normalizedEventUrl !== normalizedRequestUrlNoQuery) {
129
+ return { webId: null, error: `URL mismatch: event URL "${eventUrl}" does not match request URL "${fullUrl}"` };
130
+ }
131
+
132
+ // Validate method tag matches request method
133
+ const eventMethod = getTagValue(event, 'method');
134
+ if (!eventMethod) {
135
+ return { webId: null, error: 'Missing method tag in event' };
136
+ }
137
+ if (eventMethod.toUpperCase() !== request.method.toUpperCase()) {
138
+ return { webId: null, error: `Method mismatch: expected ${request.method}, got ${eventMethod}` };
139
+ }
140
+
141
+ // Validate payload hash if present and request has body
142
+ const payloadTag = getTagValue(event, 'payload');
143
+ if (payloadTag && request.body) {
144
+ let bodyString;
145
+ if (typeof request.body === 'string') {
146
+ bodyString = request.body;
147
+ } else if (Buffer.isBuffer(request.body)) {
148
+ bodyString = request.body.toString();
149
+ } else {
150
+ bodyString = JSON.stringify(request.body);
151
+ }
152
+
153
+ const expectedHash = crypto.createHash('sha256').update(bodyString).digest('hex');
154
+ if (payloadTag.toLowerCase() !== expectedHash.toLowerCase()) {
155
+ return { webId: null, error: 'Payload hash mismatch' };
156
+ }
157
+ }
158
+
159
+ // Validate pubkey exists
160
+ if (!event.pubkey || typeof event.pubkey !== 'string' || event.pubkey.length !== 64) {
161
+ return { webId: null, error: 'Invalid or missing pubkey' };
162
+ }
163
+
164
+ // Verify Schnorr signature
165
+ const isValid = verifyEvent(event);
166
+ if (!isValid) {
167
+ return { webId: null, error: 'Invalid Schnorr signature' };
168
+ }
169
+
170
+ // Return did:nostr as the agent identifier
171
+ const didNostr = pubkeyToDidNostr(event.pubkey);
172
+
173
+ return { webId: didNostr, error: null };
174
+ }
175
+
176
+ /**
177
+ * Get Nostr pubkey from request if authenticated via NIP-98
178
+ * @param {object} request - Fastify request object
179
+ * @returns {Promise<string|null>} Hex pubkey or null
180
+ */
181
+ export async function getNostrPubkey(request) {
182
+ if (!hasNostrAuth(request)) {
183
+ return null;
184
+ }
185
+
186
+ const token = extractNostrToken(request.headers.authorization);
187
+ if (!token) {
188
+ return null;
189
+ }
190
+
191
+ try {
192
+ const event = decodeEvent(token);
193
+ return event?.pubkey || null;
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
package/src/auth/token.js CHANGED
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Token-based authentication
3
3
  *
4
- * Supports two modes:
4
+ * Supports multiple modes:
5
5
  * 1. Simple tokens (for local/dev use): base64(JSON({webId, iat, exp})) + HMAC signature
6
6
  * 2. Solid-OIDC DPoP tokens (for federation): verified via external IdP JWKS
7
+ * 3. Nostr NIP-98 tokens: Schnorr signatures, returns did:nostr identity
7
8
  */
8
9
 
9
10
  import crypto from 'crypto';
10
11
  import { verifySolidOidc, hasSolidOidcAuth } from './solid-oidc.js';
12
+ import { verifyNostrAuth, hasNostrAuth } from './nostr.js';
11
13
 
12
14
  // Secret for signing tokens (in production, use env var)
13
15
  const SECRET = process.env.TOKEN_SECRET || 'dev-secret-change-in-production';
@@ -151,6 +153,11 @@ export function getWebIdFromRequest(request) {
151
153
  return null;
152
154
  }
153
155
 
156
+ // Skip Nostr tokens - use async version for those
157
+ if (authHeader && authHeader.startsWith('Nostr ')) {
158
+ return null;
159
+ }
160
+
154
161
  const token = extractToken(authHeader);
155
162
 
156
163
  if (!token) {
@@ -178,6 +185,11 @@ export async function getWebIdFromRequestAsync(request) {
178
185
  return verifySolidOidc(request);
179
186
  }
180
187
 
188
+ // Try Nostr NIP-98 (Schnorr signatures)
189
+ if (hasNostrAuth(request)) {
190
+ return verifyNostrAuth(request);
191
+ }
192
+
181
193
  // Fall back to simple Bearer tokens
182
194
  const token = extractToken(authHeader);
183
195
  if (!token) {
@@ -64,7 +64,7 @@ async function findApplicableAcl(resourceUrl, resourcePath, isContainer) {
64
64
  const content = await storage.read(resourceAclPath);
65
65
  if (content) {
66
66
  const aclUrl = getAclUrl(resourceUrl, isContainer);
67
- const authorizations = parseAcl(content.toString(), aclUrl);
67
+ const authorizations = await parseAcl(content.toString(), aclUrl);
68
68
  return { authorizations, isDefault: false, targetUrl: resourceUrl };
69
69
  }
70
70
  }
@@ -80,7 +80,7 @@ async function findApplicableAcl(resourceUrl, resourcePath, isContainer) {
80
80
  const content = await storage.read(parentAclPath);
81
81
  if (content) {
82
82
  const parentUrl = resourceUrl.substring(0, resourceUrl.lastIndexOf(currentPath)) + parentPath;
83
- const authorizations = parseAcl(content.toString(), parentAclPath);
83
+ const authorizations = await parseAcl(content.toString(), parentAclPath);
84
84
  return { authorizations, isDefault: true, targetUrl: parentUrl };
85
85
  }
86
86
  }
@@ -93,7 +93,7 @@ async function findApplicableAcl(resourceUrl, resourcePath, isContainer) {
93
93
  const content = await storage.read('/.acl');
94
94
  if (content) {
95
95
  const rootUrl = resourceUrl.substring(0, resourceUrl.indexOf('/', 8) + 1);
96
- const authorizations = parseAcl(content.toString(), '/.acl');
96
+ const authorizations = await parseAcl(content.toString(), '/.acl');
97
97
  return { authorizations, isDefault: true, targetUrl: rootUrl };
98
98
  }
99
99
  }
package/src/wac/parser.js CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * WAC (Web Access Control) Parser
3
- * Parses JSON-LD .acl files into authorization rules
3
+ * Parses ACL files (JSON-LD or Turtle) into authorization rules
4
4
  */
5
5
 
6
+ import { turtleToJsonLd } from '../rdf/turtle.js';
7
+
6
8
  const ACL = 'http://www.w3.org/ns/auth/acl#';
7
9
  const FOAF = 'http://xmlns.com/foaf/0.1/';
8
10
 
@@ -21,16 +23,31 @@ export const AgentClass = {
21
23
  };
22
24
 
23
25
  /**
24
- * Parse a JSON-LD ACL document
25
- * @param {string|object} content - JSON-LD content (string or parsed object)
26
+ * Parse an ACL document (JSON-LD or Turtle)
27
+ * @param {string|object} content - ACL content (JSON-LD string/object or Turtle string)
26
28
  * @param {string} aclUrl - URL of the ACL document
27
- * @returns {Array<Authorization>} List of authorization rules
29
+ * @returns {Promise<Array<Authorization>>} List of authorization rules
28
30
  */
29
- export function parseAcl(content, aclUrl) {
31
+ export async function parseAcl(content, aclUrl) {
30
32
  let doc;
31
- try {
32
- doc = typeof content === 'string' ? JSON.parse(content) : content;
33
- } catch {
33
+
34
+ // If already an object, use it directly
35
+ if (typeof content === 'object' && content !== null) {
36
+ doc = content;
37
+ } else if (typeof content === 'string') {
38
+ // Try JSON-LD first
39
+ try {
40
+ doc = JSON.parse(content);
41
+ } catch {
42
+ // Not JSON, try Turtle
43
+ try {
44
+ doc = await turtleToJsonLd(content, aclUrl);
45
+ } catch (turtleError) {
46
+ // Neither JSON-LD nor valid Turtle
47
+ return [];
48
+ }
49
+ }
50
+ } else {
34
51
  return [];
35
52
  }
36
53
 
package/test/wac.test.js CHANGED
@@ -18,7 +18,7 @@ import { checkAccess, getRequiredMode } from '../src/wac/checker.js';
18
18
 
19
19
  describe('WAC Parser', () => {
20
20
  describe('parseAcl', () => {
21
- it('should parse a simple ACL', () => {
21
+ it('should parse a simple ACL', async () => {
22
22
  const acl = {
23
23
  '@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
24
24
  '@graph': [{
@@ -30,7 +30,7 @@ describe('WAC Parser', () => {
30
30
  }]
31
31
  };
32
32
 
33
- const auths = parseAcl(JSON.stringify(acl), 'https://alice.example/.acl');
33
+ const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/.acl');
34
34
 
35
35
  assert.strictEqual(auths.length, 1);
36
36
  assert.ok(auths[0].agents.includes('https://alice.example/#me'));
@@ -38,7 +38,7 @@ describe('WAC Parser', () => {
38
38
  assert.ok(auths[0].modes.includes(AccessMode.WRITE));
39
39
  });
40
40
 
41
- it('should parse public access', () => {
41
+ it('should parse public access', async () => {
42
42
  const acl = {
43
43
  '@context': { 'acl': 'http://www.w3.org/ns/auth/acl#', 'foaf': 'http://xmlns.com/foaf/0.1/' },
44
44
  '@graph': [{
@@ -50,14 +50,14 @@ describe('WAC Parser', () => {
50
50
  }]
51
51
  };
52
52
 
53
- const auths = parseAcl(JSON.stringify(acl), 'https://alice.example/public/.acl');
53
+ const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/public/.acl');
54
54
 
55
55
  assert.strictEqual(auths.length, 1);
56
56
  assert.ok(auths[0].agentClasses.includes('foaf:Agent'));
57
57
  assert.ok(auths[0].modes.includes(AccessMode.READ));
58
58
  });
59
59
 
60
- it('should parse default authorizations for containers', () => {
60
+ it('should parse default authorizations for containers', async () => {
61
61
  const acl = {
62
62
  '@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
63
63
  '@graph': [{
@@ -69,16 +69,35 @@ describe('WAC Parser', () => {
69
69
  }]
70
70
  };
71
71
 
72
- const auths = parseAcl(JSON.stringify(acl), 'https://alice.example/folder/.acl');
72
+ const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/folder/.acl');
73
73
 
74
74
  assert.strictEqual(auths.length, 1);
75
75
  assert.ok(auths[0].default.includes('https://alice.example/folder/'));
76
76
  });
77
77
 
78
- it('should handle invalid JSON gracefully', () => {
79
- const auths = parseAcl('not valid json', 'https://example.com/.acl');
78
+ it('should handle invalid JSON gracefully', async () => {
79
+ const auths = await parseAcl('not valid json', 'https://example.com/.acl');
80
80
  assert.strictEqual(auths.length, 0);
81
81
  });
82
+
83
+ it('should parse Turtle ACL format', async () => {
84
+ const turtleAcl = `
85
+ @prefix acl: <http://www.w3.org/ns/auth/acl#>.
86
+
87
+ <#owner>
88
+ a acl:Authorization;
89
+ acl:agent <did:nostr:abc123>;
90
+ acl:accessTo <https://example.com/resource>;
91
+ acl:mode acl:Read, acl:Write.
92
+ `;
93
+
94
+ const auths = await parseAcl(turtleAcl, 'https://example.com/.acl');
95
+
96
+ assert.strictEqual(auths.length, 1);
97
+ assert.ok(auths[0].agents.includes('did:nostr:abc123'));
98
+ assert.ok(auths[0].modes.includes(AccessMode.READ));
99
+ assert.ok(auths[0].modes.includes(AccessMode.WRITE));
100
+ });
82
101
  });
83
102
 
84
103
  describe('generateOwnerAcl', () => {
@@ -1,3 +1,3 @@
1
1
  {
2
- "credtest@example.com": "318e7a79-23d5-4e0b-8fa1-b63cfaee87e1"
2
+ "credtest@example.com": "ba3591b1-4653-4c64-9661-57dc355e5acc"
3
3
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "credtest": "318e7a79-23d5-4e0b-8fa1-b63cfaee87e1"
2
+ "credtest": "ba3591b1-4653-4c64-9661-57dc355e5acc"
3
3
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "http://localhost:3101/credtest/#me": "318e7a79-23d5-4e0b-8fa1-b63cfaee87e1"
2
+ "http://localhost:3101/credtest/#me": "ba3591b1-4653-4c64-9661-57dc355e5acc"
3
3
  }
@@ -0,0 +1,10 @@
1
+ {
2
+ "id": "ba3591b1-4653-4c64-9661-57dc355e5acc",
3
+ "username": "credtest",
4
+ "email": "credtest@example.com",
5
+ "passwordHash": "$2b$10$tFYM8KuMVTFRpVMqZOYR4OKNreNLgCBqzZVTNAhpdBFUmGH1MFNBu",
6
+ "webId": "http://localhost:3101/credtest/#me",
7
+ "podName": "credtest",
8
+ "createdAt": "2025-12-28T14:20:02.176Z",
9
+ "lastLogin": "2025-12-28T14:20:02.579Z"
10
+ }
@@ -3,20 +3,20 @@
3
3
  "keys": [
4
4
  {
5
5
  "kty": "EC",
6
- "x": "Nf8dDZkLGjtbhOI4-NdDeJpP7jFZ1yRIsLGbg4wWFIU",
7
- "y": "RlENuTLrM8M6a1UQorqtB3NIS5VXq_gI9lqJMUKDjo8",
6
+ "x": "Aa7l5-YrS54RU8xPfEphUTRwNBzSm6lxm84aqKjfrSg",
7
+ "y": "tWi_lhjqQhd43KdK5YqDg7ZzRSUZo3L0ytbiBTdPOWs",
8
8
  "crv": "P-256",
9
- "d": "WZKOZkoJBrwF7JfwLXPzpJY2XXNgab-YfqUSIT2Xpfs",
10
- "kid": "91ebc94d-1ed9-4ded-b017-70f51f2aff2b",
9
+ "d": "x6NqVSfA241O10u9Qp4m0dQZsTNYw-Hku3r0eu47VZE",
10
+ "kid": "ed46f7df-3010-43da-9032-e0acaee4d3e1",
11
11
  "use": "sig",
12
12
  "alg": "ES256",
13
- "iat": 1766846030
13
+ "iat": 1766931602
14
14
  }
15
15
  ]
16
16
  },
17
17
  "cookieKeys": [
18
- "V7_pksFGkYdBgSRG_lC9AWIki50H1qzj9-L_T-Q7OC0",
19
- "hmJQwz_B5QLiHUkncYUHZC7xOtGLrLvQVyBmJ5r-nIo"
18
+ "Vb3JNLAlJHCOu5u73eUA_rzlc9aJ0_WCQCu9RWV5WL4",
19
+ "5xCVtYihgadSlvy1QRD_DcU4_9mI_Ggn0DrngzPdiyM"
20
20
  ],
21
- "createdAt": "2025-12-27T14:33:50.653Z"
21
+ "createdAt": "2025-12-28T14:20:02.080Z"
22
22
  }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Test script for did:nostr in ACL files
3
+ *
4
+ * Tests:
5
+ * 1. Create a container with restricted access
6
+ * 2. Set ACL with did:nostr agent
7
+ * 3. Verify Nostr auth grants access
8
+ */
9
+
10
+ import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
11
+ import { getToken } from 'nostr-tools/nip98';
12
+
13
+ const BASE_URL = process.env.TEST_URL || 'http://localhost:4000';
14
+
15
+ async function main() {
16
+ console.log('=== did:nostr ACL Authorization Test ===\n');
17
+
18
+ // Generate a keypair for testing
19
+ const sk = generateSecretKey();
20
+ const pk = getPublicKey(sk);
21
+ const didNostr = `did:nostr:${pk}`;
22
+
23
+ console.log('1. Generated keypair');
24
+ console.log(` Pubkey: ${pk.slice(0, 16)}...`);
25
+ console.log(` DID: ${didNostr.slice(0, 24)}...\n`);
26
+
27
+ // Create a unique test container
28
+ const testPath = `/demo/nostr-acl-test-${Date.now()}/`;
29
+ const containerUrl = `${BASE_URL}${testPath}`;
30
+
31
+ console.log(`2. Creating test container: ${testPath}`);
32
+
33
+ // Create container (unauthenticated - should work on public parent)
34
+ const createRes = await fetch(containerUrl, {
35
+ method: 'PUT',
36
+ headers: { 'Content-Type': 'text/turtle' },
37
+ body: ''
38
+ });
39
+
40
+ if (!createRes.ok && createRes.status !== 201) {
41
+ console.log(` Failed to create container: ${createRes.status}`);
42
+ // Try anyway
43
+ } else {
44
+ console.log(` Created: ${createRes.status}\n`);
45
+ }
46
+
47
+ // Create ACL with did:nostr agent (Turtle format)
48
+ const aclUrl = `${containerUrl}.acl`;
49
+ const aclContent = `
50
+ @prefix acl: <http://www.w3.org/ns/auth/acl#>.
51
+
52
+ <#nostrAccess>
53
+ a acl:Authorization;
54
+ acl:agent <${didNostr}>;
55
+ acl:accessTo <${containerUrl}>;
56
+ acl:default <${containerUrl}>;
57
+ acl:mode acl:Read, acl:Write, acl:Control.
58
+ `;
59
+
60
+ console.log('3. Creating ACL with did:nostr agent');
61
+ console.log(` ACL URL: ${aclUrl}`);
62
+ console.log(` Agent: ${didNostr.slice(0, 40)}...`);
63
+
64
+ const aclRes = await fetch(aclUrl, {
65
+ method: 'PUT',
66
+ headers: { 'Content-Type': 'text/turtle' },
67
+ body: aclContent
68
+ });
69
+
70
+ console.log(` ACL created: ${aclRes.status}\n`);
71
+
72
+ // Verify ACL was saved correctly
73
+ console.log('4. Verifying ACL content');
74
+ const aclCheck = await fetch(aclUrl, {
75
+ headers: { 'Accept': 'text/turtle' }
76
+ });
77
+ const savedAcl = await aclCheck.text();
78
+ console.log(` ACL response: ${aclCheck.status}`);
79
+ console.log(` Contains did:nostr: ${savedAcl.includes('did:nostr:')}\n`);
80
+
81
+ // Test 1: Access WITHOUT auth (should be denied)
82
+ console.log('5. Testing access WITHOUT auth (should be 401/403)...');
83
+ const noAuthRes = await fetch(containerUrl);
84
+ console.log(` Status: ${noAuthRes.status} ${noAuthRes.status === 401 || noAuthRes.status === 403 ? '✓' : '✗'}\n`);
85
+
86
+ // Test 2: Access WITH correct Nostr auth
87
+ console.log('6. Testing access WITH correct Nostr auth...');
88
+ const token = await getToken(containerUrl, 'GET', (event) => finalizeEvent(event, sk));
89
+
90
+ const authRes = await fetch(containerUrl, {
91
+ headers: {
92
+ 'Authorization': `Nostr ${token}`,
93
+ 'Accept': 'text/turtle'
94
+ }
95
+ });
96
+
97
+ console.log(` Status: ${authRes.status}`);
98
+
99
+ if (authRes.status === 200) {
100
+ console.log(' ✓ ACCESS GRANTED - did:nostr ACL working!\n');
101
+ } else {
102
+ console.log(' ✗ Access denied');
103
+ const body = await authRes.text();
104
+ console.log(` Body: ${body.slice(0, 200)}\n`);
105
+ }
106
+
107
+ // Test 3: Access with DIFFERENT Nostr key (should be denied)
108
+ console.log('7. Testing with DIFFERENT Nostr key (should be denied)...');
109
+ const wrongSk = generateSecretKey();
110
+ const wrongToken = await getToken(containerUrl, 'GET', (event) => finalizeEvent(event, wrongSk));
111
+
112
+ const wrongAuthRes = await fetch(containerUrl, {
113
+ headers: {
114
+ 'Authorization': `Nostr ${wrongToken}`,
115
+ 'Accept': 'text/turtle'
116
+ }
117
+ });
118
+
119
+ console.log(` Status: ${wrongAuthRes.status} ${wrongAuthRes.status === 403 ? '✓' : '✗'}\n`);
120
+
121
+ // Clean up
122
+ console.log('8. Cleaning up test container...');
123
+ const deleteToken = await getToken(containerUrl, 'DELETE', (event) => finalizeEvent(event, sk));
124
+ const deleteRes = await fetch(containerUrl, {
125
+ method: 'DELETE',
126
+ headers: { 'Authorization': `Nostr ${deleteToken}` }
127
+ });
128
+ console.log(` Delete: ${deleteRes.status}\n`);
129
+
130
+ // Summary
131
+ console.log('=== Test Summary ===');
132
+ console.log(`No auth: ${noAuthRes.status === 401 || noAuthRes.status === 403 ? 'PASS' : 'FAIL'} (${noAuthRes.status})`);
133
+ console.log(`Correct key: ${authRes.status === 200 ? 'PASS' : 'FAIL'} (${authRes.status})`);
134
+ console.log(`Wrong key: ${wrongAuthRes.status === 403 ? 'PASS' : 'FAIL'} (${wrongAuthRes.status})`);
135
+
136
+ const allPassed = (noAuthRes.status === 401 || noAuthRes.status === 403) &&
137
+ authRes.status === 200 &&
138
+ wrongAuthRes.status === 403;
139
+
140
+ console.log(`\nOverall: ${allPassed ? 'ALL TESTS PASSED ✓' : 'SOME TESTS FAILED ✗'}`);
141
+ process.exit(allPassed ? 0 : 1);
142
+ }
143
+
144
+ main().catch(console.error);
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Test script for Nostr NIP-98 authentication
3
+ *
4
+ * Usage: node test-nostr-auth.js
5
+ *
6
+ * This script:
7
+ * 1. Generates a Nostr keypair
8
+ * 2. Creates a NIP-98 auth event
9
+ * 3. Makes authenticated request to JSS
10
+ * 4. Verifies the did:nostr identity is recognized
11
+ */
12
+
13
+ import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
14
+ import { getToken } from 'nostr-tools/nip98';
15
+
16
+ const BASE_URL = process.env.TEST_URL || 'http://localhost:4000';
17
+
18
+ async function main() {
19
+ console.log('=== Nostr NIP-98 Authentication Test ===\n');
20
+
21
+ // Generate a new keypair
22
+ const sk = generateSecretKey();
23
+ const pk = getPublicKey(sk);
24
+
25
+ console.log('1. Generated keypair');
26
+ console.log(` Public key: ${pk}`);
27
+ console.log(` did:nostr: did:nostr:${pk}\n`);
28
+
29
+ // Create NIP-98 token for GET request to a public resource
30
+ const testUrl = `${BASE_URL}/`;
31
+ const method = 'GET';
32
+
33
+ console.log(`2. Creating NIP-98 token for ${method} ${testUrl}`);
34
+
35
+ const token = await getToken(testUrl, method, (event) => finalizeEvent(event, sk));
36
+
37
+ console.log(` Token length: ${token.length} chars\n`);
38
+
39
+ // Make authenticated request
40
+ console.log('3. Making authenticated request...');
41
+
42
+ try {
43
+ const response = await fetch(testUrl, {
44
+ method,
45
+ headers: {
46
+ 'Authorization': `Nostr ${token}`,
47
+ 'Accept': 'application/json'
48
+ }
49
+ });
50
+
51
+ console.log(` Status: ${response.status} ${response.statusText}`);
52
+
53
+ // Check headers for any auth info
54
+ const wwwAuth = response.headers.get('www-authenticate');
55
+ if (wwwAuth) {
56
+ console.log(` WWW-Authenticate: ${wwwAuth}`);
57
+ }
58
+
59
+ // For a protected resource, we'd check if access was granted
60
+ // For now, just verify the request went through
61
+ if (response.ok) {
62
+ console.log(' Request succeeded!\n');
63
+ } else {
64
+ const body = await response.text();
65
+ console.log(` Response: ${body.slice(0, 200)}\n`);
66
+ }
67
+ } catch (err) {
68
+ console.error(` Error: ${err.message}\n`);
69
+ }
70
+
71
+ // Test with a protected resource (if exists)
72
+ console.log('4. Testing access to a container...');
73
+
74
+ const containerUrl = `${BASE_URL}/demo/public/`;
75
+
76
+ try {
77
+ const containerToken = await getToken(containerUrl, 'GET', (event) => finalizeEvent(event, sk));
78
+
79
+ const response = await fetch(containerUrl, {
80
+ headers: {
81
+ 'Authorization': `Nostr ${containerToken}`,
82
+ 'Accept': 'text/turtle'
83
+ }
84
+ });
85
+
86
+ console.log(` ${containerUrl}`);
87
+ console.log(` Status: ${response.status} ${response.statusText}`);
88
+
89
+ if (response.status === 200) {
90
+ console.log(' Container accessible with Nostr auth!');
91
+ } else if (response.status === 403) {
92
+ console.log(' 403 Forbidden - auth worked but no ACL grant for did:nostr');
93
+ console.log(` (Add did:nostr:${pk} to ACL to grant access)`);
94
+ } else if (response.status === 404) {
95
+ console.log(' 404 Not Found - container does not exist');
96
+ }
97
+ } catch (err) {
98
+ console.error(` Error: ${err.message}`);
99
+ }
100
+
101
+ console.log('\n=== Test Complete ===');
102
+ console.log('\nTo grant this identity access, add to an ACL file:');
103
+ console.log(`
104
+ @prefix acl: <http://www.w3.org/ns/auth/acl#>.
105
+
106
+ <#nostrAuth>
107
+ a acl:Authorization;
108
+ acl:agent <did:nostr:${pk}>;
109
+ acl:accessTo <./>;
110
+ acl:mode acl:Read, acl:Write.
111
+ `);
112
+ }
113
+
114
+ main().catch(console.error);
@@ -1,10 +0,0 @@
1
- {
2
- "id": "318e7a79-23d5-4e0b-8fa1-b63cfaee87e1",
3
- "username": "credtest",
4
- "email": "credtest@example.com",
5
- "passwordHash": "$2b$10$ITkxFeVH56JBgjDqYASbfuounFozpoVQpBvtsYxCszx2I0PBEX0hq",
6
- "webId": "http://localhost:3101/credtest/#me",
7
- "podName": "credtest",
8
- "createdAt": "2025-12-27T14:33:50.756Z",
9
- "lastLogin": "2025-12-27T14:33:51.196Z"
10
- }