fix(puter-js): replay requests with fresh body credentials (#4376)

This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-10-26 22:10:24 -07:00
1 parent f1964778b4
commit e8aa12ace5
6 files changed
+371 -14

No files matched your search

@@ -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`,
);
});
});
@@ -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' });
});
});
+46 -12
View File
@@ -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
@@ -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;
});
});
+1 -1
View File
@@ -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;
}
@@ -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());
});
}