From be0b7080640e60d3203a280f82b23948417f6d01 Mon Sep 17 00:00:00 2001 From: Sven van Ginkel Date: Tue, 14 Apr 2026 02:00:05 +0200 Subject: [PATCH] feat(hub): add `OAUTH_DISABLE_POPUP` env var (#1900) Co-authored-by: henrygd --- internal/hub/server.go | 42 +++++++++++ internal/hub/server_development.go | 17 +---- internal/hub/server_production.go | 13 +--- internal/site/index.html | 6 +- .../site/src/components/login/auth-form.tsx | 75 ++++++++++++++----- internal/site/src/main.tsx | 12 --- internal/site/src/types.d.ts | 1 + 7 files changed, 103 insertions(+), 63 deletions(-) create mode 100644 internal/hub/server.go diff --git a/internal/hub/server.go b/internal/hub/server.go new file mode 100644 index 00000000..07113989 --- /dev/null +++ b/internal/hub/server.go @@ -0,0 +1,42 @@ +package hub + +import ( + "encoding/json" + "net/url" + "strings" + + "github.com/henrygd/beszel" + "github.com/henrygd/beszel/internal/hub/utils" +) + +// PublicAppInfo defines the structure of the public app information that will be injected into the HTML +type PublicAppInfo struct { + BASE_PATH string + HUB_VERSION string + HUB_URL string + OAUTH_DISABLE_POPUP bool `json:"OAUTH_DISABLE_POPUP,omitempty"` +} + +// modifyIndexHTML injects the public app information into the index.html content +func modifyIndexHTML(hub *Hub, html []byte) string { + info := getPublicAppInfo(hub) + content, err := json.Marshal(info) + if err != nil { + return string(html) + } + htmlContent := strings.ReplaceAll(string(html), "./", info.BASE_PATH) + return strings.Replace(htmlContent, "\"{info}\"", string(content), 1) +} + +func getPublicAppInfo(hub *Hub) PublicAppInfo { + parsedURL, _ := url.Parse(hub.appURL) + info := PublicAppInfo{ + BASE_PATH: strings.TrimSuffix(parsedURL.Path, "/") + "/", + HUB_VERSION: beszel.Version, + HUB_URL: hub.appURL, + } + if val, _ := utils.GetEnv("OAUTH_DISABLE_POPUP"); val == "true" { + info.OAUTH_DISABLE_POPUP = true + } + return info +} diff --git a/internal/hub/server_development.go b/internal/hub/server_development.go index b32dc66a..c9e7823e 100644 --- a/internal/hub/server_development.go +++ b/internal/hub/server_development.go @@ -10,8 +10,6 @@ import ( "net/url" "strings" - "github.com/henrygd/beszel" - "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/osutils" ) @@ -38,7 +36,7 @@ func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error) } resp.Body.Close() // Create a new response with the modified body - modifiedBody := rm.modifyHTML(string(body)) + modifiedBody := modifyIndexHTML(rm.hub, body) resp.Body = io.NopCloser(strings.NewReader(modifiedBody)) resp.ContentLength = int64(len(modifiedBody)) resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody))) @@ -46,19 +44,6 @@ func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error) return resp, nil } -func (rm *responseModifier) modifyHTML(html string) string { - parsedURL, err := url.Parse(rm.hub.appURL) - if err != nil { - return html - } - // fix base paths in html if using subpath - basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/" - html = strings.ReplaceAll(html, "./", basePath) - html = strings.Replace(html, "{{V}}", beszel.Version, 1) - html = strings.Replace(html, "{{HUB_URL}}", rm.hub.appURL, 1) - return html -} - // startServer sets up the development server for Beszel func (h *Hub) startServer(se *core.ServeEvent) error { proxy := httputil.NewSingleHostReverseProxy(&url.URL{ diff --git a/internal/hub/server_production.go b/internal/hub/server_production.go index d2a5b464..4a444a65 100644 --- a/internal/hub/server_production.go +++ b/internal/hub/server_production.go @@ -5,10 +5,8 @@ package hub import ( "io/fs" "net/http" - "net/url" "strings" - "github.com/henrygd/beszel" "github.com/henrygd/beszel/internal/hub/utils" "github.com/henrygd/beszel/internal/site" @@ -18,17 +16,8 @@ import ( // startServer sets up the production server for Beszel func (h *Hub) startServer(se *core.ServeEvent) error { - // parse app url - parsedURL, err := url.Parse(h.appURL) - if err != nil { - return err - } - // fix base paths in html if using subpath - basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/" indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html") - html := strings.ReplaceAll(string(indexFile), "./", basePath) - html = strings.Replace(html, "{{V}}", beszel.Version, 1) - html = strings.Replace(html, "{{HUB_URL}}", h.appURL, 1) + html := modifyIndexHTML(h, indexFile) // set up static asset serving staticPaths := [2]string{"/static/", "/assets/"} serveStatic := apis.Static(site.DistDirFS, false) diff --git a/internal/site/index.html b/internal/site/index.html index 7d10cf53..5e67a095 100644 --- a/internal/site/index.html +++ b/internal/site/index.html @@ -22,11 +22,7 @@ })(); diff --git a/internal/site/src/components/login/auth-form.tsx b/internal/site/src/components/login/auth-form.tsx index 22ac16e7..c15ff61d 100644 --- a/internal/site/src/components/login/auth-form.tsx +++ b/internal/site/src/components/login/auth-form.tsx @@ -12,7 +12,7 @@ import { Label } from "@/components/ui/label" import { pb } from "@/lib/api" import { $authenticated } from "@/lib/stores" import { cn } from "@/lib/utils" -import { $router, Link, prependBasePath } from "../router" +import { $router, Link, basePath, prependBasePath } from "../router" import { toast } from "../ui/use-toast" import { OtpInputForm } from "./otp-forms" @@ -37,8 +37,7 @@ const RegisterSchema = v.looseObject({ passwordConfirm: passwordSchema, }) -export const showLoginFaliedToast = (description?: string) => { - description ||= t`Please check your credentials and try again` +export const showLoginFaliedToast = (description = t`Please check your credentials and try again`) => { toast({ title: t`Login attempt failed`, description, @@ -130,10 +129,6 @@ export function UserAuthForm({ [isFirstRun] ) - if (!authMethods) { - return null - } - const authProviders = authMethods.oauth2.providers ?? [] const oauthEnabled = authMethods.oauth2.enabled && authProviders.length > 0 const passwordEnabled = authMethods.password.enabled @@ -142,6 +137,12 @@ export function UserAuthForm({ function loginWithOauth(provider: AuthProviderInfo, forcePopup = false) { setIsOauthLoading(true) + + if (globalThis.BESZEL.OAUTH_DISABLE_POPUP) { + redirectToOauthProvider(provider) + return + } + const oAuthOpts: OAuth2AuthConfig = { provider: provider.name, } @@ -150,10 +151,7 @@ export function UserAuthForm({ const authWindow = window.open() if (!authWindow) { setIsOauthLoading(false) - toast({ - title: t`Error`, - description: t`Please enable pop-ups for this site`, - }) + showLoginFaliedToast(t`Please enable pop-ups for this site`) return } oAuthOpts.urlCallback = (url) => { @@ -171,16 +169,57 @@ export function UserAuthForm({ }) } + /** + * Redirects the user to the OAuth provider's authentication page in the same window. + * Requires the app's base URL to be registered as a redirect URI with the OAuth provider. + */ + function redirectToOauthProvider(provider: AuthProviderInfo) { + const url = new URL(provider.authURL) + // url.searchParams.set("redirect_uri", `${window.location.origin}${basePath}`) + sessionStorage.setItem("provider", JSON.stringify(provider)) + window.location.href = url.toString() + } + useEffect(() => { - // auto login if password disabled and only one auth provider - if (!passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) { - // Add a small timeout to ensure browser is ready to handle popups - setTimeout(() => { - loginWithOauth(authProviders[0], true) - }, 300) + // handle redirect-based OAuth callback if we have a code + const params = new URLSearchParams(window.location.search) + const code = params.get("code") + if (code) { + const state = params.get("state") + const provider: AuthProviderInfo = JSON.parse(sessionStorage.getItem("provider") ?? "{}") + if (!state || provider.state !== state) { + showLoginFaliedToast() + } else { + setIsOauthLoading(true) + window.history.replaceState({}, "", window.location.pathname) + pb.collection("users") + .authWithOAuth2Code(provider.name, code, provider.codeVerifier, `${window.location.origin}${basePath}`) + .then(() => $authenticated.set(pb.authStore.isValid)) + .catch((e: unknown) => showLoginFaliedToast((e as Error).message)) + .finally(() => setIsOauthLoading(false)) + } } + + // auto login if password disabled and only one auth provider + if (!code && !passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) { + // Add a small timeout to ensure browser is ready to handle popups + setTimeout(() => loginWithOauth(authProviders[0], false), 300) + return + } + + // refresh auth if not in above states (required for trusted auth header) + pb.collection("users") + .authRefresh() + .then((res) => { + pb.authStore.save(res.token, res.record) + $authenticated.set(!!pb.authStore.isValid) + }) }, []) + if (!authMethods) { + return null + } + if (otpId && mfaId) { return } @@ -248,7 +287,7 @@ export function UserAuthForm({ )}
{/* honeypot */} - + { document.documentElement.dir = direction }, [direction]) - useEffect(() => { - // refresh auth if not authenticated (required for trusted auth header) - if (!authenticated) { - pb.collection("users") - .authRefresh() - .then((res) => { - pb.authStore.save(res.token, res.record) - $authenticated.set(!!pb.authStore.isValid) - }) - } - }, []) - return ( {!authenticated ? ( diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 9ad3ecf7..74c01a2a 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -7,6 +7,7 @@ declare global { BASE_PATH: string HUB_VERSION: string HUB_URL: string + OAUTH_DISABLE_POPUP: boolean } }