javascript-solid-server 0.0.178 → 0.0.180
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/docs/lws.md +35 -24
- package/package.json +1 -1
- package/src/auth/did-nostr.js +417 -73
- package/src/auth/nostr-keys.js +112 -0
- package/src/auth/nostr.js +100 -29
- package/src/idp/interactions.js +69 -7
- package/src/idp/views.js +11 -2
- package/src/idp/well-known-did-nostr.js +458 -0
- package/src/server.js +66 -0
- package/test/did-nostr.test.js +331 -1
- package/test/nostr-cid-vm.test.js +54 -1
- package/test/well-known-did-nostr.test.js +631 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Nostr-key encoding helpers.
|
|
3
|
+
*
|
|
4
|
+
* Lives in its own module so both the NIP-98 verifier
|
|
5
|
+
* (`src/auth/nostr.js`) and the well-known DID-doc publisher
|
|
6
|
+
* (`src/idp/well-known-did-nostr.js`) can use it without forming a
|
|
7
|
+
* circular import.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
11
|
+
|
|
12
|
+
/** Multicodec varint for secp256k1-pub: 0xe7 0x01 → "e701" hex. */
|
|
13
|
+
const MULTICODEC_SECP256K1_PUB_HEX = 'e701';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Validate a secp256k1 JWK as a Nostr key and return its x-only
|
|
17
|
+
* pubkey hex. Returns `null` if the JWK isn't a Nostr-shaped key
|
|
18
|
+
* or its `y` doesn't match the BIP-340 canonical (even-y) point
|
|
19
|
+
* for the declared `x`.
|
|
20
|
+
*
|
|
21
|
+
* Why y matters: every secp256k1 x has TWO valid points (positive
|
|
22
|
+
* and negative y). Nostr uses x-only pubkeys, which by BIP-340
|
|
23
|
+
* convention always pick the even-y point. A profile that declares
|
|
24
|
+
* a JWK with the right x but the wrong y is NOT the user's Nostr
|
|
25
|
+
* key — accepting it would let an attacker plant a JWK at someone
|
|
26
|
+
* else's WebID and have the indexer publish it as theirs.
|
|
27
|
+
*
|
|
28
|
+
* The verifier in src/auth/nostr.js (jwkMatchesNostrPubkey) does
|
|
29
|
+
* the same check. Keeping the indexer in sync prevents the
|
|
30
|
+
* "indexed but verifier rejects" inconsistency that would surface
|
|
31
|
+
* as a 401 on a key the well-known endpoint had advertised.
|
|
32
|
+
*/
|
|
33
|
+
export function pubkeyFromValidatedJwk(jwk) {
|
|
34
|
+
if (!jwk || typeof jwk !== 'object') return null;
|
|
35
|
+
if (jwk.kty !== 'EC') return null;
|
|
36
|
+
if (jwk.crv !== 'secp256k1' && jwk.crv !== 'P-256K') return null;
|
|
37
|
+
if (typeof jwk.x !== 'string' || typeof jwk.y !== 'string') return null;
|
|
38
|
+
let xHex;
|
|
39
|
+
try {
|
|
40
|
+
xHex = Buffer.from(jwk.x.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
|
|
41
|
+
.toString('hex').toLowerCase();
|
|
42
|
+
} catch { return null; }
|
|
43
|
+
if (!/^[0-9a-f]{64}$/.test(xHex)) return null;
|
|
44
|
+
let canonicalY;
|
|
45
|
+
try {
|
|
46
|
+
// Compressed SEC1 encoding for the EVEN-y point at this x.
|
|
47
|
+
const point = secp256k1.ProjectivePoint.fromHex('02' + xHex);
|
|
48
|
+
canonicalY = point.toAffine().y.toString(16).padStart(64, '0');
|
|
49
|
+
} catch { return null; }
|
|
50
|
+
let jwkYHex;
|
|
51
|
+
try {
|
|
52
|
+
jwkYHex = Buffer.from(jwk.y.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
|
|
53
|
+
.toString('hex').toLowerCase();
|
|
54
|
+
} catch { return null; }
|
|
55
|
+
if (jwkYHex !== canonicalY) return null;
|
|
56
|
+
return xHex;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decode an f-form Multikey for secp256k1-pub back into the 32-byte
|
|
61
|
+
* x-only pubkey hex. Returns null if the input isn't this shape.
|
|
62
|
+
*
|
|
63
|
+
* The f-form recipe (per CCG community#254 / did:nostr): multibase
|
|
64
|
+
* `f` (base16-lower) + multicodec `e701` + parity byte (`02`/`03`)
|
|
65
|
+
* + 32-byte xonly pubkey.
|
|
66
|
+
*/
|
|
67
|
+
export function decodeFFormSecp256k1(mb) {
|
|
68
|
+
if (typeof mb !== 'string' || !mb.startsWith('f')) return null;
|
|
69
|
+
const hex = mb.slice(1).toLowerCase();
|
|
70
|
+
if (!/^[0-9a-f]+$/.test(hex)) return null;
|
|
71
|
+
if (!hex.startsWith(MULTICODEC_SECP256K1_PUB_HEX)) return null;
|
|
72
|
+
const rest = hex.slice(MULTICODEC_SECP256K1_PUB_HEX.length);
|
|
73
|
+
// Expect parity byte (02/03) + 32-byte xonly = 66 hex chars.
|
|
74
|
+
if (rest.length !== 66) return null;
|
|
75
|
+
const parity = rest.slice(0, 2);
|
|
76
|
+
if (parity !== '02' && parity !== '03') return null;
|
|
77
|
+
return rest.slice(2);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Enumerate every Nostr pubkey declared in a profile's
|
|
82
|
+
* `verificationMethod` entries. Matches both encodings:
|
|
83
|
+
* - f-form Multikey (`publicKeyMultibase`)
|
|
84
|
+
* - JsonWebKey (`kty: EC, crv: secp256k1`) — derives x as the pubkey
|
|
85
|
+
*
|
|
86
|
+
* Returns `[ { pubkey, vm } ]` — the VM is returned alongside so
|
|
87
|
+
* callers can do further checks (`controller`, `authentication`
|
|
88
|
+
* membership, etc.) without re-parsing.
|
|
89
|
+
*/
|
|
90
|
+
export function extractNostrPubkeysFromProfile(profile) {
|
|
91
|
+
if (!profile || typeof profile !== 'object') return [];
|
|
92
|
+
const out = [];
|
|
93
|
+
const raw = profile.verificationMethod;
|
|
94
|
+
const vms = raw === undefined || raw === null ? []
|
|
95
|
+
: Array.isArray(raw) ? raw : [raw];
|
|
96
|
+
for (const vm of vms) {
|
|
97
|
+
if (!vm || typeof vm !== 'object') continue;
|
|
98
|
+
if (typeof vm.publicKeyMultibase === 'string') {
|
|
99
|
+
const xonly = decodeFFormSecp256k1(vm.publicKeyMultibase);
|
|
100
|
+
if (xonly) out.push({ pubkey: xonly, vm });
|
|
101
|
+
} else if (vm.publicKeyJwk && typeof vm.publicKeyJwk === 'object') {
|
|
102
|
+
// Require y to match the BIP-340 canonical point — the same
|
|
103
|
+
// check the NIP-98 verifier applies. Without this, the indexer
|
|
104
|
+
// could publish a JWK that the verifier will then reject,
|
|
105
|
+
// surfacing as a 401 on a key the well-known endpoint had
|
|
106
|
+
// advertised as authentic.
|
|
107
|
+
const xonly = pubkeyFromValidatedJwk(vm.publicKeyJwk);
|
|
108
|
+
if (xonly) out.push({ pubkey: xonly, vm });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
package/src/auth/nostr.js
CHANGED
|
@@ -14,10 +14,14 @@
|
|
|
14
14
|
* Match by f-form Multikey or by JsonWebKey x/y coordinates. If
|
|
15
15
|
* found, authenticate as the WebID. (#399 — pairs with the
|
|
16
16
|
* LWS10-CID verifier.)
|
|
17
|
-
* 2.
|
|
17
|
+
* 2. (IdP-only) Look up the pubkey in the local in-process index
|
|
18
|
+
* built from `<DATA_ROOT>/.idp/accounts/_webid_index.json`.
|
|
19
|
+
* No HTTP, no SSRF surface — direct function call. Catches
|
|
20
|
+
* same-pod users without a third-party round-trip. (#407)
|
|
21
|
+
* 3. Resolve via the external did:nostr DID-document path
|
|
18
22
|
* (nostr.social `.well-known` + bidirectional alsoKnownAs).
|
|
19
|
-
*
|
|
20
|
-
*
|
|
23
|
+
* Used for cross-pod identities; SSRF + redirect hardened.
|
|
24
|
+
* 4. Otherwise return `did:nostr:<64-char-hex-pubkey>` as the
|
|
21
25
|
* agent identity (the original behavior).
|
|
22
26
|
*/
|
|
23
27
|
|
|
@@ -25,8 +29,14 @@ import { verifyEvent, getEventHash } from '../nostr/event.js';
|
|
|
25
29
|
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
26
30
|
import crypto from 'crypto';
|
|
27
31
|
import { resolveDidNostrToWebId } from './did-nostr.js';
|
|
32
|
+
// resolveDidNostrLocally is loaded lazily (inside the idpEnabled
|
|
33
|
+
// branch) so non-IdP deployments don't pay the IdP/accounts module
|
|
34
|
+
// startup cost (bcryptjs, oidc-provider helpers, etc.) just by
|
|
35
|
+
// importing the NIP-98 verifier.
|
|
28
36
|
import { fetchCidDocument } from './cid-doc-fetch.js';
|
|
29
|
-
import { normalizeControllers } from './lws-cid.js';
|
|
37
|
+
import { normalizeControllers } from './lws-cid.js'; // shared JSON-LD controller helper
|
|
38
|
+
import { decodeFFormSecp256k1, extractNostrPubkeysFromProfile } from './nostr-keys.js'; // re-exported for back-compat
|
|
39
|
+
export { extractNostrPubkeysFromProfile };
|
|
30
40
|
|
|
31
41
|
// NIP-98 event kind (references RFC 7235)
|
|
32
42
|
const HTTP_AUTH_KIND = 27235;
|
|
@@ -34,11 +44,6 @@ const HTTP_AUTH_KIND = 27235;
|
|
|
34
44
|
// Timestamp tolerance in seconds
|
|
35
45
|
const TIMESTAMP_TOLERANCE = 60;
|
|
36
46
|
|
|
37
|
-
// Multicodec varint for secp256k1-pub: 0xe7 0x01 → "e701" hex.
|
|
38
|
-
// Used to decode f-form Multikey verificationMethod values back into
|
|
39
|
-
// the 32-byte x-only Nostr pubkey.
|
|
40
|
-
const MULTICODEC_SECP256K1_PUB_HEX = 'e701';
|
|
41
|
-
|
|
42
47
|
// Profile-fetch body-size cap. Matches the LWS-CID verifier; both
|
|
43
48
|
// callers go through the shared fetchCidDocument helper.
|
|
44
49
|
const MAX_PROFILE_BYTES = 256 * 1024;
|
|
@@ -282,9 +287,30 @@ export async function verifyNostrAuth(request) {
|
|
|
282
287
|
return { webId: vmWebId, error: null };
|
|
283
288
|
}
|
|
284
289
|
|
|
285
|
-
// Second lookup:
|
|
286
|
-
//
|
|
287
|
-
//
|
|
290
|
+
// Second lookup: in-process local DID resolution (#407). Fast path
|
|
291
|
+
// — direct function call into the local account index, no HTTP
|
|
292
|
+
// fetch, no SSRF surface from request-controlled headers. Catches
|
|
293
|
+
// any user who's published a Nostr Multikey VM into their profile
|
|
294
|
+
// on this same pod.
|
|
295
|
+
//
|
|
296
|
+
// Gated on idpEnabled because the index reads from
|
|
297
|
+
// <DATA_ROOT>/.idp/accounts which only exists when the IdP layer
|
|
298
|
+
// is in use. On non-IdP deployments the local resolver has nothing
|
|
299
|
+
// to find and would just spin disk on every request.
|
|
300
|
+
if (request.idpEnabled) {
|
|
301
|
+
// Dynamic import: only load the IdP-accounts stack when IdP is
|
|
302
|
+
// actually enabled. Cached after first load (ESM module caching).
|
|
303
|
+
const { resolveDidNostrLocally } = await import('../idp/well-known-did-nostr.js');
|
|
304
|
+
const localWebId = await resolveDidNostrLocally(event.pubkey);
|
|
305
|
+
if (localWebId) {
|
|
306
|
+
return { webId: localWebId, error: null };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Third lookup: external did:nostr DID-document resolver. Fetches
|
|
311
|
+
// a DID doc from the configured external resolver (nostr.social) and
|
|
312
|
+
// checks bidirectional alsoKnownAs ↔ WebID linking. Used only for
|
|
313
|
+
// cross-pod identities (the local case is handled above).
|
|
288
314
|
const resolvedWebId = await resolveDidNostrToWebId(event.pubkey);
|
|
289
315
|
if (resolvedWebId) {
|
|
290
316
|
return { webId: resolvedWebId, error: null };
|
|
@@ -503,6 +529,68 @@ function firstHeaderValue(v) {
|
|
|
503
529
|
return first || null;
|
|
504
530
|
}
|
|
505
531
|
|
|
532
|
+
/**
|
|
533
|
+
* Confirm that a verified Nostr pubkey is declared as a CID
|
|
534
|
+
* verificationMethod referenced from `authentication` in the given
|
|
535
|
+
* WebID's profile. Used by the Schnorr-login IdP path (#403): once
|
|
536
|
+
* the signature is verified and the user has typed their username,
|
|
537
|
+
* the IdP layer can derive the candidate WebID and ask this whether
|
|
538
|
+
* the verified pubkey actually belongs to that WebID.
|
|
539
|
+
*
|
|
540
|
+
* Mirrors the same controller-consistency / subject-identity /
|
|
541
|
+
* authentication-membership checks as the resource-side path
|
|
542
|
+
* (tryResolveViaCidVerificationMethod) so the two paths apply the
|
|
543
|
+
* same key-binding semantics.
|
|
544
|
+
*
|
|
545
|
+
* Returns true on match, false on any failure (fetch, VM not in
|
|
546
|
+
* authentication, subject mismatch, controller mismatch, bad input).
|
|
547
|
+
*
|
|
548
|
+
* @param {string} webId - canonical fragment-bearing WebID URI (e.g.
|
|
549
|
+
* `https://alice.example.com/profile/card.jsonld#me`). The profile's
|
|
550
|
+
* own `@id` must match this exactly after absolutization.
|
|
551
|
+
* @param {string} pubkeyHex - 32-byte x-only Nostr pubkey hex
|
|
552
|
+
* @returns {Promise<boolean>}
|
|
553
|
+
*/
|
|
554
|
+
export async function verifyNostrPubkeyAgainstWebId(webId, pubkeyHex) {
|
|
555
|
+
if (typeof webId !== 'string' || !webId) return false;
|
|
556
|
+
if (typeof pubkeyHex !== 'string' || !/^[0-9a-f]{64}$/i.test(pubkeyHex)) return false;
|
|
557
|
+
const docUrl = stripHash(webId);
|
|
558
|
+
let profile;
|
|
559
|
+
try {
|
|
560
|
+
profile = await fetchCidDocument(docUrl);
|
|
561
|
+
} catch {
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return false;
|
|
565
|
+
|
|
566
|
+
// Confirm the profile actually identifies itself as the WebID we're
|
|
567
|
+
// asking about — otherwise a profile hosted at the WebID's URL could
|
|
568
|
+
// declare a different fragment as its subject and trick us. Both
|
|
569
|
+
// sides absolutized so a relative @id (e.g. "#me") resolves against
|
|
570
|
+
// docUrl and a webId without fragment (which the docstring no longer
|
|
571
|
+
// permits, but be defensive) doesn't accidentally match a fragment
|
|
572
|
+
// form.
|
|
573
|
+
const subject = absolutize(profile['@id'] || profile.id, docUrl);
|
|
574
|
+
const expectedSubject = absolutize(webId, docUrl);
|
|
575
|
+
if (!subject || subject !== expectedSubject) return false;
|
|
576
|
+
|
|
577
|
+
const vm = findNostrVmInProfile(profile, pubkeyHex.toLowerCase(), docUrl);
|
|
578
|
+
if (!vm) return false;
|
|
579
|
+
if (!isInProofPurpose(profile, 'authentication', vm.id, docUrl)) return false;
|
|
580
|
+
|
|
581
|
+
// Controller consistency: the VM's `controller` MUST be in the
|
|
582
|
+
// profile's expected controller set (declared `controller`, with
|
|
583
|
+
// @id fallback). Without this, a profile with a Nostr-keyed VM
|
|
584
|
+
// controlled by some unrelated identity would pass — a binding the
|
|
585
|
+
// actual subject never asserted. Mirrors the resource-path check.
|
|
586
|
+
const expectedCtrls = normalizeControllers(profile.controller ?? profile['@id'] ?? profile.id, docUrl);
|
|
587
|
+
if (expectedCtrls.length === 0) return false;
|
|
588
|
+
const vmCtrls = normalizeControllers(vm.controller, docUrl);
|
|
589
|
+
if (!vmCtrls.some((c) => expectedCtrls.includes(c))) return false;
|
|
590
|
+
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
|
|
506
594
|
/**
|
|
507
595
|
* Find a verificationMethod whose key material matches the Nostr
|
|
508
596
|
* x-only pubkey hex. Two encodings supported:
|
|
@@ -537,23 +625,6 @@ function findNostrVmInProfile(profile, pubkeyHex, baseUrl) {
|
|
|
537
625
|
return null;
|
|
538
626
|
}
|
|
539
627
|
|
|
540
|
-
/**
|
|
541
|
-
* Decode an f-form Multikey for secp256k1-pub back into the 32-byte
|
|
542
|
-
* x-only pubkey hex. Returns null if the input isn't this shape.
|
|
543
|
-
*/
|
|
544
|
-
function decodeFFormSecp256k1(mb) {
|
|
545
|
-
if (typeof mb !== 'string' || !mb.startsWith('f')) return null;
|
|
546
|
-
const hex = mb.slice(1).toLowerCase();
|
|
547
|
-
if (!/^[0-9a-f]+$/.test(hex)) return null;
|
|
548
|
-
if (!hex.startsWith(MULTICODEC_SECP256K1_PUB_HEX)) return null;
|
|
549
|
-
const rest = hex.slice(MULTICODEC_SECP256K1_PUB_HEX.length);
|
|
550
|
-
// Expect parity byte (02/03) + 32-byte xonly = 66 hex chars.
|
|
551
|
-
if (rest.length !== 66) return null;
|
|
552
|
-
const parity = rest.slice(0, 2);
|
|
553
|
-
if (parity !== '02' && parity !== '03') return null;
|
|
554
|
-
return rest.slice(2);
|
|
555
|
-
}
|
|
556
|
-
|
|
557
628
|
function hexToBase64url(hex) {
|
|
558
629
|
return Buffer.from(hex, 'hex').toString('base64')
|
|
559
630
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
package/src/idp/interactions.js
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
* Handles the user-facing parts of the authentication flow
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { authenticate, findById, findByWebId, createAccount, updateLastLogin, setPasskeyPromptDismissed } from './accounts.js';
|
|
6
|
+
import { authenticate, findById, findByUsername, findByWebId, createAccount, updateLastLogin, setPasskeyPromptDismissed } from './accounts.js';
|
|
7
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
|
+
import { verifyNostrAuth, getNostrPubkey, verifyNostrPubkeyAgainstWebId } from '../auth/nostr.js';
|
|
12
12
|
|
|
13
13
|
// Security: Maximum body size for IdP form submissions (1MB)
|
|
14
14
|
const MAX_BODY_SIZE = 1024 * 1024;
|
|
@@ -658,6 +658,41 @@ export async function handlePasskeySkip(request, reply, provider) {
|
|
|
658
658
|
}
|
|
659
659
|
}
|
|
660
660
|
|
|
661
|
+
/**
|
|
662
|
+
* Pull the optional `username` field out of a schnorr-login POST.
|
|
663
|
+
*
|
|
664
|
+
* JSS registers a wildcard parseAs:'buffer' content-type parser
|
|
665
|
+
* (src/server.js), so request.body for application/x-www-form-urlencoded
|
|
666
|
+
* arrives as a Buffer that needs string-decode + URLSearchParams. JSON
|
|
667
|
+
* and already-parsed object bodies are also accepted for flexibility.
|
|
668
|
+
*
|
|
669
|
+
* Returns either:
|
|
670
|
+
* - { tooLarge: true } if the body exceeds MAX_BODY_SIZE (matching
|
|
671
|
+
* handleLogin / handleRegisterPost — caller emits 413).
|
|
672
|
+
* - { username: string } otherwise, possibly empty.
|
|
673
|
+
*/
|
|
674
|
+
function parseUsernameField(request) {
|
|
675
|
+
const body = request.body;
|
|
676
|
+
if (!body) return { username: '' };
|
|
677
|
+
const ct = (request.headers?.['content-type'] || '').toLowerCase();
|
|
678
|
+
if (Buffer.isBuffer(body) && body.length > MAX_BODY_SIZE) return { tooLarge: true };
|
|
679
|
+
if (typeof body === 'string' && body.length > MAX_BODY_SIZE) return { tooLarge: true };
|
|
680
|
+
|
|
681
|
+
let bag = {};
|
|
682
|
+
if (Buffer.isBuffer(body) || typeof body === 'string') {
|
|
683
|
+
const s = Buffer.isBuffer(body) ? body.toString() : body;
|
|
684
|
+
if (ct.includes('application/json')) {
|
|
685
|
+
try { bag = JSON.parse(s); } catch { bag = {}; }
|
|
686
|
+
} else {
|
|
687
|
+
try { bag = Object.fromEntries(new URLSearchParams(s).entries()); }
|
|
688
|
+
catch { bag = {}; }
|
|
689
|
+
}
|
|
690
|
+
} else if (typeof body === 'object') {
|
|
691
|
+
bag = body;
|
|
692
|
+
}
|
|
693
|
+
return { username: (bag.username || '').toString().trim() };
|
|
694
|
+
}
|
|
695
|
+
|
|
661
696
|
/**
|
|
662
697
|
* Handle POST /idp/interaction/:uid/schnorr-login
|
|
663
698
|
* Authenticates user via Schnorr signature (NIP-98)
|
|
@@ -689,16 +724,43 @@ export async function handleSchnorrLogin(request, reply, provider) {
|
|
|
689
724
|
const identity = authResult.webId;
|
|
690
725
|
request.log.info({ identity, uid }, 'Schnorr auth verified');
|
|
691
726
|
|
|
692
|
-
// Try to find an existing account linked to this identity
|
|
727
|
+
// Try to find an existing account linked to this identity. The
|
|
728
|
+
// primary path: identity is already a WebID (e.g. resolved via the
|
|
729
|
+
// existing did:nostr DID-doc resolver) and an account exists for it.
|
|
693
730
|
let account = await findByWebId(identity);
|
|
694
731
|
|
|
695
732
|
if (!account) {
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
//
|
|
733
|
+
// Fallback: if the user typed a username on the login form, check
|
|
734
|
+
// whether the verified Nostr pubkey is declared as a CID
|
|
735
|
+
// verificationMethod referenced from `authentication` in that
|
|
736
|
+
// user's WebID profile (#400's IdP-side parallel — #403). The
|
|
737
|
+
// signature has already been verified above, so this is just
|
|
738
|
+
// "does this verified pubkey belong to the typed user".
|
|
739
|
+
const parsed = parseUsernameField(request);
|
|
740
|
+
if (parsed.tooLarge) {
|
|
741
|
+
return reply.code(413).type('application/json').send({
|
|
742
|
+
success: false,
|
|
743
|
+
error: 'Request body exceeds maximum size.',
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
const typedUsername = parsed.username;
|
|
747
|
+
if (typedUsername) {
|
|
748
|
+
const candidate = await findByUsername(typedUsername);
|
|
749
|
+
if (candidate?.webId) {
|
|
750
|
+
const pubkey = await getNostrPubkey(request);
|
|
751
|
+
if (pubkey && await verifyNostrPubkeyAgainstWebId(candidate.webId, pubkey)) {
|
|
752
|
+
account = candidate;
|
|
753
|
+
request.log.info({ accountId: account.id, webId: candidate.webId, uid },
|
|
754
|
+
'Schnorr login resolved via typed username + profile VM');
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (!account) {
|
|
699
761
|
return reply.code(403).type('application/json').send({
|
|
700
762
|
success: false,
|
|
701
|
-
error: 'No account linked to this identity.
|
|
763
|
+
error: 'No account linked to this identity. Type your username and add a Schnorr verificationMethod to your WebID profile (or link via did:nostr DID document).'
|
|
702
764
|
});
|
|
703
765
|
}
|
|
704
766
|
|
package/src/idp/views.js
CHANGED
|
@@ -381,12 +381,21 @@ export function loginPage(uid, clientId, error = null, passkeyEnabled = true, sc
|
|
|
381
381
|
// Sign with NIP-07 extension
|
|
382
382
|
const signedEvent = await window.nostr.signEvent(event);
|
|
383
383
|
|
|
384
|
+
// Read the typed username so the server can resolve which
|
|
385
|
+
// account this Nostr key belongs to, in case the existing
|
|
386
|
+
// did:nostr DID-doc resolver doesn't have a binding yet.
|
|
387
|
+
// The signature is verified BEFORE the username is consulted —
|
|
388
|
+
// typing someone else's username doesn't grant access.
|
|
389
|
+
const typedUsername = (document.getElementById('username')?.value || '').trim();
|
|
390
|
+
|
|
384
391
|
// Send to server
|
|
385
392
|
const response = await fetch(authUrl, {
|
|
386
393
|
method: 'POST',
|
|
387
394
|
headers: {
|
|
388
|
-
'Authorization': 'Nostr ' + btoa(JSON.stringify(signedEvent))
|
|
389
|
-
|
|
395
|
+
'Authorization': 'Nostr ' + btoa(JSON.stringify(signedEvent)),
|
|
396
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
397
|
+
},
|
|
398
|
+
body: typedUsername ? 'username=' + encodeURIComponent(typedUsername) : ''
|
|
390
399
|
});
|
|
391
400
|
|
|
392
401
|
const result = await response.json();
|