Skip to content

Commit b96ae87

Browse files
Don't overwrite existing Authorization headers (#5)
Podkey's fetch/XHR interceptors unconditionally set Authorization on every outbound request, replacing whatever the page already put there. Pages using Solid-OIDC (Authorization: DPoP <token>) have their auth silently swapped for Podkey's NIP-98 signature — the server then authenticates as did:nostr:<podkey-key>, doesn't match the user's real WebID, and denies access. User gets 403 on their own pod. Fix: before injecting NIP-98, check whether the page already set Authorization. If yes, step aside and let the page authenticate itself. If that auth gets a 401, the existing retry-on-401 path still tries NIP-98 as a fallback. Covers both fetch (options.headers across Headers/object/array shapes) and XHR (via a setRequestHeader override that tags the request). New hasAuthorizationHeader helper is canonical in src/auth-header-utils.js with unit tests. The in-page scripts duplicate it because they load as classic scripts without ESM imports.
1 parent abe31b2 commit b96ae87

4 files changed

Lines changed: 535 additions & 69 deletions

File tree

src/auth-header-utils.js

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* Utility: detect whether a fetch-options / headers shape already carries
3+
* an Authorization header. Podkey's interceptors must not overwrite an
4+
* Authorization header the page already set (e.g., Solid-OIDC DPoP),
5+
* otherwise the request ends up authenticated as Podkey's NIP-98 identity
6+
* and ACLs reject the real user (see issue #5).
7+
*
8+
* Handles all three shapes `options.headers` can take per the fetch spec:
9+
* - `Headers` instance
10+
* - plain object { 'Authorization': '...' }
11+
* - array of [name, value] tuples
12+
*/
13+
export function hasAuthorizationHeader (headers) {
14+
if (!headers) return false;
15+
16+
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
17+
return headers.has('authorization');
18+
}
19+
20+
if (Array.isArray(headers)) {
21+
return headers.some((entry) =>
22+
Array.isArray(entry) && typeof entry[0] === 'string' &&
23+
entry[0].toLowerCase() === 'authorization'
24+
);
25+
}
26+
27+
if (typeof headers === 'object') {
28+
for (const key of Object.keys(headers)) {
29+
if (key.toLowerCase() === 'authorization') return true;
30+
}
31+
}
32+
33+
return false;
34+
}
35+
36+
/**
37+
* Detect whether a `fetch(input, init)` call already carries an Authorization
38+
* header anywhere — either on `init.headers` or on the input when it is a
39+
* `Request` object.
40+
*
41+
* Per the fetch spec, when `input` is a `Request` and `init.headers` is
42+
* supplied, `init.headers` overrides the Request's headers entirely.
43+
* Therefore `init.headers` is authoritative when present, and
44+
* `input.headers` is only consulted when `init.headers` is absent.
45+
*/
46+
export function fetchCallHasAuthorization (input, init) {
47+
if (init && Object.prototype.hasOwnProperty.call(init, 'headers')) {
48+
return hasAuthorizationHeader(init.headers);
49+
}
50+
if (typeof Request !== 'undefined' && input instanceof Request) {
51+
return hasAuthorizationHeader(input.headers);
52+
}
53+
return false;
54+
}
55+
56+
/**
57+
* Extract `{ url, method, body }` from a `fetch(input, init)` call so
58+
* downstream signers (NIP-98) see the actual URL/method regardless of
59+
* whether the caller used `fetch(url, init)` or `fetch(new Request(...))`.
60+
*
61+
* Per the fetch spec, when input is a Request and init supplies a method,
62+
* init's method wins. Body is read from init when provided; we do not read
63+
* from Request.body here because that consumes the stream — callers that
64+
* need body-hashing for Request inputs should clone before signing.
65+
*/
66+
export function normalizeFetchCall (input, init) {
67+
if (typeof Request !== 'undefined' && input instanceof Request) {
68+
return {
69+
url: input.url,
70+
method: init?.method || input.method || 'GET',
71+
body: init?.body
72+
};
73+
}
74+
return {
75+
url: typeof input === 'string' ? input : String(input),
76+
method: init?.method || 'GET',
77+
body: init?.body
78+
};
79+
}
80+
81+
/**
82+
* Set an Authorization header on `options.headers`, normalizing any of the
83+
* three supported shapes so the assignment actually takes effect:
84+
* - `Headers` instance → `.set('Authorization', value)`
85+
* - plain object → `headers.Authorization = value`
86+
* - array of tuples → normalized to `Headers` (so the array shape
87+
* doesn't silently swallow the injection)
88+
* Mutates `options` in place and returns the updated headers for clarity.
89+
*/
90+
export function setAuthorizationOnOptions (options, value) {
91+
options.headers = options.headers || {};
92+
if (typeof Headers !== 'undefined' && options.headers instanceof Headers) {
93+
options.headers.set('Authorization', value);
94+
} else if (Array.isArray(options.headers)) {
95+
// Assigning an 'Authorization' property to an array would not add a
96+
// header, so normalize to `Headers` first.
97+
const normalized = new Headers(options.headers);
98+
normalized.set('Authorization', value);
99+
options.headers = normalized;
100+
} else {
101+
// Delete any existing case-insensitive match before setting the new
102+
// value; otherwise fetch may see both keys and merge them into a
103+
// "DPoP …, Nostr …" comma-joined header.
104+
for (const key of Object.keys(options.headers)) {
105+
if (key.toLowerCase() === 'authorization') delete options.headers[key];
106+
}
107+
options.headers['Authorization'] = value;
108+
}
109+
return options.headers;
110+
}

src/injected.js

Lines changed: 145 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,75 @@
1212
const originalFetch = window.fetch;
1313
const originalXHROpen = XMLHttpRequest.prototype.open;
1414
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+
}
1584

1685
// Helper to get auth header (will be async, but we'll handle that)
1786
let getAuthHeaderFn = null;
@@ -24,26 +93,28 @@
2493
// Intercept fetch - MUST replace immediately to catch all calls
2594
// This runs synchronously, so it catches fetch even if called immediately
2695
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);
2999
console.log('[Podkey] 🔍 fetch() intercepted:', urlString, method);
30100

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+
31108
// If we have the auth function, use it
32-
if (authFunctionReady && getAuthHeaderFn) {
109+
if (getAuthHeaderFn && !pageSetAuth) {
33110
console.log('[Podkey] ✅ Auth function ready, adding header...');
34111
const promise = (async () => {
35112
try {
36-
const authHeader = await getAuthHeaderFn(url, options.method || 'GET', options.body);
113+
const authHeader = await getAuthHeaderFn(urlString, method, body);
37114
if (authHeader) {
38115
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');
47118
} else {
48119
console.log('[Podkey] ⚠️ No auth header returned (will retry on 401)');
49120
}
@@ -59,15 +130,10 @@
59130
console.log('[Podkey] 🔄 401 detected, retrying with auth...');
60131
return (async () => {
61132
try {
62-
const authHeader = await getAuthHeaderFn(url, options.method || 'GET', options.body);
133+
const authHeader = await getAuthHeaderFn(urlString, method, body);
63134
if (authHeader) {
64135
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);
71137
console.log('[Podkey] 🔄 Retrying with NIP-98 auth...');
72138
const retryResponse = await originalFetch.call(this, url, retryOptions);
73139
if (retryResponse.status === 200 || retryResponse.status === 201) {
@@ -87,35 +153,55 @@
87153
});
88154
}
89155

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+
}
92166

93-
// Even if not ready, try to get auth header asynchronously and retry on 401
94167
const requestPromise = originalFetch.call(this, url, options);
95168

169+
const AUTH_READY_DEADLINE_MS = 5000;
170+
96171
// If we get a 401 and auth becomes available, retry
97172
return requestPromise.then(response => {
98173
if (response.status === 401) {
99174
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).
101178
return new Promise((resolve) => {
179+
const deadline = Date.now() + AUTH_READY_DEADLINE_MS;
102180
const checkAuth = () => {
103-
if (authFunctionReady && getAuthHeaderFn) {
181+
if (getAuthHeaderFn) {
104182
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+
});
111194
} else {
112-
retryOptions.headers['Authorization'] = authHeader;
195+
resolve(response);
113196
}
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);
116200
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);
119205
} else {
120206
// Check again in 100ms
121207
setTimeout(checkAuth, 100);
@@ -132,23 +218,38 @@
132218
XMLHttpRequest.prototype.open = function (method, url, ...args) {
133219
this._podkeyMethod = method;
134220
this._podkeyUrl = url;
221+
this._podkeyHasPageAuth = false;
135222
return originalXHROpen.apply(this, [method, url, ...args]);
136223
};
137224

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+
138233
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 => {
143242
if (authHeader) {
144-
this.setRequestHeader('Authorization', authHeader);
243+
originalXHRSetRequestHeader.call(this, 'Authorization', authHeader);
145244
}
146-
} catch (e) {
245+
})
246+
.catch(e => {
147247
console.error('[Podkey] Error in XHR interceptor:', e);
148-
}
149-
})();
248+
})
249+
.finally(runSend);
250+
} else {
251+
runSend();
150252
}
151-
return originalXHRSend.apply(this, [body]);
152253
};
153254

154255
// Expose setter for the auth function

0 commit comments

Comments
 (0)