|
1 | 1 | /** |
2 | 2 | * Podkey - Content Script |
3 | | - * Bridges between injected window.nostr and background script |
| 3 | + * Bridges between injected window.nostr and background script. |
| 4 | + * |
| 5 | + * Fetch/XHR interception for NIP-98 auth headers is handled exclusively |
| 6 | + * by src/nip98-interceptor.js (injected into the page context below). |
| 7 | + * This content script only relays messages between the page and the |
| 8 | + * extension background via CustomEvents. |
4 | 9 | */ |
5 | 10 |
|
6 | | -// CRITICAL: Set up fetch/XHR interception IMMEDIATELY, synchronously |
7 | | -// This must run before ANY other code, including page scripts |
8 | | -(function setupInterceptionImmediately () { |
9 | | - 'use strict'; |
10 | | - |
11 | | - // Store original functions |
12 | | - const originalFetch = window.fetch; |
13 | | - const originalXHROpen = XMLHttpRequest.prototype.open; |
14 | | - const originalXHRSend = XMLHttpRequest.prototype.send; |
15 | | - const originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; |
16 | | - |
17 | | - // Duplicate of src/auth-header-utils.js — keep in sync. The canonical |
18 | | - // module is imported by unit tests; this copy runs in content-script |
19 | | - // context where classic scripts can't use ESM imports. Build-time |
20 | | - // bundling to drop the duplication is tracked in #7. |
21 | | - function hasAuthorizationHeader (headers) { |
22 | | - if (!headers) return false; |
23 | | - if (typeof Headers !== 'undefined' && headers instanceof Headers) { |
24 | | - return headers.has('authorization'); |
25 | | - } |
26 | | - if (Array.isArray(headers)) { |
27 | | - return headers.some((entry) => |
28 | | - Array.isArray(entry) && typeof entry[0] === 'string' && |
29 | | - entry[0].toLowerCase() === 'authorization' |
30 | | - ); |
31 | | - } |
32 | | - if (typeof headers === 'object') { |
33 | | - for (const key of Object.keys(headers)) { |
34 | | - if (key.toLowerCase() === 'authorization') return true; |
35 | | - } |
36 | | - } |
37 | | - return false; |
38 | | - } |
39 | | - |
40 | | - // Per fetch spec, init.headers overrides Request.headers entirely — so |
41 | | - // init.headers is authoritative when present. Only consult input.headers |
42 | | - // when init omits the headers key. |
43 | | - function fetchCallHasAuthorization (input, init) { |
44 | | - if (init && Object.prototype.hasOwnProperty.call(init, 'headers')) { |
45 | | - return hasAuthorizationHeader(init.headers); |
46 | | - } |
47 | | - if (typeof Request !== 'undefined' && input instanceof Request) { |
48 | | - return hasAuthorizationHeader(input.headers); |
49 | | - } |
50 | | - return false; |
51 | | - } |
52 | | - |
53 | | - function setAuthorizationOnOptions (options, value) { |
54 | | - options.headers = options.headers || {}; |
55 | | - if (typeof Headers !== 'undefined' && options.headers instanceof Headers) { |
56 | | - options.headers.set('Authorization', value); |
57 | | - } else if (Array.isArray(options.headers)) { |
58 | | - const normalized = new Headers(options.headers); |
59 | | - normalized.set('Authorization', value); |
60 | | - options.headers = normalized; |
61 | | - } else { |
62 | | - for (const key of Object.keys(options.headers)) { |
63 | | - if (key.toLowerCase() === 'authorization') delete options.headers[key]; |
64 | | - } |
65 | | - options.headers['Authorization'] = value; |
66 | | - } |
67 | | - return options.headers; |
68 | | - } |
69 | | - |
70 | | - function normalizeFetchCall (input, init) { |
71 | | - if (typeof Request !== 'undefined' && input instanceof Request) { |
72 | | - return { |
73 | | - url: input.url, |
74 | | - method: init?.method || input.method || 'GET', |
75 | | - body: init?.body |
76 | | - }; |
77 | | - } |
78 | | - return { |
79 | | - url: typeof input === 'string' ? input : String(input), |
80 | | - method: init?.method || 'GET', |
81 | | - body: init?.body |
82 | | - }; |
83 | | - } |
84 | | - |
85 | | - // Helper to get auth header (will be async, but we'll handle that) |
86 | | - let getAuthHeaderFn = null; |
87 | | - |
88 | | - // Set up the async auth header function (will be defined below) |
89 | | - function setAuthHeaderFn (fn) { |
90 | | - getAuthHeaderFn = fn; |
91 | | - } |
92 | | - |
93 | | - // Intercept fetch - MUST replace immediately to catch all calls |
94 | | - // This runs synchronously, so it catches fetch even if called immediately |
95 | | - window.fetch = function (url, options = {}) { |
96 | | - // Default-param `= {}` only applies for `undefined`; `fetch(url, null)` |
97 | | - // passes null through. Coerce once so the rest of the wrapper can |
98 | | - // assume an object. |
99 | | - options = options || {}; |
100 | | - // Normalize once — handles fetch(url, init) and fetch(new Request(...)) |
101 | | - // so downstream signing sees the real URL/method, not "[object Request]". |
102 | | - const { url: urlString, method, body } = normalizeFetchCall(url, options); |
103 | | - console.log('[Podkey] 🔍 fetch() intercepted:', urlString, method); |
104 | | - |
105 | | - // Respect an Authorization header the page already set — on either |
106 | | - // options.headers or a Request input (e.g. Solid-OIDC DPoP). Overwriting |
107 | | - // would re-identify the request as Podkey's NIP-98 and break the page's |
108 | | - // own auth. If it fails with 401, the retry path below still injects |
109 | | - // NIP-98. See issue #5. |
110 | | - const pageSetAuth = fetchCallHasAuthorization(url, options); |
111 | | - |
112 | | - // If we have the auth function, use it |
113 | | - if (getAuthHeaderFn && !pageSetAuth) { |
114 | | - console.log('[Podkey] ✅ Auth function ready, adding header...'); |
115 | | - const promise = (async () => { |
116 | | - try { |
117 | | - const authHeader = await getAuthHeaderFn(urlString, method, body); |
118 | | - if (authHeader) { |
119 | | - setAuthorizationOnOptions(options, authHeader); |
120 | | - console.log('[Podkey] ✅ Added NIP-98 auth header'); |
121 | | - } else { |
122 | | - console.log('[Podkey] ⚠️ No auth header returned (will retry on 401)'); |
123 | | - } |
124 | | - } catch (e) { |
125 | | - console.error('[Podkey] Error in fetch interceptor:', e); |
126 | | - } |
127 | | - return originalFetch.call(this, url, options); |
128 | | - })(); |
129 | | - |
130 | | - // Handle 401 retry |
131 | | - return promise.then(response => { |
132 | | - if (response.status === 401 && getAuthHeaderFn) { |
133 | | - console.log('[Podkey] 🔄 401 detected, retrying with auth...'); |
134 | | - return (async () => { |
135 | | - try { |
136 | | - const authHeader = await getAuthHeaderFn(urlString, method, body); |
137 | | - if (authHeader) { |
138 | | - const retryOptions = { ...options }; |
139 | | - setAuthorizationOnOptions(retryOptions, authHeader); |
140 | | - console.log('[Podkey] 🔄 Retrying with NIP-98 auth...'); |
141 | | - const retryResponse = await originalFetch.call(this, url, retryOptions); |
142 | | - if (retryResponse.status === 200 || retryResponse.status === 201) { |
143 | | - console.log('[Podkey] ✅✅ NIP-98 auth retry successful!'); |
144 | | - } else { |
145 | | - console.log('[Podkey] ⚠️ Retry still failed:', retryResponse.status); |
146 | | - } |
147 | | - return retryResponse; |
148 | | - } |
149 | | - } catch (e) { |
150 | | - console.error('[Podkey] Error in 401 retry:', e); |
151 | | - } |
152 | | - return response; |
153 | | - })(); |
154 | | - } |
155 | | - return response; |
156 | | - }); |
157 | | - } |
158 | | - |
159 | | - // Fall-through branch: we're here because either the page set its own |
160 | | - // Authorization (and we deliberately skipped initial injection) or the |
161 | | - // auth function isn't wired up yet. Send the request as-is; on 401, |
162 | | - // retry with NIP-98 — either immediately if ready, or after waiting |
163 | | - // for setup (with a hard deadline so we don't hang forever). |
164 | | - if (pageSetAuth) { |
165 | | - console.log('[Podkey] ⏭️ Page already set Authorization — skipping initial injection'); |
166 | | - } else { |
167 | | - console.log('[Podkey] ⚠️ Auth function not ready yet, making request...'); |
168 | | - } |
169 | | - |
170 | | - const requestPromise = originalFetch.call(this, url, options); |
171 | | - |
172 | | - const AUTH_READY_DEADLINE_MS = 5000; |
173 | | - |
174 | | - // If we get a 401 and auth becomes available, retry |
175 | | - return requestPromise.then(response => { |
176 | | - if (response.status === 401) { |
177 | | - console.log('[Podkey] 🔄 Got 401, checking if auth function is ready now...'); |
178 | | - // Wait for auth function to be ready, bounded by deadline. Every |
179 | | - // async step below has an error handler so the outer promise is |
180 | | - // guaranteed to settle (otherwise the caller would hang). |
181 | | - return new Promise((resolve) => { |
182 | | - const deadline = Date.now() + AUTH_READY_DEADLINE_MS; |
183 | | - const checkAuth = () => { |
184 | | - if (getAuthHeaderFn) { |
185 | | - console.log('[Podkey] 🔄 Auth function now ready, retrying with NIP-98...'); |
186 | | - getAuthHeaderFn(urlString, method, body) |
187 | | - .then(authHeader => { |
188 | | - if (authHeader) { |
189 | | - const retryOptions = { ...options }; |
190 | | - setAuthorizationOnOptions(retryOptions, authHeader); |
191 | | - originalFetch.call(this, url, retryOptions) |
192 | | - .then(resolve) |
193 | | - .catch(err => { |
194 | | - console.error('[Podkey] Retry fetch failed:', err); |
195 | | - resolve(response); |
196 | | - }); |
197 | | - } else { |
198 | | - resolve(response); |
199 | | - } |
200 | | - }) |
201 | | - .catch(err => { |
202 | | - console.error('[Podkey] Error getting auth header for retry:', err); |
203 | | - resolve(response); |
204 | | - }); |
205 | | - } else if (Date.now() >= deadline) { |
206 | | - console.log('[Podkey] ⚠️ Auth function still not ready after deadline, giving up'); |
207 | | - resolve(response); |
208 | | - } else { |
209 | | - // Check again in 100ms |
210 | | - setTimeout(checkAuth, 100); |
211 | | - } |
212 | | - }; |
213 | | - checkAuth(); |
214 | | - }); |
215 | | - } |
216 | | - return response; |
217 | | - }); |
218 | | - }; |
219 | | - |
220 | | - // Intercept XMLHttpRequest |
221 | | - XMLHttpRequest.prototype.open = function (method, url, ...args) { |
222 | | - this._podkeyMethod = method; |
223 | | - this._podkeyUrl = url; |
224 | | - this._podkeyHasPageAuth = false; |
225 | | - return originalXHROpen.apply(this, [method, url, ...args]); |
226 | | - }; |
227 | | - |
228 | | - // Track a page-set Authorization on XHR so send() can respect it. |
229 | | - XMLHttpRequest.prototype.setRequestHeader = function (name, value) { |
230 | | - if (typeof name === 'string' && name.toLowerCase() === 'authorization') { |
231 | | - this._podkeyHasPageAuth = true; |
232 | | - } |
233 | | - return originalXHRSetRequestHeader.apply(this, arguments); |
234 | | - }; |
235 | | - |
236 | | - XMLHttpRequest.prototype.send = function (body) { |
237 | | - // setRequestHeader must run before send(); defer originalXHRSend until |
238 | | - // the header is applied (or skipped), else the async setRequestHeader |
239 | | - // would fire after the request is already in-flight and throw |
240 | | - // InvalidStateError. |
241 | | - const runSend = () => originalXHRSend.apply(this, [body]); |
242 | | - if (getAuthHeaderFn && this._podkeyUrl && !this._podkeyHasPageAuth) { |
243 | | - getAuthHeaderFn(this._podkeyUrl, this._podkeyMethod, body) |
244 | | - .then(authHeader => { |
245 | | - if (authHeader) { |
246 | | - originalXHRSetRequestHeader.call(this, 'Authorization', authHeader); |
247 | | - } |
248 | | - }) |
249 | | - .catch(e => { |
250 | | - console.error('[Podkey] Error in XHR interceptor:', e); |
251 | | - }) |
252 | | - .finally(runSend); |
253 | | - } else { |
254 | | - runSend(); |
255 | | - } |
256 | | - }; |
257 | | - |
258 | | - // Expose setter for the auth function |
259 | | - window.__podkey_setAuthFn = setAuthHeaderFn; |
260 | | - |
261 | | - console.log('[Podkey] ✅ Synchronous interception setup complete'); |
262 | | -})(); |
263 | | - |
264 | 11 | // Inject NIP-98 interceptor FIRST (must run before any page code) |
265 | 12 | const interceptorScript = document.createElement('script'); |
266 | 13 | interceptorScript.src = chrome.runtime.getURL('src/nip98-interceptor.js'); |
@@ -312,9 +59,12 @@ script.src = chrome.runtime.getURL('src/nostr-provider.js'); |
312 | 59 | script.onload = function () { |
313 | 60 | this.remove(); |
314 | 61 | }; |
| 62 | +script.onerror = function () { |
| 63 | + console.error('[Podkey] Failed to load nostr-provider.js'); |
| 64 | +}; |
315 | 65 | (document.head || document.documentElement).appendChild(script); |
316 | 66 |
|
317 | | -// Listen for requests from the injected script |
| 67 | +// Listen for requests from the injected script (NIP-07 relay) |
318 | 68 | window.addEventListener('podkey-request', async (event) => { |
319 | 69 | const { id, type, ...data } = event.detail; |
320 | 70 |
|
@@ -370,53 +120,4 @@ window.addEventListener('podkey-request', async (event) => { |
370 | 120 | } |
371 | 121 | }); |
372 | 122 |
|
373 | | -// NIP-98 auto-auth: Set up the async auth header function |
374 | | -// This connects to the synchronous interceptor above |
375 | | -(function setupAuthFunction () { |
376 | | - console.log('[Podkey] Setting up NIP-98 auth function...'); |
377 | | - |
378 | | - async function getNip98AuthHeader (url, method, body) { |
379 | | - try { |
380 | | - const urlString = typeof url === 'string' ? url : url.toString(); |
381 | | - console.log('[Podkey] Requesting NIP-98 auth header for:', urlString, method); |
382 | | - |
383 | | - const response = await chrome.runtime.sendMessage({ |
384 | | - type: 'CREATE_NIP98_AUTH_HEADER', |
385 | | - url: urlString, |
386 | | - method: method || 'GET', |
387 | | - body: body |
388 | | - }); |
389 | | - |
390 | | - if (chrome.runtime.lastError) { |
391 | | - console.error('[Podkey] Error from background script:', chrome.runtime.lastError.message); |
392 | | - return null; |
393 | | - } |
394 | | - |
395 | | - if (response) { |
396 | | - console.log('[Podkey] ✅ Got NIP-98 auth header'); |
397 | | - } else { |
398 | | - console.log('[Podkey] ❌ No NIP-98 auth header (origin not trusted, auto-sign disabled, or no keypair)'); |
399 | | - } |
400 | | - |
401 | | - return response || null; |
402 | | - } catch (error) { |
403 | | - console.error('[Podkey] Error getting NIP-98 auth header:', error); |
404 | | - return null; |
405 | | - } |
406 | | - } |
407 | | - |
408 | | - // Connect the auth function to the synchronous interceptor |
409 | | - if (window.__podkey_setAuthFn) { |
410 | | - window.__podkey_setAuthFn(getNip98AuthHeader); |
411 | | - console.log('[Podkey] ✅ Auth function connected'); |
412 | | - } else { |
413 | | - console.error('[Podkey] ❌ Could not connect auth function - interceptor not ready'); |
414 | | - } |
415 | | -})(); |
416 | | - |
417 | | -console.log('[Podkey] Content script loaded with NIP-98 auto-auth interception'); |
418 | | - |
419 | | -// Error handling for script injection |
420 | | -script.onerror = function () { |
421 | | - console.error('[Podkey] Failed to load nostr-provider.js'); |
422 | | -}; |
| 123 | +console.log('[Podkey] Content script loaded'); |
0 commit comments