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/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 generateSigningKey() {
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 signingKey = await generateSigningKey();
123
+ const signingKeys = await generateSigningKeys();
76
124
  const cookieKeys = generateCookieKeys();
77
125
 
78
126
  const data = {
79
127
  jwks: {
80
- keys: [signingKey],
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
- const { d, ...publicKey } = key;
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
 
@@ -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: 'ES256', // ES256 is what we support
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 (CTH uses RS256)
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 notifications
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
  }