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
package/README.md
CHANGED
|
@@ -6,8 +6,12 @@ A minimal, fast, JSON-LD native Solid server.
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
### Implemented (v0.0.
|
|
9
|
+
### Implemented (v0.0.78)
|
|
10
10
|
|
|
11
|
+
- **Schnorr SSO** - Passwordless login via BIP-340 Schnorr signatures using NIP-07 browser extensions (Podkey, nos2x, Alby)
|
|
12
|
+
- **Passkey Authentication** - WebAuthn/FIDO2 passwordless login with Touch ID, Face ID, or security keys
|
|
13
|
+
- **HTTP Range Requests** - Partial content delivery for large files and media streaming
|
|
14
|
+
- **Single-User Mode** - Simplified setup for personal pod servers
|
|
11
15
|
- **ActivityPub Federation** - Fediverse integration with WebFinger, inbox/outbox, HTTP signatures
|
|
12
16
|
- **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
|
|
13
17
|
- **N3 Patch** - Solid's native patch format for RDF updates
|
|
@@ -670,6 +674,49 @@ Response:
|
|
|
670
674
|
|
|
671
675
|
For DPoP-bound tokens (Solid-OIDC compliant), include a DPoP proof header.
|
|
672
676
|
|
|
677
|
+
### Passkey Authentication (v0.0.77+)
|
|
678
|
+
|
|
679
|
+
Enable passwordless login with WebAuthn/FIDO2:
|
|
680
|
+
|
|
681
|
+
```bash
|
|
682
|
+
jss start --idp
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
**How it works:**
|
|
686
|
+
1. User logs in with username/password
|
|
687
|
+
2. Prompted to add a passkey (Touch ID, Face ID, security key)
|
|
688
|
+
3. Future logins: tap "Sign in with Passkey" → biometric → done!
|
|
689
|
+
|
|
690
|
+
**Benefits:**
|
|
691
|
+
- Phishing-resistant (bound to domain)
|
|
692
|
+
- No passwords to remember or leak
|
|
693
|
+
- Works on mobile and desktop
|
|
694
|
+
|
|
695
|
+
Passkeys are stored per-account and work across devices via platform sync (iCloud Keychain, Google Password Manager, etc.).
|
|
696
|
+
|
|
697
|
+
### Schnorr SSO (v0.0.78+)
|
|
698
|
+
|
|
699
|
+
Sign in with your Nostr key using NIP-07 browser extensions:
|
|
700
|
+
|
|
701
|
+
```bash
|
|
702
|
+
jss start --idp
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
**How it works:**
|
|
706
|
+
1. User clicks "Sign in with Schnorr" on the login page
|
|
707
|
+
2. NIP-07 extension (Podkey, nos2x, Alby) signs a NIP-98 auth event
|
|
708
|
+
3. Server verifies BIP-340 Schnorr signature
|
|
709
|
+
4. User authenticated via linked did:nostr identity
|
|
710
|
+
|
|
711
|
+
**Requirements:**
|
|
712
|
+
- Account must have a `did:nostr:<pubkey>` WebID linked
|
|
713
|
+
- User needs a NIP-07 compatible browser extension
|
|
714
|
+
|
|
715
|
+
**Benefits:**
|
|
716
|
+
- No passwords - cryptographic authentication
|
|
717
|
+
- Works with existing Nostr identity
|
|
718
|
+
- Single sign-on across Solid and Nostr ecosystems
|
|
719
|
+
|
|
673
720
|
### Solid-OIDC (External IdP)
|
|
674
721
|
|
|
675
722
|
The server also accepts DPoP-bound access tokens from external Solid identity providers:
|
|
@@ -869,7 +916,7 @@ npm run benchmark
|
|
|
869
916
|
npm test
|
|
870
917
|
```
|
|
871
918
|
|
|
872
|
-
Currently passing: **
|
|
919
|
+
Currently passing: **223 tests** (including 27 conformance tests)
|
|
873
920
|
|
|
874
921
|
### Conformance Test Harness (CTH)
|
|
875
922
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javascript-solid-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.78",
|
|
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,16 @@ import {
|
|
|
13
13
|
handleAbort,
|
|
14
14
|
handleRegisterGet,
|
|
15
15
|
handleRegisterPost,
|
|
16
|
+
handlePasskeyComplete,
|
|
17
|
+
handlePasskeySkip,
|
|
18
|
+
handleSchnorrLogin,
|
|
19
|
+
handleSchnorrComplete,
|
|
16
20
|
} from './interactions.js';
|
|
17
21
|
import {
|
|
18
22
|
handleCredentials,
|
|
19
23
|
handleCredentialsInfo,
|
|
20
24
|
} from './credentials.js';
|
|
25
|
+
import * as passkey from './passkey.js';
|
|
21
26
|
import { addTrustedIssuer } from '../auth/solid-oidc.js';
|
|
22
27
|
|
|
23
28
|
/**
|
|
@@ -290,6 +295,84 @@ export async function idpPlugin(fastify, options) {
|
|
|
290
295
|
return handleRegisterPost(request, reply, issuer, inviteOnly);
|
|
291
296
|
});
|
|
292
297
|
|
|
298
|
+
// Passkey routes
|
|
299
|
+
// Registration options - rate limited to prevent DoS
|
|
300
|
+
fastify.post('/idp/passkey/register/options', {
|
|
301
|
+
config: {
|
|
302
|
+
rateLimit: {
|
|
303
|
+
max: 10,
|
|
304
|
+
timeWindow: '1 minute',
|
|
305
|
+
keyGenerator: (request) => request.ip
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}, async (request, reply) => {
|
|
309
|
+
return passkey.registrationOptions(request, reply);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Registration verify - rate limited
|
|
313
|
+
fastify.post('/idp/passkey/register/verify', {
|
|
314
|
+
config: {
|
|
315
|
+
rateLimit: {
|
|
316
|
+
max: 10,
|
|
317
|
+
timeWindow: '1 minute',
|
|
318
|
+
keyGenerator: (request) => request.ip
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}, async (request, reply) => {
|
|
322
|
+
return passkey.registrationVerify(request, reply);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Login options - rate limited to prevent DoS
|
|
326
|
+
fastify.post('/idp/passkey/login/options', {
|
|
327
|
+
config: {
|
|
328
|
+
rateLimit: {
|
|
329
|
+
max: 10,
|
|
330
|
+
timeWindow: '1 minute',
|
|
331
|
+
keyGenerator: (request) => request.ip
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}, async (request, reply) => {
|
|
335
|
+
return passkey.authenticationOptions(request, reply);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Login verify - rate limited
|
|
339
|
+
fastify.post('/idp/passkey/login/verify', {
|
|
340
|
+
config: {
|
|
341
|
+
rateLimit: {
|
|
342
|
+
max: 10,
|
|
343
|
+
timeWindow: '1 minute',
|
|
344
|
+
keyGenerator: (request) => request.ip
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}, async (request, reply) => {
|
|
348
|
+
return passkey.authenticationVerify(request, reply);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Passkey interaction handlers
|
|
352
|
+
fastify.get('/idp/interaction/:uid/passkey-complete', async (request, reply) => {
|
|
353
|
+
return handlePasskeyComplete(request, reply, provider);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
fastify.get('/idp/interaction/:uid/passkey-skip', async (request, reply) => {
|
|
357
|
+
return handlePasskeySkip(request, reply, provider);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// Schnorr (NIP-98) interaction handlers
|
|
361
|
+
fastify.post('/idp/interaction/:uid/schnorr-login', {
|
|
362
|
+
config: {
|
|
363
|
+
rateLimit: {
|
|
364
|
+
max: 10,
|
|
365
|
+
timeWindow: '1 minute'
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}, async (request, reply) => {
|
|
369
|
+
return handleSchnorrLogin(request, reply, provider);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
fastify.get('/idp/interaction/:uid/schnorr-complete', async (request, reply) => {
|
|
373
|
+
return handleSchnorrComplete(request, reply, provider);
|
|
374
|
+
});
|
|
375
|
+
|
|
293
376
|
fastify.log.info(`IdP initialized with issuer: ${issuer}`);
|
|
294
377
|
}
|
|
295
378
|
|
package/src/idp/interactions.js
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
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, findByWebId, 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';
|
|
11
|
+
import { verifyNostrAuth } from '../auth/nostr.js';
|
|
11
12
|
|
|
12
13
|
// Security: Maximum body size for IdP form submissions (1MB)
|
|
13
14
|
const MAX_BODY_SIZE = 1024 * 1024;
|
|
@@ -122,7 +123,31 @@ export async function handleLogin(request, reply, provider) {
|
|
|
122
123
|
return reply.redirect(`/idp/interaction/${uid}`);
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
// Login successful
|
|
126
|
+
// Login successful
|
|
127
|
+
request.log.info({ accountId: account.id, uid }, 'Login successful');
|
|
128
|
+
|
|
129
|
+
// Detect if this is a browser (wants HTML/redirect) or programmatic client (wants JSON)
|
|
130
|
+
const acceptHeader = request.headers.accept || '';
|
|
131
|
+
const wantsBrowserRedirect = acceptHeader.includes('text/html') && !acceptHeader.includes('application/json');
|
|
132
|
+
|
|
133
|
+
// Check if user should see passkey prompt (browser only, no passkeys, not dismissed)
|
|
134
|
+
const fullAccount = await findById(account.id);
|
|
135
|
+
const shouldPromptPasskey = wantsBrowserRedirect &&
|
|
136
|
+
!fullAccount.passkeys?.length &&
|
|
137
|
+
!fullAccount.passkeyPromptDismissed;
|
|
138
|
+
|
|
139
|
+
if (shouldPromptPasskey) {
|
|
140
|
+
// Show passkey registration prompt before completing login
|
|
141
|
+
// Store the pending login in the interaction
|
|
142
|
+
interaction.result = {
|
|
143
|
+
login: { accountId: account.id, remember: true }
|
|
144
|
+
};
|
|
145
|
+
interaction.passkeyPromptPending = true;
|
|
146
|
+
await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
|
|
147
|
+
return reply.type('text/html').send(passkeyPromptPage(uid, account.id));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Complete the interaction
|
|
126
151
|
const result = {
|
|
127
152
|
login: {
|
|
128
153
|
accountId: account.id,
|
|
@@ -130,12 +155,6 @@ export async function handleLogin(request, reply, provider) {
|
|
|
130
155
|
},
|
|
131
156
|
};
|
|
132
157
|
|
|
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
158
|
// Save the login result to the interaction
|
|
140
159
|
interaction.result = result;
|
|
141
160
|
await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
|
|
@@ -433,3 +452,211 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
|
|
|
433
452
|
return reply.type('text/html').send(registerPage(uid, err.message, null, inviteOnly));
|
|
434
453
|
}
|
|
435
454
|
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Handle GET /idp/interaction/:uid/passkey-complete
|
|
458
|
+
* Completes OIDC interaction after passkey login or registration
|
|
459
|
+
*/
|
|
460
|
+
export async function handlePasskeyComplete(request, reply, provider) {
|
|
461
|
+
const { uid } = request.params;
|
|
462
|
+
const { accountId } = request.query;
|
|
463
|
+
|
|
464
|
+
if (!accountId) {
|
|
465
|
+
return reply.code(400).type('text/html').send(errorPage('Missing account', 'Account ID is required.'));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
const interaction = await provider.Interaction.find(uid);
|
|
470
|
+
if (!interaction) {
|
|
471
|
+
return reply.code(404).type('text/html').send(errorPage('Session expired', 'Please try logging in again.'));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// If this is a post-login passkey registration flow, validate accountId matches
|
|
475
|
+
// the already-authenticated user to prevent account takeover
|
|
476
|
+
if (interaction.passkeyPromptPending && interaction.result?.login?.accountId) {
|
|
477
|
+
if (interaction.result.login.accountId !== accountId) {
|
|
478
|
+
request.log.warn({ expected: interaction.result.login.accountId, provided: accountId }, 'AccountId mismatch in passkey complete');
|
|
479
|
+
return reply.code(403).type('text/html').send(errorPage('Access denied', 'Account mismatch.'));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const account = await findById(accountId);
|
|
484
|
+
if (!account) {
|
|
485
|
+
return reply.code(404).type('text/html').send(errorPage('Account not found', 'The account could not be found.'));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Update last login
|
|
489
|
+
await updateLastLogin(accountId);
|
|
490
|
+
|
|
491
|
+
// Complete the OIDC interaction
|
|
492
|
+
const result = {
|
|
493
|
+
login: {
|
|
494
|
+
accountId: account.id,
|
|
495
|
+
remember: true,
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
request.log.info({ accountId: account.id, uid }, 'Passkey login completed');
|
|
500
|
+
|
|
501
|
+
reply.hijack();
|
|
502
|
+
return provider.interactionFinished(request.raw, reply.raw, result, { mergeWithLastSubmission: false });
|
|
503
|
+
} catch (err) {
|
|
504
|
+
request.log.error(err, 'Passkey complete error');
|
|
505
|
+
return reply.code(500).type('text/html').send(errorPage('Error', err.message));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Handle GET /idp/interaction/:uid/passkey-skip
|
|
511
|
+
* User skipped passkey registration, complete login
|
|
512
|
+
*/
|
|
513
|
+
export async function handlePasskeySkip(request, reply, provider) {
|
|
514
|
+
const { uid } = request.params;
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
const interaction = await provider.Interaction.find(uid);
|
|
518
|
+
if (!interaction) {
|
|
519
|
+
return reply.code(404).type('text/html').send(errorPage('Session expired', 'Please try logging in again.'));
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Validate the interaction is in the passkey prompt state
|
|
523
|
+
if (!interaction.passkeyPromptPending) {
|
|
524
|
+
return reply.code(400).type('text/html').send(errorPage('Invalid state', 'Not in passkey prompt flow.'));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Get the pending login result
|
|
528
|
+
const result = interaction.result;
|
|
529
|
+
if (!result?.login?.accountId) {
|
|
530
|
+
return reply.code(400).type('text/html').send(errorPage('Invalid state', 'No pending login found.'));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Mark passkey prompt as dismissed so we don't nag again
|
|
534
|
+
await setPasskeyPromptDismissed(result.login.accountId, true);
|
|
535
|
+
|
|
536
|
+
request.log.info({ accountId: result.login.accountId, uid }, 'Passkey prompt skipped');
|
|
537
|
+
|
|
538
|
+
// Complete the OIDC interaction
|
|
539
|
+
reply.hijack();
|
|
540
|
+
return provider.interactionFinished(request.raw, reply.raw, result, { mergeWithLastSubmission: false });
|
|
541
|
+
} catch (err) {
|
|
542
|
+
request.log.error(err, 'Passkey skip error');
|
|
543
|
+
return reply.code(500).type('text/html').send(errorPage('Error', err.message));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Handle POST /idp/interaction/:uid/schnorr-login
|
|
549
|
+
* Authenticates user via Schnorr signature (NIP-98)
|
|
550
|
+
*/
|
|
551
|
+
export async function handleSchnorrLogin(request, reply, provider) {
|
|
552
|
+
const { uid } = request.params;
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
const interaction = await provider.Interaction.find(uid);
|
|
556
|
+
if (!interaction) {
|
|
557
|
+
return reply.code(404).type('application/json').send({
|
|
558
|
+
success: false,
|
|
559
|
+
error: 'Session expired. Please try again.'
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Verify the Schnorr signature
|
|
564
|
+
const authResult = await verifyNostrAuth(request);
|
|
565
|
+
|
|
566
|
+
if (authResult.error) {
|
|
567
|
+
request.log.warn({ error: authResult.error }, 'Schnorr auth failed');
|
|
568
|
+
return reply.code(401).type('application/json').send({
|
|
569
|
+
success: false,
|
|
570
|
+
error: authResult.error
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// authResult.webId is either a resolved WebID or did:nostr:pubkey
|
|
575
|
+
const identity = authResult.webId;
|
|
576
|
+
request.log.info({ identity, uid }, 'Schnorr auth verified');
|
|
577
|
+
|
|
578
|
+
// Try to find an existing account linked to this identity
|
|
579
|
+
let account = await findByWebId(identity);
|
|
580
|
+
|
|
581
|
+
if (!account) {
|
|
582
|
+
// No account linked to this did:nostr
|
|
583
|
+
// For now, return error - user needs to link their did:nostr to an account
|
|
584
|
+
// Future: could auto-create account or prompt for linking
|
|
585
|
+
return reply.code(403).type('application/json').send({
|
|
586
|
+
success: false,
|
|
587
|
+
error: 'No account linked to this identity. Please register or link your Schnorr key to an existing account.'
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Update last login
|
|
592
|
+
await updateLastLogin(account.id);
|
|
593
|
+
|
|
594
|
+
// Complete the OIDC interaction
|
|
595
|
+
const result = {
|
|
596
|
+
login: {
|
|
597
|
+
accountId: account.id,
|
|
598
|
+
remember: true,
|
|
599
|
+
},
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// Save the login result
|
|
603
|
+
interaction.result = result;
|
|
604
|
+
await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
|
|
605
|
+
|
|
606
|
+
request.log.info({ accountId: account.id, identity, uid }, 'Schnorr login successful');
|
|
607
|
+
|
|
608
|
+
// Return success with redirect URL
|
|
609
|
+
// The client will follow this redirect
|
|
610
|
+
const redirectUrl = `/idp/interaction/${uid}/schnorr-complete?accountId=${encodeURIComponent(account.id)}`;
|
|
611
|
+
|
|
612
|
+
return reply.type('application/json').send({
|
|
613
|
+
success: true,
|
|
614
|
+
redirectUrl
|
|
615
|
+
});
|
|
616
|
+
} catch (err) {
|
|
617
|
+
request.log.error(err, 'Schnorr login error');
|
|
618
|
+
return reply.code(500).type('application/json').send({
|
|
619
|
+
success: false,
|
|
620
|
+
error: err.message
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Handle GET /idp/interaction/:uid/schnorr-complete
|
|
627
|
+
* Completes OIDC interaction after Schnorr login
|
|
628
|
+
*/
|
|
629
|
+
export async function handleSchnorrComplete(request, reply, provider) {
|
|
630
|
+
const { uid } = request.params;
|
|
631
|
+
const { accountId } = request.query;
|
|
632
|
+
|
|
633
|
+
if (!accountId) {
|
|
634
|
+
return reply.code(400).type('text/html').send(errorPage('Missing account', 'Account ID is required.'));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
try {
|
|
638
|
+
const interaction = await provider.Interaction.find(uid);
|
|
639
|
+
if (!interaction) {
|
|
640
|
+
return reply.code(404).type('text/html').send(errorPage('Session expired', 'Please try logging in again.'));
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Validate accountId matches the interaction result
|
|
644
|
+
if (interaction.result?.login?.accountId !== accountId) {
|
|
645
|
+
request.log.warn({ expected: interaction.result?.login?.accountId, provided: accountId }, 'AccountId mismatch in schnorr complete');
|
|
646
|
+
return reply.code(403).type('text/html').send(errorPage('Access denied', 'Account mismatch.'));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const account = await findById(accountId);
|
|
650
|
+
if (!account) {
|
|
651
|
+
return reply.code(404).type('text/html').send(errorPage('Account not found', 'The account could not be found.'));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
request.log.info({ accountId: account.id, uid }, 'Schnorr login completed');
|
|
655
|
+
|
|
656
|
+
reply.hijack();
|
|
657
|
+
return provider.interactionFinished(request.raw, reply.raw, interaction.result, { mergeWithLastSubmission: false });
|
|
658
|
+
} catch (err) {
|
|
659
|
+
request.log.error(err, 'Schnorr complete error');
|
|
660
|
+
return reply.code(500).type('text/html').send(errorPage('Error', err.message));
|
|
661
|
+
}
|
|
662
|
+
}
|