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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javascript-solid-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
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": [
|
|
20
|
+
"keywords": [
|
|
21
|
+
"solid",
|
|
22
|
+
"ldp",
|
|
23
|
+
"linked-data",
|
|
24
|
+
"decentralized"
|
|
25
|
+
],
|
|
20
26
|
"license": "MIT"
|
|
21
27
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorization middleware
|
|
3
|
+
* Combines authentication (token verification) with WAC checking
|
|
4
|
+
* Supports both simple Bearer tokens and Solid-OIDC DPoP tokens
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getWebIdFromRequestAsync } from './token.js';
|
|
8
|
+
import { checkAccess, getRequiredMode } from '../wac/checker.js';
|
|
9
|
+
import * as storage from '../storage/filesystem.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Check if request is authorized
|
|
13
|
+
* @param {object} request - Fastify request
|
|
14
|
+
* @param {object} reply - Fastify reply
|
|
15
|
+
* @returns {Promise<{authorized: boolean, webId: string|null, wacAllow: string, authError: string|null}>}
|
|
16
|
+
*/
|
|
17
|
+
export async function authorize(request, reply) {
|
|
18
|
+
const urlPath = request.url.split('?')[0];
|
|
19
|
+
const method = request.method;
|
|
20
|
+
|
|
21
|
+
// Skip auth for .acl files (they need special handling)
|
|
22
|
+
// and for OPTIONS (CORS preflight)
|
|
23
|
+
if (urlPath.endsWith('.acl') || method === 'OPTIONS') {
|
|
24
|
+
return { authorized: true, webId: null, wacAllow: 'user="read write append control", public="read write append"', authError: null };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Get WebID from token (supports both simple and Solid-OIDC tokens)
|
|
28
|
+
const { webId, error: authError } = await getWebIdFromRequestAsync(request);
|
|
29
|
+
|
|
30
|
+
// Get resource info
|
|
31
|
+
const stats = await storage.stat(urlPath);
|
|
32
|
+
const resourceExists = stats !== null;
|
|
33
|
+
const isContainer = stats?.isDirectory || urlPath.endsWith('/');
|
|
34
|
+
|
|
35
|
+
// Build resource URL
|
|
36
|
+
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
37
|
+
|
|
38
|
+
// Get required access mode for this method
|
|
39
|
+
const requiredMode = getRequiredMode(method);
|
|
40
|
+
|
|
41
|
+
// For write operations on non-existent resources, check parent container
|
|
42
|
+
let checkPath = urlPath;
|
|
43
|
+
let checkUrl = resourceUrl;
|
|
44
|
+
let checkIsContainer = isContainer;
|
|
45
|
+
|
|
46
|
+
if (!resourceExists && (method === 'PUT' || method === 'POST' || method === 'PATCH')) {
|
|
47
|
+
// Check write permission on parent container
|
|
48
|
+
const parentPath = getParentPath(urlPath);
|
|
49
|
+
checkPath = parentPath;
|
|
50
|
+
checkUrl = `${request.protocol}://${request.hostname}${parentPath}`;
|
|
51
|
+
checkIsContainer = true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Check WAC permissions
|
|
55
|
+
const { allowed, wacAllow } = await checkAccess({
|
|
56
|
+
resourceUrl: checkUrl,
|
|
57
|
+
resourcePath: checkPath,
|
|
58
|
+
isContainer: checkIsContainer,
|
|
59
|
+
agentWebId: webId,
|
|
60
|
+
requiredMode
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return { authorized: allowed, webId, wacAllow, authError };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get parent container path
|
|
68
|
+
*/
|
|
69
|
+
function getParentPath(path) {
|
|
70
|
+
const normalized = path.endsWith('/') ? path.slice(0, -1) : path;
|
|
71
|
+
const lastSlash = normalized.lastIndexOf('/');
|
|
72
|
+
if (lastSlash <= 0) return '/';
|
|
73
|
+
return normalized.substring(0, lastSlash + 1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Handle unauthorized request
|
|
78
|
+
* @param {object} reply - Fastify reply
|
|
79
|
+
* @param {boolean} isAuthenticated - Whether user is authenticated
|
|
80
|
+
* @param {string} wacAllow - WAC-Allow header value
|
|
81
|
+
* @param {string|null} authError - Authentication error message (for DPoP failures)
|
|
82
|
+
*/
|
|
83
|
+
export function handleUnauthorized(reply, isAuthenticated, wacAllow, authError = null) {
|
|
84
|
+
reply.header('WAC-Allow', wacAllow);
|
|
85
|
+
|
|
86
|
+
if (!isAuthenticated) {
|
|
87
|
+
// Not authenticated - return 401
|
|
88
|
+
return reply.code(401).send({
|
|
89
|
+
error: 'Unauthorized',
|
|
90
|
+
message: authError || 'Authentication required'
|
|
91
|
+
});
|
|
92
|
+
} else {
|
|
93
|
+
// Authenticated but not authorized - return 403
|
|
94
|
+
return reply.code(403).send({
|
|
95
|
+
error: 'Forbidden',
|
|
96
|
+
message: 'Access denied'
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-based authentication
|
|
3
|
+
*
|
|
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
|
+
*/
|
|
8
|
+
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
import { verifySolidOidc, hasSolidOidcAuth } from './solid-oidc.js';
|
|
11
|
+
|
|
12
|
+
// Secret for signing tokens (in production, use env var)
|
|
13
|
+
const SECRET = process.env.TOKEN_SECRET || 'dev-secret-change-in-production';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Create a simple token for a WebID
|
|
17
|
+
* @param {string} webId - The WebID to create token for
|
|
18
|
+
* @param {number} expiresIn - Expiration time in seconds (default 1 hour)
|
|
19
|
+
* @returns {string} Token string
|
|
20
|
+
*/
|
|
21
|
+
export function createToken(webId, expiresIn = 3600) {
|
|
22
|
+
const payload = {
|
|
23
|
+
webId,
|
|
24
|
+
iat: Math.floor(Date.now() / 1000),
|
|
25
|
+
exp: Math.floor(Date.now() / 1000) + expiresIn
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
29
|
+
const signature = crypto
|
|
30
|
+
.createHmac('sha256', SECRET)
|
|
31
|
+
.update(data)
|
|
32
|
+
.digest('base64url');
|
|
33
|
+
|
|
34
|
+
return `${data}.${signature}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Verify and decode a token
|
|
39
|
+
* @param {string} token - The token to verify
|
|
40
|
+
* @returns {{webId: string, iat: number, exp: number} | null} Decoded payload or null
|
|
41
|
+
*/
|
|
42
|
+
export function verifyToken(token) {
|
|
43
|
+
if (!token || typeof token !== 'string') {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const parts = token.split('.');
|
|
48
|
+
if (parts.length !== 2) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const [data, signature] = parts;
|
|
53
|
+
|
|
54
|
+
// Verify signature
|
|
55
|
+
const expectedSig = crypto
|
|
56
|
+
.createHmac('sha256', SECRET)
|
|
57
|
+
.update(data)
|
|
58
|
+
.digest('base64url');
|
|
59
|
+
|
|
60
|
+
if (signature !== expectedSig) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Decode payload
|
|
65
|
+
try {
|
|
66
|
+
const payload = JSON.parse(Buffer.from(data, 'base64url').toString());
|
|
67
|
+
|
|
68
|
+
// Check expiration
|
|
69
|
+
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return payload;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extract token from Authorization header
|
|
81
|
+
* @param {string} authHeader - Authorization header value
|
|
82
|
+
* @returns {string | null} Token or null
|
|
83
|
+
*/
|
|
84
|
+
export function extractToken(authHeader) {
|
|
85
|
+
if (!authHeader || typeof authHeader !== 'string') {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Support "Bearer <token>" format
|
|
90
|
+
if (authHeader.startsWith('Bearer ')) {
|
|
91
|
+
return authHeader.slice(7);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Also support raw token
|
|
95
|
+
return authHeader;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Extract WebID from request (sync version for simple tokens only)
|
|
100
|
+
* @param {object} request - Fastify request object
|
|
101
|
+
* @returns {string | null} WebID or null if not authenticated
|
|
102
|
+
*/
|
|
103
|
+
export function getWebIdFromRequest(request) {
|
|
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
|
+
|
|
111
|
+
const token = extractToken(authHeader);
|
|
112
|
+
|
|
113
|
+
if (!token) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const payload = verifyToken(token);
|
|
118
|
+
return payload?.webId || null;
|
|
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,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
|
-
|
|
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
|
}
|
package/src/handlers/resource.js
CHANGED
|
@@ -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
|
|
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
|
|
139
|
-
headers
|
|
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
|
|
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));
|