javascript-solid-server 0.0.57 → 0.0.58
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 +3 -1
- package/README.md +32 -2
- package/package.json +1 -1
- package/src/auth/did-nostr.js +205 -0
- package/src/auth/nostr.js +10 -1
|
@@ -207,7 +207,9 @@
|
|
|
207
207
|
"WebFetch(domain:solid-chat.com)",
|
|
208
208
|
"WebFetch(domain:developer.chrome.com)",
|
|
209
209
|
"WebFetch(domain:css-tricks.com)",
|
|
210
|
-
"Bash(node bin/jss.js:*)"
|
|
210
|
+
"Bash(node bin/jss.js:*)",
|
|
211
|
+
"WebFetch(domain:nostr.social)",
|
|
212
|
+
"Bash(xargs curl -s)"
|
|
211
213
|
]
|
|
212
214
|
}
|
|
213
215
|
}
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ A minimal, fast, JSON-LD native Solid server.
|
|
|
24
24
|
- **Solid-OIDC Identity Provider** - Built-in IdP with DPoP, RS256/ES256, dynamic registration
|
|
25
25
|
- **Solid-OIDC Resource Server** - Accept DPoP-bound access tokens from external IdPs
|
|
26
26
|
- **NSS-style Registration** - Username/password auth compatible with Solid apps
|
|
27
|
-
- **Nostr Authentication** - NIP-98 HTTP Auth with Schnorr signatures
|
|
27
|
+
- **Nostr Authentication** - NIP-98 HTTP Auth with Schnorr signatures, did:nostr → WebID resolution
|
|
28
28
|
- **Simple Auth Tokens** - Built-in token authentication for development
|
|
29
29
|
- **Content Negotiation** - Turtle <-> JSON-LD conversion, including HTML data islands
|
|
30
30
|
- **CORS Support** - Full cross-origin resource sharing
|
|
@@ -387,6 +387,35 @@ git add .acl && git commit -m "Add ACL"
|
|
|
387
387
|
|
|
388
388
|
See [git-credential-nostr](https://github.com/JavaScriptSolidServer/git-credential-nostr) for more details.
|
|
389
389
|
|
|
390
|
+
### Linking Nostr to WebID (did:nostr)
|
|
391
|
+
|
|
392
|
+
Bridge your Nostr identity to a Solid WebID for seamless authentication:
|
|
393
|
+
|
|
394
|
+
**Step 1:** Add your WebID to your Nostr profile (kind 0 event):
|
|
395
|
+
```json
|
|
396
|
+
{
|
|
397
|
+
"name": "alice",
|
|
398
|
+
"alsoKnownAs": ["https://solid.social/alice/profile/card#me"]
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
**Step 2:** Add the did:nostr link to your WebID profile:
|
|
403
|
+
```json
|
|
404
|
+
{
|
|
405
|
+
"@id": "#me",
|
|
406
|
+
"owl:sameAs": "did:nostr:<your-64-char-hex-pubkey>"
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
**How it works:**
|
|
411
|
+
1. NIP-98 signature is verified (existing flow)
|
|
412
|
+
2. DID document is fetched from `nostr.social/.well-known/did/nostr/<pubkey>.json`
|
|
413
|
+
3. `alsoKnownAs` is checked for a WebID URL
|
|
414
|
+
4. WebID profile is fetched and `owl:sameAs` verified
|
|
415
|
+
5. If bidirectional link exists → authenticated as WebID
|
|
416
|
+
|
|
417
|
+
This enables Nostr users to access their Solid pods using existing NIP-07 browser extensions.
|
|
418
|
+
|
|
390
419
|
## Invite-Only Registration
|
|
391
420
|
|
|
392
421
|
Control who can create accounts by requiring invite codes:
|
|
@@ -735,7 +764,8 @@ src/
|
|
|
735
764
|
│ ├── middleware.js # Auth hook
|
|
736
765
|
│ ├── token.js # Simple token auth
|
|
737
766
|
│ ├── solid-oidc.js # DPoP verification
|
|
738
|
-
│
|
|
767
|
+
│ ├── nostr.js # NIP-98 Nostr authentication
|
|
768
|
+
│ └── did-nostr.js # did:nostr → WebID resolution
|
|
739
769
|
├── wac/
|
|
740
770
|
│ ├── parser.js # ACL parsing
|
|
741
771
|
│ └── checker.js # Permission checking
|
package/package.json
CHANGED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DID:nostr Resolution
|
|
3
|
+
*
|
|
4
|
+
* Resolves did:nostr:<pubkey> to a Solid WebID by:
|
|
5
|
+
* 1. Fetching DID document from nostr.social
|
|
6
|
+
* 2. Extracting alsoKnownAs WebID
|
|
7
|
+
* 3. Verifying bidirectional link (WebID links back to did:nostr)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Default DID resolver endpoint
|
|
11
|
+
const DEFAULT_DID_RESOLVER = 'https://nostr.social/.well-known/did/nostr';
|
|
12
|
+
|
|
13
|
+
// Cache for resolved DIDs (pubkey -> webId or null)
|
|
14
|
+
const cache = new Map();
|
|
15
|
+
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fetch with timeout
|
|
19
|
+
*/
|
|
20
|
+
async function fetchWithTimeout(url, options = {}, timeout = 5000) {
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const id = setTimeout(() => controller.abort(), timeout);
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
25
|
+
clearTimeout(id);
|
|
26
|
+
return response;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
clearTimeout(id);
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve did:nostr pubkey to WebID via DID document
|
|
35
|
+
* @param {string} pubkey - 64-char hex Nostr pubkey
|
|
36
|
+
* @param {string} resolverUrl - DID resolver base URL
|
|
37
|
+
* @returns {Promise<string|null>} WebID URL or null
|
|
38
|
+
*/
|
|
39
|
+
export async function resolveDidNostrToWebId(pubkey, resolverUrl = DEFAULT_DID_RESOLVER) {
|
|
40
|
+
if (!pubkey || pubkey.length !== 64) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check cache
|
|
45
|
+
const cacheKey = pubkey.toLowerCase();
|
|
46
|
+
const cached = cache.get(cacheKey);
|
|
47
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
48
|
+
return cached.webId;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// Fetch DID document
|
|
53
|
+
const didUrl = `${resolverUrl}/${pubkey}.json`;
|
|
54
|
+
const didRes = await fetchWithTimeout(didUrl, {
|
|
55
|
+
headers: { 'Accept': 'application/did+json, application/json' }
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (!didRes.ok) {
|
|
59
|
+
cache.set(cacheKey, { webId: null, timestamp: Date.now() });
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const didDoc = await didRes.json();
|
|
64
|
+
|
|
65
|
+
// Extract WebID from alsoKnownAs (array) or profile.webid or profile.sameAs
|
|
66
|
+
let webId = null;
|
|
67
|
+
|
|
68
|
+
if (Array.isArray(didDoc.alsoKnownAs) && didDoc.alsoKnownAs.length > 0) {
|
|
69
|
+
// Find first HTTP(S) URL that looks like a WebID
|
|
70
|
+
webId = didDoc.alsoKnownAs.find(aka =>
|
|
71
|
+
typeof aka === 'string' && aka.startsWith('https://'));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Fallback to profile fields
|
|
75
|
+
if (!webId && didDoc.profile) {
|
|
76
|
+
webId = didDoc.profile.webid || didDoc.profile.sameAs;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!webId) {
|
|
80
|
+
cache.set(cacheKey, { webId: null, timestamp: Date.now() });
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Verify bidirectional link - WebID must link back to did:nostr
|
|
85
|
+
const verified = await verifyWebIdBacklink(webId, pubkey);
|
|
86
|
+
|
|
87
|
+
if (verified) {
|
|
88
|
+
cache.set(cacheKey, { webId, timestamp: Date.now() });
|
|
89
|
+
return webId;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
cache.set(cacheKey, { webId: null, timestamp: Date.now() });
|
|
93
|
+
return null;
|
|
94
|
+
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// Network error or timeout - don't cache failures
|
|
97
|
+
console.error(`DID resolution error for ${pubkey}:`, err.message);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Verify WebID profile links back to did:nostr
|
|
104
|
+
* @param {string} webId - WebID URL
|
|
105
|
+
* @param {string} pubkey - Nostr pubkey
|
|
106
|
+
* @returns {Promise<boolean>}
|
|
107
|
+
*/
|
|
108
|
+
async function verifyWebIdBacklink(webId, pubkey) {
|
|
109
|
+
try {
|
|
110
|
+
const expectedDid = `did:nostr:${pubkey.toLowerCase()}`;
|
|
111
|
+
|
|
112
|
+
// Fetch WebID profile
|
|
113
|
+
const res = await fetchWithTimeout(webId, {
|
|
114
|
+
headers: { 'Accept': 'application/ld+json, application/json, text/html' }
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const contentType = res.headers.get('content-type') || '';
|
|
122
|
+
const text = await res.text();
|
|
123
|
+
|
|
124
|
+
// Handle HTML with JSON-LD data island
|
|
125
|
+
if (contentType.includes('text/html')) {
|
|
126
|
+
const jsonLdMatch = text.match(/<script\s+type=["']application\/ld\+json["']\s*>([\s\S]*?)<\/script>/i);
|
|
127
|
+
if (jsonLdMatch) {
|
|
128
|
+
try {
|
|
129
|
+
const jsonLd = JSON.parse(jsonLdMatch[1]);
|
|
130
|
+
return checkSameAsLink(jsonLd, expectedDid);
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Handle JSON-LD directly
|
|
139
|
+
if (contentType.includes('json')) {
|
|
140
|
+
try {
|
|
141
|
+
const jsonLd = JSON.parse(text);
|
|
142
|
+
return checkSameAsLink(jsonLd, expectedDid);
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return false;
|
|
149
|
+
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.error(`WebID backlink verification error for ${webId}:`, err.message);
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Check if JSON-LD contains sameAs/owl:sameAs link to expected DID
|
|
158
|
+
* @param {object} jsonLd - Parsed JSON-LD
|
|
159
|
+
* @param {string} expectedDid - Expected did:nostr:pubkey
|
|
160
|
+
* @returns {boolean}
|
|
161
|
+
*/
|
|
162
|
+
function checkSameAsLink(jsonLd, expectedDid) {
|
|
163
|
+
// Check various sameAs fields
|
|
164
|
+
const sameAsFields = [
|
|
165
|
+
jsonLd['owl:sameAs'],
|
|
166
|
+
jsonLd['sameAs'],
|
|
167
|
+
jsonLd['schema:sameAs'],
|
|
168
|
+
jsonLd['http://www.w3.org/2002/07/owl#sameAs']
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
for (const field of sameAsFields) {
|
|
172
|
+
if (!field) continue;
|
|
173
|
+
|
|
174
|
+
// Handle string value
|
|
175
|
+
if (typeof field === 'string' && field.toLowerCase() === expectedDid) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Handle object with @id
|
|
180
|
+
if (field && typeof field === 'object' && field['@id']?.toLowerCase() === expectedDid) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Handle array
|
|
185
|
+
if (Array.isArray(field)) {
|
|
186
|
+
for (const item of field) {
|
|
187
|
+
if (typeof item === 'string' && item.toLowerCase() === expectedDid) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
if (item && typeof item === 'object' && item['@id']?.toLowerCase() === expectedDid) {
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Clear the resolution cache (for testing)
|
|
202
|
+
*/
|
|
203
|
+
export function clearCache() {
|
|
204
|
+
cache.clear();
|
|
205
|
+
}
|
package/src/auth/nostr.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { verifyEvent } from 'nostr-tools';
|
|
15
15
|
import crypto from 'crypto';
|
|
16
|
+
import { resolveDidNostrToWebId } from './did-nostr.js';
|
|
16
17
|
|
|
17
18
|
// NIP-98 event kind (references RFC 7235)
|
|
18
19
|
const HTTP_AUTH_KIND = 27235;
|
|
@@ -223,7 +224,15 @@ export async function verifyNostrAuth(request) {
|
|
|
223
224
|
return { webId: null, error: 'Invalid Schnorr signature' };
|
|
224
225
|
}
|
|
225
226
|
|
|
226
|
-
//
|
|
227
|
+
// Try to resolve did:nostr to a linked WebID
|
|
228
|
+
// This checks if the pubkey has an alsoKnownAs pointing to a WebID
|
|
229
|
+
// and verifies the WebID links back to did:nostr (bidirectional)
|
|
230
|
+
const resolvedWebId = await resolveDidNostrToWebId(event.pubkey);
|
|
231
|
+
if (resolvedWebId) {
|
|
232
|
+
return { webId: resolvedWebId, error: null };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Fall back to did:nostr as the agent identifier
|
|
227
236
|
const didNostr = pubkeyToDidNostr(event.pubkey);
|
|
228
237
|
|
|
229
238
|
return { webId: didNostr, error: null };
|