javascript-solid-server 0.0.35 → 0.0.37
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 +20 -1
- package/AGENTS.md +152 -0
- package/bin/jss.js +4 -0
- package/docs/design/nostr-relay-integration.md +353 -0
- package/docs/git-support.md +283 -0
- package/package.json +1 -1
- package/src/handlers/git.js +207 -0
- package/src/handlers/resource.js +75 -23
- package/src/idp/credentials.js +3 -2
- package/src/idp/index.js +1 -1
- package/src/idp/keys.js +57 -8
- package/src/idp/provider.js +100 -2
- package/src/rdf/turtle.js +4 -2
- package/src/server.js +53 -1
package/src/idp/keys.js
CHANGED
|
@@ -21,16 +21,15 @@ function getJwksPath() {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Generate a new EC P-256 key pair for signing
|
|
24
|
+
* Generate a new EC P-256 key pair for signing (ES256)
|
|
25
25
|
* @returns {Promise<object>} - JWK key pair with private key
|
|
26
26
|
*/
|
|
27
|
-
async function
|
|
27
|
+
async function generateES256Key() {
|
|
28
28
|
const { publicKey, privateKey } = await jose.generateKeyPair('ES256', {
|
|
29
29
|
extractable: true,
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
const privateJwk = await jose.exportJWK(privateKey);
|
|
33
|
-
const publicJwk = await jose.exportJWK(publicKey);
|
|
34
33
|
|
|
35
34
|
// Add metadata
|
|
36
35
|
const kid = crypto.randomUUID();
|
|
@@ -45,6 +44,44 @@ async function generateSigningKey() {
|
|
|
45
44
|
};
|
|
46
45
|
}
|
|
47
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Generate a new RSA key pair for signing (RS256)
|
|
49
|
+
* NSS v5.x may only support RS256 for external IdP verification
|
|
50
|
+
* @returns {Promise<object>} - JWK key pair with private key
|
|
51
|
+
*/
|
|
52
|
+
async function generateRS256Key() {
|
|
53
|
+
const { publicKey, privateKey } = await jose.generateKeyPair('RS256', {
|
|
54
|
+
modulusLength: 2048,
|
|
55
|
+
extractable: true,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const privateJwk = await jose.exportJWK(privateKey);
|
|
59
|
+
|
|
60
|
+
// Add metadata
|
|
61
|
+
const kid = crypto.randomUUID();
|
|
62
|
+
const now = Math.floor(Date.now() / 1000);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
...privateJwk,
|
|
66
|
+
kid,
|
|
67
|
+
use: 'sig',
|
|
68
|
+
alg: 'RS256',
|
|
69
|
+
iat: now,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Generate signing keys (both ES256 and RS256 for compatibility)
|
|
75
|
+
* @returns {Promise<object[]>} - Array of JWK key pairs
|
|
76
|
+
*/
|
|
77
|
+
async function generateSigningKeys() {
|
|
78
|
+
// Generate RS256 first (primary, for NSS compatibility)
|
|
79
|
+
const rs256Key = await generateRS256Key();
|
|
80
|
+
// Also generate ES256 for modern clients
|
|
81
|
+
const es256Key = await generateES256Key();
|
|
82
|
+
return [rs256Key, es256Key];
|
|
83
|
+
}
|
|
84
|
+
|
|
48
85
|
/**
|
|
49
86
|
* Generate cookie signing keys
|
|
50
87
|
* @returns {string[]} - Array of random secret strings
|
|
@@ -66,25 +103,36 @@ export async function initializeKeys() {
|
|
|
66
103
|
try {
|
|
67
104
|
// Try to load existing keys
|
|
68
105
|
const data = await fs.readJson(getJwksPath());
|
|
106
|
+
|
|
107
|
+
// Check if we have RS256 key (needed for NSS compatibility)
|
|
108
|
+
const hasRS256 = data.jwks.keys.some((k) => k.alg === 'RS256');
|
|
109
|
+
if (!hasRS256) {
|
|
110
|
+
console.log('Adding RS256 key for NSS compatibility...');
|
|
111
|
+
const rs256Key = await generateRS256Key();
|
|
112
|
+
data.jwks.keys.unshift(rs256Key); // RS256 first (primary)
|
|
113
|
+
await fs.writeJson(getJwksPath(), data, { spaces: 2 });
|
|
114
|
+
console.log('RS256 key added.');
|
|
115
|
+
}
|
|
116
|
+
|
|
69
117
|
return data;
|
|
70
118
|
} catch (err) {
|
|
71
119
|
if (err.code !== 'ENOENT') throw err;
|
|
72
120
|
|
|
73
|
-
// Generate new keys
|
|
121
|
+
// Generate new keys (both RS256 and ES256)
|
|
74
122
|
console.log('Generating new IdP signing keys...');
|
|
75
|
-
const
|
|
123
|
+
const signingKeys = await generateSigningKeys();
|
|
76
124
|
const cookieKeys = generateCookieKeys();
|
|
77
125
|
|
|
78
126
|
const data = {
|
|
79
127
|
jwks: {
|
|
80
|
-
keys:
|
|
128
|
+
keys: signingKeys,
|
|
81
129
|
},
|
|
82
130
|
cookieKeys,
|
|
83
131
|
createdAt: new Date().toISOString(),
|
|
84
132
|
};
|
|
85
133
|
|
|
86
134
|
await fs.writeJson(getJwksPath(), data, { spaces: 2 });
|
|
87
|
-
console.log('IdP signing keys generated and saved.');
|
|
135
|
+
console.log('IdP signing keys generated and saved (RS256 + ES256).');
|
|
88
136
|
|
|
89
137
|
return data;
|
|
90
138
|
}
|
|
@@ -100,7 +148,8 @@ export async function getPublicJwks() {
|
|
|
100
148
|
// Return only public key components
|
|
101
149
|
const publicKeys = jwks.keys.map((key) => {
|
|
102
150
|
// For EC keys, remove 'd' (private key component)
|
|
103
|
-
|
|
151
|
+
// For RSA keys, remove 'd', 'p', 'q', 'dp', 'dq', 'qi' (private components)
|
|
152
|
+
const { d, p, q, dp, dq, qi, ...publicKey } = key;
|
|
104
153
|
return publicKey;
|
|
105
154
|
});
|
|
106
155
|
|
package/src/idp/provider.js
CHANGED
|
@@ -8,6 +8,68 @@ import { createAdapter } from './adapter.js';
|
|
|
8
8
|
import { getJwks, getCookieKeys } from './keys.js';
|
|
9
9
|
import { getAccountForProvider } from './accounts.js';
|
|
10
10
|
|
|
11
|
+
// Cache for fetched client documents
|
|
12
|
+
const clientDocumentCache = new Map();
|
|
13
|
+
const CLIENT_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fetch and validate a Solid-OIDC Client Identifier Document
|
|
17
|
+
* @param {string} clientId - URL to the client document
|
|
18
|
+
* @returns {Promise<object|null>} - Client metadata or null
|
|
19
|
+
*/
|
|
20
|
+
async function fetchClientDocument(clientId) {
|
|
21
|
+
try {
|
|
22
|
+
// Check cache
|
|
23
|
+
const cached = clientDocumentCache.get(clientId);
|
|
24
|
+
if (cached && Date.now() - cached.timestamp < CLIENT_CACHE_TTL) {
|
|
25
|
+
return cached.data;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const response = await fetch(clientId, {
|
|
29
|
+
headers: { 'Accept': 'application/json, application/ld+json' },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
console.error(`Failed to fetch client document from ${clientId}: ${response.status}`);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const doc = await response.json();
|
|
38
|
+
|
|
39
|
+
// Validate required fields for Solid-OIDC client
|
|
40
|
+
// The client_id in the document must match the URL we fetched
|
|
41
|
+
if (doc.client_id && doc.client_id !== clientId) {
|
|
42
|
+
console.error(`Client ID mismatch: document says ${doc.client_id}, URL is ${clientId}`);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Build client metadata compatible with oidc-provider
|
|
47
|
+
const clientMeta = {
|
|
48
|
+
client_id: clientId,
|
|
49
|
+
client_name: doc.client_name || doc.name || 'Unknown Client',
|
|
50
|
+
redirect_uris: doc.redirect_uris || [],
|
|
51
|
+
response_types: ['code'],
|
|
52
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
53
|
+
token_endpoint_auth_method: 'none', // Public client
|
|
54
|
+
application_type: 'web',
|
|
55
|
+
// Copy other useful metadata
|
|
56
|
+
logo_uri: doc.logo_uri,
|
|
57
|
+
client_uri: doc.client_uri,
|
|
58
|
+
policy_uri: doc.policy_uri,
|
|
59
|
+
tos_uri: doc.tos_uri,
|
|
60
|
+
scope: doc.scope || 'openid webid',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Cache the result
|
|
64
|
+
clientDocumentCache.set(clientId, { data: clientMeta, timestamp: Date.now() });
|
|
65
|
+
|
|
66
|
+
return clientMeta;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error(`Error fetching client document from ${clientId}:`, err.message);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
11
73
|
/**
|
|
12
74
|
* Create and configure the OIDC provider
|
|
13
75
|
* @param {string} issuer - The issuer URL (e.g., 'https://example.com')
|
|
@@ -242,12 +304,17 @@ export async function createProvider(issuer) {
|
|
|
242
304
|
return true;
|
|
243
305
|
},
|
|
244
306
|
|
|
307
|
+
// Extra client metadata fields to allow
|
|
308
|
+
extraClientMetadata: {
|
|
309
|
+
properties: ['client_name', 'logo_uri', 'client_uri', 'policy_uri', 'tos_uri'],
|
|
310
|
+
},
|
|
311
|
+
|
|
245
312
|
// Client defaults
|
|
246
313
|
clientDefaults: {
|
|
247
314
|
grant_types: ['authorization_code', 'refresh_token'],
|
|
248
315
|
response_types: ['code'],
|
|
249
316
|
token_endpoint_auth_method: 'none', // Public clients by default
|
|
250
|
-
id_token_signed_response_alg: '
|
|
317
|
+
id_token_signed_response_alg: 'RS256', // RS256 for NSS compatibility
|
|
251
318
|
},
|
|
252
319
|
|
|
253
320
|
// Response modes
|
|
@@ -263,9 +330,12 @@ export async function createProvider(issuer) {
|
|
|
263
330
|
methods: ['S256'],
|
|
264
331
|
},
|
|
265
332
|
|
|
266
|
-
// Enable RS256 for DPoP (
|
|
333
|
+
// Enable RS256 for DPoP and ID tokens (NSS requires RS256)
|
|
267
334
|
enabledJWA: {
|
|
268
335
|
dPoPSigningAlgValues: ['ES256', 'RS256', 'Ed25519', 'EdDSA'],
|
|
336
|
+
idTokenSigningAlgValues: ['RS256', 'ES256'],
|
|
337
|
+
userinfoSigningAlgValues: ['RS256', 'ES256'],
|
|
338
|
+
introspectionSigningAlgValues: ['RS256', 'ES256'],
|
|
269
339
|
},
|
|
270
340
|
|
|
271
341
|
// Enable request parameter
|
|
@@ -337,5 +407,33 @@ export async function createProvider(issuer) {
|
|
|
337
407
|
// Allow localhost for development
|
|
338
408
|
provider.proxy = true;
|
|
339
409
|
|
|
410
|
+
// Override Client.find to support Solid-OIDC Client Identifier Documents
|
|
411
|
+
// When client_id is a URL, fetch the document and create a client from it
|
|
412
|
+
const originalClientFind = provider.Client.find.bind(provider.Client);
|
|
413
|
+
provider.Client.find = async function(id, ...args) {
|
|
414
|
+
// First try the normal lookup (registered clients)
|
|
415
|
+
let client = await originalClientFind(id, ...args);
|
|
416
|
+
if (client) {
|
|
417
|
+
return client;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// If client_id looks like a URL, try to fetch the client document
|
|
421
|
+
if (id && (id.startsWith('http://') || id.startsWith('https://'))) {
|
|
422
|
+
const clientMeta = await fetchClientDocument(id);
|
|
423
|
+
if (clientMeta) {
|
|
424
|
+
// Create a temporary client object from the fetched metadata
|
|
425
|
+
// Use the Client constructor with the metadata
|
|
426
|
+
try {
|
|
427
|
+
client = new provider.Client(clientMeta, undefined);
|
|
428
|
+
return client;
|
|
429
|
+
} catch (err) {
|
|
430
|
+
console.error('Failed to create client from document:', err.message);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return undefined;
|
|
436
|
+
};
|
|
437
|
+
|
|
340
438
|
return provider;
|
|
341
439
|
}
|
package/src/rdf/turtle.js
CHANGED
|
@@ -66,9 +66,11 @@ export async function jsonLdToTurtle(jsonLd, baseUri) {
|
|
|
66
66
|
try {
|
|
67
67
|
const quads = jsonLdToQuads(jsonLd, baseUri);
|
|
68
68
|
|
|
69
|
+
// Don't use baseIRI in writer - output absolute URIs for compatibility
|
|
70
|
+
// Some Solid servers (like NSS) may not properly resolve relative URIs
|
|
71
|
+
// when verifying oidcIssuer claims
|
|
69
72
|
const writer = new Writer({
|
|
70
|
-
prefixes: COMMON_PREFIXES
|
|
71
|
-
baseIRI: baseUri
|
|
73
|
+
prefixes: COMMON_PREFIXES
|
|
72
74
|
});
|
|
73
75
|
|
|
74
76
|
for (const q of quads) {
|
package/src/server.js
CHANGED
|
@@ -8,6 +8,7 @@ import { getCorsHeaders } from './ldp/headers.js';
|
|
|
8
8
|
import { authorize, handleUnauthorized } from './auth/middleware.js';
|
|
9
9
|
import { notificationsPlugin } from './notifications/index.js';
|
|
10
10
|
import { idpPlugin } from './idp/index.js';
|
|
11
|
+
import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js';
|
|
11
12
|
|
|
12
13
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
14
|
|
|
@@ -23,6 +24,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
23
24
|
* @param {string} options.root - Data directory path (default from env or ./data)
|
|
24
25
|
* @param {boolean} options.subdomains - Enable subdomain-based pods for XSS protection (default false)
|
|
25
26
|
* @param {string} options.baseDomain - Base domain for subdomain pods (e.g., "example.com")
|
|
27
|
+
* @param {boolean} options.git - Enable Git HTTP backend for clone/push (default false)
|
|
26
28
|
*/
|
|
27
29
|
export function createServer(options = {}) {
|
|
28
30
|
// Content negotiation is OFF by default - we're a JSON-LD native server
|
|
@@ -40,6 +42,8 @@ export function createServer(options = {}) {
|
|
|
40
42
|
const mashlibEnabled = options.mashlib ?? false;
|
|
41
43
|
const mashlibCdn = options.mashlibCdn ?? false;
|
|
42
44
|
const mashlibVersion = options.mashlibVersion ?? '2.0.0';
|
|
45
|
+
// Git HTTP backend is OFF by default - enables clone/push via git protocol
|
|
46
|
+
const gitEnabled = options.git ?? false;
|
|
43
47
|
|
|
44
48
|
// Set data root via environment variable if provided
|
|
45
49
|
if (options.root) {
|
|
@@ -128,16 +132,64 @@ export function createServer(options = {}) {
|
|
|
128
132
|
// Note: OPTIONS requests are handled by handleOptions to include Accept-* headers
|
|
129
133
|
});
|
|
130
134
|
|
|
135
|
+
// Security: Block access to dotfiles except allowed Solid-specific ones
|
|
136
|
+
// This prevents exposure of .git/, .env, .htpasswd, etc.
|
|
137
|
+
// Git protocol requests bypass this check when git is enabled
|
|
138
|
+
const ALLOWED_DOTFILES = ['.well-known', '.acl', '.meta'];
|
|
139
|
+
fastify.addHook('onRequest', async (request, reply) => {
|
|
140
|
+
// Allow git protocol requests through when git is enabled
|
|
141
|
+
if (gitEnabled && isGitRequest(request.url)) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const segments = request.url.split('/').map(s => s.split('?')[0]); // Remove query strings
|
|
146
|
+
const hasForbiddenDotfile = segments.some(seg =>
|
|
147
|
+
seg.startsWith('.') &&
|
|
148
|
+
seg.length > 1 &&
|
|
149
|
+
!ALLOWED_DOTFILES.includes(seg)
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (hasForbiddenDotfile) {
|
|
153
|
+
return reply.code(403).send({ error: 'Forbidden', message: 'Dotfile access is not allowed' });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Git HTTP backend handler - uses git http-backend CGI
|
|
158
|
+
// Authorization: Read for clone/fetch, Write for push
|
|
159
|
+
if (gitEnabled) {
|
|
160
|
+
fastify.addHook('preHandler', async (request, reply) => {
|
|
161
|
+
if (!isGitRequest(request.url)) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Run WAC authorization - checkAccess already verifies the required mode
|
|
166
|
+
const { authorized, webId, wacAllow, authError } = await authorize(request, reply);
|
|
167
|
+
request.webId = webId;
|
|
168
|
+
request.wacAllow = wacAllow;
|
|
169
|
+
|
|
170
|
+
if (!authorized) {
|
|
171
|
+
const needsWrite = isGitWriteOperation(request.url);
|
|
172
|
+
const message = needsWrite ? 'Write access required for push' : 'Read access required for clone';
|
|
173
|
+
reply.header('WAC-Allow', wacAllow);
|
|
174
|
+
return reply.code(webId ? 403 : 401).send({ error: message });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Handle the git request directly
|
|
178
|
+
return handleGit(request, reply);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
131
182
|
// Authorization hook - check WAC permissions
|
|
132
183
|
// Skip for pod creation endpoint (needs special handling)
|
|
133
184
|
fastify.addHook('preHandler', async (request, reply) => {
|
|
134
|
-
// Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, and
|
|
185
|
+
// Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, notifications, and git
|
|
135
186
|
const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
|
|
136
187
|
if (request.url === '/.pods' ||
|
|
137
188
|
request.url === '/.notifications' ||
|
|
138
189
|
request.method === 'OPTIONS' ||
|
|
139
190
|
request.url.startsWith('/idp/') ||
|
|
140
191
|
request.url.startsWith('/.well-known/') ||
|
|
192
|
+
(gitEnabled && isGitRequest(request.url)) ||
|
|
141
193
|
mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
|
|
142
194
|
return;
|
|
143
195
|
}
|