// Package authn owns gsg's browser session endpoints. Login trusts the // identity header that the reverse proxy sets after forward_auth; logout // only clears gsg's own cookie. Keeping both here means main.go composes // listeners rather than implementing authentication. // // The two handlers are returned separately on purpose: login must only ever // be mounted on the admin listener, while logout belongs on the public one. // A single combined handler would make that distinction a calling // convention instead of a structural one. package authn import ( "fmt" "net/http" "net/url" "strings" "gsg/internal/session" ) // Config is the small amount of state the session endpoints need. type Config struct { Sessions *session.Manager UserHeader string // e.g. "X-Authentik-Username"; defaults to that } // Handlers are mounted individually by the caller. type Handlers struct { Login http.Handler Logout http.Handler } // NewHandlers builds the login and logout endpoints. func NewHandlers(cfg Config) (Handlers, error) { if cfg.Sessions == nil { return Handlers{}, fmt.Errorf("authn: no session manager") } if cfg.UserHeader == "" { cfg.UserHeader = "X-Authentik-Username" } return Handlers{ Login: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { login(cfg, w, r) }), Logout: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { logout(cfg, w, r) }), }, nil } // login mints a session from the proxy-supplied identity. Reaching this // handler at all is the authorization decision: the admin listener is the // only place it is mounted, and Caddy's forward_auth runs before it. func login(cfg Config, w http.ResponseWriter, r *http.Request) { user := r.Header.Get(cfg.UserHeader) if user == "" { http.Error(w, "missing identity header", http.StatusUnauthorized) return } http.SetCookie(w, cfg.Sessions.Issue(user)) http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther) } // logout needs no round trip through the identity provider; it only clears // the cookie this process issued. func logout(cfg Config, w http.ResponseWriter, r *http.Request) { http.SetCookie(w, cfg.Sessions.Clear()) http.Redirect(w, r, "/", http.StatusSeeOther) } // safeNext returns next if it is a same-site relative path, else "/". next // comes from a query parameter on a login redirect; without this check it // would be an open redirect (?next=https://evil.example) off a trusted // domain, right after proving who the user is. func safeNext(next string) string { if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") { return "/" } if u, err := url.Parse(next); err != nil || u.Scheme != "" || u.Host != "" { return "/" } return next }