Ebus alternative for Vaillant (integrate the myVaillant binding like for HA myPyllant)

Hello,
I’d like to develop myVaillant connector. There is already a python library wrapping perfectly the myVaillant services. I have some problems just on the login, I put a test code here (it does do exactly the same of myPyllant project )

import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.commons.lang3.RandomStringUtils;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.impl.client.BasicCookieStore;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.LaxRedirectStrategy;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;

public class MyVaillantMain {

    private final static String CLIENT_ID = "myvaillant";
    private final static String AUTHENTICATE_URL = "https://identity.vaillant-group.com/auth/realms/vaillant-italy-b2c/protocol/openid-connect/auth";
    private final static String LOGIN_URL = "https://identity.vaillant-group.com/auth/realms/vaillant-italy-b2c/login-actions/authenticate";

    public static void main(String[] args) throws Exception {
        String generatedCode[] = generateCode();

        BasicCookieStore cookieStore = new BasicCookieStore();
        CloseableHttpClient httpclient = HttpClientBuilder.create()
                .setRedirectStrategy(new LaxRedirectStrategy())
                .setDefaultCookieStore(cookieStore)
                .disableRedirectHandling()
                .build();

        URI uri = new URIBuilder(AUTHENTICATE_URL).addParameter("client_id", CLIENT_ID)
                .addParameter("redirect_uri", "enduservaillant.page.link://login").addParameter("response_type", "code")
                .addParameter("code", "code_challenge").addParameter("code_challenge_method", "S256")
                .addParameter("code_challenge", generatedCode[1]).build();
        HttpGet httpGet = new HttpGet(uri.toString());
        try {
            CloseableHttpResponse response = (CloseableHttpResponse) httpclient.execute(httpGet);
            String login_html = EntityUtils.toString(response.getEntity());
            // String login_html = new BasicResponseHandler().handleResponse(response);
            response.close();
            Pattern pattern = Pattern.compile(LOGIN_URL + "\\?([^\\\"]*)");
            Matcher matcher = pattern.matcher(login_html);
            if (matcher.find()) {
                String username = "<your-username>";
                String password = "<your-password>";
                String loginUrl = matcher.group().replace("&amp;", "&");

                HttpPost authPost = new HttpPost(loginUrl);
                List<NameValuePair> params = new ArrayList<NameValuePair>();
                params.add(new BasicNameValuePair("username", username));
                params.add(new BasicNameValuePair("password", password));
                params.add(new BasicNameValuePair("credentialId", ""));
                authPost.setEntity(new UrlEncodedFormEntity(params, "UTF-8"));

                response = httpclient.execute(authPost);
                response.getAllHeaders();
                System.out.println(EntityUtils.toString(response.getEntity()));
                System.out.println(response.getStatusLine().getStatusCode());
                response.close();
            }
        } finally {

            httpclient.close();

        }

    }

    private static String[] generateCode() throws NoSuchAlgorithmException {
        String code_verifier = shuffle(RandomStringUtils.randomAlphabetic(64) + RandomStringUtils.randomNumeric(64));
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest(code_verifier.getBytes(StandardCharsets.UTF_8));

        String b64 = Base64.getUrlEncoder().encodeToString(hash);
        String code_challenge = b64.replace("=", "");

        return new String[] { code_verifier, code_challenge };
    }

    public static String shuffle(String input) {
        List<Character> characters = new ArrayList<Character>();
        for (char c : input.toCharArray()) {
            characters.add(c);
        }
        StringBuilder output = new StringBuilder(input.length());
        while (characters.size() != 0) {
            int randPicker = (int) (Math.random() * characters.size());
            output.append(characters.remove(randPicker));
        }
        return output.toString();
    }

}

I get “Your request was automatically detected as a potential threat.” In Python I don’t have any problem, can anybody help me to know what I’m missing so I can try to develop the binding?
Thanks in advance

You might need to ask on the myPylian project (look through the issues, they may have see the same). That error looks like it’s coming from the Vailant API and it’s rejecting the connection because it’s suspicious.

They dont have the issue. It seems something is missing in the cookie but from wireshark they look like the same

I doubt if cookie is an issue, it is browser thingy used only for SSO cases (after first login). What you do mimic is ouath flow (apparently against Keycloak which is OpenID Connect compatible solution).
There is number of things which might go wrong with oauth setup. Are you sure that all parameters (client-id, redirect uri rec) do match?
What message do you get from Vaillant authentication service?

In the second response I get an html

Your request was automatically detected as a potential threat.

with the same python request using aiohttp the request is going good:

async def login(self):
        """
        This should really be done in the browser with OIDC, but that's not easy without support from Vaillant

        So instead, we grab the login endpoint from the HTML form of the login website and send username + password
        to obtain a session
        """

        code_verifier, code_challenge = generate_code()
        auth_querystring = {
            "response_type": "code",
            "client_id": CLIENT_ID,
            "code": "code_challenge",
            "redirect_uri": "enduservaillant.page.link://login",
            "code_challenge_method": "S256",
            "code_challenge": code_challenge,
        }

        # Grabbing the login URL from the HTML form of the login page
        try:
            async with self.aiohttp_session.get(
                AUTHENTICATE_URL.format(realm=get_realm(self.brand, self.country))
                + "?"
                + urlencode(auth_querystring)
            ) as resp:
                login_html = await resp.text()
        except ClientResponseError as e:
            raise LoginEndpointInvalid from e

        result = re.search(
            LOGIN_URL.format(realm=get_realm(self.brand, self.country)) + r"\?([^\"]*)",
            login_html,
        )
        login_url = unescape(result.group())

        logger.debug(f"Got login url {login_url}")

        login_payload = {
            "username": self.username,
            "password": self.password,
            "credentialId": "",
        }
        # Obtaining the code
        async with self.aiohttp_session.post(
            login_url, data=login_payload, allow_redirects=False
        ) as resp:
            logger.debug(f"Got login response headers {resp.headers}")
            if "Location" not in resp.headers:
                raise AuthenticationFailed("Login failed")
            logger.debug(
                f'Got location from authorize endpoint: {resp.headers["Location"]}'
            )
            parsed_url = urlparse(resp.headers["Location"])
            code = parse_qs(parsed_url.query)["code"]

Second request to authorization server in code flow is exchange of auth code to a token thus you may need more parameters than just user/password. From what I remember this step in oauth is secured with client credentials (which are optional for public clients such as mobile app).

Login flow is different with Keycloack on Vaillant in this case. The Python client sends the request only passing that params to the form he got from the first request (I get the same html too) but the link given accept username and password (It’s a standard form) on python all is working good only with that params:

        login_payload = {
            "username": self.username,
            "password": self.password,
            "credentialId": "",
        }
        # Obtaining the code
        async with self.aiohttp_session.post(
            login_url, data=login_payload, allow_redirects=False
        ) as resp:
            logger.debug(f"Got login response headers {resp.headers}")
            if "Location" not in resp.headers:
                raise AuthenticationFailed("Login failed")
            logger.debug(
                f'Got location from authorize endpoint: {resp.headers["Location"]}'
            )
            parsed_url = urlparse(resp.headers["Location"])
            code = parse_qs(parsed_url.query)["code"]

I think there is something wrong with the cookie managment, may be I have to try with the OkHttp3 client

You’re right, looking closer at the code I see that auth code is exchanged in third request, not in second. This means that HTML form you receive is a customization of Keycloak login flow which requires proper maintenance of already started Keycloak session. In order to do so you need to look carefully at cookies named AUTH_SESSION_ID or similar. Somewhere there should be also a “tab_id” parameter which is essential on Keycloak end to properly reclaim session. I have not looked into Keycloak cookie management since a while, but from what I remember it was able to ship new cookies between first screen (login form) and second one (consent screen). Have a look on behavior of http client.

Although error message you get does not come from Keycloak it might be that some part of low level Keycloak infrastructure is unhappy with your request.