javascript-solid-server 0.0.19 → 0.0.21
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 +3 -1
- package/package.json +1 -1
- package/src/handlers/container.js +62 -46
- package/src/handlers/resource.js +35 -30
- package/src/idp/accounts.js +62 -12
- package/src/idp/index.js +11 -0
- package/src/idp/interactions.js +109 -10
- package/src/idp/views.js +62 -2
- package/test/idp.test.js +0 -12
- package/test/patch.test.js +8 -3
- package/test/sparql-update.test.js +8 -3
- package/test-data-idp-accounts/.idp/accounts/318e7a79-23d5-4e0b-8fa1-b63cfaee87e1.json +10 -0
- package/test-data-idp-accounts/.idp/accounts/_email_index.json +1 -1
- package/test-data-idp-accounts/.idp/accounts/_username_index.json +3 -0
- package/test-data-idp-accounts/.idp/accounts/_webid_index.json +1 -1
- package/test-data-idp-accounts/.idp/keys/jwks.json +8 -8
- package/test-data-idp-accounts/.idp/accounts/95d4d470-1e8c-4b17-bcff-a62b8a54813b.json +0 -9
package/package.json
CHANGED
|
@@ -121,6 +121,63 @@ export async function handlePost(request, reply) {
|
|
|
121
121
|
return reply.code(201).send();
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Create pod directory structure (reusable for registration)
|
|
126
|
+
* @param {string} name - Pod name (username)
|
|
127
|
+
* @param {string} webId - User's WebID URI
|
|
128
|
+
* @param {string} baseUrl - Base URL (without trailing slash)
|
|
129
|
+
*/
|
|
130
|
+
export async function createPodStructure(name, webId, baseUrl) {
|
|
131
|
+
const podPath = `/${name}/`;
|
|
132
|
+
const podUri = `${baseUrl}/${name}/`;
|
|
133
|
+
const issuer = baseUrl + '/';
|
|
134
|
+
|
|
135
|
+
// Create pod directory structure
|
|
136
|
+
await storage.createContainer(podPath);
|
|
137
|
+
await storage.createContainer(`${podPath}inbox/`);
|
|
138
|
+
await storage.createContainer(`${podPath}public/`);
|
|
139
|
+
await storage.createContainer(`${podPath}private/`);
|
|
140
|
+
await storage.createContainer(`${podPath}settings/`);
|
|
141
|
+
|
|
142
|
+
// Generate and write WebID profile as index.html at pod root
|
|
143
|
+
const profileHtml = generateProfile({ webId, name, podUri, issuer });
|
|
144
|
+
await storage.write(`${podPath}index.html`, profileHtml);
|
|
145
|
+
|
|
146
|
+
// Generate and write preferences
|
|
147
|
+
const prefs = generatePreferences({ webId, podUri });
|
|
148
|
+
await storage.write(`${podPath}settings/prefs`, serialize(prefs));
|
|
149
|
+
|
|
150
|
+
// Generate and write type indexes
|
|
151
|
+
const publicTypeIndex = generateTypeIndex(`${podUri}settings/publicTypeIndex`);
|
|
152
|
+
await storage.write(`${podPath}settings/publicTypeIndex`, serialize(publicTypeIndex));
|
|
153
|
+
|
|
154
|
+
const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex`);
|
|
155
|
+
await storage.write(`${podPath}settings/privateTypeIndex`, serialize(privateTypeIndex));
|
|
156
|
+
|
|
157
|
+
// Create default ACL files
|
|
158
|
+
// Pod root: owner full control, public read
|
|
159
|
+
const rootAcl = generateOwnerAcl(podUri, webId, true);
|
|
160
|
+
await storage.write(`${podPath}.acl`, serializeAcl(rootAcl));
|
|
161
|
+
|
|
162
|
+
// Private folder: owner only (no public)
|
|
163
|
+
const privateAcl = generatePrivateAcl(`${podUri}private/`, webId);
|
|
164
|
+
await storage.write(`${podPath}private/.acl`, serializeAcl(privateAcl));
|
|
165
|
+
|
|
166
|
+
// Settings folder: owner only
|
|
167
|
+
const settingsAcl = generatePrivateAcl(`${podUri}settings/`, webId);
|
|
168
|
+
await storage.write(`${podPath}settings/.acl`, serializeAcl(settingsAcl));
|
|
169
|
+
|
|
170
|
+
// Inbox: owner full, public append
|
|
171
|
+
const inboxAcl = generateInboxAcl(`${podUri}inbox/`, webId);
|
|
172
|
+
await storage.write(`${podPath}inbox/.acl`, serializeAcl(inboxAcl));
|
|
173
|
+
|
|
174
|
+
// Public folder: owner full, public read (with inheritance)
|
|
175
|
+
const publicAcl = generatePublicFolderAcl(`${podUri}public/`, webId);
|
|
176
|
+
await storage.write(`${podPath}public/.acl`, serializeAcl(publicAcl));
|
|
177
|
+
|
|
178
|
+
return { podPath, podUri };
|
|
179
|
+
}
|
|
180
|
+
|
|
124
181
|
/**
|
|
125
182
|
* Create a pod (container) for a user
|
|
126
183
|
* POST /.pods with { "name": "alice" }
|
|
@@ -149,8 +206,8 @@ export async function handleCreatePod(request, reply) {
|
|
|
149
206
|
if (!email || typeof email !== 'string') {
|
|
150
207
|
return reply.code(400).send({ error: 'Email required for account creation' });
|
|
151
208
|
}
|
|
152
|
-
if (!password
|
|
153
|
-
return reply.code(400).send({ error: 'Password required
|
|
209
|
+
if (!password) {
|
|
210
|
+
return reply.code(400).send({ error: 'Password required' });
|
|
154
211
|
}
|
|
155
212
|
}
|
|
156
213
|
|
|
@@ -189,49 +246,8 @@ export async function handleCreatePod(request, reply) {
|
|
|
189
246
|
const issuer = baseUri + '/';
|
|
190
247
|
|
|
191
248
|
try {
|
|
192
|
-
//
|
|
193
|
-
await
|
|
194
|
-
await storage.createContainer(`${podPath}inbox/`);
|
|
195
|
-
await storage.createContainer(`${podPath}public/`);
|
|
196
|
-
await storage.createContainer(`${podPath}private/`);
|
|
197
|
-
await storage.createContainer(`${podPath}settings/`);
|
|
198
|
-
|
|
199
|
-
// Generate and write WebID profile as index.html at pod root
|
|
200
|
-
const profileHtml = generateProfile({ webId, name, podUri, issuer });
|
|
201
|
-
await storage.write(`${podPath}index.html`, profileHtml);
|
|
202
|
-
|
|
203
|
-
// Generate and write preferences
|
|
204
|
-
const prefs = generatePreferences({ webId, podUri });
|
|
205
|
-
await storage.write(`${podPath}settings/prefs`, serialize(prefs));
|
|
206
|
-
|
|
207
|
-
// Generate and write type indexes
|
|
208
|
-
const publicTypeIndex = generateTypeIndex(`${podUri}settings/publicTypeIndex`);
|
|
209
|
-
await storage.write(`${podPath}settings/publicTypeIndex`, serialize(publicTypeIndex));
|
|
210
|
-
|
|
211
|
-
const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex`);
|
|
212
|
-
await storage.write(`${podPath}settings/privateTypeIndex`, serialize(privateTypeIndex));
|
|
213
|
-
|
|
214
|
-
// Create default ACL files
|
|
215
|
-
// Pod root: owner full control, public read
|
|
216
|
-
const rootAcl = generateOwnerAcl(podUri, webId, true);
|
|
217
|
-
await storage.write(`${podPath}.acl`, serializeAcl(rootAcl));
|
|
218
|
-
|
|
219
|
-
// Private folder: owner only (no public)
|
|
220
|
-
const privateAcl = generatePrivateAcl(`${podUri}private/`, webId);
|
|
221
|
-
await storage.write(`${podPath}private/.acl`, serializeAcl(privateAcl));
|
|
222
|
-
|
|
223
|
-
// Settings folder: owner only
|
|
224
|
-
const settingsAcl = generatePrivateAcl(`${podUri}settings/`, webId);
|
|
225
|
-
await storage.write(`${podPath}settings/.acl`, serializeAcl(settingsAcl));
|
|
226
|
-
|
|
227
|
-
// Inbox: owner full, public append
|
|
228
|
-
const inboxAcl = generateInboxAcl(`${podUri}inbox/`, webId);
|
|
229
|
-
await storage.write(`${podPath}inbox/.acl`, serializeAcl(inboxAcl));
|
|
230
|
-
|
|
231
|
-
// Public folder: owner full, public read (with inheritance)
|
|
232
|
-
const publicAcl = generatePublicFolderAcl(`${podUri}public/`, webId);
|
|
233
|
-
await storage.write(`${podPath}public/.acl`, serializeAcl(publicAcl));
|
|
234
|
-
|
|
249
|
+
// Use shared pod creation function
|
|
250
|
+
await createPodStructure(name, webId, baseUri);
|
|
235
251
|
} catch (err) {
|
|
236
252
|
console.error('Pod creation error:', err);
|
|
237
253
|
// Cleanup on failure
|
|
@@ -249,7 +265,7 @@ export async function handleCreatePod(request, reply) {
|
|
|
249
265
|
if (idpEnabled) {
|
|
250
266
|
try {
|
|
251
267
|
const { createAccount } = await import('../idp/accounts.js');
|
|
252
|
-
await createAccount({ email, password, webId, podName: name });
|
|
268
|
+
await createAccount({ username: name, email, password, webId, podName: name });
|
|
253
269
|
|
|
254
270
|
return reply.code(201).send({
|
|
255
271
|
name,
|
package/src/handlers/resource.js
CHANGED
|
@@ -451,40 +451,44 @@ export async function handlePatch(request, reply) {
|
|
|
451
451
|
});
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
-
// Check if resource exists
|
|
454
|
+
// Check if resource exists - PATCH can create resources in Solid
|
|
455
455
|
const stats = await storage.stat(storagePath);
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
if (ifMatch) {
|
|
467
|
-
const check = checkIfMatch(ifMatch, stats.etag);
|
|
468
|
-
if (!check.ok) {
|
|
469
|
-
return reply.code(check.status).send({ error: check.error });
|
|
456
|
+
const resourceExists = !!stats;
|
|
457
|
+
|
|
458
|
+
// Check If-Match header (for safe updates) - only if resource exists
|
|
459
|
+
if (resourceExists) {
|
|
460
|
+
const ifMatch = request.headers['if-match'];
|
|
461
|
+
if (ifMatch) {
|
|
462
|
+
const check = checkIfMatch(ifMatch, stats.etag);
|
|
463
|
+
if (!check.ok) {
|
|
464
|
+
return reply.code(check.status).send({ error: check.error });
|
|
465
|
+
}
|
|
470
466
|
}
|
|
471
467
|
}
|
|
472
468
|
|
|
473
|
-
// Read existing content
|
|
474
|
-
const existingContent = await storage.read(storagePath);
|
|
475
|
-
if (existingContent === null) {
|
|
476
|
-
return reply.code(500).send({ error: 'Read error' });
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
// Parse existing document as JSON-LD
|
|
469
|
+
// Read existing content or start with empty JSON-LD document
|
|
480
470
|
let document;
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
471
|
+
if (resourceExists) {
|
|
472
|
+
const existingContent = await storage.read(storagePath);
|
|
473
|
+
if (existingContent === null) {
|
|
474
|
+
return reply.code(500).send({ error: 'Read error' });
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Parse existing document as JSON-LD
|
|
478
|
+
try {
|
|
479
|
+
document = JSON.parse(existingContent.toString());
|
|
480
|
+
} catch (e) {
|
|
481
|
+
return reply.code(409).send({
|
|
482
|
+
error: 'Conflict',
|
|
483
|
+
message: 'Resource is not valid JSON-LD and cannot be patched'
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
} else {
|
|
487
|
+
// Create empty JSON-LD document for new resource
|
|
488
|
+
document = {
|
|
489
|
+
'@context': {},
|
|
490
|
+
'@graph': []
|
|
491
|
+
};
|
|
488
492
|
}
|
|
489
493
|
|
|
490
494
|
// Parse the patch
|
|
@@ -553,5 +557,6 @@ export async function handlePatch(request, reply) {
|
|
|
553
557
|
emitChange(resourceUrl);
|
|
554
558
|
}
|
|
555
559
|
|
|
556
|
-
|
|
560
|
+
// Return 201 Created if resource was created, 204 No Content if updated
|
|
561
|
+
return reply.code(resourceExists ? 204 : 201).send();
|
|
557
562
|
}
|
package/src/idp/accounts.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Account management for the Identity Provider
|
|
3
|
-
* Handles user accounts with
|
|
3
|
+
* Handles user accounts with username/password authentication
|
|
4
|
+
* Email is optional - internally uses username@jss if not provided
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import bcrypt from 'bcrypt';
|
|
@@ -8,6 +9,9 @@ import crypto from 'crypto';
|
|
|
8
9
|
import fs from 'fs-extra';
|
|
9
10
|
import path from 'path';
|
|
10
11
|
|
|
12
|
+
// Internal domain for generated emails
|
|
13
|
+
const INTERNAL_DOMAIN = 'jss';
|
|
14
|
+
|
|
11
15
|
/**
|
|
12
16
|
* Get accounts directory (computed dynamically to support changing DATA_ROOT)
|
|
13
17
|
*/
|
|
@@ -16,6 +20,10 @@ function getAccountsDir() {
|
|
|
16
20
|
return path.join(dataRoot, '.idp', 'accounts');
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
function getUsernameIndexPath() {
|
|
24
|
+
return path.join(getAccountsDir(), '_username_index.json');
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
function getEmailIndexPath() {
|
|
20
28
|
return path.join(getAccountsDir(), '_email_index.json');
|
|
21
29
|
}
|
|
@@ -55,21 +63,34 @@ async function saveIndex(indexPath, index) {
|
|
|
55
63
|
/**
|
|
56
64
|
* Create a new user account
|
|
57
65
|
* @param {object} options - Account options
|
|
58
|
-
* @param {string} options.
|
|
66
|
+
* @param {string} options.username - Username (typically same as podName)
|
|
59
67
|
* @param {string} options.password - Plain text password
|
|
60
68
|
* @param {string} options.webId - User's WebID URI
|
|
61
69
|
* @param {string} options.podName - Pod name
|
|
70
|
+
* @param {string} [options.email] - Optional email (defaults to username@jss)
|
|
62
71
|
* @returns {Promise<object>} - Created account (without password)
|
|
63
72
|
*/
|
|
64
|
-
export async function createAccount({
|
|
73
|
+
export async function createAccount({ username, password, webId, podName, email }) {
|
|
65
74
|
await ensureDir();
|
|
66
75
|
|
|
67
|
-
const
|
|
76
|
+
const normalizedUsername = username.toLowerCase().trim();
|
|
77
|
+
// Use provided email or generate internal one
|
|
78
|
+
const normalizedEmail = email
|
|
79
|
+
? email.toLowerCase().trim()
|
|
80
|
+
: `${normalizedUsername}@${INTERNAL_DOMAIN}`;
|
|
81
|
+
|
|
82
|
+
// Check username uniqueness
|
|
83
|
+
const existingByUsername = await findByUsername(normalizedUsername);
|
|
84
|
+
if (existingByUsername) {
|
|
85
|
+
throw new Error('Username already taken');
|
|
86
|
+
}
|
|
68
87
|
|
|
69
|
-
// Check email uniqueness
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
88
|
+
// Check email uniqueness (if real email provided)
|
|
89
|
+
if (email) {
|
|
90
|
+
const existingByEmail = await findByEmail(normalizedEmail);
|
|
91
|
+
if (existingByEmail) {
|
|
92
|
+
throw new Error('Email already registered');
|
|
93
|
+
}
|
|
73
94
|
}
|
|
74
95
|
|
|
75
96
|
// Check webId uniqueness
|
|
@@ -84,6 +105,7 @@ export async function createAccount({ email, password, webId, podName }) {
|
|
|
84
105
|
|
|
85
106
|
const account = {
|
|
86
107
|
id,
|
|
108
|
+
username: normalizedUsername,
|
|
87
109
|
email: normalizedEmail,
|
|
88
110
|
passwordHash,
|
|
89
111
|
webId,
|
|
@@ -96,6 +118,11 @@ export async function createAccount({ email, password, webId, podName }) {
|
|
|
96
118
|
const accountPath = path.join(getAccountsDir(), `${id}.json`);
|
|
97
119
|
await fs.writeJson(accountPath, account, { spaces: 2 });
|
|
98
120
|
|
|
121
|
+
// Update username index
|
|
122
|
+
const usernameIndex = await loadIndex(getUsernameIndexPath());
|
|
123
|
+
usernameIndex[normalizedUsername] = id;
|
|
124
|
+
await saveIndex(getUsernameIndexPath(), usernameIndex);
|
|
125
|
+
|
|
99
126
|
// Update email index
|
|
100
127
|
const emailIndex = await loadIndex(getEmailIndexPath());
|
|
101
128
|
emailIndex[normalizedEmail] = id;
|
|
@@ -112,13 +139,17 @@ export async function createAccount({ email, password, webId, podName }) {
|
|
|
112
139
|
}
|
|
113
140
|
|
|
114
141
|
/**
|
|
115
|
-
* Authenticate a user with email and password
|
|
116
|
-
* @param {string}
|
|
142
|
+
* Authenticate a user with username/email and password
|
|
143
|
+
* @param {string} identifier - Username or email
|
|
117
144
|
* @param {string} password - Plain text password
|
|
118
145
|
* @returns {Promise<object|null>} - Account if valid, null if invalid
|
|
119
146
|
*/
|
|
120
|
-
export async function authenticate(
|
|
121
|
-
|
|
147
|
+
export async function authenticate(identifier, password) {
|
|
148
|
+
// Try to find by username first, then by email
|
|
149
|
+
let account = await findByUsername(identifier);
|
|
150
|
+
if (!account) {
|
|
151
|
+
account = await findByEmail(identifier);
|
|
152
|
+
}
|
|
122
153
|
if (!account) return null;
|
|
123
154
|
|
|
124
155
|
const valid = await bcrypt.compare(password, account.passwordHash);
|
|
@@ -149,6 +180,19 @@ export async function findById(id) {
|
|
|
149
180
|
}
|
|
150
181
|
}
|
|
151
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Find an account by username
|
|
185
|
+
* @param {string} username - Username
|
|
186
|
+
* @returns {Promise<object|null>} - Account or null
|
|
187
|
+
*/
|
|
188
|
+
export async function findByUsername(username) {
|
|
189
|
+
const normalizedUsername = username.toLowerCase().trim();
|
|
190
|
+
const usernameIndex = await loadIndex(getUsernameIndexPath());
|
|
191
|
+
const id = usernameIndex[normalizedUsername];
|
|
192
|
+
if (!id) return null;
|
|
193
|
+
return findById(id);
|
|
194
|
+
}
|
|
195
|
+
|
|
152
196
|
/**
|
|
153
197
|
* Find an account by email
|
|
154
198
|
* @param {string} email - User email
|
|
@@ -201,6 +245,12 @@ export async function deleteAccount(id) {
|
|
|
201
245
|
if (!account) return;
|
|
202
246
|
|
|
203
247
|
// Remove from indexes
|
|
248
|
+
if (account.username) {
|
|
249
|
+
const usernameIndex = await loadIndex(getUsernameIndexPath());
|
|
250
|
+
delete usernameIndex[account.username];
|
|
251
|
+
await saveIndex(getUsernameIndexPath(), usernameIndex);
|
|
252
|
+
}
|
|
253
|
+
|
|
204
254
|
const emailIndex = await loadIndex(getEmailIndexPath());
|
|
205
255
|
delete emailIndex[account.email];
|
|
206
256
|
await saveIndex(getEmailIndexPath(), emailIndex);
|
package/src/idp/index.js
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
handleLogin,
|
|
12
12
|
handleConsent,
|
|
13
13
|
handleAbort,
|
|
14
|
+
handleRegisterGet,
|
|
15
|
+
handleRegisterPost,
|
|
14
16
|
} from './interactions.js';
|
|
15
17
|
import {
|
|
16
18
|
handleCredentials,
|
|
@@ -220,6 +222,15 @@ export async function idpPlugin(fastify, options) {
|
|
|
220
222
|
return handleAbort(request, reply, provider);
|
|
221
223
|
});
|
|
222
224
|
|
|
225
|
+
// Registration routes
|
|
226
|
+
fastify.get('/idp/register', async (request, reply) => {
|
|
227
|
+
return handleRegisterGet(request, reply);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
fastify.post('/idp/register', async (request, reply) => {
|
|
231
|
+
return handleRegisterPost(request, reply, issuer);
|
|
232
|
+
});
|
|
233
|
+
|
|
223
234
|
fastify.log.info(`IdP initialized with issuer: ${issuer}`);
|
|
224
235
|
}
|
|
225
236
|
|
package/src/idp/interactions.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Interaction handlers for login and
|
|
2
|
+
* Interaction handlers for login, consent, and registration flows
|
|
3
3
|
* Handles the user-facing parts of the authentication flow
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { authenticate, findById } from './accounts.js';
|
|
7
|
-
import { loginPage, consentPage, errorPage } from './views.js';
|
|
6
|
+
import { authenticate, findById, createAccount } from './accounts.js';
|
|
7
|
+
import { loginPage, consentPage, errorPage, registerPage } from './views.js';
|
|
8
|
+
import * as storage from '../storage/filesystem.js';
|
|
9
|
+
import { createPodStructure } from '../handlers/container.js';
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Handle GET /idp/interaction/:uid
|
|
@@ -81,11 +83,11 @@ export async function handleLogin(request, reply, provider) {
|
|
|
81
83
|
}
|
|
82
84
|
// If it's already an object, use as-is
|
|
83
85
|
|
|
84
|
-
// Support
|
|
85
|
-
const
|
|
86
|
+
// Support username, email, or legacy 'email' field for backwards compatibility
|
|
87
|
+
const identifier = parsedBody.username || parsedBody.email;
|
|
86
88
|
const password = parsedBody.password;
|
|
87
89
|
|
|
88
|
-
request.log.info({
|
|
90
|
+
request.log.info({ identifier, hasPassword: !!password, bodyType: typeof request.body, keys: Object.keys(parsedBody) }, 'Login attempt');
|
|
89
91
|
|
|
90
92
|
try {
|
|
91
93
|
const interaction = await provider.Interaction.find(uid);
|
|
@@ -94,16 +96,16 @@ export async function handleLogin(request, reply, provider) {
|
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
// Validate input
|
|
97
|
-
if (!
|
|
98
|
-
interaction.lastError = '
|
|
99
|
+
if (!identifier || !password) {
|
|
100
|
+
interaction.lastError = 'Username and password are required';
|
|
99
101
|
await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
|
|
100
102
|
return reply.redirect(`/idp/interaction/${uid}`);
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
// Authenticate
|
|
104
|
-
const account = await authenticate(
|
|
106
|
+
const account = await authenticate(identifier, password);
|
|
105
107
|
if (!account) {
|
|
106
|
-
interaction.lastError = 'Invalid
|
|
108
|
+
interaction.lastError = 'Invalid username or password';
|
|
107
109
|
await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
|
|
108
110
|
return reply.redirect(`/idp/interaction/${uid}`);
|
|
109
111
|
}
|
|
@@ -291,3 +293,100 @@ export async function handleAbort(request, reply, provider) {
|
|
|
291
293
|
return reply.code(500).type('text/html').send(errorPage('Error', err.message));
|
|
292
294
|
}
|
|
293
295
|
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Handle GET /idp/register
|
|
299
|
+
* Shows registration page
|
|
300
|
+
*/
|
|
301
|
+
export async function handleRegisterGet(request, reply) {
|
|
302
|
+
const uid = request.query.uid || null;
|
|
303
|
+
return reply.type('text/html').send(registerPage(uid));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Handle POST /idp/register
|
|
308
|
+
* Creates account and pod
|
|
309
|
+
*/
|
|
310
|
+
export async function handleRegisterPost(request, reply, issuer) {
|
|
311
|
+
const uid = request.query.uid || null;
|
|
312
|
+
|
|
313
|
+
// Parse body
|
|
314
|
+
let parsedBody = request.body || {};
|
|
315
|
+
const contentType = request.headers['content-type'] || '';
|
|
316
|
+
|
|
317
|
+
if (Buffer.isBuffer(parsedBody)) {
|
|
318
|
+
const bodyStr = parsedBody.toString();
|
|
319
|
+
if (contentType.includes('application/json')) {
|
|
320
|
+
try {
|
|
321
|
+
parsedBody = JSON.parse(bodyStr);
|
|
322
|
+
} catch (e) {
|
|
323
|
+
parsedBody = {};
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
const params = new URLSearchParams(bodyStr);
|
|
327
|
+
parsedBody = Object.fromEntries(params.entries());
|
|
328
|
+
}
|
|
329
|
+
} else if (typeof parsedBody === 'string') {
|
|
330
|
+
const params = new URLSearchParams(parsedBody);
|
|
331
|
+
parsedBody = Object.fromEntries(params.entries());
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const { username, password, confirmPassword } = parsedBody;
|
|
335
|
+
|
|
336
|
+
// Validate input
|
|
337
|
+
if (!username || !password) {
|
|
338
|
+
return reply.type('text/html').send(registerPage(uid, 'Username and password are required'));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Validate username format
|
|
342
|
+
const usernameRegex = /^[a-z0-9]+$/;
|
|
343
|
+
if (!usernameRegex.test(username)) {
|
|
344
|
+
return reply.type('text/html').send(registerPage(uid, 'Username must contain only lowercase letters and numbers'));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (username.length < 3) {
|
|
348
|
+
return reply.type('text/html').send(registerPage(uid, 'Username must be at least 3 characters'));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
if (password !== confirmPassword) {
|
|
353
|
+
return reply.type('text/html').send(registerPage(uid, 'Passwords do not match'));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
// Build URLs
|
|
358
|
+
const baseUrl = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer;
|
|
359
|
+
const podUri = `${baseUrl}/${username}/`;
|
|
360
|
+
const webId = `${podUri}#me`;
|
|
361
|
+
|
|
362
|
+
// Check if pod already exists
|
|
363
|
+
const podPath = `${username}/`;
|
|
364
|
+
const podExists = await storage.exists(podPath);
|
|
365
|
+
if (podExists) {
|
|
366
|
+
return reply.type('text/html').send(registerPage(uid, 'Username is already taken'));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Create pod structure
|
|
370
|
+
await createPodStructure(username, webId, baseUrl);
|
|
371
|
+
|
|
372
|
+
// Create account
|
|
373
|
+
await createAccount({
|
|
374
|
+
username,
|
|
375
|
+
password,
|
|
376
|
+
webId,
|
|
377
|
+
podName: username,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
request.log.info({ username, webId }, 'Account and pod created');
|
|
381
|
+
|
|
382
|
+
// Redirect to login
|
|
383
|
+
if (uid) {
|
|
384
|
+
return reply.redirect(`/idp/interaction/${uid}`);
|
|
385
|
+
} else {
|
|
386
|
+
return reply.type('text/html').send(registerPage(null, null, `Account created! You can now sign in as "${username}".`));
|
|
387
|
+
}
|
|
388
|
+
} catch (err) {
|
|
389
|
+
request.log.error(err, 'Registration error');
|
|
390
|
+
return reply.type('text/html').send(registerPage(uid, err.message));
|
|
391
|
+
}
|
|
392
|
+
}
|
package/src/idp/views.js
CHANGED
|
@@ -52,6 +52,7 @@ const styles = `
|
|
|
52
52
|
color: #333;
|
|
53
53
|
margin-bottom: 6px;
|
|
54
54
|
}
|
|
55
|
+
input[type="text"],
|
|
55
56
|
input[type="email"],
|
|
56
57
|
input[type="password"] {
|
|
57
58
|
width: 100%;
|
|
@@ -160,6 +161,8 @@ const scopeDescriptions = {
|
|
|
160
161
|
* Login page HTML
|
|
161
162
|
*/
|
|
162
163
|
export function loginPage(uid, clientId, error = null) {
|
|
164
|
+
const appName = clientId || 'An application';
|
|
165
|
+
|
|
163
166
|
return `
|
|
164
167
|
<!DOCTYPE html>
|
|
165
168
|
<html lang="en">
|
|
@@ -175,11 +178,16 @@ export function loginPage(uid, clientId, error = null) {
|
|
|
175
178
|
<h1>Sign In</h1>
|
|
176
179
|
<p class="subtitle">Sign in to your Solid Pod</p>
|
|
177
180
|
|
|
181
|
+
<div class="client-info">
|
|
182
|
+
<div class="client-name">${escapeHtml(appName)}</div>
|
|
183
|
+
<div class="client-uri">is requesting access to your pod</div>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
178
186
|
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
|
179
187
|
|
|
180
188
|
<form method="POST" action="/idp/interaction/${uid}/login">
|
|
181
|
-
<label for="
|
|
182
|
-
<input type="
|
|
189
|
+
<label for="username">Username</label>
|
|
190
|
+
<input type="text" id="username" name="username" required autofocus placeholder="Your username">
|
|
183
191
|
|
|
184
192
|
<label for="password">Password</label>
|
|
185
193
|
<input type="password" id="password" name="password" required placeholder="Your password">
|
|
@@ -190,6 +198,10 @@ export function loginPage(uid, clientId, error = null) {
|
|
|
190
198
|
<form method="POST" action="/idp/interaction/${uid}/abort">
|
|
191
199
|
<button type="submit" class="btn btn-secondary">Cancel</button>
|
|
192
200
|
</form>
|
|
201
|
+
|
|
202
|
+
<p style="text-align: center; margin-top: 24px; color: #666; font-size: 14px;">
|
|
203
|
+
Don't have an account? <a href="/idp/register?uid=${uid}" style="color: #0066cc;">Register</a>
|
|
204
|
+
</p>
|
|
193
205
|
</div>
|
|
194
206
|
</body>
|
|
195
207
|
</html>
|
|
@@ -281,6 +293,54 @@ export function errorPage(title, message) {
|
|
|
281
293
|
`;
|
|
282
294
|
}
|
|
283
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Registration page HTML
|
|
298
|
+
*/
|
|
299
|
+
export function registerPage(uid = null, error = null, success = null) {
|
|
300
|
+
return `
|
|
301
|
+
<!DOCTYPE html>
|
|
302
|
+
<html lang="en">
|
|
303
|
+
<head>
|
|
304
|
+
<meta charset="UTF-8">
|
|
305
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
306
|
+
<title>Register - Solid IdP</title>
|
|
307
|
+
<style>${styles}</style>
|
|
308
|
+
</head>
|
|
309
|
+
<body>
|
|
310
|
+
<div class="container">
|
|
311
|
+
<div class="logo">${solidLogo}</div>
|
|
312
|
+
<h1>Create Account</h1>
|
|
313
|
+
<p class="subtitle">Register for a new Solid Pod</p>
|
|
314
|
+
|
|
315
|
+
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
|
316
|
+
${success ? `<div class="error" style="background: #efe; border-color: #cfc; color: #060;">${escapeHtml(success)}</div>` : ''}
|
|
317
|
+
|
|
318
|
+
<form method="POST" action="/idp/register${uid ? `?uid=${uid}` : ''}">
|
|
319
|
+
<label for="username">Username</label>
|
|
320
|
+
<input type="text" id="username" name="username" required autofocus
|
|
321
|
+
placeholder="Choose a username" pattern="[a-z0-9]+"
|
|
322
|
+
title="Lowercase letters and numbers only">
|
|
323
|
+
|
|
324
|
+
<label for="password">Password</label>
|
|
325
|
+
<input type="password" id="password" name="password" required
|
|
326
|
+
placeholder="Choose a password">
|
|
327
|
+
|
|
328
|
+
<label for="confirmPassword">Confirm Password</label>
|
|
329
|
+
<input type="password" id="confirmPassword" name="confirmPassword" required
|
|
330
|
+
placeholder="Confirm your password">
|
|
331
|
+
|
|
332
|
+
<button type="submit" class="btn btn-primary">Create Account</button>
|
|
333
|
+
</form>
|
|
334
|
+
|
|
335
|
+
<p style="text-align: center; margin-top: 24px; color: #666; font-size: 14px;">
|
|
336
|
+
Already have an account? <a href="${uid ? `/idp/interaction/${uid}` : '/idp/auth'}" style="color: #0066cc;">Sign In</a>
|
|
337
|
+
</p>
|
|
338
|
+
</div>
|
|
339
|
+
</body>
|
|
340
|
+
</html>
|
|
341
|
+
`;
|
|
342
|
+
}
|
|
343
|
+
|
|
284
344
|
/**
|
|
285
345
|
* Escape HTML to prevent XSS
|
|
286
346
|
*/
|
package/test/idp.test.js
CHANGED
|
@@ -96,18 +96,6 @@ describe('Identity Provider', () => {
|
|
|
96
96
|
assert.ok(body.error.includes('Password'));
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
it('should require minimum password length', async () => {
|
|
100
|
-
const res = await fetch(`${BASE_URL}/.pods`, {
|
|
101
|
-
method: 'POST',
|
|
102
|
-
headers: { 'Content-Type': 'application/json' },
|
|
103
|
-
body: JSON.stringify({ name: 'shortpass', email: 'test@example.com', password: 'short' }),
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
assert.strictEqual(res.status, 400);
|
|
107
|
-
const body = await res.json();
|
|
108
|
-
assert.ok(body.error.includes('8'));
|
|
109
|
-
});
|
|
110
|
-
|
|
111
99
|
it('should create pod with account', async () => {
|
|
112
100
|
const uniqueId = Date.now();
|
|
113
101
|
const res = await fetch(`${BASE_URL}/.pods`, {
|
package/test/patch.test.js
CHANGED
|
@@ -207,21 +207,26 @@ describe('PATCH Operations', () => {
|
|
|
207
207
|
assertStatus(res, 415);
|
|
208
208
|
});
|
|
209
209
|
|
|
210
|
-
it('should
|
|
210
|
+
it('should create resource if it does not exist', async () => {
|
|
211
211
|
const patch = `
|
|
212
212
|
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
|
|
213
213
|
_:patch a solid:InsertDeletePatch;
|
|
214
214
|
solid:inserts { <#me> <http://example.org/p> "test" }.
|
|
215
215
|
`;
|
|
216
216
|
|
|
217
|
-
const res = await request('/patchtest/public/
|
|
217
|
+
const res = await request('/patchtest/public/patch-created.json', {
|
|
218
218
|
method: 'PATCH',
|
|
219
219
|
headers: { 'Content-Type': 'text/n3' },
|
|
220
220
|
body: patch,
|
|
221
221
|
auth: 'patchtest'
|
|
222
222
|
});
|
|
223
223
|
|
|
224
|
-
|
|
224
|
+
// PATCH creates resources in Solid
|
|
225
|
+
assertStatus(res, 201);
|
|
226
|
+
|
|
227
|
+
// Verify resource was created with the inserted data
|
|
228
|
+
const getRes = await request('/patchtest/public/patch-created.json');
|
|
229
|
+
assertStatus(getRes, 200);
|
|
225
230
|
});
|
|
226
231
|
|
|
227
232
|
it('should return 409 when patching non-JSON-LD resource', async () => {
|
|
@@ -157,15 +157,20 @@ describe('SPARQL Update', () => {
|
|
|
157
157
|
});
|
|
158
158
|
|
|
159
159
|
describe('Error handling', () => {
|
|
160
|
-
it('should
|
|
160
|
+
it('should create resource if it does not exist', async () => {
|
|
161
161
|
const sparql = `INSERT DATA { <#x> <http://example.org/p> "v" }`;
|
|
162
|
-
const res = await request('/sparqltest/public/
|
|
162
|
+
const res = await request('/sparqltest/public/sparql-created.json', {
|
|
163
163
|
method: 'PATCH',
|
|
164
164
|
headers: { 'Content-Type': 'application/sparql-update' },
|
|
165
165
|
body: sparql,
|
|
166
166
|
auth: 'sparqltest'
|
|
167
167
|
});
|
|
168
|
-
|
|
168
|
+
// PATCH creates resources in Solid
|
|
169
|
+
assertStatus(res, 201);
|
|
170
|
+
|
|
171
|
+
// Verify resource was created
|
|
172
|
+
const getRes = await request('/sparqltest/public/sparql-created.json');
|
|
173
|
+
assertStatus(getRes, 200);
|
|
169
174
|
});
|
|
170
175
|
|
|
171
176
|
it('should return 415 for unsupported content type', async () => {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "318e7a79-23d5-4e0b-8fa1-b63cfaee87e1",
|
|
3
|
+
"username": "credtest",
|
|
4
|
+
"email": "credtest@example.com",
|
|
5
|
+
"passwordHash": "$2b$10$ITkxFeVH56JBgjDqYASbfuounFozpoVQpBvtsYxCszx2I0PBEX0hq",
|
|
6
|
+
"webId": "http://localhost:3101/credtest/#me",
|
|
7
|
+
"podName": "credtest",
|
|
8
|
+
"createdAt": "2025-12-27T14:33:50.756Z",
|
|
9
|
+
"lastLogin": "2025-12-27T14:33:51.196Z"
|
|
10
|
+
}
|
|
@@ -3,20 +3,20 @@
|
|
|
3
3
|
"keys": [
|
|
4
4
|
{
|
|
5
5
|
"kty": "EC",
|
|
6
|
-
"x": "
|
|
7
|
-
"y": "
|
|
6
|
+
"x": "Nf8dDZkLGjtbhOI4-NdDeJpP7jFZ1yRIsLGbg4wWFIU",
|
|
7
|
+
"y": "RlENuTLrM8M6a1UQorqtB3NIS5VXq_gI9lqJMUKDjo8",
|
|
8
8
|
"crv": "P-256",
|
|
9
|
-
"d": "
|
|
10
|
-
"kid": "
|
|
9
|
+
"d": "WZKOZkoJBrwF7JfwLXPzpJY2XXNgab-YfqUSIT2Xpfs",
|
|
10
|
+
"kid": "91ebc94d-1ed9-4ded-b017-70f51f2aff2b",
|
|
11
11
|
"use": "sig",
|
|
12
12
|
"alg": "ES256",
|
|
13
|
-
"iat":
|
|
13
|
+
"iat": 1766846030
|
|
14
14
|
}
|
|
15
15
|
]
|
|
16
16
|
},
|
|
17
17
|
"cookieKeys": [
|
|
18
|
-
"
|
|
19
|
-
"
|
|
18
|
+
"V7_pksFGkYdBgSRG_lC9AWIki50H1qzj9-L_T-Q7OC0",
|
|
19
|
+
"hmJQwz_B5QLiHUkncYUHZC7xOtGLrLvQVyBmJ5r-nIo"
|
|
20
20
|
],
|
|
21
|
-
"createdAt": "2025-12-
|
|
21
|
+
"createdAt": "2025-12-27T14:33:50.653Z"
|
|
22
22
|
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "95d4d470-1e8c-4b17-bcff-a62b8a54813b",
|
|
3
|
-
"email": "credtest@example.com",
|
|
4
|
-
"passwordHash": "$2b$10$dfsBq19GkIsL/qaer5LlxeSD8cMFJS4U8o7Wt06f/u29Cdhc8eJ3i",
|
|
5
|
-
"webId": "http://localhost:3101/credtest/#me",
|
|
6
|
-
"podName": "credtest",
|
|
7
|
-
"createdAt": "2025-12-27T13:57:26.941Z",
|
|
8
|
-
"lastLogin": "2025-12-27T13:57:27.182Z"
|
|
9
|
-
}
|