Skip to content

[2068] Service worker prevents auth redirect when dashboard is behind an authentication proxy - #2213

Merged
n-lark merged 5 commits into
mainfrom
2068-sw-auth-proxy
Sep 15, 2026
Merged

n-lark merged 5 commits into
mainfrom
2068-sw-auth-proxy

Conversation

@n-lark

@n-lark n-lark commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Description

Lets a PWA behind an auth proxy follow the proxy's login redirect when the session expires, instead of getting stuck on a cached page. Page loads now go to the network first so the redirect comes through, and fall back to the cached copy only when the network is down, so the app still loads offline and on a cold start.

Builds on @pakerfeldt's #2069: that PR removed the NavigationRoute, which fixed the redirect but left the PWA with no offline fallback, so it hangs on launch when the network isn't up yet, @colinl hit this on Android. Network-first keeps both.

Test plan

Verified manually on Chrome + iOS Safari PWA

All run against a production build (npm run build) served by Node-RED, the service worker only precaches in prod builds. The offline cases just turn the network off (DevTools Offline on desktop, airplane mode on iOS). The auth cases sit the dashboard behind a small stub proxy that redirects to a login page once the session is marked expired, standing in for Cloudflare Access; on iOS that runs over an HTTPS tunnel so the phone can reach it.

Desktop Chrome

  • Service worker installs. Load the dashboard once and confirm the SW is active and has precached the app shell.
  • Loads while offline. With the SW active, go offline and reload. The app still loads from cache instead of showing a blank page or hanging.
  • Redirects to login when the session expires. Expire the session, then reload. The SW lets the proxy's redirect through and the browser lands on the login page, instead of serving the stale cached page. This is the actual bug.
  • The redirect is the handler's doing. Repeat the expired-session reload with the SW unregistered, it still redirects. Getting the same result with and without the SW confirms our handler is passing the redirect through, not that the browser would have anyway.

iOS Safari PWA (over HTTPS tunnel)

  • Redirects to login when the session expires. Expire the session, force-close the PWA, reopen it. WebKit follows the redirect to login.
  • Loads while offline. Airplane mode on, force-close, relaunch. The app loads from cache with no hang on the icon screen.

Related Issue(s)

Resolves #2068

Checklist

  • I have read the contribution guidelines
  • Suitable unit/system level tests have been added and they pass
  • Documentation has been updated
    • Upgrade instructions
    • Configuration details
    • Concepts
  • Changes flowforge.yml?
    • Issue/PR raised on FlowFuse/helm to update ConfigMap Template
    • Issue/PR raised on FlowFuse/CloudProject to update values for Staging/Production
  • Link to Changelog Entry PR, or note why one is not needed.

Labels

  • Includes a DB migration? -> add the area:migration label

@n-lark
n-lark requested review from cstns and knolleary September 8, 2026 17:46
@n-lark n-lark self-assigned this Sep 8, 2026
@n-lark n-lark changed the title [2068] Add navigationRoute that allows for auth redirects with offline fallback [2068] Service worker prevents auth redirect when dashboard is behind an authentication proxy Sep 8, 2026
@n-lark

n-lark commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Hey @colinl - could you test this one on your end please?

@colinl

colinl commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

I will give it a go.

@colinl

colinl commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

No problems specific to this PR on android PWA so far.

I have noticed one thing, that sometimes when returning to the app it hangs forever showing the loading page, but I think that was happening on main. It recovers on swiping down to force a refresh. I need to go back to the current release I think and see if that did it too. It may not be new, it may just be that I am doing a lot of testing with a short expiry time on the cloudflare token and am seeing a rare event. It will take some time to test properly as it is only occurring once or twice or even no times a day. I want to stick with the PR for a few days though to make sure that I don't see lockup I saw on #2069 (#2069 (comment)).

@cstns

cstns commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

I took this for a spin locally to verify both the bug and the fix. Setup: built main and this branch, served each by Node-RED, and put a small stub auth proxy in front that can expire the session and redirect to a login page, standing in for Cloudflare Access. Drove it with real browsers (fresh profile per scenario so each one installs its own service worker), plus an offline pass with the network cut.

What checked out:

  • The bug reproduces on main exactly as Service worker prevents auth redirect when dashboard is behind an authentication proxy #2068 describes. With the SW active and the session expired, a reload never reaches the proxy: the cached shell is served, only the _setup API call hits the proxy and gets a 302 it can't follow, and you end up on "There was an error loading the Dashboard".
  • This PR fixes it. Same scenario on this branch lands on the login page, and the full round trip works: log back in, return to the dashboard, everything renders.
  • Offline still works. With the network gone, the SW serves the cached shell and the app shows its "device appears to be offline" screen. So the regression from removing the NavigationRoute entirely (fix: let browser handle navigation requests to support auth proxies #2069) is avoided, as intended.

One real problem though, and it's subtle: passing { signal } to fetch() with a navigation Request silently downgrades the request mode from navigate to same-origin (this is why workbox passes no init for navigations, see GoogleChrome/workbox#1796). The effect is that the re-issued request arrives at the proxy with Sec-Fetch-Mode: same-origin. I confirmed this in the proxy's logs: every SW-mediated navigation shows same-origin where a plain browser navigation shows navigate. My stub didn't care, so the redirect still worked, but proxies that branch on that header do exist (Cloudflare Access has an option to return 401 instead of a redirect for non-navigation traffic). I ran that variant too: with header checking on and the session expired, the SW build strands the user on a bare 401 page, while a browser without the SW redirects to login fine against the identical proxy. So for exactly the deployments this PR targets, the fix can reintroduce the bug through a side door.

The good news is the clean fix also simplifies the code: workbox-strategies is already in the dependencies (currently imported nowhere), and its NetworkOnly strategy with a handlerDidError plugin is this handler, minus the footguns:

registerRoute(new NavigationRoute(new NetworkOnly({
    networkTimeoutSeconds: 5,
    plugins: [{
        handlerDidError: async () => (await matchPrecache('index.html')) || Response.error()
    }]
})))

I patched the branch to this form, rebuilt, and re-ran every scenario: the strict proxy now sees Sec-Fetch-Mode: navigate and redirects to login, and the offline fallback still works. It also improves the timeout behavior as a side effect: networkTimeoutSeconds races the network instead of aborting it, so a login redirect that takes longer than the timeout can still land, where the AbortController version kills it mid-flight and serves the stale shell.

A few smaller things worth a look while you're in here:

  • The globPatterns switch keys off process.env.NODE_ENV, and Vite only defaults that when it isn't already set. An environment that exports NODE_ENV=development and runs the plain npm run build (the release workflow's command) would silently publish a service worker that precaches nothing. Keying off Vite's mode via the defineConfig(({ mode }) => ...) callback, or pinning NODE_ENV=production in the build script, would close that off.
  • The precache route is registered before the navigation route and wins on exact URL matches, so a navigation to the literal index.html URL is still served from cache and skips the auth redirect. Registering the NavigationRoute first (or not routing index.html) closes it, and makes the "the route below owns navigations" comment accurate.
  • Resolved HTTP errors (a 502 while Node-RED restarts, say) are now passed through as the document, where the old cache-first behavior booted the shell and its self-reloading "server unreachable" screen. Kiosk-style deployments lose that self-healing. If you add a 5xx fallback to the shell, watch out that redirects arrive here as opaqueredirect with status 0, so a naive !response.ok check would swallow the auth redirect again.
  • Unrelated to this PR but noticed while testing: when the server is unreachable while the OS is still online, the app's forcePageReload navigates to the base path without a trailing slash, which falls outside the SW scope, so no offline fallback can help there. Pre-existing behavior, just context.

Nice work on the manual test plan by the way, it was accurate for everything it covered; the header-checking proxy behavior is just hard to catch with a stub that doesn't emulate it.

@cstns cstns left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see comment above

@colinl

colinl commented Sep 10, 2026

Copy link
Copy Markdown
Contributor
  • Unrelated to this PR but noticed while testing: when the server is unreachable while the OS is still online, the app's forcePageReload navigates to the base path without a trailing slash, which falls outside the SW scope, so no offline fallback can help there. Pre-existing behavior, just context.

@cstns, I don't fully understand the implications of that. What symptoms might that cause on a device running Android?

@cstns

cstns commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

With the current setup:

  • The service worker is registered from the app as sw.js relative to the page, so it lands at /dashboard/sw.js and its scope defaults to the script's directory, /dashboard/. The PWA manifest declares the same (scope: './' in nodes/config/ui_base.js:224).
  • Service worker scope matching is plain URL prefix matching, so /dashboard/, /dashboard/page1 and /dashboard/anything are all in scope. /dashboard without the trailing slash is not, because the string /dashboard doesn't start with the prefix /dashboard/.
  • forcePageReload (ui/src/main.mjs:88) hardcodes its destination as window.location.origin + '/dashboard', no trailing slash, plus a reloadTime cache-buster, then calls window.location.replace(url).

The symptom chain:

  1. The PWA is open or backgrounded, and the server becomes unreachable while the OS still has connectivity. For example: a Node-RED restart taking a while, a dropped VPN, the server going offline, or a flaky mobile link where the socket dies but Android keeps wifi "up".
  2. The socket retries exhaust and forcePageReload fires, navigating the PWA to /dashboard?reloadTime=....
  3. No service worker intercepts that URL, and the network can't serve it, so the user gets Chrome's native error page (the "you're offline" dinosaur, or an ERR_CONNECTION_* page) rendered inside the standalone PWA window, instead of the dashboard's own cached shell with its "device appears to be offline" screen.

To a user it looks like the app got replaced by a broken browser page. It self-heals once the server is reachable again and the user refreshes, since pull-to-refresh works on that error page and Express serves index.html for /dashboard without the slash just fine. That last part is also why nobody notices this path in normal operation.

@cstns

cstns commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

The fix is tiny: build the URL with the trailing slash (or better, derive it from the current location instead of hardcoding), and the reload becomes an in-scope navigation that this PR's fallback can catch.

@colinl

colinl commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

To a user it looks like the app got replaced by a broken browser page.

OK, thanks. So not the symptom I see very occasionally (which is not specific to this PR) where it hangs on the Loading screen until refreshed.

@cstns

cstns commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Yep!

@n-lark

n-lark commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Hey @cstns ty for review and testing. Fixed:

  • Handler is now NetworkOnly + handlerDidError, so navigations keep navigate mode and the timeout races instead of aborting.
  • NavigationRoute registered before the precache route so it owns navigations.
  • vite.config keys off Vite's mode now, not NODE_ENV.
  • forcePageReload reloads to /dashboard/ so it stays in scope.
  • Added the 5xx self-heal soo fetchDidSucceed serves the shell on >= 500. Matched on status, not !response.ok, so the status-0 opaqueredirect still passes through.

@n-lark
n-lark requested a review from cstns September 14, 2026 19:54
@colinl

colinl commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

All looking good here, apart from the occasional hang I get when I go back to the app on Android if the session has expired. For completeness, the symptom I see is that the node red icon shows for a couple of seconds, then it switches to the loading screen, with the cycling dots, and just hangs there apparently for ever. As I said, though, I think that was there before this PR was applied.

@n-lark

n-lark commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Hey @colinl thank you for testing! Around the loading issue, I raised Dashboard hangs on the loading screen after resuming a PWA #2226 that I'll take a look into.

@n-lark
n-lark merged commit 88c777d into main Sep 15, 2026
4 checks passed
@n-lark
n-lark deleted the 2068-sw-auth-proxy branch September 15, 2026 13:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Service worker prevents auth redirect when dashboard is behind an authentication proxy

3 participants