Skip to content
Merged
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
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

_No changes yet._

## [1.2.1] — 2026-07-22

### Fixed

- **`BearerTokenAuth` no longer discards a valid token when a refresh yields
nothing.** On a 401/403, `onAuthError` invoked the refresh callback and then
called `store.setToken(newToken ?? null)` — so a refresh that returned `null`
(no refresh configured, or an error a refresh cannot fix, such as a permission
`403`) overwrote the still-valid token with `null`. Every subsequent request
then went out with no `Authorization` header and failed with `401`, turning a
single permission error into a cascade of unauthenticated failures across
unrelated requests. The stored token is now left untouched unless the refresh
actually produces a new one; the failing request still surfaces its original
error. Regression test added.

## [1.2.0] — 2026-07-22

Adds an optional client-side request governor for staying within an upstream
Expand Down Expand Up @@ -421,7 +436,8 @@ PKCE refresh recovery, full type-system rigor across primitive bodies and
JSON encoding, and `duplex: 'half'` propagation for `ReadableStream`
bodies — is captured here as the canonical first set of release notes.

[Unreleased]: https://github.com/erelsop/fetch-enh/compare/v1.2.0...HEAD
[Unreleased]: https://github.com/erelsop/fetch-enh/compare/v1.2.1...HEAD
[1.2.1]: https://github.com/erelsop/fetch-enh/compare/v1.2.0...v1.2.1
[1.2.0]: https://github.com/erelsop/fetch-enh/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/erelsop/fetch-enh/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/erelsop/fetch-enh/releases/tag/v1.0.0
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@erelsop/fetch-enh",
"version": "1.2.0",
"version": "1.2.1",
"description": "An enhanced fetch utility with built-in retry logic, authentication strategies, request/response interceptors, and intelligent error handling.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
10 changes: 8 additions & 2 deletions src/auth/strategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,21 @@ export class BearerTokenAuth implements AuthStrategy {
if (!this.refreshingPromise) {
this.refreshingPromise = this.refresh()
.then(async (newToken) => {
await this.store.setToken(newToken ?? null);
// Only persist a *successful* refresh. A null/empty result means the
// refresh could not produce a new token (e.g. no refresh configured,
// or a 403 that a refresh cannot fix) — in that case leave the stored
// token untouched. Overwriting it with null would strip the still-valid
// token from every subsequent request, turning one 401/403 into a
// cascade of unauthenticated failures.
if (newToken) await this.store.setToken(newToken);
return newToken ?? null;
})
.finally(() => {
this.refreshingPromise = null;
});
}
const token = await this.refreshingPromise;
if (!token) return; // give up; let caller handle error
if (!token) return; // give up; let caller handle the original error

const headers = new Headers(request.headers);
headers.set('Authorization', `Bearer ${token}`);
Expand Down
25 changes: 25 additions & 0 deletions tests/fetchEnh.critical.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,4 +464,29 @@ describe('concurrent auth-refresh deduplication', () => {
// Both callers should have received a valid retry response
expect(retryFn).toHaveBeenCalledTimes(2);
});

test('BearerTokenAuth: a null refresh result preserves the existing token (no clobber)', async () => {
// A no-op refresh (returns null) must NOT overwrite the still-valid stored
// token. Otherwise a single 401/403 strips auth from every later request,
// turning one permission error into a cascade of unauthenticated failures.
const store = new MemoryTokenStore('valid-token');
const refresh = jest.fn(async () => null);
const auth = new BearerTokenAuth(store, refresh);

const req = new Request('https://api.test/forbidden');
const res = new Response('Forbidden', { status: 403 });
const retryFn = jest.fn(async (r: Request) => new Response('', { status: 200 }));

// onAuthError gives up (no new token) and does not retry.
const result = await auth.onAuthError(req, res, retryFn);
expect(result).toBeUndefined();
expect(retryFn).not.toHaveBeenCalled();

// Crucially, the original token survives for the next request.
expect(await store.getToken()).toBe('valid-token');

// And onRequest still attaches it.
const decorated = await auth.onRequest(new Request('https://api.test/next'));
expect((decorated as Request).headers.get('Authorization')).toBe('Bearer valid-token');
});
});