package auth import ( "context" "log/slog" "net" "net/http" "strings" "veola/internal/models" ) // ForwardAuthConfig configures trust of reverse-proxy identity headers // (Traefik forwardAuth in front of Authentik). It is nil unless forward-auth // is enabled in config. type ForwardAuthConfig struct { HeaderUser string HeaderEmail string HeaderName string HeaderGroups string AdminGroup string trustedNets []*net.IPNet } // NewForwardAuthConfig parses the trusted-proxy CIDRs and returns a ready // config. An entry that is a bare IP (no "/") is treated as a /32 or /128. func NewForwardAuthConfig(headerUser, headerEmail, headerName, headerGroups, adminGroup string, trustedProxies []string) (*ForwardAuthConfig, error) { fa := &ForwardAuthConfig{ HeaderUser: headerUser, HeaderEmail: headerEmail, HeaderName: headerName, HeaderGroups: headerGroups, AdminGroup: adminGroup, } for _, raw := range trustedProxies { raw = strings.TrimSpace(raw) if raw == "" { continue } if !strings.Contains(raw, "/") { if strings.Contains(raw, ":") { raw += "/128" } else { raw += "/32" } } _, n, err := net.ParseCIDR(raw) if err != nil { return nil, err } fa.trustedNets = append(fa.trustedNets, n) } return fa, nil } // trusts reports whether a direct-peer IP string is within a trusted CIDR. func (fa *ForwardAuthConfig) trusts(ipStr string) bool { ip := net.ParseIP(ipStr) if ip == nil { return false } for _, n := range fa.trustedNets { if n.Contains(ip) { return true } } return false } // SetForwardAuth enables forward-auth header trust on the manager. func (m *Manager) SetForwardAuth(fa *ForwardAuthConfig) { m.forward = fa } type directIPCtxKey struct{} // CaptureDirectIP records the direct connection's remote IP into the request // context BEFORE middleware.RealIP rewrites RemoteAddr from X-Forwarded-For. // Forward-auth trust is checked against this captured value, not the // proxy-supplied one, so a client cannot claim to be the proxy. func CaptureDirectIP(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr } ctx := context.WithValue(r.Context(), directIPCtxKey{}, host) next.ServeHTTP(w, r.WithContext(ctx)) }) } func directIP(ctx context.Context) string { s, _ := ctx.Value(directIPCtxKey{}).(string) return s } // ForwardAuth, when enabled, trusts identity headers from a configured proxy. // It runs after LoadUser: if the direct peer is a trusted proxy and an email // header is present, it find-or-creates the local user (keyed by email), // re-syncs role from the IdP group, writes the user into the session + context, // and so supersedes any stale local session. When forward-auth is disabled, or // the peer is untrusted, or no headers are present, it is a no-op and local // session auth applies (break-glass login still works). func (m *Manager) ForwardAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fa := m.forward if fa == nil { next.ServeHTTP(w, r) return } if !fa.trusts(directIP(r.Context())) { next.ServeHTTP(w, r) return } email := strings.ToLower(strings.TrimSpace(r.Header.Get(fa.HeaderEmail))) if email == "" { next.ServeHTTP(w, r) return } username := strings.TrimSpace(r.Header.Get(fa.HeaderUser)) if username == "" { username = strings.TrimSpace(r.Header.Get(fa.HeaderName)) } role := models.RoleUser if fa.hasAdminGroup(r.Header.Get(fa.HeaderGroups)) { role = models.RoleAdmin } u, err := m.Store.UpsertForwardUser(r.Context(), email, username, role) if err != nil || u == nil { slog.Error("forward-auth upsert failed", "email", email, "err", err) http.Error(w, "forward-auth provisioning failed", http.StatusInternalServerError) return } // Keep the session in sync so UserID()/CSRFToken() and a later // non-proxied request behave; rotate only when the bound user changes. if m.UserID(r.Context()) != u.ID { if err := m.LogIn(r.Context(), u.ID); err != nil { slog.Error("forward-auth session login failed", "err", err) } } ctx := context.WithValue(r.Context(), ctxKeyUser, u) next.ServeHTTP(w, r.WithContext(ctx)) }) } // hasAdminGroup reports whether the IdP groups header contains the admin group. // Authentik joins groups with "|"; commas are also accepted. func (fa *ForwardAuthConfig) hasAdminGroup(header string) bool { if fa.AdminGroup == "" || header == "" { return false } for _, g := range strings.FieldsFunc(header, func(r rune) bool { return r == '|' || r == ',' }) { if strings.EqualFold(strings.TrimSpace(g), fa.AdminGroup) { return true } } return false }