mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-24 23:58:47 +00:00
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
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:
1 parent
4729d733bb
commit
acd3df8c1b
4 files changed
+179
-1
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user