auth.go
1package appview
2
3import (
4 "bufio"
5 "crypto/hmac"
6 "crypto/rand"
7 "crypto/sha256"
8 "crypto/subtle"
9 "encoding/base64"
10 "encoding/hex"
11 "fmt"
12 "net/http"
13 "os"
14 "strconv"
15 "strings"
16 "time"
17
18 "golang.org/x/crypto/bcrypt"
19)
20
21// Who is making a request.
22type Who struct {
23 Owner bool // logged in with the owner's password
24 Agent string // name of the agent token used, if any
25}
26
27// CanRead: the owner and any agent.
28func (w Who) CanRead() bool { return w.Owner || w.Agent != "" }
29
30// CanApprove: only the owner's session approves gates, runs danger actions
31// and cancels (DESIGN.md ยง8 rule 7).
32func (w Who) CanApprove() bool { return w.Owner }
33
34// Actor is how the decision is recorded.
35func (w Who) Actor(owner string) string {
36 if w.Owner {
37 return owner
38 }
39 return "agent:" + w.Agent
40}
41
42// Auth holds the owner's password hash, the session key and agent tokens.
43type Auth struct {
44 PasswordHash []byte // bcrypt
45 Key []byte // signs session cookies and CSRF tokens
46 Agents map[string]string // token -> name
47 Secure bool // Secure cookies (behind https)
48 SessionTTL time.Duration
49}
50
51const cookieName = "ci_session"
52
53// LoadAgents reads "<name> <token>" lines (# comments).
54func LoadAgents(path string) (map[string]string, error) {
55 out := map[string]string{}
56 if path == "" {
57 return out, nil
58 }
59 f, err := os.Open(path)
60 if err != nil {
61 return nil, err
62 }
63 defer f.Close()
64 sc := bufio.NewScanner(f)
65 for sc.Scan() {
66 line := strings.TrimSpace(sc.Text())
67 if line == "" || strings.HasPrefix(line, "#") {
68 continue
69 }
70 name, tok, ok := strings.Cut(line, " ")
71 tok = strings.TrimSpace(tok)
72 if !ok || len(tok) < 16 {
73 return nil, fmt.Errorf("%s: want \"<name> <token>\" with a token of 16+ characters", path)
74 }
75 out[tok] = name
76 }
77 return out, sc.Err()
78}
79
80// NewKey returns a random session key (sessions end when the appview restarts
81// unless a key file is configured).
82func NewKey() []byte {
83 k := make([]byte, 32)
84 _, _ = rand.Read(k)
85 return k
86}
87
88func (a *Auth) ttl() time.Duration {
89 if a.SessionTTL > 0 {
90 return a.SessionTTL
91 }
92 return 30 * 24 * time.Hour
93}
94
95func (a *Auth) sign(s string) string {
96 m := hmac.New(sha256.New, a.Key)
97 m.Write([]byte(s))
98 return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
99}
100
101// CheckPassword reports whether password is the owner's.
102func (a *Auth) CheckPassword(password string) bool {
103 return len(a.PasswordHash) > 0 && bcrypt.CompareHashAndPassword(a.PasswordHash, []byte(password)) == nil
104}
105
106// Login sets the session cookie.
107func (a *Auth) Login(w http.ResponseWriter) {
108 exp := strconv.FormatInt(time.Now().Add(a.ttl()).Unix(), 10)
109 nonce := make([]byte, 12)
110 _, _ = rand.Read(nonce)
111 v := exp + "." + hex.EncodeToString(nonce)
112 http.SetCookie(w, &http.Cookie{
113 Name: cookieName, Value: v + "." + a.sign(v), Path: "/", HttpOnly: true, Secure: a.Secure,
114 SameSite: http.SameSiteStrictMode, MaxAge: int(a.ttl().Seconds()),
115 })
116}
117
118// Logout clears it.
119func (a *Auth) Logout(w http.ResponseWriter) {
120 http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: a.Secure, SameSite: http.SameSiteStrictMode})
121}
122
123// session returns the session id if the cookie is valid.
124func (a *Auth) session(r *http.Request) (string, bool) {
125 c, err := r.Cookie(cookieName)
126 if err != nil {
127 return "", false
128 }
129 i := strings.LastIndexByte(c.Value, '.')
130 if i < 0 {
131 return "", false
132 }
133 v, sig := c.Value[:i], c.Value[i+1:]
134 if subtle.ConstantTimeCompare([]byte(sig), []byte(a.sign(v))) != 1 {
135 return "", false
136 }
137 exp, _, _ := strings.Cut(v, ".")
138 t, err := strconv.ParseInt(exp, 10, 64)
139 if err != nil || time.Now().Unix() > t {
140 return "", false
141 }
142 return v, true
143}
144
145// Who identifies a request: a valid session cookie, or an agent's bearer
146// token. Agent tokens never make a request the owner's.
147func (a *Auth) Who(r *http.Request) Who {
148 if _, ok := a.session(r); ok {
149 return Who{Owner: true}
150 }
151 if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
152 tok := strings.TrimPrefix(h, "Bearer ")
153 for t, name := range a.Agents {
154 if subtle.ConstantTimeCompare([]byte(t), []byte(tok)) == 1 {
155 return Who{Agent: name}
156 }
157 }
158 }
159 return Who{}
160}
161
162// CSRF returns the form token for the request's session.
163func (a *Auth) CSRF(r *http.Request) string {
164 s, ok := a.session(r)
165 if !ok {
166 return ""
167 }
168 return a.sign("csrf:" + s)
169}
170
171// CheckCSRF verifies a form's token (session-bound; cookies are also
172// SameSite=Strict).
173func (a *Auth) CheckCSRF(r *http.Request) bool {
174 want := a.CSRF(r)
175 return want != "" && subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(want)) == 1
176}