package server
import ( "crypto/rand" "crypto/subtle" "encoding/base64" "encoding/json" "net/http" "net/url" "strings" "time" )
// Browser single-sign-on against an external issuer (vault). gitweb never calls // the issuer at runtime: it redirects the browser to the issuer's login page, // receives an issuer-signed SSO token back in the callback URL fragment, and // verifies it statelessly with the same VaultVerifier used for Basic-auth // tokens (the signature roots in the issuer's upspin keyserver key). The token // then lives in a session cookie. // // Flow: /_auth/login -> issuer /authorize -> /_auth/callback#token=…&state=… // -> (page JS) POST /_auth/session -> gitweb_session cookie set.
const ( sessionCookie = "gitweb_session" // holds the verified SSO token oauthCookie = "gitweb_oauth" // holds {state, next} across the redirect oauthTTL = 10 * time.Minute sessionTTL = 12 * time.Hour // matches the issuer token TTL; expiry is // ultimately enforced by verifying the token, not by the cookie MaxAge. )
// ssoEnabled reports whether the browser login flow is configured. func (s *Server) ssoEnabled() bool { return s.vault != nil && s.ssoAuthURL != "" && s.baseURL != "" }
// handleLogin starts the browser SSO flow: it remembers where to return to, // then redirects to the issuer's login page with our callback as redirect_uri. func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { next := sanitizeNext(r.URL.Query().Get("next")) state := randToken()
// Remember state (CSRF) and the post-login destination in a short-lived,
// same-origin cookie. SameSite=Lax so it survives the top-level GET
// navigation back from the issuer.
setCookie(w, oauthCookie, url.Values{"state": {state}, "next": {next}}.Encode(), oauthTTL)
q := url.Values{
"redirect_uri": {s.baseURL + "/_auth/callback"},
"state": {state},
"aud": {s.ssoAud},
}
http.Redirect(w, r, s.ssoAuthURL+"?"+q.Encode(), http.StatusSeeOther)
}
// handleCallback serves the page that the issuer redirects back to. The SSO // token arrives in the URL fragment (never sent to a server), so a tiny // same-origin script reads it and POSTs it to /_auth/session. Served as HTML // referencing an external script because the CSP forbids inline scripts. func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(`<!doctype html>
Completing sign-in…
`)) }// handleSession consumes the token the callback page extracted from the
// fragment. It checks the CSRF state against the oauth cookie, verifies the
// token, and on success installs the session cookie. Body: {"token","state"}.
func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) {
var body struct {
Token string json:"token"
State string json:"state"
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&body); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
c, err := r.Cookie(oauthCookie)
if err != nil {
http.Error(w, "no login in progress", http.StatusBadRequest)
return
}
saved, _ := url.ParseQuery(c.Value)
clearCookie(w, oauthCookie) // one-shot regardless of outcome
wantState := saved.Get("state")
if wantState == "" || subtle.ConstantTimeCompare([]byte(wantState), []byte(body.State)) != 1 {
http.Error(w, "state mismatch", http.StatusBadRequest)
return
}
if _, err := s.vault.Verify(body.Token); err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
setCookie(w, sessionCookie, body.Token, sessionTTL)
writeJSON(w, map[string]string{"next": sanitizeNext(saved.Get("next"))})
}
// handleLogout clears the session cookie and returns to the site root. It is a // plain GET link: gitweb's CSP forbids form submissions (form-action 'none'), // and forcing a logout is not a meaningful attack. func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { clearCookie(w, sessionCookie) http.Redirect(w, r, "/", http.StatusSeeOther) }
// sanitizeNext confines a post-login redirect to a local path, so the flow // cannot be turned into an open redirect. Anything not a plain absolute path // (including protocol-relative "//host") falls back to the site root. func sanitizeNext(next string) string { if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") { return "/" } return next }
func randToken() string { b := make([]byte, 16) _, _ = rand.Read(b) return base64.RawURLEncoding.EncodeToString(b) }
func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) }
func setCookie(w http.ResponseWriter, name, val string, ttl time.Duration) { http.SetCookie(w, &http.Cookie{ Name: name, Value: val, Path: "/", MaxAge: int(ttl.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) }
func clearCookie(w http.ResponseWriter, name string) { http.SetCookie(w, &http.Cookie{ Name: name, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) }