fix(ngax): stop auth refresh infinite loop on terminal refresh failures

Treat auth refresh responses 400, 401, and 415 as terminal authentication failures
Clear stale auth state and refresh nonce, then immediately redirect to /login.html
Stop reconnect countdown/retry when terminal refresh failure is detected
Preserve normal retry behavior for transient failures such as 503
Add Playwright regression tests covering:
415 refresh failure redirects to login without runaway retries
503 refresh failure keeps retry behavior and does not force login redirect
This commit is contained in:
adnit
2026-04-10 00:25:42 +02:00
parent ce37bb9774
commit 721c1c96fe
3 changed files with 127 additions and 6 deletions
@@ -2,6 +2,7 @@ backupApp.service('AppService', function ($http, $cookies, $q, $cookies, DialogS
this.apiurl = '../api/v1';
this.access_token = null;
this.access_token_promise = null;
this.redirecting_to_login = false;
const self = this;
@@ -12,6 +13,50 @@ backupApp.service('AppService', function ($http, $cookies, $q, $cookies, DialogS
});
}
function isRefreshStatusCode(status) {
return status === 400 || status === 401 || status === 415;
}
function isRefreshUrl(url) {
if (!url)
return false;
if (url.indexOf('/auth/refresh/logout') >= 0)
return false;
if (url.indexOf('/api/v1/auth/refresh') >= 0 || url.indexOf('/auth/refresh') >= 0)
return true;
return false;
}
this.isRefreshFailureResponse = function(response) {
return response != null
&& isRefreshStatusCode(response.status)
&& isRefreshUrl(response.config && response.config.url);
};
function clearAuthState() {
self.clearAccessToken();
localStorage.removeItem('v1:persist:duplicati:refreshNonce');
}
function redirectToLogin() {
if (self.redirecting_to_login)
return;
self.redirecting_to_login = true;
window.location.href = '/login.html';
}
function handleRefreshFailure(response) {
if (response != null)
response.refreshAuthFailure = true;
clearAuthState();
redirectToLogin();
}
var setupConfig = function (method, options, data, targeturl) {
options = options || {};
@@ -66,10 +111,16 @@ backupApp.service('AppService', function ($http, $cookies, $q, $cookies, DialogS
}
);
},
() => {
refreshResponse => {
// If the refresh endpoint itself failed in a terminal auth state,
// do not hide it behind the original response.
if (self.isRefreshFailureResponse(refreshResponse)) {
deferred.reject(refreshResponse);
} else {
// Fail, but report the original failed response, not the refresh response
deferred.reject(response);
}
}
);
} else {
@@ -101,9 +152,11 @@ backupApp.service('AppService', function ($http, $cookies, $q, $cookies, DialogS
self.access_token_promise = deferred.promise;
const storedNonce = localStorage.getItem('v1:persist:duplicati:refreshNonce');
const body = storedNonce ? { Nonce: storedNonce } : undefined;
const body = storedNonce ? { Nonce: storedNonce } : {};
const refreshUrl = self.apiurl + '/auth/refresh';
const refreshConfig = setupConfig('POST', {}, body, refreshUrl);
$http.post(self.apiurl + '/auth/refresh', body)
$http.post(refreshUrl, body, refreshConfig)
.then(function (response) {
self.access_token = response.data.AccessToken;
self.access_token_promise = null;
@@ -115,8 +168,12 @@ backupApp.service('AppService', function ($http, $cookies, $q, $cookies, DialogS
self.access_token = null;
self.access_token_promise = null;
// Explicit terminal auth failures for /auth/refresh should not be retried.
if (self.isRefreshFailureResponse(response)) {
handleRefreshFailure(response);
}
// Auth error, refresh token invalid
if (response.status == 401)
else if (response.status == 401)
loginRequired();
deferred.reject(response);
@@ -315,7 +315,24 @@ backupApp.service('ServerStatus', function ($rootScope, $timeout, AppService, Ap
const webSocketUnauthorizedCode = 4401;
const unauthorizedCode = 401;
function isTerminalRefreshFailure(response) {
return (response && response.refreshAuthFailure === true)
|| (AppService.isRefreshFailureResponse && AppService.isRefreshFailureResponse(response));
}
function handleConnectionError(response) {
if (isTerminalRefreshFailure(response)) {
if (websocketReconnectTimer != null) {
window.clearInterval(websocketReconnectTimer);
websocketReconnectTimer = null;
}
state.failedAuthAttempts++;
state.connectionState = 'unauthorized';
$rootScope.$broadcast('serverstatechanged');
return;
}
state.failedConnectionAttempts++;
if (response.status === webSocketUnauthorizedCode || response.status === unauthorizedCode)
{
+47
View File
@@ -0,0 +1,47 @@
import { expect, test } from "@playwright/test";
const NGAX_URL = process.env.NGAX_URL || "http://localhost:8200/ngax/index.html";
test("ngax redirects to login on terminal refresh failures", async ({ page }) => {
let refreshCalls = 0;
await page.route("**/api/v1/auth/refresh", async (route) => {
refreshCalls++;
await route.fulfill({
status: 415,
contentType: "application/json",
body: JSON.stringify({ Error: "Unsupported Media Type" }),
});
});
await page.goto(NGAX_URL, { waitUntil: "domcontentloaded" });
// The app should immediately abandon refresh retries and go to login.
await page.waitForURL(/\/login\.html(\?.*)?$/, { timeout: 15000 });
// Give it a short window and ensure no runaway retry loop occurs.
await page.waitForTimeout(1500);
expect(refreshCalls).toBeLessThanOrEqual(3);
});
test("ngax keeps retry behavior for transient refresh failures", async ({ page }) => {
let refreshCalls = 0;
await page.route("**/api/v1/auth/refresh", async (route) => {
refreshCalls++;
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ Error: "Service Unavailable" }),
});
});
await page.goto(NGAX_URL, { waitUntil: "domcontentloaded" });
// For transient failures, the app should not force a login redirect.
await page.waitForTimeout(2500);
await expect(page).not.toHaveURL(/\/login\.html(\?.*)?$/);
// The reconnect flow should attempt refresh again rather than terminating.
expect(refreshCalls).toBeGreaterThanOrEqual(2);
});