From acd3df8c1b271ab550848cf37e3aef69351e11eb Mon Sep 17 00:00:00 2001 From: Rasad Regmi <123284094+rasadregmi@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:52:56 +0545 Subject: [PATCH] fix(puter-js): replay the request after a successful token_auth_failed reauth (#3954) * fix(puter-js): replay the request after a token_auth_failed reauth A 401 carrying the legacy `token_auth_failed` code opened the sign-in prompt and then dropped the re-authenticated token on the floor: `resolveReauth()` fell through to `return null` instead of returning `{ action: 'replay' }`, so the original call was never re-issued and rejected with `Unauthorized` even though the new session was valid. Return the replay verdict the `reauth_required` branch already returns. Both replay paths stay one-shot (`ctx.done.add('reauth')`, `spec._replayed`), and the auth gate rejects before the route handler runs, so a replay has no side effects. Closes #3953 * fix(puter-js): sign in once when a replayed XHR is refused again handle_resp ran the reauth flow on the replay's own 401, so a token_auth_failed that survived the replay reset the fresh token and opened a second sign-in before rejecting. Treat a replay's 401 as final, as the 403 verification-gate branch already does. --------- Co-authored-by: Daniel Salazar --- src/puter-js/src/lib/networkUtils.js | 1 + src/puter-js/src/lib/networkUtils.test.js | 90 +++++++++++++++++++++++ src/puter-js/src/lib/utils.js | 3 +- src/puter-js/src/lib/utils.reauth.test.js | 86 ++++++++++++++++++++++ 4 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 src/puter-js/src/lib/utils.reauth.test.js diff --git a/src/puter-js/src/lib/networkUtils.js b/src/puter-js/src/lib/networkUtils.js index 84134fac5..92bbdc2ad 100644 --- a/src/puter-js/src/lib/networkUtils.js +++ b/src/puter-js/src/lib/networkUtils.js @@ -143,6 +143,7 @@ async function resolveReauth(resp, { interactive = true, sentToken } = {}) { try { puter.resetAuthToken(); await puter.ui.authenticateWithPuter(); + return { action: 'replay' }; } catch (e) { return { action: 'reject', diff --git a/src/puter-js/src/lib/networkUtils.test.js b/src/puter-js/src/lib/networkUtils.test.js index 73c1644d7..b4ae28580 100644 --- a/src/puter-js/src/lib/networkUtils.test.js +++ b/src/puter-js/src/lib/networkUtils.test.js @@ -263,6 +263,69 @@ describe('fetchUrl', () => { expect(await resp.json()).toEqual({ ok: true }); }); + // The legacy `token_auth_failed` signal drives the sign-in UI instead + // of triggerReauth, and must recover the same way: re-authenticate, + // then re-issue the call with the token the new session installed. + it('replays with the fresh token after a legacy token_auth_failed re-login', async () => { + const resetAuthToken = vi.fn(); + const authenticateWithPuter = vi.fn(async () => { + globalThis.puter.authToken = 'fresh'; + }); + globalThis.puter = { + authToken: 'stale', + env: 'web', + resetAuthToken, + ui: { authenticateWithPuter }, + }; + + let call = 0; + installFakeXHR((xhr) => { + call++; + if (call === 1) { + return respond({ + status: 401, + body: { code: 'token_auth_failed' }, + })(xhr); + } + expect(xhr._reqHeaders['authorization']).toBe('Bearer fresh'); + return respond({ status: 200, body: { ok: true } })(xhr); + }); + + const resp = await fetchUrl('https://api.example/x', { + includePuterAuth: true, + }); + expect(resetAuthToken).toHaveBeenCalledTimes(1); + expect(authenticateWithPuter).toHaveBeenCalledTimes(1); + expect(call).toBe(2); + expect(resp.ok).toBe(true); + expect(await resp.json()).toEqual({ ok: true }); + }); + + it('does not loop: a token_auth_failed that survives the replay surfaces as ok:false', async () => { + const authenticateWithPuter = vi.fn(async () => { + globalThis.puter.authToken = 'fresh'; + }); + globalThis.puter = { + authToken: 'stale', + env: 'web', + resetAuthToken: vi.fn(), + ui: { authenticateWithPuter }, + }; + + const xhrs = installFakeXHR( + respond({ status: 401, body: { code: 'token_auth_failed' } }), + ); + + const resp = await fetchUrl('https://api.example/x', { + includePuterAuth: true, + }); + // Re-signed in once; the retry's 401 is returned, not re-prompted. + expect(authenticateWithPuter).toHaveBeenCalledTimes(1); + expect(xhrs.length).toBe(2); + expect(resp.ok).toBe(false); + expect(resp.status).toBe(401); + }); + it('does not loop: a second 401 after replay surfaces as ok:false', async () => { const triggerReauth = vi.fn(async () => {}); globalThis.puter = { @@ -1181,6 +1244,33 @@ describe('driverCall', () => { expect(xhrs.length).toBe(0); // no request without a token }); + it('replays a driver call once after a legacy token_auth_failed re-login', async () => { + const authenticateWithPuter = vi.fn(async () => { + globalThis.puter.authToken = 'fresh'; + }); + globalThis.puter = { + authToken: 'stale', + APIOrigin: 'https://api.example', + env: 'web', + resetAuthToken: vi.fn(), + ui: { authenticateWithPuter }, + }; + + const xhrs = installFakeXHR( + sequence( + respond({ status: 401, body: { code: 'token_auth_failed' } }), + respond({ body: { success: true, result: 'v' } }), + ), + ); + + await expect(driverCall(call)).resolves.toBe('v'); + expect(authenticateWithPuter).toHaveBeenCalledTimes(1); + expect(xhrs.length).toBe(2); + // The replay carries the token the new session installed, not the + // one the first attempt was rejected for. + expect(JSON.parse(xhrs[1].reqBody).auth_token).toBe('fresh'); + }); + it('resolves an NDJSON response as an iterator of lines that stringify to their text', async () => { installFakeXHR((xhr) => { xhr._setHeaders(200, { 'content-type': 'application/x-ndjson' }); diff --git a/src/puter-js/src/lib/utils.js b/src/puter-js/src/lib/utils.js index 5ce9e98bb..ea1342b8a 100644 --- a/src/puter-js/src/lib/utils.js +++ b/src/puter-js/src/lib/utils.js @@ -98,7 +98,8 @@ async function handle_resp (success_cb, error_cb, resolve_func, reject_func, res // error - unauthorized if ( response.status === 401 ) { const spec = response._puterReq; - const reauth = await resolveReauth(resp, { + // A replay's 401 is final: one sign-in per call. + const reauth = spec?._replayed ? null : await resolveReauth(resp, { interactive: spec?.interactiveReauth !== false, sentToken: spec?._sentAuthToken, }); diff --git a/src/puter-js/src/lib/utils.reauth.test.js b/src/puter-js/src/lib/utils.reauth.test.js new file mode 100644 index 000000000..cca7dfb4e --- /dev/null +++ b/src/puter-js/src/lib/utils.reauth.test.js @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { initXhr, setupXhrEventHandlers } from './utils.js'; + +/** + * A legacy XHR request refused with `token_auth_failed` signs the user in and + * replays once with the fresh token. A refusal that survives the replay reaches + * the caller without a second sign-in. + */ + +// Minimal XHR fake: answers each request from a queue of { status, body }. +function installFakeXHR (responses) { + const requests = []; + class FakeXHR { + _listeners = {}; + _headers = {}; + responseType = ''; + status = 200; + open (method, url) { this.method = method; this.url = url; } + setRequestHeader (name, value) { this._headers[name.toLowerCase()] = value; } + addEventListener (type, fn) { (this._listeners[type] ??= []).push(fn); } + getResponseHeader () { return null; } + send (body) { + requests.push(this); + const next = responses.shift(); + queueMicrotask(() => { + this.status = next.status; + this.responseText = JSON.stringify(next.body); + for ( const fn of this._listeners.load ?? [] ) fn.call(this, { target: this }); + }); + } + } + globalThis.XMLHttpRequest = FakeXHR; + return requests; +} + +const send = () => new Promise((resolve, reject) => { + const xhr = initXhr('/whoami', 'https://api.test', 'stale', 'get'); + setupXhrEventHandlers(xhr, undefined, undefined, resolve, reject); + xhr.send(); +}); + +const authFailed = { status: 401, body: { code: 'token_auth_failed' } }; +const ok = { status: 200, body: { username: 'u' } }; + +let savedXHR; +beforeEach(() => { + savedXHR = globalThis.XMLHttpRequest; + globalThis.puter = { + authToken: 'stale', + APIOrigin: 'https://api.test', + env: 'web', + resetAuthToken: vi.fn(() => { + globalThis.puter.authToken = null; + }), + ui: { + authenticateWithPuter: vi.fn(async () => { + globalThis.puter.authToken = 'fresh'; + }), + }, + }; +}); +afterEach(() => { + globalThis.XMLHttpRequest = savedXHR; + delete globalThis.puter; + vi.restoreAllMocks(); +}); + +describe('token_auth_failed on a legacy XHR request', () => { + it('signs in and replays once with the fresh token', async () => { + const requests = installFakeXHR([authFailed, ok]); + await expect(send()).resolves.toEqual({ username: 'u' }); + expect(puter.ui.authenticateWithPuter).toHaveBeenCalledTimes(1); + expect(requests).toHaveLength(2); + expect(requests[1]._headers.authorization).toBe('Bearer fresh'); + }); + + it('rejects a 401 that survives the replay without signing in again', async () => { + const requests = installFakeXHR([authFailed, authFailed]); + await expect(send()).rejects.toEqual({ status: 401, message: 'Unauthorized' }); + expect(puter.ui.authenticateWithPuter).toHaveBeenCalledTimes(1); + expect(puter.resetAuthToken).toHaveBeenCalledTimes(1); + expect(requests).toHaveLength(2); + // The token from the sign-in is kept. + expect(puter.authToken).toBe('fresh'); + }); +});