Skip to content

Commit ad98dd0

Browse files
committed
fix(tampermonkey): preserve refreshed auth tokens
1 parent bb6f1a9 commit ad98dd0

2 files changed

Lines changed: 167 additions & 20 deletions

File tree

scripts/tampermonkey/igloo-site-sync.user.js

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// ==UserScript==
22
// @name Igloo Site Sync
33
// @namespace local.igloo.site.sync
4-
// @version 8.0.34
4+
// @version 8.0.35
55
// @author screwys
66
// @description Follow X, TikTok, Instagram, and YouTube channels in Igloo; includes the full X media workflow.
77
// @homepageURL https://github.com/screwys/Igloo
@@ -36,7 +36,7 @@
3636

3737
(function () {
3838
"use strict";
39-
const SCRIPT_VERSION = "8.0.34";
39+
const SCRIPT_VERSION = "8.0.35";
4040

4141
const SETTINGS = {
4242
apiBase: "xsync_api_base",
@@ -476,6 +476,59 @@
476476
return true;
477477
}
478478

479+
function responseErrorCode(resp) {
480+
return String(resp?.json?.error_code || "");
481+
}
482+
483+
function responseErrorDetail(resp) {
484+
return (
485+
resp?.json?.error_message ||
486+
resp?.json?.error_code ||
487+
resp?.error ||
488+
resp?.status ||
489+
"error"
490+
);
491+
}
492+
493+
function isHTMLAuthRedirect(resp) {
494+
return (
495+
resp?.status === 303 ||
496+
(resp?.ok && resp?.json === null && resp?.text?.includes("<!DOCTYPE"))
497+
);
498+
}
499+
500+
function shouldRefreshAuthResponse(resp) {
501+
if (!resp) return false;
502+
if (isHTMLAuthRedirect(resp)) return true;
503+
if (resp.status !== 401) return false;
504+
const code = responseErrorCode(resp);
505+
if (code === "access_token_expired" || code === "legacy_token_invalid") {
506+
return true;
507+
}
508+
return !code && !!getToken();
509+
}
510+
511+
function authFailureNotice(resp, refreshResp) {
512+
const code = responseErrorCode(refreshResp) || responseErrorCode(resp);
513+
if (code === "refresh_token_expired" || code === "access_token_expired") {
514+
return "Session expired; use Tampermonkey menu \u2192 Log in to server";
515+
}
516+
if (code === "refresh_token_replayed" || code === "session_revoked") {
517+
return "Session revoked; use Tampermonkey menu \u2192 Log in to server";
518+
}
519+
if (
520+
code === "legacy_token_invalid" ||
521+
code === "refresh_token_invalid" ||
522+
code === "access_token_invalid"
523+
) {
524+
return "Saved login was rejected; use Tampermonkey menu \u2192 Log in to server";
525+
}
526+
if (code === "unauthenticated") {
527+
return "Not logged in; use Tampermonkey menu \u2192 Log in to server";
528+
}
529+
return `Authentication failed (${responseErrorDetail(refreshResp || resp)}); use Tampermonkey menu \u2192 Log in to server`;
530+
}
531+
479532
function forgetLegacyDashboardPassword() {
480533
try {
481534
if (typeof GM_deleteValue === "function") {
@@ -982,7 +1035,8 @@
9821035

9831036
async function _refreshToken() {
9841037
const refresh = getRefresh();
985-
if (!refresh) return false;
1038+
const access = getToken();
1039+
if (!refresh) return { ok: false, response: null };
9861040
const r = await _rawApiRequest(
9871041
"POST",
9881042
"/api/auth/refresh",
@@ -991,33 +1045,33 @@
9911045
);
9921046
if (r.ok && storeAuthTokens(r.json)) {
9931047
console.log("[XSync] token rotated");
994-
return true;
1048+
return { ok: true, response: r };
1049+
}
1050+
if (getRefresh() !== refresh && getToken() !== access) {
1051+
console.log("[XSync] refresh superseded by newer login");
1052+
return { ok: true, response: r, superseded: true };
9951053
}
9961054
if (r.status === 401) {
997-
GM_setValue(SETTINGS.authRefresh, "");
998-
GM_setValue(SETTINGS.authToken, "");
1055+
if (getRefresh() === refresh) GM_setValue(SETTINGS.authRefresh, "");
1056+
if (getToken() === access) GM_setValue(SETTINGS.authToken, "");
9991057
}
1000-
return false;
1058+
return { ok: false, response: r };
10011059
}
10021060

10031061
async function apiRequest(method, path, body, withAuth = true) {
10041062
const resp = await _rawApiRequest(method, path, body, withAuth);
1005-
// Detect expired token: 303 redirect to /login, or 401
1006-
if (
1007-
withAuth &&
1008-
(resp.status === 303 ||
1009-
resp.status === 401 ||
1010-
(resp.ok && resp.json === null && resp.text.includes("<!DOCTYPE")))
1011-
) {
1063+
if (withAuth && shouldRefreshAuthResponse(resp)) {
10121064
if (!_refreshingToken) {
1013-
_refreshingToken = _refreshToken().then((ok) => {
1065+
_refreshingToken = _refreshToken().then((result) => {
10141066
_refreshingToken = null;
1015-
return ok;
1067+
return result;
10161068
});
10171069
}
1018-
const refreshed = await _refreshingToken;
1019-
if (refreshed) return _rawApiRequest(method, path, body, withAuth);
1020-
notify("Token expired — use Tampermonkey menu → Log in to server");
1070+
const refreshResult = await _refreshingToken;
1071+
if (refreshResult.ok) return _rawApiRequest(method, path, body, withAuth);
1072+
notify(authFailureNotice(resp, refreshResult.response));
1073+
} else if (withAuth && resp.status === 401) {
1074+
notify(authFailureNotice(resp, null));
10211075
}
10221076
return resp;
10231077
}

scripts/tampermonkey/igloo-site-sync.user.test.mjs

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ function buildHarness({
181181
unsafeWindow = {},
182182
userAgent = "Mozilla/5.0 Chrome/120.0.0.0",
183183
initialValues = {},
184+
onRequest = null,
185+
responseOverrides = {},
184186
twitterChannels = [
185187
{
186188
channel_id: "twitter_alice",
@@ -323,8 +325,11 @@ function buildHarness({
323325
});
324326
const response = responseFor(options.url, {
325327
data: options.data,
328+
headers: options.headers || {},
329+
responseOverrides,
326330
twitterChannels,
327331
});
332+
onRequest?.(requestCalls[requestCalls.length - 1], values);
328333
queueMicrotask(() => {
329334
options.onload({
330335
status: response.status,
@@ -364,7 +369,16 @@ function buildHarness({
364369
};
365370
}
366371

367-
function responseFor(url, { data, twitterChannels } = {}) {
372+
function responseFor(
373+
url,
374+
{ data, headers = {}, responseOverrides = {}, twitterChannels } = {},
375+
) {
376+
const override = responseOverrides[url];
377+
if (override) {
378+
return typeof override === "function"
379+
? override({ data, headers, twitterChannels })
380+
: override;
381+
}
368382
if (url === "http://127.0.0.1:5001/api/health/live") {
369383
return {
370384
status: 400,
@@ -763,6 +777,85 @@ test("expired refresh token asks for login without password fallback", async ()
763777
);
764778
});
765779

780+
test("failed refresh does not clear tokens replaced by a newer login", async () => {
781+
const freshAccess = "fresh-access";
782+
const freshRefresh = "fresh-refresh";
783+
const harness = buildHarness({
784+
initialValues: {
785+
xsync_auth_token: "expired-access",
786+
xsync_auth_refresh: "expired-refresh",
787+
},
788+
onRequest(call, values) {
789+
if (call.url === "https://localhost:5001/api/auth/refresh") {
790+
values.set("xsync_auth_token", freshAccess);
791+
values.set("xsync_auth_refresh", freshRefresh);
792+
}
793+
},
794+
responseOverrides: {
795+
"https://localhost:5001/api/stats": ({ headers }) => {
796+
if (headers.Authorization === `Bearer ${freshAccess}`) {
797+
return { status: 200, text: JSON.stringify({ ok: true }) };
798+
}
799+
return {
800+
status: 401,
801+
text: JSON.stringify({
802+
error_code: "access_token_expired",
803+
error_message: "token expired",
804+
}),
805+
};
806+
},
807+
},
808+
});
809+
runScript(harness);
810+
811+
const testConnection = harness.menu.get("Test connection");
812+
await testConnection();
813+
await drainMicrotasks();
814+
815+
assert.ok(
816+
harness.requests.includes("https://localhost:5001/api/auth/refresh"),
817+
`expected refresh request, got ${harness.requests.join(", ")}`,
818+
);
819+
assert.equal(harness.values.get("xsync_auth_token"), freshAccess);
820+
assert.equal(harness.values.get("xsync_auth_refresh"), freshRefresh);
821+
assert.ok(
822+
harness.toasts.some((message) => message.includes("Server connection OK")),
823+
`expected successful retry toast, got ${harness.toasts.join(", ")}`,
824+
);
825+
});
826+
827+
test("unauthenticated API response does not claim token expiry", async () => {
828+
const harness = buildHarness({
829+
responseOverrides: {
830+
"https://localhost:5001/api/stats": {
831+
status: 401,
832+
text: JSON.stringify({
833+
error_code: "unauthenticated",
834+
error_message: "Authentication required",
835+
}),
836+
},
837+
},
838+
});
839+
runScript(harness);
840+
841+
const testConnection = harness.menu.get("Test connection");
842+
await testConnection();
843+
await drainMicrotasks();
844+
845+
assert.equal(
846+
harness.requests.includes("https://localhost:5001/api/auth/refresh"),
847+
false,
848+
);
849+
assert.equal(
850+
harness.toasts.some((message) => message.includes("Token expired")),
851+
false,
852+
);
853+
assert.ok(
854+
harness.toasts.some((message) => message.includes("Not logged in")),
855+
`expected login guidance, got ${harness.toasts.join(", ")}`,
856+
);
857+
});
858+
766859
test("stays idle on X auth routes", async () => {
767860
class FakeXMLHttpRequest {
768861
open() {}

0 commit comments

Comments
 (0)