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.
- package/.claude/settings.local.json +27 -1
- package/README.md +38 -2
- package/bin/jss.js +3 -0
- package/package.json +2 -1
- package/src/auth/middleware.js +6 -3
- package/src/config.js +5 -0
- package/src/handlers/resource.js +104 -6
- package/src/idp/accounts.js +133 -0
- package/src/idp/index.js +65 -0
- package/src/idp/interactions.js +118 -9
- package/src/idp/passkey.js +311 -0
- package/src/idp/views.js +312 -1
- package/src/ldp/headers.js +3 -2
- package/src/mashlib/index.js +107 -0
- package/src/server.js +37 -1
- package/src/storage/filesystem.js +22 -0
- package/test/range.test.js +145 -0
package/src/idp/interactions.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|