Skip to content

Commit 86114f3

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 86114f3

4 files changed

Lines changed: 333 additions & 45 deletions

File tree

src/auth-header-utils.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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+
* Set an Authorization header on `options.headers`, normalizing any of the
51+
* three supported shapes so the assignment actually takes effect:
52+
* - `Headers` instance → `.set('Authorization', value)`
53+
* - plain object → `headers.Authorization = value`
54+
* - array of tuples → normalized to `Headers` (so the array shape
55+
* doesn't silently swallow the injection)
56+
* Mutates `options` in place and returns the updated headers for clarity.
57+
*/
58+
export function setAuthorizationOnOptions(options, value) {
59+
options.headers = options.headers || {};
60+
if (typeof Headers !== 'undefined' && options.headers instanceof Headers) {
61+
options.headers.set('Authorization', value);
62+
} else if (Array.isArray(options.headers)) {
63+
// Assigning an 'Authorization' property to an array would not add a
64+
// header, so normalize to `Headers` first.
65+
const normalized = new Headers(options.headers);
66+
normalized.set('Authorization', value);
67+
options.headers = normalized;
68+
} else {
69+
options.headers['Authorization'] = value;
70+
}
71+
return options.headers;
72+
}

src/injected.js

Lines changed: 68 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,51 @@
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 page context where
19+
// classic scripts can't use ESM imports.
20+
function hasAuthorizationHeader (headers) {
21+
if (!headers) return false;
22+
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
23+
return headers.has('authorization');
24+
}
25+
if (Array.isArray(headers)) {
26+
return headers.some((entry) =>
27+
Array.isArray(entry) && typeof entry[0] === 'string' &&
28+
entry[0].toLowerCase() === 'authorization'
29+
);
30+
}
31+
if (typeof headers === 'object') {
32+
for (const key of Object.keys(headers)) {
33+
if (key.toLowerCase() === 'authorization') return true;
34+
}
35+
}
36+
return false;
37+
}
38+
39+
function fetchCallHasAuthorization (input, init) {
40+
if (hasAuthorizationHeader(init?.headers)) return true;
41+
if (typeof Request !== 'undefined' && input instanceof Request) {
42+
return hasAuthorizationHeader(input.headers);
43+
}
44+
return false;
45+
}
46+
47+
function setAuthorizationOnOptions (options, value) {
48+
options.headers = options.headers || {};
49+
if (typeof Headers !== 'undefined' && options.headers instanceof Headers) {
50+
options.headers.set('Authorization', value);
51+
} else if (Array.isArray(options.headers)) {
52+
const normalized = new Headers(options.headers);
53+
normalized.set('Authorization', value);
54+
options.headers = normalized;
55+
} else {
56+
options.headers['Authorization'] = value;
57+
}
58+
return options.headers;
59+
}
1560

1661
// Helper to get auth header (will be async, but we'll handle that)
1762
let getAuthHeaderFn = null;
@@ -28,22 +73,23 @@
2873
const method = options?.method || 'GET';
2974
console.log('[Podkey] 🔍 fetch() intercepted:', urlString, method);
3075

76+
// Respect an Authorization header the page already set — on either
77+
// options.headers or a Request input (e.g. Solid-OIDC DPoP). Overwriting
78+
// would re-identify the request as Podkey's NIP-98 and break the page's
79+
// own auth. If it fails with 401, the retry path below still injects
80+
// NIP-98. See issue #5.
81+
const pageSetAuth = fetchCallHasAuthorization(url, options);
82+
3183
// If we have the auth function, use it
32-
if (authFunctionReady && getAuthHeaderFn) {
84+
if (authFunctionReady && getAuthHeaderFn && !pageSetAuth) {
3385
console.log('[Podkey] ✅ Auth function ready, adding header...');
3486
const promise = (async () => {
3587
try {
3688
const authHeader = await getAuthHeaderFn(url, options.method || 'GET', options.body);
3789
if (authHeader) {
3890
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-
}
91+
setAuthorizationOnOptions(options, authHeader);
92+
console.log('[Podkey] ✅ Added NIP-98 auth header');
4793
} else {
4894
console.log('[Podkey] ⚠️ No auth header returned (will retry on 401)');
4995
}
@@ -62,12 +108,7 @@
62108
const authHeader = await getAuthHeaderFn(url, options.method || 'GET', options.body);
63109
if (authHeader) {
64110
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-
}
111+
setAuthorizationOnOptions(retryOptions, authHeader);
71112
console.log('[Podkey] 🔄 Retrying with NIP-98 auth...');
72113
const retryResponse = await originalFetch.call(this, url, retryOptions);
73114
if (retryResponse.status === 200 || retryResponse.status === 201) {
@@ -105,12 +146,7 @@
105146
getAuthHeaderFn(url, method, options?.body).then(authHeader => {
106147
if (authHeader) {
107148
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-
}
149+
setAuthorizationOnOptions(retryOptions, authHeader);
114150
originalFetch.call(this, url, retryOptions).then(resolve);
115151
} else {
116152
resolve(response);
@@ -132,16 +168,25 @@
132168
XMLHttpRequest.prototype.open = function (method, url, ...args) {
133169
this._podkeyMethod = method;
134170
this._podkeyUrl = url;
171+
this._podkeyHasPageAuth = false;
135172
return originalXHROpen.apply(this, [method, url, ...args]);
136173
};
137174

175+
// Track a page-set Authorization on XHR so send() can respect it.
176+
XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
177+
if (typeof name === 'string' && name.toLowerCase() === 'authorization') {
178+
this._podkeyHasPageAuth = true;
179+
}
180+
return originalXHRSetRequestHeader.apply(this, arguments);
181+
};
182+
138183
XMLHttpRequest.prototype.send = function (body) {
139-
if (getAuthHeaderFn && this._podkeyUrl) {
184+
if (getAuthHeaderFn && this._podkeyUrl && !this._podkeyHasPageAuth) {
140185
(async () => {
141186
try {
142187
const authHeader = await getAuthHeaderFn(this._podkeyUrl, this._podkeyMethod, body);
143188
if (authHeader) {
144-
this.setRequestHeader('Authorization', authHeader);
189+
originalXHRSetRequestHeader.call(this, 'Authorization', authHeader);
145190
}
146191
} catch (e) {
147192
console.error('[Podkey] Error in XHR interceptor:', e);

src/nip98-interceptor.js

Lines changed: 80 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,51 @@
1616
const originalFetch = window.fetch;
1717
const originalXHROpen = XMLHttpRequest.prototype.open;
1818
const originalXHRSend = XMLHttpRequest.prototype.send;
19+
const originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
20+
21+
// Duplicate of src/auth-header-utils.js — keep in sync. The canonical
22+
// module is imported by unit tests; this copy runs in page context where
23+
// classic scripts can't use ESM imports.
24+
function hasAuthorizationHeader (headers) {
25+
if (!headers) return false;
26+
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
27+
return headers.has('authorization');
28+
}
29+
if (Array.isArray(headers)) {
30+
return headers.some((entry) =>
31+
Array.isArray(entry) && typeof entry[0] === 'string' &&
32+
entry[0].toLowerCase() === 'authorization'
33+
);
34+
}
35+
if (typeof headers === 'object') {
36+
for (const key of Object.keys(headers)) {
37+
if (key.toLowerCase() === 'authorization') return true;
38+
}
39+
}
40+
return false;
41+
}
42+
43+
function fetchCallHasAuthorization (input, init) {
44+
if (hasAuthorizationHeader(init?.headers)) return true;
45+
if (typeof Request !== 'undefined' && input instanceof Request) {
46+
return hasAuthorizationHeader(input.headers);
47+
}
48+
return false;
49+
}
50+
51+
function setAuthorizationOnOptions (options, value) {
52+
options.headers = options.headers || {};
53+
if (typeof Headers !== 'undefined' && options.headers instanceof Headers) {
54+
options.headers.set('Authorization', value);
55+
} else if (Array.isArray(options.headers)) {
56+
const normalized = new Headers(options.headers);
57+
normalized.set('Authorization', value);
58+
options.headers = normalized;
59+
} else {
60+
options.headers['Authorization'] = value;
61+
}
62+
return options.headers;
63+
}
1964

2065
// Helper to get auth header from extension
2166
async function getNip98AuthHeader (url, method, body) {
@@ -63,23 +108,28 @@
63108
const method = options?.method || 'GET';
64109
console.log('[Podkey] 🔍 fetch() intercepted:', urlString, method);
65110

66-
try {
67-
const authHeader = await getNip98AuthHeader(url, method, options?.body);
68-
if (authHeader) {
69-
options = options || {};
70-
options.headers = options.headers || {};
71-
if (options.headers instanceof Headers) {
72-
options.headers.set('Authorization', authHeader);
73-
console.log('[Podkey] ✅ Added NIP-98 auth header (Headers)');
111+
// Respect an Authorization header the page already set — on either
112+
// options.headers or a Request input (e.g. Solid-OIDC DPoP). Overwriting
113+
// would re-identify the request as Podkey's NIP-98 and break the page's
114+
// own auth. If that auth fails with 401, the retry path below still
115+
// injects NIP-98. See issue #5.
116+
const pageSetAuth = fetchCallHasAuthorization(url, options);
117+
118+
if (!pageSetAuth) {
119+
try {
120+
const authHeader = await getNip98AuthHeader(url, method, options?.body);
121+
if (authHeader) {
122+
options = options || {};
123+
setAuthorizationOnOptions(options, authHeader);
124+
console.log('[Podkey] ✅ Added NIP-98 auth header');
74125
} else {
75-
options.headers['Authorization'] = authHeader;
76-
console.log('[Podkey] ✅ Added NIP-98 auth header (object)');
126+
console.log('[Podkey] ⚠️ No auth header (will retry on 401)');
77127
}
78-
} else {
79-
console.log('[Podkey] ⚠️ No auth header (will retry on 401)');
128+
} catch (error) {
129+
console.error('[Podkey] Error adding NIP-98 auth:', error);
80130
}
81-
} catch (error) {
82-
console.error('[Podkey] Error adding NIP-98 auth:', error);
131+
} else {
132+
console.log('[Podkey] ⏭️ Page already set Authorization — skipping injection');
83133
}
84134

85135
const response = await originalFetch.call(this, url, options);
@@ -91,12 +141,7 @@
91141
const authHeader = await getNip98AuthHeader(url, method, options?.body);
92142
if (authHeader) {
93143
const retryOptions = { ...options };
94-
retryOptions.headers = retryOptions.headers || {};
95-
if (retryOptions.headers instanceof Headers) {
96-
retryOptions.headers.set('Authorization', authHeader);
97-
} else {
98-
retryOptions.headers['Authorization'] = authHeader;
99-
}
144+
setAuthorizationOnOptions(retryOptions, authHeader);
100145
console.log('[Podkey] 🔄 Retrying with NIP-98 auth...');
101146
const retryResponse = await originalFetch.call(this, url, retryOptions);
102147
if (retryResponse.status === 200 || retryResponse.status === 201) {
@@ -118,20 +163,33 @@
118163
XMLHttpRequest.prototype.open = function (method, url, ...args) {
119164
this._podkeyMethod = method;
120165
this._podkeyUrl = url;
166+
this._podkeyHasPageAuth = false;
121167
return originalXHROpen.apply(this, [method, url, ...args]);
122168
};
123169

170+
// Track page-set Authorization on XHR so we don't overwrite it in send().
171+
// setRequestHeader auto-merges values per the XHR spec, but a merged
172+
// "DPoP xxx, Nostr yyy" still confuses servers that branch on scheme.
173+
XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
174+
if (typeof name === 'string' && name.toLowerCase() === 'authorization') {
175+
this._podkeyHasPageAuth = true;
176+
}
177+
return originalXHRSetRequestHeader.apply(this, arguments);
178+
};
179+
124180
XMLHttpRequest.prototype.send = async function (body) {
125-
if (this._podkeyUrl) {
181+
if (this._podkeyUrl && !this._podkeyHasPageAuth) {
126182
try {
127183
const authHeader = await getNip98AuthHeader(this._podkeyUrl, this._podkeyMethod, body);
128184
if (authHeader) {
129-
this.setRequestHeader('Authorization', authHeader);
185+
originalXHRSetRequestHeader.call(this, 'Authorization', authHeader);
130186
console.log('[Podkey] ✅ Added NIP-98 auth to XHR');
131187
}
132188
} catch (error) {
133189
console.error('[Podkey] Error adding NIP-98 auth to XHR:', error);
134190
}
191+
} else if (this._podkeyHasPageAuth) {
192+
console.log('[Podkey] ⏭️ Page set XHR Authorization — skipping injection');
135193
}
136194
return originalXHRSend.apply(this, [body]);
137195
};

0 commit comments

Comments
 (0)