Files
Mako/internal/web/auth.go
T
VeillaxandClaude Sonnet 5.5 e3147597e8
CI / test (push) Successful in 2m9s
Release / release (push) Successful in 2m16s
Rename Nurse to Mako
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 00:07:43 -06:00

181 lines
5.2 KiB
Go

package web
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"log"
"net/http"
"regexp"
"strings"
"time"
"git.veillax.com/Veillax/mako/internal/config"
"git.veillax.com/Veillax/mako/internal/oidc"
)
const (
sessionCookie = "mako_session"
flowCookie = "mako_signin"
sessionTTL = 14 * 24 * time.Hour
flowTTL = 10 * time.Minute
)
// A cookie is "payload.signature": the payload is JSON, the signature is an HMAC with the secret from
// the settings file. Nothing is kept on the server, so a restart does not sign anyone out.
func seal(c config.Config, kind string, v any) string {
raw, _ := json.Marshal(v)
payload := base64.RawURLEncoding.EncodeToString(raw)
return payload + "." + mac(c, kind, payload)
}
func mac(c config.Config, kind, payload string) string {
m := hmac.New(sha256.New, []byte(c.Secret))
m.Write([]byte(kind + "|" + payload))
return hex.EncodeToString(m.Sum(nil))
}
func unseal(c config.Config, kind, value string, v any) bool {
payload, sig, ok := strings.Cut(value, ".")
if !ok || !hmac.Equal([]byte(sig), []byte(mac(c, kind, payload))) {
return false
}
raw, err := base64.RawURLEncoding.DecodeString(payload)
return err == nil && json.Unmarshal(raw, v) == nil
}
type sessionData struct {
Sub string `json:"s"`
Name string `json:"n"`
Email string `json:"e"`
User string `json:"u"`
Exp int64 `json:"x"`
}
type flowData struct {
State string `json:"s"`
Nonce string `json:"n"`
Verifier string `json:"v"`
Next string `json:"t"`
Exp int64 `json:"x"`
}
// session reads the sign-in cookie. The allowed list is checked on every request, so taking someone
// off it takes effect at once.
func (s *Server) session(c config.Config, r *http.Request) (sessionData, bool) {
var d sessionData
ck, err := r.Cookie(sessionCookie)
if err != nil || !unseal(c, "session", ck.Value, &d) || d.Sub == "" || time.Now().Unix() > d.Exp {
return sessionData{}, false
}
return d, allowed(c, d)
}
func allowed(c config.Config, d sessionData) bool {
if len(c.Allowed) == 0 {
return true
}
for _, a := range c.Allowed {
for _, who := range []string{d.Email, d.User, d.Sub} {
if who != "" && strings.EqualFold(strings.TrimSpace(a), who) {
return true
}
}
}
return false
}
func setCookie(w http.ResponseWriter, c config.Config, name, value, path string, ttl time.Duration) {
ck := &http.Cookie{Name: name, Value: value, Path: path, HttpOnly: true, Secure: c.Secure(), SameSite: http.SameSiteLaxMode}
if value == "" {
ck.MaxAge = -1
} else {
ck.MaxAge = int(ttl.Seconds())
}
http.SetCookie(w, ck)
}
// Where to go after signing in: only a place inside Mako, such as #/p/abc.
var nextRe = regexp.MustCompile(`^#/[A-Za-z0-9/_.~%-]{0,300}$`)
func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
c := s.cfg.Get()
next := r.URL.Query().Get("next")
if !nextRe.MatchString(next) {
next = ""
}
f := flowData{State: oidc.Random(18), Nonce: oidc.Random(18), Verifier: oidc.Random(40), Next: next, Exp: time.Now().Add(flowTTL).Unix()}
to, err := s.oidc.AuthURL(r.Context(), f.State, f.Nonce, oidc.Challenge(f.Verifier))
if err != nil {
log.Printf("sign-in: %v", err)
http.Redirect(w, r, "/?signin=unavailable", http.StatusSeeOther)
return
}
setCookie(w, c, flowCookie, seal(c, "flow", f), "/auth/", flowTTL)
http.Redirect(w, r, to, http.StatusSeeOther)
}
func (s *Server) authCallback(w http.ResponseWriter, r *http.Request) {
c := s.cfg.Get()
back := func(why string) { http.Redirect(w, r, "/?signin="+why, http.StatusSeeOther) }
var f flowData
ck, err := r.Cookie(flowCookie)
setCookie(w, c, flowCookie, "", "/auth/", 0) // a sign-in attempt is used once
if err != nil || !unseal(c, "flow", ck.Value, &f) || time.Now().Unix() > f.Exp {
back("expired")
return
}
q := r.URL.Query()
if e := q.Get("error"); e != "" {
log.Printf("sign-in: the provider answered %q: %s", e, q.Get("error_description"))
back("denied")
return
}
if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(f.State)) != 1 || q.Get("code") == "" {
back("failed")
return
}
id, err := s.oidc.Exchange(r.Context(), q.Get("code"), f.Verifier, f.Nonce)
if err != nil {
log.Printf("sign-in: %v", err)
if errors.Is(err, oidc.ErrUnavailable) {
back("unavailable")
} else {
back("failed")
}
return
}
d := sessionData{Sub: id.Sub, Name: firstOf(id.Name, id.Username, id.Email, "You"), Email: id.Email, User: id.Username,
Exp: time.Now().Add(sessionTTL).Unix()}
if !allowed(c, d) {
log.Printf("sign-in: %s (%s) is not on the allowed list", d.Name, firstOf(d.Email, d.User, d.Sub))
back("denied")
return
}
setCookie(w, c, sessionCookie, seal(c, "session", d), "/", sessionTTL)
http.Redirect(w, r, "/"+f.Next, http.StatusSeeOther)
}
func firstOf(s ...string) string {
for _, x := range s {
if strings.TrimSpace(x) != "" {
return x
}
}
return ""
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request, p principal) {
setCookie(w, s.cfg.Get(), sessionCookie, "", "/", 0)
writeJSON(w, map[string]bool{"ok": true})
}
func (s *Server) me(w http.ResponseWriter, r *http.Request, p principal) {
writeJSON(w, map[string]any{"name": p.Name, "email": p.Email, "version": s.version})
}