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.
@@ -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
+ }