mirror of
https://github.com/duplicati/duplicati.git
synced 2026-05-06 07:16:38 -04:00
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:
@@ -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,9 +111,15 @@ backupApp.service('AppService', function ($http, $cookies, $q, $cookies, DialogS
|
||||
}
|
||||
);
|
||||
},
|
||||
() => {
|
||||
// Fail, but report the original failed response, not the refresh response
|
||||
deferred.reject(response);
|
||||
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);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user