From 6bfaa8b79a6740863d55b35023a034e355fc5032 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 30 Sep 2025 21:06:20 +0000 Subject: [PATCH] fix: OIDC redirect URL now respects X-Forwarded-Proto header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses #327 - Users behind reverse proxies (Traefik, nginx, etc) were experiencing redirect loop issues because the redirect URL was being built with http:// instead of https:// when X-Forwarded-Proto was set. Changes: - Build OIDC redirect URL dynamically from each request instead of at startup - Respect X-Forwarded-Proto and X-Forwarded-Host headers from reverse proxies - Update UI help text to clarify auto-detection behavior - Add debug logging to show how redirect URL is constructed When redirect URL is not explicitly configured, Pulse now builds it from the incoming request headers, properly detecting HTTPS when behind a proxy. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/components/Settings/OIDCPanel.tsx | 4 +- internal/api/oidc_handlers.go | 59 +++++++++++++++++-- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/frontend-modern/src/components/Settings/OIDCPanel.tsx b/frontend-modern/src/components/Settings/OIDCPanel.tsx index 2f152589b..7db76dc4b 100644 --- a/frontend-modern/src/components/Settings/OIDCPanel.tsx +++ b/frontend-modern/src/components/Settings/OIDCPanel.tsx @@ -326,8 +326,8 @@ export const OIDCPanel: Component = (props) => { />

{config()?.defaultRedirect - ? `If left blank, Pulse will use ${config()?.defaultRedirect}` - : 'Set PUBLIC_URL environment variable or enter redirect URL manually'} + ? `Optional - Leave blank to auto-detect from request headers (supports reverse proxies). Detected URL: ${config()?.defaultRedirect}` + : 'Leave blank to auto-detect from request headers, or set PUBLIC_URL environment variable'}

diff --git a/internal/api/oidc_handlers.go b/internal/api/oidc_handlers.go index eb6cc5d1b..930e51d5f 100644 --- a/internal/api/oidc_handlers.go +++ b/internal/api/oidc_handlers.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "net/url" @@ -26,7 +27,10 @@ func (r *Router) handleOIDCLogin(w http.ResponseWriter, req *http.Request) { return } - service, err := r.getOIDCService(req.Context()) + // Build redirect URL from request (respects X-Forwarded-* headers) + redirectURL := buildRedirectURL(req, cfg.RedirectURL) + + service, err := r.getOIDCService(req.Context(), redirectURL) if err != nil { log.Error().Err(err).Str("issuer", cfg.IssuerURL).Msg("Failed to initialise OIDC service") writeErrorResponse(w, http.StatusInternalServerError, "oidc_init_failed", "OIDC provider is unavailable", nil) @@ -72,7 +76,10 @@ func (r *Router) handleOIDCCallback(w http.ResponseWriter, req *http.Request) { return } - service, err := r.getOIDCService(req.Context()) + // Build redirect URL from request (respects X-Forwarded-* headers) + redirectURL := buildRedirectURL(req, cfg.RedirectURL) + + service, err := r.getOIDCService(req.Context(), redirectURL) if err != nil { log.Error().Err(err).Str("issuer", cfg.IssuerURL).Msg("Failed to initialise OIDC service for callback") r.redirectOIDCError(w, req, "", "oidc_init_failed") @@ -221,7 +228,7 @@ func (r *Router) handleOIDCCallback(w http.ResponseWriter, req *http.Request) { http.Redirect(w, req, target, http.StatusFound) } -func (r *Router) getOIDCService(ctx context.Context) (*OIDCService, error) { +func (r *Router) getOIDCService(ctx context.Context, redirectURL string) (*OIDCService, error) { cfg := r.ensureOIDCConfig() if cfg == nil || !cfg.Enabled { return nil, errors.New("oidc disabled") @@ -230,11 +237,15 @@ func (r *Router) getOIDCService(ctx context.Context) (*OIDCService, error) { r.oidcMu.Lock() defer r.oidcMu.Unlock() - if r.oidcService != nil && r.oidcService.Matches(cfg) { + // Create a config clone with the dynamic redirect URL + cfgWithRedirect := cfg.Clone() + cfgWithRedirect.RedirectURL = redirectURL + + if r.oidcService != nil && r.oidcService.Matches(cfgWithRedirect) { return r.oidcService, nil } - service, err := NewOIDCService(ctx, cfg) + service, err := NewOIDCService(ctx, cfgWithRedirect) if err != nil { return nil, err } @@ -394,3 +405,41 @@ func (r *Router) ensureOIDCConfig() *config.OIDCConfig { } return r.config.OIDC } + +// buildRedirectURL constructs the OIDC redirect URL from the incoming request, +// respecting X-Forwarded-* headers when behind a reverse proxy +func buildRedirectURL(req *http.Request, configuredURL string) string { + // If explicitly configured, use that + if strings.TrimSpace(configuredURL) != "" { + return configuredURL + } + + // Build from request headers (respects reverse proxy headers) + scheme := "http" + if req.TLS != nil { + scheme = "https" + } + // Check X-Forwarded-Proto header (set by reverse proxies) + if proto := req.Header.Get("X-Forwarded-Proto"); proto != "" { + scheme = proto + } + + host := req.Host + // Check X-Forwarded-Host header (set by reverse proxies) + if fwdHost := req.Header.Get("X-Forwarded-Host"); fwdHost != "" { + host = fwdHost + } + + redirectURL := fmt.Sprintf("%s://%s%s", scheme, host, config.DefaultOIDCCallbackPath) + + log.Debug(). + Str("scheme", scheme). + Str("host", host). + Str("x_forwarded_proto", req.Header.Get("X-Forwarded-Proto")). + Str("x_forwarded_host", req.Header.Get("X-Forwarded-Host")). + Str("redirect_url", redirectURL). + Bool("has_tls", req.TLS != nil). + Msg("Built OIDC redirect URL from request") + + return redirectURL +}