From 721c1c96fedc6709ca86be216ea241e5da68df50 Mon Sep 17 00:00:00 2001 From: adnit Date: Fri, 10 Apr 2026 00:25:42 +0200 Subject: [PATCH] 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 --- .../ngax/scripts/services/AppService.js | 69 +++++++++++++++++-- .../ngax/scripts/services/ServerStatus.js | 17 +++++ playwright-tests/ngaxAuthRefresh.spec.ts | 47 +++++++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 playwright-tests/ngaxAuthRefresh.spec.ts diff --git a/Duplicati/Server/webroot/ngax/scripts/services/AppService.js b/Duplicati/Server/webroot/ngax/scripts/services/AppService.js index 88cc8ff4f..4ff29434d 100644 --- a/Duplicati/Server/webroot/ngax/scripts/services/AppService.js +++ b/Duplicati/Server/webroot/ngax/scripts/services/AppService.js @@ -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); diff --git a/Duplicati/Server/webroot/ngax/scripts/services/ServerStatus.js b/Duplicati/Server/webroot/ngax/scripts/services/ServerStatus.js index e55fc2a5d..03ece05e6 100644 --- a/Duplicati/Server/webroot/ngax/scripts/services/ServerStatus.js +++ b/Duplicati/Server/webroot/ngax/scripts/services/ServerStatus.js @@ -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) { diff --git a/playwright-tests/ngaxAuthRefresh.spec.ts b/playwright-tests/ngaxAuthRefresh.spec.ts new file mode 100644 index 000000000..3f7451a55 --- /dev/null +++ b/playwright-tests/ngaxAuthRefresh.spec.ts @@ -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); +});