javascript-solid-server 0.0.75 → 0.0.77
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/README.md +38 -2
- package/bin/jss.js +3 -0
- package/package.json +2 -1
- package/src/auth/middleware.js +6 -3
- package/src/config.js +5 -0
- package/src/handlers/resource.js +104 -6
- package/src/idp/accounts.js +133 -0
- package/src/idp/index.js +65 -0
- package/src/idp/interactions.js +118 -9
- package/src/idp/passkey.js +311 -0
- package/src/idp/views.js +312 -1
- package/src/ldp/headers.js +3 -2
- package/src/mashlib/index.js +107 -0
- package/src/server.js +37 -1
- package/src/storage/filesystem.js +22 -0
- package/test/range.test.js +145 -0
|
@@ -234,7 +234,33 @@
|
|
|
234
234
|
"Bash(timeout 90 bash -c:*)",
|
|
235
235
|
"Bash(git commit -m \"$\\(cat <<''EOF''\nsecurity: add ACL check on WebSocket subscription requests\n\nCheck WAC read permission before allowing subscription to prevent\ninformation leakage via notifications. Unauthorized subscriptions\nnow receive ''err <url> forbidden'' response.\n\nSecurity improvements:\n- Check ACL read access before allowing subscription\n- Validate URLs are on this server \\(prevents SSRF-like probing\\)\n- Add subscription limit and URL length validation\n\nFixes #62\nEOF\n\\)\")",
|
|
236
236
|
"Bash(gh repo fork:*)",
|
|
237
|
-
"Bash(timeout 180 npm test:*)"
|
|
237
|
+
"Bash(timeout 180 npm test:*)",
|
|
238
|
+
"Bash(git show:*)",
|
|
239
|
+
"Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline -30 -- \"test/integration/acl-tls-test.mjs\" \"test-esm/integration/acl-tls-test.js\")",
|
|
240
|
+
"Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline --follow -30 -- \"test/integration/acl-tls-test.mjs\")",
|
|
241
|
+
"Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server show 778095ad --stat)",
|
|
242
|
+
"Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server show 778095ad)",
|
|
243
|
+
"Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server show b183c7a0)",
|
|
244
|
+
"Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline --before=\"2019-10-29\" --after=\"2019-10-01\" -20)",
|
|
245
|
+
"Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server show 1a92a912 --stat)",
|
|
246
|
+
"Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline --all --grep=jaxoncreed)",
|
|
247
|
+
"Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline --author=\"jaxoncreed\" -30)",
|
|
248
|
+
"Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline --author=\"[Dd]mitri\" -20)",
|
|
249
|
+
"Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline --all --grep=\"oidc\" -20)",
|
|
250
|
+
"Bash(npm install)",
|
|
251
|
+
"Bash(timeout 60 npx mocha:*)",
|
|
252
|
+
"Bash(timeout 120 npx mocha:*)",
|
|
253
|
+
"Bash(timeout 30 npx mocha:*)",
|
|
254
|
+
"Bash(openssl x509:*)",
|
|
255
|
+
"Bash(gh pr checks:*)",
|
|
256
|
+
"Bash(gh run view:*)",
|
|
257
|
+
"Bash(gh pr edit:*)",
|
|
258
|
+
"WebFetch(domain:patch-diff.githubusercontent.com)",
|
|
259
|
+
"Bash(git rebase:*)",
|
|
260
|
+
"Bash(timeout 10 npm start)",
|
|
261
|
+
"Bash(node bin/jss.js start:*)",
|
|
262
|
+
"Bash(ssh solid.social \"cd /var/www/jss && git pull && pm2 restart jss\")",
|
|
263
|
+
"Bash(ssh solid.social:*)"
|
|
238
264
|
]
|
|
239
265
|
}
|
|
240
266
|
}
|
package/README.md
CHANGED
|
@@ -6,8 +6,11 @@ A minimal, fast, JSON-LD native Solid server.
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
### Implemented (v0.0.
|
|
9
|
+
### Implemented (v0.0.77)
|
|
10
10
|
|
|
11
|
+
- **Passkey Authentication** - WebAuthn/FIDO2 passwordless login with Touch ID, Face ID, or security keys
|
|
12
|
+
- **HTTP Range Requests** - Partial content delivery for large files and media streaming
|
|
13
|
+
- **Single-User Mode** - Simplified setup for personal pod servers
|
|
11
14
|
- **ActivityPub Federation** - Fediverse integration with WebFinger, inbox/outbox, HTTP signatures
|
|
12
15
|
- **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
|
|
13
16
|
- **N3 Patch** - Solid's native patch format for RDF updates
|
|
@@ -122,6 +125,7 @@ jss --help # Show help
|
|
|
122
125
|
| `--mashlib` | Enable Mashlib (local mode) | false |
|
|
123
126
|
| `--mashlib-cdn` | Enable Mashlib (CDN mode) | false |
|
|
124
127
|
| `--mashlib-version <ver>` | Mashlib CDN version | 2.0.0 |
|
|
128
|
+
| `--solidos-ui` | Enable modern SolidOS UI (requires --mashlib) | false |
|
|
125
129
|
| `--git` | Enable Git HTTP backend | false |
|
|
126
130
|
| `--nostr` | Enable Nostr relay | false |
|
|
127
131
|
| `--nostr-path <path>` | Nostr relay WebSocket path | /relay |
|
|
@@ -333,6 +337,18 @@ npm install && npm run build
|
|
|
333
337
|
|
|
334
338
|
**Note:** Mashlib works best with `--conneg` enabled for Turtle support.
|
|
335
339
|
|
|
340
|
+
**Modern UI (SolidOS UI):**
|
|
341
|
+
```bash
|
|
342
|
+
jss start --mashlib --solidos-ui --conneg
|
|
343
|
+
```
|
|
344
|
+
Serves a modern Nextcloud-style UI shell while reusing mashlib's data layer. The `--solidos-ui` flag swaps the classic databrowser interface for a cleaner, mobile-friendly design with:
|
|
345
|
+
- Modern file browser with breadcrumb navigation
|
|
346
|
+
- Profile, Contacts, Sharing, and Settings views
|
|
347
|
+
- Path-based URLs (browser URL reflects current resource)
|
|
348
|
+
- Responsive design for mobile devices
|
|
349
|
+
|
|
350
|
+
Requires solidos-ui dist files in `src/mashlib-local/dist/solidos-ui/`. See [solidos-ui](https://github.com/solidos/solidos/tree/main/workspaces/solidos-ui) for details.
|
|
351
|
+
|
|
336
352
|
### Profile Pages
|
|
337
353
|
|
|
338
354
|
Pod profiles (`/alice/`) use HTML with embedded JSON-LD data islands and are rendered using:
|
|
@@ -657,6 +673,26 @@ Response:
|
|
|
657
673
|
|
|
658
674
|
For DPoP-bound tokens (Solid-OIDC compliant), include a DPoP proof header.
|
|
659
675
|
|
|
676
|
+
### Passkey Authentication (v0.0.77+)
|
|
677
|
+
|
|
678
|
+
Enable passwordless login with WebAuthn/FIDO2:
|
|
679
|
+
|
|
680
|
+
```bash
|
|
681
|
+
jss start --idp
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
**How it works:**
|
|
685
|
+
1. User logs in with username/password
|
|
686
|
+
2. Prompted to add a passkey (Touch ID, Face ID, security key)
|
|
687
|
+
3. Future logins: tap "Sign in with Passkey" → biometric → done!
|
|
688
|
+
|
|
689
|
+
**Benefits:**
|
|
690
|
+
- Phishing-resistant (bound to domain)
|
|
691
|
+
- No passwords to remember or leak
|
|
692
|
+
- Works on mobile and desktop
|
|
693
|
+
|
|
694
|
+
Passkeys are stored per-account and work across devices via platform sync (iCloud Keychain, Google Password Manager, etc.).
|
|
695
|
+
|
|
660
696
|
### Solid-OIDC (External IdP)
|
|
661
697
|
|
|
662
698
|
The server also accepts DPoP-bound access tokens from external Solid identity providers:
|
|
@@ -856,7 +892,7 @@ npm run benchmark
|
|
|
856
892
|
npm test
|
|
857
893
|
```
|
|
858
894
|
|
|
859
|
-
Currently passing: **
|
|
895
|
+
Currently passing: **223 tests** (including 27 conformance tests)
|
|
860
896
|
|
|
861
897
|
### Conformance Test Harness (CTH)
|
|
862
898
|
|
package/bin/jss.js
CHANGED
|
@@ -57,6 +57,7 @@ program
|
|
|
57
57
|
.option('--mashlib-cdn', 'Enable Mashlib data browser (CDN mode, no local files needed)')
|
|
58
58
|
.option('--no-mashlib', 'Disable Mashlib data browser')
|
|
59
59
|
.option('--mashlib-version <version>', 'Mashlib version for CDN mode (default: 2.0.0)')
|
|
60
|
+
.option('--solidos-ui', 'Enable modern Nextcloud-style UI (requires --mashlib)')
|
|
60
61
|
.option('--git', 'Enable Git HTTP backend (clone/push support)')
|
|
61
62
|
.option('--no-git', 'Disable Git HTTP backend')
|
|
62
63
|
.option('--nostr', 'Enable Nostr relay')
|
|
@@ -114,6 +115,7 @@ program
|
|
|
114
115
|
mashlib: config.mashlib || config.mashlibCdn,
|
|
115
116
|
mashlibCdn: config.mashlibCdn,
|
|
116
117
|
mashlibVersion: config.mashlibVersion,
|
|
118
|
+
solidosUi: config.solidosUi,
|
|
117
119
|
git: config.git,
|
|
118
120
|
nostr: config.nostr,
|
|
119
121
|
nostrPath: config.nostrPath,
|
|
@@ -143,6 +145,7 @@ program
|
|
|
143
145
|
} else if (config.mashlib) {
|
|
144
146
|
console.log(` Mashlib: local (data browser enabled)`);
|
|
145
147
|
}
|
|
148
|
+
if (config.solidosUi) console.log(' SolidOS UI: enabled (modern interface)');
|
|
146
149
|
if (config.git) console.log(' Git: enabled (clone/push support)');
|
|
147
150
|
if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
|
|
148
151
|
if (config.activitypub) console.log(` ActivityPub: enabled (@${config.apUsername || 'me'})`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javascript-solid-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.77",
|
|
4
4
|
"description": "A minimal, fast Solid server",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"@fastify/middie": "^8.3.3",
|
|
27
27
|
"@fastify/rate-limit": "^9.1.0",
|
|
28
28
|
"@fastify/websocket": "^8.3.1",
|
|
29
|
+
"@simplewebauthn/server": "^13.2.2",
|
|
29
30
|
"bcrypt": "^6.0.0",
|
|
30
31
|
"bcryptjs": "^3.0.3",
|
|
31
32
|
"better-sqlite3": "^12.5.0",
|
package/src/auth/middleware.js
CHANGED
|
@@ -9,7 +9,7 @@ import { checkAccess, getRequiredMode } from '../wac/checker.js';
|
|
|
9
9
|
import { AccessMode } from '../wac/parser.js';
|
|
10
10
|
import * as storage from '../storage/filesystem.js';
|
|
11
11
|
import { getEffectiveUrlPath } from '../utils/url.js';
|
|
12
|
-
import { generateDatabrowserHtml } from '../mashlib/index.js';
|
|
12
|
+
import { generateDatabrowserHtml, generateSolidosUiHtml } from '../mashlib/index.js';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Check if request is authorized
|
|
@@ -117,8 +117,11 @@ export function handleUnauthorized(request, reply, isAuthenticated, wacAllow, au
|
|
|
117
117
|
// If mashlib is enabled, serve mashlib instead of static error page
|
|
118
118
|
// Mashlib has built-in login functionality via panes.runDataBrowser()
|
|
119
119
|
if (request.mashlibEnabled) {
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
// Use SolidOS UI if enabled, otherwise fallback to classic mashlib
|
|
121
|
+
const html = request.solidosUiEnabled
|
|
122
|
+
? generateSolidosUiHtml()
|
|
123
|
+
: generateDatabrowserHtml(request.url, request.mashlibCdn ? request.mashlibVersion : null);
|
|
124
|
+
return reply.code(statusCode).type('text/html').send(html);
|
|
122
125
|
}
|
|
123
126
|
return reply.code(statusCode).type('text/html').send(getErrorPage(statusCode, isAuthenticated, request));
|
|
124
127
|
}
|
package/src/config.js
CHANGED
|
@@ -42,6 +42,9 @@ export const defaults = {
|
|
|
42
42
|
mashlibCdn: false,
|
|
43
43
|
mashlibVersion: '2.0.0',
|
|
44
44
|
|
|
45
|
+
// SolidOS UI (modern Nextcloud-style interface)
|
|
46
|
+
solidosUi: false,
|
|
47
|
+
|
|
45
48
|
// Git HTTP backend
|
|
46
49
|
git: false,
|
|
47
50
|
|
|
@@ -95,6 +98,7 @@ const envMap = {
|
|
|
95
98
|
JSS_MASHLIB: 'mashlib',
|
|
96
99
|
JSS_MASHLIB_CDN: 'mashlibCdn',
|
|
97
100
|
JSS_MASHLIB_VERSION: 'mashlibVersion',
|
|
101
|
+
JSS_SOLIDOS_UI: 'solidosUi',
|
|
98
102
|
JSS_GIT: 'git',
|
|
99
103
|
JSS_NOSTR: 'nostr',
|
|
100
104
|
JSS_NOSTR_PATH: 'nostrPath',
|
|
@@ -258,5 +262,6 @@ export function printConfig(config) {
|
|
|
258
262
|
console.log(` IdP: ${config.idp ? (config.idpIssuer || 'enabled') : 'disabled'}`);
|
|
259
263
|
console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`);
|
|
260
264
|
console.log(` Mashlib: ${config.mashlibCdn ? `CDN v${config.mashlibVersion}` : config.mashlib ? 'local' : 'disabled'}`);
|
|
265
|
+
console.log(` SolidOS UI: ${config.solidosUi ? 'enabled' : 'disabled'}`);
|
|
261
266
|
console.log('─'.repeat(40));
|
|
262
267
|
}
|
package/src/handlers/resource.js
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from '../rdf/conneg.js';
|
|
16
16
|
import { emitChange } from '../notifications/events.js';
|
|
17
17
|
import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
|
|
18
|
-
import { generateDatabrowserHtml, shouldServeMashlib } from '../mashlib/index.js';
|
|
18
|
+
import { generateDatabrowserHtml, generateSolidosUiHtml, shouldServeMashlib } from '../mashlib/index.js';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Get the storage path and resource URL for a request
|
|
@@ -30,6 +30,64 @@ function getRequestPaths(request) {
|
|
|
30
30
|
return { urlPath, storagePath, resourceUrl };
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Parse HTTP Range header
|
|
35
|
+
* @param {string} rangeHeader - The Range header value (e.g., "bytes=0-1023")
|
|
36
|
+
* @param {number} fileSize - Total file size in bytes
|
|
37
|
+
* @returns {{ start: number, end: number } | null}
|
|
38
|
+
*/
|
|
39
|
+
function parseRangeHeader(rangeHeader, fileSize) {
|
|
40
|
+
if (!rangeHeader || !rangeHeader.startsWith('bytes=')) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const range = rangeHeader.slice(6); // Remove 'bytes='
|
|
45
|
+
|
|
46
|
+
// Multi-range requests (e.g., "0-100,200-300") are not supported
|
|
47
|
+
// Per RFC 7233, ignore Range header and serve full content instead of 416
|
|
48
|
+
if (range.includes(',')) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const parts = range.split('-');
|
|
53
|
+
|
|
54
|
+
if (parts.length !== 2) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let start, end;
|
|
59
|
+
|
|
60
|
+
if (parts[0] === '') {
|
|
61
|
+
// Suffix range: bytes=-500 (last 500 bytes)
|
|
62
|
+
const suffix = parseInt(parts[1], 10);
|
|
63
|
+
if (isNaN(suffix) || suffix <= 0) return null;
|
|
64
|
+
start = Math.max(0, fileSize - suffix);
|
|
65
|
+
end = fileSize - 1;
|
|
66
|
+
} else if (parts[1] === '') {
|
|
67
|
+
// Open-ended range: bytes=1024- (from 1024 to end)
|
|
68
|
+
start = parseInt(parts[0], 10);
|
|
69
|
+
if (isNaN(start) || start < 0) return null;
|
|
70
|
+
end = fileSize - 1;
|
|
71
|
+
} else {
|
|
72
|
+
// Normal range: bytes=0-1023
|
|
73
|
+
start = parseInt(parts[0], 10);
|
|
74
|
+
end = parseInt(parts[1], 10);
|
|
75
|
+
if (isNaN(start) || isNaN(end) || start < 0 || end < start) return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Clamp end to file size
|
|
79
|
+
if (end >= fileSize) {
|
|
80
|
+
end = fileSize - 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check if range is satisfiable
|
|
84
|
+
if (start > end || start >= fileSize) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { start, end };
|
|
89
|
+
}
|
|
90
|
+
|
|
33
91
|
/**
|
|
34
92
|
* Handle GET request
|
|
35
93
|
*/
|
|
@@ -149,8 +207,10 @@ export async function handleGet(request, reply) {
|
|
|
149
207
|
|
|
150
208
|
// Check if we should serve Mashlib data browser for containers
|
|
151
209
|
if (shouldServeMashlib(request, request.mashlibEnabled, 'application/ld+json')) {
|
|
152
|
-
|
|
153
|
-
const html =
|
|
210
|
+
// Use SolidOS UI if enabled, otherwise fallback to classic mashlib
|
|
211
|
+
const html = request.solidosUiEnabled
|
|
212
|
+
? generateSolidosUiHtml()
|
|
213
|
+
: generateDatabrowserHtml(resourceUrl, request.mashlibCdn ? request.mashlibVersion : null);
|
|
154
214
|
const headers = getAllHeaders({
|
|
155
215
|
isContainer: true,
|
|
156
216
|
etag: stats.etag,
|
|
@@ -224,9 +284,10 @@ export async function handleGet(request, reply) {
|
|
|
224
284
|
// Check if we should serve Mashlib data browser
|
|
225
285
|
// Only for RDF resources when Accept: text/html is requested
|
|
226
286
|
if (shouldServeMashlib(request, request.mashlibEnabled, storedContentType)) {
|
|
227
|
-
//
|
|
228
|
-
const
|
|
229
|
-
|
|
287
|
+
// Use SolidOS UI if enabled, otherwise fallback to classic mashlib
|
|
288
|
+
const html = request.solidosUiEnabled
|
|
289
|
+
? generateSolidosUiHtml()
|
|
290
|
+
: generateDatabrowserHtml(resourceUrl, request.mashlibCdn ? request.mashlibVersion : null);
|
|
230
291
|
const headers = getAllHeaders({
|
|
231
292
|
isContainer: false,
|
|
232
293
|
etag: stats.etag,
|
|
@@ -245,6 +306,43 @@ export async function handleGet(request, reply) {
|
|
|
245
306
|
return reply.type('text/html').send(html);
|
|
246
307
|
}
|
|
247
308
|
|
|
309
|
+
// Handle Range requests for media files (video, audio, etc.)
|
|
310
|
+
const rangeHeader = request.headers.range;
|
|
311
|
+
if (rangeHeader && !isRdfContentType(storedContentType)) {
|
|
312
|
+
const range = parseRangeHeader(rangeHeader, stats.size);
|
|
313
|
+
|
|
314
|
+
if (range) {
|
|
315
|
+
const { start, end } = range;
|
|
316
|
+
const chunkSize = end - start + 1;
|
|
317
|
+
|
|
318
|
+
const headers = getAllHeaders({
|
|
319
|
+
isContainer: false,
|
|
320
|
+
etag: stats.etag,
|
|
321
|
+
contentType: storedContentType,
|
|
322
|
+
origin,
|
|
323
|
+
resourceUrl,
|
|
324
|
+
connegEnabled
|
|
325
|
+
});
|
|
326
|
+
headers['Content-Range'] = `bytes ${start}-${end}/${stats.size}`;
|
|
327
|
+
headers['Content-Length'] = chunkSize;
|
|
328
|
+
|
|
329
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
330
|
+
|
|
331
|
+
const streamResult = storage.createReadStream(storagePath, { start, end });
|
|
332
|
+
if (!streamResult) {
|
|
333
|
+
return reply.code(500).send({ error: 'Stream error' });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Handle stream errors that occur during response
|
|
337
|
+
streamResult.stream.on('error', (err) => {
|
|
338
|
+
console.error('Stream error during range response:', err.message);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
return reply.code(206).send(streamResult.stream);
|
|
342
|
+
}
|
|
343
|
+
// If range is null (unsupported format or multi-range), fall through to serve full content
|
|
344
|
+
}
|
|
345
|
+
|
|
248
346
|
const content = await storage.read(storagePath);
|
|
249
347
|
if (content === null) {
|
|
250
348
|
return reply.code(500).send({ error: 'Read error' });
|
package/src/idp/accounts.js
CHANGED
|
@@ -38,6 +38,10 @@ function getWebIdIndexPath() {
|
|
|
38
38
|
return path.join(getAccountsDir(), '_webid_index.json');
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function getCredentialIndexPath() {
|
|
42
|
+
return path.join(getAccountsDir(), '_credential_index.json');
|
|
43
|
+
}
|
|
44
|
+
|
|
41
45
|
const SALT_ROUNDS = 10;
|
|
42
46
|
|
|
43
47
|
/**
|
|
@@ -270,6 +274,135 @@ export async function deleteAccount(id) {
|
|
|
270
274
|
await fs.remove(accountPath);
|
|
271
275
|
}
|
|
272
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Save an account (internal helper)
|
|
279
|
+
* @param {object} account - Account object
|
|
280
|
+
*/
|
|
281
|
+
async function saveAccount(account) {
|
|
282
|
+
const accountPath = path.join(getAccountsDir(), `${account.id}.json`);
|
|
283
|
+
await fs.writeJson(accountPath, account, { spaces: 2 });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Update last login timestamp
|
|
288
|
+
* @param {string} id - Account ID
|
|
289
|
+
*/
|
|
290
|
+
export async function updateLastLogin(id) {
|
|
291
|
+
const account = await findById(id);
|
|
292
|
+
if (!account) return;
|
|
293
|
+
account.lastLogin = new Date().toISOString();
|
|
294
|
+
await saveAccount(account);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Add a passkey credential to an account
|
|
299
|
+
* @param {string} accountId - Account ID
|
|
300
|
+
* @param {object} credential - Passkey credential
|
|
301
|
+
* @param {string} credential.credentialId - Base64url encoded credential ID
|
|
302
|
+
* @param {string} credential.publicKey - Base64url encoded public key
|
|
303
|
+
* @param {number} credential.counter - Authenticator counter
|
|
304
|
+
* @param {string[]} [credential.transports] - Supported transports
|
|
305
|
+
* @param {string} [credential.name] - User-friendly name
|
|
306
|
+
* @returns {Promise<boolean>} - Success
|
|
307
|
+
*/
|
|
308
|
+
export async function addPasskey(accountId, credential) {
|
|
309
|
+
const account = await findById(accountId);
|
|
310
|
+
if (!account) return false;
|
|
311
|
+
|
|
312
|
+
account.passkeys = account.passkeys || [];
|
|
313
|
+
|
|
314
|
+
// Check for duplicate credentialId
|
|
315
|
+
const existingPasskey = account.passkeys.find(pk => pk.credentialId === credential.credentialId);
|
|
316
|
+
if (existingPasskey) {
|
|
317
|
+
return false; // Already registered
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
account.passkeys.push({
|
|
321
|
+
credentialId: credential.credentialId,
|
|
322
|
+
publicKey: credential.publicKey,
|
|
323
|
+
counter: credential.counter || 0,
|
|
324
|
+
transports: credential.transports || [],
|
|
325
|
+
createdAt: new Date().toISOString(),
|
|
326
|
+
lastUsed: null,
|
|
327
|
+
name: credential.name || 'Security Key'
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await saveAccount(account);
|
|
331
|
+
|
|
332
|
+
// Update credential index
|
|
333
|
+
const credentialIndex = await loadIndex(getCredentialIndexPath());
|
|
334
|
+
credentialIndex[credential.credentialId] = accountId;
|
|
335
|
+
await saveIndex(getCredentialIndexPath(), credentialIndex);
|
|
336
|
+
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Find an account by passkey credential ID
|
|
342
|
+
* @param {string} credentialId - Base64url encoded credential ID
|
|
343
|
+
* @returns {Promise<object|null>} - Account or null
|
|
344
|
+
*/
|
|
345
|
+
export async function findByCredentialId(credentialId) {
|
|
346
|
+
const credentialIndex = await loadIndex(getCredentialIndexPath());
|
|
347
|
+
const id = credentialIndex[credentialId];
|
|
348
|
+
if (!id) return null;
|
|
349
|
+
return findById(id);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Update passkey counter after successful authentication
|
|
354
|
+
* @param {string} accountId - Account ID
|
|
355
|
+
* @param {string} credentialId - Credential ID
|
|
356
|
+
* @param {number} newCounter - New counter value
|
|
357
|
+
*/
|
|
358
|
+
export async function updatePasskeyCounter(accountId, credentialId, newCounter) {
|
|
359
|
+
const account = await findById(accountId);
|
|
360
|
+
if (!account || !account.passkeys) return;
|
|
361
|
+
|
|
362
|
+
const passkey = account.passkeys.find(p => p.credentialId === credentialId);
|
|
363
|
+
if (passkey) {
|
|
364
|
+
passkey.counter = newCounter;
|
|
365
|
+
passkey.lastUsed = new Date().toISOString();
|
|
366
|
+
await saveAccount(account);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Remove a passkey from an account
|
|
372
|
+
* @param {string} accountId - Account ID
|
|
373
|
+
* @param {string} credentialId - Credential ID to remove
|
|
374
|
+
* @returns {Promise<boolean>} - Success
|
|
375
|
+
*/
|
|
376
|
+
export async function removePasskey(accountId, credentialId) {
|
|
377
|
+
const account = await findById(accountId);
|
|
378
|
+
if (!account || !account.passkeys) return false;
|
|
379
|
+
|
|
380
|
+
const index = account.passkeys.findIndex(p => p.credentialId === credentialId);
|
|
381
|
+
if (index === -1) return false;
|
|
382
|
+
|
|
383
|
+
account.passkeys.splice(index, 1);
|
|
384
|
+
await saveAccount(account);
|
|
385
|
+
|
|
386
|
+
// Update credential index
|
|
387
|
+
const credentialIndex = await loadIndex(getCredentialIndexPath());
|
|
388
|
+
delete credentialIndex[credentialId];
|
|
389
|
+
await saveIndex(getCredentialIndexPath(), credentialIndex);
|
|
390
|
+
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Set passkey prompt dismissed flag
|
|
396
|
+
* @param {string} accountId - Account ID
|
|
397
|
+
* @param {boolean} dismissed - Whether prompt was dismissed
|
|
398
|
+
*/
|
|
399
|
+
export async function setPasskeyPromptDismissed(accountId, dismissed = true) {
|
|
400
|
+
const account = await findById(accountId);
|
|
401
|
+
if (!account) return;
|
|
402
|
+
account.passkeyPromptDismissed = dismissed;
|
|
403
|
+
await saveAccount(account);
|
|
404
|
+
}
|
|
405
|
+
|
|
273
406
|
/**
|
|
274
407
|
* Get account for oidc-provider's findAccount
|
|
275
408
|
* This is the interface oidc-provider expects
|
package/src/idp/index.js
CHANGED
|
@@ -13,11 +13,14 @@ import {
|
|
|
13
13
|
handleAbort,
|
|
14
14
|
handleRegisterGet,
|
|
15
15
|
handleRegisterPost,
|
|
16
|
+
handlePasskeyComplete,
|
|
17
|
+
handlePasskeySkip,
|
|
16
18
|
} from './interactions.js';
|
|
17
19
|
import {
|
|
18
20
|
handleCredentials,
|
|
19
21
|
handleCredentialsInfo,
|
|
20
22
|
} from './credentials.js';
|
|
23
|
+
import * as passkey from './passkey.js';
|
|
21
24
|
import { addTrustedIssuer } from '../auth/solid-oidc.js';
|
|
22
25
|
|
|
23
26
|
/**
|
|
@@ -290,6 +293,68 @@ export async function idpPlugin(fastify, options) {
|
|
|
290
293
|
return handleRegisterPost(request, reply, issuer, inviteOnly);
|
|
291
294
|
});
|
|
292
295
|
|
|
296
|
+
// Passkey routes
|
|
297
|
+
// Registration options - rate limited to prevent DoS
|
|
298
|
+
fastify.post('/idp/passkey/register/options', {
|
|
299
|
+
config: {
|
|
300
|
+
rateLimit: {
|
|
301
|
+
max: 10,
|
|
302
|
+
timeWindow: '1 minute',
|
|
303
|
+
keyGenerator: (request) => request.ip
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}, async (request, reply) => {
|
|
307
|
+
return passkey.registrationOptions(request, reply);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Registration verify - rate limited
|
|
311
|
+
fastify.post('/idp/passkey/register/verify', {
|
|
312
|
+
config: {
|
|
313
|
+
rateLimit: {
|
|
314
|
+
max: 10,
|
|
315
|
+
timeWindow: '1 minute',
|
|
316
|
+
keyGenerator: (request) => request.ip
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}, async (request, reply) => {
|
|
320
|
+
return passkey.registrationVerify(request, reply);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Login options - rate limited to prevent DoS
|
|
324
|
+
fastify.post('/idp/passkey/login/options', {
|
|
325
|
+
config: {
|
|
326
|
+
rateLimit: {
|
|
327
|
+
max: 10,
|
|
328
|
+
timeWindow: '1 minute',
|
|
329
|
+
keyGenerator: (request) => request.ip
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}, async (request, reply) => {
|
|
333
|
+
return passkey.authenticationOptions(request, reply);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Login verify - rate limited
|
|
337
|
+
fastify.post('/idp/passkey/login/verify', {
|
|
338
|
+
config: {
|
|
339
|
+
rateLimit: {
|
|
340
|
+
max: 10,
|
|
341
|
+
timeWindow: '1 minute',
|
|
342
|
+
keyGenerator: (request) => request.ip
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}, async (request, reply) => {
|
|
346
|
+
return passkey.authenticationVerify(request, reply);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Passkey interaction handlers
|
|
350
|
+
fastify.get('/idp/interaction/:uid/passkey-complete', async (request, reply) => {
|
|
351
|
+
return handlePasskeyComplete(request, reply, provider);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
fastify.get('/idp/interaction/:uid/passkey-skip', async (request, reply) => {
|
|
355
|
+
return handlePasskeySkip(request, reply, provider);
|
|
356
|
+
});
|
|
357
|
+
|
|
293
358
|
fastify.log.info(`IdP initialized with issuer: ${issuer}`);
|
|
294
359
|
}
|
|
295
360
|
|