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.
- package/.claude/settings.local.json +6 -1
- package/package.json +2 -1
- package/src/auth/nostr.js +197 -0
- package/src/auth/token.js +13 -1
- package/src/wac/checker.js +3 -3
- package/src/wac/parser.js +25 -8
- package/test/wac.test.js +27 -8
- package/test-data-idp-accounts/.idp/accounts/_email_index.json +1 -1
- package/test-data-idp-accounts/.idp/accounts/_username_index.json +1 -1
- package/test-data-idp-accounts/.idp/accounts/_webid_index.json +1 -1
- package/test-data-idp-accounts/.idp/accounts/ba3591b1-4653-4c64-9661-57dc355e5acc.json +10 -0
- package/test-data-idp-accounts/.idp/keys/jwks.json +8 -8
- package/test-nostr-acl.js +144 -0
- package/test-nostr-auth.js +114 -0
- package/test-data-idp-accounts/.idp/accounts/318e7a79-23d5-4e0b-8fa1-b63cfaee87e1.json +0 -10
|
@@ -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.
|
|
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
|
|
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) {
|
package/src/wac/checker.js
CHANGED
|
@@ -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
|
|
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
|
|
25
|
-
* @param {string|object} content - JSON-LD
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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', () => {
|
|
@@ -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": "
|
|
7
|
-
"y": "
|
|
6
|
+
"x": "Aa7l5-YrS54RU8xPfEphUTRwNBzSm6lxm84aqKjfrSg",
|
|
7
|
+
"y": "tWi_lhjqQhd43KdK5YqDg7ZzRSUZo3L0ytbiBTdPOWs",
|
|
8
8
|
"crv": "P-256",
|
|
9
|
-
"d": "
|
|
10
|
-
"kid": "
|
|
9
|
+
"d": "x6NqVSfA241O10u9Qp4m0dQZsTNYw-Hku3r0eu47VZE",
|
|
10
|
+
"kid": "ed46f7df-3010-43da-9032-e0acaee4d3e1",
|
|
11
11
|
"use": "sig",
|
|
12
12
|
"alg": "ES256",
|
|
13
|
-
"iat":
|
|
13
|
+
"iat": 1766931602
|
|
14
14
|
}
|
|
15
15
|
]
|
|
16
16
|
},
|
|
17
17
|
"cookieKeys": [
|
|
18
|
-
"
|
|
19
|
-
"
|
|
18
|
+
"Vb3JNLAlJHCOu5u73eUA_rzlc9aJ0_WCQCu9RWV5WL4",
|
|
19
|
+
"5xCVtYihgadSlvy1QRD_DcU4_9mI_Ggn0DrngzPdiyM"
|
|
20
20
|
],
|
|
21
|
-
"createdAt": "2025-12-
|
|
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
|
-
}
|