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