Implement Automatic NIP-98 HTTP Authentication#2
Conversation
- Add webRequest listeners to intercept HTTP requests - Automatically add NIP-98 Authorization headers for trusted origins - Create NIP-98 auth events with proper event structure (kind 27235) - Hash request bodies and include in payload tag - Cache signed events to avoid re-signing identical requests - Detect 401 responses (retry logic limited by Chrome API) - Respect user preferences (auto-sign, trusted origins) Closes #1
There was a problem hiding this comment.
Pull request overview
This PR implements automatic NIP-98 HTTP authentication for the Podkey browser extension. The implementation adds request interception capabilities to automatically sign HTTP requests with Nostr authentication events for trusted origins when auto-sign is enabled.
Key changes:
- Added webRequest listeners to intercept outgoing HTTP requests and inject NIP-98 Authorization headers
- Implemented NIP-98 authentication event creation (kind 27235) with URL, method, and payload tags
- Added caching mechanism to avoid re-signing identical requests within a 60-second TTL
- Attempted 401 response detection and retry logic (though with significant limitations)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async function interceptRequest (details) { | ||
| try { | ||
| // Only process XMLHttpRequest and fetch requests | ||
| if (!['xmlhttprequest', 'main_frame', 'sub_frame'].includes(details.type)) { |
There was a problem hiding this comment.
The type filter includes 'main_frame' and 'sub_frame', which refers to navigation requests (page loads). NIP-98 HTTP authentication is designed for API requests, not full page navigation. Including these types could cause issues:
- Page navigation requests typically don't need NIP-98 auth (they use cookies/sessions)
- Adding auth headers to page loads could interfere with normal navigation
- Request bodies for navigation requests may not be accessible
Consider removing 'main_frame' and 'sub_frame' from the type filter and only process 'xmlhttprequest' and 'fetch' types, which are actual API/AJAX requests.
| if (!['xmlhttprequest', 'main_frame', 'sub_frame'].includes(details.type)) { | |
| if (!['xmlhttprequest', 'fetch'].includes(details.type)) { |
| chrome.webRequest.onHeadersReceived.addListener( | ||
| handle401Response, | ||
| { | ||
| urls: ['<all_urls>'], | ||
| types: ['xmlhttprequest', 'main_frame', 'sub_frame'] | ||
| }, |
There was a problem hiding this comment.
The type filters are inconsistent between the two listeners. The onBeforeSendHeaders listener filters for 'xmlhttprequest', 'main_frame', 'sub_frame' in both the shouldAddNip98Auth check and the listener registration. However, the onHeadersReceived listener uses the same types but without corresponding filtering logic.
Additionally, as mentioned in another comment, 'main_frame' and 'sub_frame' should likely not be included for NIP-98 authentication as these are navigation requests, not API calls. Consider using only 'xmlhttprequest' and potentially 'fetch' for AJAX/API requests.
| expires: Date.now() + CACHE_TTL | ||
| }); | ||
|
|
||
| console.log('[Podkey] Created and signed NIP-98 auth event for', details.url); |
There was a problem hiding this comment.
The function logs the full URL in console.log statements, which could expose sensitive information in query parameters (tokens, session IDs, personal data, etc.).
Consider sanitizing URLs before logging, or reducing the verbosity of these logs. For security-conscious extensions, avoid logging full URLs that might contain sensitive query parameters.
| urls: ['<all_urls>'], | ||
| types: ['xmlhttprequest', 'main_frame', 'sub_frame'] | ||
| }, | ||
| ['responseHeaders'] |
There was a problem hiding this comment.
The listener includes 'responseHeaders' in the extraInfoSpec array, but this is unnecessary for detecting 401 status codes. The statusCode is available in the details object without needing access to response headers.
Remove 'responseHeaders' from the extraInfoSpec array as it's not needed and may cause unnecessary overhead or permission issues.
| ['responseHeaders'] | |
| [] |
| const signedEvent = await signEvent(event, keypair.privateKey); | ||
| const authHeader = encodeNip98Header(signedEvent); | ||
|
|
||
| console.log('[Podkey] 401 detected, created NIP-98 auth for retry:', details.url); |
There was a problem hiding this comment.
Similar to another comment, this log statement exposes the full URL which may contain sensitive information in query parameters.
Consider sanitizing URLs in logs or reducing log verbosity for production builds.
| console.log('[Podkey] 401 detected, created NIP-98 auth for retry:', details.url); | |
| console.log('[Podkey] 401 detected, created NIP-98 auth for retry for origin:', origin); |
| const retryState = new Map(); | ||
|
|
There was a problem hiding this comment.
The retryState Map uses requestId as the key, but Chrome reuses requestIds across different navigation sessions. This could cause legitimate requests to be incorrectly identified as retries, preventing them from getting authentication headers.
Additionally, there's no cleanup mechanism for this Map (similar to the cache issue), which will cause memory leaks. Consider:
- Including additional identifiers (URL, timestamp) in the retry key
- Implementing proper cleanup for stale retry state entries
- Using a time-based approach instead of relying on requestId alone
| const retryState = new Map(); | |
| const retryState = new Map(); | |
| const RETRY_STATE_TTL = 60000; // 60 seconds | |
| // Periodically clear retry state to avoid memory leaks and stale entries | |
| setInterval(() => { | |
| retryState.clear(); | |
| }, RETRY_STATE_TTL); |
| urls: ['<all_urls>'], | ||
| types: ['xmlhttprequest', 'main_frame', 'sub_frame'] | ||
| }, | ||
| ['requestHeaders', 'blocking'] |
There was a problem hiding this comment.
The manifest.json is missing the "webRequestBlocking" permission which is required for synchronous blocking webRequest listeners in Manifest V3. The onBeforeSendHeaders listener uses the 'blocking' flag, but this won't work without the proper permission.
Add "webRequestBlocking" to the permissions array in manifest.json. Without this permission, the blocking listener will fail silently and authentication headers won't be added to requests.
| ['requestHeaders', 'blocking'] | |
| ['requestHeaders'] |
| const event = await createNip98AuthEvent({ | ||
| url: details.url, | ||
| method: details.method, | ||
| requestBody: details.requestBody |
There was a problem hiding this comment.
The function attempts to access requestBody from the details object in onHeadersReceived, but requestBody is only available in onBeforeRequest listener, not in onHeadersReceived. This means the payload hash will always be empty for retries, causing authentication to fail for POST/PUT requests.
To fix this, you would need to either:
- Cache the request body hash when the request is first seen in onBeforeSendHeaders
- Accept that 401 retry doesn't work with request bodies (document this limitation)
- Remove the 401 retry functionality entirely as noted in other comments
| const event = await createNip98AuthEvent({ | |
| url: details.url, | |
| method: details.method, | |
| requestBody: details.requestBody | |
| // Note: requestBody is not available in onHeadersReceived; retries for POST/PUT | |
| // will not include a body hash. This is a known limitation of the webRequest API. | |
| const event = await createNip98AuthEvent({ | |
| url: details.url, | |
| method: details.method |
| async function interceptRequest (details) { | ||
| try { | ||
| // Only process XMLHttpRequest and fetch requests | ||
| if (!['xmlhttprequest', 'main_frame', 'sub_frame'].includes(details.type)) { | ||
| return; | ||
| } | ||
|
|
||
| const shouldAuth = await shouldAddNip98Auth(details); | ||
| if (!shouldAuth) { | ||
| return; | ||
| } | ||
|
|
||
| // Check cache first | ||
| const bodyHash = details.requestBody ? await hashRequestBody(details.requestBody) : ''; | ||
| const cacheKey = `${details.url}:${details.method}:${bodyHash}`; | ||
| const cached = nip98Cache.get(cacheKey); | ||
|
|
||
| let signedEvent; | ||
| if (cached && cached.expires > Date.now()) { | ||
| // Use cached event | ||
| signedEvent = cached.event; | ||
| console.log('[Podkey] Using cached NIP-98 auth event'); | ||
| } else { | ||
| // Create and sign new event | ||
| const event = await createNip98AuthEvent(details); | ||
| const keypair = await getKeypair(); | ||
| signedEvent = await signEvent(event, keypair.privateKey); | ||
|
|
||
| // Cache the signed event | ||
| nip98Cache.set(cacheKey, { | ||
| event: signedEvent, | ||
| expires: Date.now() + CACHE_TTL | ||
| }); | ||
|
|
||
| console.log('[Podkey] Created and signed NIP-98 auth event for', details.url); | ||
| } | ||
|
|
||
| // Encode to Authorization header | ||
| const authHeader = encodeNip98Header(signedEvent); | ||
|
|
||
| // Add or replace Authorization header | ||
| const headers = details.requestHeaders || []; | ||
| const authIndex = headers.findIndex(h => h.name.toLowerCase() === 'authorization'); | ||
|
|
||
| if (authIndex >= 0) { | ||
| headers[authIndex].value = `Nostr ${authHeader}`; | ||
| } else { | ||
| headers.push({ | ||
| name: 'Authorization', | ||
| value: `Nostr ${authHeader}` | ||
| }); | ||
| } | ||
|
|
||
| return { requestHeaders: headers }; | ||
| } catch (error) { | ||
| console.error('[Podkey] Error intercepting request:', error); | ||
| // Don't block the request if auth fails | ||
| return; | ||
| } |
There was a problem hiding this comment.
The async function is registered as a blocking listener, but async functions in blocking webRequest listeners don't properly block the request in Manifest V3. The function returns a Promise, but Chrome's webRequest API in MV3 doesn't wait for async functions to resolve.
The listener needs to be synchronous, or you need to use chrome.declarativeNetRequest instead. As currently written, the request will proceed before the authentication header is added, defeating the purpose of the NIP-98 authentication.
This is a fundamental architectural issue with using async/await in blocking webRequest listeners in Manifest V3.
| async function interceptRequest (details) { | |
| try { | |
| // Only process XMLHttpRequest and fetch requests | |
| if (!['xmlhttprequest', 'main_frame', 'sub_frame'].includes(details.type)) { | |
| return; | |
| } | |
| const shouldAuth = await shouldAddNip98Auth(details); | |
| if (!shouldAuth) { | |
| return; | |
| } | |
| // Check cache first | |
| const bodyHash = details.requestBody ? await hashRequestBody(details.requestBody) : ''; | |
| const cacheKey = `${details.url}:${details.method}:${bodyHash}`; | |
| const cached = nip98Cache.get(cacheKey); | |
| let signedEvent; | |
| if (cached && cached.expires > Date.now()) { | |
| // Use cached event | |
| signedEvent = cached.event; | |
| console.log('[Podkey] Using cached NIP-98 auth event'); | |
| } else { | |
| // Create and sign new event | |
| const event = await createNip98AuthEvent(details); | |
| const keypair = await getKeypair(); | |
| signedEvent = await signEvent(event, keypair.privateKey); | |
| // Cache the signed event | |
| nip98Cache.set(cacheKey, { | |
| event: signedEvent, | |
| expires: Date.now() + CACHE_TTL | |
| }); | |
| console.log('[Podkey] Created and signed NIP-98 auth event for', details.url); | |
| } | |
| // Encode to Authorization header | |
| const authHeader = encodeNip98Header(signedEvent); | |
| // Add or replace Authorization header | |
| const headers = details.requestHeaders || []; | |
| const authIndex = headers.findIndex(h => h.name.toLowerCase() === 'authorization'); | |
| if (authIndex >= 0) { | |
| headers[authIndex].value = `Nostr ${authHeader}`; | |
| } else { | |
| headers.push({ | |
| name: 'Authorization', | |
| value: `Nostr ${authHeader}` | |
| }); | |
| } | |
| return { requestHeaders: headers }; | |
| } catch (error) { | |
| console.error('[Podkey] Error intercepting request:', error); | |
| // Don't block the request if auth fails | |
| return; | |
| } | |
| function interceptRequest (details) { | |
| // NOTE: In Manifest V3, webRequest blocking listeners must be synchronous. | |
| // This wrapper returns immediately, and the async work runs in the background. | |
| (async () => { | |
| try { | |
| // Only process XMLHttpRequest and fetch requests | |
| if (!['xmlhttprequest', 'main_frame', 'sub_frame'].includes(details.type)) { | |
| return; | |
| } | |
| const shouldAuth = await shouldAddNip98Auth(details); | |
| if (!shouldAuth) { | |
| return; | |
| } | |
| // Check cache first | |
| const bodyHash = details.requestBody ? await hashRequestBody(details.requestBody) : ''; | |
| const cacheKey = `${details.url}:${details.method}:${bodyHash}`; | |
| const cached = nip98Cache.get(cacheKey); | |
| let signedEvent; | |
| if (cached && cached.expires > Date.now()) { | |
| // Use cached event | |
| signedEvent = cached.event; | |
| console.log('[Podkey] Using cached NIP-98 auth event'); | |
| } else { | |
| // Create and sign new event | |
| const event = await createNip98AuthEvent(details); | |
| const keypair = await getKeypair(); | |
| signedEvent = await signEvent(event, keypair.privateKey); | |
| // Cache the signed event | |
| nip98Cache.set(cacheKey, { | |
| event: signedEvent, | |
| expires: Date.now() + CACHE_TTL | |
| }); | |
| console.log('[Podkey] Created and signed NIP-98 auth event for', details.url); | |
| } | |
| // Encode to Authorization header | |
| const authHeader = encodeNip98Header(signedEvent); | |
| // Add or replace Authorization header | |
| const headers = details.requestHeaders || []; | |
| const authIndex = headers.findIndex(h => h.name.toLowerCase() === 'authorization'); | |
| if (authIndex >= 0) { | |
| headers[authIndex].value = `Nostr ${authHeader}`; | |
| } else { | |
| headers.push({ | |
| name: 'Authorization', | |
| value: `Nostr ${authHeader}` | |
| }); | |
| } | |
| // In MV3, returning from this async IIFE does not affect the in-flight request. | |
| // The listener itself (this function) has already returned. | |
| // Any future architecture that requires true blocking should use declarativeNetRequest. | |
| } catch (error) { | |
| console.error('[Podkey] Error intercepting request:', error); | |
| // Don't block the request if auth fails | |
| return; | |
| } | |
| })(); |
| const signedEvent = await signEvent(event, keypair.privateKey); | ||
| const authHeader = encodeNip98Header(signedEvent); | ||
|
|
||
| console.log('[Podkey] 401 detected, created NIP-98 auth for retry:', details.url); |
There was a problem hiding this comment.
Unused variable authHeader.
| console.log('[Podkey] 401 detected, created NIP-98 auth for retry:', details.url); | |
| console.log('[Podkey] 401 detected, created NIP-98 auth for retry:', details.url, 'header:', authHeader); |
- Remove blocking webRequest listeners that require webRequestBlocking permission - Implement JavaScript-level interception via fetch/XMLHttpRequest patching - Add CREATE_NIP98_AUTH_HEADER message handler - Intercept fetch and XMLHttpRequest in content script - Handle 401 responses with automatic retry Fixes Chrome extension error about webRequestBlocking permission
Overview
This PR implements automatic NIP-98 HTTP authentication as described in #1. The extension now automatically intercepts HTTP requests and adds NIP-98 Authorization headers for trusted origins when auto-sign is enabled.
Changes
chrome.webRequestlisteners for request interceptionImplementation Details
Request Interception
onBeforeSendHeadersto intercept outgoing requestsAuthorization: Nostr <base64-event>headerNIP-98 Event Creation
utag with full URL (including query params)methodtag with HTTP methodpayloadtag with SHA-256 hash for requests with bodyCaching
401 Detection
onHeadersReceivedTesting
Limitations
401 Retry: Chrome's webRequest API doesn't support automatic request retry. The extension can detect 401s and create auth events, but pages/scripts need to handle the actual retry.
Request Body Access: Some request body formats may not be fully accessible via webRequest API (e.g., streaming bodies).
Related
Closes #1