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 +49 -2
- package/package.json +2 -1
- package/src/idp/accounts.js +133 -0
- package/src/idp/index.js +83 -0
- package/src/idp/interactions.js +236 -9
- package/src/idp/passkey.js +311 -0
- package/src/idp/views.js +414 -1
- package/.claude/settings.local.json +0 -262
|
@@ -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
|
+
}
|