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 +1 -1
- package/src/auth/middleware.js +97 -0
- package/src/auth/token.js +112 -0
- package/src/handlers/container.js +24 -4
- package/src/handlers/resource.js +19 -10
- package/src/ldp/headers.js +31 -6
- package/src/server.js +20 -0
- package/src/wac/checker.js +257 -0
- package/src/wac/parser.js +284 -0
- package/test/auth.test.js +175 -0
- package/test/helpers.js +38 -4
- package/test/ldp.test.js +61 -20
- package/test/pod.test.js +16 -23
- package/test/wac.test.js +189 -0
- package/benchmark-report-2025-03-31T14-25-24.234Z.json +0 -44
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAC (Web Access Control) Checker
|
|
3
|
+
* Checks if an agent has permission to access a resource
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as storage from '../storage/filesystem.js';
|
|
7
|
+
import { parseAcl, AccessMode, AgentClass } from './parser.js';
|
|
8
|
+
import { getAclUrl } from '../ldp/headers.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if agent has required access mode for resource
|
|
12
|
+
* @param {object} options
|
|
13
|
+
* @param {string} options.resourceUrl - Full URL of the resource
|
|
14
|
+
* @param {string} options.resourcePath - Path portion of the resource URL
|
|
15
|
+
* @param {boolean} options.isContainer - Whether resource is a container
|
|
16
|
+
* @param {string|null} options.agentWebId - WebID of the agent (null for unauthenticated)
|
|
17
|
+
* @param {string} options.requiredMode - Required access mode (from AccessMode)
|
|
18
|
+
* @returns {Promise<{allowed: boolean, wacAllow: string}>}
|
|
19
|
+
*/
|
|
20
|
+
export async function checkAccess({
|
|
21
|
+
resourceUrl,
|
|
22
|
+
resourcePath,
|
|
23
|
+
isContainer,
|
|
24
|
+
agentWebId,
|
|
25
|
+
requiredMode
|
|
26
|
+
}) {
|
|
27
|
+
// Find applicable ACL
|
|
28
|
+
const aclResult = await findApplicableAcl(resourceUrl, resourcePath, isContainer);
|
|
29
|
+
|
|
30
|
+
if (!aclResult) {
|
|
31
|
+
// No ACL found - allow by default (permissive mode)
|
|
32
|
+
// This allows resources without ACLs to be publicly accessible
|
|
33
|
+
return { allowed: true, wacAllow: 'user="read write append control", public="read write append"' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { authorizations, isDefault, targetUrl } = aclResult;
|
|
37
|
+
|
|
38
|
+
// Check authorizations
|
|
39
|
+
const allowed = checkAuthorizations(
|
|
40
|
+
authorizations,
|
|
41
|
+
targetUrl,
|
|
42
|
+
agentWebId,
|
|
43
|
+
requiredMode,
|
|
44
|
+
isDefault
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Calculate WAC-Allow header
|
|
48
|
+
const wacAllow = calculateWacAllow(authorizations, targetUrl, agentWebId, isDefault);
|
|
49
|
+
|
|
50
|
+
return { allowed, wacAllow };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Find the applicable ACL for a resource
|
|
55
|
+
* Walks up the path hierarchy looking for .acl files
|
|
56
|
+
*/
|
|
57
|
+
async function findApplicableAcl(resourceUrl, resourcePath, isContainer) {
|
|
58
|
+
// First check for resource-specific ACL
|
|
59
|
+
const resourceAclPath = isContainer
|
|
60
|
+
? (resourcePath.endsWith('/') ? resourcePath : resourcePath + '/') + '.acl'
|
|
61
|
+
: resourcePath + '.acl';
|
|
62
|
+
|
|
63
|
+
if (await storage.exists(resourceAclPath)) {
|
|
64
|
+
const content = await storage.read(resourceAclPath);
|
|
65
|
+
if (content) {
|
|
66
|
+
const aclUrl = getAclUrl(resourceUrl, isContainer);
|
|
67
|
+
const authorizations = parseAcl(content.toString(), aclUrl);
|
|
68
|
+
return { authorizations, isDefault: false, targetUrl: resourceUrl };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Walk up the hierarchy looking for default ACLs
|
|
73
|
+
let currentPath = resourcePath;
|
|
74
|
+
while (currentPath && currentPath !== '/') {
|
|
75
|
+
// Get parent container
|
|
76
|
+
const parentPath = getParentPath(currentPath);
|
|
77
|
+
const parentAclPath = parentPath + '.acl';
|
|
78
|
+
|
|
79
|
+
if (await storage.exists(parentAclPath)) {
|
|
80
|
+
const content = await storage.read(parentAclPath);
|
|
81
|
+
if (content) {
|
|
82
|
+
const parentUrl = resourceUrl.substring(0, resourceUrl.lastIndexOf(currentPath)) + parentPath;
|
|
83
|
+
const authorizations = parseAcl(content.toString(), parentAclPath);
|
|
84
|
+
return { authorizations, isDefault: true, targetUrl: parentUrl };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
currentPath = parentPath;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check root ACL
|
|
92
|
+
if (await storage.exists('/.acl')) {
|
|
93
|
+
const content = await storage.read('/.acl');
|
|
94
|
+
if (content) {
|
|
95
|
+
const rootUrl = resourceUrl.substring(0, resourceUrl.indexOf('/', 8) + 1);
|
|
96
|
+
const authorizations = parseAcl(content.toString(), '/.acl');
|
|
97
|
+
return { authorizations, isDefault: true, targetUrl: rootUrl };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get parent container path
|
|
106
|
+
*/
|
|
107
|
+
function getParentPath(path) {
|
|
108
|
+
// Remove trailing slash
|
|
109
|
+
const normalized = path.endsWith('/') ? path.slice(0, -1) : path;
|
|
110
|
+
const lastSlash = normalized.lastIndexOf('/');
|
|
111
|
+
if (lastSlash <= 0) return '/';
|
|
112
|
+
return normalized.substring(0, lastSlash + 1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Check if any authorization grants the required mode
|
|
117
|
+
*/
|
|
118
|
+
function checkAuthorizations(authorizations, targetUrl, agentWebId, requiredMode, isDefault) {
|
|
119
|
+
for (const auth of authorizations) {
|
|
120
|
+
// Check if this authorization applies to the resource
|
|
121
|
+
const appliesToResource = isDefault
|
|
122
|
+
? auth.default.some(d => urlMatches(d, targetUrl))
|
|
123
|
+
: auth.accessTo.some(a => urlMatches(a, targetUrl));
|
|
124
|
+
|
|
125
|
+
if (!appliesToResource && !isDefault) continue;
|
|
126
|
+
if (isDefault && auth.default.length === 0) continue;
|
|
127
|
+
|
|
128
|
+
// Check if agent is authorized
|
|
129
|
+
const agentAuthorized = isAgentAuthorized(auth, agentWebId);
|
|
130
|
+
if (!agentAuthorized) continue;
|
|
131
|
+
|
|
132
|
+
// Check if mode is granted
|
|
133
|
+
if (auth.modes.includes(requiredMode)) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Write implies Append
|
|
138
|
+
if (requiredMode === AccessMode.APPEND && auth.modes.includes(AccessMode.WRITE)) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Check if the agent is authorized by an authorization rule
|
|
148
|
+
*/
|
|
149
|
+
function isAgentAuthorized(auth, agentWebId) {
|
|
150
|
+
// Check specific agent
|
|
151
|
+
if (agentWebId && auth.agents.includes(agentWebId)) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Check agent classes
|
|
156
|
+
for (const agentClass of auth.agentClasses) {
|
|
157
|
+
// foaf:Agent - everyone (including unauthenticated)
|
|
158
|
+
if (agentClass === AgentClass.AGENT || agentClass === 'foaf:Agent') {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// acl:AuthenticatedAgent - any authenticated user
|
|
163
|
+
if (agentWebId && (agentClass === AgentClass.AUTHENTICATED || agentClass === 'acl:AuthenticatedAgent')) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// TODO: Check agent groups (requires fetching and parsing group documents)
|
|
169
|
+
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Check if URLs match (handles trailing slashes)
|
|
175
|
+
*/
|
|
176
|
+
function urlMatches(pattern, url) {
|
|
177
|
+
const normalizedPattern = pattern.replace(/\/$/, '');
|
|
178
|
+
const normalizedUrl = url.replace(/\/$/, '');
|
|
179
|
+
return normalizedPattern === normalizedUrl;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Calculate WAC-Allow header value
|
|
184
|
+
*/
|
|
185
|
+
function calculateWacAllow(authorizations, targetUrl, agentWebId, isDefault) {
|
|
186
|
+
const userModes = new Set();
|
|
187
|
+
const publicModes = new Set();
|
|
188
|
+
|
|
189
|
+
for (const auth of authorizations) {
|
|
190
|
+
// Check if applies to resource
|
|
191
|
+
const applies = isDefault
|
|
192
|
+
? auth.default.length > 0
|
|
193
|
+
: auth.accessTo.some(a => urlMatches(a, targetUrl));
|
|
194
|
+
|
|
195
|
+
if (!applies && !isDefault) continue;
|
|
196
|
+
|
|
197
|
+
// Check what modes this grants
|
|
198
|
+
const modes = auth.modes.map(m => {
|
|
199
|
+
if (m === AccessMode.READ || m === 'acl:Read') return 'read';
|
|
200
|
+
if (m === AccessMode.WRITE || m === 'acl:Write') return 'write';
|
|
201
|
+
if (m === AccessMode.APPEND || m === 'acl:Append') return 'append';
|
|
202
|
+
if (m === AccessMode.CONTROL || m === 'acl:Control') return 'control';
|
|
203
|
+
return null;
|
|
204
|
+
}).filter(Boolean);
|
|
205
|
+
|
|
206
|
+
// Check if public
|
|
207
|
+
const isPublic = auth.agentClasses.some(c =>
|
|
208
|
+
c === AgentClass.AGENT || c === 'foaf:Agent'
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
if (isPublic) {
|
|
212
|
+
modes.forEach(m => publicModes.add(m));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Check if user-specific
|
|
216
|
+
if (agentWebId && auth.agents.includes(agentWebId)) {
|
|
217
|
+
modes.forEach(m => userModes.add(m));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check authenticated class
|
|
221
|
+
if (agentWebId && auth.agentClasses.some(c =>
|
|
222
|
+
c === AgentClass.AUTHENTICATED || c === 'acl:AuthenticatedAgent'
|
|
223
|
+
)) {
|
|
224
|
+
modes.forEach(m => userModes.add(m));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// User also gets public modes
|
|
229
|
+
publicModes.forEach(m => userModes.add(m));
|
|
230
|
+
|
|
231
|
+
const userStr = Array.from(userModes).join(' ');
|
|
232
|
+
const publicStr = Array.from(publicModes).join(' ');
|
|
233
|
+
|
|
234
|
+
return `user="${userStr}", public="${publicStr}"`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get the required access mode for an HTTP method
|
|
239
|
+
* @param {string} method - HTTP method
|
|
240
|
+
* @returns {string} Access mode
|
|
241
|
+
*/
|
|
242
|
+
export function getRequiredMode(method) {
|
|
243
|
+
switch (method.toUpperCase()) {
|
|
244
|
+
case 'GET':
|
|
245
|
+
case 'HEAD':
|
|
246
|
+
case 'OPTIONS':
|
|
247
|
+
return AccessMode.READ;
|
|
248
|
+
case 'POST':
|
|
249
|
+
return AccessMode.APPEND;
|
|
250
|
+
case 'PUT':
|
|
251
|
+
case 'PATCH':
|
|
252
|
+
case 'DELETE':
|
|
253
|
+
return AccessMode.WRITE;
|
|
254
|
+
default:
|
|
255
|
+
return AccessMode.READ;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAC (Web Access Control) Parser
|
|
3
|
+
* Parses JSON-LD .acl files into authorization rules
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const ACL = 'http://www.w3.org/ns/auth/acl#';
|
|
7
|
+
const FOAF = 'http://xmlns.com/foaf/0.1/';
|
|
8
|
+
|
|
9
|
+
// Access modes
|
|
10
|
+
export const AccessMode = {
|
|
11
|
+
READ: `${ACL}Read`,
|
|
12
|
+
WRITE: `${ACL}Write`,
|
|
13
|
+
APPEND: `${ACL}Append`,
|
|
14
|
+
CONTROL: `${ACL}Control`
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Agent classes
|
|
18
|
+
export const AgentClass = {
|
|
19
|
+
AGENT: `${FOAF}Agent`, // Everyone (public)
|
|
20
|
+
AUTHENTICATED: `${ACL}AuthenticatedAgent` // Any authenticated user
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse a JSON-LD ACL document
|
|
25
|
+
* @param {string|object} content - JSON-LD content (string or parsed object)
|
|
26
|
+
* @param {string} aclUrl - URL of the ACL document
|
|
27
|
+
* @returns {Array<Authorization>} List of authorization rules
|
|
28
|
+
*/
|
|
29
|
+
export function parseAcl(content, aclUrl) {
|
|
30
|
+
let doc;
|
|
31
|
+
try {
|
|
32
|
+
doc = typeof content === 'string' ? JSON.parse(content) : content;
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const authorizations = [];
|
|
38
|
+
|
|
39
|
+
// Handle @graph array or single object
|
|
40
|
+
const nodes = doc['@graph'] || [doc];
|
|
41
|
+
|
|
42
|
+
for (const node of nodes) {
|
|
43
|
+
if (isAuthorization(node)) {
|
|
44
|
+
const auth = parseAuthorization(node, aclUrl);
|
|
45
|
+
if (auth) {
|
|
46
|
+
authorizations.push(auth);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return authorizations;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if node is an Authorization
|
|
56
|
+
*/
|
|
57
|
+
function isAuthorization(node) {
|
|
58
|
+
const type = node['@type'];
|
|
59
|
+
if (!type) return false;
|
|
60
|
+
|
|
61
|
+
const types = Array.isArray(type) ? type : [type];
|
|
62
|
+
return types.some(t =>
|
|
63
|
+
t === 'acl:Authorization' ||
|
|
64
|
+
t === `${ACL}Authorization` ||
|
|
65
|
+
t === 'Authorization'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse a single Authorization node
|
|
71
|
+
*/
|
|
72
|
+
function parseAuthorization(node, aclUrl) {
|
|
73
|
+
const auth = {
|
|
74
|
+
id: node['@id'],
|
|
75
|
+
accessTo: [], // Resources this applies to
|
|
76
|
+
default: [], // Default for contained resources
|
|
77
|
+
agents: [], // Specific WebIDs
|
|
78
|
+
agentClasses: [], // Agent classes (public, authenticated)
|
|
79
|
+
agentGroups: [], // Groups
|
|
80
|
+
modes: [] // Access modes
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Parse accessTo
|
|
84
|
+
auth.accessTo = parseUriArray(node['acl:accessTo'] || node['accessTo']);
|
|
85
|
+
|
|
86
|
+
// Parse default (for containers)
|
|
87
|
+
auth.default = parseUriArray(node['acl:default'] || node['default']);
|
|
88
|
+
|
|
89
|
+
// Parse agents
|
|
90
|
+
auth.agents = parseUriArray(node['acl:agent'] || node['agent']);
|
|
91
|
+
|
|
92
|
+
// Parse agentClass
|
|
93
|
+
auth.agentClasses = parseUriArray(node['acl:agentClass'] || node['agentClass']);
|
|
94
|
+
|
|
95
|
+
// Parse agentGroup
|
|
96
|
+
auth.agentGroups = parseUriArray(node['acl:agentGroup'] || node['agentGroup']);
|
|
97
|
+
|
|
98
|
+
// Parse modes
|
|
99
|
+
auth.modes = parseUriArray(node['acl:mode'] || node['mode']).map(normalizeMode);
|
|
100
|
+
|
|
101
|
+
return auth;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse a value that could be a URI, @id object, or array of either
|
|
106
|
+
*/
|
|
107
|
+
function parseUriArray(value) {
|
|
108
|
+
if (!value) return [];
|
|
109
|
+
|
|
110
|
+
const values = Array.isArray(value) ? value : [value];
|
|
111
|
+
|
|
112
|
+
return values.map(v => {
|
|
113
|
+
if (typeof v === 'string') return v;
|
|
114
|
+
if (v && typeof v === 'object' && v['@id']) return v['@id'];
|
|
115
|
+
return null;
|
|
116
|
+
}).filter(Boolean);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Normalize mode URIs to full form
|
|
121
|
+
*/
|
|
122
|
+
function normalizeMode(mode) {
|
|
123
|
+
const modeMap = {
|
|
124
|
+
'Read': AccessMode.READ,
|
|
125
|
+
'Write': AccessMode.WRITE,
|
|
126
|
+
'Append': AccessMode.APPEND,
|
|
127
|
+
'Control': AccessMode.CONTROL,
|
|
128
|
+
'acl:Read': AccessMode.READ,
|
|
129
|
+
'acl:Write': AccessMode.WRITE,
|
|
130
|
+
'acl:Append': AccessMode.APPEND,
|
|
131
|
+
'acl:Control': AccessMode.CONTROL
|
|
132
|
+
};
|
|
133
|
+
return modeMap[mode] || mode;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Generate a default public read ACL
|
|
138
|
+
* @param {string} resourceUrl - URL of the resource
|
|
139
|
+
* @returns {object} JSON-LD ACL document
|
|
140
|
+
*/
|
|
141
|
+
export function generatePublicReadAcl(resourceUrl) {
|
|
142
|
+
return {
|
|
143
|
+
'@context': {
|
|
144
|
+
'acl': ACL,
|
|
145
|
+
'foaf': FOAF
|
|
146
|
+
},
|
|
147
|
+
'@graph': [
|
|
148
|
+
{
|
|
149
|
+
'@id': '#public',
|
|
150
|
+
'@type': 'acl:Authorization',
|
|
151
|
+
'acl:agentClass': { '@id': 'foaf:Agent' },
|
|
152
|
+
'acl:accessTo': { '@id': resourceUrl },
|
|
153
|
+
'acl:mode': [
|
|
154
|
+
{ '@id': 'acl:Read' }
|
|
155
|
+
]
|
|
156
|
+
}
|
|
157
|
+
]
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Generate a full owner ACL (owner has full control, public read)
|
|
163
|
+
* @param {string} resourceUrl - URL of the resource
|
|
164
|
+
* @param {string} ownerWebId - WebID of the owner
|
|
165
|
+
* @param {boolean} isContainer - Whether this is a container
|
|
166
|
+
* @returns {object} JSON-LD ACL document
|
|
167
|
+
*/
|
|
168
|
+
export function generateOwnerAcl(resourceUrl, ownerWebId, isContainer = false) {
|
|
169
|
+
const graph = [
|
|
170
|
+
{
|
|
171
|
+
'@id': '#owner',
|
|
172
|
+
'@type': 'acl:Authorization',
|
|
173
|
+
'acl:agent': { '@id': ownerWebId },
|
|
174
|
+
'acl:accessTo': { '@id': resourceUrl },
|
|
175
|
+
'acl:mode': [
|
|
176
|
+
{ '@id': 'acl:Read' },
|
|
177
|
+
{ '@id': 'acl:Write' },
|
|
178
|
+
{ '@id': 'acl:Control' }
|
|
179
|
+
]
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
'@id': '#public',
|
|
183
|
+
'@type': 'acl:Authorization',
|
|
184
|
+
'acl:agentClass': { '@id': 'foaf:Agent' },
|
|
185
|
+
'acl:accessTo': { '@id': resourceUrl },
|
|
186
|
+
'acl:mode': [
|
|
187
|
+
{ '@id': 'acl:Read' }
|
|
188
|
+
]
|
|
189
|
+
}
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
// Add default rules for containers
|
|
193
|
+
if (isContainer) {
|
|
194
|
+
graph[0]['acl:default'] = { '@id': resourceUrl };
|
|
195
|
+
graph[1]['acl:default'] = { '@id': resourceUrl };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
'@context': {
|
|
200
|
+
'acl': ACL,
|
|
201
|
+
'foaf': FOAF
|
|
202
|
+
},
|
|
203
|
+
'@graph': graph
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Generate a private ACL (owner only, no public access)
|
|
209
|
+
* @param {string} resourceUrl - URL of the resource
|
|
210
|
+
* @param {string} ownerWebId - WebID of the owner
|
|
211
|
+
* @param {boolean} isContainer - Whether this is a container
|
|
212
|
+
* @returns {object} JSON-LD ACL document
|
|
213
|
+
*/
|
|
214
|
+
export function generatePrivateAcl(resourceUrl, ownerWebId, isContainer = true) {
|
|
215
|
+
const auth = {
|
|
216
|
+
'@id': '#owner',
|
|
217
|
+
'@type': 'acl:Authorization',
|
|
218
|
+
'acl:agent': { '@id': ownerWebId },
|
|
219
|
+
'acl:accessTo': { '@id': resourceUrl },
|
|
220
|
+
'acl:mode': [
|
|
221
|
+
{ '@id': 'acl:Read' },
|
|
222
|
+
{ '@id': 'acl:Write' },
|
|
223
|
+
{ '@id': 'acl:Control' }
|
|
224
|
+
]
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
if (isContainer) {
|
|
228
|
+
auth['acl:default'] = { '@id': resourceUrl };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
'@context': {
|
|
233
|
+
'acl': ACL,
|
|
234
|
+
'foaf': FOAF
|
|
235
|
+
},
|
|
236
|
+
'@graph': [auth]
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Generate an inbox ACL (owner full control, public append)
|
|
242
|
+
* @param {string} resourceUrl - URL of the inbox
|
|
243
|
+
* @param {string} ownerWebId - WebID of the owner
|
|
244
|
+
* @returns {object} JSON-LD ACL document
|
|
245
|
+
*/
|
|
246
|
+
export function generateInboxAcl(resourceUrl, ownerWebId) {
|
|
247
|
+
return {
|
|
248
|
+
'@context': {
|
|
249
|
+
'acl': ACL,
|
|
250
|
+
'foaf': FOAF
|
|
251
|
+
},
|
|
252
|
+
'@graph': [
|
|
253
|
+
{
|
|
254
|
+
'@id': '#owner',
|
|
255
|
+
'@type': 'acl:Authorization',
|
|
256
|
+
'acl:agent': { '@id': ownerWebId },
|
|
257
|
+
'acl:accessTo': { '@id': resourceUrl },
|
|
258
|
+
'acl:default': { '@id': resourceUrl },
|
|
259
|
+
'acl:mode': [
|
|
260
|
+
{ '@id': 'acl:Read' },
|
|
261
|
+
{ '@id': 'acl:Write' },
|
|
262
|
+
{ '@id': 'acl:Control' }
|
|
263
|
+
]
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
'@id': '#public',
|
|
267
|
+
'@type': 'acl:Authorization',
|
|
268
|
+
'acl:agentClass': { '@id': 'foaf:Agent' },
|
|
269
|
+
'acl:accessTo': { '@id': resourceUrl },
|
|
270
|
+
'acl:default': { '@id': resourceUrl },
|
|
271
|
+
'acl:mode': [
|
|
272
|
+
{ '@id': 'acl:Append' }
|
|
273
|
+
]
|
|
274
|
+
}
|
|
275
|
+
]
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Serialize ACL to JSON string
|
|
281
|
+
*/
|
|
282
|
+
export function serializeAcl(acl) {
|
|
283
|
+
return JSON.stringify(acl, null, 2);
|
|
284
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication and Authorization tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, before, after } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import {
|
|
8
|
+
startTestServer,
|
|
9
|
+
stopTestServer,
|
|
10
|
+
request,
|
|
11
|
+
createTestPod,
|
|
12
|
+
getPodToken,
|
|
13
|
+
getBaseUrl,
|
|
14
|
+
assertStatus,
|
|
15
|
+
assertHeader
|
|
16
|
+
} from './helpers.js';
|
|
17
|
+
|
|
18
|
+
describe('Authentication', () => {
|
|
19
|
+
before(async () => {
|
|
20
|
+
await startTestServer();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
after(async () => {
|
|
24
|
+
await stopTestServer();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('Token Authentication', () => {
|
|
28
|
+
it('should return token on pod creation', async () => {
|
|
29
|
+
const result = await createTestPod('authtest');
|
|
30
|
+
|
|
31
|
+
assert.ok(result.token, 'Should return a token');
|
|
32
|
+
assert.ok(result.token.includes('.'), 'Token should have signature');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should allow authenticated access to private resources', async () => {
|
|
36
|
+
await createTestPod('privatetest');
|
|
37
|
+
|
|
38
|
+
// Should succeed with auth
|
|
39
|
+
const res = await request('/privatetest/private/', { auth: 'privatetest' });
|
|
40
|
+
assertStatus(res, 200);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should deny unauthenticated access to private resources', async () => {
|
|
44
|
+
await createTestPod('denytest');
|
|
45
|
+
|
|
46
|
+
// Should fail without auth
|
|
47
|
+
const res = await request('/denytest/private/');
|
|
48
|
+
assertStatus(res, 401);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should return 403 for wrong user accessing private resources', async () => {
|
|
52
|
+
await createTestPod('user1');
|
|
53
|
+
await createTestPod('user2');
|
|
54
|
+
|
|
55
|
+
// User2 trying to access User1's private folder
|
|
56
|
+
const res = await request('/user1/private/', { auth: 'user2' });
|
|
57
|
+
assertStatus(res, 403);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should accept Bearer token format', async () => {
|
|
61
|
+
await createTestPod('bearertest');
|
|
62
|
+
const token = getPodToken('bearertest');
|
|
63
|
+
|
|
64
|
+
const res = await fetch(`${getBaseUrl()}/bearertest/private/`, {
|
|
65
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
66
|
+
});
|
|
67
|
+
assertStatus(res, 200);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should reject invalid tokens', async () => {
|
|
71
|
+
await createTestPod('invalidtest');
|
|
72
|
+
|
|
73
|
+
const res = await fetch(`${getBaseUrl()}/invalidtest/private/`, {
|
|
74
|
+
headers: { 'Authorization': 'Bearer invalid.token' }
|
|
75
|
+
});
|
|
76
|
+
assertStatus(res, 401);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('WAC Enforcement', () => {
|
|
81
|
+
it('should allow public read on pod root', async () => {
|
|
82
|
+
await createTestPod('publicread');
|
|
83
|
+
|
|
84
|
+
// Public folder should be readable without auth
|
|
85
|
+
const res = await request('/publicread/public/');
|
|
86
|
+
assertStatus(res, 200);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should allow public read on explicit public folders', async () => {
|
|
90
|
+
await createTestPod('explicitpublic');
|
|
91
|
+
|
|
92
|
+
// Root ACL has public read default
|
|
93
|
+
const res = await request('/explicitpublic/');
|
|
94
|
+
assertStatus(res, 200);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should allow authenticated write to owned resources', async () => {
|
|
98
|
+
await createTestPod('writetest');
|
|
99
|
+
|
|
100
|
+
const res = await request('/writetest/public/test.txt', {
|
|
101
|
+
method: 'PUT',
|
|
102
|
+
body: 'test content',
|
|
103
|
+
auth: 'writetest'
|
|
104
|
+
});
|
|
105
|
+
assertStatus(res, 201);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should deny unauthenticated write', async () => {
|
|
109
|
+
await createTestPod('nowrite');
|
|
110
|
+
|
|
111
|
+
const res = await request('/nowrite/public/test.txt', {
|
|
112
|
+
method: 'PUT',
|
|
113
|
+
body: 'test content'
|
|
114
|
+
});
|
|
115
|
+
assertStatus(res, 401);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should deny other user write to owned resources', async () => {
|
|
119
|
+
await createTestPod('owner1');
|
|
120
|
+
await createTestPod('attacker');
|
|
121
|
+
|
|
122
|
+
const res = await request('/owner1/public/test.txt', {
|
|
123
|
+
method: 'PUT',
|
|
124
|
+
body: 'malicious content',
|
|
125
|
+
auth: 'attacker'
|
|
126
|
+
});
|
|
127
|
+
assertStatus(res, 403);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should allow public append to inbox', async () => {
|
|
131
|
+
await createTestPod('inboxtest');
|
|
132
|
+
|
|
133
|
+
// POST to inbox should work for anyone (public append)
|
|
134
|
+
const res = await request('/inboxtest/inbox/', {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: {
|
|
137
|
+
'Content-Type': 'application/json',
|
|
138
|
+
'Slug': 'notification'
|
|
139
|
+
},
|
|
140
|
+
body: JSON.stringify({ type: 'notification' })
|
|
141
|
+
});
|
|
142
|
+
assertStatus(res, 201);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should deny public read on inbox', async () => {
|
|
146
|
+
await createTestPod('inboxread');
|
|
147
|
+
|
|
148
|
+
// GET inbox should fail for unauthenticated
|
|
149
|
+
const res = await request('/inboxread/inbox/');
|
|
150
|
+
assertStatus(res, 401);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('WAC-Allow Header', () => {
|
|
155
|
+
it('should include user permissions for authenticated requests', async () => {
|
|
156
|
+
await createTestPod('wacallow');
|
|
157
|
+
|
|
158
|
+
const res = await request('/wacallow/public/', { auth: 'wacallow' });
|
|
159
|
+
const wacAllow = res.headers.get('WAC-Allow');
|
|
160
|
+
|
|
161
|
+
assert.ok(wacAllow, 'Should have WAC-Allow header');
|
|
162
|
+
assert.ok(wacAllow.includes('user='), 'Should include user permissions');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should include public permissions', async () => {
|
|
166
|
+
await createTestPod('wacpublic');
|
|
167
|
+
|
|
168
|
+
const res = await request('/wacpublic/public/');
|
|
169
|
+
const wacAllow = res.headers.get('WAC-Allow');
|
|
170
|
+
|
|
171
|
+
assert.ok(wacAllow, 'Should have WAC-Allow header');
|
|
172
|
+
assert.ok(wacAllow.includes('public='), 'Should include public permissions');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|