|
12 | 12 | const originalFetch = window.fetch; |
13 | 13 | const originalXHROpen = XMLHttpRequest.prototype.open; |
14 | 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 | + } |
15 | 84 |
|
16 | 85 | // Helper to get auth header (will be async, but we'll handle that) |
17 | 86 | let getAuthHeaderFn = null; |
|
24 | 93 | // Intercept fetch - MUST replace immediately to catch all calls |
25 | 94 | // This runs synchronously, so it catches fetch even if called immediately |
26 | 95 | window.fetch = function (url, options = {}) { |
27 | | - const urlString = typeof url === 'string' ? url : url.toString(); |
28 | | - const method = options?.method || 'GET'; |
| 96 | + // Normalize once — handles fetch(url, init) and fetch(new Request(...)) |
| 97 | + // so downstream signing sees the real URL/method, not "[object Request]". |
| 98 | + const { url: urlString, method, body } = normalizeFetchCall(url, options); |
29 | 99 | console.log('[Podkey] 🔍 fetch() intercepted:', urlString, method); |
30 | 100 |
|
| 101 | + // Respect an Authorization header the page already set — on either |
| 102 | + // options.headers or a Request input (e.g. Solid-OIDC DPoP). Overwriting |
| 103 | + // would re-identify the request as Podkey's NIP-98 and break the page's |
| 104 | + // own auth. If it fails with 401, the retry path below still injects |
| 105 | + // NIP-98. See issue #5. |
| 106 | + const pageSetAuth = fetchCallHasAuthorization(url, options); |
| 107 | + |
31 | 108 | // If we have the auth function, use it |
32 | | - if (authFunctionReady && getAuthHeaderFn) { |
| 109 | + if (getAuthHeaderFn && !pageSetAuth) { |
33 | 110 | console.log('[Podkey] ✅ Auth function ready, adding header...'); |
34 | 111 | const promise = (async () => { |
35 | 112 | try { |
36 | | - const authHeader = await getAuthHeaderFn(url, options.method || 'GET', options.body); |
| 113 | + const authHeader = await getAuthHeaderFn(urlString, method, body); |
37 | 114 | if (authHeader) { |
38 | 115 | options = options || {}; |
39 | | - options.headers = options.headers || {}; |
40 | | - if (options.headers instanceof Headers) { |
41 | | - options.headers.set('Authorization', authHeader); |
42 | | - console.log('[Podkey] ✅ Added NIP-98 auth header (Headers)'); |
43 | | - } else { |
44 | | - options.headers['Authorization'] = authHeader; |
45 | | - console.log('[Podkey] ✅ Added NIP-98 auth header (object)'); |
46 | | - } |
| 116 | + setAuthorizationOnOptions(options, authHeader); |
| 117 | + console.log('[Podkey] ✅ Added NIP-98 auth header'); |
47 | 118 | } else { |
48 | 119 | console.log('[Podkey] ⚠️ No auth header returned (will retry on 401)'); |
49 | 120 | } |
|
59 | 130 | console.log('[Podkey] 🔄 401 detected, retrying with auth...'); |
60 | 131 | return (async () => { |
61 | 132 | try { |
62 | | - const authHeader = await getAuthHeaderFn(url, options.method || 'GET', options.body); |
| 133 | + const authHeader = await getAuthHeaderFn(urlString, method, body); |
63 | 134 | if (authHeader) { |
64 | 135 | const retryOptions = { ...options }; |
65 | | - retryOptions.headers = retryOptions.headers || {}; |
66 | | - if (retryOptions.headers instanceof Headers) { |
67 | | - retryOptions.headers.set('Authorization', authHeader); |
68 | | - } else { |
69 | | - retryOptions.headers['Authorization'] = authHeader; |
70 | | - } |
| 136 | + setAuthorizationOnOptions(retryOptions, authHeader); |
71 | 137 | console.log('[Podkey] 🔄 Retrying with NIP-98 auth...'); |
72 | 138 | const retryResponse = await originalFetch.call(this, url, retryOptions); |
73 | 139 | if (retryResponse.status === 200 || retryResponse.status === 201) { |
|
87 | 153 | }); |
88 | 154 | } |
89 | 155 |
|
90 | | - // Fallback if auth function not ready yet - but still try to get auth |
91 | | - console.log('[Podkey] ⚠️ Auth function not ready yet, making request...'); |
| 156 | + // Fall-through branch: we're here because either the page set its own |
| 157 | + // Authorization (and we deliberately skipped initial injection) or the |
| 158 | + // auth function isn't wired up yet. Send the request as-is; on 401, |
| 159 | + // retry with NIP-98 — either immediately if ready, or after waiting |
| 160 | + // for setup (with a hard deadline so we don't hang forever). |
| 161 | + if (pageSetAuth) { |
| 162 | + console.log('[Podkey] ⏭️ Page already set Authorization — skipping initial injection'); |
| 163 | + } else { |
| 164 | + console.log('[Podkey] ⚠️ Auth function not ready yet, making request...'); |
| 165 | + } |
92 | 166 |
|
93 | | - // Even if not ready, try to get auth header asynchronously and retry on 401 |
94 | 167 | const requestPromise = originalFetch.call(this, url, options); |
95 | 168 |
|
| 169 | + const AUTH_READY_DEADLINE_MS = 5000; |
| 170 | + |
96 | 171 | // If we get a 401 and auth becomes available, retry |
97 | 172 | return requestPromise.then(response => { |
98 | 173 | if (response.status === 401) { |
99 | 174 | console.log('[Podkey] 🔄 Got 401, checking if auth function is ready now...'); |
100 | | - // Wait a bit for auth function to be ready, then retry |
| 175 | + // Wait for auth function to be ready, bounded by deadline. Every |
| 176 | + // async step below has an error handler so the outer promise is |
| 177 | + // guaranteed to settle (otherwise the caller would hang). |
101 | 178 | return new Promise((resolve) => { |
| 179 | + const deadline = Date.now() + AUTH_READY_DEADLINE_MS; |
102 | 180 | const checkAuth = () => { |
103 | | - if (authFunctionReady && getAuthHeaderFn) { |
| 181 | + if (getAuthHeaderFn) { |
104 | 182 | console.log('[Podkey] 🔄 Auth function now ready, retrying with NIP-98...'); |
105 | | - getAuthHeaderFn(url, method, options?.body).then(authHeader => { |
106 | | - if (authHeader) { |
107 | | - const retryOptions = { ...options }; |
108 | | - retryOptions.headers = retryOptions.headers || {}; |
109 | | - if (retryOptions.headers instanceof Headers) { |
110 | | - retryOptions.headers.set('Authorization', authHeader); |
| 183 | + getAuthHeaderFn(urlString, method, body) |
| 184 | + .then(authHeader => { |
| 185 | + if (authHeader) { |
| 186 | + const retryOptions = { ...options }; |
| 187 | + setAuthorizationOnOptions(retryOptions, authHeader); |
| 188 | + originalFetch.call(this, url, retryOptions) |
| 189 | + .then(resolve) |
| 190 | + .catch(err => { |
| 191 | + console.error('[Podkey] Retry fetch failed:', err); |
| 192 | + resolve(response); |
| 193 | + }); |
111 | 194 | } else { |
112 | | - retryOptions.headers['Authorization'] = authHeader; |
| 195 | + resolve(response); |
113 | 196 | } |
114 | | - originalFetch.call(this, url, retryOptions).then(resolve); |
115 | | - } else { |
| 197 | + }) |
| 198 | + .catch(err => { |
| 199 | + console.error('[Podkey] Error getting auth header for retry:', err); |
116 | 200 | resolve(response); |
117 | | - } |
118 | | - }); |
| 201 | + }); |
| 202 | + } else if (Date.now() >= deadline) { |
| 203 | + console.log('[Podkey] ⚠️ Auth function still not ready after deadline, giving up'); |
| 204 | + resolve(response); |
119 | 205 | } else { |
120 | 206 | // Check again in 100ms |
121 | 207 | setTimeout(checkAuth, 100); |
|
132 | 218 | XMLHttpRequest.prototype.open = function (method, url, ...args) { |
133 | 219 | this._podkeyMethod = method; |
134 | 220 | this._podkeyUrl = url; |
| 221 | + this._podkeyHasPageAuth = false; |
135 | 222 | return originalXHROpen.apply(this, [method, url, ...args]); |
136 | 223 | }; |
137 | 224 |
|
| 225 | + // Track a page-set Authorization on XHR so send() can respect it. |
| 226 | + XMLHttpRequest.prototype.setRequestHeader = function (name, value) { |
| 227 | + if (typeof name === 'string' && name.toLowerCase() === 'authorization') { |
| 228 | + this._podkeyHasPageAuth = true; |
| 229 | + } |
| 230 | + return originalXHRSetRequestHeader.apply(this, arguments); |
| 231 | + }; |
| 232 | + |
138 | 233 | XMLHttpRequest.prototype.send = function (body) { |
139 | | - if (getAuthHeaderFn && this._podkeyUrl) { |
140 | | - (async () => { |
141 | | - try { |
142 | | - const authHeader = await getAuthHeaderFn(this._podkeyUrl, this._podkeyMethod, body); |
| 234 | + // setRequestHeader must run before send(); defer originalXHRSend until |
| 235 | + // the header is applied (or skipped), else the async setRequestHeader |
| 236 | + // would fire after the request is already in-flight and throw |
| 237 | + // InvalidStateError. |
| 238 | + const runSend = () => originalXHRSend.apply(this, [body]); |
| 239 | + if (getAuthHeaderFn && this._podkeyUrl && !this._podkeyHasPageAuth) { |
| 240 | + getAuthHeaderFn(this._podkeyUrl, this._podkeyMethod, body) |
| 241 | + .then(authHeader => { |
143 | 242 | if (authHeader) { |
144 | | - this.setRequestHeader('Authorization', authHeader); |
| 243 | + originalXHRSetRequestHeader.call(this, 'Authorization', authHeader); |
145 | 244 | } |
146 | | - } catch (e) { |
| 245 | + }) |
| 246 | + .catch(e => { |
147 | 247 | console.error('[Podkey] Error in XHR interceptor:', e); |
148 | | - } |
149 | | - })(); |
| 248 | + }) |
| 249 | + .finally(runSend); |
| 250 | + } else { |
| 251 | + runSend(); |
150 | 252 | } |
151 | | - return originalXHRSend.apply(this, [body]); |
152 | 253 | }; |
153 | 254 |
|
154 | 255 | // Expose setter for the auth function |
|
0 commit comments