From 9e5052e6af0db22915e631be264da75c09898c1f Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 8 Nov 2026 19:51:34 +0100 Subject: [PATCH] Retry a signed GET/HEAD unsigned when it fails before a response (#33) Podkey's Authorization header turns a cross-origin GET into a non-simple request, so the browser sends a CORS preflight. Static hosts such as GitHub Pages don't answer it, the fetch rejects with a TypeError, and public files stop loading once the user is logged in. The fetch wrapper now tries once more without its own header when Podkey signed the request, the method is GET or HEAD, and the failure is a TypeError rather than an abort. Page-set auth, writes and the 401 retry are unchanged. XHR is left as it is: its errors are asynchronous. The helpers live in auth-header-utils.js with a copy in the interceptor, as for the existing ones (#7). --- CHANGELOG.md | 11 +++ src/auth-header-utils.js | 51 ++++++++++++ src/nip98-interceptor.js | 48 ++++++++++- test/unsigned-retry.test.js | 162 ++++++++++++++++++++++++++++++++++++ 4 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 test/unsigned-retry.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7988f62..73aefdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to Podkey will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **Public files load again when you're logged in (#33).** Podkey's NIP-98 + header makes a cross-origin request need a CORS preflight, and hosts that + don't answer preflights, such as GitHub Pages, failed the fetch outright. A + signed GET or HEAD that fails that way is now tried once more without + Podkey's header. Writes, aborts and requests where the page set its own + `Authorization` are never retried, and the 401 retry is unchanged. + ## [0.0.11] - 2026-09-25 ### Fixed diff --git a/src/auth-header-utils.js b/src/auth-header-utils.js index b1e318a..62569f5 100644 --- a/src/auth-header-utils.js +++ b/src/auth-header-utils.js @@ -108,3 +108,54 @@ export function setAuthorizationOnOptions (options, value) { } return options.headers; } + +/** + * Should a fetch that failed outright be tried again without Podkey's + * NIP-98 header (#33)? Adding `Authorization` makes a cross-origin request + * "non-simple", so the browser sends a CORS preflight first. Static hosts + * such as GitHub Pages don't answer preflights, and the fetch rejects with a + * TypeError before any response, so a public file the page could read + * becomes unreadable once the user is logged in. + * + * Only retry when all of these hold: + * - Podkey added the header itself (never when the page set its own auth) + * - the method is GET or HEAD (safe to repeat, no body to resend) + * - the failure is a TypeError (network/CORS), not an abort + * Checks the error by name, not `instanceof`, so it holds across realms. + */ +export function shouldRetryUnsigned ({ method, signed, error, signal }) { + if (!signed) return false; + if (!/^(GET|HEAD)$/i.test(method || 'GET')) return false; + if (signal && signal.aborted) return false; + return !!error && error.name === 'TypeError'; +} + +/** + * A copy of fetch `init` without the Authorization header Podkey added, for + * the unsigned retry (#33). `hadHeaders` says whether the caller's init had a + * `headers` key before Podkey signed: if it didn't, the key is dropped again, + * so a Request input keeps its own headers (init.headers would override them). + * Never mutates `init`. + */ +export function withoutAuthorization (init, hadHeaders) { + const out = { ...init }; + if (!hadHeaders) { + delete out.headers; + return out; + } + const headers = init.headers; + if (typeof Headers !== 'undefined' && headers instanceof Headers) { + const copy = new Headers(headers); + copy.delete('authorization'); + out.headers = copy; + } else if (Array.isArray(headers)) { + out.headers = headers.filter((entry) => + !(Array.isArray(entry) && typeof entry[0] === 'string' && entry[0].toLowerCase() === 'authorization')); + } else if (headers && typeof headers === 'object') { + out.headers = {}; + for (const key of Object.keys(headers)) { + if (key.toLowerCase() !== 'authorization') out.headers[key] = headers[key]; + } + } + return out; +} diff --git a/src/nip98-interceptor.js b/src/nip98-interceptor.js index 6feb576..480237b 100644 --- a/src/nip98-interceptor.js +++ b/src/nip98-interceptor.js @@ -75,6 +75,36 @@ return options.headers; } + function shouldRetryUnsigned ({ method, signed, error, signal }) { + if (!signed) return false; + if (!/^(GET|HEAD)$/i.test(method || 'GET')) return false; + if (signal && signal.aborted) return false; + return !!error && error.name === 'TypeError'; + } + + function withoutAuthorization (init, hadHeaders) { + const out = { ...init }; + if (!hadHeaders) { + delete out.headers; + return out; + } + const headers = init.headers; + if (typeof Headers !== 'undefined' && headers instanceof Headers) { + const copy = new Headers(headers); + copy.delete('authorization'); + out.headers = copy; + } else if (Array.isArray(headers)) { + out.headers = headers.filter((entry) => + !(Array.isArray(entry) && typeof entry[0] === 'string' && entry[0].toLowerCase() === 'authorization')); + } else if (headers && typeof headers === 'object') { + out.headers = {}; + for (const key of Object.keys(headers)) { + if (key.toLowerCase() !== 'authorization') out.headers[key] = headers[key]; + } + } + return out; + } + function normalizeFetchCall (input, init) { if (typeof Request !== 'undefined' && input instanceof Request) { return { @@ -173,12 +203,16 @@ // own auth. If that auth fails with 401, the retry path below still // injects NIP-98. See issue #5. const pageSetAuth = fetchCallHasAuthorization(url, options); + // For the unsigned retry (#33): did the caller pass headers, and did we sign? + const hadHeaders = Object.prototype.hasOwnProperty.call(options, 'headers'); + let signed = false; if (!pageSetAuth) { try { const authHeader = await getNip98AuthHeader(urlString, method, body); if (authHeader) { setAuthorizationOnOptions(options, authHeader); + signed = true; if (DEBUG) console.log('[Podkey] Added NIP-98 auth header'); } else if (DEBUG) { console.log('[Podkey] No auth header (will retry on 401)'); @@ -190,7 +224,19 @@ console.log('[Podkey] Page already set Authorization — skipping injection'); } - const response = await originalFetch.call(this, url, options); + let response; + try { + response = await originalFetch.call(this, url, options); + } catch (error) { + // Our header made the request need a CORS preflight, and hosts that + // don't answer preflights (e.g. GitHub Pages) fail it outright. A public + // GET/HEAD the page could read on its own shouldn't break because the + // user is logged in: try once more, unsigned. See issue #33. + const signal = options.signal || (typeof Request !== 'undefined' && url instanceof Request ? url.signal : undefined); + if (!shouldRetryUnsigned({ method, signed, error, signal })) throw error; + if (DEBUG) console.log('[Podkey] Signed fetch failed (likely CORS preflight), retrying unsigned'); + return originalFetch.call(this, url, withoutAuthorization(options, hadHeaders)); + } // Handle 401 retry if (response.status === 401) { diff --git a/test/unsigned-retry.test.js b/test/unsigned-retry.test.js new file mode 100644 index 0000000..b3e03ae --- /dev/null +++ b/test/unsigned-retry.test.js @@ -0,0 +1,162 @@ +/** + * Tests for the unsigned retry (#33). A NIP-98 Authorization header turns a + * cross-origin GET into a non-simple request, so the browser sends a CORS + * preflight first. Hosts that don't answer preflights (GitHub Pages) make the + * fetch reject with a TypeError, and a public file breaks only because the + * user is logged in. The interceptor now tries once more without its header. + * + * The helpers are tested from src/auth-header-utils.js, and the real + * interceptor is run in a vm sandbox (as in body-hash.test.js) with a fake + * fetch that fails whenever Authorization is present, like a host with no + * preflight support. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { webcrypto } from 'node:crypto'; +import vm from 'node:vm'; +import { shouldRetryUnsigned, withoutAuthorization } from '../src/auth-header-utils.js'; + +const corsError = () => new TypeError('Failed to fetch'); + +describe('shouldRetryUnsigned (#33)', () => { + it('retries a signed GET or HEAD that failed with a TypeError', () => { + assert.equal(shouldRetryUnsigned({ method: 'GET', signed: true, error: corsError() }), true); + assert.equal(shouldRetryUnsigned({ method: 'head', signed: true, error: corsError() }), true); + assert.equal(shouldRetryUnsigned({ method: undefined, signed: true, error: corsError() }), true); + }); + + it('never retries when Podkey did not sign (page auth, or no key)', () => { + assert.equal(shouldRetryUnsigned({ method: 'GET', signed: false, error: corsError() }), false); + }); + + it('never retries writes', () => { + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { + assert.equal(shouldRetryUnsigned({ method, signed: true, error: corsError() }), false, method); + } + }); + + it('never retries aborts', () => { + const ac = new AbortController(); + ac.abort(); + assert.equal(shouldRetryUnsigned({ method: 'GET', signed: true, error: corsError(), signal: ac.signal }), false); + const abort = new DOMException('aborted', 'AbortError'); + assert.equal(shouldRetryUnsigned({ method: 'GET', signed: true, error: abort }), false); + }); + + it('matches TypeError by name, so it works across realms', () => { + const foreign = vm.runInNewContext('new TypeError("Failed to fetch")'); + assert.equal(foreign instanceof TypeError, false); + assert.equal(shouldRetryUnsigned({ method: 'GET', signed: true, error: foreign }), true); + }); +}); + +describe('withoutAuthorization (#33)', () => { + it('drops headers entirely when the caller passed none', () => { + const init = { method: 'GET', headers: { Authorization: 'Nostr x' } }; + const out = withoutAuthorization(init, false); + assert.equal(Object.prototype.hasOwnProperty.call(out, 'headers'), false); + assert.equal(out.method, 'GET'); + }); + + it('removes Authorization from a plain object, keeping the rest', () => { + const init = { headers: { Accept: 'application/json', authorization: 'Nostr x' } }; + const out = withoutAuthorization(init, true); + assert.deepEqual(out.headers, { Accept: 'application/json' }); + assert.equal(init.headers.authorization, 'Nostr x', 'must not mutate the original'); + }); + + it('removes Authorization from Headers and from tuple arrays', () => { + const h = new Headers({ Accept: 'text/html', Authorization: 'Nostr x' }); + const out = withoutAuthorization({ headers: h }, true); + assert.equal(out.headers.has('authorization'), false); + assert.equal(out.headers.get('accept'), 'text/html'); + assert.equal(h.has('authorization'), true, 'must not mutate the original'); + + const arr = withoutAuthorization({ headers: [['Accept', 'a'], ['Authorization', 'Nostr x']] }, true); + assert.deepEqual(arr.headers, [['Accept', 'a']]); + }); +}); + +/** The real interceptor in a sandbox whose network rejects any request carrying Authorization. */ +function makePage ({ token = 'Nostr signed' } = {}) { + const src = readFileSync(new URL('../src/nip98-interceptor.js', import.meta.url), 'utf8'); + const bus = new EventTarget(); + const calls = []; + const auth = init => { + const h = init && init.headers; + if (!h) return null; + if (h instanceof Headers) return h.get('authorization'); + if (Array.isArray(h)) return (h.find(e => e[0].toLowerCase() === 'authorization') || [])[1] || null; + const key = Object.keys(h).find(k => k.toLowerCase() === 'authorization'); + return key ? h[key] : null; + }; + const win = { + __podkey_nip98_intercepted: false, + addEventListener: (...a) => bus.addEventListener(...a), + removeEventListener: (...a) => bus.removeEventListener(...a), + dispatchEvent: (e) => bus.dispatchEvent(e), + // a static host with no preflight support: Authorization makes the fetch fail + fetch: async (url, init) => { + calls.push({ url, auth: auth(init), hasHeadersKey: !!init && Object.prototype.hasOwnProperty.call(init, 'headers') }); + if (auth(init)) throw new TypeError('Failed to fetch'); + return { status: 200, redirected: false, url: String(url) }; + } + }; + // the extension signs every request for this trusted origin + bus.addEventListener('podkey-nip98-request', (e) => { + bus.dispatchEvent(new CustomEvent('podkey-nip98-response', { detail: { id: e.detail.id, result: token } })); + }); + function XHR () {} + XHR.prototype = { open () {}, send () {}, setRequestHeader () {} }; + const sandbox = { + window: win, + XMLHttpRequest: XHR, + crypto: webcrypto, + CustomEvent, Headers, Request, TextEncoder, + Blob, URLSearchParams, ArrayBuffer, Uint8Array, Uint16Array, Array, Object, + console, setTimeout, clearTimeout + }; + vm.createContext(sandbox); + vm.runInContext(src, sandbox); + return { fetch: (...a) => win.fetch(...a), calls }; +} + +describe('interceptor: unsigned retry when a signed fetch fails (#33)', () => { + it('a public GET still loads: signed attempt fails, unsigned retry succeeds', async () => { + const page = makePage(); + const res = await page.fetch('https://user.github.io/maps/sugar.json'); + assert.equal(res.status, 200); + assert.equal(page.calls.length, 2); + assert.equal(page.calls[0].auth, 'Nostr signed'); + assert.equal(page.calls[1].auth, null); + assert.equal(page.calls[1].hasHeadersKey, false, 'no headers key, so a Request keeps its own headers'); + }); + + it('keeps the page\'s other headers on the retry', async () => { + const page = makePage(); + await page.fetch('https://user.github.io/a.json', { headers: { Accept: 'application/json' } }); + assert.equal(page.calls.length, 2); + assert.equal(page.calls[1].auth, null); + }); + + it('does not retry a POST: the error reaches the page', async () => { + const page = makePage(); + await assert.rejects(page.fetch('https://user.github.io/a.json', { method: 'POST', body: 'x' }), { name: 'TypeError' }); + assert.equal(page.calls.length, 1); + }); + + it('does not retry when the page set its own Authorization', async () => { + const page = makePage(); + await assert.rejects(page.fetch('https://user.github.io/a.json', { headers: { Authorization: 'DPoP page' } }), { name: 'TypeError' }); + assert.equal(page.calls.length, 1); + }); + + it('does not retry when Podkey did not sign', async () => { + const page = makePage({ token: null }); + const res = await page.fetch('https://user.github.io/a.json'); + assert.equal(res.status, 200); + assert.equal(page.calls.length, 1); + }); +});