fix(puter-js): replay the request after a successful token_auth_failed reauth (#3954)
release-please / release-please (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s

* 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 <daniel.salazar@puter.com>
This commit is contained in:
Rasad RegmiandDaniel Salazar authored and GitHub committed 2026-10-23 00:07:56 -07:00
1 parent 4729d733bb
commit acd3df8c1b
4 files changed
+179 -1

No files matched your search

+1
View File
@@ -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',
+90
View File
@@ -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' });
+2 -1
View File
@@ -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,
});
+86
View File
@@ -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');
});
});