javascript-solid-server 0.0.76 → 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.
@@ -256,7 +256,11 @@
256
256
  "Bash(gh run view:*)",
257
257
  "Bash(gh pr edit:*)",
258
258
  "WebFetch(domain:patch-diff.githubusercontent.com)",
259
- "Bash(git rebase:*)"
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:*)"
260
264
  ]
261
265
  }
262
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.75)
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
@@ -670,6 +673,26 @@ Response:
670
673
 
671
674
  For DPoP-bound tokens (Solid-OIDC compliant), include a DPoP proof header.
672
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
+
673
696
  ### Solid-OIDC (External IdP)
674
697
 
675
698
  The server also accepts DPoP-bound access tokens from external Solid identity providers:
@@ -869,7 +892,7 @@ npm run benchmark
869
892
  npm test
870
893
  ```
871
894
 
872
- Currently passing: **213 tests** (including 27 conformance tests)
895
+ Currently passing: **223 tests** (including 27 conformance tests)
873
896
 
874
897
  ### Conformance Test Harness (CTH)
875
898
 
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.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",
@@ -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
 
@@ -3,8 +3,8 @@
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, 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';
@@ -122,7 +122,31 @@ export async function handleLogin(request, reply, provider) {
122
122
  return reply.redirect(`/idp/interaction/${uid}`);
123
123
  }
124
124
 
125
- // Login successful - complete the interaction
125
+ // Login successful
126
+ request.log.info({ accountId: account.id, uid }, 'Login successful');
127
+
128
+ // Detect if this is a browser (wants HTML/redirect) or programmatic client (wants JSON)
129
+ const acceptHeader = request.headers.accept || '';
130
+ const wantsBrowserRedirect = acceptHeader.includes('text/html') && !acceptHeader.includes('application/json');
131
+
132
+ // Check if user should see passkey prompt (browser only, no passkeys, not dismissed)
133
+ const fullAccount = await findById(account.id);
134
+ const shouldPromptPasskey = wantsBrowserRedirect &&
135
+ !fullAccount.passkeys?.length &&
136
+ !fullAccount.passkeyPromptDismissed;
137
+
138
+ if (shouldPromptPasskey) {
139
+ // Show passkey registration prompt before completing login
140
+ // Store the pending login in the interaction
141
+ interaction.result = {
142
+ login: { accountId: account.id, remember: true }
143
+ };
144
+ interaction.passkeyPromptPending = true;
145
+ await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
146
+ return reply.type('text/html').send(passkeyPromptPage(uid, account.id));
147
+ }
148
+
149
+ // Complete the interaction
126
150
  const result = {
127
151
  login: {
128
152
  accountId: account.id,
@@ -130,12 +154,6 @@ export async function handleLogin(request, reply, provider) {
130
154
  },
131
155
  };
132
156
 
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
157
  // Save the login result to the interaction
140
158
  interaction.result = result;
141
159
  await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
@@ -433,3 +451,94 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
433
451
  return reply.type('text/html').send(registerPage(uid, err.message, null, inviteOnly));
434
452
  }
435
453
  }
454
+
455
+ /**
456
+ * Handle GET /idp/interaction/:uid/passkey-complete
457
+ * Completes OIDC interaction after passkey login or registration
458
+ */
459
+ export async function handlePasskeyComplete(request, reply, provider) {
460
+ const { uid } = request.params;
461
+ const { accountId } = request.query;
462
+
463
+ if (!accountId) {
464
+ return reply.code(400).type('text/html').send(errorPage('Missing account', 'Account ID is required.'));
465
+ }
466
+
467
+ try {
468
+ const interaction = await provider.Interaction.find(uid);
469
+ if (!interaction) {
470
+ return reply.code(404).type('text/html').send(errorPage('Session expired', 'Please try logging in again.'));
471
+ }
472
+
473
+ // If this is a post-login passkey registration flow, validate accountId matches
474
+ // the already-authenticated user to prevent account takeover
475
+ if (interaction.passkeyPromptPending && interaction.result?.login?.accountId) {
476
+ if (interaction.result.login.accountId !== accountId) {
477
+ request.log.warn({ expected: interaction.result.login.accountId, provided: accountId }, 'AccountId mismatch in passkey complete');
478
+ return reply.code(403).type('text/html').send(errorPage('Access denied', 'Account mismatch.'));
479
+ }
480
+ }
481
+
482
+ const account = await findById(accountId);
483
+ if (!account) {
484
+ return reply.code(404).type('text/html').send(errorPage('Account not found', 'The account could not be found.'));
485
+ }
486
+
487
+ // Update last login
488
+ await updateLastLogin(accountId);
489
+
490
+ // Complete the OIDC interaction
491
+ const result = {
492
+ login: {
493
+ accountId: account.id,
494
+ remember: true,
495
+ },
496
+ };
497
+
498
+ request.log.info({ accountId: account.id, uid }, 'Passkey login completed');
499
+
500
+ reply.hijack();
501
+ return provider.interactionFinished(request.raw, reply.raw, result, { mergeWithLastSubmission: false });
502
+ } catch (err) {
503
+ request.log.error(err, 'Passkey complete error');
504
+ return reply.code(500).type('text/html').send(errorPage('Error', err.message));
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Handle GET /idp/interaction/:uid/passkey-skip
510
+ * User skipped passkey registration, complete login
511
+ */
512
+ export async function handlePasskeySkip(request, reply, provider) {
513
+ const { uid } = request.params;
514
+
515
+ try {
516
+ const interaction = await provider.Interaction.find(uid);
517
+ if (!interaction) {
518
+ return reply.code(404).type('text/html').send(errorPage('Session expired', 'Please try logging in again.'));
519
+ }
520
+
521
+ // Validate the interaction is in the passkey prompt state
522
+ if (!interaction.passkeyPromptPending) {
523
+ return reply.code(400).type('text/html').send(errorPage('Invalid state', 'Not in passkey prompt flow.'));
524
+ }
525
+
526
+ // Get the pending login result
527
+ const result = interaction.result;
528
+ if (!result?.login?.accountId) {
529
+ return reply.code(400).type('text/html').send(errorPage('Invalid state', 'No pending login found.'));
530
+ }
531
+
532
+ // Mark passkey prompt as dismissed so we don't nag again
533
+ await setPasskeyPromptDismissed(result.login.accountId, true);
534
+
535
+ request.log.info({ accountId: result.login.accountId, uid }, 'Passkey prompt skipped');
536
+
537
+ // Complete the OIDC interaction
538
+ reply.hijack();
539
+ return provider.interactionFinished(request.raw, reply.raw, result, { mergeWithLastSubmission: false });
540
+ } catch (err) {
541
+ request.log.error(err, 'Passkey skip error');
542
+ return reply.code(500).type('text/html').send(errorPage('Error', err.message));
543
+ }
544
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Passkey (WebAuthn) authentication endpoints
3
+ * Handles registration and authentication of passkey credentials
4
+ */
5
+
6
+ import {
7
+ generateRegistrationOptions,
8
+ verifyRegistrationResponse,
9
+ generateAuthenticationOptions,
10
+ verifyAuthenticationResponse
11
+ } from '@simplewebauthn/server';
12
+ import crypto from 'crypto';
13
+ import * as accounts from './accounts.js';
14
+
15
+ // Temporary challenge storage (in-memory, cleared on restart)
16
+ // For production clusters, use Redis or session storage
17
+ const challenges = new Map();
18
+ const MAX_CHALLENGES = 10000; // Prevent unbounded growth
19
+
20
+ // Clean up expired challenges periodically
21
+ // Use unref() so this timer doesn't prevent process exit (important for tests)
22
+ const cleanupInterval = setInterval(() => {
23
+ const now = Date.now();
24
+ for (const [key, value] of challenges.entries()) {
25
+ if (now > value.expires) {
26
+ challenges.delete(key);
27
+ }
28
+ }
29
+ }, 60000);
30
+ cleanupInterval.unref();
31
+
32
+ /**
33
+ * Store a challenge with size limit enforcement
34
+ */
35
+ function storeChallenge(key, value) {
36
+ // If at capacity, remove oldest expired entries first
37
+ if (challenges.size >= MAX_CHALLENGES) {
38
+ const now = Date.now();
39
+ for (const [k, v] of challenges.entries()) {
40
+ if (now > v.expires) {
41
+ challenges.delete(k);
42
+ }
43
+ if (challenges.size < MAX_CHALLENGES) break;
44
+ }
45
+ }
46
+ // If still at capacity, reject (DoS protection)
47
+ if (challenges.size >= MAX_CHALLENGES) {
48
+ return false;
49
+ }
50
+ challenges.set(key, value);
51
+ return true;
52
+ }
53
+
54
+ /**
55
+ * Get Relying Party configuration from request
56
+ * Handles both IPv4 (with port) and IPv6 addresses correctly
57
+ */
58
+ function getRP(request) {
59
+ let hostname;
60
+ try {
61
+ // Use URL parsing to correctly extract hostname (handles IPv6)
62
+ const url = new URL(`${request.protocol}://${request.hostname}`);
63
+ hostname = url.hostname;
64
+ } catch {
65
+ // Fallback: strip port from hostname (IPv4 only)
66
+ hostname = String(request.hostname || '').split(':')[0];
67
+ }
68
+ return {
69
+ name: 'Solid Pod',
70
+ id: hostname
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Get origin from request
76
+ */
77
+ function getOrigin(request) {
78
+ return `${request.protocol}://${request.hostname}`;
79
+ }
80
+
81
+ /**
82
+ * POST /idp/passkey/register/options
83
+ * Generate registration options for a logged-in user
84
+ */
85
+ export async function registrationOptions(request, reply) {
86
+ const { accountId } = request.body || {};
87
+
88
+ if (!accountId) {
89
+ return reply.code(401).send({ error: 'Must provide accountId' });
90
+ }
91
+
92
+ const account = await accounts.findById(accountId);
93
+ if (!account) {
94
+ return reply.code(404).send({ error: 'Account not found' });
95
+ }
96
+
97
+ const rp = getRP(request);
98
+
99
+ const options = await generateRegistrationOptions({
100
+ rpName: rp.name,
101
+ rpID: rp.id,
102
+ userID: new TextEncoder().encode(account.id),
103
+ userName: account.username,
104
+ userDisplayName: account.username,
105
+ attestationType: 'none', // Don't require attestation for privacy
106
+ excludeCredentials: (account.passkeys || []).map(pk => ({
107
+ id: Buffer.from(pk.credentialId, 'base64url'),
108
+ type: 'public-key',
109
+ transports: pk.transports
110
+ })),
111
+ authenticatorSelection: {
112
+ residentKey: 'preferred',
113
+ userVerification: 'preferred'
114
+ }
115
+ });
116
+
117
+ // Store challenge for verification with unique key (prevents race conditions from multiple tabs)
118
+ const challengeKey = crypto.randomUUID();
119
+ const stored = storeChallenge(challengeKey, {
120
+ challenge: options.challenge,
121
+ type: 'registration',
122
+ accountId: account.id,
123
+ expires: Date.now() + 60000 // 1 minute
124
+ });
125
+
126
+ if (!stored) {
127
+ return reply.code(503).send({ error: 'Server busy, try again later' });
128
+ }
129
+
130
+ return reply.send({ ...options, challengeKey });
131
+ }
132
+
133
+ /**
134
+ * POST /idp/passkey/register/verify
135
+ * Verify and store the registration response
136
+ */
137
+ export async function registrationVerify(request, reply) {
138
+ const { accountId, credential, name, challengeKey } = request.body || {};
139
+
140
+ if (!accountId || !credential || !challengeKey) {
141
+ return reply.code(400).send({ error: 'Missing required fields' });
142
+ }
143
+
144
+ const stored = challenges.get(challengeKey);
145
+ if (!stored || stored.type !== 'registration' || Date.now() > stored.expires) {
146
+ return reply.code(400).send({ error: 'Challenge expired or invalid' });
147
+ }
148
+
149
+ // Verify the accountId matches the challenge
150
+ if (stored.accountId !== accountId) {
151
+ return reply.code(403).send({ error: 'Account mismatch' });
152
+ }
153
+
154
+ const account = await accounts.findById(accountId);
155
+ if (!account) {
156
+ return reply.code(404).send({ error: 'Account not found' });
157
+ }
158
+
159
+ const rp = getRP(request);
160
+
161
+ try {
162
+ const verification = await verifyRegistrationResponse({
163
+ response: credential,
164
+ expectedChallenge: stored.challenge,
165
+ expectedOrigin: getOrigin(request),
166
+ expectedRPID: rp.id
167
+ });
168
+
169
+ if (!verification.verified || !verification.registrationInfo) {
170
+ request.log.warn({ verified: verification.verified, hasInfo: !!verification.registrationInfo }, 'Passkey registration verification failed');
171
+ return reply.code(400).send({ error: 'Verification failed' });
172
+ }
173
+
174
+ const { credential: regCredential } = verification.registrationInfo;
175
+
176
+ await accounts.addPasskey(accountId, {
177
+ credentialId: regCredential.id, // Already base64url string
178
+ publicKey: Buffer.from(regCredential.publicKey).toString('base64url'),
179
+ counter: regCredential.counter,
180
+ transports: regCredential.transports || credential.response?.transports || [],
181
+ name: name || 'Security Key'
182
+ });
183
+
184
+ challenges.delete(challengeKey);
185
+
186
+ return reply.send({ success: true });
187
+ } catch (err) {
188
+ request.log.error({ err }, 'Passkey registration error');
189
+ return reply.code(400).send({ error: 'Passkey registration failed' });
190
+ }
191
+ }
192
+
193
+ /**
194
+ * POST /idp/passkey/login/options
195
+ * Generate authentication options
196
+ */
197
+ export async function authenticationOptions(request, reply) {
198
+ const { username } = request.body || {};
199
+ const rp = getRP(request);
200
+
201
+ let allowCredentials = [];
202
+ let accountId = null;
203
+
204
+ // If username provided, limit to that user's credentials
205
+ if (username) {
206
+ const account = await accounts.findByUsername(username);
207
+ if (account && account.passkeys?.length) {
208
+ accountId = account.id;
209
+ allowCredentials = account.passkeys.map(pk => ({
210
+ id: Buffer.from(pk.credentialId, 'base64url'),
211
+ type: 'public-key',
212
+ transports: pk.transports
213
+ }));
214
+ }
215
+ }
216
+
217
+ const options = await generateAuthenticationOptions({
218
+ rpID: rp.id,
219
+ allowCredentials,
220
+ userVerification: 'preferred'
221
+ });
222
+
223
+ // Store challenge - use visitorId for anonymous requests
224
+ const challengeKey = accountId || request.body?.visitorId || crypto.randomUUID();
225
+ const stored = storeChallenge(challengeKey, {
226
+ challenge: options.challenge,
227
+ type: 'authentication',
228
+ accountId,
229
+ expires: Date.now() + 60000 // 1 minute
230
+ });
231
+
232
+ if (!stored) {
233
+ return reply.code(503).send({ error: 'Server busy, try again later' });
234
+ }
235
+
236
+ return reply.send({ ...options, challengeKey });
237
+ }
238
+
239
+ /**
240
+ * POST /idp/passkey/login/verify
241
+ * Verify authentication and return account info
242
+ */
243
+ export async function authenticationVerify(request, reply) {
244
+ const { challengeKey, credential } = request.body || {};
245
+
246
+ if (!challengeKey || !credential) {
247
+ return reply.code(400).send({ error: 'Missing challengeKey or credential' });
248
+ }
249
+
250
+ const stored = challenges.get(challengeKey);
251
+ if (!stored || stored.type !== 'authentication' || Date.now() > stored.expires) {
252
+ return reply.code(400).send({ error: 'Challenge expired or invalid' });
253
+ }
254
+
255
+ // Find account by credential ID
256
+ const credentialId = credential.id;
257
+ const account = stored.accountId
258
+ ? await accounts.findById(stored.accountId)
259
+ : await accounts.findByCredentialId(credentialId);
260
+
261
+ if (!account) {
262
+ return reply.code(400).send({ error: 'Unknown credential' });
263
+ }
264
+
265
+ const passkey = account.passkeys?.find(pk => pk.credentialId === credentialId);
266
+ if (!passkey) {
267
+ return reply.code(400).send({ error: 'Credential not found' });
268
+ }
269
+
270
+ const rp = getRP(request);
271
+
272
+ try {
273
+ const verification = await verifyAuthenticationResponse({
274
+ response: credential,
275
+ expectedChallenge: stored.challenge,
276
+ expectedOrigin: getOrigin(request),
277
+ expectedRPID: rp.id,
278
+ credential: {
279
+ id: Buffer.from(passkey.credentialId, 'base64url'),
280
+ publicKey: Buffer.from(passkey.publicKey, 'base64url'),
281
+ counter: passkey.counter
282
+ }
283
+ });
284
+
285
+ if (!verification.verified) {
286
+ return reply.code(400).send({ error: 'Verification failed' });
287
+ }
288
+
289
+ // Update counter to prevent replay attacks
290
+ await accounts.updatePasskeyCounter(
291
+ account.id,
292
+ credentialId,
293
+ verification.authenticationInfo.newCounter
294
+ );
295
+
296
+ // Update last login
297
+ await accounts.updateLastLogin(account.id);
298
+
299
+ challenges.delete(challengeKey);
300
+
301
+ // Return account info for session creation
302
+ return reply.send({
303
+ success: true,
304
+ accountId: account.id,
305
+ webId: account.webId
306
+ });
307
+ } catch (err) {
308
+ request.log.error({ err }, 'Passkey authentication error');
309
+ return reply.code(400).send({ error: 'Authentication failed' });
310
+ }
311
+ }
package/src/idp/views.js CHANGED
@@ -105,6 +105,38 @@ const styles = `
105
105
  .btn-secondary:hover {
106
106
  background: #e0e0e0;
107
107
  }
108
+ .btn-passkey {
109
+ background: #1a73e8;
110
+ color: white;
111
+ width: 100%;
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+ gap: 8px;
116
+ }
117
+ .btn-passkey:hover {
118
+ background: #1557b0;
119
+ }
120
+ .btn-passkey svg {
121
+ width: 20px;
122
+ height: 20px;
123
+ }
124
+ .divider {
125
+ display: flex;
126
+ align-items: center;
127
+ margin: 20px 0;
128
+ color: #666;
129
+ font-size: 14px;
130
+ }
131
+ .divider::before,
132
+ .divider::after {
133
+ content: '';
134
+ flex: 1;
135
+ border-bottom: 1px solid #ddd;
136
+ }
137
+ .divider span {
138
+ padding: 0 12px;
139
+ }
108
140
  .scopes {
109
141
  margin: 20px 0;
110
142
  }
@@ -149,6 +181,12 @@ const solidLogo = `
149
181
  </svg>
150
182
  `;
151
183
 
184
+ const passkeyIcon = `
185
+ <svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
186
+ <path d="M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>
187
+ </svg>
188
+ `;
189
+
152
190
  const scopeDescriptions = {
153
191
  openid: 'Access your identity',
154
192
  webid: 'Access your WebID',
@@ -157,11 +195,125 @@ const scopeDescriptions = {
157
195
  offline_access: 'Stay logged in',
158
196
  };
159
197
 
198
+ /**
199
+ * Escape string for safe use in JavaScript
200
+ */
201
+ function escapeJs(text) {
202
+ if (!text) return '';
203
+ return String(text)
204
+ .replace(/\\/g, '\\\\')
205
+ .replace(/'/g, "\\'")
206
+ .replace(/"/g, '\\"')
207
+ .replace(/</g, '\\x3c')
208
+ .replace(/>/g, '\\x3e')
209
+ .replace(/\n/g, '\\n')
210
+ .replace(/\r/g, '\\r');
211
+ }
212
+
160
213
  /**
161
214
  * Login page HTML
162
215
  */
163
- export function loginPage(uid, clientId, error = null) {
216
+ export function loginPage(uid, clientId, error = null, passkeyEnabled = true) {
164
217
  const appName = clientId || 'An application';
218
+ const safeUid = escapeJs(uid);
219
+
220
+ const passkeySection = passkeyEnabled ? `
221
+ <button type="button" class="btn btn-passkey" onclick="loginWithPasskey()">
222
+ ${passkeyIcon}
223
+ Sign in with Passkey
224
+ </button>
225
+
226
+ <div class="divider"><span>or</span></div>
227
+ ` : '';
228
+
229
+ const passkeyScript = passkeyEnabled ? `
230
+ <script>
231
+ var INTERACTION_UID = '${safeUid}';
232
+
233
+ async function loginWithPasskey() {
234
+ try {
235
+ // Get authentication options
236
+ const optionsRes = await fetch('/idp/passkey/login/options', {
237
+ method: 'POST',
238
+ headers: { 'Content-Type': 'application/json' },
239
+ body: JSON.stringify({ visitorId: crypto.randomUUID() })
240
+ });
241
+ const options = await optionsRes.json();
242
+ if (options.error) {
243
+ alert('Error: ' + options.error);
244
+ return;
245
+ }
246
+
247
+ // Convert base64url to ArrayBuffer
248
+ options.challenge = base64urlToBuffer(options.challenge);
249
+ if (options.allowCredentials) {
250
+ options.allowCredentials = options.allowCredentials.map(c => ({
251
+ ...c,
252
+ id: base64urlToBuffer(c.id)
253
+ }));
254
+ }
255
+
256
+ // Prompt user for passkey
257
+ const credential = await navigator.credentials.get({ publicKey: options });
258
+
259
+ // Send response to server
260
+ const verifyRes = await fetch('/idp/passkey/login/verify', {
261
+ method: 'POST',
262
+ headers: { 'Content-Type': 'application/json' },
263
+ body: JSON.stringify({
264
+ challengeKey: options.challengeKey,
265
+ credential: {
266
+ id: credential.id,
267
+ rawId: bufferToBase64url(credential.rawId),
268
+ type: credential.type,
269
+ response: {
270
+ clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
271
+ authenticatorData: bufferToBase64url(credential.response.authenticatorData),
272
+ signature: bufferToBase64url(credential.response.signature),
273
+ userHandle: credential.response.userHandle
274
+ ? bufferToBase64url(credential.response.userHandle)
275
+ : null
276
+ }
277
+ }
278
+ })
279
+ });
280
+
281
+ const result = await verifyRes.json();
282
+ if (result.success) {
283
+ // Complete the OIDC interaction - build URL safely
284
+ const redirectUrl = '/idp/interaction/' + encodeURIComponent(INTERACTION_UID) + '/passkey-complete?accountId=' + encodeURIComponent(result.accountId);
285
+ window.location.href = redirectUrl;
286
+ } else {
287
+ alert('Passkey authentication failed: ' + (result.error || 'Unknown error'));
288
+ }
289
+ } catch (err) {
290
+ if (err.name === 'NotAllowedError') {
291
+ // User cancelled - do nothing
292
+ } else {
293
+ console.error('Passkey error:', err);
294
+ alert('Passkey authentication failed: ' + err.message);
295
+ }
296
+ }
297
+ }
298
+
299
+ function base64urlToBuffer(base64url) {
300
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
301
+ const padLen = (4 - base64.length % 4) % 4;
302
+ const padded = base64 + '='.repeat(padLen);
303
+ const binary = atob(padded);
304
+ const bytes = new Uint8Array(binary.length);
305
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
306
+ return bytes.buffer;
307
+ }
308
+
309
+ function bufferToBase64url(buffer) {
310
+ const bytes = new Uint8Array(buffer);
311
+ let binary = '';
312
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
313
+ return btoa(binary).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=/g, '');
314
+ }
315
+ </script>
316
+ ` : '';
165
317
 
166
318
  return `
167
319
  <!DOCTYPE html>
@@ -185,6 +337,8 @@ export function loginPage(uid, clientId, error = null) {
185
337
 
186
338
  ${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
187
339
 
340
+ ${passkeySection}
341
+
188
342
  <form method="POST" action="/idp/interaction/${uid}/login">
189
343
  <label for="username">Username</label>
190
344
  <input type="text" id="username" name="username" required autofocus placeholder="Your username">
@@ -203,6 +357,7 @@ export function loginPage(uid, clientId, error = null) {
203
357
  Don't have an account? <a href="/idp/register?uid=${uid}" style="color: #0066cc;">Register</a>
204
358
  </p>
205
359
  </div>
360
+ ${passkeyScript}
206
361
  </body>
207
362
  </html>
208
363
  `;
@@ -349,6 +504,162 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl
349
504
  `;
350
505
  }
351
506
 
507
+ /**
508
+ * Passkey prompt page - shown after password login to encourage passkey setup
509
+ */
510
+ export function passkeyPromptPage(uid, accountId) {
511
+ const safeUid = escapeJs(uid);
512
+ const safeAccountId = escapeJs(accountId);
513
+ // Pre-escape the SVG for innerHTML assignment (no user data, just static SVG)
514
+ const passkeyIconEscaped = passkeyIcon.replace(/'/g, "\\'").replace(/\n/g, '');
515
+
516
+ return `
517
+ <!DOCTYPE html>
518
+ <html lang="en">
519
+ <head>
520
+ <meta charset="UTF-8">
521
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
522
+ <title>Add a Passkey - Solid IdP</title>
523
+ <style>${styles}</style>
524
+ </head>
525
+ <body>
526
+ <div class="container">
527
+ <div class="logo">${solidLogo}</div>
528
+ <h1>Add a Passkey?</h1>
529
+ <p class="subtitle">Sign in faster next time</p>
530
+
531
+ <div class="client-info">
532
+ <div class="client-name">Passkeys are more secure</div>
533
+ <div class="client-uri">Use Touch ID, Face ID, or a security key instead of your password</div>
534
+ </div>
535
+
536
+ <button type="button" class="btn btn-passkey" onclick="registerPasskey()" id="addBtn">
537
+ ${passkeyIcon}
538
+ Add Passkey
539
+ </button>
540
+
541
+ <form method="GET" action="/idp/interaction/${escapeHtml(uid)}/passkey-skip">
542
+ <button type="submit" class="btn btn-secondary">Skip for now</button>
543
+ </form>
544
+ </div>
545
+
546
+ <script>
547
+ var INTERACTION_UID = '${safeUid}';
548
+ var ACCOUNT_ID = '${safeAccountId}';
549
+ var PASSKEY_ICON = '${passkeyIconEscaped}';
550
+
551
+ async function registerPasskey() {
552
+ const btn = document.getElementById('addBtn');
553
+ btn.disabled = true;
554
+ btn.textContent = 'Setting up...';
555
+
556
+ try {
557
+ // Get registration options
558
+ const optionsRes = await fetch('/idp/passkey/register/options', {
559
+ method: 'POST',
560
+ headers: { 'Content-Type': 'application/json' },
561
+ body: JSON.stringify({ accountId: ACCOUNT_ID })
562
+ });
563
+ const options = await optionsRes.json();
564
+ if (options.error) {
565
+ alert('Error: ' + options.error);
566
+ btn.disabled = false;
567
+ btn.innerHTML = PASSKEY_ICON + ' Add Passkey';
568
+ return;
569
+ }
570
+
571
+ // Save challengeKey for verification
572
+ const challengeKey = options.challengeKey;
573
+
574
+ // Convert base64url to ArrayBuffer
575
+ options.challenge = base64urlToBuffer(options.challenge);
576
+ options.user.id = base64urlToBuffer(options.user.id);
577
+ if (options.excludeCredentials) {
578
+ options.excludeCredentials = options.excludeCredentials.map(c => ({
579
+ ...c,
580
+ id: base64urlToBuffer(c.id)
581
+ }));
582
+ }
583
+
584
+ // Prompt user to create passkey
585
+ const credential = await navigator.credentials.create({ publicKey: options });
586
+
587
+ // Send response to server
588
+ const verifyRes = await fetch('/idp/passkey/register/verify', {
589
+ method: 'POST',
590
+ headers: { 'Content-Type': 'application/json' },
591
+ body: JSON.stringify({
592
+ accountId: ACCOUNT_ID,
593
+ challengeKey: challengeKey,
594
+ credential: {
595
+ id: credential.id,
596
+ rawId: bufferToBase64url(credential.rawId),
597
+ type: credential.type,
598
+ response: {
599
+ clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
600
+ attestationObject: bufferToBase64url(credential.response.attestationObject),
601
+ transports: credential.response.getTransports ? credential.response.getTransports() : []
602
+ }
603
+ },
604
+ name: detectDeviceName()
605
+ })
606
+ });
607
+
608
+ const result = await verifyRes.json();
609
+ if (result.success) {
610
+ // Passkey added, continue to app - build URL safely
611
+ const redirectUrl = '/idp/interaction/' + encodeURIComponent(INTERACTION_UID) + '/passkey-complete?accountId=' + encodeURIComponent(ACCOUNT_ID);
612
+ window.location.href = redirectUrl;
613
+ } else {
614
+ alert('Failed to add passkey: ' + (result.error || 'Unknown error'));
615
+ btn.disabled = false;
616
+ btn.innerHTML = PASSKEY_ICON + ' Add Passkey';
617
+ }
618
+ } catch (err) {
619
+ if (err.name === 'NotAllowedError') {
620
+ // User cancelled
621
+ } else {
622
+ console.error('Passkey error:', err);
623
+ alert('Failed to add passkey: ' + err.message);
624
+ }
625
+ btn.disabled = false;
626
+ btn.innerHTML = PASSKEY_ICON + ' Add Passkey';
627
+ }
628
+ }
629
+
630
+ function detectDeviceName() {
631
+ const ua = navigator.userAgent;
632
+ if (/iPhone/.test(ua)) return 'iPhone';
633
+ if (/iPad/.test(ua)) return 'iPad';
634
+ if (/Mac/.test(ua)) return 'Mac';
635
+ if (/Android/.test(ua)) return 'Android';
636
+ if (/Windows/.test(ua)) return 'Windows';
637
+ if (/Linux/.test(ua)) return 'Linux';
638
+ return 'Security Key';
639
+ }
640
+
641
+ function base64urlToBuffer(base64url) {
642
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
643
+ const padLen = (4 - base64.length % 4) % 4;
644
+ const padded = base64 + '='.repeat(padLen);
645
+ const binary = atob(padded);
646
+ const bytes = new Uint8Array(binary.length);
647
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
648
+ return bytes.buffer;
649
+ }
650
+
651
+ function bufferToBase64url(buffer) {
652
+ const bytes = new Uint8Array(buffer);
653
+ let binary = '';
654
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
655
+ return btoa(binary).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=/g, '');
656
+ }
657
+ </script>
658
+ </body>
659
+ </html>
660
+ `;
661
+ }
662
+
352
663
  /**
353
664
  * Escape HTML to prevent XSS
354
665
  */