Skip to content

Commit b5cd75d

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 b5cd75d

4 files changed

Lines changed: 497 additions & 69 deletions

File tree

src/auth-header-utils.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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+
export function fetchCallHasAuthorization(input, init) {
42+
if (hasAuthorizationHeader(init?.headers)) return true;
43+
if (typeof Request !== 'undefined' && input instanceof Request) {
44+
return hasAuthorizationHeader(input.headers);
45+
}
46+
return false;
47+
}
48+
49+
/**
50+
* Extract `{ url, method, body }` from a `fetch(input, init)` call so
51+
* downstream signers (NIP-98) see the actual URL/method regardless of
52+
* whether the caller used `fetch(url, init)` or `fetch(new Request(...))`.
53+
*
54+
* Per the fetch spec, when input is a Request and init supplies a method,
55+
* init's method wins. Body is read from init when provided; we do not read
56+
* from Request.body here because that consumes the stream — callers that
57+
* need body-hashing for Request inputs should clone before signing.
58+
*/
59+
export function normalizeFetchCall(input, init) {
60+
if (typeof Request !== 'undefined' && input instanceof Request) {
61+
return {
62+
url: input.url,
63+
method: init?.method || input.method || 'GET',
64+
body: init?.body
65+
};
66+
}
67+
return {
68+
url: typeof input === 'string' ? input : String(input),
69+
method: init?.method || 'GET',
70+
body: init?.body
71+
};
72+
}
73+
74+
/**
75+
* Set an Authorization header on `options.headers`, normalizing any of the
76+
* three supported shapes so the assignment actually takes effect:
77+
* - `Headers` instance → `.set('Authorization', value)`
78+
* - plain object → `headers.Authorization = value`
79+
* - array of tuples → normalized to `Headers` (so the array shape
80+
* doesn't silently swallow the injection)
81+
* Mutates `options` in place and returns the updated headers for clarity.
82+
*/
83+
export function setAuthorizationOnOptions(options, value) {
84+
options.headers = options.headers || {};
85+
if (typeof Headers !== 'undefined' && options.headers instanceof Headers) {
86+
options.headers.set('Authorization', value);
87+
} else if (Array.isArray(options.headers)) {
88+
// Assigning an 'Authorization' property to an array would not add a
89+
// header, so normalize to `Headers` first.
90+
const normalized = new Headers(options.headers);
91+
normalized.set('Authorization', value);
92+
options.headers = normalized;
93+
} else {
94+
// Delete any existing case-insensitive match before setting the new
95+
// value; otherwise fetch may see both keys and merge them into a
96+
// "DPoP …, Nostr …" comma-joined header.
97+
for (const key of Object.keys(options.headers)) {
98+
if (key.toLowerCase() === 'authorization') delete options.headers[key];
99+
}
100+
options.headers['Authorization'] = value;
101+
}
102+
return options.headers;
103+
}

src/injected.js

Lines changed: 140 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,70 @@
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+
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+
}
1579

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

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+
31103
// If we have the auth function, use it
32-
if (authFunctionReady && getAuthHeaderFn) {
104+
if (getAuthHeaderFn && !pageSetAuth) {
33105
console.log('[Podkey] ✅ Auth function ready, adding header...');
34106
const promise = (async () => {
35107
try {
36-
const authHeader = await getAuthHeaderFn(url, options.method || 'GET', options.body);
108+
const authHeader = await getAuthHeaderFn(urlString, method, body);
37109
if (authHeader) {
38110
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');
47113
} else {
48114
console.log('[Podkey] ⚠️ No auth header returned (will retry on 401)');
49115
}
@@ -59,15 +125,10 @@
59125
console.log('[Podkey] 🔄 401 detected, retrying with auth...');
60126
return (async () => {
61127
try {
62-
const authHeader = await getAuthHeaderFn(url, options.method || 'GET', options.body);
128+
const authHeader = await getAuthHeaderFn(urlString, method, body);
63129
if (authHeader) {
64130
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);
71132
console.log('[Podkey] 🔄 Retrying with NIP-98 auth...');
72133
const retryResponse = await originalFetch.call(this, url, retryOptions);
73134
if (retryResponse.status === 200 || retryResponse.status === 201) {
@@ -87,35 +148,55 @@
87148
});
88149
}
89150

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

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

164+
const AUTH_READY_DEADLINE_MS = 5000;
165+
96166
// If we get a 401 and auth becomes available, retry
97167
return requestPromise.then(response => {
98168
if (response.status === 401) {
99169
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).
101173
return new Promise((resolve) => {
174+
const deadline = Date.now() + AUTH_READY_DEADLINE_MS;
102175
const checkAuth = () => {
103-
if (authFunctionReady && getAuthHeaderFn) {
176+
if (getAuthHeaderFn) {
104177
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+
});
111189
} else {
112-
retryOptions.headers['Authorization'] = authHeader;
190+
resolve(response);
113191
}
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);
116195
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);
119200
} else {
120201
// Check again in 100ms
121202
setTimeout(checkAuth, 100);
@@ -132,23 +213,38 @@
132213
XMLHttpRequest.prototype.open = function (method, url, ...args) {
133214
this._podkeyMethod = method;
134215
this._podkeyUrl = url;
216+
this._podkeyHasPageAuth = false;
135217
return originalXHROpen.apply(this, [method, url, ...args]);
136218
};
137219

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+
138228
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 => {
143237
if (authHeader) {
144-
this.setRequestHeader('Authorization', authHeader);
238+
originalXHRSetRequestHeader.call(this, 'Authorization', authHeader);
145239
}
146-
} catch (e) {
240+
})
241+
.catch(e => {
147242
console.error('[Podkey] Error in XHR interceptor:', e);
148-
}
149-
})();
243+
})
244+
.finally(runSend);
245+
} else {
246+
runSend();
150247
}
151-
return originalXHRSend.apply(this, [body]);
152248
};
153249

154250
// Expose setter for the auth function

0 commit comments

Comments
 (0)