From 28ecc81460bc9bd1aed1b79811c4282d13b25d03 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Tue, 12 May 2026 16:30:15 +0200 Subject: [PATCH] auth: did:nostr resolver prefers local index before external (#423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the requesting Nostr pubkey belongs to a local account, resolve to its WebID via the in-process well-known index instead of round- tripping through the configured external resolver. The local binding is already verified by construction (the index only admits VMs that are declared in `verificationMethod` AND referenced from `authentication` of a local profile), so we also skip the backlink verification the external path requires. Eliminates the most common SSO failure mode: user signs into their own pod, the auth path tries the external resolver (`nostr.social/.well-known/did/nostr/`) and the external host is unreachable (network down, SSL cert expired, etc.). With this change the external resolver is only consulted for cross-pod pubkeys that no local account claims. `verifyNostrAuth` (src/auth/nostr.js) already tries the local resolver first in its chain; duplicating that ordering inside `resolveDidNostrToWebId` itself is defense-in-depth — any caller from another code path (or any future caller that forgets the chain ordering) still gets the cheap, reliable answer for the dominant case. Dynamic import keeps the IdP module optional for builds that don't use it. --- src/auth/did-nostr.js | 46 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/src/auth/did-nostr.js b/src/auth/did-nostr.js index 3eaef70b..eeb6c9be 100644 --- a/src/auth/did-nostr.js +++ b/src/auth/did-nostr.js @@ -2,9 +2,12 @@ * DID:nostr Resolution * * Resolves did:nostr: to a Solid WebID by: - * 1. Fetching DID document from nostr.social - * 2. Extracting alsoKnownAs WebID - * 3. Verifying bidirectional link (WebID links back to did:nostr) + * 1. Trying the in-process local well-known index first (the pod is + * its own authoritative resolver for its accounts — no HTTP) + * 2. Falling back to the configured external resolver: + * a. Fetching the DID document + * b. Extracting alsoKnownAs WebID + * c. Verifying bidirectional link (WebID links back to did:nostr) */ import { validateExternalUrl } from '../utils/ssrf.js'; @@ -168,12 +171,21 @@ export async function fetchWithRedirectGuard(initialUrl, { } /** - * Resolve did:nostr pubkey to WebID via DID document. + * Resolve did:nostr pubkey to WebID. * - * Local users are resolved by `resolveDidNostrLocally` in the auth - * caller (well-known-did-nostr.js exports an in-process function) — - * this resolver is the cross-pod fallback that fetches an external - * DID doc, so all fetches run through the SSRF guard. + * Tries the in-process local well-known index first (the pod is its + * own authoritative resolver for its accounts — no HTTP fetch, no + * external dependency, no SSRF surface). Falls back to the configured + * external resolver (`nostr.social` by default) only for cross-pod + * pubkeys that no local account claims. + * + * This local-first ordering is also enforced in the `verifyNostrAuth` + * resolver chain (src/auth/nostr.js). Duplicating it inside the + * resolver itself is defense-in-depth: callers from other code paths + * (and any future caller that forgets to consult the local index) + * still get the cheap, reliable answer for the dominant SSO case + * — user signing in to their own pod — even when the external + * resolver is unavailable (network down, SSL cert expired, etc.). * * @param {string} pubkey - 64-char hex Nostr pubkey * @param {string} [resolverUrl] - DID resolver base URL (without the @@ -193,6 +205,24 @@ export async function resolveDidNostrToWebId(pubkey, resolverUrl = DEFAULT_DID_R } pubkey = pubkey.toLowerCase(); + // Local-first: consult the in-process well-known index before any + // HTTP fetch. The index is built from local WebID profiles whose + // `verificationMethod` declares this Nostr pubkey AND is referenced + // from `authentication` (see src/idp/well-known-did-nostr.js) — so + // the binding is already verified by construction and we skip the + // backlink round-trip required for external answers. + // + // Dynamic import keeps the IdP module optional: pods built/run + // without the IdP layer don't pull it in. Any import or lookup + // error falls through to the external resolver. + try { + const { resolveDidNostrLocally } = await import('../idp/well-known-did-nostr.js'); + const localWebId = await resolveDidNostrLocally(pubkey); + if (localWebId) return localWebId; + } catch { + // IdP module unavailable or local lookup threw — fall through. + } + // Cache key includes the resolver URL because different resolvers // can legitimately disagree about the same pubkey (one might have // a DID doc, another not; alsoKnownAs values can differ across