diff --git a/src/puter-js/src/lib/networkUtils.blob.test.js b/src/puter-js/src/lib/networkUtils.blob.test.js new file mode 100644 index 000000000..6eca42d2b --- /dev/null +++ b/src/puter-js/src/lib/networkUtils.blob.test.js @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { driverCall } from './networkUtils.js'; + +const SIZE = 1024 * 1024; + +function installBlobXHR(blob) { + class FakeXHR { + _listeners = {}; + status = 0; + readyState = 0; + responseType = ''; + upload = { addEventListener() {} }; + open() {} + setRequestHeader() {} + addEventListener(type, fn) { + (this._listeners[type] ??= []).push(fn); + } + getResponseHeader(name) { + return name.toLowerCase() === 'content-type' ? 'video/mp4' : null; + } + abort() {} + send() { + queueMicrotask(() => { + this.status = 200; + this.response = blob; + this.readyState = 4; + for (const fn of this._listeners.readystatechange ?? []) + fn.call(this); + for (const fn of this._listeners.load ?? []) fn.call(this); + }); + } + } + globalThis.XMLHttpRequest = FakeXHR; +} + +let savedXHR; +beforeEach(() => { + savedXHR = globalThis.XMLHttpRequest; + globalThis.puter = { + authToken: 't', + APIOrigin: 'https://api.test', + env: 'nodejs', + }; +}); +afterEach(() => { + globalThis.XMLHttpRequest = savedXHR; + delete globalThis.puter; + vi.restoreAllMocks(); +}); + +describe('successful blob driver call', () => { + it('returns the blob without decoding it to text', async () => { + const blob = new Blob([randomBytes(SIZE)], { type: 'video/mp4' }); + installBlobXHR(blob); + const textSpy = vi.spyOn(Blob.prototype, 'text'); + + const t0 = performance.now(); + const result = await driverCall( + { + iface: 'puter-video-generation', + method: 'generate', + args: {}, + puter: globalThis.puter, + }, + { responseType: 'blob' }, + ); + const ms = performance.now() - t0; + + expect(result).toBe(blob); + expect(textSpy).not.toHaveBeenCalled(); + process.stdout.write( + `driverCall on a ${SIZE / 1024 / 1024} MiB blob: ${ms.toFixed(1)} ms\n`, + ); + }); +}); diff --git a/src/puter-js/src/lib/networkUtils.bodyToken.test.js b/src/puter-js/src/lib/networkUtils.bodyToken.test.js new file mode 100644 index 000000000..dd55c65ba --- /dev/null +++ b/src/puter-js/src/lib/networkUtils.bodyToken.test.js @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fsRequest } from '../modules/FileSystem/operations/scaffold.js'; +import { driverCallEnvelope } from './networkUtils.js'; + +function installFakeXHR(responses) { + const requests = []; + class FakeXHR { + _listeners = {}; + _headers = {}; + responseType = ''; + status = 200; + readyState = 0; + upload = { addEventListener() {} }; + 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(name) { + return name.toLowerCase() === 'content-type' + ? 'application/json' + : null; + } + abort() {} + send(body) { + this.body = body; + requests.push(this); + const next = responses.shift(); + queueMicrotask(() => { + this.status = next.status; + this.responseText = JSON.stringify(next.body); + this.response = this.responseText; + this.readyState = 4; + this.onreadystatechange?.(); + for (const fn of this._listeners.readystatechange ?? []) + fn.call(this); + for (const fn of this._listeners.load ?? []) + fn.call(this, { target: this }); + }); + } + } + globalThis.XMLHttpRequest = FakeXHR; + return requests; +} + +const authFailed = { status: 401, body: { code: 'token_auth_failed' } }; + +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('reauth replay of body-token requests', () => { + it('fs /stat rebuilds its body token after sign-in', async () => { + const requests = installFakeXHR([ + authFailed, + { status: 200, body: { success: true, result: 'ok' } }, + ]); + const fsModule = { + APIOrigin: 'https://api.test', + get authToken() { + return puter.authToken; + }, + }; + await expect( + fsRequest.call(fsModule, { + endpoint: '/stat', + authHeader: false, + body: { path: '/u/x', auth_token: fsModule.authToken }, + }), + ).resolves.toEqual({ success: true, result: 'ok' }); + + expect(puter.ui.authenticateWithPuter).toHaveBeenCalledTimes(1); + expect(puter.authToken).toBe('fresh'); + expect(requests).toHaveLength(2); + expect(JSON.parse(requests[1].body).auth_token).toBe('fresh'); + expect(requests[1]._headers.authorization).toBeUndefined(); + }); + + it('puter.drivers.call rebuilds its body token after sign-in', async () => { + const requests = installFakeXHR([ + authFailed, + { status: 200, body: { success: true, result: 'ok' } }, + ]); + const result = await driverCallEnvelope({ + puter: globalThis.puter, + iface: 'puter-kvstore', + method: 'get', + args: { key: 'k' }, + }); + + expect(puter.ui.authenticateWithPuter).toHaveBeenCalledTimes(1); + expect(requests).toHaveLength(2); + expect(JSON.parse(requests[1].body).auth_token).toBe('fresh'); + expect(result).toEqual({ success: true, result: 'ok' }); + }); +}); diff --git a/src/puter-js/src/lib/networkUtils.js b/src/puter-js/src/lib/networkUtils.js index 92bbdc2ad..f5921116d 100644 --- a/src/puter-js/src/lib/networkUtils.js +++ b/src/puter-js/src/lib/networkUtils.js @@ -88,18 +88,26 @@ function resolveBackgroundReauth(resp, sentToken) { * reauth-recoverable 401 and the caller should handle it normally. */ async function resolveReauth(resp, { interactive = true, sentToken } = {}) { + const needsReauth = + resp?.code === 'reauth_required' || resp?.code === 'token_auth_failed'; + if ( + needsReauth && + sentToken && + puter.authToken && + sentToken !== puter.authToken + ) { + return { action: 'replay' }; + } + if ( + needsReauth && + puter.env === 'app' && + !puter.isGodmodeToken_?.(sentToken ?? puter.authToken) + ) { + return { action: 'reject', error: { status: 401, ...resp } }; + } // A godmode app's token expires on schedule and the desktop renews it. // Asking shows no UI, so background requests ask too. - if ( - (resp?.code === 'reauth_required' || - resp?.code === 'token_auth_failed') && - puter.env === 'app' && - puter.isGodmodeToken_?.(sentToken ?? puter.authToken) - ) { - // Renewed while this request was in flight. - if (sentToken && puter.authToken && sentToken !== puter.authToken) { - return { action: 'replay' }; - } + if (needsReauth && puter.env === 'app') { try { await puter.triggerReauth({ reason: resp.reason ?? 'token_expired', @@ -293,6 +301,22 @@ function buildXhr(spec, { readSafe = false } = {}) { const origSend = xhr.send.bind(xhr); xhr.send = function (body) { spec.body = body; + // Body-token requests: builders that know the token say so, so large + // driver bodies aren't parsed just to read it. + if (typeof spec.bodyAuthToken === 'function') { + const token = spec.bodyAuthToken(); + if (typeof token === 'string' && token) spec._sentAuthToken = token; + } else if ( + typeof body === 'string' && + body.startsWith('{') && + body.includes('"auth_token"') + ) { + try { + const token = JSON.parse(body).auth_token; + if (typeof token === 'string' && token) + spec._sentAuthToken = token; + } catch {} + } // An upload listener makes the browser preflight a cross-origin // request. One carrying a bearer is preflighted already; driver calls // go out as simple requests and must stay that way. @@ -768,7 +792,7 @@ async function classifyRetry(outcome, ctx) { if (outcome.networkError) return transientRetry(ctx); const { xhr, status } = outcome; - if (outcome.parsed === undefined) { + if (outcome.parsed === undefined && [401, 403, 429].includes(status)) { outcome.parsed = await bodyJson(xhr).catch(() => null); } const parsed = outcome.parsed; @@ -929,6 +953,10 @@ function dedupe(key, factory, { windowMs = 2000 } = {}) { * @param {Object} [opts.headers] - Extra request headers (undefined/null values * skipped). * @param {string | Blob | ArrayBuffer | FormData | null} [opts.body] + * @param {() => string | Blob | ArrayBuffer | FormData | null} [opts.buildBody] + * Rebuilds the body for each attempt. + * @param {() => string | null | undefined} [opts.bodyAuthToken] The token the + * built body carries, recorded for reauth. * @param {'' | 'text' | 'json' | 'blob' | 'arraybuffer'} [opts.responseType=''] * Default is `''` * @param {boolean} [opts.withCredentials=true] Default is `true` @@ -957,6 +985,8 @@ function fetchUrl(url, opts = {}) { method = 'GET', headers = {}, body = null, + buildBody, + bodyAuthToken, responseType = '', withCredentials = true, signal, @@ -981,6 +1011,8 @@ function fetchUrl(url, opts = {}) { withCredentials, responseType, body, + buildBody, + bodyAuthToken, signal, logId, interactiveReauth, @@ -1252,6 +1284,7 @@ async function driverCall(call, opts = {}) { responseType, // Rebuilt per attempt, so a reauth replay carries the fresh token. buildBody: () => callBody(call, puter), + bodyAuthToken: () => puter.authToken, timeout, signal, }; @@ -1320,7 +1353,8 @@ async function driverCallEnvelope(call) { const resp = await fetchUrl(`${puter.APIOrigin}/drivers/call`, { method: 'POST', headers: { 'Content-Type': DRIVER_CONTENT_TYPE }, - body: callBody(call, puter), + buildBody: () => callBody(call, puter), + bodyAuthToken: () => puter.authToken, }); // TODO: parser for Content-Type diff --git a/src/puter-js/src/lib/networkUtils.reauth.test.js b/src/puter-js/src/lib/networkUtils.reauth.test.js new file mode 100644 index 000000000..74a8e814c --- /dev/null +++ b/src/puter-js/src/lib/networkUtils.reauth.test.js @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('socket.io-client', () => ({ + io: () => ({ + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + disconnect: vi.fn(), + }), +})); +vi.mock('./polyfills/localStorage.js', () => ({ default: {} })); +vi.mock('./polyfills/xhrshim.js', () => ({ default: class {} })); + +const STORAGE_KEY = 'puter.auth.token.v2'; +const b64url = (value) => Buffer.from(value).toString('base64url'); +const jwt = (payload) => + `${b64url('{"alg":"HS256"}')}.${b64url(JSON.stringify(payload))}.sig`; +const APP_TOKEN = jwt({ + t: 'au', + v: '2', + au: Buffer.alloc(16, 1).toString('base64'), +}); + +const origDefine = customElements.define.bind(customElements); +customElements.define = (name, cls) => + customElements.get(name) || origDefine(name, cls); + +const origParent = Object.getOwnPropertyDescriptor(globalThis, 'parent'); +const origXHR = globalThis.XMLHttpRequest; +class StubXHR { + open() {} + setRequestHeader() {} + addEventListener() {} + send() {} +} + +let parentPost; +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + parentPost = vi.fn(); + Object.defineProperty(globalThis, 'parent', { + value: { postMessage: parentPost }, + configurable: true, + }); + Object.defineProperty(document, 'readyState', { + value: 'loading', + configurable: true, + }); + globalThis.XMLHttpRequest = StubXHR; +}); +afterEach(() => { + vi.useRealTimers(); + if (origParent) Object.defineProperty(globalThis, 'parent', origParent); + delete document.readyState; + globalThis.XMLHttpRequest = origXHR; + vi.restoreAllMocks(); +}); + +describe('reauth_required in a non-godmode app', () => { + it('rejects promptly without asking the desktop to renew a regular app token', async () => { + history.replaceState( + null, + '', + `/app/index.html?puter.app_instance_id=i&puter.auth.token=${APP_TOKEN}`, + ); + vi.resetModules(); + const { puter } = await import('../index.js'); + const { resolveReauth } = await import('./networkUtils.js'); + expect(puter.env).toBe('app'); + expect(puter.authToken).toBe(APP_TOKEN); + parentPost.mockClear(); + + vi.useFakeTimers(); + let settled; + const pending = resolveReauth( + { code: 'reauth_required', reason: 'session_revoked' }, + { interactive: true, sentToken: APP_TOKEN }, + ).then((v) => { + settled = v; + }); + + await pending; + expect(parentPost).not.toHaveBeenCalled(); + expect(puter.authToken).toBe(APP_TOKEN); + expect(settled).toMatchObject({ + action: 'reject', + error: { code: 'reauth_required' }, + }); + }); +}); + +describe('token_auth_failed for a request sent before a sign-in finished (web)', () => { + it('replays with the fresh token without opening another sign-in', async () => { + vi.resetModules(); + const { resolveReauth } = await import('./networkUtils.js'); + globalThis.puter = { + env: 'web', + // Another call's sign-in already installed this one. + authToken: 'fresh', + resetAuthToken: vi.fn(() => { + globalThis.puter.authToken = null; + }), + ui: { + authenticateWithPuter: vi.fn(async () => { + globalThis.puter.authToken = 'second'; + }), + }, + }; + const verdict = await resolveReauth( + { code: 'token_auth_failed' }, + { interactive: true, sentToken: 'stale' }, + ); + expect(verdict).toEqual({ action: 'replay' }); + expect(globalThis.puter.resetAuthToken).not.toHaveBeenCalled(); + expect( + globalThis.puter.ui.authenticateWithPuter, + ).not.toHaveBeenCalled(); + delete globalThis.puter; + }); +}); diff --git a/src/puter-js/src/lib/utils.js b/src/puter-js/src/lib/utils.js index ea1342b8a..fa58302c9 100644 --- a/src/puter-js/src/lib/utils.js +++ b/src/puter-js/src/lib/utils.js @@ -72,7 +72,7 @@ function replayXhr (response, success_cb, error_cb, resolve_func, reject_func) { const newSpec = { ...spec, _replayed: true }; const newXhr = buildXhr(newSpec); setupXhrEventHandlers(newXhr, success_cb, error_cb, resolve_func, reject_func); - newXhr.send(spec.body); + newXhr.send(typeof newSpec.buildBody === 'function' ? newSpec.buildBody() : spec.body); return true; } diff --git a/src/puter-js/src/modules/FileSystem/operations/scaffold.js b/src/puter-js/src/modules/FileSystem/operations/scaffold.js index 91fd9ebf2..b1895b86e 100644 --- a/src/puter-js/src/modules/FileSystem/operations/scaffold.js +++ b/src/puter-js/src/modules/FileSystem/operations/scaffold.js @@ -182,7 +182,12 @@ export async function fsRequest (spec) { } }, rejectWithPrompt); - xhr.send(body === undefined ? undefined : JSON.stringify(body)); + const buildBody = () => body === undefined ? undefined : JSON.stringify({ + ...body, + ...(Object.hasOwn(body, 'auth_token') ? { auth_token: this.authToken } : {}), + }); + if (xhr._puterReq) xhr._puterReq.buildBody = buildBody; + xhr.send(buildBody()); }); }