mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-04 21:15:50 +00:00
fix(puter-js): replay requests with fresh body credentials (#4376)
This commit is contained in:
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' });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user