auth: did:nostr resolver prefers local index before external (#423)#424
Merged
Conversation
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/<pubkey>`) 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.
There was a problem hiding this comment.
Pull request overview
This PR updates did:nostr → WebID resolution so that, when a pubkey belongs to a local account, the server resolves it via the in-process well-known index instead of always round-tripping through the configured external resolver (e.g. nostr.social). This improves reliability for the dominant “user signing into their own pod” SSO flow when external resolvers are unavailable.
Changes:
- Reorders
resolveDidNostrToWebId()to consult the local well-known index first via dynamic import ofresolveDidNostrLocally. - Keeps the external resolver path as a fallback for non-local pubkeys, preserving cross-pod identity resolution.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+208
to
+223
| // 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. |
Comment on lines
+208
to
+224
| // 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. | ||
| } |
melvincarvalho
added a commit
that referenced
this pull request
May 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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.
Closes #423.
Why
The dominant SSO case is a user signing into their own pod. With the previous ordering inside
resolveDidNostrToWebId, that case always reached the configured external resolver (https://nostr.social/.well-known/did/nostr/<pubkey>by default). When the external host is unreachable — network outage, expired SSL certificate, etc. — auth fails entirely even though the pod has the authoritative DID document for the user in memory.Real-world incident that motivated this:
nostr.social's SSL certificate expired, causingfetch()to fail withSSRF protection/fetch failedand the user's own write requests to come back 401 because the agent couldn't be resolved to a WebID.What
In
src/auth/did-nostr.js,resolveDidNostrToWebIdnow consults the local well-known index first (via dynamic import ofresolveDidNostrLocallyfromsrc/idp/well-known-did-nostr.js) before any HTTP fetch. Falls back to the external resolver only when no local account claims the pubkey, so cross-pod identities still work.The local binding is already verified by construction — the indexer only admits VMs declared in
verificationMethodAND referenced fromauthenticationof a local profile — so the backlink-verification round-trip the external path requires is also skipped on the local path.Defense-in-depth
verifyNostrAuth(src/auth/nostr.js) already callsresolveDidNostrLocallyahead ofresolveDidNostrToWebIdin its resolver chain. Duplicating the local-first preference inside the resolver itself protects callers from other code paths (and any future caller that forgets the chain ordering) without behavioural cost.Dynamic import keeps the IdP module optional: pods built/run without IdP don't pull it in, and any import or lookup error falls through to the external resolver.
Test plan
test/did-nostr.test.jscases passtest/well-known-did-nostr.test.js,test/nostr-event.test.js,test/nostr-cid-vm.test.jspasssolid.socialand verify the user's write-after-NIP-98-SSO flow returns the correct WebID without hitting the external resolver. Confirmed locally viacurlagainst the pod's own.well-known/did/nostr/<pubkey>returning the correct DID document withalsoKnownAspointing at the WebID granted Write in the test ACL.