Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions src/auth-header-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
48 changes: 47 additions & 1 deletion src/nip98-interceptor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)');
Expand All @@ -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) {
Expand Down
162 changes: 162 additions & 0 deletions test/unsigned-retry.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading