javascript-solid-server 0.0.11 → 0.0.12

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.
@@ -20,7 +20,12 @@
20
20
  "WebFetch(domain:solid-contrib.github.io)",
21
21
  "Bash(git clone:*)",
22
22
  "Bash(chmod:*)",
23
- "Bash(JSS_PORT=4000 JSS_CONNEG=true node bin/jss.js:*)"
23
+ "Bash(JSS_PORT=4000 JSS_CONNEG=true node bin/jss.js:*)",
24
+ "Bash(find:*)",
25
+ "Bash(timeout 5 node:*)",
26
+ "Bash(npm view:*)",
27
+ "Bash(npm ls:*)",
28
+ "Bash(timeout 10 node:*)"
24
29
  ]
25
30
  }
26
31
  }
package/README.md CHANGED
@@ -54,7 +54,7 @@ npm run benchmark
54
54
 
55
55
  ## Features
56
56
 
57
- ### Implemented (v0.0.11)
57
+ ### Implemented (v0.0.12)
58
58
 
59
59
  - **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
60
60
  - **N3 Patch** - Solid's native patch format for RDF updates
@@ -67,6 +67,7 @@ npm run benchmark
67
67
  - **Multi-user Pods** - Create pods at `/<username>/`
68
68
  - **WebID Profiles** - JSON-LD structured data in HTML at pod root
69
69
  - **Web Access Control (WAC)** - `.acl` file-based authorization
70
+ - **Solid-OIDC Identity Provider** - Built-in IdP with DPoP, dynamic registration
70
71
  - **Solid-OIDC Resource Server** - Accept DPoP-bound access tokens from external IdPs
71
72
  - **Simple Auth Tokens** - Built-in token authentication for development
72
73
  - **Content Negotiation** - Optional Turtle <-> JSON-LD conversion
@@ -132,6 +133,8 @@ jss --help # Show help
132
133
  | `--ssl-cert <path>` | SSL certificate (PEM) | - |
133
134
  | `--conneg` | Enable Turtle support | false |
134
135
  | `--notifications` | Enable WebSocket | false |
136
+ | `--idp` | Enable built-in IdP | false |
137
+ | `--idp-issuer <url>` | IdP issuer URL | (auto) |
135
138
  | `-q, --quiet` | Suppress logs | false |
136
139
 
137
140
  ### Environment Variables
@@ -274,9 +277,38 @@ Use the token returned from pod creation:
274
277
  curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3000/alice/private/
275
278
  ```
276
279
 
277
- ### Solid-OIDC (Production)
280
+ ### Built-in Identity Provider (v0.0.12+)
278
281
 
279
- The server accepts DPoP-bound access tokens from external Solid identity providers:
282
+ Enable the built-in Solid-OIDC Identity Provider:
283
+
284
+ ```bash
285
+ jss start --idp
286
+ ```
287
+
288
+ With IdP enabled, pod creation requires email and password:
289
+
290
+ ```bash
291
+ curl -X POST http://localhost:3000/.pods \
292
+ -H "Content-Type: application/json" \
293
+ -d '{"name": "alice", "email": "alice@example.com", "password": "secret123"}'
294
+ ```
295
+
296
+ Response:
297
+ ```json
298
+ {
299
+ "name": "alice",
300
+ "webId": "http://localhost:3000/alice/#me",
301
+ "podUri": "http://localhost:3000/alice/",
302
+ "idpIssuer": "http://localhost:3000",
303
+ "loginUrl": "http://localhost:3000/idp/auth"
304
+ }
305
+ ```
306
+
307
+ OIDC Discovery: `/.well-known/openid-configuration`
308
+
309
+ ### Solid-OIDC (External IdP)
310
+
311
+ The server also accepts DPoP-bound access tokens from external Solid identity providers:
280
312
 
281
313
  ```bash
282
314
  curl -H "Authorization: DPoP ACCESS_TOKEN" \
@@ -323,7 +355,7 @@ Server: pub http://localhost:3000/alice/public/data.json (on change)
323
355
  npm test
324
356
  ```
325
357
 
326
- Currently passing: **163 tests** (including 27 conformance tests)
358
+ Currently passing: **174 tests** (including 27 conformance tests)
327
359
 
328
360
  ## Project Structure
329
361
 
@@ -355,6 +387,14 @@ src/
355
387
  │ ├── index.js # WebSocket plugin
356
388
  │ ├── events.js # Event emitter
357
389
  │ └── websocket.js # solid-0.1 protocol
390
+ ├── idp/
391
+ │ ├── index.js # Identity Provider plugin
392
+ │ ├── provider.js # oidc-provider config
393
+ │ ├── adapter.js # Filesystem adapter
394
+ │ ├── accounts.js # User account management
395
+ │ ├── keys.js # JWKS key management
396
+ │ ├── interactions.js # Login/consent handlers
397
+ │ └── views.js # HTML templates
358
398
  ├── rdf/
359
399
  │ ├── turtle.js # Turtle <-> JSON-LD
360
400
  │ └── conneg.js # Content negotiation
@@ -372,6 +412,8 @@ Minimal dependencies for a fast, secure server:
372
412
  - **fs-extra** - Enhanced file operations
373
413
  - **jose** - JWT/JWK handling for Solid-OIDC
374
414
  - **n3** - Turtle parsing (only used when conneg enabled)
415
+ - **oidc-provider** - OpenID Connect Identity Provider (only when IdP enabled)
416
+ - **bcrypt** - Password hashing (only when IdP enabled)
375
417
 
376
418
  ## License
377
419
 
package/bin/jss.js CHANGED
@@ -44,6 +44,9 @@ program
44
44
  .option('--no-conneg', 'Disable content negotiation')
45
45
  .option('--notifications', 'Enable WebSocket notifications')
46
46
  .option('--no-notifications', 'Disable WebSocket notifications')
47
+ .option('--idp', 'Enable built-in Identity Provider')
48
+ .option('--no-idp', 'Disable built-in Identity Provider')
49
+ .option('--idp-issuer <url>', 'IdP issuer URL (defaults to server URL)')
47
50
  .option('-q, --quiet', 'Suppress log output')
48
51
  .option('--print-config', 'Print configuration and exit')
49
52
  .action(async (options) => {
@@ -55,11 +58,19 @@ program
55
58
  process.exit(0);
56
59
  }
57
60
 
61
+ // Determine IdP issuer URL
62
+ const protocol = config.ssl ? 'https' : 'http';
63
+ const serverHost = config.host === '0.0.0.0' ? 'localhost' : config.host;
64
+ const baseUrl = `${protocol}://${serverHost}:${config.port}`;
65
+ const idpIssuer = config.idpIssuer || baseUrl;
66
+
58
67
  // Create and start server
59
68
  const server = createServer({
60
69
  logger: config.logger,
61
70
  conneg: config.conneg,
62
71
  notifications: config.notifications,
72
+ idp: config.idp,
73
+ idpIssuer: idpIssuer,
63
74
  ssl: config.ssl ? {
64
75
  key: await fs.readFile(config.sslKey),
65
76
  cert: await fs.readFile(config.sslCert),
@@ -69,16 +80,14 @@ program
69
80
 
70
81
  await server.listen({ port: config.port, host: config.host });
71
82
 
72
- const protocol = config.ssl ? 'https' : 'http';
73
- const address = config.host === '0.0.0.0' ? 'localhost' : config.host;
74
-
75
83
  if (!config.quiet) {
76
84
  console.log(`\n JavaScript Solid Server v${pkg.version}`);
77
- console.log(` ${protocol}://${address}:${config.port}/`);
85
+ console.log(` ${baseUrl}/`);
78
86
  console.log(`\n Data: ${path.resolve(config.root)}`);
79
87
  if (config.ssl) console.log(' SSL: enabled');
80
88
  if (config.conneg) console.log(' Conneg: enabled');
81
89
  if (config.notifications) console.log(' WebSocket: enabled');
90
+ if (config.idp) console.log(` IdP: ${idpIssuer}`);
82
91
  console.log('\n Press Ctrl+C to stop\n');
83
92
  }
84
93
 
@@ -142,6 +151,15 @@ program
142
151
  config.sslCert = await prompt('SSL certificate path', './ssl/cert.pem');
143
152
  }
144
153
 
154
+ // Ask about IdP
155
+ config.idp = await confirm('Enable built-in Identity Provider?', false);
156
+ if (config.idp) {
157
+ const customIssuer = await confirm('Use custom issuer URL?', false);
158
+ if (customIssuer) {
159
+ config.idpIssuer = await prompt('IdP issuer URL', 'https://example.com');
160
+ }
161
+ }
162
+
145
163
  console.log('');
146
164
  }
147
165
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -22,12 +22,15 @@
22
22
  "benchmark": "node benchmark.js"
23
23
  },
24
24
  "dependencies": {
25
+ "@fastify/middie": "^8.3.3",
25
26
  "@fastify/websocket": "^8.3.1",
27
+ "bcrypt": "^6.0.0",
26
28
  "commander": "^14.0.2",
27
29
  "fastify": "^4.25.2",
28
30
  "fs-extra": "^11.2.0",
29
31
  "jose": "^6.1.3",
30
- "n3": "^1.26.0"
32
+ "n3": "^1.26.0",
33
+ "oidc-provider": "^9.6.0"
31
34
  },
32
35
  "engines": {
33
36
  "node": ">=18.0.0"
package/src/config.js CHANGED
@@ -29,6 +29,10 @@ export const defaults = {
29
29
  conneg: false,
30
30
  notifications: false,
31
31
 
32
+ // Identity Provider
33
+ idp: false,
34
+ idpIssuer: null,
35
+
32
36
  // Logging
33
37
  logger: true,
34
38
  quiet: false,
@@ -51,6 +55,8 @@ const envMap = {
51
55
  JSS_NOTIFICATIONS: 'notifications',
52
56
  JSS_QUIET: 'quiet',
53
57
  JSS_CONFIG_PATH: 'configPath',
58
+ JSS_IDP: 'idp',
59
+ JSS_IDP_ISSUER: 'idpIssuer',
54
60
  };
55
61
 
56
62
  /**
@@ -181,5 +187,6 @@ export function printConfig(config) {
181
187
  console.log(` Multi-user: ${config.multiuser}`);
182
188
  console.log(` Conneg: ${config.conneg}`);
183
189
  console.log(` Notifications: ${config.notifications}`);
190
+ console.log(` IdP: ${config.idp ? (config.idpIssuer || 'enabled') : 'disabled'}`);
184
191
  console.log('─'.repeat(40));
185
192
  }
@@ -110,6 +110,7 @@ export async function handlePost(request, reply) {
110
110
  /**
111
111
  * Create a pod (container) for a user
112
112
  * POST /.pods with { "name": "alice" }
113
+ * With IdP enabled: { "name": "alice", "email": "alice@example.com", "password": "secret" }
113
114
  *
114
115
  * Creates the following structure:
115
116
  * /{name}/
@@ -122,12 +123,23 @@ export async function handlePost(request, reply) {
122
123
  * /{name}/settings/privateTypeIndex
123
124
  */
124
125
  export async function handleCreatePod(request, reply) {
125
- const { name } = request.body || {};
126
+ const { name, email, password } = request.body || {};
127
+ const idpEnabled = request.idpEnabled;
126
128
 
127
129
  if (!name || typeof name !== 'string') {
128
130
  return reply.code(400).send({ error: 'Pod name required' });
129
131
  }
130
132
 
133
+ // If IdP is enabled, require email and password
134
+ if (idpEnabled) {
135
+ if (!email || typeof email !== 'string') {
136
+ return reply.code(400).send({ error: 'Email required for account creation' });
137
+ }
138
+ if (!password || password.length < 8) {
139
+ return reply.code(400).send({ error: 'Password required (minimum 8 characters)' });
140
+ }
141
+ }
142
+
131
143
  // Validate pod name (alphanumeric, dash, underscore)
132
144
  if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
133
145
  return reply.code(400).send({ error: 'Invalid pod name. Use alphanumeric, dash, or underscore only.' });
@@ -200,7 +212,28 @@ export async function handleCreatePod(request, reply) {
200
212
 
201
213
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
202
214
 
203
- // Generate token for the pod owner
215
+ // If IdP is enabled, create account instead of simple token
216
+ if (idpEnabled) {
217
+ try {
218
+ const { createAccount } = await import('../idp/accounts.js');
219
+ await createAccount({ email, password, webId, podName: name });
220
+
221
+ return reply.code(201).send({
222
+ name,
223
+ webId,
224
+ podUri,
225
+ idpIssuer: issuer,
226
+ loginUrl: `${issuer}/idp/auth`,
227
+ });
228
+ } catch (err) {
229
+ console.error('Account creation error:', err);
230
+ // Rollback pod creation on account failure
231
+ await storage.remove(podPath);
232
+ return reply.code(409).send({ error: err.message });
233
+ }
234
+ }
235
+
236
+ // Generate token for the pod owner (simple auth mode)
204
237
  const token = createToken(webId);
205
238
 
206
239
  return reply.code(201).send({
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Account management for the Identity Provider
3
+ * Handles user accounts with email/password authentication
4
+ */
5
+
6
+ import bcrypt from 'bcrypt';
7
+ import crypto from 'crypto';
8
+ import fs from 'fs-extra';
9
+ import path from 'path';
10
+
11
+ /**
12
+ * Get accounts directory (computed dynamically to support changing DATA_ROOT)
13
+ */
14
+ function getAccountsDir() {
15
+ const dataRoot = process.env.DATA_ROOT || './data';
16
+ return path.join(dataRoot, '.idp', 'accounts');
17
+ }
18
+
19
+ function getEmailIndexPath() {
20
+ return path.join(getAccountsDir(), '_email_index.json');
21
+ }
22
+
23
+ function getWebIdIndexPath() {
24
+ return path.join(getAccountsDir(), '_webid_index.json');
25
+ }
26
+
27
+ const SALT_ROUNDS = 10;
28
+
29
+ /**
30
+ * Initialize the accounts directory
31
+ */
32
+ async function ensureDir() {
33
+ await fs.ensureDir(getAccountsDir());
34
+ }
35
+
36
+ /**
37
+ * Load an index file
38
+ */
39
+ async function loadIndex(indexPath) {
40
+ try {
41
+ return await fs.readJson(indexPath);
42
+ } catch (err) {
43
+ if (err.code === 'ENOENT') return {};
44
+ throw err;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Save an index file
50
+ */
51
+ async function saveIndex(indexPath, index) {
52
+ await fs.writeJson(indexPath, index, { spaces: 2 });
53
+ }
54
+
55
+ /**
56
+ * Create a new user account
57
+ * @param {object} options - Account options
58
+ * @param {string} options.email - User email
59
+ * @param {string} options.password - Plain text password
60
+ * @param {string} options.webId - User's WebID URI
61
+ * @param {string} options.podName - Pod name
62
+ * @returns {Promise<object>} - Created account (without password)
63
+ */
64
+ export async function createAccount({ email, password, webId, podName }) {
65
+ await ensureDir();
66
+
67
+ const normalizedEmail = email.toLowerCase().trim();
68
+
69
+ // Check email uniqueness
70
+ const existingByEmail = await findByEmail(normalizedEmail);
71
+ if (existingByEmail) {
72
+ throw new Error('Email already registered');
73
+ }
74
+
75
+ // Check webId uniqueness
76
+ const existingByWebId = await findByWebId(webId);
77
+ if (existingByWebId) {
78
+ throw new Error('WebID already has an account');
79
+ }
80
+
81
+ // Generate account ID and hash password
82
+ const id = crypto.randomUUID();
83
+ const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
84
+
85
+ const account = {
86
+ id,
87
+ email: normalizedEmail,
88
+ passwordHash,
89
+ webId,
90
+ podName,
91
+ createdAt: new Date().toISOString(),
92
+ lastLogin: null,
93
+ };
94
+
95
+ // Save account
96
+ const accountPath = path.join(getAccountsDir(), `${id}.json`);
97
+ await fs.writeJson(accountPath, account, { spaces: 2 });
98
+
99
+ // Update email index
100
+ const emailIndex = await loadIndex(getEmailIndexPath());
101
+ emailIndex[normalizedEmail] = id;
102
+ await saveIndex(getEmailIndexPath(), emailIndex);
103
+
104
+ // Update webId index
105
+ const webIdIndex = await loadIndex(getWebIdIndexPath());
106
+ webIdIndex[webId] = id;
107
+ await saveIndex(getWebIdIndexPath(), webIdIndex);
108
+
109
+ // Return account without password hash
110
+ const { passwordHash: _, ...safeAccount } = account;
111
+ return safeAccount;
112
+ }
113
+
114
+ /**
115
+ * Authenticate a user with email and password
116
+ * @param {string} email - User email
117
+ * @param {string} password - Plain text password
118
+ * @returns {Promise<object|null>} - Account if valid, null if invalid
119
+ */
120
+ export async function authenticate(email, password) {
121
+ const account = await findByEmail(email);
122
+ if (!account) return null;
123
+
124
+ const valid = await bcrypt.compare(password, account.passwordHash);
125
+ if (!valid) return null;
126
+
127
+ // Update last login
128
+ account.lastLogin = new Date().toISOString();
129
+ const accountPath = path.join(getAccountsDir(), `${account.id}.json`);
130
+ await fs.writeJson(accountPath, account, { spaces: 2 });
131
+
132
+ // Return account without password hash
133
+ const { passwordHash: _, ...safeAccount } = account;
134
+ return safeAccount;
135
+ }
136
+
137
+ /**
138
+ * Find an account by ID
139
+ * @param {string} id - Account ID
140
+ * @returns {Promise<object|null>} - Account or null
141
+ */
142
+ export async function findById(id) {
143
+ try {
144
+ const accountPath = path.join(getAccountsDir(), `${id}.json`);
145
+ return await fs.readJson(accountPath);
146
+ } catch (err) {
147
+ if (err.code === 'ENOENT') return null;
148
+ throw err;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Find an account by email
154
+ * @param {string} email - User email
155
+ * @returns {Promise<object|null>} - Account or null
156
+ */
157
+ export async function findByEmail(email) {
158
+ const normalizedEmail = email.toLowerCase().trim();
159
+ const emailIndex = await loadIndex(getEmailIndexPath());
160
+ const id = emailIndex[normalizedEmail];
161
+ if (!id) return null;
162
+ return findById(id);
163
+ }
164
+
165
+ /**
166
+ * Find an account by WebID
167
+ * @param {string} webId - User WebID
168
+ * @returns {Promise<object|null>} - Account or null
169
+ */
170
+ export async function findByWebId(webId) {
171
+ const webIdIndex = await loadIndex(getWebIdIndexPath());
172
+ const id = webIdIndex[webId];
173
+ if (!id) return null;
174
+ return findById(id);
175
+ }
176
+
177
+ /**
178
+ * Update account password
179
+ * @param {string} id - Account ID
180
+ * @param {string} newPassword - New plain text password
181
+ */
182
+ export async function updatePassword(id, newPassword) {
183
+ const account = await findById(id);
184
+ if (!account) {
185
+ throw new Error('Account not found');
186
+ }
187
+
188
+ account.passwordHash = await bcrypt.hash(newPassword, SALT_ROUNDS);
189
+ account.passwordChangedAt = new Date().toISOString();
190
+
191
+ const accountPath = path.join(getAccountsDir(), `${id}.json`);
192
+ await fs.writeJson(accountPath, account, { spaces: 2 });
193
+ }
194
+
195
+ /**
196
+ * Delete an account
197
+ * @param {string} id - Account ID
198
+ */
199
+ export async function deleteAccount(id) {
200
+ const account = await findById(id);
201
+ if (!account) return;
202
+
203
+ // Remove from indexes
204
+ const emailIndex = await loadIndex(getEmailIndexPath());
205
+ delete emailIndex[account.email];
206
+ await saveIndex(getEmailIndexPath(), emailIndex);
207
+
208
+ const webIdIndex = await loadIndex(getWebIdIndexPath());
209
+ delete webIdIndex[account.webId];
210
+ await saveIndex(getWebIdIndexPath(), webIdIndex);
211
+
212
+ // Delete account file
213
+ const accountPath = path.join(getAccountsDir(), `${id}.json`);
214
+ await fs.remove(accountPath);
215
+ }
216
+
217
+ /**
218
+ * Get account for oidc-provider's findAccount
219
+ * This is the interface oidc-provider expects
220
+ * @param {string} id - Account ID
221
+ * @returns {Promise<object|undefined>} - Account interface for oidc-provider
222
+ */
223
+ export async function getAccountForProvider(id) {
224
+ const account = await findById(id);
225
+ if (!account) return undefined;
226
+
227
+ return {
228
+ accountId: id,
229
+ /**
230
+ * Return claims for the token
231
+ * @param {string} use - 'id_token' or 'userinfo'
232
+ * @param {string} scope - Requested scopes
233
+ * @param {object} claims - Requested claims
234
+ * @param {string[]} rejected - Rejected claims
235
+ */
236
+ async claims(use, scope, claims, rejected) {
237
+ const result = {
238
+ sub: id,
239
+ };
240
+
241
+ // Always include webid for Solid-OIDC
242
+ result.webid = account.webId;
243
+
244
+ // Profile scope
245
+ if (scope.includes('profile')) {
246
+ result.name = account.podName;
247
+ }
248
+
249
+ // Email scope
250
+ if (scope.includes('email')) {
251
+ result.email = account.email;
252
+ result.email_verified = false; // We don't have email verification yet
253
+ }
254
+
255
+ return result;
256
+ },
257
+ };
258
+ }