Skip to content

Commit 631fc54

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 631fc54

4 files changed

Lines changed: 426 additions & 53 deletions

File tree

src/auth-header-utils.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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+
options.headers['Authorization'] = value;
95+
}
96+
return options.headers;
97+
}

src/injected.js

Lines changed: 89 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,66 @@
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+
}
60+
61+
function normalizeFetchCall (input, init) {
62+
if (typeof Request !== 'undefined' && input instanceof Request) {
63+
return {
64+
url: input.url,
65+
method: init?.method || input.method || 'GET',
66+
body: init?.body
67+
};
68+
}
69+
return {
70+
url: typeof input === 'string' ? input : String(input),
71+
method: init?.method || 'GET',
72+
body: init?.body
73+
};
74+
}
1575

1676
// Helper to get auth header (will be async, but we'll handle that)
1777
let getAuthHeaderFn = null;
@@ -24,26 +84,28 @@
2484
// Intercept fetch - MUST replace immediately to catch all calls
2585
// This runs synchronously, so it catches fetch even if called immediately
2686
window.fetch = function (url, options = {}) {
27-
const urlString = typeof url === 'string' ? url : url.toString();
28-
const method = options?.method || 'GET';
87+
// Normalize once — handles fetch(url, init) and fetch(new Request(...))
88+
// so downstream signing sees the real URL/method, not "[object Request]".
89+
const { url: urlString, method, body } = normalizeFetchCall(url, options);
2990
console.log('[Podkey] 🔍 fetch() intercepted:', urlString, method);
3091

92+
// Respect an Authorization header the page already set — on either
93+
// options.headers or a Request input (e.g. Solid-OIDC DPoP). Overwriting
94+
// would re-identify the request as Podkey's NIP-98 and break the page's
95+
// own auth. If it fails with 401, the retry path below still injects
96+
// NIP-98. See issue #5.
97+
const pageSetAuth = fetchCallHasAuthorization(url, options);
98+
3199
// If we have the auth function, use it
32-
if (authFunctionReady && getAuthHeaderFn) {
100+
if (authFunctionReady && getAuthHeaderFn && !pageSetAuth) {
33101
console.log('[Podkey] ✅ Auth function ready, adding header...');
34102
const promise = (async () => {
35103
try {
36-
const authHeader = await getAuthHeaderFn(url, options.method || 'GET', options.body);
104+
const authHeader = await getAuthHeaderFn(urlString, method, body);
37105
if (authHeader) {
38106
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-
}
107+
setAuthorizationOnOptions(options, authHeader);
108+
console.log('[Podkey] ✅ Added NIP-98 auth header');
47109
} else {
48110
console.log('[Podkey] ⚠️ No auth header returned (will retry on 401)');
49111
}
@@ -59,15 +121,10 @@
59121
console.log('[Podkey] 🔄 401 detected, retrying with auth...');
60122
return (async () => {
61123
try {
62-
const authHeader = await getAuthHeaderFn(url, options.method || 'GET', options.body);
124+
const authHeader = await getAuthHeaderFn(urlString, method, body);
63125
if (authHeader) {
64126
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-
}
127+
setAuthorizationOnOptions(retryOptions, authHeader);
71128
console.log('[Podkey] 🔄 Retrying with NIP-98 auth...');
72129
const retryResponse = await originalFetch.call(this, url, retryOptions);
73130
if (retryResponse.status === 200 || retryResponse.status === 201) {
@@ -102,15 +159,10 @@
102159
const checkAuth = () => {
103160
if (authFunctionReady && getAuthHeaderFn) {
104161
console.log('[Podkey] 🔄 Auth function now ready, retrying with NIP-98...');
105-
getAuthHeaderFn(url, method, options?.body).then(authHeader => {
162+
getAuthHeaderFn(urlString, method, body).then(authHeader => {
106163
if (authHeader) {
107164
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-
}
165+
setAuthorizationOnOptions(retryOptions, authHeader);
114166
originalFetch.call(this, url, retryOptions).then(resolve);
115167
} else {
116168
resolve(response);
@@ -132,16 +184,25 @@
132184
XMLHttpRequest.prototype.open = function (method, url, ...args) {
133185
this._podkeyMethod = method;
134186
this._podkeyUrl = url;
187+
this._podkeyHasPageAuth = false;
135188
return originalXHROpen.apply(this, [method, url, ...args]);
136189
};
137190

191+
// Track a page-set Authorization on XHR so send() can respect it.
192+
XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
193+
if (typeof name === 'string' && name.toLowerCase() === 'authorization') {
194+
this._podkeyHasPageAuth = true;
195+
}
196+
return originalXHRSetRequestHeader.apply(this, arguments);
197+
};
198+
138199
XMLHttpRequest.prototype.send = function (body) {
139-
if (getAuthHeaderFn && this._podkeyUrl) {
200+
if (getAuthHeaderFn && this._podkeyUrl && !this._podkeyHasPageAuth) {
140201
(async () => {
141202
try {
142203
const authHeader = await getAuthHeaderFn(this._podkeyUrl, this._podkeyMethod, body);
143204
if (authHeader) {
144-
this.setRequestHeader('Authorization', authHeader);
205+
originalXHRSetRequestHeader.call(this, 'Authorization', authHeader);
145206
}
146207
} catch (e) {
147208
console.error('[Podkey] Error in XHR interceptor:', e);

0 commit comments

Comments
 (0)