Skip to content

Commit 4ae50f2

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 8ccc1c2 commit 4ae50f2

4 files changed

Lines changed: 190 additions & 19 deletions

File tree

src/auth-header-utils.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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+
}

src/injected.js

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,29 @@
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+
}
1538

1639
// Helper to get auth header (will be async, but we'll handle that)
1740
let getAuthHeaderFn = null;
@@ -28,8 +51,14 @@
2851
const method = options?.method || 'GET';
2952
console.log('[Podkey] 🔍 fetch() intercepted:', urlString, method);
3053

54+
// Respect an Authorization header the page already set (e.g. Solid-OIDC
55+
// DPoP). Overwriting it would re-identify the request as Podkey's NIP-98
56+
// and break the page's own auth. If it fails with 401, the retry path
57+
// below still injects NIP-98. See issue #5.
58+
const pageSetAuth = hasAuthorizationHeader(options?.headers);
59+
3160
// If we have the auth function, use it
32-
if (authFunctionReady && getAuthHeaderFn) {
61+
if (authFunctionReady && getAuthHeaderFn && !pageSetAuth) {
3362
console.log('[Podkey] ✅ Auth function ready, adding header...');
3463
const promise = (async () => {
3564
try {
@@ -132,16 +161,25 @@
132161
XMLHttpRequest.prototype.open = function (method, url, ...args) {
133162
this._podkeyMethod = method;
134163
this._podkeyUrl = url;
164+
this._podkeyHasPageAuth = false;
135165
return originalXHROpen.apply(this, [method, url, ...args]);
136166
};
137167

168+
// Track a page-set Authorization on XHR so send() can respect it.
169+
XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
170+
if (typeof name === 'string' && name.toLowerCase() === 'authorization') {
171+
this._podkeyHasPageAuth = true;
172+
}
173+
return originalXHRSetRequestHeader.apply(this, arguments);
174+
};
175+
138176
XMLHttpRequest.prototype.send = function (body) {
139-
if (getAuthHeaderFn && this._podkeyUrl) {
177+
if (getAuthHeaderFn && this._podkeyUrl && !this._podkeyHasPageAuth) {
140178
(async () => {
141179
try {
142180
const authHeader = await getAuthHeaderFn(this._podkeyUrl, this._podkeyMethod, body);
143181
if (authHeader) {
144-
this.setRequestHeader('Authorization', authHeader);
182+
originalXHRSetRequestHeader.call(this, 'Authorization', authHeader);
145183
}
146184
} catch (e) {
147185
console.error('[Podkey] Error in XHR interceptor:', e);

src/nip98-interceptor.js

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,29 @@
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+
}
1942

2043
// Helper to get auth header from extension
2144
async function getNip98AuthHeader (url, method, body) {
@@ -63,23 +86,33 @@
6386
const method = options?.method || 'GET';
6487
console.log('[Podkey] 🔍 fetch() intercepted:', urlString, method);
6588

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)');
89+
// Respect an Authorization header the page already set (e.g. Solid-OIDC
90+
// DPoP). Overwriting it would re-identify the request as Podkey's NIP-98
91+
// and break the page's own auth. If that auth fails with 401, the retry
92+
// path below will still inject NIP-98. See issue #5.
93+
const pageSetAuth = hasAuthorizationHeader(options?.headers);
94+
95+
if (!pageSetAuth) {
96+
try {
97+
const authHeader = await getNip98AuthHeader(url, method, options?.body);
98+
if (authHeader) {
99+
options = options || {};
100+
options.headers = options.headers || {};
101+
if (options.headers instanceof Headers) {
102+
options.headers.set('Authorization', authHeader);
103+
console.log('[Podkey] ✅ Added NIP-98 auth header (Headers)');
104+
} else {
105+
options.headers['Authorization'] = authHeader;
106+
console.log('[Podkey] ✅ Added NIP-98 auth header (object)');
107+
}
74108
} else {
75-
options.headers['Authorization'] = authHeader;
76-
console.log('[Podkey] ✅ Added NIP-98 auth header (object)');
109+
console.log('[Podkey] ⚠️ No auth header (will retry on 401)');
77110
}
78-
} else {
79-
console.log('[Podkey] ⚠️ No auth header (will retry on 401)');
111+
} catch (error) {
112+
console.error('[Podkey] Error adding NIP-98 auth:', error);
80113
}
81-
} catch (error) {
82-
console.error('[Podkey] Error adding NIP-98 auth:', error);
114+
} else {
115+
console.log('[Podkey] ⏭️ Page already set Authorization — skipping injection');
83116
}
84117

85118
const response = await originalFetch.call(this, url, options);
@@ -118,20 +151,33 @@
118151
XMLHttpRequest.prototype.open = function (method, url, ...args) {
119152
this._podkeyMethod = method;
120153
this._podkeyUrl = url;
154+
this._podkeyHasPageAuth = false;
121155
return originalXHROpen.apply(this, [method, url, ...args]);
122156
};
123157

158+
// Track page-set Authorization on XHR so we don't overwrite it in send().
159+
// setRequestHeader auto-merges values per the XHR spec, but a merged
160+
// "DPoP xxx, Nostr yyy" still confuses servers that branch on scheme.
161+
XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
162+
if (typeof name === 'string' && name.toLowerCase() === 'authorization') {
163+
this._podkeyHasPageAuth = true;
164+
}
165+
return originalXHRSetRequestHeader.apply(this, arguments);
166+
};
167+
124168
XMLHttpRequest.prototype.send = async function (body) {
125-
if (this._podkeyUrl) {
169+
if (this._podkeyUrl && !this._podkeyHasPageAuth) {
126170
try {
127171
const authHeader = await getNip98AuthHeader(this._podkeyUrl, this._podkeyMethod, body);
128172
if (authHeader) {
129-
this.setRequestHeader('Authorization', authHeader);
173+
originalXHRSetRequestHeader.call(this, 'Authorization', authHeader);
130174
console.log('[Podkey] ✅ Added NIP-98 auth to XHR');
131175
}
132176
} catch (error) {
133177
console.error('[Podkey] Error adding NIP-98 auth to XHR:', error);
134178
}
179+
} else if (this._podkeyHasPageAuth) {
180+
console.log('[Podkey] ⏭️ Page set XHR Authorization — skipping injection');
135181
}
136182
return originalXHRSend.apply(this, [body]);
137183
};

test/auth-header-utils.test.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, it } from 'node:test';
2+
import assert from 'node:assert';
3+
import { hasAuthorizationHeader } from '../src/auth-header-utils.js';
4+
5+
describe('hasAuthorizationHeader (#5)', () => {
6+
it('returns false for undefined / empty', () => {
7+
assert.strictEqual(hasAuthorizationHeader(undefined), false);
8+
assert.strictEqual(hasAuthorizationHeader(null), false);
9+
assert.strictEqual(hasAuthorizationHeader({}), false);
10+
});
11+
12+
it('detects plain-object Authorization (exact case)', () => {
13+
assert.strictEqual(hasAuthorizationHeader({ Authorization: 'DPoP xxx' }), true);
14+
});
15+
16+
it('detects plain-object authorization case-insensitively', () => {
17+
assert.strictEqual(hasAuthorizationHeader({ authorization: 'DPoP xxx' }), true);
18+
assert.strictEqual(hasAuthorizationHeader({ AUTHORIZATION: 'DPoP xxx' }), true);
19+
assert.strictEqual(hasAuthorizationHeader({ aUtHoRiZaTiOn: 'DPoP xxx' }), true);
20+
});
21+
22+
it('returns false when plain object has unrelated headers', () => {
23+
assert.strictEqual(
24+
hasAuthorizationHeader({ 'Content-Type': 'application/json', 'X-Foo': 'bar' }),
25+
false
26+
);
27+
});
28+
29+
it('detects Headers instance (case-insensitive per spec)', () => {
30+
const h = new Headers();
31+
h.set('Authorization', 'DPoP xxx');
32+
assert.strictEqual(hasAuthorizationHeader(h), true);
33+
assert.strictEqual(hasAuthorizationHeader(new Headers()), false);
34+
});
35+
36+
it('detects array-of-tuples shape', () => {
37+
assert.strictEqual(
38+
hasAuthorizationHeader([['Content-Type', 'application/json'], ['authorization', 'Bearer x']]),
39+
true
40+
);
41+
assert.strictEqual(
42+
hasAuthorizationHeader([['Content-Type', 'application/json']]),
43+
false
44+
);
45+
});
46+
47+
it('does not false-positive on header names that merely contain "authorization"', () => {
48+
assert.strictEqual(
49+
hasAuthorizationHeader({ 'X-Authorization-Source': 'podkey' }),
50+
false
51+
);
52+
});
53+
});

0 commit comments

Comments
 (0)