mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-24 04:21:25 +00:00
feat (put-1022 put-1023 put-1024): puter.js reauth handling + GUI v2 modal + silent v1 migration (#3157)
* feat (put-1022 put-1023 put-1024): puter.js reauth handling + GUI v2 modal + silent v1 migration
PUT-1022 (PJS-1): puter.js handles 401 reauth_required
- Single apiCall wrapper intercepts 401 { code: "reauth_required" }
- Clears local token, emits puter.auth.reauth_required { reason, auth_id }
- Queues in-flight requests, re-issues after re-auth completes
- web/app: opens puter.com login popup forwarding auth_id
- gui: no-op (handled by GUI-1 modal)
- workers: surfaces as structured exception
- All token writes go through setAuthToken() (future-proofed for PJS-2)
PUT-1023 (GUI-1): web GUI reauth modal + v2 token storage
- Detects 401 reauth_required across http + websocket connect paths
- Soft modal preserves URL/window state; auth_id forwarded into login form
- Storage key migrated: auth_token -> auth_token_v2
- Cookie cleared on logout: puter_token (legacy) cleared, backend writes puter_token_v2
- Cross-tab propagation via localStorage storage events
- WebSocket revocation surfaces as same modal (no silent failure)
PUT-1024 (PJS-2): puter.js storage versioning + silent v1->v2 migration
- New storage key puter.auth.token.v2
- On SDK init: prefer v2 key; else legacy v1 -> silent POST /auth/migrate-token (SDK-1)
- On success: store v2, clear v1. On failure: fall back to PJS-1 reauth flow
- URL-param tokens (?puter.auth.token=, ?auth_token=) also run through silent migration
- Logout clears both keys; setAuthToken writes v2 only
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(put-1023): manage-sessions polish — hide self-revoke, fix modal stack, richer rows
Three issues surfaced during local testing of the manage-sessions UI:
1. The session row representing the caller's own cookie had a Revoke
button that, if used, left the client in an ambiguous identity state
(backend now rejects it too — see put-1019 backend fix). The button
is now omitted entirely when session.current=true; /logout remains
the right path for ending the active session.
2. The confirm-revoke prompt rendered behind the manage-sessions
window because both share the dominant z-index pool. UIAlert calls
now pass parent_uuid (the manage-sessions window's data-element_uuid)
plus stay_on_top, so the prompt stacks above its parent.
3. Each row only showed the bare uuid. Now renders: title (app
title for app sessions, label / "Browser session" / "Access token"
otherwise), app icon when applicable, kind / current badges,
created / last-active / expires (timeago, with absolute on hover),
and last_ip. App metadata is joined server-side per the backend
listSessions change.
Adds en.js strings for the new labels (ui_session_*).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(put-1022): tighten reauth replay + iframe postMessage hygiene
driverCall_ replay (G1): on 401 reauth_required, re-enter driverCall_
instead of calling replayXhrAfterReauth. The generic helper wires the
retried XHR through setupXhrEventHandlers which resolves with the
parsed response and silently skips driverCall_'s streaming detection,
usage-limit / email-confirmation handling, settings.transform, and
resp.result unwrapping — i.e. it would change the driver call API
contract on retry. One-shot via settings._reauthReplayed so a
fresh-token rejection bubbles up instead of looping.
responseType drift (G2): driverCall_ sets xhr.responseType from
settings AFTER initXhr (which captured the stale value into
xhr._puterReq). Mirror the mutation onto _puterReq so any replay path
builds the retry with the live config rather than the snapshot.
targetOrigin lockdown (G3): triggerReauth's parent.postMessage now
targets this.defaultGUIOrigin instead of '*'. The payload carries
reauth metadata + auth_id and is only meaningful to the GUI; '*'
would leak the signal to whatever frame happened to be embedding us.
event.source pinning (G4): the reauth wait listener also matches
event.source against globalThis.parent, not just event.origin —
origin alone admits any same-origin frame on the GUI domain.
event name alignment (G5): the SDK now emits and listens for
'puter.auth.reauth_required' (matches the documented name in the
PR / commit description). The old 'auth.reauth_required' key isn't
yet consumed externally so this is a safe rename.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5394ccc45c
commit
3ae076b73e
20 files changed
+831
-113
No files matched your search
+2
-2
@@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' ) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
// '<pre>' +
|
||||
// JSON.stringify(session, null, 2) +
|
||||
// '</pre>';
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -101,8 +101,8 @@ function UIWindowRecoverPassword (options) {
|
||||
username: username,
|
||||
}),
|
||||
statusCode: {
|
||||
401: function () {
|
||||
window.logout();
|
||||
401: function (xhr) {
|
||||
window.handle401(xhr);
|
||||
},
|
||||
},
|
||||
success: async function (res) {
|
||||
|
||||
@@ -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 ) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+160
-4
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 ) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ) {
|
||||
|
||||
+256
-13
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user