feat(hub): add OAUTH_DISABLE_POPUP env var (#1900)

Co-authored-by: henrygd <hank@henrygd.me>
This commit is contained in:
Sven van Ginkel
2026-04-14 02:00:05 +02:00
committed by GitHub
parent ab3a3de46c
commit be0b708064
7 changed files with 103 additions and 63 deletions

42
internal/hub/server.go Normal file
View File

@@ -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
}

View File

@@ -10,8 +10,6 @@ import (
"net/url" "net/url"
"strings" "strings"
"github.com/henrygd/beszel"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/osutils" "github.com/pocketbase/pocketbase/tools/osutils"
) )
@@ -38,7 +36,7 @@ func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error)
} }
resp.Body.Close() resp.Body.Close()
// Create a new response with the modified body // 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.Body = io.NopCloser(strings.NewReader(modifiedBody))
resp.ContentLength = int64(len(modifiedBody)) resp.ContentLength = int64(len(modifiedBody))
resp.Header.Set("Content-Length", fmt.Sprintf("%d", 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 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 // startServer sets up the development server for Beszel
func (h *Hub) startServer(se *core.ServeEvent) error { func (h *Hub) startServer(se *core.ServeEvent) error {
proxy := httputil.NewSingleHostReverseProxy(&url.URL{ proxy := httputil.NewSingleHostReverseProxy(&url.URL{

View File

@@ -5,10 +5,8 @@ package hub
import ( import (
"io/fs" "io/fs"
"net/http" "net/http"
"net/url"
"strings" "strings"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/hub/utils" "github.com/henrygd/beszel/internal/hub/utils"
"github.com/henrygd/beszel/internal/site" "github.com/henrygd/beszel/internal/site"
@@ -18,17 +16,8 @@ import (
// startServer sets up the production server for Beszel // startServer sets up the production server for Beszel
func (h *Hub) startServer(se *core.ServeEvent) error { 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") indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
html := strings.ReplaceAll(string(indexFile), "./", basePath) html := modifyIndexHTML(h, indexFile)
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
html = strings.Replace(html, "{{HUB_URL}}", h.appURL, 1)
// set up static asset serving // set up static asset serving
staticPaths := [2]string{"/static/", "/assets/"} staticPaths := [2]string{"/static/", "/assets/"}
serveStatic := apis.Static(site.DistDirFS, false) serveStatic := apis.Static(site.DistDirFS, false)

View File

@@ -22,11 +22,7 @@
})(); })();
</script> </script>
<script> <script>
globalThis.BESZEL = { globalThis.BESZEL = "{info}"
BASE_PATH: "%BASE_URL%",
HUB_VERSION: "{{V}}",
HUB_URL: "{{HUB_URL}}"
}
</script> </script>
</head> </head>
<body> <body>

View File

@@ -12,7 +12,7 @@ import { Label } from "@/components/ui/label"
import { pb } from "@/lib/api" import { pb } from "@/lib/api"
import { $authenticated } from "@/lib/stores" import { $authenticated } from "@/lib/stores"
import { cn } from "@/lib/utils" 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 { toast } from "../ui/use-toast"
import { OtpInputForm } from "./otp-forms" import { OtpInputForm } from "./otp-forms"
@@ -37,8 +37,7 @@ const RegisterSchema = v.looseObject({
passwordConfirm: passwordSchema, passwordConfirm: passwordSchema,
}) })
export const showLoginFaliedToast = (description?: string) => { export const showLoginFaliedToast = (description = t`Please check your credentials and try again`) => {
description ||= t`Please check your credentials and try again`
toast({ toast({
title: t`Login attempt failed`, title: t`Login attempt failed`,
description, description,
@@ -130,10 +129,6 @@ export function UserAuthForm({
[isFirstRun] [isFirstRun]
) )
if (!authMethods) {
return null
}
const authProviders = authMethods.oauth2.providers ?? [] const authProviders = authMethods.oauth2.providers ?? []
const oauthEnabled = authMethods.oauth2.enabled && authProviders.length > 0 const oauthEnabled = authMethods.oauth2.enabled && authProviders.length > 0
const passwordEnabled = authMethods.password.enabled const passwordEnabled = authMethods.password.enabled
@@ -142,6 +137,12 @@ export function UserAuthForm({
function loginWithOauth(provider: AuthProviderInfo, forcePopup = false) { function loginWithOauth(provider: AuthProviderInfo, forcePopup = false) {
setIsOauthLoading(true) setIsOauthLoading(true)
if (globalThis.BESZEL.OAUTH_DISABLE_POPUP) {
redirectToOauthProvider(provider)
return
}
const oAuthOpts: OAuth2AuthConfig = { const oAuthOpts: OAuth2AuthConfig = {
provider: provider.name, provider: provider.name,
} }
@@ -150,10 +151,7 @@ export function UserAuthForm({
const authWindow = window.open() const authWindow = window.open()
if (!authWindow) { if (!authWindow) {
setIsOauthLoading(false) setIsOauthLoading(false)
toast({ showLoginFaliedToast(t`Please enable pop-ups for this site`)
title: t`Error`,
description: t`Please enable pop-ups for this site`,
})
return return
} }
oAuthOpts.urlCallback = (url) => { oAuthOpts.urlCallback = (url) => {
@@ -171,16 +169,57 @@ export function UserAuthForm({
}) })
} }
useEffect(() => { /**
// auto login if password disabled and only one auth provider * Redirects the user to the OAuth provider's authentication page in the same window.
if (!passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) { * Requires the app's base URL to be registered as a redirect URI with the OAuth provider.
// Add a small timeout to ensure browser is ready to handle popups */
setTimeout(() => { function redirectToOauthProvider(provider: AuthProviderInfo) {
loginWithOauth(authProviders[0], true) const url = new URL(provider.authURL)
}, 300) // url.searchParams.set("redirect_uri", `${window.location.origin}${basePath}`)
sessionStorage.setItem("provider", JSON.stringify(provider))
window.location.href = url.toString()
} }
useEffect(() => {
// 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) { if (otpId && mfaId) {
return <OtpInputForm otpId={otpId} mfaId={mfaId} /> return <OtpInputForm otpId={otpId} mfaId={mfaId} />
} }
@@ -248,7 +287,7 @@ export function UserAuthForm({
)} )}
<div className="sr-only"> <div className="sr-only">
{/* honeypot */} {/* honeypot */}
<label htmlFor="website"></label> <label htmlFor="website">Website</label>
<input <input
id="website" id="website"
type="text" type="text"

View File

@@ -94,18 +94,6 @@ const Layout = () => {
document.documentElement.dir = direction document.documentElement.dir = direction
}, [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 ( return (
<DirectionProvider dir={direction}> <DirectionProvider dir={direction}>
{!authenticated ? ( {!authenticated ? (

View File

@@ -7,6 +7,7 @@ declare global {
BASE_PATH: string BASE_PATH: string
HUB_VERSION: string HUB_VERSION: string
HUB_URL: string HUB_URL: string
OAUTH_DISABLE_POPUP: boolean
} }
} }