diff --git a/src/gui/src/IPC.js b/src/gui/src/IPC.js index 780a817fb..04e5a0f69 100644 --- a/src/gui/src/IPC.js +++ b/src/gui/src/IPC.js @@ -1422,8 +1422,8 @@ const ipc_listener = async (event, handled) => { 'Authorization': `Bearer ${window.auth_token}`, }, statusCode: { - 401: function () { - window.logout(); + 401: function (xhr) { + window.handle401(xhr); }, }, }); diff --git a/src/gui/src/UI/UIDesktop.js b/src/gui/src/UI/UIDesktop.js index 3a809b096..79e2be642 100644 --- a/src/gui/src/UI/UIDesktop.js +++ b/src/gui/src/UI/UIDesktop.js @@ -125,6 +125,23 @@ async function UIDesktop (options) { console.error('GUI Socket Error:', error); }); + // PUT-1023 (GUI-1) — pick up reauth_required signals delivered via the + // socket.io handshake. SocketService emits `Error { data: { code: + // 'reauth_required', reason, auth_id } }` for legacy/revoked/expired + // tokens. Without this branch the disconnect would look like a silent + // network failure to the user. + window.socket.on('connect_error', (err) => { + const signal = err?.data; + if ( signal?.code === 'reauth_required' ) { + window.handleReauthRequired({ + reason: signal.reason, + auth_id: signal.auth_id, + }); + } else { + console.error('GUI Socket connect_error:', err); + } + }); + window.socket.on('connect', function () { // console.log('GUI Socket: Connected', window.socket.id); window.socket.emit('puter_is_actually_open'); @@ -1735,8 +1752,8 @@ async function UIDesktop (options) { 'Authorization': `Bearer ${window.auth_token}`, }, statusCode: { - 401: function () { - window.logout(); + 401: function (xhr) { + window.handle401(xhr); }, }, }); diff --git a/src/gui/src/UI/UIItem.js b/src/gui/src/UI/UIItem.js index 02d94b007..c0bf57162 100644 --- a/src/gui/src/UI/UIItem.js +++ b/src/gui/src/UI/UIItem.js @@ -1418,9 +1418,9 @@ async function UIItem (options) { 'Authorization': `Bearer ${window.auth_token}`, }, statusCode: { - 401: function () { - window.logout(); - }, + 401: function (xhr) { + window.handle401(xhr); + }, }, }); $(el_window).close(); diff --git a/src/gui/src/UI/UIWindow.js b/src/gui/src/UI/UIWindow.js index aad6d6f8e..7a2b21862 100644 --- a/src/gui/src/UI/UIWindow.js +++ b/src/gui/src/UI/UIWindow.js @@ -3727,9 +3727,9 @@ window.set_sort_by = function (item_uid, sort_by, sort_order) { 'Authorization': `Bearer ${window.auth_token}`, }, statusCode: { - 401: function () { - window.logout(); - }, + 401: function (xhr) { + window.handle401(xhr); + }, }, success: function () { }, @@ -4080,9 +4080,9 @@ window.set_layout = function (item_uid, layout) { 'Authorization': `Bearer ${window.auth_token}`, }, statusCode: { - 401: function () { - window.logout(); - }, + 401: function (xhr) { + window.handle401(xhr); + }, }, success: function () { if ( layout === 'details' ) { diff --git a/src/gui/src/UI/UIWindowDesktopBGSettings.js b/src/gui/src/UI/UIWindowDesktopBGSettings.js index 52c4dc547..e5332d927 100644 --- a/src/gui/src/UI/UIWindowDesktopBGSettings.js +++ b/src/gui/src/UI/UIWindowDesktopBGSettings.js @@ -178,9 +178,9 @@ async function UIWindowDesktopBGSettings (options) { 'Authorization': `Bearer ${window.auth_token}`, }, statusCode: { - 401: function () { - window.logout(); - }, + 401: function (xhr) { + window.handle401(xhr); + }, }, }); $(el_window).close(); diff --git a/src/gui/src/UI/UIWindowEmailConfirmationRequired.js b/src/gui/src/UI/UIWindowEmailConfirmationRequired.js index c9fb615ec..1e3a292af 100644 --- a/src/gui/src/UI/UIWindowEmailConfirmationRequired.js +++ b/src/gui/src/UI/UIWindowEmailConfirmationRequired.js @@ -130,9 +130,9 @@ function UIWindowEmailConfirmationRequired (options) { 'Authorization': `Bearer ${window.auth_token}`, }, statusCode: { - 401: function () { - window.logout(); - }, + 401: function (xhr) { + window.handle401(xhr); + }, }, success: function (res) { if ( res.email_confirmed ) { @@ -176,8 +176,8 @@ function UIWindowEmailConfirmationRequired (options) { 'Authorization': `Bearer ${window.auth_token}`, }, statusCode: { - 401: function () { - window.logout(); + 401: function (xhr) { + window.handle401(xhr); }, }, success: async function (res) { diff --git a/src/gui/src/UI/UIWindowLogin.js b/src/gui/src/UI/UIWindowLogin.js index 83d749666..75c7dd825 100644 --- a/src/gui/src/UI/UIWindowLogin.js +++ b/src/gui/src/UI/UIWindowLogin.js @@ -454,16 +454,25 @@ async function UIWindowLogin (options) { } // Prepare data for the request + // PUT-1023 (GUI-1) — when this login was triggered by a 401 + // reauth_required, forward `auth_id` (and the originating + // reason) so the backend can re-attach the user/session to the + // same stable identity (consumed by GUI-2). + const reauth_payload = options.auth_id + ? { auth_id: options.auth_id, reauth_reason: options.reauth_reason } + : {}; let data; if ( window.is_email(email_username) ) { data = JSON.stringify({ email: email_username, password: password, + ...reauth_payload, }); } else { data = JSON.stringify({ username: email_username, password: password, + ...reauth_payload, }); } diff --git a/src/gui/src/UI/UIWindowManageSessions.js b/src/gui/src/UI/UIWindowManageSessions.js index 296ec75e6..ee8692421 100644 --- a/src/gui/src/UI/UIWindowManageSessions.js +++ b/src/gui/src/UI/UIWindowManageSessions.js @@ -44,6 +44,38 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) { ...options.window_options, }); + // Backend BIGINT columns store epoch seconds (SessionStore writes + // `nowSeconds()`). timeago / Date both take ms — multiply on read. + const fmtRelative = (secs) => { + if ( !secs ) return null; + const ms = Number(secs) * 1000; + if ( !Number.isFinite(ms) || ms <= 0 ) return null; + try { + return window.timeago?.format(ms) ?? new Date(ms).toLocaleString(); + } catch { + return new Date(ms).toLocaleString(); + } + }; + const fmtAbsolute = (secs) => { + if ( !secs ) return null; + const ms = Number(secs) * 1000; + if ( !Number.isFinite(ms) || ms <= 0 ) return null; + return new Date(ms).toLocaleString(); + }; + + const sessionTitle = (session) => { + if ( session.kind === 'app' ) { + return session.app?.title || session.app?.name || i18n('ui_session_kind_app') || 'App session'; + } + if ( session.kind === 'access_token' ) { + return session.label || i18n('ui_session_kind_access_token') || 'Access token'; + } + if ( session.kind === 'web' ) { + return session.label || i18n('ui_session_kind_web') || 'Browser session'; + } + return session.label || session.kind || 'Session'; + }; + const SessionWidget = ({ session }) => { const el = document.createElement('div'); el.classList.add('session-widget'); @@ -51,18 +83,47 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) { el.classList.add('current-session'); } el.dataset.uuid = session.uuid; - // '
' + - // JSON.stringify(session, null, 2) + - // ''; - const el_uuid = document.createElement('div'); - el_uuid.textContent = session.uuid; - el.appendChild(el_uuid); - el_uuid.classList.add('session-widget-uuid'); + // ── Header: icon (app) + title + badges + const el_header = document.createElement('div'); + el_header.classList.add('session-widget-header'); + if ( session.kind === 'app' && session.app?.icon ) { + const el_icon = document.createElement('img'); + el_icon.classList.add('session-widget-app-icon'); + el_icon.src = session.app.icon; + el_icon.alt = ''; + el_header.appendChild(el_icon); + } + + const el_title = document.createElement('div'); + el_title.classList.add('session-widget-title'); + el_title.textContent = sessionTitle(session); + el_header.appendChild(el_title); + + const el_badges = document.createElement('div'); + el_badges.classList.add('session-widget-badges'); + if ( session.current ) { + const b = document.createElement('span'); + b.classList.add('session-widget-badge', 'session-widget-badge-current'); + b.textContent = i18n('ui_session_current') || 'Current'; + el_badges.appendChild(b); + } + if ( session.kind && session.kind !== 'web' ) { + const b = document.createElement('span'); + b.classList.add('session-widget-badge', `session-widget-badge-${session.kind}`); + b.textContent = session.kind; + el_badges.appendChild(b); + } + el_header.appendChild(el_badges); + el.appendChild(el_header); + + // ── Metadata rows const el_meta = document.createElement('div'); el_meta.classList.add('session-widget-meta'); - for ( const key in session.meta ) { + + const addRow = (key, value, absolute) => { + if ( !value ) return; const el_entry = document.createElement('div'); el_entry.classList.add('session-widget-meta-entry'); @@ -72,64 +133,108 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) { el_entry.appendChild(el_key); const el_value = document.createElement('div'); - el_value.textContent = session.meta[key]; + el_value.textContent = value; el_value.classList.add('session-widget-meta-value'); + if ( absolute ) el_value.title = absolute; el_entry.appendChild(el_value); el_meta.appendChild(el_entry); + }; + + if ( session.kind === 'app' && session.app ) { + addRow(i18n('ui_session_app') || 'App', session.app.title || session.app.name || session.app_uid); } + addRow( + i18n('ui_session_created') || 'Created', + fmtRelative(session.created_at), + fmtAbsolute(session.created_at), + ); + addRow( + i18n('ui_session_last_active') || 'Last active', + fmtRelative(session.last_activity), + fmtAbsolute(session.last_activity), + ); + if ( session.expires_at ) { + addRow( + i18n('ui_session_expires') || 'Expires', + fmtRelative(session.expires_at), + fmtAbsolute(session.expires_at), + ); + } + if ( session.last_ip ) { + addRow(i18n('ui_session_ip') || 'IP', session.last_ip); + } + el.appendChild(el_meta); - const el_actions = document.createElement('div'); - el_actions.classList.add('session-widget-actions'); + // ── Actions: omit revoke entirely for the current session so the + // caller can't self-revoke (backend also rejects this, but the + // button has no useful meaning here either way — /logout is + // the right path for "end the session you're using"). + if ( ! session.current ) { + const el_actions = document.createElement('div'); + el_actions.classList.add('session-widget-actions'); - const el_btn_revoke = document.createElement('button'); - el_btn_revoke.textContent = i18n('ui_revoke'); - el_btn_revoke.classList.add('button', 'button-danger'); - el_btn_revoke.addEventListener('click', async () => { - try { - const alert_resp = await UIAlert({ - message: i18n('confirm_session_revoke'), - buttons: [ - { - label: i18n('yes'), - value: 'yes', - type: 'primary', + const el_btn_revoke = document.createElement('button'); + el_btn_revoke.textContent = i18n('ui_revoke'); + el_btn_revoke.classList.add('button', 'button-danger'); + el_btn_revoke.addEventListener('click', async () => { + try { + // parent_uuid routes the UIAlert under this window so it + // stacks above the dominant manage-sessions modal. + // Without it, the confirm prompt rendered behind the + // session list because both windows share the dominant + // z-index pool. + const parent_uuid = $(w).attr('data-element_uuid'); + const alert_resp = await UIAlert({ + parent_uuid, + stay_on_top: true, + message: i18n('confirm_session_revoke'), + buttons: [ + { + label: i18n('yes'), + value: 'yes', + type: 'primary', + }, + { + label: i18n('cancel'), + }, + ], + }); + + if ( alert_resp !== 'yes' ) { + return; + } + + const anti_csrf = await services.get('anti-csrf').token(); + + const resp = await fetch(`${window.api_origin}/auth/revoke-session`, { + method: 'POST', + headers: { + Authorization: `Bearer ${puter.authToken}`, + 'Content-Type': 'application/json', }, - { - label: i18n('cancel'), - }, - ], - }); - - if ( alert_resp !== 'yes' ) { - return; + body: JSON.stringify({ + uuid: session.uuid, + anti_csrf, + }), + }); + if ( resp.ok ) { + el.remove(); + return; + } + UIAlert({ parent_uuid, stay_on_top: true, message: await resp.text() }); + } catch ( e ) { + UIAlert({ + parent_uuid: $(w).attr('data-element_uuid'), + stay_on_top: true, + message: e.toString(), + }); } - - const anti_csrf = await services.get('anti-csrf').token(); - - const resp = await fetch(`${window.api_origin}/auth/revoke-session`, { - method: 'POST', - headers: { - Authorization: `Bearer ${puter.authToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - uuid: session.uuid, - anti_csrf, - }), - }); - if ( resp.ok ) { - el.remove(); - return; - } - UIAlert({ message: await resp.text() }).appendTo(w_body); - } catch ( e ) { - UIAlert({ message: e.toString() }).appendTo(w_body); - } - }); - el_actions.appendChild(el_btn_revoke); - el.appendChild(el_actions); + }); + el_actions.appendChild(el_btn_revoke); + el.appendChild(el_actions); + } return { appendTo (parent) { diff --git a/src/gui/src/UI/UIWindowMyWebsites.js b/src/gui/src/UI/UIWindowMyWebsites.js index e485525ad..a70944043 100644 --- a/src/gui/src/UI/UIWindowMyWebsites.js +++ b/src/gui/src/UI/UIWindowMyWebsites.js @@ -160,9 +160,9 @@ $(document).on('click', '.mywebsites-site-setting', function (e) { 'Authorization': `Bearer ${window.auth_token}`, }, statusCode: { - 401: function () { - window.logout(); - }, + 401: function (xhr) { + window.handle401(xhr); + }, }, success: function () { $(`.mywebsites-card[data-uuid="${$(e.target).attr('data-site-uuid')}"]`).fadeOut(); diff --git a/src/gui/src/UI/UIWindowRecoverPassword.js b/src/gui/src/UI/UIWindowRecoverPassword.js index 7e3cd83c6..f909d608a 100644 --- a/src/gui/src/UI/UIWindowRecoverPassword.js +++ b/src/gui/src/UI/UIWindowRecoverPassword.js @@ -101,8 +101,8 @@ function UIWindowRecoverPassword (options) { username: username, }), statusCode: { - 401: function () { - window.logout(); + 401: function (xhr) { + window.handle401(xhr); }, }, success: async function (res) { diff --git a/src/gui/src/UI/UIWindowSearch.js b/src/gui/src/UI/UIWindowSearch.js index f94428cdb..28ca056a2 100644 --- a/src/gui/src/UI/UIWindowSearch.js +++ b/src/gui/src/UI/UIWindowSearch.js @@ -195,9 +195,9 @@ $(document).on('click', '.search-result', async function (e) { 'Authorization': `Bearer ${window.auth_token}`, }, statusCode: { - 401: function () { - window.logout(); - }, + 401: function (xhr) { + window.handle401(xhr); + }, }, }); } catch ( err ) { diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index 67c1a1dbb..5da2a7226 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -5066,9 +5066,55 @@ html.dark-mode .usage-table-show-less:hover { .session-widget-meta { display: flex; flex-direction: column; - gap: 10px; - max-height: 100px; - overflow-y: scroll; + gap: 6px; +} + +.session-widget-header { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.session-widget-app-icon { + width: 24px; + height: 24px; + border-radius: 4px; + flex-shrink: 0; +} + +.session-widget-title { + font-size: 13px; + font-weight: 600; + color: #222; + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-widget-badges { + display: flex; + flex-direction: row; + gap: 4px; + flex-shrink: 0; +} + +.session-widget-badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 6px; + border-radius: 10px; + background-color: #e0e0e0; + color: #555; +} + +.session-widget-badge-current { + background-color: #1f7a3a; + color: #fff; } .session-widget-meta-entry { diff --git a/src/gui/src/globals.js b/src/gui/src/globals.js index 033c3a9ea..90e90200b 100644 --- a/src/gui/src/globals.js +++ b/src/gui/src/globals.js @@ -55,8 +55,13 @@ if ( window.logged_in_users === null ) window.logged_in_users = []; } -// this sessions's user -window.auth_token = localStorage.getItem('auth_token'); +// this sessions's user — PUT-1023 (GUI-1) prefers the v2 storage key. The +// legacy key is still consulted so that users with a stale v1 token get +// picked up here (and immediately routed through the reauth modal by the +// first 401 from the backend). +window.auth_token = + localStorage.getItem('auth_token_v2') + || localStorage.getItem('auth_token'); try { window.user = JSON.parse(localStorage.getItem('user')); } catch (e) { diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index 974abef92..33291e821 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -31,8 +31,16 @@ import UIWindowLogin from './UI/UIWindowLogin.js'; import UIWindowProgress from './UI/UIWindowProgress.js'; import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js'; +// PUT-1023 (GUI-1) — localStorage keys for the GUI session token. v2 is the +// new key written after the v1→v2 cutover. Reads prefer v2; v1 is only kept +// to drive the reauth-modal path when a stale legacy session is found. +window.AUTH_TOKEN_KEY_V1 = 'auth_token'; +window.AUTH_TOKEN_KEY_V2 = 'auth_token_v2'; + window.is_auth = () => { - if ( localStorage.getItem('auth_token') === null || window.auth_token === null ) + const stored = localStorage.getItem(window.AUTH_TOKEN_KEY_V2) + || localStorage.getItem(window.AUTH_TOKEN_KEY_V1); + if ( stored === null || window.auth_token === null ) { return false; } @@ -42,6 +50,101 @@ window.is_auth = () => { } }; +/** + * PUT-1023 (GUI-1) — central handler for `401 { code: 'reauth_required' }`. + * + * The backend `authProbe` middleware (AUTH-4) emits this 401 shape whenever + * a token is legacy/revoked/expired. The GUI must NOT silently logout: that + * loses window/URL state and surprises the user. Instead we: + * 1. Snapshot enough state to land back where we were after sign-in. + * 2. Clear both the v1 and v2 token keys. + * 3. Show a soft modal explaining what happened. + * 4. Re-open UIWindowLogin, forwarding `auth_id` so temp users (and + * pending GUI-2 work) can re-attach to the same identity. + * + * Idempotent: parallel 401s while the modal is already open are dropped. + */ +window._reauthInProgress = false; +window.handleReauthRequired = async (signal = {}) => { + if ( window._reauthInProgress ) return; + window._reauthInProgress = true; + try { + // Snapshot the current URL so login can land us back here. + try { + sessionStorage.setItem('reauth_return_to', window.location.href); + } catch ( e ) { + // sessionStorage may be unavailable in embedded contexts; non-fatal. + } + // Drop both keys — once we have a reauth signal the current token + // must not be used again, and a stale v1 key would re-trigger the + // same modal on the next boot. + try { + localStorage.removeItem(window.AUTH_TOKEN_KEY_V2); + localStorage.removeItem(window.AUTH_TOKEN_KEY_V1); + } catch ( e ) { /* ignore */ } + window.auth_token = null; + + // Soft modal. UIAlert is the lightest-weight existing surface; the + // copy is the one specified by GUI-1's spec. + try { + await UIAlert({ + message: i18n('reauth_required_message') || 'Your session was updated for security — please sign in again.', + buttons: [ + { + label: i18n('sign_in') || 'Sign in', + value: 'sign_in', + type: 'primary', + }, + ], + }); + } catch ( e ) { + // If the alert surface isn't available (very early in boot), + // fall through to UIWindowLogin directly. + } + + // Open the login window, forwarding auth_id (consumed in GUI-2). + try { + const login = await UIWindowLogin({ + reload_on_success: true, + auth_id: signal.auth_id, + reauth_reason: signal.reason, + redirect_url: sessionStorage.getItem('reauth_return_to') || window.location.href, + }); + if ( login ) { + try { sessionStorage.removeItem('reauth_return_to'); } catch ( e ) { /* ignore */ } + } + } catch ( e ) { + console.error('Reauth login flow failed:', e); + } + } finally { + window._reauthInProgress = false; + } +}; + +/** + * Inspect a jQuery / fetch error payload for the v2 `reauth_required` shape + * and route to either the reauth modal (soft) or the legacy hard-logout. + * + * Accepts a jQuery xhr-like object OR a parsed response body. + */ +window.handle401 = (xhrOrBody) => { + let body = null; + if ( xhrOrBody && typeof xhrOrBody === 'object' ) { + if ( 'responseJSON' in xhrOrBody && xhrOrBody.responseJSON ) { + body = xhrOrBody.responseJSON; + } else if ( 'responseText' in xhrOrBody && xhrOrBody.responseText ) { + try { body = JSON.parse(xhrOrBody.responseText); } catch ( e ) { body = null; } + } else if ( xhrOrBody.code ) { + body = xhrOrBody; + } + } + if ( body?.code === 'reauth_required' ) { + window.handleReauthRequired({ reason: body.reason, auth_id: body.auth_id }); + return; + } + window.logout(); +}; + window.suggest_apps_for_fsentry = async (options) => { let res = await $.ajax({ url: `${window.api_origin }/suggest_apps`, @@ -55,8 +158,8 @@ window.suggest_apps_for_fsentry = async (options) => { 'Authorization': `Bearer ${window.auth_token}`, }, statusCode: { - 401: function () { - window.logout(); + 401: function (xhr) { + window.handle401(xhr); }, }, success: function (res) { @@ -477,7 +580,10 @@ window.refresh_user_data = async (auth_token) => { window.update_auth_data = async (auth_token, user, api_origin) => { window.auth_token = auth_token; - localStorage.setItem('auth_token', auth_token); + // PUT-1023 (GUI-1) — write the v2 key going forward and clear any + // lingering v1 key so a single localStorage source-of-truth is used. + localStorage.setItem(window.AUTH_TOKEN_KEY_V2, auth_token); + localStorage.removeItem(window.AUTH_TOKEN_KEY_V1); // Set http-only session cookie when user is changing. // This ensures user-protected endpoints, which only refer to the http-only cookie, @@ -635,10 +741,60 @@ window.sendWindowWillCloseMsg = function (iframe_element) { window.logout = () => { // clear cache puter._cache.flushall(); + // PUT-1023 (GUI-1) — clear both old and new token keys. Cookie clear + // is handled by the backend logout endpoint. + try { + localStorage.removeItem(window.AUTH_TOKEN_KEY_V2); + localStorage.removeItem(window.AUTH_TOKEN_KEY_V1); + } catch ( e ) { /* ignore */ } $(document).trigger('logout'); // document.dispatchEvent(new Event("logout", { bubbles: true})); }; +// PUT-1023 (GUI-1) — global jQuery ajax error interceptor. Catches any +// `401 { code: 'reauth_required' }` that wasn't already handled by a more +// specific `statusCode[401]` callback on the call site, so we don't have +// to audit every legacy `$.ajax` call when AUTH-4 rolls out. +if ( typeof $ !== 'undefined' && $.fn ) { + $(document).ajaxError(function (event, jqxhr, settings, thrownError) { + if ( jqxhr?.status !== 401 ) return; + if ( window._reauthInProgress ) return; + let body = jqxhr.responseJSON; + if ( ! body && jqxhr.responseText ) { + try { body = JSON.parse(jqxhr.responseText); } catch ( e ) { /* ignore */ } + } + if ( body?.code === 'reauth_required' ) { + window.handleReauthRequired({ reason: body.reason, auth_id: body.auth_id }); + } + }); +} + +// PUT-1023 (GUI-1) — multi-tab propagation. If another tab signs in (sets +// the v2 token) or signs out (clears it), reflect that here without forcing +// a full page reload when we can avoid it. +if ( typeof window !== 'undefined' && window.addEventListener ) { + window.addEventListener('storage', (event) => { + if ( event.key !== window.AUTH_TOKEN_KEY_V2 ) return; + if ( event.newValue ) { + // A peer tab just (re)authed. Pick up the fresh token so + // subsequent requests stop bouncing on reauth_required. + window.auth_token = event.newValue; + try { puter?.setAuthToken?.(event.newValue, window.api_origin); } catch ( e ) { /* ignore */ } + // If our own reauth modal is up, the new token from a peer + // tab is good enough — dismiss and reload to pick up the new + // session. + if ( window._reauthInProgress ) { + window.location.reload(); + } + } else { + // A peer tab signed out. Mirror that locally as a soft logout. + if ( ! window._reauthInProgress ) { + window.logout(); + } + } + }); +} + /** * Checks if the current document is in fullscreen mode. * diff --git a/src/gui/src/helpers/generate_file_context_menu.js b/src/gui/src/helpers/generate_file_context_menu.js index 0b42509eb..0009f8c1a 100644 --- a/src/gui/src/helpers/generate_file_context_menu.js +++ b/src/gui/src/helpers/generate_file_context_menu.js @@ -365,9 +365,9 @@ const generate_file_context_menu = async function (options) { 'Authorization': `Bearer ${window.auth_token}`, }, statusCode: { - 401: function () { - window.logout(); - }, + 401: function (xhr) { + window.handle401(xhr); + }, }, }); } catch ( err ) { diff --git a/src/gui/src/helpers/open_item.js b/src/gui/src/helpers/open_item.js index 8e3612d0c..8c03c328e 100644 --- a/src/gui/src/helpers/open_item.js +++ b/src/gui/src/helpers/open_item.js @@ -303,8 +303,8 @@ Please try recreating the link.`); 'Authorization': `Bearer ${window.auth_token}`, }, statusCode: { - 401: function () { - window.logout(); + 401: function (xhr) { + window.handle401(xhr); }, }, }); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 0cb61aad1..a10c187d7 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -360,6 +360,15 @@ const en = { ui_colors: 'UI Colors', ui_manage_sessions: 'Session Manager', ui_revoke: 'Revoke', + ui_session_app: 'App', + ui_session_created: 'Created', + ui_session_current: 'Current', + ui_session_expires: 'Expires', + ui_session_ip: 'IP', + ui_session_kind_access_token: 'Access token', + ui_session_kind_app: 'App session', + ui_session_kind_web: 'Browser session', + ui_session_last_active: 'Last active', undo: 'Undo', unlimited: 'Unlimited', unzip: 'Unzip', diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index a134b94af..8e2b45c1f 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -392,7 +392,9 @@ window.initgui = async function (options) { if ( r.ok ) { const { token } = await r.json(); window.auth_token = token; - localStorage.setItem('auth_token', token); + // PUT-1023 (GUI-1) — write the v2 key; drop legacy v1 key. + localStorage.setItem(window.AUTH_TOKEN_KEY_V2 || 'auth_token_v2', token); + try { localStorage.removeItem(window.AUTH_TOKEN_KEY_V1 || 'auth_token'); } catch ( e ) { /* ignore */ } if ( typeof puter !== 'undefined' ) puter.setAuthToken(token, window.api_origin); const tokenChanged = token !== window.auth_token; if ( tokenChanged ) { diff --git a/src/puter-js/src/index.js b/src/puter-js/src/index.js index e722dbc7a..e8bd8a69c 100644 --- a/src/puter-js/src/index.js +++ b/src/puter-js/src/index.js @@ -92,6 +92,14 @@ class Lock { // (using defaultGUIOrigin breaks locally-hosted apps) const PROD_ORIGIN = 'https://puter.com'; +// localStorage keys for the auth token. v1 is the legacy key. v2 is the new +// key written after the token rotation in the v1→v2 cutover (PUT-1024). +// Reads prefer v2; v1 is only consulted to drive the silent-migration path +// (SDK access_token / app kinds) or — for kind='web' — to fail over to the +// interactive reauth flow. +const STORAGE_KEY_V1 = 'puter.auth.token'; +const STORAGE_KEY_V2 = 'puter.auth.token.v2'; + const puterInit = (function () { 'use strict'; @@ -144,6 +152,12 @@ const puterInit = (function () { // Event handling properties eventHandlers = {}; + // Reauth coordinator state (PUT-1022 PJS-1). When the backend signals + // `401 { code: 'reauth_required' }`, in-flight requests await this + // promise; the first caller drives the interactive flow, everyone + // else replays after it resolves. + _reauthInflight = null; + // debug flag debugMode = false; @@ -457,18 +471,41 @@ const puterInit = (function () { ); try { let selectedAuthToken = bootstrapAuthToken; + let needsSilentMigration = false; if ( bootstrapAuthToken ) { + // URL-param tokens may still be v1 (host apps that + // haven't rebuilt yet). Set immediately so submodules + // can run, then attempt silent migration in the + // background — PJS-2. this.setAuthToken(bootstrapAuthToken); + needsSilentMigration = true; } else { - const storedAuthToken = this.normalizeAuthTokenCandidate( - localStorage.getItem('puter.auth.token'), + // Prefer the v2 storage key (PJS-2). Fall back to v1 + // and queue a silent migrate-token call. + const v2 = this.normalizeAuthTokenCandidate( + localStorage.getItem(STORAGE_KEY_V2), ); - // If the authToken is already set in localStorage, then we don't need to show the dialog - if ( storedAuthToken ) { - this.setAuthToken(storedAuthToken); - selectedAuthToken = storedAuthToken; + if ( v2 ) { + this.setAuthToken(v2); + selectedAuthToken = v2; + } else { + const v1 = this.normalizeAuthTokenCandidate( + localStorage.getItem(STORAGE_KEY_V1), + ); + if ( v1 ) { + this.setAuthToken(v1); + selectedAuthToken = v1; + needsSilentMigration = true; + } } } + if ( needsSilentMigration && selectedAuthToken ) { + // Fire-and-forget. On success, setAuthToken inside the + // migration helper updates submodules with the v2 + // token. On failure the next 401 reauth_required + // triggers the interactive flow. + this._silentMigrateV1Token(selectedAuthToken); + } const tokenAppID = this.getAppIDFromAuthToken(selectedAuthToken); if ( !tokenAppID && !this.appID ) { // if appID is already set in localStorage, then we don't need to show the dialog @@ -490,10 +527,25 @@ const puterInit = (function () { // initialize submodules this.initSubmodules(); try { - // If the authToken is already set in localStorage, then we don't need to show the dialog - const storedAuthToken = this.normalizeAuthTokenCandidate(localStorage.getItem('puter.auth.token')); - if ( storedAuthToken ) { - this.setAuthToken(storedAuthToken); + // Prefer the v2 storage key (PJS-2). Fall back to v1 and + // run a silent migration in the background. + const v2 = this.normalizeAuthTokenCandidate( + localStorage.getItem(STORAGE_KEY_V2), + ); + if ( v2 ) { + this.setAuthToken(v2); + } else { + const v1 = this.normalizeAuthTokenCandidate( + localStorage.getItem(STORAGE_KEY_V1), + ); + if ( v1 ) { + this.setAuthToken(v1); + // For kind='access_token' the backend will swap + // this for a v2 token. For kind='web' it'll + // refuse (409) and the next request bounces us + // through the reauth popup. + this._silentMigrateV1Token(v1); + } } // if appID is already set in localStorage, then we don't need to show the dialog if ( !this.appID && localStorage.getItem('puter.app.id') ) { @@ -647,10 +699,14 @@ const puterInit = (function () { if ( this.env === 'web' || this.env === 'app' ) { try { if ( normalizedAuthToken ) { - localStorage.setItem('puter.auth.token', normalizedAuthToken); + localStorage.setItem(STORAGE_KEY_V2, normalizedAuthToken); } else { - localStorage.removeItem('puter.auth.token'); + localStorage.removeItem(STORAGE_KEY_V2); } + // Always clear the legacy v1 key on a write — once we + // have a v2 token, the v1 one must not linger or it + // would be picked up by older code paths. + localStorage.removeItem(STORAGE_KEY_V1); } catch ( error ) { // Handle the error here console.error('Error accessing localStorage:', error); @@ -706,7 +762,8 @@ const puterInit = (function () { // If the SDK is running on a 3rd-party site or an app, then save the authToken in localStorage if ( this.env === 'web' || this.env === 'app' ) { try { - localStorage.removeItem('puter.auth.token'); + localStorage.removeItem(STORAGE_KEY_V2); + localStorage.removeItem(STORAGE_KEY_V1); } catch ( error ) { // Handle the error here console.error('Error accessing localStorage:', error); @@ -716,6 +773,192 @@ const puterInit = (function () { this.updateSubmodules(); }; + /** + * PUT-1022 (PJS-1) — reauth coordinator. Called by the network layer + * (lib/utils.js) when the backend returns + * `401 { code: 'reauth_required', reason, auth_id }`. + * + * Behavior is environment-specific: + * - `web` / `app`: clear the stored token, emit an event, and + * drive the existing puter.com login popup. Returns a promise + * that resolves when the user signs in (so callers can replay) + * or rejects if reauth fails / is canceled. + * - `gui`: no-op — the GUI environment renders its own modal + * (PUT-1023 GUI-1) and host code is responsible for the flow. + * - workers / nodejs: there's no UI surface to drive, so reject + * with a structured error and let worker code react. + * + * Idempotent: parallel callers share a single in-flight promise. + * @param {{ reason?: string, auth_id?: string }} signal + */ + triggerReauth = async function (signal = {}) { + const { reason, auth_id } = signal; + if ( this._reauthInflight ) return this._reauthInflight; + + // Emit before clearing so listeners can read state if needed. + this._emitReauthEvent({ reason, auth_id }); + + // Drop the stored token immediately so a failed/canceled reauth + // doesn't leave a poisoned value in localStorage. The new token + // (if reauth succeeds) is written by setAuthToken downstream. + this.authToken = null; + if ( this.env === 'web' || this.env === 'app' ) { + try { + localStorage.removeItem(STORAGE_KEY_V2); + localStorage.removeItem(STORAGE_KEY_V1); + } catch ( e ) { + console.error('Error accessing localStorage:', e); + } + } + this.updateSubmodules(); + + this._reauthInflight = (async () => { + if ( this.env === 'gui' ) { + // GUI handles its own modal at the layer above puter-js. + return; + } + if ( + this.env === 'web-worker' || + this.env === 'service-worker' || + this.env === 'nodejs' + ) { + const err = new Error('reauth_required'); + err.code = 'reauth_required'; + err.reason = reason; + err.auth_id = auth_id; + throw err; + } + if ( this.env === 'web' ) { + // Drives the puter.com login popup. On success, the + // postMessage handler at the bottom of this file calls + // setAuthToken() and updates this.authToken. + await this.ui.authenticateWithPuter({ auth_id, reason }); + return; + } + if ( this.env === 'app' ) { + // We're inside an iframe in the GUI. Ask the parent to + // surface the reauth modal; once the user signs in there, + // the existing `puter.token` postMessage delivers the + // fresh token back to us. + // + // targetOrigin is locked to the expected GUI origin — + // postMessage('*') would leak reauth signal + auth_id + // to any embedding parent (including a malicious one), + // and the message body is only meaningful to the GUI. + try { + globalThis.parent?.postMessage?.({ + msg: 'reauth_required', + appInstanceID: this.appInstanceID, + reason, + auth_id, + }, this.defaultGUIOrigin); + } catch ( e ) { + // Best-effort: if postMessage isn't available + // (sandboxed iframe), fall through to error. + } + // Wait for the parent to deliver a fresh token. + // Validate both event.origin AND event.source — origin + // alone lets any same-origin frame on the GUI domain + // deliver a token; pinning source to globalThis.parent + // ensures the message came from the actual embedder. + await new Promise((resolve, reject) => { + const expectedSource = globalThis.parent; + const onToken = (event) => { + if ( event.origin !== this.defaultGUIOrigin ) return; + if ( expectedSource && event.source !== expectedSource ) return; + if ( event.data?.msg !== 'puter.token' ) return; + globalThis.removeEventListener('message', onToken); + resolve(); + }; + globalThis.addEventListener?.('message', onToken); + // Give the user a generous window to re-auth. + setTimeout(() => { + globalThis.removeEventListener?.('message', onToken); + reject(new Error('reauth_timeout')); + }, 5 * 60 * 1000); + }); + } + })(); + + try { + await this._reauthInflight; + } finally { + this._reauthInflight = null; + } + }; + + _emitReauthEvent = function ({ reason, auth_id }) { + try { + const handlers = this.eventHandlers?.['puter.auth.reauth_required']; + if ( Array.isArray(handlers) ) { + for ( const h of handlers ) { + try { h({ reason, auth_id }); } catch ( e ) { /* swallow per-handler errors */ } + } + } + } catch ( e ) { + // Never let event delivery break the reauth flow itself. + } + }; + + /** + * Register a listener for SDK events. Used by host apps to react + * to `puter.auth.reauth_required` (PUT-1022 PJS-1). + */ + on = function (eventName, handler) { + if ( ! this.eventHandlers[eventName] ) this.eventHandlers[eventName] = []; + this.eventHandlers[eventName].push(handler); + return () => this.off(eventName, handler); + }; + + off = function (eventName, handler) { + const handlers = this.eventHandlers[eventName]; + if ( ! handlers ) return; + const idx = handlers.indexOf(handler); + if ( idx >= 0 ) handlers.splice(idx, 1); + }; + + /** + * PUT-1024 (PJS-2) — best-effort silent v1→v2 token migration via + * the backend `/auth/migrate-token` endpoint (SDK-1). Used at boot + * when only a legacy `puter.auth.token` is present; on success the + * v2 token is set via setAuthToken (which clears the v1 key). + * + * Returns true on successful migration, false otherwise. + * Failures fall through to the existing 401-reauth path: any + * subsequent API call against the v1 token will receive a + * `reauth_required` and route through triggerReauth. + */ + _silentMigrateV1Token = async function (v1Token) { + if ( ! v1Token ) return false; + try { + const resp = await fetch(`${this.APIOrigin}/auth/migrate-token`, { + method: 'POST', + headers: { + Authorization: `Bearer ${v1Token}`, + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({}), + }); + if ( ! resp.ok ) { + // 409 = backend refuses silent migration for this kind + // (web sessions). Any caller will then 401 reauth_required + // and the interactive flow takes over. + return false; + } + const data = await resp.json().catch(() => null); + const token = data?.token; + if ( typeof token === 'string' && token.length > 0 ) { + this.setAuthToken(token); + return true; + } + return false; + } catch ( e ) { + // Network errors etc. — fall back to reauth on next 401. + return false; + } + }; + exit = function (statusCode = 0) { if ( statusCode && (typeof statusCode !== 'number') ) { console.warn('puter.exit() requires status code to be a number. Treating it as 1'); diff --git a/src/puter-js/src/lib/utils.js b/src/puter-js/src/lib/utils.js index 8bfdfbf73..f20b38b15 100644 --- a/src/puter-js/src/lib/utils.js +++ b/src/puter-js/src/lib/utils.js @@ -100,6 +100,21 @@ function initXhr (endpoint, APIOrigin, authToken, method = 'post', contentType = xhr.setRequestHeader('Content-Type', contentType); xhr.responseType = responseType ?? ''; + // Capture enough request shape to replay this XHR after a reauth_required + // (PUT-1022 PJS-1). The body is captured below by intercepting send(). + xhr._puterReq = { + endpoint, + APIOrigin, + method, + contentType, + responseType, + }; + const origSend = xhr.send.bind(xhr); + xhr.send = function (body) { + xhr._puterReq.body = body; + return origSend(body); + }; + // Add API call logging if available if ( globalThis.puter?.apiCallLogger?.isEnabled() ) { xhr._puterRequestId = { @@ -113,6 +128,33 @@ function initXhr (endpoint, APIOrigin, authToken, method = 'post', contentType = return xhr; } +/** + * Re-issue an XHR after the reauth coordinator resolves. Uses the request + * shape captured on `_puterReq` and the fresh `puter.authToken` to build a + * new XHR; the new response is then routed back through the same callbacks. + * Returns true if a replay was scheduled, false otherwise. + */ +function replayXhrAfterReauth (response, success_cb, error_cb, resolve_func, reject_func) { + const xhr = response.target ?? response; + const req = xhr?._puterReq; + if ( ! req ) return false; + // Already a replay attempt — don't loop into reauth a second time if + // even the fresh token comes back rejected. The retry path is one-shot. + if ( req._replayed ) return false; + const newXhr = initXhr( + req.endpoint, + req.APIOrigin, + globalThis.puter?.authToken, + req.method, + req.contentType, + req.responseType, + ); + newXhr._puterReq._replayed = true; + setupXhrEventHandlers(newXhr, success_cb, error_cb, resolve_func, reject_func); + newXhr.send(req.body); + return true; +} + /** * Handles an HTTP response by invoking appropriate callback functions and resolving or rejecting a promise. * @@ -134,6 +176,33 @@ async function handle_resp (success_cb, error_cb, resolve_func, reject_func, res const resp = await parseResponse(response); // error - unauthorized if ( response.status === 401 ) { + // PUT-1022 (PJS-1) — v2 reauth signal. The backend `authProbe` + // middleware returns `401 { code: 'reauth_required', reason, auth_id }` + // for legacy v1 tokens, revoked sessions, and expired sessions + // beyond the silent re-mint window. Drive the env-specific reauth + // flow on the Puter class, then replay the original request with + // the new token. + if ( resp?.code === 'reauth_required' ) { + try { + await puter.triggerReauth({ + reason: resp.reason, + auth_id: resp.auth_id, + }); + if ( replayXhrAfterReauth(response, success_cb, error_cb, resolve_func, reject_func) ) { + return; + } + } catch ( e ) { + const err = { + status: 401, + code: 'reauth_required', + reason: resp.reason, + auth_id: resp.auth_id, + message: e?.message || 'Reauthentication required', + }; + if ( error_cb && typeof error_cb === 'function' ) error_cb(err); + return reject_func(err); + } + } // Backend signals "token is no longer valid, prompt re-login" via // the legacy `token_auth_failed` code (matches v1 backend's // APIError.create('token_auth_failed') shape). Trigger the same @@ -374,6 +443,10 @@ async function driverCall_ ( if ( settings.responseType ) { xhr.responseType = settings.responseType; + // Keep _puterReq in sync with the live XHR config so any replay + // path (driver or generic) builds the retry with the correct + // responseType rather than the stale value captured by initXhr. + if ( xhr._puterReq ) xhr._puterReq.responseType = settings.responseType; } // =============================================== @@ -522,6 +595,59 @@ async function driverCall_ ( // HTTP Error - unauthorized if ( response.target.status === 401 || resp?.code === 'token_auth_failed' ) { + // PUT-1022 (PJS-1) — v2 reauth signal. Replay the driver + // call by re-entering driverCall_ rather than using the + // generic replayXhrAfterReauth helper: the generic helper + // wires the retried XHR through setupXhrEventHandlers, which + // resolves with the parsed response and skips driverCall_'s + // streaming detection, usage-limit / email-confirmation + // handling, settings.transform, and `resp.result` unwrapping + // — i.e. it would silently change the driver call API + // contract on retry. One-shot via `settings._reauthReplayed` + // so a fresh-token rejection bubbles up instead of looping. + if ( resp?.code === 'reauth_required' ) { + try { + await puter.triggerReauth({ + reason: resp.reason, + auth_id: resp.auth_id, + }); + if ( ! settings._reauthReplayed ) { + return driverCall_( + options, + resolve_func, + reject_func, + driverInterface, + driverName, + driverMethod, + driverArgs, + method, + contentType, + { ...settings, _reauthReplayed: true }, + ); + } + // Already replayed once — the fresh token is still + // rejected. Bubble the reauth error. + const err = { + status: 401, + code: 'reauth_required', + reason: resp.reason, + auth_id: resp.auth_id, + message: 'Reauthentication still required after retry', + }; + if ( error_cb && typeof error_cb === 'function' ) error_cb(err); + return reject_func(err); + } catch ( e ) { + const err = { + status: 401, + code: 'reauth_required', + reason: resp.reason, + auth_id: resp.auth_id, + message: e?.message || 'Reauthentication required', + }; + if ( error_cb && typeof error_cb === 'function' ) error_cb(err); + return reject_func(err); + } + } if ( resp?.code === 'token_auth_failed' && puter.env === 'web' ) { try { puter.resetAuthToken();