javascript-solid-server 0.0.3 → 0.0.5

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.3",
3
+ "version": "0.0.5",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Authorization middleware
3
+ * Combines authentication (token verification) with WAC checking
4
+ */
5
+
6
+ import { getWebIdFromRequest } from './token.js';
7
+ import { checkAccess, getRequiredMode } from '../wac/checker.js';
8
+ import * as storage from '../storage/filesystem.js';
9
+
10
+ /**
11
+ * Check if request is authorized
12
+ * @param {object} request - Fastify request
13
+ * @param {object} reply - Fastify reply
14
+ * @returns {Promise<{authorized: boolean, webId: string|null, wacAllow: string}>}
15
+ */
16
+ export async function authorize(request, reply) {
17
+ const urlPath = request.url.split('?')[0];
18
+ const method = request.method;
19
+
20
+ // Skip auth for .acl files (they need special handling)
21
+ // and for OPTIONS (CORS preflight)
22
+ if (urlPath.endsWith('.acl') || method === 'OPTIONS') {
23
+ return { authorized: true, webId: null, wacAllow: 'user="read write append control", public="read write append"' };
24
+ }
25
+
26
+ // Get WebID from token (null if not authenticated)
27
+ const webId = getWebIdFromRequest(request);
28
+
29
+ // Get resource info
30
+ const stats = await storage.stat(urlPath);
31
+ const resourceExists = stats !== null;
32
+ const isContainer = stats?.isDirectory || urlPath.endsWith('/');
33
+
34
+ // Build resource URL
35
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
36
+
37
+ // Get required access mode for this method
38
+ const requiredMode = getRequiredMode(method);
39
+
40
+ // For write operations on non-existent resources, check parent container
41
+ let checkPath = urlPath;
42
+ let checkUrl = resourceUrl;
43
+ let checkIsContainer = isContainer;
44
+
45
+ if (!resourceExists && (method === 'PUT' || method === 'POST' || method === 'PATCH')) {
46
+ // Check write permission on parent container
47
+ const parentPath = getParentPath(urlPath);
48
+ checkPath = parentPath;
49
+ checkUrl = `${request.protocol}://${request.hostname}${parentPath}`;
50
+ checkIsContainer = true;
51
+ }
52
+
53
+ // Check WAC permissions
54
+ const { allowed, wacAllow } = await checkAccess({
55
+ resourceUrl: checkUrl,
56
+ resourcePath: checkPath,
57
+ isContainer: checkIsContainer,
58
+ agentWebId: webId,
59
+ requiredMode
60
+ });
61
+
62
+ return { authorized: allowed, webId, wacAllow };
63
+ }
64
+
65
+ /**
66
+ * Get parent container path
67
+ */
68
+ function getParentPath(path) {
69
+ const normalized = path.endsWith('/') ? path.slice(0, -1) : path;
70
+ const lastSlash = normalized.lastIndexOf('/');
71
+ if (lastSlash <= 0) return '/';
72
+ return normalized.substring(0, lastSlash + 1);
73
+ }
74
+
75
+ /**
76
+ * Handle unauthorized request
77
+ * @param {object} reply - Fastify reply
78
+ * @param {boolean} isAuthenticated - Whether user is authenticated
79
+ * @param {string} wacAllow - WAC-Allow header value
80
+ */
81
+ export function handleUnauthorized(reply, isAuthenticated, wacAllow) {
82
+ reply.header('WAC-Allow', wacAllow);
83
+
84
+ if (!isAuthenticated) {
85
+ // Not authenticated - return 401
86
+ return reply.code(401).send({
87
+ error: 'Unauthorized',
88
+ message: 'Authentication required'
89
+ });
90
+ } else {
91
+ // Authenticated but not authorized - return 403
92
+ return reply.code(403).send({
93
+ error: 'Forbidden',
94
+ message: 'Access denied'
95
+ });
96
+ }
97
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Simple token-based authentication
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
7
+ */
8
+
9
+ import crypto from 'crypto';
10
+
11
+ // Secret for signing tokens (in production, use env var)
12
+ const SECRET = process.env.TOKEN_SECRET || 'dev-secret-change-in-production';
13
+
14
+ /**
15
+ * Create a simple token for a WebID
16
+ * @param {string} webId - The WebID to create token for
17
+ * @param {number} expiresIn - Expiration time in seconds (default 1 hour)
18
+ * @returns {string} Token string
19
+ */
20
+ export function createToken(webId, expiresIn = 3600) {
21
+ const payload = {
22
+ webId,
23
+ iat: Math.floor(Date.now() / 1000),
24
+ exp: Math.floor(Date.now() / 1000) + expiresIn
25
+ };
26
+
27
+ const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
28
+ const signature = crypto
29
+ .createHmac('sha256', SECRET)
30
+ .update(data)
31
+ .digest('base64url');
32
+
33
+ return `${data}.${signature}`;
34
+ }
35
+
36
+ /**
37
+ * Verify and decode a token
38
+ * @param {string} token - The token to verify
39
+ * @returns {{webId: string, iat: number, exp: number} | null} Decoded payload or null
40
+ */
41
+ export function verifyToken(token) {
42
+ if (!token || typeof token !== 'string') {
43
+ return null;
44
+ }
45
+
46
+ const parts = token.split('.');
47
+ if (parts.length !== 2) {
48
+ return null;
49
+ }
50
+
51
+ const [data, signature] = parts;
52
+
53
+ // Verify signature
54
+ const expectedSig = crypto
55
+ .createHmac('sha256', SECRET)
56
+ .update(data)
57
+ .digest('base64url');
58
+
59
+ if (signature !== expectedSig) {
60
+ return null;
61
+ }
62
+
63
+ // Decode payload
64
+ try {
65
+ const payload = JSON.parse(Buffer.from(data, 'base64url').toString());
66
+
67
+ // Check expiration
68
+ if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
69
+ return null;
70
+ }
71
+
72
+ return payload;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Extract token from Authorization header
80
+ * @param {string} authHeader - Authorization header value
81
+ * @returns {string | null} Token or null
82
+ */
83
+ export function extractToken(authHeader) {
84
+ if (!authHeader || typeof authHeader !== 'string') {
85
+ return null;
86
+ }
87
+
88
+ // Support "Bearer <token>" format
89
+ if (authHeader.startsWith('Bearer ')) {
90
+ return authHeader.slice(7);
91
+ }
92
+
93
+ // Also support raw token
94
+ return authHeader;
95
+ }
96
+
97
+ /**
98
+ * Extract WebID from request
99
+ * @param {object} request - Fastify request object
100
+ * @returns {string | null} WebID or null if not authenticated
101
+ */
102
+ export function getWebIdFromRequest(request) {
103
+ const authHeader = request.headers.authorization;
104
+ const token = extractToken(authHeader);
105
+
106
+ if (!token) {
107
+ return null;
108
+ }
109
+
110
+ const payload = verifyToken(token);
111
+ return payload?.webId || null;
112
+ }
@@ -2,9 +2,8 @@ import * as storage from '../storage/filesystem.js';
2
2
  import { getAllHeaders } from '../ldp/headers.js';
3
3
  import { isContainer } from '../utils/url.js';
4
4
  import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js';
5
-
6
- // Content type for profile card
7
- const PROFILE_CONTENT_TYPE = 'text/html';
5
+ import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, serializeAcl } from '../wac/parser.js';
6
+ import { createToken } from '../auth/token.js';
8
7
 
9
8
  /**
10
9
  * Handle POST request to container (create new resource)
@@ -133,6 +132,23 @@ export async function handleCreatePod(request, reply) {
133
132
  const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex`);
134
133
  await storage.write(`${podPath}settings/privateTypeIndex`, serialize(privateTypeIndex));
135
134
 
135
+ // Create default ACL files
136
+ // Pod root: owner full control, public read
137
+ const rootAcl = generateOwnerAcl(podUri, webId, true);
138
+ await storage.write(`${podPath}.acl`, serializeAcl(rootAcl));
139
+
140
+ // Private folder: owner only (no public)
141
+ const privateAcl = generatePrivateAcl(`${podUri}private/`, webId);
142
+ await storage.write(`${podPath}private/.acl`, serializeAcl(privateAcl));
143
+
144
+ // Settings folder: owner only
145
+ const settingsAcl = generatePrivateAcl(`${podUri}settings/`, webId);
146
+ await storage.write(`${podPath}settings/.acl`, serializeAcl(settingsAcl));
147
+
148
+ // Inbox: owner full, public append
149
+ const inboxAcl = generateInboxAcl(`${podUri}inbox/`, webId);
150
+ await storage.write(`${podPath}inbox/.acl`, serializeAcl(inboxAcl));
151
+
136
152
  } catch (err) {
137
153
  console.error('Pod creation error:', err);
138
154
  // Cleanup on failure
@@ -146,9 +162,13 @@ export async function handleCreatePod(request, reply) {
146
162
 
147
163
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
148
164
 
165
+ // Generate token for the pod owner
166
+ const token = createToken(webId);
167
+
149
168
  return reply.code(201).send({
150
169
  name,
151
170
  webId,
152
- podUri
171
+ podUri,
172
+ token
153
173
  });
154
174
  }
@@ -15,6 +15,7 @@ export async function handleGet(request, reply) {
15
15
  }
16
16
 
17
17
  const origin = request.headers.origin;
18
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
18
19
 
19
20
  // Handle container
20
21
  if (stats.isDirectory) {
@@ -31,7 +32,8 @@ export async function handleGet(request, reply) {
31
32
  isContainer: true,
32
33
  etag: indexStats?.etag || stats.etag,
33
34
  contentType: 'text/html',
34
- origin
35
+ origin,
36
+ resourceUrl
35
37
  });
36
38
 
37
39
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
@@ -40,14 +42,14 @@ export async function handleGet(request, reply) {
40
42
 
41
43
  // No index.html, return JSON-LD container listing
42
44
  const entries = await storage.listContainer(urlPath);
43
- const baseUrl = `${request.protocol}://${request.hostname}${urlPath}`;
44
- const jsonLd = generateContainerJsonLd(baseUrl, entries || []);
45
+ const jsonLd = generateContainerJsonLd(resourceUrl, entries || []);
45
46
 
46
47
  const headers = getAllHeaders({
47
48
  isContainer: true,
48
49
  etag: stats.etag,
49
50
  contentType: 'application/ld+json',
50
- origin
51
+ origin,
52
+ resourceUrl
51
53
  });
52
54
 
53
55
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
@@ -65,7 +67,8 @@ export async function handleGet(request, reply) {
65
67
  isContainer: false,
66
68
  etag: stats.etag,
67
69
  contentType,
68
- origin
70
+ origin,
71
+ resourceUrl
69
72
  });
70
73
 
71
74
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
@@ -84,13 +87,15 @@ export async function handleHead(request, reply) {
84
87
  }
85
88
 
86
89
  const origin = request.headers.origin;
90
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
87
91
  const contentType = stats.isDirectory ? 'application/ld+json' : getContentType(urlPath);
88
92
 
89
93
  const headers = getAllHeaders({
90
94
  isContainer: stats.isDirectory,
91
95
  etag: stats.etag,
92
96
  contentType,
93
- origin
97
+ origin,
98
+ resourceUrl
94
99
  });
95
100
 
96
101
  if (!stats.isDirectory) {
@@ -135,8 +140,9 @@ export async function handlePut(request, reply) {
135
140
  }
136
141
 
137
142
  const origin = request.headers.origin;
138
- const headers = getAllHeaders({ isContainer: false, origin });
139
- headers['Location'] = `${request.protocol}://${request.hostname}${urlPath}`;
143
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
144
+ const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
145
+ headers['Location'] = resourceUrl;
140
146
 
141
147
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
142
148
  return reply.code(existed ? 204 : 201).send();
@@ -159,7 +165,8 @@ export async function handleDelete(request, reply) {
159
165
  }
160
166
 
161
167
  const origin = request.headers.origin;
162
- const headers = getAllHeaders({ isContainer: false, origin });
168
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
169
+ const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
163
170
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
164
171
 
165
172
  return reply.code(204).send();
@@ -173,9 +180,11 @@ export async function handleOptions(request, reply) {
173
180
  const stats = await storage.stat(urlPath);
174
181
 
175
182
  const origin = request.headers.origin;
183
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
176
184
  const headers = getAllHeaders({
177
185
  isContainer: stats?.isDirectory || isContainer(urlPath),
178
- origin
186
+ origin,
187
+ resourceUrl
179
188
  });
180
189
 
181
190
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
@@ -7,9 +7,10 @@ const LDP = 'http://www.w3.org/ns/ldp#';
7
7
  /**
8
8
  * Get Link headers for a resource
9
9
  * @param {boolean} isContainer
10
+ * @param {string} aclUrl - URL to the ACL resource
10
11
  * @returns {string}
11
12
  */
12
- export function getLinkHeader(isContainer) {
13
+ export function getLinkHeader(isContainer, aclUrl = null) {
13
14
  const links = [`<${LDP}Resource>; rel="type"`];
14
15
 
15
16
  if (isContainer) {
@@ -17,18 +18,42 @@ export function getLinkHeader(isContainer) {
17
18
  links.push(`<${LDP}BasicContainer>; rel="type"`);
18
19
  }
19
20
 
21
+ // Add acl link for auxiliary resource discovery
22
+ if (aclUrl) {
23
+ links.push(`<${aclUrl}>; rel="acl"`);
24
+ }
25
+
20
26
  return links.join(', ');
21
27
  }
22
28
 
29
+ /**
30
+ * Get the ACL URL for a resource
31
+ * @param {string} resourceUrl - Full URL of the resource
32
+ * @param {boolean} isContainer - Whether the resource is a container
33
+ * @returns {string} ACL URL
34
+ */
35
+ export function getAclUrl(resourceUrl, isContainer) {
36
+ if (isContainer) {
37
+ // Container ACL: /path/.acl
38
+ const base = resourceUrl.endsWith('/') ? resourceUrl : resourceUrl + '/';
39
+ return base + '.acl';
40
+ }
41
+ // Resource ACL: /path/file.acl
42
+ return resourceUrl + '.acl';
43
+ }
44
+
23
45
  /**
24
46
  * Get standard LDP response headers
25
47
  * @param {object} options
26
48
  * @returns {object}
27
49
  */
28
- export function getResponseHeaders({ isContainer = false, etag = null, contentType = null }) {
50
+ export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null }) {
51
+ // Calculate ACL URL if resource URL provided
52
+ const aclUrl = resourceUrl ? getAclUrl(resourceUrl, isContainer) : null;
53
+
29
54
  const headers = {
30
- 'Link': getLinkHeader(isContainer),
31
- 'WAC-Allow': 'user="read write append control", public="read write append"',
55
+ 'Link': getLinkHeader(isContainer, aclUrl),
56
+ 'WAC-Allow': wacAllow || 'user="read write append control", public="read write append"',
32
57
  'Accept-Patch': 'application/sparql-update',
33
58
  'Allow': 'GET, HEAD, PUT, DELETE, OPTIONS' + (isContainer ? ', POST' : ''),
34
59
  'Vary': 'Accept, Authorization, Origin'
@@ -70,9 +95,9 @@ export function getCorsHeaders(origin) {
70
95
  * @param {object} options
71
96
  * @returns {object}
72
97
  */
73
- export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null }) {
98
+ export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null }) {
74
99
  return {
75
- ...getResponseHeaders({ isContainer, etag, contentType }),
100
+ ...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow }),
76
101
  ...getCorsHeaders(origin)
77
102
  };
78
103
  }
package/src/server.js CHANGED
@@ -2,6 +2,7 @@ import Fastify from 'fastify';
2
2
  import { handleGet, handleHead, handlePut, handleDelete, handleOptions } from './handlers/resource.js';
3
3
  import { handlePost, handleCreatePod } from './handlers/container.js';
4
4
  import { getCorsHeaders } from './ldp/headers.js';
5
+ import { authorize, handleUnauthorized } from './auth/middleware.js';
5
6
 
6
7
  /**
7
8
  * Create and configure Fastify server
@@ -34,6 +35,25 @@ export function createServer(options = {}) {
34
35
  }
35
36
  });
36
37
 
38
+ // Authorization hook - check WAC permissions
39
+ // Skip for pod creation endpoint (needs special handling)
40
+ fastify.addHook('preHandler', async (request, reply) => {
41
+ // Skip auth for pod creation and OPTIONS
42
+ if (request.url === '/.pods' || request.method === 'OPTIONS') {
43
+ return;
44
+ }
45
+
46
+ const { authorized, webId, wacAllow } = await authorize(request, reply);
47
+
48
+ // Store webId and wacAllow on request for handlers to use
49
+ request.webId = webId;
50
+ request.wacAllow = wacAllow;
51
+
52
+ if (!authorized) {
53
+ return handleUnauthorized(reply, webId !== null, wacAllow);
54
+ }
55
+ });
56
+
37
57
  // Pod creation endpoint
38
58
  fastify.post('/.pods', handleCreatePod);
39
59