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); + }); +});