fix(puter-js): coordinate sign-in callbacks and filesystem sockets (#4375)

This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-10-26 22:09:52 -07:00
1 parent c8f7b1ceda
commit f1964778b4
12 files changed
+458 -11

No files matched your search

+4 -1
View File
@@ -191,8 +191,11 @@ async function UIWindowLogin (options) {
// returns it as a plain sign-in popup, losing whatever the popup
// was opened to do — and, for a permission prompt, handing the
// opener a token instead of a decision. Don't offer it there.
// A cross-origin-isolated opener can only get the token through
// the session relay, which the provider's return doesn't keep.
if ( window.embedded_in_popup
&& ! offersFederatedSignInInPopup(window.gui_action) ) {
&& ( ! offersFederatedSignInInPopup(window.gui_action)
|| window.url_query_params?.get('cross_origin_isolated') === 'true' ) ) {
return;
}
const res = await fetch(`${window.api_origin}/auth/oidc/providers`);
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
const state = vi.hoisted(() => ({ el: null }));
vi.mock('./UIWindowRecoverPassword.js', () => ({ default: vi.fn() }));
vi.mock('./UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() }));
vi.mock('./UIWindowPhoneVerificationRequired.js', () => ({ default: vi.fn() }));
vi.mock('./UIWindowCardVerificationRequired.js', () => ({ default: vi.fn() }));
vi.mock('../helpers/authLogoHeader.js', () => ({
authLogoHeader: () => '',
wireAuthLogoHeader: vi.fn(),
}));
vi.mock('./UIWindow.js', () => ({
default: async (options) => {
state.el = document.createElement('div');
state.el.innerHTML = options.body_content;
document.body.append(state.el);
options.onAppend?.(state.el);
return state.el;
},
}));
globalThis.def = globalThis.def ?? ((value) => value);
const { default: UIWindowLogin } = await import('./UIWindowLogin.js');
const { default: UIWindowSignup } = await import('./UIWindowSignup.js');
const PROVIDERS_URL = 'https://api.puter.test/auth/oidc/providers';
const RELAY_POPUP =
'embedded_in_popup=true&msg_id=1&cross_origin_isolated=true&signin_session=s';
const PLAIN_POPUP = 'embedded_in_popup=true&msg_id=1';
const asPopup = (query) => {
window.embedded_in_popup = true;
window.gui_action = 'sign-in';
window.url_query_params = new URLSearchParams(query);
};
const askedForProviders = () =>
fetch.mock.calls.some(([url]) => url === PROVIDERS_URL);
beforeEach(() => {
document.body.innerHTML = '';
globalThis.$ = jQuery;
jQuery.fn.close = vi.fn();
globalThis.i18n = (key) => key;
globalThis.html_encode = (value) => jQuery('<div>').text(value).html();
globalThis.fetch = vi.fn(
async () => new Response(JSON.stringify({ providers: ['google'] })),
);
window.gui_origin = 'https://puter.test';
window.api_origin = 'https://api.puter.test';
window.gui_params = { turnstileSiteKey: 'site-key' };
window.turnstile = { render: vi.fn() };
window.icons = {};
window.uuidv4 = () => 'oidc-popup-test';
window.embedded_in_popup = false;
window.gui_action = undefined;
window.url_query_params = new URLSearchParams('');
state.el = null;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe.each([
[
'login',
() => UIWindowLogin({ reload_on_success: false, redirect_url: '/' }),
'.login-btn',
],
[
'signup',
() => UIWindowSignup({ reload_on_success: false, redirect_url: '/' }),
'.signup-btn',
],
])('OIDC buttons in the %s window', (_name, open, formButton) => {
const rendered = async () => {
void open();
await vi.waitFor(() =>
expect(jQuery(state.el).find(formButton)).toHaveLength(1),
);
};
// The provider's return drops the relay that hands the token to an
// isolated opener, so only password sign-in can finish there.
it('hides them in a cross-origin-isolated relay popup', async () => {
asPopup(RELAY_POPUP);
await rendered();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(askedForProviders()).toBe(false);
expect(jQuery(state.el).find('.oidc-google-btn').css('display')).toBe(
'none',
);
});
it('keeps them in a regular sign-in popup', async () => {
asPopup(PLAIN_POPUP);
await rendered();
await vi.waitFor(() =>
expect(
jQuery(state.el).find('.oidc-google-btn').css('display'),
).toBe('flex'),
);
});
it('keeps them outside a popup', async () => {
await rendered();
await vi.waitFor(() =>
expect(
jQuery(state.el).find('.oidc-google-btn').css('display'),
).toBe('flex'),
);
expect(askedForProviders()).toBe(true);
});
});
+8 -2
View File
@@ -285,10 +285,16 @@ function UIWindowSignup(options) {
// provider returns it as a plain sign-in popup, losing
// whatever the popup was opened to do — and, for a
// permission prompt, handing the opener a token instead
// of a decision. Don't offer it there.
// of a decision. Don't offer it there. A
// cross-origin-isolated opener can only get the token
// through the session relay, which the provider's
// return doesn't keep.
if (
window.embedded_in_popup &&
!offersFederatedSignInInPopup(window.gui_action)
(!offersFederatedSignInInPopup(window.gui_action) ||
window.url_query_params?.get(
'cross_origin_isolated',
) === 'true')
) {
return;
}
+134
View File
@@ -0,0 +1,134 @@
// @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('./lib/polyfills/localStorage.js', () => ({ default: {} }));
vi.mock('./lib/polyfills/xhrshim.js', () => ({ default: class {} }));
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;
const urls = [];
class CountingXHR {
_listeners = {};
status = 200;
readyState = 0;
responseType = '';
upload = { addEventListener() {} };
open(method, url) {
this.url = url;
urls.push(`${method} ${url}`);
}
setRequestHeader() {}
addEventListener(type, fn) {
(this._listeners[type] ??= []).push(fn);
}
getResponseHeader(n) {
return n.toLowerCase() === 'content-type' ? 'application/json' : null;
}
abort() {}
send() {
queueMicrotask(() => {
this.responseText = JSON.stringify({ username: 'u' });
this.response = this.responseText;
this.readyState = 4;
for (const fn of this._listeners.readystatechange ?? [])
fn.call(this);
for (const fn of this._listeners.load ?? [])
fn.call(this, { target: this });
});
}
}
beforeEach(() => {
localStorage.clear();
urls.length = 0;
Object.defineProperty(globalThis, 'parent', {
value: globalThis,
configurable: true,
});
globalThis.XMLHttpRequest = CountingXHR;
});
afterEach(() => {
if (origParent) Object.defineProperty(globalThis, 'parent', origParent);
globalThis.XMLHttpRequest = origXHR;
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const flush = async () => {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
};
describe('implicit sign-in on a web page', () => {
it('runs onAuth twice for one sign-in', async () => {
history.replaceState(null, '', '/site/index.html');
vi.resetModules();
// Hide `process` from the SDK's environment probe only, so the
// module's own instance boots as a top-level web page.
const realProcess = globalThis.process;
Object.defineProperty(globalThis, 'process', {
configurable: true,
get() {
const stack = new Error().stack ?? '';
return /src\/index\.js:\d+/.test(stack.split('\n')[2] ?? '')
? undefined
: realProcess;
},
});
let puter;
try {
({ puter } = await import('./index.js'));
} finally {
Object.defineProperty(globalThis, 'process', {
value: realProcess,
configurable: true,
writable: true,
});
}
expect(puter.env).toBe('web');
expect(puter.authToken).toBeFalsy();
const popup = { closed: false };
vi.spyOn(window, 'open').mockReturnValue(popup);
Object.defineProperty(navigator, 'userActivation', {
value: { hasBeenActive: true, isActive: true },
configurable: true,
});
vi.spyOn(crypto, 'randomUUID').mockReturnValue('sess-1');
const onAuth = vi.fn();
puter.onAuth = onAuth;
const signedIn = puter.ui.authenticateWithPuter();
window.dispatchEvent(
new MessageEvent('message', {
origin: puter.defaultGUIOrigin,
source: popup,
data: {
msg: 'puter.token',
msg_id: 'sess-1',
success: true,
token: 'tok',
},
}),
);
await signedIn;
await flush();
expect(puter.authToken).toBe('tok');
expect(onAuth).toHaveBeenCalledTimes(1);
const whoami = urls.filter((u) => u.includes('/whoami'));
expect(whoami.length).toBe(1);
});
});
+9 -3
View File
@@ -722,8 +722,6 @@ export class Puter {
// When SDK is loaded in GUI the initiation process should start when the DOM is ready. This is because
// the SDK needs to show a dialog to the user to ask for permission to access their Puter account.
else if (this.env === 'web') {
// initialize submodules
this.initSubmodules();
try {
const storedToken = this.normalizeAuthTokenCandidate(
localStorage.getItem(STORAGE_KEY_V2),
@@ -754,6 +752,8 @@ export class Puter {
console.error('Error accessing localStorage:', error);
}
this.initSubmodules();
// Print a CTA for developers to publish their app on the Puter App Store
this.printDevCTA();
@@ -2015,9 +2015,14 @@ globalThis.addEventListener &&
'*',
);
} else if (event.data.msg === 'puter.token') {
if (puter.auth?.pendingSignInMessageIds_?.has(event.data.msg_id))
return;
if (!puter.tokenSourceAllowed_(event.source)) {
return;
}
// Could be a late reply from an old popup; the flow that owns the
// prompt settles its own failures.
if (event.data.success === false) return;
// Set the authToken property
puter.setAuthToken(event.data.token);
// update appID only when token does not include app identity
@@ -2038,7 +2043,8 @@ globalThis.addEventListener &&
// Not awaited: the waiting sign-ins below settle first.
(async () => {
try {
puter.onAuth(await puter.getUser());
const user = await puter.whoamiCache_;
puter.onAuth(user ?? (await puter.getUser()));
} catch (e) {
console.error(e);
}
+23
View File
@@ -328,6 +328,29 @@ describe('GUI message listener', () => {
).resolves.toBeUndefined();
});
it('ignores a late failure message without touching a pending sign-in', async () => {
const { puter, handler } = await bootWithListener();
const previous = puter.authToken;
const waiter = { resolve: vi.fn(), reject: vi.fn() };
puter.puterAuthState.isPromptOpen = true;
puter.puterAuthState.resolvers.push(waiter);
await handler({
origin: puter.defaultGUIOrigin,
source: globalThis.parent,
data: {
msg: 'puter.token',
success: false,
token: 'invalid',
error: 'auth_canceled',
},
});
expect(puter.authToken).toBe(previous);
expect(waiter.reject).not.toHaveBeenCalled();
expect(waiter.resolve).not.toHaveBeenCalled();
expect(puter.puterAuthState.resolvers).toEqual([waiter]);
expect(puter.puterAuthState.isPromptOpen).toBe(true);
});
it('reports a failed user lookup after sign-in', async () => {
const { puter, handler } = await bootWithListener();
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
+87
View File
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const ioCalls = [];
vi.mock('socket.io-client', () => ({
io: (origin, opts) => {
ioCalls.push({ origin, token: opts?.auth?.auth_token ?? null });
return {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
disconnect: vi.fn(),
};
},
}));
vi.mock('./lib/polyfills/localStorage.js', () => ({ default: {} }));
vi.mock('./lib/polyfills/xhrshim.js', () => ({ default: class {} }));
const STORAGE_KEY = 'puter.auth.token.v2';
const ORIGIN_KEY = 'puter.auth.token.origin.v2';
const b64url = (value) => Buffer.from(value).toString('base64url');
const APP_TOKEN = `${b64url('{"alg":"HS256"}')}.${b64url(JSON.stringify({ t: 'au', v: '2', au: Buffer.alloc(16, 1).toString('base64') }))}.sig`;
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() {}
}
beforeEach(() => {
localStorage.clear();
ioCalls.length = 0;
Object.defineProperty(globalThis, 'parent', {
value: globalThis,
configurable: true,
});
globalThis.XMLHttpRequest = StubXHR;
});
afterEach(() => {
if (origParent) Object.defineProperty(globalThis, 'parent', origParent);
globalThis.XMLHttpRequest = origXHR;
});
describe('web-env boot with a stored token', () => {
it('opens only one authenticated socket', async () => {
localStorage.setItem(STORAGE_KEY, APP_TOKEN);
localStorage.setItem(ORIGIN_KEY, 'https://api.puter.com');
history.replaceState(null, '', '/site/index.html');
vi.resetModules();
const { Puter } = await import('./index.js');
ioCalls.length = 0;
const realProcess = globalThis.process;
Object.defineProperty(globalThis, 'process', {
configurable: true,
get() {
const caller = (new Error().stack ?? '').split('\n')[2] ?? '';
return /src\/index\.js:\d+/.test(caller)
? undefined
: realProcess;
},
});
let puter;
try {
puter = new Puter();
} finally {
Object.defineProperty(globalThis, 'process', {
value: realProcess,
configurable: true,
writable: true,
});
}
expect(puter.env).toBe('web');
expect(puter.authToken).toBe(APP_TOKEN);
expect(ioCalls).toEqual([
{ origin: 'https://api.puter.com', token: APP_TOKEN },
]);
});
});
+19
View File
@@ -116,6 +116,9 @@ import {
* are for apps that drive the sign-in flow themselves.
*/
export class AuthModule extends PuterModule {
/** @internal */
pendingSignInMessageIds_ = new Set();
/**
* Signs the user in, opening a popup with the appropriate authentication
* method. Must be called from a user gesture (such as a click) — without
@@ -164,6 +167,7 @@ export class AuthModule extends PuterModule {
return new Promise((resolve, reject) => {
const signinsession = crypto.randomUUID();
const msg_id = signinsession;
this.pendingSignInMessageIds_.add(msg_id);
const popupName = `Puter-sign-in-${signinsession}`;
const url = `${puter.defaultGUIOrigin}/action/sign-in?embedded_in_popup=true&msg_id=${msg_id}${window.crossOriginIsolated ? `&cross_origin_isolated=true&signin_session=${signinsession}` : ''}${options.attempt_temp_user_creation ? '&attempt_temp_user_creation=true' : ''}${options.request_auth ? '&request_auth=true' : ''}`;
@@ -177,6 +181,7 @@ export class AuthModule extends PuterModule {
let popupWindow = null;
const cleanup = () => {
this.pendingSignInMessageIds_.delete(msg_id);
if (checkClosed) {
clearInterval(checkClosed);
checkClosed = null;
@@ -184,6 +189,18 @@ export class AuthModule extends PuterModule {
window.removeEventListener('message', messageHandler);
};
const notifyAuth = () => {
if (typeof puter.onAuth !== 'function') return;
void (async () => {
try {
const user = await puter.whoamiCache_;
puter.onAuth(user ?? (await puter.getUser()));
} catch (error) {
console.error(error);
}
})();
};
if (window.crossOriginIsolated) {
(async () => {
// Bounded too: isolated with a gesture, nothing watches.
@@ -223,6 +240,7 @@ export class AuthModule extends PuterModule {
settled = true;
cleanup();
puter.setAuthToken(auth_token);
notifyAuth();
resolve({ success: true, token: auth_token });
return '';
}
@@ -262,6 +280,7 @@ export class AuthModule extends PuterModule {
if (e.data.success) {
// set the auth token
puter.setAuthToken(e.data.token);
notifyAuth();
resolve(e.data);
} else {
reject(e.data);
@@ -131,6 +131,18 @@ describe('signIn popup binding', () => {
},
);
it('notifies explicit sign-in once using the cached user lookup', async () => {
const user = { username: 'alice' };
puter.whoamiCache_ = Promise.resolve(user);
puter.onAuth = vi.fn();
puter.getUser = vi.fn();
const signedIn = auth.signIn();
respond(popups[0], 'token');
await signedIn;
expect(puter.onAuth).toHaveBeenCalledExactlyOnceWith(user);
expect(puter.getUser).not.toHaveBeenCalled();
});
it('keeps the isolated-page relay independent of popup messages', async () => {
vi.stubGlobal('crossOriginIsolated', true);
let answer;
@@ -103,6 +103,10 @@ export class PuterJSFileSystemModule extends PuterModule {
this.socket.disconnect();
}
this.socket = null;
if (!this.authToken) return;
this.socketToken_ = this.authToken;
this.socketOrigin_ = this.APIOrigin;
this.socket = io(this.APIOrigin, {
auth: {
auth_token: this.authToken,
@@ -212,6 +216,9 @@ export class PuterJSFileSystemModule extends PuterModule {
}
if ( this.puter.socketEnabled ) {
if (this.socket?.active &&
this.socketToken_ === this.authToken &&
this.socketOrigin_ === this.APIOrigin) return;
this.initializeSocket();
}
}
@@ -137,6 +137,43 @@ describe('auth state', () => {
expect(fs.socket).not.toBe(previousSocket);
});
it('keeps an active socket when credentials and origin have not changed', () => {
const fs = makeModule('web');
const previous = fs.socket;
previous.active = true;
fs.onAuthStateChanged();
expect(fs.socket).toBe(previous);
expect(previous.disconnect).not.toHaveBeenCalled();
});
it('replaces an inactive socket even when credentials are unchanged', () => {
const fs = makeModule('web');
const previous = fs.socket;
previous.active = false;
fs.onAuthStateChanged();
expect(fs.socket).not.toBe(previous);
expect(previous.disconnect).toHaveBeenCalledOnce();
});
it('closes the socket on sign-out without opening an anonymous replacement', () => {
const fs = makeModule('web');
const previous = fs.socket;
fs.puter.authToken = null;
fs.onAuthStateChanged();
expect(previous.disconnect).toHaveBeenCalledOnce();
expect(fs.socket).toBeNull();
});
it('replaces an active socket when the API origin changes', () => {
const fs = makeModule('web');
const previous = fs.socket;
previous.active = true;
fs.puter.APIOrigin = 'https://new-api.test';
fs.onAuthStateChanged();
expect(fs.socket).not.toBe(previous);
expect(previous.disconnect).toHaveBeenCalledOnce();
});
it('never opens a socket when the client opted out', () => {
const fs = makeModule('web', false);
-5
View File
@@ -2615,11 +2615,6 @@ export class UIModule extends EventListener {
return puter.auth.signIn({ request_auth: true }).then(
() => {
settle(true);
if ( puter.onAuth && typeof puter.onAuth === 'function' ) {
puter.getUser().then((user) => {
puter.onAuth(user);
});
}
},
(err) => {
settle(false);