1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
|
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>
<html lang="en"><head><meta charset="utf-8"><title>Signing in…</title>
<link rel="stylesheet" href="/_assets/style.css"></head>
<body><main><p id="msg">Completing sign-in…</p></main>
<script src="/_assets/auth-callback.js"></script></body></html>`))
}
// 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,
})
}
|