javascript-solid-server 0.0.3 → 0.0.6
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 +9 -3
- package/src/auth/middleware.js +99 -0
- package/src/auth/solid-oidc.js +260 -0
- package/src/auth/token.js +150 -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/solid-oidc.test.js +211 -0
- package/test/wac.test.js +189 -0
- package/benchmark-report-2025-03-31T14-25-24.234Z.json +0 -44
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Solid-OIDC tests
|
|
3
|
+
* Tests for DPoP token verification
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, before, after } from 'node:test';
|
|
7
|
+
import assert from 'node:assert';
|
|
8
|
+
import * as jose from 'jose';
|
|
9
|
+
import {
|
|
10
|
+
startTestServer,
|
|
11
|
+
stopTestServer,
|
|
12
|
+
request,
|
|
13
|
+
createTestPod,
|
|
14
|
+
getBaseUrl,
|
|
15
|
+
assertStatus
|
|
16
|
+
} from './helpers.js';
|
|
17
|
+
|
|
18
|
+
describe('Solid-OIDC', () => {
|
|
19
|
+
let keyPair;
|
|
20
|
+
let publicJwk;
|
|
21
|
+
|
|
22
|
+
before(async () => {
|
|
23
|
+
await startTestServer();
|
|
24
|
+
await createTestPod('oidctest');
|
|
25
|
+
|
|
26
|
+
// Generate a key pair for testing
|
|
27
|
+
keyPair = await jose.generateKeyPair('ES256');
|
|
28
|
+
publicJwk = await jose.exportJWK(keyPair.publicKey);
|
|
29
|
+
publicJwk.alg = 'ES256';
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
after(async () => {
|
|
33
|
+
await stopTestServer();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('DPoP Header Parsing', () => {
|
|
37
|
+
// Use private folder - requires authentication
|
|
38
|
+
const privatePath = '/oidctest/private/';
|
|
39
|
+
|
|
40
|
+
it('should reject requests with DPoP auth but no DPoP proof', async () => {
|
|
41
|
+
const res = await fetch(`${getBaseUrl()}${privatePath}`, {
|
|
42
|
+
headers: {
|
|
43
|
+
'Authorization': 'DPoP some-token'
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
assertStatus(res, 401);
|
|
48
|
+
const body = await res.json();
|
|
49
|
+
assert.ok(body.message.includes('DPoP proof'), 'Should mention DPoP proof');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should reject invalid DPoP proof JWT', async () => {
|
|
53
|
+
const res = await fetch(`${getBaseUrl()}${privatePath}`, {
|
|
54
|
+
headers: {
|
|
55
|
+
'Authorization': 'DPoP some-token',
|
|
56
|
+
'DPoP': 'not-a-valid-jwt'
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
assertStatus(res, 401);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should reject DPoP proof with wrong type', async () => {
|
|
64
|
+
// Create a JWT that's not a DPoP proof (wrong typ)
|
|
65
|
+
const wrongTypeJwt = await new jose.SignJWT({
|
|
66
|
+
htm: 'GET',
|
|
67
|
+
htu: `${getBaseUrl()}${privatePath}`,
|
|
68
|
+
iat: Math.floor(Date.now() / 1000),
|
|
69
|
+
jti: crypto.randomUUID()
|
|
70
|
+
})
|
|
71
|
+
.setProtectedHeader({ alg: 'ES256', typ: 'JWT', jwk: publicJwk })
|
|
72
|
+
.sign(keyPair.privateKey);
|
|
73
|
+
|
|
74
|
+
const res = await fetch(`${getBaseUrl()}${privatePath}`, {
|
|
75
|
+
headers: {
|
|
76
|
+
'Authorization': 'DPoP some-token',
|
|
77
|
+
'DPoP': wrongTypeJwt
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
assertStatus(res, 401);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should reject DPoP proof with wrong HTTP method', async () => {
|
|
85
|
+
const dpopProof = await createDpopProof('POST', `${getBaseUrl()}${privatePath}`);
|
|
86
|
+
|
|
87
|
+
const res = await fetch(`${getBaseUrl()}${privatePath}`, {
|
|
88
|
+
method: 'GET',
|
|
89
|
+
headers: {
|
|
90
|
+
'Authorization': 'DPoP some-token',
|
|
91
|
+
'DPoP': dpopProof
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
assertStatus(res, 401);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should reject DPoP proof with wrong URL', async () => {
|
|
99
|
+
const dpopProof = await createDpopProof('GET', 'https://other-server.example/');
|
|
100
|
+
|
|
101
|
+
const res = await fetch(`${getBaseUrl()}${privatePath}`, {
|
|
102
|
+
headers: {
|
|
103
|
+
'Authorization': 'DPoP some-token',
|
|
104
|
+
'DPoP': dpopProof
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
assertStatus(res, 401);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should reject expired DPoP proof', async () => {
|
|
112
|
+
// Create a DPoP proof with old iat
|
|
113
|
+
const dpopProof = await new jose.SignJWT({
|
|
114
|
+
htm: 'GET',
|
|
115
|
+
htu: `${getBaseUrl()}${privatePath}`,
|
|
116
|
+
iat: Math.floor(Date.now() / 1000) - 600, // 10 minutes ago
|
|
117
|
+
jti: crypto.randomUUID()
|
|
118
|
+
})
|
|
119
|
+
.setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk: publicJwk })
|
|
120
|
+
.sign(keyPair.privateKey);
|
|
121
|
+
|
|
122
|
+
const res = await fetch(`${getBaseUrl()}${privatePath}`, {
|
|
123
|
+
headers: {
|
|
124
|
+
'Authorization': 'DPoP some-token',
|
|
125
|
+
'DPoP': dpopProof
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
assertStatus(res, 401);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should reject DPoP proof missing jti', async () => {
|
|
133
|
+
const dpopProof = await new jose.SignJWT({
|
|
134
|
+
htm: 'GET',
|
|
135
|
+
htu: `${getBaseUrl()}${privatePath}`,
|
|
136
|
+
iat: Math.floor(Date.now() / 1000)
|
|
137
|
+
// missing jti
|
|
138
|
+
})
|
|
139
|
+
.setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk: publicJwk })
|
|
140
|
+
.sign(keyPair.privateKey);
|
|
141
|
+
|
|
142
|
+
const res = await fetch(`${getBaseUrl()}${privatePath}`, {
|
|
143
|
+
headers: {
|
|
144
|
+
'Authorization': 'DPoP some-token',
|
|
145
|
+
'DPoP': dpopProof
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
assertStatus(res, 401);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('Access Token Verification', () => {
|
|
154
|
+
const privatePath = '/oidctest/private/';
|
|
155
|
+
|
|
156
|
+
it('should reject token with invalid issuer (unreachable)', async () => {
|
|
157
|
+
// Create a valid DPoP proof
|
|
158
|
+
const dpopProof = await createDpopProof('GET', `${getBaseUrl()}${privatePath}`);
|
|
159
|
+
|
|
160
|
+
// Create a fake access token with unreachable issuer
|
|
161
|
+
const fakeToken = await new jose.SignJWT({
|
|
162
|
+
webid: 'https://example.com/user#me',
|
|
163
|
+
sub: 'https://example.com/user#me',
|
|
164
|
+
iss: 'https://nonexistent-idp.example.com',
|
|
165
|
+
aud: 'solid',
|
|
166
|
+
iat: Math.floor(Date.now() / 1000),
|
|
167
|
+
exp: Math.floor(Date.now() / 1000) + 3600
|
|
168
|
+
})
|
|
169
|
+
.setProtectedHeader({ alg: 'ES256' })
|
|
170
|
+
.sign(keyPair.privateKey);
|
|
171
|
+
|
|
172
|
+
const res = await fetch(`${getBaseUrl()}${privatePath}`, {
|
|
173
|
+
headers: {
|
|
174
|
+
'Authorization': `DPoP ${fakeToken}`,
|
|
175
|
+
'DPoP': dpopProof
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
assertStatus(res, 401);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('Bearer Token Fallback', () => {
|
|
184
|
+
it('should still accept simple Bearer tokens', async () => {
|
|
185
|
+
// This should work with our simple token system
|
|
186
|
+
const res = await request('/oidctest/public/', { auth: 'oidctest' });
|
|
187
|
+
assertStatus(res, 200);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should still accept simple Bearer tokens for writes', async () => {
|
|
191
|
+
const res = await request('/oidctest/public/solid-oidc-test.txt', {
|
|
192
|
+
method: 'PUT',
|
|
193
|
+
body: 'test content',
|
|
194
|
+
auth: 'oidctest'
|
|
195
|
+
});
|
|
196
|
+
assertStatus(res, 201);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Helper to create DPoP proofs
|
|
201
|
+
async function createDpopProof(method, uri) {
|
|
202
|
+
return new jose.SignJWT({
|
|
203
|
+
htm: method,
|
|
204
|
+
htu: uri,
|
|
205
|
+
iat: Math.floor(Date.now() / 1000),
|
|
206
|
+
jti: crypto.randomUUID()
|
|
207
|
+
})
|
|
208
|
+
.setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk: publicJwk })
|
|
209
|
+
.sign(keyPair.privateKey);
|
|
210
|
+
}
|
|
211
|
+
});
|
package/test/wac.test.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAC (Web Access Control) 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
|
+
assertStatus,
|
|
13
|
+
assertHeader,
|
|
14
|
+
getBaseUrl
|
|
15
|
+
} from './helpers.js';
|
|
16
|
+
import { parseAcl, AccessMode, generateOwnerAcl, serializeAcl } from '../src/wac/parser.js';
|
|
17
|
+
import { checkAccess, getRequiredMode } from '../src/wac/checker.js';
|
|
18
|
+
|
|
19
|
+
describe('WAC Parser', () => {
|
|
20
|
+
describe('parseAcl', () => {
|
|
21
|
+
it('should parse a simple ACL', () => {
|
|
22
|
+
const acl = {
|
|
23
|
+
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
|
|
24
|
+
'@graph': [{
|
|
25
|
+
'@id': '#owner',
|
|
26
|
+
'@type': 'acl:Authorization',
|
|
27
|
+
'acl:agent': { '@id': 'https://alice.example/#me' },
|
|
28
|
+
'acl:accessTo': { '@id': 'https://alice.example/resource' },
|
|
29
|
+
'acl:mode': [{ '@id': 'acl:Read' }, { '@id': 'acl:Write' }]
|
|
30
|
+
}]
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const auths = parseAcl(JSON.stringify(acl), 'https://alice.example/.acl');
|
|
34
|
+
|
|
35
|
+
assert.strictEqual(auths.length, 1);
|
|
36
|
+
assert.ok(auths[0].agents.includes('https://alice.example/#me'));
|
|
37
|
+
assert.ok(auths[0].modes.includes(AccessMode.READ));
|
|
38
|
+
assert.ok(auths[0].modes.includes(AccessMode.WRITE));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should parse public access', () => {
|
|
42
|
+
const acl = {
|
|
43
|
+
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#', 'foaf': 'http://xmlns.com/foaf/0.1/' },
|
|
44
|
+
'@graph': [{
|
|
45
|
+
'@id': '#public',
|
|
46
|
+
'@type': 'acl:Authorization',
|
|
47
|
+
'acl:agentClass': { '@id': 'foaf:Agent' },
|
|
48
|
+
'acl:accessTo': { '@id': 'https://alice.example/public/' },
|
|
49
|
+
'acl:mode': [{ '@id': 'acl:Read' }]
|
|
50
|
+
}]
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const auths = parseAcl(JSON.stringify(acl), 'https://alice.example/public/.acl');
|
|
54
|
+
|
|
55
|
+
assert.strictEqual(auths.length, 1);
|
|
56
|
+
assert.ok(auths[0].agentClasses.includes('foaf:Agent'));
|
|
57
|
+
assert.ok(auths[0].modes.includes(AccessMode.READ));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should parse default authorizations for containers', () => {
|
|
61
|
+
const acl = {
|
|
62
|
+
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
|
|
63
|
+
'@graph': [{
|
|
64
|
+
'@id': '#default',
|
|
65
|
+
'@type': 'acl:Authorization',
|
|
66
|
+
'acl:agent': { '@id': 'https://alice.example/#me' },
|
|
67
|
+
'acl:default': { '@id': 'https://alice.example/folder/' },
|
|
68
|
+
'acl:mode': [{ '@id': 'acl:Read' }]
|
|
69
|
+
}]
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const auths = parseAcl(JSON.stringify(acl), 'https://alice.example/folder/.acl');
|
|
73
|
+
|
|
74
|
+
assert.strictEqual(auths.length, 1);
|
|
75
|
+
assert.ok(auths[0].default.includes('https://alice.example/folder/'));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should handle invalid JSON gracefully', () => {
|
|
79
|
+
const auths = parseAcl('not valid json', 'https://example.com/.acl');
|
|
80
|
+
assert.strictEqual(auths.length, 0);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('generateOwnerAcl', () => {
|
|
85
|
+
it('should generate owner ACL with public read', () => {
|
|
86
|
+
const acl = generateOwnerAcl('https://alice.example/', 'https://alice.example/#me', true);
|
|
87
|
+
|
|
88
|
+
assert.ok(acl['@graph'].length >= 2);
|
|
89
|
+
|
|
90
|
+
// Find owner auth
|
|
91
|
+
const ownerAuth = acl['@graph'].find(a => a['@id'] === '#owner');
|
|
92
|
+
assert.ok(ownerAuth);
|
|
93
|
+
assert.strictEqual(ownerAuth['acl:agent']['@id'], 'https://alice.example/#me');
|
|
94
|
+
|
|
95
|
+
// Find public auth
|
|
96
|
+
const publicAuth = acl['@graph'].find(a => a['@id'] === '#public');
|
|
97
|
+
assert.ok(publicAuth);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('WAC Checker', () => {
|
|
103
|
+
describe('getRequiredMode', () => {
|
|
104
|
+
it('should return READ for GET', () => {
|
|
105
|
+
assert.strictEqual(getRequiredMode('GET'), AccessMode.READ);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should return READ for HEAD', () => {
|
|
109
|
+
assert.strictEqual(getRequiredMode('HEAD'), AccessMode.READ);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should return APPEND for POST', () => {
|
|
113
|
+
assert.strictEqual(getRequiredMode('POST'), AccessMode.APPEND);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should return WRITE for PUT', () => {
|
|
117
|
+
assert.strictEqual(getRequiredMode('PUT'), AccessMode.WRITE);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should return WRITE for DELETE', () => {
|
|
121
|
+
assert.strictEqual(getRequiredMode('DELETE'), AccessMode.WRITE);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('WAC Integration', () => {
|
|
127
|
+
let baseUrl;
|
|
128
|
+
|
|
129
|
+
before(async () => {
|
|
130
|
+
const result = await startTestServer();
|
|
131
|
+
baseUrl = result.baseUrl;
|
|
132
|
+
await createTestPod('wactest');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
after(async () => {
|
|
136
|
+
await stopTestServer();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('ACL Files', () => {
|
|
140
|
+
it('should create root .acl on pod creation', async () => {
|
|
141
|
+
const res = await request('/wactest/.acl');
|
|
142
|
+
|
|
143
|
+
assertStatus(res, 200);
|
|
144
|
+
const content = await res.json();
|
|
145
|
+
assert.ok(content['@graph'], 'Should be JSON-LD');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should create private folder .acl', async () => {
|
|
149
|
+
const res = await request('/wactest/private/.acl');
|
|
150
|
+
|
|
151
|
+
assertStatus(res, 200);
|
|
152
|
+
const content = await res.json();
|
|
153
|
+
assert.ok(content['@graph']);
|
|
154
|
+
|
|
155
|
+
// Should only have owner, no public
|
|
156
|
+
const hasPublic = content['@graph'].some(a =>
|
|
157
|
+
a['acl:agentClass'] && a['acl:agentClass']['@id'] === 'foaf:Agent'
|
|
158
|
+
);
|
|
159
|
+
assert.ok(!hasPublic, 'Private folder should not have public access');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should create inbox .acl with public append', async () => {
|
|
163
|
+
const res = await request('/wactest/inbox/.acl');
|
|
164
|
+
|
|
165
|
+
assertStatus(res, 200);
|
|
166
|
+
const content = await res.json();
|
|
167
|
+
|
|
168
|
+
// Should have public append
|
|
169
|
+
const publicAuth = content['@graph'].find(a =>
|
|
170
|
+
a['acl:agentClass'] && a['acl:agentClass']['@id'] === 'foaf:Agent'
|
|
171
|
+
);
|
|
172
|
+
assert.ok(publicAuth, 'Inbox should have public access');
|
|
173
|
+
|
|
174
|
+
const modes = publicAuth['acl:mode'].map(m => m['@id']);
|
|
175
|
+
assert.ok(modes.includes('acl:Append'), 'Public should have Append');
|
|
176
|
+
assert.ok(!modes.includes('acl:Read'), 'Public should not have Read');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('WAC-Allow Header', () => {
|
|
181
|
+
it('should return WAC-Allow header for public container', async () => {
|
|
182
|
+
const res = await request('/wactest/public/');
|
|
183
|
+
|
|
184
|
+
assertHeader(res, 'WAC-Allow');
|
|
185
|
+
const wacAllow = res.headers.get('WAC-Allow');
|
|
186
|
+
assert.ok(wacAllow.includes('public='), 'Should have public permissions');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"timestamp": "2025-03-31T14:25:24.234Z",
|
|
3
|
-
"server": "http://nostr.social:3000",
|
|
4
|
-
"testDuration": 30000,
|
|
5
|
-
"averageResponseTimes": {
|
|
6
|
-
"register": 49.28774029878249,
|
|
7
|
-
"login": 49.07647125548627,
|
|
8
|
-
"read": 145.50252931779679,
|
|
9
|
-
"write": 143.20483738812226,
|
|
10
|
-
"delete": 145.5004399698014
|
|
11
|
-
},
|
|
12
|
-
"throughputResults": [
|
|
13
|
-
{
|
|
14
|
-
"concurrentUsers": 1,
|
|
15
|
-
"operations": 300,
|
|
16
|
-
"duration": 629.3636230230331,
|
|
17
|
-
"throughput": 476.67197312581374
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
"concurrentUsers": 5,
|
|
21
|
-
"operations": 1500,
|
|
22
|
-
"duration": 3072.7389999628067,
|
|
23
|
-
"throughput": 488.16381736885444
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
"concurrentUsers": 10,
|
|
27
|
-
"operations": 3000,
|
|
28
|
-
"duration": 6042.516402959824,
|
|
29
|
-
"throughput": 496.4818959416479
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
"concurrentUsers": 50,
|
|
33
|
-
"operations": 14000,
|
|
34
|
-
"duration": 30000,
|
|
35
|
-
"throughput": 466.6666666666667
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"concurrentUsers": 100,
|
|
39
|
-
"operations": 13900,
|
|
40
|
-
"duration": 30000,
|
|
41
|
-
"throughput": 463.3333333333333
|
|
42
|
-
}
|
|
43
|
-
]
|
|
44
|
-
}
|