|
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. |
101 | 171 | return new Promise((resolve) => { |
| 172 | + const deadline = Date.now() + AUTH_READY_DEADLINE_MS; |
102 | 173 | const checkAuth = () => { |
103 | | - if (authFunctionReady && getAuthHeaderFn) { |
| 174 | + if (getAuthHeaderFn) { |
104 | 175 | console.log('[Podkey] 🔄 Auth function now ready, retrying with NIP-98...'); |
105 | | - getAuthHeaderFn(url, method, options?.body).then(authHeader => { |
| 176 | + getAuthHeaderFn(urlString, method, body).then(authHeader => { |
106 | 177 | if (authHeader) { |
107 | 178 | const retryOptions = { ...options }; |
108 | | - retryOptions.headers = retryOptions.headers || {}; |
109 | | - if (retryOptions.headers instanceof Headers) { |
110 | | - retryOptions.headers.set('Authorization', authHeader); |
111 | | - } else { |
112 | | - retryOptions.headers['Authorization'] = authHeader; |
113 | | - } |
| 179 | + setAuthorizationOnOptions(retryOptions, authHeader); |
114 | 180 | originalFetch.call(this, url, retryOptions).then(resolve); |
115 | 181 | } else { |
116 | 182 | resolve(response); |
117 | 183 | } |
118 | 184 | }); |
| 185 | + } else if (Date.now() >= deadline) { |
| 186 | + console.log('[Podkey] ⚠️ Auth function still not ready after deadline, giving up'); |
| 187 | + resolve(response); |
119 | 188 | } else { |
120 | 189 | // Check again in 100ms |
121 | 190 | setTimeout(checkAuth, 100); |
|
132 | 201 | XMLHttpRequest.prototype.open = function (method, url, ...args) { |
133 | 202 | this._podkeyMethod = method; |
134 | 203 | this._podkeyUrl = url; |
| 204 | + this._podkeyHasPageAuth = false; |
135 | 205 | return originalXHROpen.apply(this, [method, url, ...args]); |
136 | 206 | }; |
137 | 207 |
|
| 208 | + // Track a page-set Authorization on XHR so send() can respect it. |
| 209 | + XMLHttpRequest.prototype.setRequestHeader = function (name, value) { |
| 210 | + if (typeof name === 'string' && name.toLowerCase() === 'authorization') { |
| 211 | + this._podkeyHasPageAuth = true; |
| 212 | + } |
| 213 | + return originalXHRSetRequestHeader.apply(this, arguments); |
| 214 | + }; |
| 215 | + |
138 | 216 | XMLHttpRequest.prototype.send = function (body) { |
139 | | - if (getAuthHeaderFn && this._podkeyUrl) { |
| 217 | + if (getAuthHeaderFn && this._podkeyUrl && !this._podkeyHasPageAuth) { |
140 | 218 | (async () => { |
141 | 219 | try { |
142 | 220 | const authHeader = await getAuthHeaderFn(this._podkeyUrl, this._podkeyMethod, body); |
143 | 221 | if (authHeader) { |
144 | | - this.setRequestHeader('Authorization', authHeader); |
| 222 | + originalXHRSetRequestHeader.call(this, 'Authorization', authHeader); |
145 | 223 | } |
146 | 224 | } catch (e) { |
147 | 225 | console.error('[Podkey] Error in XHR interceptor:', e); |
|
0 commit comments