diff --git a/server/server.go b/server/server.go index 8b3a172d..a88afeaf 100644 --- a/server/server.go +++ b/server/server.go @@ -603,13 +603,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor return s.writeJSON(w, response) } -func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, v *visitor) error { +func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error { w.Header().Set("Cache-Control", "no-cache") - return s.writeJSON(w, s.configResponse(v)) + return s.writeJSON(w, s.configResponse()) } -func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, v *visitor) error { - b, err := json.MarshalIndent(s.configResponse(v), "", " ") +func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error { + b, err := json.MarshalIndent(s.configResponse(), "", " ") if err != nil { return err } @@ -619,10 +619,10 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, v *visi return err } -func (s *Server) configResponse(v *visitor) *apiConfigResponse { - authUser := "" - if s.config.AuthUserHeader != "" && v != nil && v.User() != nil { - authUser = v.User().Name +func (s *Server) configResponse() *apiConfigResponse { + authMode := "" + if s.config.AuthUserHeader != "" { + authMode = "proxy" } return &apiConfigResponse{ BaseURL: "", // Will translate to window.location.origin @@ -639,8 +639,8 @@ func (s *Server) configResponse(v *visitor) *apiConfigResponse { WebPushPublicKey: s.config.WebPushPublicKey, DisallowedTopics: s.config.DisallowedTopics, ConfigHash: s.config.Hash(), + AuthMode: authMode, AuthLogoutURL: s.config.AuthLogoutURL, - AuthUser: authUser, } } diff --git a/server/types.go b/server/types.go index 915d7e17..43d3bbc9 100644 --- a/server/types.go +++ b/server/types.go @@ -483,8 +483,8 @@ type apiConfigResponse struct { WebPushPublicKey string `json:"web_push_public_key"` DisallowedTopics []string `json:"disallowed_topics"` ConfigHash string `json:"config_hash"` + AuthMode string `json:"auth_mode,omitempty"` // "proxy" if auth-user-header is set, empty otherwise AuthLogoutURL string `json:"auth_logout_url,omitempty"` // URL to redirect to on logout (only for proxy auth) - AuthUser string `json:"auth_user,omitempty"` // Authenticated username (for proxy auth, empty if not using proxy auth) } type apiAccountBillingPrices struct { diff --git a/web/public/config.js b/web/public/config.js index bd241661..03a061d1 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -20,6 +20,6 @@ var config = { web_push_public_key: "", disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"], config_hash: "dev", // Placeholder for development; actual value is generated server-side + auth_mode: "", // "proxy" if auth-user-header is set, empty otherwise auth_logout_url: "", // URL to redirect to on logout (only for proxy auth) - auth_user: "", // Authenticated username (non-empty if using proxy auth) }; diff --git a/web/public/sw.js b/web/public/sw.js index c8808145..f084341c 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -342,7 +342,7 @@ if (!import.meta.env.DEV) { // since we don't have access to `window` like in `src/app/config.js` self.importScripts("/config.js"); - // this is the fallback single-page-app route, matching vite.config.js PWA config, + // This is the fallback single-page-app route, matching vite.config.js PWA config, // and is served by the go web server. It is needed for the single-page-app to work. // https://developer.chrome.com/docs/workbox/modules/workbox-routing/#how-to-register-a-navigation-route registerRoute( @@ -354,10 +354,46 @@ if (!import.meta.env.DEV) { }) ); - // the manifest excludes config.js (see vite.config.js) since the dist-file differs from the - // actual config served by the go server. this adds it back with `NetworkFirst`, so that the - // most recent config from the go server is cached, but the app still works if the network - // is unavailable. this is important since there's no "refresh" button in the installed pwa - // to force a reload. - registerRoute(({ url }) => url.pathname === "/config.js", new NetworkFirst()); + // The manifest excludes config.js (see vite.config.js) since the dist-file differs from the + // actual config served by the go server. This adds it back with a custom handler that validates + // the response. If the response is HTML (e.g., from an auth proxy like Authelia), the service + // worker unregisters itself and clears caches to allow the auth proxy to handle the request. + registerRoute( + ({ url }) => url.pathname === "/config.js", + async ({ request }) => { + const cache = await caches.open("config-cache"); + try { + const response = await fetch(request); + const contentType = response.headers.get("content-type") || ""; + + // If we got HTML instead of JavaScript, we're likely logged out at the proxy level + // (e.g., Authelia is serving its login page). Clear caches, unregister the service + // worker, and reload all clients so the browser can handle the auth flow properly. + if (contentType.includes("text/html")) { + console.log("[ServiceWorker] Config returned HTML - proxy session likely expired, unregistering"); + const cacheNames = await caches.keys(); + await Promise.all(cacheNames.map((name) => caches.delete(name))); + await self.registration.unregister(); + + // Reload all open clients so they go through the auth proxy + const allClients = await self.clients.matchAll({ type: "window" }); + allClients.forEach((client) => client.navigate(client.url)); + + return response; + } + + // Valid config response - cache it and return + await cache.put(request, response.clone()); + return response; + } catch (e) { + // Network failed, try to serve from cache + console.log("[ServiceWorker] Network failed for config.js, trying cache", e); + const cached = await cache.match(request); + if (cached) { + return cached; + } + throw e; + } + } + ); } diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index dc904708..e72ac1b4 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -342,10 +342,16 @@ class AccountApi { async sync() { try { - // For proxy auth, store the username from config if not already in session - if (config.auth_user && !session.exists()) { - console.log(`[AccountApi] Proxy auth: storing session for user ${config.auth_user}`); - await session.store(config.auth_user, ""); // Empty token for proxy auth + // For proxy auth, detect user from /v1/account if no session exists + if (config.auth_mode === AuthMode.PROXY && !session.exists()) { + console.log(`[AccountApi] Proxy auth mode, detecting user from /v1/account`); + const account = await this.get(); + // Never store "*" (anonymous) as username + if (account.username && account.username !== "*") { + console.log(`[AccountApi] Proxy auth: storing session for ${account.username}`); + await session.store(account.username, ""); // Empty token for proxy auth + } + return account; } if (!session.exists()) { return null; @@ -373,6 +379,11 @@ class AccountApi { } catch (e) { console.log(`[AccountApi] Error fetching account`, e); if (e instanceof UnauthorizedError) { + // For proxy auth, hard refresh to get fresh auth from proxy + if (config.auth_mode === AuthMode.PROXY) { + window.location.reload(); + return undefined; + } await session.resetAndRedirect(routes.login); } return undefined; @@ -437,5 +448,10 @@ export const Permission = { DENY_ALL: "deny-all", }; +// Maps to apiConfigResponse.AuthMode in server/types.go +export const AuthMode = { + PROXY: "proxy", +}; + const accountApi = new AccountApi(); export default accountApi; diff --git a/web/src/components/ActionBar.jsx b/web/src/components/ActionBar.jsx index 83c59fa2..86148468 100644 --- a/web/src/components/ActionBar.jsx +++ b/web/src/components/ActionBar.jsx @@ -16,7 +16,7 @@ import routes from "./routes"; import db from "../app/db"; import { topicDisplayName } from "../app/utils"; import Navigation from "./Navigation"; -import accountApi from "../app/AccountApi"; +import accountApi, { AuthMode } from "../app/AccountApi"; import PopupMenu from "./PopupMenu"; import { SubscriptionPopup } from "./SubscriptionPopup"; import { useIsLaunchedPWA } from "./hooks"; @@ -140,7 +140,7 @@ const ProfileIcon = () => { const handleLogout = async () => { // For proxy auth, redirect to the logout URL if configured - if (config.auth_user) { + if (config.auth_mode === AuthMode.PROXY) { if (config.auth_logout_url) { await db().delete(); localStorage.removeItem("user"); @@ -159,7 +159,7 @@ const ProfileIcon = () => { }; // Determine if logout button should be shown (hide if proxy auth without logout URL) - const showLogout = !config.auth_user || config.auth_logout_url; + const showLogout = config.auth_mode !== AuthMode.PROXY || config.auth_logout_url; return ( <> diff --git a/web/src/components/Login.jsx b/web/src/components/Login.jsx index 5b8dcc63..7a35bd8c 100644 --- a/web/src/components/Login.jsx +++ b/web/src/components/Login.jsx @@ -5,7 +5,7 @@ import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import { NavLink } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Visibility, VisibilityOff } from "@mui/icons-material"; -import accountApi from "../app/AccountApi"; +import accountApi, { AuthMode } from "../app/AccountApi"; import AvatarBox from "./AvatarBox"; import session from "../app/Session"; import routes from "./routes"; @@ -20,7 +20,7 @@ const Login = () => { // Redirect to app if using proxy authentication useEffect(() => { - if (config.auth_user) { + if (config.auth_mode === AuthMode.PROXY) { window.location.href = routes.app; } }, []);