javascript-solid-server 0.0.76 → 0.0.78

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/README.md CHANGED
@@ -6,8 +6,12 @@ A minimal, fast, JSON-LD native Solid server.
6
6
 
7
7
  ## Features
8
8
 
9
- ### Implemented (v0.0.75)
9
+ ### Implemented (v0.0.78)
10
10
 
11
+ - **Schnorr SSO** - Passwordless login via BIP-340 Schnorr signatures using NIP-07 browser extensions (Podkey, nos2x, Alby)
12
+ - **Passkey Authentication** - WebAuthn/FIDO2 passwordless login with Touch ID, Face ID, or security keys
13
+ - **HTTP Range Requests** - Partial content delivery for large files and media streaming
14
+ - **Single-User Mode** - Simplified setup for personal pod servers
11
15
  - **ActivityPub Federation** - Fediverse integration with WebFinger, inbox/outbox, HTTP signatures
12
16
  - **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
13
17
  - **N3 Patch** - Solid's native patch format for RDF updates
@@ -670,6 +674,49 @@ Response:
670
674
 
671
675
  For DPoP-bound tokens (Solid-OIDC compliant), include a DPoP proof header.
672
676
 
677
+ ### Passkey Authentication (v0.0.77+)
678
+
679
+ Enable passwordless login with WebAuthn/FIDO2:
680
+
681
+ ```bash
682
+ jss start --idp
683
+ ```
684
+
685
+ **How it works:**
686
+ 1. User logs in with username/password
687
+ 2. Prompted to add a passkey (Touch ID, Face ID, security key)
688
+ 3. Future logins: tap "Sign in with Passkey" → biometric → done!
689
+
690
+ **Benefits:**
691
+ - Phishing-resistant (bound to domain)
692
+ - No passwords to remember or leak
693
+ - Works on mobile and desktop
694
+
695
+ Passkeys are stored per-account and work across devices via platform sync (iCloud Keychain, Google Password Manager, etc.).
696
+
697
+ ### Schnorr SSO (v0.0.78+)
698
+
699
+ Sign in with your Nostr key using NIP-07 browser extensions:
700
+
701
+ ```bash
702
+ jss start --idp
703
+ ```
704
+
705
+ **How it works:**
706
+ 1. User clicks "Sign in with Schnorr" on the login page
707
+ 2. NIP-07 extension (Podkey, nos2x, Alby) signs a NIP-98 auth event
708
+ 3. Server verifies BIP-340 Schnorr signature
709
+ 4. User authenticated via linked did:nostr identity
710
+
711
+ **Requirements:**
712
+ - Account must have a `did:nostr:<pubkey>` WebID linked
713
+ - User needs a NIP-07 compatible browser extension
714
+
715
+ **Benefits:**
716
+ - No passwords - cryptographic authentication
717
+ - Works with existing Nostr identity
718
+ - Single sign-on across Solid and Nostr ecosystems
719
+
673
720
  ### Solid-OIDC (External IdP)
674
721
 
675
722
  The server also accepts DPoP-bound access tokens from external Solid identity providers:
@@ -869,7 +916,7 @@ npm run benchmark
869
916
  npm test
870
917
  ```
871
918
 
872
- Currently passing: **213 tests** (including 27 conformance tests)
919
+ Currently passing: **223 tests** (including 27 conformance tests)
873
920
 
874
921
  ### Conformance Test Harness (CTH)
875
922
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.76",
3
+ "version": "0.0.78",
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",
@@ -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,16 @@ import {
13
13
  handleAbort,
14
14
  handleRegisterGet,
15
15
  handleRegisterPost,
16
+ handlePasskeyComplete,
17
+ handlePasskeySkip,
18
+ handleSchnorrLogin,
19
+ handleSchnorrComplete,
16
20
  } from './interactions.js';
17
21
  import {
18
22
  handleCredentials,
19
23
  handleCredentialsInfo,
20
24
  } from './credentials.js';
25
+ import * as passkey from './passkey.js';
21
26
  import { addTrustedIssuer } from '../auth/solid-oidc.js';
22
27
 
23
28
  /**
@@ -290,6 +295,84 @@ export async function idpPlugin(fastify, options) {
290
295
  return handleRegisterPost(request, reply, issuer, inviteOnly);
291
296
  });
292
297
 
298
+ // Passkey routes
299
+ // Registration options - rate limited to prevent DoS
300
+ fastify.post('/idp/passkey/register/options', {
301
+ config: {
302
+ rateLimit: {
303
+ max: 10,
304
+ timeWindow: '1 minute',
305
+ keyGenerator: (request) => request.ip
306
+ }
307
+ }
308
+ }, async (request, reply) => {
309
+ return passkey.registrationOptions(request, reply);
310
+ });
311
+
312
+ // Registration verify - rate limited
313
+ fastify.post('/idp/passkey/register/verify', {
314
+ config: {
315
+ rateLimit: {
316
+ max: 10,
317
+ timeWindow: '1 minute',
318
+ keyGenerator: (request) => request.ip
319
+ }
320
+ }
321
+ }, async (request, reply) => {
322
+ return passkey.registrationVerify(request, reply);
323
+ });
324
+
325
+ // Login options - rate limited to prevent DoS
326
+ fastify.post('/idp/passkey/login/options', {
327
+ config: {
328
+ rateLimit: {
329
+ max: 10,
330
+ timeWindow: '1 minute',
331
+ keyGenerator: (request) => request.ip
332
+ }
333
+ }
334
+ }, async (request, reply) => {
335
+ return passkey.authenticationOptions(request, reply);
336
+ });
337
+
338
+ // Login verify - rate limited
339
+ fastify.post('/idp/passkey/login/verify', {
340
+ config: {
341
+ rateLimit: {
342
+ max: 10,
343
+ timeWindow: '1 minute',
344
+ keyGenerator: (request) => request.ip
345
+ }
346
+ }
347
+ }, async (request, reply) => {
348
+ return passkey.authenticationVerify(request, reply);
349
+ });
350
+
351
+ // Passkey interaction handlers
352
+ fastify.get('/idp/interaction/:uid/passkey-complete', async (request, reply) => {
353
+ return handlePasskeyComplete(request, reply, provider);
354
+ });
355
+
356
+ fastify.get('/idp/interaction/:uid/passkey-skip', async (request, reply) => {
357
+ return handlePasskeySkip(request, reply, provider);
358
+ });
359
+
360
+ // Schnorr (NIP-98) interaction handlers
361
+ fastify.post('/idp/interaction/:uid/schnorr-login', {
362
+ config: {
363
+ rateLimit: {
364
+ max: 10,
365
+ timeWindow: '1 minute'
366
+ }
367
+ }
368
+ }, async (request, reply) => {
369
+ return handleSchnorrLogin(request, reply, provider);
370
+ });
371
+
372
+ fastify.get('/idp/interaction/:uid/schnorr-complete', async (request, reply) => {
373
+ return handleSchnorrComplete(request, reply, provider);
374
+ });
375
+
293
376
  fastify.log.info(`IdP initialized with issuer: ${issuer}`);
294
377
  }
295
378
 
@@ -3,11 +3,12 @@
3
3
  * Handles the user-facing parts of the authentication flow
4
4
  */
5
5
 
6
- import { authenticate, findById, createAccount } from './accounts.js';
7
- import { loginPage, consentPage, errorPage, registerPage } from './views.js';
6
+ import { authenticate, findById, findByWebId, createAccount, updateLastLogin, setPasskeyPromptDismissed } from './accounts.js';
7
+ import { loginPage, consentPage, errorPage, registerPage, passkeyPromptPage } from './views.js';
8
8
  import * as storage from '../storage/filesystem.js';
9
9
  import { createPodStructure } from '../handlers/container.js';
10
10
  import { validateInvite } from './invites.js';
11
+ import { verifyNostrAuth } from '../auth/nostr.js';
11
12
 
12
13
  // Security: Maximum body size for IdP form submissions (1MB)
13
14
  const MAX_BODY_SIZE = 1024 * 1024;
@@ -122,7 +123,31 @@ export async function handleLogin(request, reply, provider) {
122
123
  return reply.redirect(`/idp/interaction/${uid}`);
123
124
  }
124
125
 
125
- // Login successful - complete the interaction
126
+ // Login successful
127
+ request.log.info({ accountId: account.id, uid }, 'Login successful');
128
+
129
+ // Detect if this is a browser (wants HTML/redirect) or programmatic client (wants JSON)
130
+ const acceptHeader = request.headers.accept || '';
131
+ const wantsBrowserRedirect = acceptHeader.includes('text/html') && !acceptHeader.includes('application/json');
132
+
133
+ // Check if user should see passkey prompt (browser only, no passkeys, not dismissed)
134
+ const fullAccount = await findById(account.id);
135
+ const shouldPromptPasskey = wantsBrowserRedirect &&
136
+ !fullAccount.passkeys?.length &&
137
+ !fullAccount.passkeyPromptDismissed;
138
+
139
+ if (shouldPromptPasskey) {
140
+ // Show passkey registration prompt before completing login
141
+ // Store the pending login in the interaction
142
+ interaction.result = {
143
+ login: { accountId: account.id, remember: true }
144
+ };
145
+ interaction.passkeyPromptPending = true;
146
+ await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
147
+ return reply.type('text/html').send(passkeyPromptPage(uid, account.id));
148
+ }
149
+
150
+ // Complete the interaction
126
151
  const result = {
127
152
  login: {
128
153
  accountId: account.id,
@@ -130,12 +155,6 @@ export async function handleLogin(request, reply, provider) {
130
155
  },
131
156
  };
132
157
 
133
- request.log.info({ accountId: account.id, uid }, 'Login successful');
134
-
135
- // Detect if this is a browser (wants HTML/redirect) or programmatic client (wants JSON)
136
- const acceptHeader = request.headers.accept || '';
137
- const wantsBrowserRedirect = acceptHeader.includes('text/html') && !acceptHeader.includes('application/json');
138
-
139
158
  // Save the login result to the interaction
140
159
  interaction.result = result;
141
160
  await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
@@ -433,3 +452,211 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
433
452
  return reply.type('text/html').send(registerPage(uid, err.message, null, inviteOnly));
434
453
  }
435
454
  }
455
+
456
+ /**
457
+ * Handle GET /idp/interaction/:uid/passkey-complete
458
+ * Completes OIDC interaction after passkey login or registration
459
+ */
460
+ export async function handlePasskeyComplete(request, reply, provider) {
461
+ const { uid } = request.params;
462
+ const { accountId } = request.query;
463
+
464
+ if (!accountId) {
465
+ return reply.code(400).type('text/html').send(errorPage('Missing account', 'Account ID is required.'));
466
+ }
467
+
468
+ try {
469
+ const interaction = await provider.Interaction.find(uid);
470
+ if (!interaction) {
471
+ return reply.code(404).type('text/html').send(errorPage('Session expired', 'Please try logging in again.'));
472
+ }
473
+
474
+ // If this is a post-login passkey registration flow, validate accountId matches
475
+ // the already-authenticated user to prevent account takeover
476
+ if (interaction.passkeyPromptPending && interaction.result?.login?.accountId) {
477
+ if (interaction.result.login.accountId !== accountId) {
478
+ request.log.warn({ expected: interaction.result.login.accountId, provided: accountId }, 'AccountId mismatch in passkey complete');
479
+ return reply.code(403).type('text/html').send(errorPage('Access denied', 'Account mismatch.'));
480
+ }
481
+ }
482
+
483
+ const account = await findById(accountId);
484
+ if (!account) {
485
+ return reply.code(404).type('text/html').send(errorPage('Account not found', 'The account could not be found.'));
486
+ }
487
+
488
+ // Update last login
489
+ await updateLastLogin(accountId);
490
+
491
+ // Complete the OIDC interaction
492
+ const result = {
493
+ login: {
494
+ accountId: account.id,
495
+ remember: true,
496
+ },
497
+ };
498
+
499
+ request.log.info({ accountId: account.id, uid }, 'Passkey login completed');
500
+
501
+ reply.hijack();
502
+ return provider.interactionFinished(request.raw, reply.raw, result, { mergeWithLastSubmission: false });
503
+ } catch (err) {
504
+ request.log.error(err, 'Passkey complete error');
505
+ return reply.code(500).type('text/html').send(errorPage('Error', err.message));
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Handle GET /idp/interaction/:uid/passkey-skip
511
+ * User skipped passkey registration, complete login
512
+ */
513
+ export async function handlePasskeySkip(request, reply, provider) {
514
+ const { uid } = request.params;
515
+
516
+ try {
517
+ const interaction = await provider.Interaction.find(uid);
518
+ if (!interaction) {
519
+ return reply.code(404).type('text/html').send(errorPage('Session expired', 'Please try logging in again.'));
520
+ }
521
+
522
+ // Validate the interaction is in the passkey prompt state
523
+ if (!interaction.passkeyPromptPending) {
524
+ return reply.code(400).type('text/html').send(errorPage('Invalid state', 'Not in passkey prompt flow.'));
525
+ }
526
+
527
+ // Get the pending login result
528
+ const result = interaction.result;
529
+ if (!result?.login?.accountId) {
530
+ return reply.code(400).type('text/html').send(errorPage('Invalid state', 'No pending login found.'));
531
+ }
532
+
533
+ // Mark passkey prompt as dismissed so we don't nag again
534
+ await setPasskeyPromptDismissed(result.login.accountId, true);
535
+
536
+ request.log.info({ accountId: result.login.accountId, uid }, 'Passkey prompt skipped');
537
+
538
+ // Complete the OIDC interaction
539
+ reply.hijack();
540
+ return provider.interactionFinished(request.raw, reply.raw, result, { mergeWithLastSubmission: false });
541
+ } catch (err) {
542
+ request.log.error(err, 'Passkey skip error');
543
+ return reply.code(500).type('text/html').send(errorPage('Error', err.message));
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Handle POST /idp/interaction/:uid/schnorr-login
549
+ * Authenticates user via Schnorr signature (NIP-98)
550
+ */
551
+ export async function handleSchnorrLogin(request, reply, provider) {
552
+ const { uid } = request.params;
553
+
554
+ try {
555
+ const interaction = await provider.Interaction.find(uid);
556
+ if (!interaction) {
557
+ return reply.code(404).type('application/json').send({
558
+ success: false,
559
+ error: 'Session expired. Please try again.'
560
+ });
561
+ }
562
+
563
+ // Verify the Schnorr signature
564
+ const authResult = await verifyNostrAuth(request);
565
+
566
+ if (authResult.error) {
567
+ request.log.warn({ error: authResult.error }, 'Schnorr auth failed');
568
+ return reply.code(401).type('application/json').send({
569
+ success: false,
570
+ error: authResult.error
571
+ });
572
+ }
573
+
574
+ // authResult.webId is either a resolved WebID or did:nostr:pubkey
575
+ const identity = authResult.webId;
576
+ request.log.info({ identity, uid }, 'Schnorr auth verified');
577
+
578
+ // Try to find an existing account linked to this identity
579
+ let account = await findByWebId(identity);
580
+
581
+ if (!account) {
582
+ // No account linked to this did:nostr
583
+ // For now, return error - user needs to link their did:nostr to an account
584
+ // Future: could auto-create account or prompt for linking
585
+ return reply.code(403).type('application/json').send({
586
+ success: false,
587
+ error: 'No account linked to this identity. Please register or link your Schnorr key to an existing account.'
588
+ });
589
+ }
590
+
591
+ // Update last login
592
+ await updateLastLogin(account.id);
593
+
594
+ // Complete the OIDC interaction
595
+ const result = {
596
+ login: {
597
+ accountId: account.id,
598
+ remember: true,
599
+ },
600
+ };
601
+
602
+ // Save the login result
603
+ interaction.result = result;
604
+ await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
605
+
606
+ request.log.info({ accountId: account.id, identity, uid }, 'Schnorr login successful');
607
+
608
+ // Return success with redirect URL
609
+ // The client will follow this redirect
610
+ const redirectUrl = `/idp/interaction/${uid}/schnorr-complete?accountId=${encodeURIComponent(account.id)}`;
611
+
612
+ return reply.type('application/json').send({
613
+ success: true,
614
+ redirectUrl
615
+ });
616
+ } catch (err) {
617
+ request.log.error(err, 'Schnorr login error');
618
+ return reply.code(500).type('application/json').send({
619
+ success: false,
620
+ error: err.message
621
+ });
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Handle GET /idp/interaction/:uid/schnorr-complete
627
+ * Completes OIDC interaction after Schnorr login
628
+ */
629
+ export async function handleSchnorrComplete(request, reply, provider) {
630
+ const { uid } = request.params;
631
+ const { accountId } = request.query;
632
+
633
+ if (!accountId) {
634
+ return reply.code(400).type('text/html').send(errorPage('Missing account', 'Account ID is required.'));
635
+ }
636
+
637
+ try {
638
+ const interaction = await provider.Interaction.find(uid);
639
+ if (!interaction) {
640
+ return reply.code(404).type('text/html').send(errorPage('Session expired', 'Please try logging in again.'));
641
+ }
642
+
643
+ // Validate accountId matches the interaction result
644
+ if (interaction.result?.login?.accountId !== accountId) {
645
+ request.log.warn({ expected: interaction.result?.login?.accountId, provided: accountId }, 'AccountId mismatch in schnorr complete');
646
+ return reply.code(403).type('text/html').send(errorPage('Access denied', 'Account mismatch.'));
647
+ }
648
+
649
+ const account = await findById(accountId);
650
+ if (!account) {
651
+ return reply.code(404).type('text/html').send(errorPage('Account not found', 'The account could not be found.'));
652
+ }
653
+
654
+ request.log.info({ accountId: account.id, uid }, 'Schnorr login completed');
655
+
656
+ reply.hijack();
657
+ return provider.interactionFinished(request.raw, reply.raw, interaction.result, { mergeWithLastSubmission: false });
658
+ } catch (err) {
659
+ request.log.error(err, 'Schnorr complete error');
660
+ return reply.code(500).type('text/html').send(errorPage('Error', err.message));
661
+ }
662
+ }