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