fix: OIDC redirect URL now respects X-Forwarded-Proto header

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 <noreply@anthropic.com>
This commit is contained in:
rcourtman 2025-09-30 21:06:20 +00:00
parent 0778b8f002
commit 6bfaa8b79a
2 changed files with 56 additions and 7 deletions

View file

@ -326,8 +326,8 @@ export const OIDCPanel: Component<Props> = (props) => {
/>
<p class={formHelpText}>
{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'}
</p>
</div>
</div>

View file

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