prole/gitea/spnego-proxy/main.go
chrisfu 5077e1335d feat(gitea): SPNEGO Kerberos SSO for git.prole.org
Three-part fix to make browser and curl SPNEGO auth work end-to-end:

1. spnego-proxy: issue WWW-Authenticate: Negotiate challenge when no
   Authorization header is present so Chrome (with AuthServerAllowlist)
   and curl --negotiate automatically present Kerberos tokens. Previously
   the proxy only validated tokens if the client proactively sent them.
   Pass-through preserved for non-Negotiate schemes (Basic/token) so
   git CLI users with PATs continue to work via Gitea own auth.

2. gitea_spnego_keytab.yml: new Ansible playbook that provisions the
   gitea-http AD account (AES-only, msDS-SupportedEncryptionTypes=24),
   registers SPN HTTP/git.prole.org, resets the password to derive fresh
   AES keys, exports the domain keytab, and rekeys it to principal name
   HTTP/git.prole.org@PROLE.ORG that gokrb5 needs for keytab lookup.
   Key lesson: samba-tool exportkeytab --principal=HTTP/... returns empty;
   must export full domain keytab and rekey in Python.

3. init_gitea.sh: add setup_gitea_spnego() calling the Ansible playbook
   in k3s mode as part of the standard deploy flow, with inline notes
   on every non-obvious constraint discovered during this work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 05:19:59 -07:00

112 lines
3.7 KiB
Go

package main
import (
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
goidentity "github.com/jcmturner/goidentity/v6"
"github.com/jcmturner/gokrb5/v8/keytab"
"github.com/jcmturner/gokrb5/v8/spnego"
)
const (
upstreamURL = "http://gitea-http.gitea.svc.cluster.local:3000"
listenAddr = ":4000"
)
// statusCapture wraps http.ResponseWriter to capture the HTTP status code
// written by a downstream handler so callers can inspect it after ServeHTTP.
type statusCapture struct {
http.ResponseWriter
status int
}
func (s *statusCapture) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func main() {
keytabPath := os.Getenv("KRB5_KTNAME")
if keytabPath == "" {
keytabPath = "/etc/krb5/http.keytab"
}
kt, err := keytab.Load(keytabPath)
if err != nil {
log.Fatalf("failed to load keytab %s: %v", keytabPath, err)
}
upstream, _ := url.Parse(upstreamURL)
proxy := httputil.NewSingleHostReverseProxy(upstream)
proxy.Director = func(req *http.Request) {
req.URL.Scheme = upstream.Scheme
req.URL.Host = upstream.Host
if _, ok := req.Header["User-Agent"]; !ok {
req.Header.Set("User-Agent", "")
}
}
// authedInner runs only when SPNEGO negotiation succeeds.
// It extracts the client username from the request context and injects
// X-WEBAUTH-USER for Gitea reverse-proxy auto-login.
authedInner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// goidentity.AddToHTTPRequestContext is called by SPNEGOKRB5Authenticate on success.
// FromHTTPRequestContext retrieves the identity set there.
if id := goidentity.FromHTTPRequestContext(r); id != nil {
username := strings.SplitN(id.UserName(), "@", 2)[0]
r.Header.Set("X-WEBAUTH-USER", username)
log.Printf("SPNEGO auth OK: %s -> %s", id.UserName(), username)
}
proxy.ServeHTTP(w, r)
})
spnegoHandler := spnego.SPNEGOKRB5Authenticate(authedInner, kt)
mux := http.NewServeMux()
mux.HandleFunc("/_healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Negotiate ") {
// Client is presenting a Kerberos ticket — run full SPNEGO negotiation.
// On success: X-WEBAUTH-USER is set by authedInner, Gitea auto-logs in.
// On failure: gokrb5 returns 401 (invalid ticket — correct to reject).
rw := &statusCapture{ResponseWriter: w}
spnegoHandler.ServeHTTP(rw, r)
if rw.status == http.StatusUnauthorized {
log.Printf("SPNEGO auth FAILED: from=%s host=%s path=%s token-prefix=%.20s",
r.RemoteAddr, r.Host, r.URL.Path,
auth[len("Negotiate "):])
}
return
}
if auth != "" {
// Other auth scheme (Basic, token) — pass through to Gitea.
// This allows git CLI users with personal access tokens or passwords to
// authenticate directly via Gitea's own credential validation.
log.Printf("pass-through auth: from=%s scheme=%.6s...", r.RemoteAddr, auth)
proxy.ServeHTTP(w, r)
return
}
// No auth header — issue a SPNEGO challenge.
// Browsers with Chrome SPNEGO policy (AuthServerAllowlist=*.prole.org) will
// automatically respond with Authorization: Negotiate <kerberos-token>.
// git CLI users with PATs will retry with Basic auth (handled above).
w.Header().Set("WWW-Authenticate", "Negotiate")
http.Error(w, "Authentication required (SPNEGO/Kerberos)", http.StatusUnauthorized)
log.Printf("SPNEGO challenge issued: from=%s host=%s path=%s", r.RemoteAddr, r.Host, r.URL.Path)
})
log.Printf("spnego-proxy listening on %s, upstream %s", listenAddr, upstreamURL)
log.Fatal(http.ListenAndServe(listenAddr, mux))
}