javascript-solid-server 0.0.163 → 0.0.165
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 +18 -1
- package/bin/jss.js +1 -1
- package/docs/configuration.md +19 -5
- package/package.json +1 -1
- package/src/config.js +15 -14
- package/src/idp/accounts.js +13 -0
- package/src/idp/credentials.js +75 -1
- package/src/idp/index.js +14 -0
- package/src/server.js +33 -6
- package/test/config.test.js +32 -0
- package/test/idp-change-password.test.js +206 -0
- package/test/idp.test.js +78 -0
- package/test/url.test.js +8 -0
- package/jsserve/LICENSE +0 -21
- package/jsserve/README.md +0 -194
- package/jsserve/bin/jsserve.js +0 -329
- package/jsserve/package-lock.json +0 -1832
- package/jsserve/package.json +0 -45
|
@@ -361,7 +361,24 @@
|
|
|
361
361
|
"WebFetch(domain:www.gitfork.app)",
|
|
362
362
|
"Bash(JSS_SINGLE_USER_PASSWORD=hunter2 timeout 3 node bin/jss.js start --port 4581 --root /tmp/jss-103 --single-user --single-user-name alice --idp)",
|
|
363
363
|
"Bash(JSS_SINGLE_USER_PASSWORD=hunter2 timeout 2 node bin/jss.js start --port 4581 --root /tmp/jss-103 --single-user-name alice --idp)",
|
|
364
|
-
"Bash(JSS_SINGLE_USER_PASSWORD=hunter2 timeout 3 node bin/jss.js start --port 4581 --root /tmp/jss-103-sanity --single-user-name alice --single-user --idp)"
|
|
364
|
+
"Bash(JSS_SINGLE_USER_PASSWORD=hunter2 timeout 3 node bin/jss.js start --port 4581 --root /tmp/jss-103-sanity --single-user-name alice --single-user --idp)",
|
|
365
|
+
"Bash(pm2 jlist *)",
|
|
366
|
+
"Bash(jss --version)",
|
|
367
|
+
"Bash(cp -a ~/main/me ~/main/me.backup-pre-348)",
|
|
368
|
+
"Bash(cp -a ~/main/.idp/accounts ~/main/.idp/accounts.backup-pre-348)",
|
|
369
|
+
"Bash(mv ~/main/me.backup-pre-348 ~/main.backup-me-pre-348)",
|
|
370
|
+
"Bash(mv ~/main/.idp/accounts.backup-pre-348 ~/main.backup-accounts-pre-348)",
|
|
371
|
+
"Bash(shopt -s dotglob)",
|
|
372
|
+
"Bash(mv ~/main/me/* ~/main/)",
|
|
373
|
+
"Bash(shopt -u dotglob)",
|
|
374
|
+
"Bash(rmdir ~/main/me)",
|
|
375
|
+
"Bash(rm /tmp/dup-issue.md)",
|
|
376
|
+
"Bash(rm -rf /tmp/losos-fix)",
|
|
377
|
+
"Bash(terser losos/shell.js -m -c)",
|
|
378
|
+
"Bash(terser losos/html.js -m -c)",
|
|
379
|
+
"Bash(terser losos/store.js -m -c)",
|
|
380
|
+
"Bash(terser losos/registry.js -m -c)",
|
|
381
|
+
"Bash(terser losos/losos.js -m -c)"
|
|
365
382
|
]
|
|
366
383
|
}
|
|
367
384
|
}
|
package/bin/jss.js
CHANGED
|
@@ -126,7 +126,7 @@ program
|
|
|
126
126
|
.option('--invite-only', 'Require invite code for registration')
|
|
127
127
|
.option('--no-invite-only', 'Allow open registration')
|
|
128
128
|
.option('--single-user', 'Single-user mode (creates pod on startup, disables registration)')
|
|
129
|
-
.option('--single-user-name <name>', '
|
|
129
|
+
.option('--single-user-name <name>', 'Mount the pod at /<name>/ instead of at the server root (default: root pod at /)')
|
|
130
130
|
.option('--single-user-password <pw>', 'Initial IDP password to seed when creating the single-user pod (or set JSS_SINGLE_USER_PASSWORD)')
|
|
131
131
|
.option('--webid-tls', 'Enable WebID-TLS client certificate authentication')
|
|
132
132
|
.option('--no-webid-tls', 'Disable WebID-TLS authentication')
|
package/docs/configuration.md
CHANGED
|
@@ -258,19 +258,21 @@ Response:
|
|
|
258
258
|
For personal pod servers where only one user needs access:
|
|
259
259
|
|
|
260
260
|
```bash
|
|
261
|
-
#
|
|
262
|
-
#
|
|
261
|
+
# Default: pod served at server root (#348). WebID is
|
|
262
|
+
# /profile/card.jsonld#me; the IDP login username is "me". On first
|
|
263
|
+
# run JSS will prompt for an initial password (TTY only).
|
|
263
264
|
jss start --single-user --idp
|
|
264
265
|
|
|
265
266
|
# Provide the initial IDP password non-interactively (systemd, containers, CI):
|
|
266
267
|
jss start --single-user --idp --single-user-password 'choose-a-good-one'
|
|
267
268
|
JSS_SINGLE_USER_PASSWORD='choose-a-good-one' jss start --single-user --idp
|
|
268
269
|
|
|
269
|
-
#
|
|
270
|
+
# Mount the pod at a named path instead of the origin. WebID becomes
|
|
271
|
+
# /alice/profile/card.jsonld#me; login as "alice".
|
|
270
272
|
jss start --single-user --single-user-name alice --idp
|
|
271
273
|
|
|
272
|
-
#
|
|
273
|
-
jss start --single-user --single-user-name
|
|
274
|
+
# Legacy /me/ pod — same as the old default before #348.
|
|
275
|
+
jss start --single-user --single-user-name me --idp
|
|
274
276
|
|
|
275
277
|
# Via environment
|
|
276
278
|
JSS_SINGLE_USER=true jss start --idp
|
|
@@ -283,6 +285,18 @@ JSS_SINGLE_USER=true jss start --idp
|
|
|
283
285
|
- Login works for the single user via password (`POST /idp/credentials`) or any other configured method
|
|
284
286
|
- Proper ACLs generated automatically
|
|
285
287
|
|
|
288
|
+
**Upgrading from a pre-#348 install:** if your existing pod was created with the old default (data lives under `<root>/me/`), JSS no longer auto-detects it — restarting plain `jss start --single-user` will start seeding a fresh empty root pod alongside your legacy `/me/` data, and your existing IDP account will keep authenticating against `/me/`. Pick one path on the next restart:
|
|
289
|
+
- **Keep the legacy layout:** add `--single-user-name me` to your launch command. No data movement needed.
|
|
290
|
+
- **Migrate to root pod:** move the *entire* contents of `<root>/me/` (including dotfiles like `.acl`, `.meta`, `.quota.json` — a plain `mv <root>/me/* <root>/` skips them) to `<root>/`, delete the IDP account for `me` (so the new root pod's `me` account can be seeded), then restart without the name flag. Use one of:
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
# Option A: rsync handles dotfiles correctly with the trailing slash.
|
|
294
|
+
rsync -a <root>/me/ <root>/ && rm -rf <root>/me
|
|
295
|
+
|
|
296
|
+
# Option B: bash with dotglob enabled so * matches dotfiles too.
|
|
297
|
+
shopt -s dotglob && mv <root>/me/* <root>/ && rmdir <root>/me
|
|
298
|
+
```
|
|
299
|
+
|
|
286
300
|
**Initial password sources, in priority order:**
|
|
287
301
|
1. `--single-user-password <pw>` CLI flag
|
|
288
302
|
2. `JSS_SINGLE_USER_PASSWORD` env var
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -74,7 +74,11 @@ export const defaults = {
|
|
|
74
74
|
|
|
75
75
|
// Single-user mode (personal pod server)
|
|
76
76
|
singleUser: false,
|
|
77
|
-
|
|
77
|
+
// null = root pod (mounted at server origin, WebID at
|
|
78
|
+
// /profile/card.jsonld#me). A string mounts the pod at /<name>/ —
|
|
79
|
+
// useful when more than one Solid identity coexists on the same
|
|
80
|
+
// origin, or when the operator wants the pre-#348 /me/ shape.
|
|
81
|
+
singleUserName: null,
|
|
78
82
|
// Initial IDP password seeded on first single-user pod creation. If
|
|
79
83
|
// unset and --idp is enabled, the server prompts on a TTY or logs a
|
|
80
84
|
// warning and continues startup on non-TTY (so the pod is created but
|
|
@@ -399,20 +403,17 @@ export function printConfig(config) {
|
|
|
399
403
|
console.log(` SSL: ${config.ssl ? 'enabled' : 'disabled'}`);
|
|
400
404
|
console.log(` Multi-user: ${config.multiuser}`);
|
|
401
405
|
if (config.singleUser) {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
406
|
+
const isRootPod = config.singleUserName === '/' || !config.singleUserName;
|
|
407
|
+
let details = isRootPod ? '/ (root pod)' : config.singleUserName;
|
|
408
|
+
// The "login as me" hint and password line only make sense when
|
|
409
|
+
// the built-in IdP is on. With --no-idp / external issuer there's
|
|
410
|
+
// no built-in login form, so don't imply one exists.
|
|
407
411
|
if (config.idp) {
|
|
408
|
-
if (
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
: (process.stdin.isTTY ? 'will prompt at startup' : 'missing — login disabled');
|
|
414
|
-
details += ` (password: ${pwSource})`;
|
|
415
|
-
}
|
|
412
|
+
if (isRootPod) details += ', login as "me"';
|
|
413
|
+
const pwSource = config.singleUserPassword
|
|
414
|
+
? 'provided'
|
|
415
|
+
: (process.stdin.isTTY ? 'will prompt at startup' : 'missing — login disabled');
|
|
416
|
+
details += ` (password: ${pwSource})`;
|
|
416
417
|
}
|
|
417
418
|
console.log(` Single-user: ${details}`);
|
|
418
419
|
}
|
package/src/idp/accounts.js
CHANGED
|
@@ -143,6 +143,19 @@ export async function createAccount({ username, password, webId, podName, email
|
|
|
143
143
|
return safeAccount;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Verify a password against an account's stored hash without side effects.
|
|
148
|
+
* Use this for re-auth proofs (e.g. password rotation) where stamping
|
|
149
|
+
* lastLogin would falsify the audit trail.
|
|
150
|
+
* @param {object} account - Account object with passwordHash
|
|
151
|
+
* @param {string} password - Plain text password
|
|
152
|
+
* @returns {Promise<boolean>}
|
|
153
|
+
*/
|
|
154
|
+
export async function verifyPassword(account, password) {
|
|
155
|
+
if (!account?.passwordHash) return false;
|
|
156
|
+
return bcrypt.compare(password, account.passwordHash);
|
|
157
|
+
}
|
|
158
|
+
|
|
146
159
|
/**
|
|
147
160
|
* Authenticate a user with username/email and password
|
|
148
161
|
* @param {string} identifier - Username or email
|
package/src/idp/credentials.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import * as jose from 'jose';
|
|
7
7
|
import crypto from 'crypto';
|
|
8
|
-
import { authenticate } from './accounts.js';
|
|
8
|
+
import { authenticate, findByWebId, updatePassword, verifyPassword } from './accounts.js';
|
|
9
9
|
import { getJwks } from './keys.js';
|
|
10
|
+
import { getWebIdFromRequestAsync } from '../auth/token.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Handle POST /idp/credentials
|
|
@@ -198,6 +199,79 @@ async function validateDpopProof(proof, method, url) {
|
|
|
198
199
|
return thumbprint;
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Handle PUT /idp/credentials
|
|
204
|
+
* Authenticated owner rotates their own password.
|
|
205
|
+
*
|
|
206
|
+
* Auth: caller must be authenticated (Bearer/DPoP/Nostr-NIP-98).
|
|
207
|
+
* Body (JSON): { currentPassword, newPassword }
|
|
208
|
+
*
|
|
209
|
+
* Responses:
|
|
210
|
+
* 200 { ok: true, webid, passwordChangedAt }
|
|
211
|
+
* 400 missing fields
|
|
212
|
+
* 401 unauthenticated, or currentPassword wrong
|
|
213
|
+
* 403 caller's WebID does not match any account
|
|
214
|
+
*/
|
|
215
|
+
export async function handleChangePassword(request, reply) {
|
|
216
|
+
// 1. Authenticate caller
|
|
217
|
+
const { webId, error: authError } = await getWebIdFromRequestAsync(request);
|
|
218
|
+
if (!webId) {
|
|
219
|
+
return reply.code(401).send({
|
|
220
|
+
error: 'invalid_token',
|
|
221
|
+
error_description: authError || 'Authentication required',
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 2. Parse body
|
|
226
|
+
let body = request.body;
|
|
227
|
+
if (Buffer.isBuffer(body)) body = body.toString('utf-8');
|
|
228
|
+
if (typeof body === 'string') {
|
|
229
|
+
try { body = JSON.parse(body); } catch { body = {}; }
|
|
230
|
+
}
|
|
231
|
+
const currentPassword = body?.currentPassword;
|
|
232
|
+
const newPassword = body?.newPassword;
|
|
233
|
+
|
|
234
|
+
if (typeof currentPassword !== 'string' || typeof newPassword !== 'string'
|
|
235
|
+
|| !currentPassword || !newPassword) {
|
|
236
|
+
return reply.code(400).send({
|
|
237
|
+
error: 'invalid_request',
|
|
238
|
+
error_description: 'currentPassword and newPassword are required (strings)',
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 3. Resolve account from caller's WebID
|
|
243
|
+
const account = await findByWebId(webId);
|
|
244
|
+
if (!account) {
|
|
245
|
+
return reply.code(403).send({
|
|
246
|
+
error: 'forbidden',
|
|
247
|
+
error_description: 'No account found for authenticated WebID',
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 4. Verify currentPassword (re-auth proof). Side-effect-free — does NOT
|
|
252
|
+
// stamp lastLogin, since password rotation isn't a login event.
|
|
253
|
+
if (!(await verifyPassword(account, currentPassword))) {
|
|
254
|
+
return reply.code(401).send({
|
|
255
|
+
error: 'invalid_grant',
|
|
256
|
+
error_description: 'Current password is incorrect',
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 5. Rotate
|
|
261
|
+
await updatePassword(account.id, newPassword);
|
|
262
|
+
|
|
263
|
+
// Re-read to surface passwordChangedAt
|
|
264
|
+
const updated = await findByWebId(webId);
|
|
265
|
+
|
|
266
|
+
reply.header('Cache-Control', 'no-store');
|
|
267
|
+
reply.header('Pragma', 'no-cache');
|
|
268
|
+
return {
|
|
269
|
+
ok: true,
|
|
270
|
+
webid: account.webId,
|
|
271
|
+
passwordChangedAt: updated?.passwordChangedAt,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
201
275
|
/**
|
|
202
276
|
* Handle GET /idp/credentials
|
|
203
277
|
* Returns info about the credentials endpoint
|
package/src/idp/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import {
|
|
22
22
|
handleCredentials,
|
|
23
23
|
handleCredentialsInfo,
|
|
24
|
+
handleChangePassword,
|
|
24
25
|
} from './credentials.js';
|
|
25
26
|
import * as passkey from './passkey.js';
|
|
26
27
|
import { addTrustedIssuer } from '../auth/solid-oidc.js';
|
|
@@ -264,6 +265,19 @@ export async function idpPlugin(fastify, options) {
|
|
|
264
265
|
return handleCredentials(request, reply, issuer);
|
|
265
266
|
});
|
|
266
267
|
|
|
268
|
+
// PUT credentials - authenticated owner rotates their own password (#351)
|
|
269
|
+
fastify.put('/idp/credentials', {
|
|
270
|
+
config: {
|
|
271
|
+
rateLimit: {
|
|
272
|
+
max: 10,
|
|
273
|
+
timeWindow: '1 minute',
|
|
274
|
+
keyGenerator: (request) => request.ip
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}, async (request, reply) => {
|
|
278
|
+
return handleChangePassword(request, reply);
|
|
279
|
+
});
|
|
280
|
+
|
|
267
281
|
// Interaction routes (our custom login/consent UI)
|
|
268
282
|
// These bypass oidc-provider and use our handlers
|
|
269
283
|
|
package/src/server.js
CHANGED
|
@@ -93,7 +93,22 @@ export function createServer(options = {}) {
|
|
|
93
93
|
const inviteOnly = options.inviteOnly ?? false;
|
|
94
94
|
// Single-user mode - creates pod on startup, disables registration
|
|
95
95
|
const singleUser = options.singleUser ?? false;
|
|
96
|
-
|
|
96
|
+
// Default null = root pod (#348). Pass an explicit singleUserName
|
|
97
|
+
// to mount the pod at /<name>/ instead. Normalize the
|
|
98
|
+
// historical `'/'` / `''` forms to null up front so downstream
|
|
99
|
+
// code (remoteStoragePlugin, decorators, etc.) doesn't have to
|
|
100
|
+
// re-check for the same three shapes.
|
|
101
|
+
//
|
|
102
|
+
// Pre-#348 installs (default 'me') that upgrade in place will see
|
|
103
|
+
// a fresh empty root pod alongside their /me/ data. The fix is to
|
|
104
|
+
// pass `--single-user-name me` on restart (or move data/me/* out
|
|
105
|
+
// to the data root). At v0.0.x we accept that one-time
|
|
106
|
+
// intervention rather than carrying detection magic in the code.
|
|
107
|
+
const rawSingleUserName = options.singleUserName ?? null;
|
|
108
|
+
const singleUserName =
|
|
109
|
+
(rawSingleUserName === '/' || rawSingleUserName === '')
|
|
110
|
+
? null
|
|
111
|
+
: rawSingleUserName;
|
|
97
112
|
const singleUserPassword = options.singleUserPassword ?? null;
|
|
98
113
|
// Default storage quota per pod (50MB default, 0 = unlimited)
|
|
99
114
|
const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024;
|
|
@@ -558,8 +573,10 @@ export function createServer(options = {}) {
|
|
|
558
573
|
const baseUrl = idpIssuer?.replace(/\/$/, '') || `${protocol}://${host}:${port}`;
|
|
559
574
|
const issuer = idpIssuer || `${baseUrl}/`;
|
|
560
575
|
|
|
561
|
-
// Root
|
|
562
|
-
|
|
576
|
+
// Root pod (no name) vs named pod. After the singleUserName
|
|
577
|
+
// normalization at the top of createServer(), null is the only
|
|
578
|
+
// root-pod shape we need to recognize here.
|
|
579
|
+
const isRootPod = !singleUserName;
|
|
563
580
|
const podPath = isRootPod ? '/' : `/${singleUserName}/`;
|
|
564
581
|
const podUri = isRootPod ? `${baseUrl}/` : `${baseUrl}/${singleUserName}/`;
|
|
565
582
|
const displayName = isRootPod ? 'me' : singleUserName;
|
|
@@ -595,12 +612,22 @@ export function createServer(options = {}) {
|
|
|
595
612
|
// this, single-user + --idp produces a pod but no credential, and
|
|
596
613
|
// registration is intentionally disabled in single-user mode — so
|
|
597
614
|
// the pod is unloggable until a password is set externally (#323).
|
|
598
|
-
|
|
615
|
+
//
|
|
616
|
+
// Root pods (#348) need this too: the pod has no name, but the IDP
|
|
617
|
+
// still needs *some* username for the login form. Default to 'me'
|
|
618
|
+
// — matches the WebID fragment, fits the historical convention.
|
|
619
|
+
if (idpEnabled) {
|
|
620
|
+
// The IDP also persists `podName` and surfaces it as the
|
|
621
|
+
// `name` claim under the OIDC `profile` scope (see
|
|
622
|
+
// src/idp/accounts.js). For root pods we use 'me' here too —
|
|
623
|
+
// a null podName would leak through as a null/missing
|
|
624
|
+
// profile.name on every login, which OIDC clients expect to
|
|
625
|
+
// be a non-empty human-readable string.
|
|
599
626
|
await seedSingleUserIdpAccount({
|
|
600
627
|
fastify,
|
|
601
|
-
username: singleUserName,
|
|
628
|
+
username: isRootPod ? 'me' : singleUserName,
|
|
602
629
|
webId,
|
|
603
|
-
podName: singleUserName,
|
|
630
|
+
podName: isRootPod ? 'me' : singleUserName,
|
|
604
631
|
providedPassword: singleUserPassword
|
|
605
632
|
});
|
|
606
633
|
}
|
package/test/config.test.js
CHANGED
|
@@ -158,3 +158,35 @@ describe('config — --single-user implies --idp (#331)', () => {
|
|
|
158
158
|
'--no-idp without --single-user should not trigger the #331 warning');
|
|
159
159
|
});
|
|
160
160
|
});
|
|
161
|
+
|
|
162
|
+
// #348: the user-visible default change — `jss start --single-user`
|
|
163
|
+
// (no name flag) must produce a config where singleUserName is null,
|
|
164
|
+
// so createServer() takes the root-pod path. createServer() has its
|
|
165
|
+
// own tests but a future refactor of loadConfig() could silently
|
|
166
|
+
// restore the old `'me'` default and only the server-level tests
|
|
167
|
+
// would catch it via behaviour, not the config layer directly.
|
|
168
|
+
describe('config — singleUserName default (#348)', () => {
|
|
169
|
+
it('loadConfig() returns singleUserName=null when no flag/env is set', async () => {
|
|
170
|
+
delete process.env.JSS_SINGLE_USER_NAME;
|
|
171
|
+
const cfg = await loadConfig({}, null);
|
|
172
|
+
assert.strictEqual(cfg.singleUserName, null,
|
|
173
|
+
'default must be null (= root pod), not the legacy "me"');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('loadConfig() preserves an explicit singleUserName CLI arg', async () => {
|
|
177
|
+
delete process.env.JSS_SINGLE_USER_NAME;
|
|
178
|
+
const cfg = await loadConfig({ singleUserName: 'alice' }, null);
|
|
179
|
+
assert.strictEqual(cfg.singleUserName, 'alice');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('loadConfig() respects JSS_SINGLE_USER_NAME from env', async () => {
|
|
183
|
+
process.env.JSS_SINGLE_USER_NAME = 'me';
|
|
184
|
+
try {
|
|
185
|
+
const cfg = await loadConfig({}, null);
|
|
186
|
+
assert.strictEqual(cfg.singleUserName, 'me',
|
|
187
|
+
'env var should restore the legacy "me" pod path on demand');
|
|
188
|
+
} finally {
|
|
189
|
+
delete process.env.JSS_SINGLE_USER_NAME;
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PUT /idp/credentials — authenticated owner rotates their own password (#351)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, before, after } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { createServer } from '../src/server.js';
|
|
8
|
+
import fs from 'fs-extra';
|
|
9
|
+
import { createServer as createNetServer } from 'net';
|
|
10
|
+
|
|
11
|
+
const TEST_HOST = 'localhost';
|
|
12
|
+
|
|
13
|
+
function getAvailablePort() {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const srv = createNetServer();
|
|
16
|
+
srv.on('error', reject);
|
|
17
|
+
srv.listen(0, TEST_HOST, () => {
|
|
18
|
+
const port = srv.address().port;
|
|
19
|
+
srv.close(() => resolve(port));
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function createPod(baseUrl, name, email, password) {
|
|
25
|
+
const res = await fetch(`${baseUrl}/.pods`, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'Content-Type': 'application/json' },
|
|
28
|
+
body: JSON.stringify({ name, email, password }),
|
|
29
|
+
});
|
|
30
|
+
const body = await res.json().catch(() => ({}));
|
|
31
|
+
assert.strictEqual(res.status, 201, `pod create failed: ${JSON.stringify(body)}`);
|
|
32
|
+
return body;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function loginToken(baseUrl, email, password) {
|
|
36
|
+
const res = await fetch(`${baseUrl}/idp/credentials`, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
body: JSON.stringify({ email, password }),
|
|
40
|
+
});
|
|
41
|
+
const body = await res.json().catch(() => ({}));
|
|
42
|
+
assert.strictEqual(res.status, 200, `login failed: ${JSON.stringify(body)}`);
|
|
43
|
+
return body.access_token;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('PUT /idp/credentials — change password', () => {
|
|
47
|
+
let server;
|
|
48
|
+
let baseUrl;
|
|
49
|
+
let originalDataRoot;
|
|
50
|
+
const DATA_DIR = './test-data-change-password';
|
|
51
|
+
|
|
52
|
+
before(async () => {
|
|
53
|
+
originalDataRoot = process.env.DATA_ROOT;
|
|
54
|
+
await fs.remove(DATA_DIR);
|
|
55
|
+
await fs.ensureDir(DATA_DIR);
|
|
56
|
+
const port = await getAvailablePort();
|
|
57
|
+
baseUrl = `http://${TEST_HOST}:${port}`;
|
|
58
|
+
server = createServer({
|
|
59
|
+
logger: false,
|
|
60
|
+
root: DATA_DIR,
|
|
61
|
+
idp: true,
|
|
62
|
+
idpIssuer: baseUrl,
|
|
63
|
+
forceCloseConnections: true,
|
|
64
|
+
});
|
|
65
|
+
await server.listen({ port, host: TEST_HOST });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
after(async () => {
|
|
69
|
+
await server.close();
|
|
70
|
+
await fs.remove(DATA_DIR);
|
|
71
|
+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
|
|
72
|
+
else process.env.DATA_ROOT = originalDataRoot;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('rejects unauthenticated request with 401', async () => {
|
|
76
|
+
const res = await fetch(`${baseUrl}/idp/credentials`, {
|
|
77
|
+
method: 'PUT',
|
|
78
|
+
headers: { 'Content-Type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({ currentPassword: 'a', newPassword: 'b' }),
|
|
80
|
+
});
|
|
81
|
+
assert.strictEqual(res.status, 401);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('rejects missing fields with 400', async () => {
|
|
85
|
+
const id = `alice${Date.now()}`;
|
|
86
|
+
await createPod(baseUrl, id, `${id}@example.com`, 'oldpassword123');
|
|
87
|
+
const token = await loginToken(baseUrl, `${id}@example.com`, 'oldpassword123');
|
|
88
|
+
|
|
89
|
+
const res = await fetch(`${baseUrl}/idp/credentials`, {
|
|
90
|
+
method: 'PUT',
|
|
91
|
+
headers: {
|
|
92
|
+
'Content-Type': 'application/json',
|
|
93
|
+
'Authorization': `Bearer ${token}`,
|
|
94
|
+
},
|
|
95
|
+
body: JSON.stringify({ currentPassword: 'oldpassword123' }),
|
|
96
|
+
});
|
|
97
|
+
assert.strictEqual(res.status, 400);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('rejects wrong current password with 401, hash unchanged', async () => {
|
|
101
|
+
const id = `bob${Date.now()}`;
|
|
102
|
+
await createPod(baseUrl, id, `${id}@example.com`, 'oldpassword123');
|
|
103
|
+
const token = await loginToken(baseUrl, `${id}@example.com`, 'oldpassword123');
|
|
104
|
+
|
|
105
|
+
const res = await fetch(`${baseUrl}/idp/credentials`, {
|
|
106
|
+
method: 'PUT',
|
|
107
|
+
headers: {
|
|
108
|
+
'Content-Type': 'application/json',
|
|
109
|
+
'Authorization': `Bearer ${token}`,
|
|
110
|
+
},
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
currentPassword: 'wrongpassword',
|
|
113
|
+
newPassword: 'newpassword456',
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
assert.strictEqual(res.status, 401);
|
|
117
|
+
|
|
118
|
+
// Original password still works
|
|
119
|
+
const reLogin = await fetch(`${baseUrl}/idp/credentials`, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { 'Content-Type': 'application/json' },
|
|
122
|
+
body: JSON.stringify({ email: `${id}@example.com`, password: 'oldpassword123' }),
|
|
123
|
+
});
|
|
124
|
+
assert.strictEqual(reLogin.status, 200);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('happy path: rotates password, old fails, new succeeds', async () => {
|
|
128
|
+
const id = `carol${Date.now()}`;
|
|
129
|
+
await createPod(baseUrl, id, `${id}@example.com`, 'oldpassword123');
|
|
130
|
+
const token = await loginToken(baseUrl, `${id}@example.com`, 'oldpassword123');
|
|
131
|
+
|
|
132
|
+
const res = await fetch(`${baseUrl}/idp/credentials`, {
|
|
133
|
+
method: 'PUT',
|
|
134
|
+
headers: {
|
|
135
|
+
'Content-Type': 'application/json',
|
|
136
|
+
'Authorization': `Bearer ${token}`,
|
|
137
|
+
},
|
|
138
|
+
body: JSON.stringify({
|
|
139
|
+
currentPassword: 'oldpassword123',
|
|
140
|
+
newPassword: 'newpassword456',
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
assert.strictEqual(res.status, 200);
|
|
144
|
+
const body = await res.json();
|
|
145
|
+
assert.strictEqual(body.ok, true);
|
|
146
|
+
assert.ok(body.webid.includes(id), 'response carries webid');
|
|
147
|
+
assert.ok(body.passwordChangedAt, 'response carries passwordChangedAt');
|
|
148
|
+
|
|
149
|
+
// Old password rejected
|
|
150
|
+
const oldRes = await fetch(`${baseUrl}/idp/credentials`, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: { 'Content-Type': 'application/json' },
|
|
153
|
+
body: JSON.stringify({ email: `${id}@example.com`, password: 'oldpassword123' }),
|
|
154
|
+
});
|
|
155
|
+
assert.strictEqual(oldRes.status, 401);
|
|
156
|
+
|
|
157
|
+
// New password accepted
|
|
158
|
+
const newRes = await fetch(`${baseUrl}/idp/credentials`, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: { 'Content-Type': 'application/json' },
|
|
161
|
+
body: JSON.stringify({ email: `${id}@example.com`, password: 'newpassword456' }),
|
|
162
|
+
});
|
|
163
|
+
assert.strictEqual(newRes.status, 200);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('cross-account write: A authenticated cannot rotate B by sending B\'s currentPassword', async () => {
|
|
167
|
+
const aId = `dave${Date.now()}`;
|
|
168
|
+
const bId = `eve${Date.now() + 1}`;
|
|
169
|
+
await createPod(baseUrl, aId, `${aId}@example.com`, 'apassword123');
|
|
170
|
+
await createPod(baseUrl, bId, `${bId}@example.com`, 'bpassword123');
|
|
171
|
+
|
|
172
|
+
const aToken = await loginToken(baseUrl, `${aId}@example.com`, 'apassword123');
|
|
173
|
+
|
|
174
|
+
// A sends B's currentPassword → server resolves account from A's WebID, so the
|
|
175
|
+
// currentPassword must match A's, not B's. With B's password it must fail 401
|
|
176
|
+
// (and crucially must NOT touch B's account).
|
|
177
|
+
const res = await fetch(`${baseUrl}/idp/credentials`, {
|
|
178
|
+
method: 'PUT',
|
|
179
|
+
headers: {
|
|
180
|
+
'Content-Type': 'application/json',
|
|
181
|
+
'Authorization': `Bearer ${aToken}`,
|
|
182
|
+
},
|
|
183
|
+
body: JSON.stringify({
|
|
184
|
+
currentPassword: 'bpassword123',
|
|
185
|
+
newPassword: 'hijack',
|
|
186
|
+
}),
|
|
187
|
+
});
|
|
188
|
+
assert.strictEqual(res.status, 401);
|
|
189
|
+
|
|
190
|
+
// B's password unchanged
|
|
191
|
+
const bLogin = await fetch(`${baseUrl}/idp/credentials`, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: { 'Content-Type': 'application/json' },
|
|
194
|
+
body: JSON.stringify({ email: `${bId}@example.com`, password: 'bpassword123' }),
|
|
195
|
+
});
|
|
196
|
+
assert.strictEqual(bLogin.status, 200);
|
|
197
|
+
|
|
198
|
+
// A's password also unchanged
|
|
199
|
+
const aLogin = await fetch(`${baseUrl}/idp/credentials`, {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: { 'Content-Type': 'application/json' },
|
|
202
|
+
body: JSON.stringify({ email: `${aId}@example.com`, password: 'apassword123' }),
|
|
203
|
+
});
|
|
204
|
+
assert.strictEqual(aLogin.status, 200);
|
|
205
|
+
});
|
|
206
|
+
});
|
package/test/idp.test.js
CHANGED
|
@@ -458,6 +458,84 @@ describe('Identity Provider - Root pod type index ACLs', () => {
|
|
|
458
458
|
});
|
|
459
459
|
});
|
|
460
460
|
|
|
461
|
+
// #348: --single-user with no name flag now defaults to a root pod
|
|
462
|
+
// (was '/me/' historically). The server-side seed must land the
|
|
463
|
+
// profile at /profile/card.jsonld, not /me/profile/card.jsonld.
|
|
464
|
+
describe('Single-user default — root pod (#348)', () => {
|
|
465
|
+
let server;
|
|
466
|
+
let baseUrl;
|
|
467
|
+
const DEFAULT_DATA_DIR = './test-data-348-default-root';
|
|
468
|
+
const ROOT_POD_PASSWORD = 'root-pod-test-pw';
|
|
469
|
+
|
|
470
|
+
before(async () => {
|
|
471
|
+
await fs.remove(DEFAULT_DATA_DIR);
|
|
472
|
+
await fs.ensureDir(DEFAULT_DATA_DIR);
|
|
473
|
+
|
|
474
|
+
const port = await getAvailablePort();
|
|
475
|
+
baseUrl = `http://${TEST_HOST}:${port}`;
|
|
476
|
+
|
|
477
|
+
server = createServer({
|
|
478
|
+
logger: false,
|
|
479
|
+
root: DEFAULT_DATA_DIR,
|
|
480
|
+
idp: true,
|
|
481
|
+
idpIssuer: baseUrl,
|
|
482
|
+
singleUser: true,
|
|
483
|
+
// singleUserName intentionally omitted — exercises the new default.
|
|
484
|
+
// Provide a password so the seeding path runs non-interactively.
|
|
485
|
+
singleUserPassword: ROOT_POD_PASSWORD,
|
|
486
|
+
forceCloseConnections: true,
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
await server.listen({ port, host: TEST_HOST });
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
after(async () => {
|
|
493
|
+
await server.close();
|
|
494
|
+
await fs.remove(DEFAULT_DATA_DIR);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('seeds the profile at /profile/card.jsonld (not /me/profile/...)', async () => {
|
|
498
|
+
const root = await fetch(`${baseUrl}/profile/card.jsonld`);
|
|
499
|
+
assert.strictEqual(root.status, 200,
|
|
500
|
+
'--single-user with no name should default to a root pod');
|
|
501
|
+
// Check the filesystem directly — an HTTP-only check could pass
|
|
502
|
+
// on a 401 even if /me/ data was somehow seeded, which would
|
|
503
|
+
// hide the regression we care about (root vs /me/ pod).
|
|
504
|
+
assert.strictEqual(await fs.pathExists(path.join(DEFAULT_DATA_DIR, 'me/profile/card.jsonld')), false,
|
|
505
|
+
'no /me/ pod files should be created when singleUserName is unset');
|
|
506
|
+
assert.strictEqual(await fs.pathExists(path.join(DEFAULT_DATA_DIR, 'me/profile/card')), false,
|
|
507
|
+
'no legacy /me/ pod files should be created either');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('WebID resolves at the server origin', async () => {
|
|
511
|
+
const res = await fetch(`${baseUrl}/profile/card.jsonld`);
|
|
512
|
+
const body = await res.json();
|
|
513
|
+
const webId = `${baseUrl}/profile/card.jsonld#me`;
|
|
514
|
+
const matches = Array.isArray(body)
|
|
515
|
+
? body.some(n => n['@id'] === webId)
|
|
516
|
+
: body['@id'] === webId || (body['@graph'] || []).some(n => n['@id'] === webId);
|
|
517
|
+
assert.ok(matches, `profile should declare WebID ${webId}, got: ${JSON.stringify(body).slice(0, 200)}`);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('seeds an IDP account for "me" so the root pod is loggable', async () => {
|
|
521
|
+
// Round-2 review of #348: a regression here would mean a fresh
|
|
522
|
+
// `jss start --single-user --idp` produces a pod nobody can log
|
|
523
|
+
// in to (registration is disabled in single-user mode, so there
|
|
524
|
+
// would be no recovery path other than out-of-band account
|
|
525
|
+
// creation). Use the credentials endpoint as a black-box login
|
|
526
|
+
// probe — if it issues a token, the seed worked.
|
|
527
|
+
const res = await fetch(`${baseUrl}/idp/credentials`, {
|
|
528
|
+
method: 'POST',
|
|
529
|
+
headers: { 'Content-Type': 'application/json' },
|
|
530
|
+
body: JSON.stringify({ username: 'me', password: ROOT_POD_PASSWORD }),
|
|
531
|
+
});
|
|
532
|
+
assert.strictEqual(res.status, 200,
|
|
533
|
+
`login as "me" should succeed for the default root pod (got ${res.status})`);
|
|
534
|
+
const body = await res.json();
|
|
535
|
+
assert.ok(body.access_token, 'response should carry an access token');
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
461
539
|
describe('Identity Provider - Accounts', () => {
|
|
462
540
|
let server;
|
|
463
541
|
let accountsUrl;
|