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