package auth import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "net/http" "os" "strings" "time" ) const ( AuthCookieName = "multica_auth" CSRFCookieName = "multica_csrf" authCookieMaxAge = 30 * 24 * 60 * 60 // 30 days in seconds ) func cookieDomain() string { return strings.TrimSpace(os.Getenv("COOKIE_DOMAIN")) } func isSecureCookie() bool { env := os.Getenv("APP_ENV") return env == "production" || env == "staging" } // generateCSRFToken creates a CSRF token bound to the auth token via HMAC. // Format: hex(nonce) + "." + hex(HMAC-SHA256(nonce, authToken)). // This ensures an attacker who can write cookies on a subdomain cannot forge // a valid CSRF token without knowing the auth token. func generateCSRFToken(authToken string) (string, error) { nonce := make([]byte, 16) if _, err := rand.Read(nonce); err != nil { return "", err } nonceHex := hex.EncodeToString(nonce) mac := hmac.New(sha256.New, []byte(authToken)) mac.Write(nonce) sig := hex.EncodeToString(mac.Sum(nil)) return nonceHex + "." + sig, nil } // SetAuthCookies sets the HttpOnly auth cookie and the readable CSRF cookie on the response. func SetAuthCookies(w http.ResponseWriter, token string) error { secure := isSecureCookie() domain := cookieDomain() http.SetCookie(w, &http.Cookie{ Name: AuthCookieName, Value: token, Path: "/", Domain: domain, MaxAge: authCookieMaxAge, Expires: time.Now().Add(30 * 24 * time.Hour), HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, }) csrfToken, err := generateCSRFToken(token) if err != nil { return err } http.SetCookie(w, &http.Cookie{ Name: CSRFCookieName, Value: csrfToken, Path: "/", Domain: domain, MaxAge: authCookieMaxAge, Expires: time.Now().Add(30 * 24 * time.Hour), HttpOnly: false, Secure: secure, SameSite: http.SameSiteStrictMode, }) return nil } // ClearAuthCookies removes the auth and CSRF cookies. func ClearAuthCookies(w http.ResponseWriter) { domain := cookieDomain() secure := isSecureCookie() http.SetCookie(w, &http.Cookie{ Name: AuthCookieName, Value: "", Path: "/", Domain: domain, MaxAge: -1, Expires: time.Unix(0, 0), HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, }) http.SetCookie(w, &http.Cookie{ Name: CSRFCookieName, Value: "", Path: "/", Domain: domain, MaxAge: -1, Expires: time.Unix(0, 0), HttpOnly: false, Secure: secure, SameSite: http.SameSiteStrictMode, }) } // ValidateCSRF checks the X-CSRF-Token header against the auth cookie. // The CSRF token is HMAC-signed with the auth token, so the server verifies // the signature rather than simply comparing cookie == header. // Returns true if validation passes (including for safe methods that don't need CSRF). func ValidateCSRF(r *http.Request) bool { switch r.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: return true } csrfHeader := r.Header.Get("X-CSRF-Token") if csrfHeader == "" { return false } authCookie, err := r.Cookie(AuthCookieName) if err != nil || authCookie.Value == "" { return false } parts := strings.SplitN(csrfHeader, ".", 2) if len(parts) != 2 { return false } nonce, err := hex.DecodeString(parts[0]) if err != nil { return false } expectedSig, err := hex.DecodeString(parts[1]) if err != nil { return false } mac := hmac.New(sha256.New, []byte(authCookie.Value)) mac.Write(nonce) return hmac.Equal(mac.Sum(nil), expectedSig) }