package customerauth import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" "fmt" "net/http" "strconv" "strings" "time" ) // SessionCookieName is the name of the HttpOnly cookie that carries the signed // session token for a logged-in customer. const SessionCookieName = "sid" // DefaultSessionTTL is how long an issued session stays valid. const DefaultSessionTTL = 30 * 24 * time.Hour var ( // ErrInvalidToken is returned when a token is malformed or its signature // does not verify. ErrInvalidToken = errors.New("customerauth: invalid session token") // ErrExpiredToken is returned when a token's signature is valid but it has // passed its expiry. ErrExpiredToken = errors.New("customerauth: expired session token") ) // Signer issues and verifies session tokens using an HMAC-SHA256 key. type Signer struct { secret []byte } // NewSigner returns a Signer over the given secret key bytes. func NewSigner(secret []byte) *Signer { return &Signer{secret: secret} } // token format: base64url(.) "." base64url(hmac) // The payload is signed, not encrypted — it carries no secrets, only the // profile id and expiry, and the HMAC makes it tamper-evident. // Issue returns a signed token for profileID that expires after ttl. func (s *Signer) Issue(profileID uint64, ttl time.Duration) string { exp := time.Now().Add(ttl).Unix() payload := fmt.Sprintf("%d.%d", profileID, exp) b := base64.RawURLEncoding.EncodeToString([]byte(payload)) return b + "." + s.sign(b) } // Parse verifies a token and returns the profile id it carries. It returns // ErrInvalidToken for a bad signature/format and ErrExpiredToken when expired. func (s *Signer) Parse(token string) (uint64, error) { b, sig, ok := strings.Cut(token, ".") if !ok || b == "" || sig == "" { return 0, ErrInvalidToken } if !hmac.Equal([]byte(sig), []byte(s.sign(b))) { return 0, ErrInvalidToken } raw, err := base64.RawURLEncoding.DecodeString(b) if err != nil { return 0, ErrInvalidToken } idStr, expStr, ok := strings.Cut(string(raw), ".") if !ok { return 0, ErrInvalidToken } id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { return 0, ErrInvalidToken } exp, err := strconv.ParseInt(expStr, 10, 64) if err != nil { return 0, ErrInvalidToken } if time.Now().Unix() >= exp { return 0, ErrExpiredToken } return id, nil } func (s *Signer) sign(b string) string { mac := hmac.New(sha256.New, s.secret) mac.Write([]byte(b)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } // SetSession writes the session cookie for token onto w. The Secure flag is set // when the request arrived over TLS (directly or via an https-terminating // proxy) so the cookie still works on plain-http localhost in dev. func SetSession(w http.ResponseWriter, r *http.Request, token string, ttl time.Duration) { http.SetCookie(w, &http.Cookie{ Name: SessionCookieName, Value: token, Path: "/", HttpOnly: true, Secure: isSecure(r), SameSite: http.SameSiteLaxMode, Expires: time.Now().Add(ttl), MaxAge: int(ttl.Seconds()), }) } // ClearSession expires the session cookie on w. func ClearSession(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: SessionCookieName, Value: "", Path: "/", HttpOnly: true, Secure: isSecure(r), SameSite: http.SameSiteLaxMode, MaxAge: -1, }) } func isSecure(r *http.Request) bool { if r.TLS != nil { return true } return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") }