javascript-solid-server 0.0.5 → 0.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -11,11 +11,17 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "fastify": "^4.25.2",
14
- "fs-extra": "^11.2.0"
14
+ "fs-extra": "^11.2.0",
15
+ "jose": "^6.1.3"
15
16
  },
16
17
  "engines": {
17
18
  "node": ">=18.0.0"
18
19
  },
19
- "keywords": ["solid", "ldp", "linked-data", "decentralized"],
20
+ "keywords": [
21
+ "solid",
22
+ "ldp",
23
+ "linked-data",
24
+ "decentralized"
25
+ ],
20
26
  "license": "MIT"
21
27
  }
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Authorization middleware
3
3
  * Combines authentication (token verification) with WAC checking
4
+ * Supports both simple Bearer tokens and Solid-OIDC DPoP tokens
4
5
  */
5
6
 
6
- import { getWebIdFromRequest } from './token.js';
7
+ import { getWebIdFromRequestAsync } from './token.js';
7
8
  import { checkAccess, getRequiredMode } from '../wac/checker.js';
8
9
  import * as storage from '../storage/filesystem.js';
9
10
 
@@ -11,7 +12,7 @@ import * as storage from '../storage/filesystem.js';
11
12
  * Check if request is authorized
12
13
  * @param {object} request - Fastify request
13
14
  * @param {object} reply - Fastify reply
14
- * @returns {Promise<{authorized: boolean, webId: string|null, wacAllow: string}>}
15
+ * @returns {Promise<{authorized: boolean, webId: string|null, wacAllow: string, authError: string|null}>}
15
16
  */
16
17
  export async function authorize(request, reply) {
17
18
  const urlPath = request.url.split('?')[0];
@@ -20,11 +21,11 @@ export async function authorize(request, reply) {
20
21
  // Skip auth for .acl files (they need special handling)
21
22
  // and for OPTIONS (CORS preflight)
22
23
  if (urlPath.endsWith('.acl') || method === 'OPTIONS') {
23
- return { authorized: true, webId: null, wacAllow: 'user="read write append control", public="read write append"' };
24
+ return { authorized: true, webId: null, wacAllow: 'user="read write append control", public="read write append"', authError: null };
24
25
  }
25
26
 
26
- // Get WebID from token (null if not authenticated)
27
- const webId = getWebIdFromRequest(request);
27
+ // Get WebID from token (supports both simple and Solid-OIDC tokens)
28
+ const { webId, error: authError } = await getWebIdFromRequestAsync(request);
28
29
 
29
30
  // Get resource info
30
31
  const stats = await storage.stat(urlPath);
@@ -59,7 +60,7 @@ export async function authorize(request, reply) {
59
60
  requiredMode
60
61
  });
61
62
 
62
- return { authorized: allowed, webId, wacAllow };
63
+ return { authorized: allowed, webId, wacAllow, authError };
63
64
  }
64
65
 
65
66
  /**
@@ -77,15 +78,16 @@ function getParentPath(path) {
77
78
  * @param {object} reply - Fastify reply
78
79
  * @param {boolean} isAuthenticated - Whether user is authenticated
79
80
  * @param {string} wacAllow - WAC-Allow header value
81
+ * @param {string|null} authError - Authentication error message (for DPoP failures)
80
82
  */
81
- export function handleUnauthorized(reply, isAuthenticated, wacAllow) {
83
+ export function handleUnauthorized(reply, isAuthenticated, wacAllow, authError = null) {
82
84
  reply.header('WAC-Allow', wacAllow);
83
85
 
84
86
  if (!isAuthenticated) {
85
87
  // Not authenticated - return 401
86
88
  return reply.code(401).send({
87
89
  error: 'Unauthorized',
88
- message: 'Authentication required'
90
+ message: authError || 'Authentication required'
89
91
  });
90
92
  } else {
91
93
  // Authenticated but not authorized - return 403
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Solid-OIDC Resource Server
3
+ * Verifies DPoP-bound access tokens from external Identity Providers
4
+ *
5
+ * Flow:
6
+ * 1. User authenticates at external IdP (e.g., solidcommunity.net)
7
+ * 2. User gets DPoP-bound access token
8
+ * 3. User sends request with Authorization: DPoP <token> and DPoP: <proof>
9
+ * 4. We verify the token and DPoP proof
10
+ * 5. Extract WebID from token
11
+ */
12
+
13
+ import * as jose from 'jose';
14
+
15
+ // Cache for OIDC configurations and JWKS
16
+ const oidcConfigCache = new Map();
17
+ const jwksCache = new Map();
18
+
19
+ // Cache TTL (15 minutes)
20
+ const CACHE_TTL = 15 * 60 * 1000;
21
+
22
+ // DPoP proof max age (5 minutes)
23
+ const DPOP_MAX_AGE = 5 * 60;
24
+
25
+ /**
26
+ * Verify a Solid-OIDC request and extract WebID
27
+ * @param {object} request - Fastify request object
28
+ * @returns {Promise<{webId: string|null, error: string|null}>}
29
+ */
30
+ export async function verifySolidOidc(request) {
31
+ const authHeader = request.headers.authorization;
32
+ const dpopHeader = request.headers.dpop;
33
+
34
+ // Check for DPoP authorization scheme
35
+ if (!authHeader || !authHeader.startsWith('DPoP ')) {
36
+ return { webId: null, error: null }; // Not a Solid-OIDC request
37
+ }
38
+
39
+ if (!dpopHeader) {
40
+ return { webId: null, error: 'Missing DPoP proof header' };
41
+ }
42
+
43
+ const accessToken = authHeader.slice(5); // Remove 'DPoP ' prefix
44
+
45
+ try {
46
+ // Step 1: Decode access token (without verification) to get issuer
47
+ const tokenPayload = jose.decodeJwt(accessToken);
48
+ const issuer = tokenPayload.iss;
49
+
50
+ if (!issuer) {
51
+ return { webId: null, error: 'Access token missing issuer' };
52
+ }
53
+
54
+ // Step 2: Verify DPoP proof
55
+ const dpopResult = await verifyDpopProof(dpopHeader, request, accessToken);
56
+ if (dpopResult.error) {
57
+ return { webId: null, error: dpopResult.error };
58
+ }
59
+
60
+ // Step 3: Fetch JWKS and verify access token
61
+ const jwks = await getJwks(issuer);
62
+ const { payload } = await jose.jwtVerify(accessToken, jwks, {
63
+ issuer,
64
+ clockTolerance: 30 // 30 seconds clock skew tolerance
65
+ });
66
+
67
+ // Step 4: Verify DPoP binding (cnf.jkt must match DPoP key thumbprint)
68
+ if (payload.cnf?.jkt) {
69
+ if (payload.cnf.jkt !== dpopResult.thumbprint) {
70
+ return { webId: null, error: 'DPoP key does not match token binding' };
71
+ }
72
+ }
73
+
74
+ // Step 5: Extract WebID
75
+ const webId = payload.webid || payload.sub;
76
+ if (!webId) {
77
+ return { webId: null, error: 'Token missing WebID claim' };
78
+ }
79
+
80
+ // Validate WebID is a valid URL
81
+ try {
82
+ new URL(webId);
83
+ } catch {
84
+ return { webId: null, error: 'Invalid WebID URL' };
85
+ }
86
+
87
+ return { webId, error: null };
88
+
89
+ } catch (err) {
90
+ // Handle specific JWT errors
91
+ if (err.code === 'ERR_JWT_EXPIRED') {
92
+ return { webId: null, error: 'Access token expired' };
93
+ }
94
+ if (err.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
95
+ return { webId: null, error: 'Invalid token signature' };
96
+ }
97
+ if (err.code === 'ERR_JWKS_NO_MATCHING_KEY') {
98
+ return { webId: null, error: 'No matching key found in JWKS' };
99
+ }
100
+
101
+ console.error('Solid-OIDC verification error:', err.message);
102
+ return { webId: null, error: 'Token verification failed' };
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Verify DPoP proof JWT
108
+ * @param {string} dpopProof - The DPoP proof JWT
109
+ * @param {object} request - Fastify request
110
+ * @param {string} accessToken - The access token (for ath claim verification)
111
+ * @returns {Promise<{thumbprint: string|null, error: string|null}>}
112
+ */
113
+ async function verifyDpopProof(dpopProof, request, accessToken) {
114
+ try {
115
+ // Decode header to get the public key
116
+ const protectedHeader = jose.decodeProtectedHeader(dpopProof);
117
+
118
+ if (protectedHeader.typ !== 'dpop+jwt') {
119
+ return { thumbprint: null, error: 'Invalid DPoP proof type' };
120
+ }
121
+
122
+ if (!protectedHeader.jwk) {
123
+ return { thumbprint: null, error: 'DPoP proof missing jwk header' };
124
+ }
125
+
126
+ // Import the public key from the JWK in the header
127
+ const publicKey = await jose.importJWK(protectedHeader.jwk, protectedHeader.alg);
128
+
129
+ // Verify the DPoP proof signature
130
+ const { payload } = await jose.jwtVerify(dpopProof, publicKey, {
131
+ clockTolerance: 30
132
+ });
133
+
134
+ // Verify required claims
135
+ // htm: HTTP method
136
+ const expectedMethod = request.method.toUpperCase();
137
+ if (payload.htm !== expectedMethod) {
138
+ return { thumbprint: null, error: `DPoP htm mismatch: expected ${expectedMethod}` };
139
+ }
140
+
141
+ // htu: HTTP URI (without query string and fragment)
142
+ const requestUrl = `${request.protocol}://${request.hostname}${request.url.split('?')[0]}`;
143
+ // Normalize both URLs for comparison
144
+ const payloadHtu = payload.htu?.replace(/\/$/, '');
145
+ const expectedHtu = requestUrl.replace(/\/$/, '');
146
+
147
+ if (payloadHtu !== expectedHtu) {
148
+ // Also try without port for localhost
149
+ const altHtu = requestUrl.replace(/:(\d+)/, '').replace(/\/$/, '');
150
+ if (payloadHtu !== altHtu) {
151
+ return { thumbprint: null, error: `DPoP htu mismatch: expected ${expectedHtu}` };
152
+ }
153
+ }
154
+
155
+ // iat: Issued at (must be recent)
156
+ const now = Math.floor(Date.now() / 1000);
157
+ if (!payload.iat || Math.abs(now - payload.iat) > DPOP_MAX_AGE) {
158
+ return { thumbprint: null, error: 'DPoP proof expired or invalid iat' };
159
+ }
160
+
161
+ // jti: Unique identifier (we should track these to prevent replay, but skip for now)
162
+ if (!payload.jti) {
163
+ return { thumbprint: null, error: 'DPoP proof missing jti' };
164
+ }
165
+
166
+ // ath: Access token hash (optional but recommended)
167
+ if (payload.ath) {
168
+ const expectedAth = await calculateAth(accessToken);
169
+ if (payload.ath !== expectedAth) {
170
+ return { thumbprint: null, error: 'DPoP ath mismatch' };
171
+ }
172
+ }
173
+
174
+ // Calculate JWK thumbprint for binding verification
175
+ const thumbprint = await jose.calculateJwkThumbprint(protectedHeader.jwk, 'sha256');
176
+
177
+ return { thumbprint, error: null };
178
+
179
+ } catch (err) {
180
+ console.error('DPoP verification error:', err.message);
181
+ return { thumbprint: null, error: 'Invalid DPoP proof' };
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Calculate access token hash (ath) for DPoP binding
187
+ */
188
+ async function calculateAth(accessToken) {
189
+ const encoder = new TextEncoder();
190
+ const data = encoder.encode(accessToken);
191
+ const hash = await crypto.subtle.digest('SHA-256', data);
192
+ return jose.base64url.encode(new Uint8Array(hash));
193
+ }
194
+
195
+ /**
196
+ * Fetch and cache OIDC configuration
197
+ */
198
+ async function getOidcConfig(issuer) {
199
+ const cached = oidcConfigCache.get(issuer);
200
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
201
+ return cached.config;
202
+ }
203
+
204
+ const configUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
205
+
206
+ try {
207
+ const response = await fetch(configUrl);
208
+ if (!response.ok) {
209
+ throw new Error(`Failed to fetch OIDC config: ${response.status}`);
210
+ }
211
+
212
+ const config = await response.json();
213
+ oidcConfigCache.set(issuer, { config, timestamp: Date.now() });
214
+ return config;
215
+
216
+ } catch (err) {
217
+ console.error(`Failed to fetch OIDC config from ${issuer}:`, err.message);
218
+ throw err;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Get JWKS for an issuer (with caching)
224
+ */
225
+ async function getJwks(issuer) {
226
+ const cached = jwksCache.get(issuer);
227
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
228
+ return cached.jwks;
229
+ }
230
+
231
+ // Get OIDC config to find JWKS URI
232
+ const config = await getOidcConfig(issuer);
233
+ const jwksUri = config.jwks_uri;
234
+
235
+ if (!jwksUri) {
236
+ throw new Error('OIDC config missing jwks_uri');
237
+ }
238
+
239
+ // Create a remote JWKS set
240
+ const jwks = jose.createRemoteJWKSet(new URL(jwksUri));
241
+
242
+ jwksCache.set(issuer, { jwks, timestamp: Date.now() });
243
+ return jwks;
244
+ }
245
+
246
+ /**
247
+ * Clear caches (useful for testing)
248
+ */
249
+ export function clearCaches() {
250
+ oidcConfigCache.clear();
251
+ jwksCache.clear();
252
+ }
253
+
254
+ /**
255
+ * Check if request has Solid-OIDC authorization
256
+ */
257
+ export function hasSolidOidcAuth(request) {
258
+ const authHeader = request.headers.authorization;
259
+ return authHeader && authHeader.startsWith('DPoP ');
260
+ }
package/src/auth/token.js CHANGED
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Simple token-based authentication
2
+ * Token-based authentication
3
3
  *
4
- * For now, we use a simple JWT-like approach:
5
- * - Token format: base64(JSON({webId, iat, exp}))
6
- * - In production, this would be replaced with proper Solid-OIDC DPoP tokens
4
+ * Supports two modes:
5
+ * 1. Simple tokens (for local/dev use): base64(JSON({webId, iat, exp})) + HMAC signature
6
+ * 2. Solid-OIDC DPoP tokens (for federation): verified via external IdP JWKS
7
7
  */
8
8
 
9
9
  import crypto from 'crypto';
10
+ import { verifySolidOidc, hasSolidOidcAuth } from './solid-oidc.js';
10
11
 
11
12
  // Secret for signing tokens (in production, use env var)
12
13
  const SECRET = process.env.TOKEN_SECRET || 'dev-secret-change-in-production';
@@ -95,12 +96,18 @@ export function extractToken(authHeader) {
95
96
  }
96
97
 
97
98
  /**
98
- * Extract WebID from request
99
+ * Extract WebID from request (sync version for simple tokens only)
99
100
  * @param {object} request - Fastify request object
100
101
  * @returns {string | null} WebID or null if not authenticated
101
102
  */
102
103
  export function getWebIdFromRequest(request) {
103
104
  const authHeader = request.headers.authorization;
105
+
106
+ // Skip DPoP tokens - use async version for those
107
+ if (authHeader && authHeader.startsWith('DPoP ')) {
108
+ return null;
109
+ }
110
+
104
111
  const token = extractToken(authHeader);
105
112
 
106
113
  if (!token) {
@@ -110,3 +117,34 @@ export function getWebIdFromRequest(request) {
110
117
  const payload = verifyToken(token);
111
118
  return payload?.webId || null;
112
119
  }
120
+
121
+ /**
122
+ * Extract WebID from request (async version supporting Solid-OIDC)
123
+ * @param {object} request - Fastify request object
124
+ * @returns {Promise<{webId: string|null, error: string|null}>}
125
+ */
126
+ export async function getWebIdFromRequestAsync(request) {
127
+ const authHeader = request.headers.authorization;
128
+
129
+ if (!authHeader) {
130
+ return { webId: null, error: null };
131
+ }
132
+
133
+ // Try Solid-OIDC first (DPoP tokens)
134
+ if (hasSolidOidcAuth(request)) {
135
+ return verifySolidOidc(request);
136
+ }
137
+
138
+ // Fall back to simple Bearer tokens
139
+ const token = extractToken(authHeader);
140
+ if (!token) {
141
+ return { webId: null, error: null };
142
+ }
143
+
144
+ const payload = verifyToken(token);
145
+ if (payload?.webId) {
146
+ return { webId: payload.webId, error: null };
147
+ }
148
+
149
+ return { webId: null, error: 'Invalid token' };
150
+ }
@@ -2,6 +2,7 @@ import * as storage from '../storage/filesystem.js';
2
2
  import { getAllHeaders } from '../ldp/headers.js';
3
3
  import { generateContainerJsonLd, serializeJsonLd } from '../ldp/container.js';
4
4
  import { isContainer, getContentType, isRdfContentType } from '../utils/url.js';
5
+ import { parseN3Patch, applyN3Patch, validatePatch } from '../patch/n3-patch.js';
5
6
 
6
7
  /**
7
8
  * Handle GET request
@@ -190,3 +191,99 @@ export async function handleOptions(request, reply) {
190
191
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
191
192
  return reply.code(204).send();
192
193
  }
194
+
195
+ /**
196
+ * Handle PATCH request
197
+ * Supports N3 Patch format (text/n3) for updating RDF resources
198
+ */
199
+ export async function handlePatch(request, reply) {
200
+ const urlPath = request.url.split('?')[0];
201
+
202
+ // Don't allow PATCH to containers
203
+ if (isContainer(urlPath)) {
204
+ return reply.code(409).send({ error: 'Cannot PATCH containers' });
205
+ }
206
+
207
+ // Check content type
208
+ const contentType = request.headers['content-type'] || '';
209
+ const isN3Patch = contentType.includes('text/n3') ||
210
+ contentType.includes('application/n3') ||
211
+ contentType.includes('application/sparql-update');
212
+
213
+ if (!isN3Patch) {
214
+ return reply.code(415).send({
215
+ error: 'Unsupported Media Type',
216
+ message: 'PATCH requires Content-Type: text/n3 for N3 Patch format'
217
+ });
218
+ }
219
+
220
+ // Check if resource exists
221
+ const stats = await storage.stat(urlPath);
222
+ if (!stats) {
223
+ return reply.code(404).send({ error: 'Not Found' });
224
+ }
225
+
226
+ // Read existing content
227
+ const existingContent = await storage.read(urlPath);
228
+ if (existingContent === null) {
229
+ return reply.code(500).send({ error: 'Read error' });
230
+ }
231
+
232
+ // Parse existing document as JSON-LD
233
+ let document;
234
+ try {
235
+ document = JSON.parse(existingContent.toString());
236
+ } catch (e) {
237
+ return reply.code(409).send({
238
+ error: 'Conflict',
239
+ message: 'Resource is not valid JSON-LD and cannot be patched'
240
+ });
241
+ }
242
+
243
+ // Parse the patch
244
+ const patchContent = Buffer.isBuffer(request.body)
245
+ ? request.body.toString()
246
+ : request.body;
247
+
248
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
249
+ let patch;
250
+ try {
251
+ patch = parseN3Patch(patchContent, resourceUrl);
252
+ } catch (e) {
253
+ return reply.code(400).send({
254
+ error: 'Bad Request',
255
+ message: 'Invalid N3 Patch format: ' + e.message
256
+ });
257
+ }
258
+
259
+ // Validate that deletes exist (optional strict mode)
260
+ // const validation = validatePatch(document, patch, resourceUrl);
261
+ // if (!validation.valid) {
262
+ // return reply.code(409).send({ error: 'Conflict', message: validation.error });
263
+ // }
264
+
265
+ // Apply the patch
266
+ let updatedDocument;
267
+ try {
268
+ updatedDocument = applyN3Patch(document, patch, resourceUrl);
269
+ } catch (e) {
270
+ return reply.code(409).send({
271
+ error: 'Conflict',
272
+ message: 'Failed to apply patch: ' + e.message
273
+ });
274
+ }
275
+
276
+ // Write updated document
277
+ const updatedContent = JSON.stringify(updatedDocument, null, 2);
278
+ const success = await storage.write(urlPath, Buffer.from(updatedContent));
279
+
280
+ if (!success) {
281
+ return reply.code(500).send({ error: 'Write failed' });
282
+ }
283
+
284
+ const origin = request.headers.origin;
285
+ const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
286
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
287
+
288
+ return reply.code(204).send();
289
+ }