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.
- package/.claude/settings.local.json +5 -1
- package/README.md +25 -2
- package/package.json +2 -1
- 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
|
@@ -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.
|
|
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: **
|
|
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.
|
|
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",
|
package/src/idp/accounts.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
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
|
*/
|