javascript-solid-server 0.0.13 → 0.0.16
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/.claude/settings.local.json +27 -1
- package/CTH.md +222 -0
- package/README.md +92 -3
- package/bin/jss.js +11 -1
- package/cth-config/application.yaml +2 -0
- package/cth-config/jss.ttl +6 -0
- package/cth-config/test-subjects.ttl +14 -0
- package/cth.env +19 -0
- package/package.json +1 -1
- package/scripts/test-cth-compat.js +3 -2
- package/src/auth/middleware.js +17 -7
- package/src/auth/token.js +44 -1
- package/src/config.js +7 -0
- package/src/handlers/container.js +49 -16
- package/src/handlers/resource.js +99 -32
- package/src/idp/accounts.js +11 -2
- package/src/idp/credentials.js +38 -38
- package/src/idp/index.js +112 -21
- package/src/idp/interactions.js +123 -11
- package/src/idp/provider.js +68 -2
- package/src/rdf/turtle.js +15 -2
- package/src/server.js +24 -0
- package/src/utils/url.js +52 -0
- package/src/wac/parser.js +43 -1
- package/test/idp.test.js +17 -14
- package/test/ldp.test.js +10 -5
- package/test-data-idp-accounts/.idp/accounts/3c1cd503-1d7f-4ba0-a3af-ebedf519594d.json +9 -0
- package/test-data-idp-accounts/.idp/accounts/_email_index.json +3 -0
- package/test-data-idp-accounts/.idp/accounts/_webid_index.json +3 -0
- package/test-data-idp-accounts/.idp/keys/jwks.json +22 -0
- package/test-dpop-flow.js +148 -0
- package/test-subjects.ttl +21 -0
- package/data/alice/.acl +0 -50
- package/data/alice/inbox/.acl +0 -50
- package/data/alice/index.html +0 -80
- package/data/alice/private/.acl +0 -32
- package/data/alice/public/test.json +0 -1
- package/data/alice/settings/.acl +0 -32
- package/data/alice/settings/prefs +0 -17
- package/data/alice/settings/privateTypeIndex +0 -7
- package/data/alice/settings/publicTypeIndex +0 -7
- package/data/bob/.acl +0 -50
- package/data/bob/inbox/.acl +0 -50
- package/data/bob/index.html +0 -80
- package/data/bob/private/.acl +0 -32
- package/data/bob/settings/.acl +0 -32
- package/data/bob/settings/prefs +0 -17
- package/data/bob/settings/privateTypeIndex +0 -7
- package/data/bob/settings/publicTypeIndex +0 -7
package/src/idp/index.js
CHANGED
|
@@ -36,38 +36,123 @@ export async function idpPlugin(fastify, options) {
|
|
|
36
36
|
// Create the OIDC provider
|
|
37
37
|
const provider = await createProvider(issuer);
|
|
38
38
|
|
|
39
|
+
// Add error listener to catch internal oidc-provider errors
|
|
40
|
+
provider.on('server_error', (ctx, err) => {
|
|
41
|
+
fastify.log.error({
|
|
42
|
+
err: err.message,
|
|
43
|
+
stack: err.stack,
|
|
44
|
+
path: ctx?.path,
|
|
45
|
+
cause: err.cause?.message,
|
|
46
|
+
error_description: err.error_description,
|
|
47
|
+
}, 'oidc-provider server error');
|
|
48
|
+
});
|
|
49
|
+
provider.on('grant.error', (ctx, err) => {
|
|
50
|
+
fastify.log.error({
|
|
51
|
+
err: err.message,
|
|
52
|
+
stack: err.stack?.substring(0, 800),
|
|
53
|
+
cause: err.cause?.message,
|
|
54
|
+
error_description: err.error_description,
|
|
55
|
+
}, 'oidc-provider grant error');
|
|
56
|
+
});
|
|
57
|
+
|
|
39
58
|
// Store provider reference on fastify for handlers
|
|
40
59
|
fastify.decorate('oidcProvider', provider);
|
|
41
60
|
|
|
42
61
|
// Register middleware support for oidc-provider (Koa app)
|
|
43
|
-
await fastify.register(middie
|
|
44
|
-
|
|
62
|
+
await fastify.register(middie);
|
|
63
|
+
|
|
64
|
+
// Helper to forward requests to oidc-provider
|
|
65
|
+
const forwardToProvider = async (request, reply) => {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
// Get raw Node.js req/res
|
|
68
|
+
const req = request.raw;
|
|
69
|
+
const res = reply.raw;
|
|
70
|
+
|
|
71
|
+
// oidc-provider is now configured with /idp routes, no stripping needed
|
|
72
|
+
// Ensure parsed body is accessible to oidc-provider
|
|
73
|
+
// Fastify parses body into request.body, oidc-provider looks for req.body
|
|
74
|
+
if (request.body !== undefined) {
|
|
75
|
+
if (Buffer.isBuffer(request.body)) {
|
|
76
|
+
// Parse buffer to object if it's JSON
|
|
77
|
+
const contentType = request.headers['content-type'] || '';
|
|
78
|
+
if (contentType.includes('application/json')) {
|
|
79
|
+
try {
|
|
80
|
+
req.body = JSON.parse(request.body.toString());
|
|
81
|
+
} catch (e) {
|
|
82
|
+
req.body = request.body;
|
|
83
|
+
}
|
|
84
|
+
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
85
|
+
// Parse form data
|
|
86
|
+
const params = new URLSearchParams(request.body.toString());
|
|
87
|
+
req.body = Object.fromEntries(params.entries());
|
|
88
|
+
} else {
|
|
89
|
+
req.body = request.body;
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
req.body = request.body;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Call oidc-provider's callback
|
|
97
|
+
provider.callback()(req, res);
|
|
98
|
+
|
|
99
|
+
// Wait for response to finish
|
|
100
|
+
res.on('finish', resolve);
|
|
101
|
+
res.on('error', reject);
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Legacy handler for /auth/:uid without prefix - redirect to /idp/auth/:uid
|
|
106
|
+
// In case any old redirects or cached URLs exist
|
|
107
|
+
fastify.get('/auth/:uid', async (request, reply) => {
|
|
108
|
+
return reply.redirect(`/idp/auth/${request.params.uid}`);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Catch-all route for oidc-provider paths
|
|
112
|
+
// Must be registered BEFORE specific routes to be matched as fallback
|
|
113
|
+
const oidcPaths = ['/idp/auth', '/idp/token', '/idp/reg', '/idp/me', '/idp/session', '/idp/session/*'];
|
|
114
|
+
|
|
115
|
+
for (const path of oidcPaths) {
|
|
116
|
+
fastify.route({
|
|
117
|
+
method: ['GET', 'POST', 'DELETE'],
|
|
118
|
+
url: path,
|
|
119
|
+
handler: forwardToProvider,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Also handle /idp/auth/:uid for continued authorization after login
|
|
124
|
+
fastify.get('/idp/auth/:uid', forwardToProvider);
|
|
125
|
+
|
|
126
|
+
// Token sub-paths
|
|
127
|
+
fastify.route({
|
|
128
|
+
method: ['GET', 'POST'],
|
|
129
|
+
url: '/idp/token/introspection',
|
|
130
|
+
handler: forwardToProvider,
|
|
45
131
|
});
|
|
46
132
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (req.url.startsWith('/interaction/') || req.url.startsWith('/credentials')) {
|
|
52
|
-
return next();
|
|
53
|
-
}
|
|
54
|
-
// Let oidc-provider handle everything else
|
|
55
|
-
provider.callback()(req, res);
|
|
133
|
+
fastify.route({
|
|
134
|
+
method: ['GET', 'POST'],
|
|
135
|
+
url: '/idp/token/revocation',
|
|
136
|
+
handler: forwardToProvider,
|
|
56
137
|
});
|
|
57
138
|
|
|
58
139
|
// /.well-known/openid-configuration
|
|
59
140
|
fastify.get('/.well-known/openid-configuration', async (request, reply) => {
|
|
141
|
+
// Ensure issuer has trailing slash for CTH compatibility
|
|
142
|
+
const normalizedIssuer = issuer.endsWith('/') ? issuer : issuer + '/';
|
|
143
|
+
// Base URL without trailing slash for building endpoint URLs
|
|
144
|
+
const baseUrl = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer;
|
|
60
145
|
// Build discovery document
|
|
61
146
|
const config = {
|
|
62
|
-
issuer,
|
|
63
|
-
authorization_endpoint: `${
|
|
64
|
-
token_endpoint: `${
|
|
65
|
-
userinfo_endpoint: `${
|
|
66
|
-
jwks_uri: `${
|
|
67
|
-
registration_endpoint: `${
|
|
68
|
-
introspection_endpoint: `${
|
|
69
|
-
revocation_endpoint: `${
|
|
70
|
-
end_session_endpoint: `${
|
|
147
|
+
issuer: normalizedIssuer,
|
|
148
|
+
authorization_endpoint: `${baseUrl}/idp/auth`,
|
|
149
|
+
token_endpoint: `${baseUrl}/idp/token`,
|
|
150
|
+
userinfo_endpoint: `${baseUrl}/idp/me`,
|
|
151
|
+
jwks_uri: `${baseUrl}/.well-known/jwks.json`,
|
|
152
|
+
registration_endpoint: `${baseUrl}/idp/reg`,
|
|
153
|
+
introspection_endpoint: `${baseUrl}/idp/token/introspection`,
|
|
154
|
+
revocation_endpoint: `${baseUrl}/idp/token/revocation`,
|
|
155
|
+
end_session_endpoint: `${baseUrl}/idp/session/end`,
|
|
71
156
|
scopes_supported: ['openid', 'webid', 'profile', 'email', 'offline_access'],
|
|
72
157
|
response_types_supported: ['code'],
|
|
73
158
|
response_modes_supported: ['query', 'fragment', 'form_post'],
|
|
@@ -114,7 +199,13 @@ export async function idpPlugin(fastify, options) {
|
|
|
114
199
|
return handleInteractionGet(request, reply, provider);
|
|
115
200
|
});
|
|
116
201
|
|
|
117
|
-
// POST
|
|
202
|
+
// POST interaction - direct form submission (CTH compatibility)
|
|
203
|
+
// This handles form submissions directly to /idp/interaction/:uid
|
|
204
|
+
fastify.post('/idp/interaction/:uid', async (request, reply) => {
|
|
205
|
+
return handleLogin(request, reply, provider);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// POST login (explicit path)
|
|
118
209
|
fastify.post('/idp/interaction/:uid/login', async (request, reply) => {
|
|
119
210
|
return handleLogin(request, reply, provider);
|
|
120
211
|
});
|
package/src/idp/interactions.js
CHANGED
|
@@ -48,7 +48,44 @@ export async function handleInteractionGet(request, reply, provider) {
|
|
|
48
48
|
*/
|
|
49
49
|
export async function handleLogin(request, reply, provider) {
|
|
50
50
|
const { uid } = request.params;
|
|
51
|
-
|
|
51
|
+
|
|
52
|
+
// Parse body - handle multiple formats (Buffer, string, object)
|
|
53
|
+
let parsedBody = request.body || {};
|
|
54
|
+
const contentType = request.headers['content-type'] || '';
|
|
55
|
+
|
|
56
|
+
if (Buffer.isBuffer(parsedBody)) {
|
|
57
|
+
const bodyStr = parsedBody.toString();
|
|
58
|
+
if (contentType.includes('application/json')) {
|
|
59
|
+
try {
|
|
60
|
+
parsedBody = JSON.parse(bodyStr);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
parsedBody = {};
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
// Assume form-urlencoded
|
|
66
|
+
const params = new URLSearchParams(bodyStr);
|
|
67
|
+
parsedBody = Object.fromEntries(params.entries());
|
|
68
|
+
}
|
|
69
|
+
} else if (typeof parsedBody === 'string') {
|
|
70
|
+
// Body might be a string for form-urlencoded
|
|
71
|
+
if (contentType.includes('application/json')) {
|
|
72
|
+
try {
|
|
73
|
+
parsedBody = JSON.parse(parsedBody);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
parsedBody = {};
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
const params = new URLSearchParams(parsedBody);
|
|
79
|
+
parsedBody = Object.fromEntries(params.entries());
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// If it's already an object, use as-is
|
|
83
|
+
|
|
84
|
+
// Support both 'email' and 'username' fields for CTH compatibility
|
|
85
|
+
const email = parsedBody.email || parsedBody.username;
|
|
86
|
+
const password = parsedBody.password;
|
|
87
|
+
|
|
88
|
+
request.log.info({ email, hasPassword: !!password, bodyType: typeof request.body, keys: Object.keys(parsedBody) }, 'Login attempt');
|
|
52
89
|
|
|
53
90
|
try {
|
|
54
91
|
const interaction = await provider.Interaction.find(uid);
|
|
@@ -79,14 +116,86 @@ export async function handleLogin(request, reply, provider) {
|
|
|
79
116
|
},
|
|
80
117
|
};
|
|
81
118
|
|
|
82
|
-
|
|
83
|
-
request.raw,
|
|
84
|
-
reply.raw,
|
|
85
|
-
result,
|
|
86
|
-
{ mergeWithLastSubmission: false }
|
|
87
|
-
);
|
|
119
|
+
request.log.info({ accountId: account.id, uid }, 'Login successful');
|
|
88
120
|
|
|
89
|
-
return
|
|
121
|
+
// For CTH compatibility, we need to return a response that CTH can handle.
|
|
122
|
+
// CTH expects either:
|
|
123
|
+
// 1. A redirect it can follow (but Java HttpClient follows to final destination which fails)
|
|
124
|
+
// 2. A 200 response with "location" in body (CSS v3+ style)
|
|
125
|
+
//
|
|
126
|
+
// We use interactionResult to get the redirect URL, then save it and return JSON
|
|
127
|
+
|
|
128
|
+
// Save the login result to the interaction for programmatic clients
|
|
129
|
+
// This allows the auth endpoint to continue the flow when resumed
|
|
130
|
+
interaction.result = result;
|
|
131
|
+
await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
|
|
132
|
+
|
|
133
|
+
// For CTH and programmatic clients: use interactionFinished with hijacked response
|
|
134
|
+
// to properly complete the interaction while returning JSON
|
|
135
|
+
try {
|
|
136
|
+
reply.hijack();
|
|
137
|
+
|
|
138
|
+
// Create a mock response that captures the redirect and returns JSON
|
|
139
|
+
let capturedLocation = null;
|
|
140
|
+
let headersSent = false;
|
|
141
|
+
const mockRes = {
|
|
142
|
+
statusCode: 200,
|
|
143
|
+
headersSent: false,
|
|
144
|
+
setHeader: (name, value) => {
|
|
145
|
+
if (name.toLowerCase() === 'location') {
|
|
146
|
+
capturedLocation = value;
|
|
147
|
+
}
|
|
148
|
+
return mockRes;
|
|
149
|
+
},
|
|
150
|
+
getHeader: (name) => {
|
|
151
|
+
if (name.toLowerCase() === 'location') return capturedLocation;
|
|
152
|
+
return undefined;
|
|
153
|
+
},
|
|
154
|
+
removeHeader: () => mockRes,
|
|
155
|
+
writeHead: (status, headers) => {
|
|
156
|
+
if (headers) {
|
|
157
|
+
if (typeof headers === 'object' && !Array.isArray(headers)) {
|
|
158
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
159
|
+
if (key.toLowerCase() === 'location') {
|
|
160
|
+
capturedLocation = value;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return mockRes;
|
|
166
|
+
},
|
|
167
|
+
write: () => mockRes,
|
|
168
|
+
end: (body) => {
|
|
169
|
+
if (!headersSent) {
|
|
170
|
+
headersSent = true;
|
|
171
|
+
const location = capturedLocation || `/idp/auth/${uid}`;
|
|
172
|
+
reply.raw.writeHead(200, {
|
|
173
|
+
'Content-Type': 'application/json',
|
|
174
|
+
'Location': location,
|
|
175
|
+
});
|
|
176
|
+
reply.raw.end(JSON.stringify({ location }));
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
finished: false,
|
|
180
|
+
on: () => mockRes,
|
|
181
|
+
once: () => mockRes,
|
|
182
|
+
emit: () => mockRes,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
await provider.interactionFinished(request.raw, mockRes, result, { mergeWithLastSubmission: false });
|
|
186
|
+
return;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
request.log.warn({ err: err.message, errName: err.name, uid }, 'interactionFinished failed, using fallback');
|
|
189
|
+
|
|
190
|
+
// Fallback: return the redirect URL for manual following
|
|
191
|
+
// The interaction result is already saved above
|
|
192
|
+
const redirectTo = `/idp/auth/${uid}`;
|
|
193
|
+
return reply
|
|
194
|
+
.code(200)
|
|
195
|
+
.header('Location', redirectTo)
|
|
196
|
+
.type('application/json')
|
|
197
|
+
.send({ location: redirectTo });
|
|
198
|
+
}
|
|
90
199
|
} catch (err) {
|
|
91
200
|
request.log.error(err, 'Login error');
|
|
92
201
|
return reply.code(500).type('text/html').send(errorPage('Login failed', err.message));
|
|
@@ -138,14 +247,16 @@ export async function handleConsent(request, reply, provider) {
|
|
|
138
247
|
},
|
|
139
248
|
};
|
|
140
249
|
|
|
141
|
-
|
|
250
|
+
// Mark reply as sent since interactionFinished will handle the response
|
|
251
|
+
reply.hijack();
|
|
252
|
+
|
|
253
|
+
// Use interactionFinished which handles the redirect directly
|
|
254
|
+
return provider.interactionFinished(
|
|
142
255
|
request.raw,
|
|
143
256
|
reply.raw,
|
|
144
257
|
result,
|
|
145
258
|
{ mergeWithLastSubmission: true }
|
|
146
259
|
);
|
|
147
|
-
|
|
148
|
-
return reply.redirect(redirectTo);
|
|
149
260
|
} catch (err) {
|
|
150
261
|
request.log.error(err, 'Consent error');
|
|
151
262
|
return reply.code(500).type('text/html').send(errorPage('Consent failed', err.message));
|
|
@@ -165,6 +276,7 @@ export async function handleAbort(request, reply, provider) {
|
|
|
165
276
|
error_description: 'User cancelled the authorization request',
|
|
166
277
|
};
|
|
167
278
|
|
|
279
|
+
// oidc-provider is configured with /idp routes, so redirectTo will have correct path
|
|
168
280
|
const redirectTo = await provider.interactionResult(
|
|
169
281
|
request.raw,
|
|
170
282
|
reply.raw,
|
package/src/idp/provider.js
CHANGED
|
@@ -27,16 +27,19 @@ export async function createProvider(issuer) {
|
|
|
27
27
|
// Cookie configuration
|
|
28
28
|
cookies: {
|
|
29
29
|
keys: cookieKeys,
|
|
30
|
+
// Use root path so cookies work across all endpoints
|
|
30
31
|
long: {
|
|
31
32
|
signed: true,
|
|
32
33
|
maxAge: 14 * 24 * 60 * 60 * 1000, // 14 days
|
|
33
34
|
httpOnly: true,
|
|
34
35
|
sameSite: 'lax',
|
|
36
|
+
path: '/',
|
|
35
37
|
},
|
|
36
38
|
short: {
|
|
37
39
|
signed: true,
|
|
38
40
|
httpOnly: true,
|
|
39
41
|
sameSite: 'lax',
|
|
42
|
+
path: '/',
|
|
40
43
|
},
|
|
41
44
|
},
|
|
42
45
|
|
|
@@ -94,13 +97,16 @@ export async function createProvider(issuer) {
|
|
|
94
97
|
enabled: false, // Keep disabled for MVP
|
|
95
98
|
},
|
|
96
99
|
|
|
97
|
-
// Allow resource parameter
|
|
100
|
+
// Allow resource parameter - always use JWT format for access tokens
|
|
101
|
+
// Resource must be a valid URI, but audience can be 'solid' for Solid-OIDC
|
|
98
102
|
resourceIndicators: {
|
|
99
103
|
enabled: true,
|
|
100
|
-
|
|
104
|
+
// Default to a URI resource that maps to audience 'solid'
|
|
105
|
+
defaultResource: () => 'urn:solid',
|
|
101
106
|
getResourceServerInfo: () => ({
|
|
102
107
|
scope: 'openid webid profile email offline_access',
|
|
103
108
|
accessTokenFormat: 'jwt',
|
|
109
|
+
audience: 'solid', // Solid-OIDC requires this audience
|
|
104
110
|
}),
|
|
105
111
|
useGrantedResource: () => true,
|
|
106
112
|
},
|
|
@@ -176,6 +182,60 @@ export async function createProvider(issuer) {
|
|
|
176
182
|
},
|
|
177
183
|
},
|
|
178
184
|
|
|
185
|
+
// Auto-approve consent by loading/creating grants automatically
|
|
186
|
+
// This skips the consent prompt for all clients (appropriate for test/dev servers)
|
|
187
|
+
loadExistingGrant: async (ctx) => {
|
|
188
|
+
// Check if there's an existing grant for this client/account pair
|
|
189
|
+
const grantId = ctx.oidc.session?.grantIdFor(ctx.oidc.client?.clientId);
|
|
190
|
+
|
|
191
|
+
if (grantId) {
|
|
192
|
+
const existingGrant = await ctx.oidc.provider.Grant.find(grantId);
|
|
193
|
+
if (existingGrant) {
|
|
194
|
+
return existingGrant;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Auto-approve: create a new grant with all requested scopes
|
|
199
|
+
if (ctx.oidc.session?.accountId && ctx.oidc.client?.clientId) {
|
|
200
|
+
const grant = new ctx.oidc.provider.Grant({
|
|
201
|
+
accountId: ctx.oidc.session.accountId,
|
|
202
|
+
clientId: ctx.oidc.client.clientId,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Grant all requested OIDC scopes
|
|
206
|
+
if (ctx.oidc.params?.scope) {
|
|
207
|
+
grant.addOIDCScope(ctx.oidc.params.scope);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Grant all requested resource scopes
|
|
211
|
+
if (ctx.oidc.params?.resource) {
|
|
212
|
+
const resources = Array.isArray(ctx.oidc.params.resource)
|
|
213
|
+
? ctx.oidc.params.resource
|
|
214
|
+
: [ctx.oidc.params.resource];
|
|
215
|
+
for (const resource of resources) {
|
|
216
|
+
grant.addResourceScope(resource, ctx.oidc.params.scope || 'openid');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
await grant.save();
|
|
221
|
+
return grant;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return undefined;
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
// Configure routes with /idp prefix so oidc-provider uses correct paths
|
|
228
|
+
routes: {
|
|
229
|
+
authorization: '/idp/auth',
|
|
230
|
+
token: '/idp/token',
|
|
231
|
+
userinfo: '/idp/me',
|
|
232
|
+
jwks: '/.well-known/jwks.json',
|
|
233
|
+
registration: '/idp/reg',
|
|
234
|
+
introspection: '/idp/token/introspection',
|
|
235
|
+
revocation: '/idp/token/revocation',
|
|
236
|
+
end_session: '/idp/session/end',
|
|
237
|
+
},
|
|
238
|
+
|
|
179
239
|
// Enable refresh token rotation
|
|
180
240
|
rotateRefreshToken: (ctx) => {
|
|
181
241
|
return true;
|
|
@@ -186,6 +246,7 @@ export async function createProvider(issuer) {
|
|
|
186
246
|
grant_types: ['authorization_code', 'refresh_token'],
|
|
187
247
|
response_types: ['code'],
|
|
188
248
|
token_endpoint_auth_method: 'none', // Public clients by default
|
|
249
|
+
id_token_signed_response_alg: 'ES256', // ES256 is what we support
|
|
189
250
|
},
|
|
190
251
|
|
|
191
252
|
// Response modes
|
|
@@ -201,6 +262,11 @@ export async function createProvider(issuer) {
|
|
|
201
262
|
methods: ['S256'],
|
|
202
263
|
},
|
|
203
264
|
|
|
265
|
+
// Enable RS256 for DPoP (CTH uses RS256)
|
|
266
|
+
enabledJWA: {
|
|
267
|
+
dPoPSigningAlgValues: ['ES256', 'RS256', 'Ed25519', 'EdDSA'],
|
|
268
|
+
},
|
|
269
|
+
|
|
204
270
|
// Enable request parameter
|
|
205
271
|
requestObjects: {
|
|
206
272
|
request: false,
|
package/src/rdf/turtle.js
CHANGED
|
@@ -199,9 +199,13 @@ function jsonLdToQuads(jsonLd, baseUri) {
|
|
|
199
199
|
const predicateUri = expandUri(key, context);
|
|
200
200
|
const predicate = namedNode(predicateUri);
|
|
201
201
|
|
|
202
|
+
// Check if context specifies this property should be a URI (@type: "@id")
|
|
203
|
+
const propContext = context[key];
|
|
204
|
+
const isIdType = propContext && typeof propContext === 'object' && propContext['@type'] === '@id';
|
|
205
|
+
|
|
202
206
|
const values = Array.isArray(value) ? value : [value];
|
|
203
207
|
for (const v of values) {
|
|
204
|
-
const object = valueToTerm(v, baseUri, context);
|
|
208
|
+
const object = valueToTerm(v, baseUri, context, isIdType);
|
|
205
209
|
if (object) {
|
|
206
210
|
quads.push(quad(subject, predicate, object));
|
|
207
211
|
}
|
|
@@ -265,14 +269,23 @@ function termToJsonLd(term, baseUri, prefixes) {
|
|
|
265
269
|
|
|
266
270
|
/**
|
|
267
271
|
* Convert JSON-LD value to N3.js term
|
|
272
|
+
* @param {any} value - The value to convert
|
|
273
|
+
* @param {string} baseUri - Base URI for resolving relative URIs
|
|
274
|
+
* @param {object} context - JSON-LD context
|
|
275
|
+
* @param {boolean} isIdType - Whether the property context specifies @type: "@id"
|
|
268
276
|
*/
|
|
269
|
-
function valueToTerm(value, baseUri, context) {
|
|
277
|
+
function valueToTerm(value, baseUri, context, isIdType = false) {
|
|
270
278
|
if (value === null || value === undefined) {
|
|
271
279
|
return null;
|
|
272
280
|
}
|
|
273
281
|
|
|
274
282
|
// Plain values
|
|
275
283
|
if (typeof value === 'string') {
|
|
284
|
+
// If context says this should be a URI, treat it as a named node
|
|
285
|
+
if (isIdType) {
|
|
286
|
+
const uri = resolveUri(value, baseUri);
|
|
287
|
+
return namedNode(uri);
|
|
288
|
+
}
|
|
276
289
|
return literal(value);
|
|
277
290
|
}
|
|
278
291
|
if (typeof value === 'number') {
|
package/src/server.js
CHANGED
|
@@ -16,6 +16,8 @@ import { idpPlugin } from './idp/index.js';
|
|
|
16
16
|
* @param {string} options.idpIssuer - IdP issuer URL (default: server URL)
|
|
17
17
|
* @param {object} options.ssl - SSL configuration { key, cert } (default null)
|
|
18
18
|
* @param {string} options.root - Data directory path (default from env or ./data)
|
|
19
|
+
* @param {boolean} options.subdomains - Enable subdomain-based pods for XSS protection (default false)
|
|
20
|
+
* @param {string} options.baseDomain - Base domain for subdomain pods (e.g., "example.com")
|
|
19
21
|
*/
|
|
20
22
|
export function createServer(options = {}) {
|
|
21
23
|
// Content negotiation is OFF by default - we're a JSON-LD native server
|
|
@@ -25,6 +27,9 @@ export function createServer(options = {}) {
|
|
|
25
27
|
// Identity Provider is OFF by default
|
|
26
28
|
const idpEnabled = options.idp ?? false;
|
|
27
29
|
const idpIssuer = options.idpIssuer;
|
|
30
|
+
// Subdomain mode is OFF by default - use path-based pods
|
|
31
|
+
const subdomainsEnabled = options.subdomains ?? false;
|
|
32
|
+
const baseDomain = options.baseDomain || null;
|
|
28
33
|
|
|
29
34
|
// Set data root via environment variable if provided
|
|
30
35
|
if (options.root) {
|
|
@@ -58,10 +63,29 @@ export function createServer(options = {}) {
|
|
|
58
63
|
fastify.decorateRequest('connegEnabled', null);
|
|
59
64
|
fastify.decorateRequest('notificationsEnabled', null);
|
|
60
65
|
fastify.decorateRequest('idpEnabled', null);
|
|
66
|
+
fastify.decorateRequest('subdomainsEnabled', null);
|
|
67
|
+
fastify.decorateRequest('baseDomain', null);
|
|
68
|
+
fastify.decorateRequest('podName', null);
|
|
61
69
|
fastify.addHook('onRequest', async (request) => {
|
|
62
70
|
request.connegEnabled = connegEnabled;
|
|
63
71
|
request.notificationsEnabled = notificationsEnabled;
|
|
64
72
|
request.idpEnabled = idpEnabled;
|
|
73
|
+
request.subdomainsEnabled = subdomainsEnabled;
|
|
74
|
+
request.baseDomain = baseDomain;
|
|
75
|
+
|
|
76
|
+
// Extract pod name from subdomain if enabled
|
|
77
|
+
if (subdomainsEnabled && baseDomain) {
|
|
78
|
+
const host = request.hostname;
|
|
79
|
+
// Check if host is a subdomain of baseDomain
|
|
80
|
+
if (host !== baseDomain && host.endsWith('.' + baseDomain)) {
|
|
81
|
+
// Extract subdomain (e.g., "alice.example.com" -> "alice")
|
|
82
|
+
const subdomain = host.slice(0, -(baseDomain.length + 1));
|
|
83
|
+
// Only single-level subdomains (no dots)
|
|
84
|
+
if (!subdomain.includes('.')) {
|
|
85
|
+
request.podName = subdomain;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
65
89
|
});
|
|
66
90
|
|
|
67
91
|
// Register WebSocket notifications plugin if enabled
|
package/src/utils/url.js
CHANGED
|
@@ -19,6 +19,58 @@ export function urlToPath(urlPath) {
|
|
|
19
19
|
return path.join(DATA_ROOT, normalized);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Convert URL path to filesystem path in subdomain mode
|
|
24
|
+
* In subdomain mode, the pod is determined by the hostname, not the path
|
|
25
|
+
* @param {string} urlPath - The URL path (e.g., /public/file.txt)
|
|
26
|
+
* @param {string} podName - The pod name from subdomain (e.g., "alice")
|
|
27
|
+
* @returns {string} - Filesystem path (e.g., DATA_ROOT/alice/public/file.txt)
|
|
28
|
+
*/
|
|
29
|
+
export function urlToPathWithPod(urlPath, podName) {
|
|
30
|
+
// Normalize: remove leading slash, decode URI
|
|
31
|
+
let normalized = urlPath.startsWith('/') ? urlPath.slice(1) : urlPath;
|
|
32
|
+
normalized = decodeURIComponent(normalized);
|
|
33
|
+
|
|
34
|
+
// Security: prevent path traversal
|
|
35
|
+
normalized = normalized.replace(/\.\./g, '');
|
|
36
|
+
|
|
37
|
+
// Prepend pod name to path
|
|
38
|
+
return path.join(DATA_ROOT, podName, normalized);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get the effective path for a request (subdomain-aware)
|
|
43
|
+
* @param {object} request - Fastify request object
|
|
44
|
+
* @returns {string} - Filesystem path
|
|
45
|
+
*/
|
|
46
|
+
export function getPathFromRequest(request) {
|
|
47
|
+
const urlPath = request.url.split('?')[0];
|
|
48
|
+
|
|
49
|
+
// In subdomain mode with a recognized pod subdomain
|
|
50
|
+
if (request.subdomainsEnabled && request.podName) {
|
|
51
|
+
return urlToPathWithPod(urlPath, request.podName);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Path-based mode (default)
|
|
55
|
+
return urlToPath(urlPath);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get the effective URL path for a request (with pod prefix in subdomain mode)
|
|
60
|
+
* @param {object} request - Fastify request object
|
|
61
|
+
* @returns {string} - URL path with pod prefix if needed
|
|
62
|
+
*/
|
|
63
|
+
export function getEffectiveUrlPath(request) {
|
|
64
|
+
const urlPath = request.url.split('?')[0];
|
|
65
|
+
|
|
66
|
+
// In subdomain mode with a recognized pod subdomain, prepend pod name
|
|
67
|
+
if (request.subdomainsEnabled && request.podName) {
|
|
68
|
+
return '/' + request.podName + urlPath;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return urlPath;
|
|
72
|
+
}
|
|
73
|
+
|
|
22
74
|
/**
|
|
23
75
|
* Check if URL path represents a container (ends with /)
|
|
24
76
|
* @param {string} urlPath
|
package/src/wac/parser.js
CHANGED
|
@@ -190,9 +190,11 @@ export function generateOwnerAcl(resourceUrl, ownerWebId, isContainer = false) {
|
|
|
190
190
|
];
|
|
191
191
|
|
|
192
192
|
// Add default rules for containers
|
|
193
|
+
// Only owner gets default - children don't inherit public read
|
|
193
194
|
if (isContainer) {
|
|
194
195
|
graph[0]['acl:default'] = { '@id': resourceUrl };
|
|
195
|
-
|
|
196
|
+
// Note: intentionally not adding default to #public
|
|
197
|
+
// so child resources require authentication by default
|
|
196
198
|
}
|
|
197
199
|
|
|
198
200
|
return {
|
|
@@ -276,6 +278,46 @@ export function generateInboxAcl(resourceUrl, ownerWebId) {
|
|
|
276
278
|
};
|
|
277
279
|
}
|
|
278
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Generate a public folder ACL (owner full control, public read with inheritance)
|
|
283
|
+
* Used for /public/ folders where content should be publicly readable
|
|
284
|
+
* @param {string} resourceUrl - URL of the folder
|
|
285
|
+
* @param {string} ownerWebId - WebID of the owner
|
|
286
|
+
* @returns {object} JSON-LD ACL document
|
|
287
|
+
*/
|
|
288
|
+
export function generatePublicFolderAcl(resourceUrl, ownerWebId) {
|
|
289
|
+
return {
|
|
290
|
+
'@context': {
|
|
291
|
+
'acl': ACL,
|
|
292
|
+
'foaf': FOAF
|
|
293
|
+
},
|
|
294
|
+
'@graph': [
|
|
295
|
+
{
|
|
296
|
+
'@id': '#owner',
|
|
297
|
+
'@type': 'acl:Authorization',
|
|
298
|
+
'acl:agent': { '@id': ownerWebId },
|
|
299
|
+
'acl:accessTo': { '@id': resourceUrl },
|
|
300
|
+
'acl:default': { '@id': resourceUrl },
|
|
301
|
+
'acl:mode': [
|
|
302
|
+
{ '@id': 'acl:Read' },
|
|
303
|
+
{ '@id': 'acl:Write' },
|
|
304
|
+
{ '@id': 'acl:Control' }
|
|
305
|
+
]
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
'@id': '#public',
|
|
309
|
+
'@type': 'acl:Authorization',
|
|
310
|
+
'acl:agentClass': { '@id': 'foaf:Agent' },
|
|
311
|
+
'acl:accessTo': { '@id': resourceUrl },
|
|
312
|
+
'acl:default': { '@id': resourceUrl },
|
|
313
|
+
'acl:mode': [
|
|
314
|
+
{ '@id': 'acl:Read' }
|
|
315
|
+
]
|
|
316
|
+
}
|
|
317
|
+
]
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
279
321
|
/**
|
|
280
322
|
* Serialize ACL to JSON string
|
|
281
323
|
*/
|