Hyundai: use refresh token instead of password (#24491)

Signed-off-by: Stefan Rubner <stefan@whocares.de>
This commit is contained in:
stefan 2025-10-17 08:59:17 +02:00 • committed by GitHub
parent cb67dbfb77
commit f8cc09afcb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 21 additions and 347 deletions

View file

@ -5,8 +5,14 @@ products:
generic: Bluelink
requirements:
description:
en: Some models (e.g. Kona) switch internally to 2 phases at low charging currents (< 8A). In cases where the wallbox also measures the phase currents, this leads to undesirable fluctuations in the charging power. The remedy here is to set the minimum charging current to 8A.
de: Manche Modelle (z.B. Kona) schalten bei geringen Ladeströmen (< 8A) intern auf 2 Phasen um. In den Fällen, in denen die Wallbox auch die Phasenströme misst, führt das zu unerwünschten Schwankungen der Ladeleistung. Abhilfe schafft hier, den Mindestladestrom auf 8A zu setzen.
en: |
Instead of your account's password, the password field needs to be filled with a `refresh_token` ([instructions](https://github.com/evcc-io/evcc/wiki/Hyundai-Kia:-Refresh%E2%80%90Token-ermitteln#english-version)).
Some models (e.g. Kona) switch internally to 2 phases at low charging currents (< 8A). In cases where the wallbox also measures the phase currents, this leads to undesirable fluctuations in the charging power. The remedy here is to set the minimum charging current to 8A.
de: |
Anstelle des Passworts muss in das Passwort-Feld ein `refresh_token` eingetragen werden ([Anleitung](https://github.com/evcc-io/evcc/wiki/Hyundai-Kia:-Refresh%E2%80%90Token-ermitteln)).
Manche Modelle (z.B. Kona) schalten bei geringen Ladeströmen (< 8A) intern auf 2 Phasen um. In den Fällen, in denen die Wallbox auch die Phasenströme misst, führt das zu unerwünschten Schwankungen der Ladeleistung. Abhilfe schafft hier, den Mindestladestrom auf 8A zu setzen.
params:
- preset: vehicle-base
- preset: vehicle-language

View file

@ -6,11 +6,11 @@ products:
requirements:
description:
en: |
Instead of your account's password, the password field needs to be filled with a `refresh_token` ([instructions](https://github.com/evcc-io/evcc/wiki/Kia:-Refresh%E2%80%90Token-ermitteln#english-version)).
Instead of your account's password, the password field needs to be filled with a `refresh_token` ([instructions](https://github.com/evcc-io/evcc/wiki/Hyundai-Kia:-Refresh%E2%80%90Token-ermitteln#english-version)).
Some models (e.g. Niro EV) switch internally to 2 phases at low charging currents (< 8A). In cases where the wallbox also measures the phase currents, this leads to undesirable fluctuations in the charging power. The remedy here is to set the minimum charging current to 8A.
de: |
Anstelle des Passworts muss in das Passwort-Feld ein `refresh_token` eingetragen werden ([Anleitung](https://github.com/evcc-io/evcc/wiki/Kia:-Refresh%E2%80%90Token-ermitteln)).
Anstelle des Passworts muss in das Passwort-Feld ein `refresh_token` eingetragen werden ([Anleitung](https://github.com/evcc-io/evcc/wiki/Hyundai-Kia:-Refresh%E2%80%90Token-ermitteln)).
Manche Modelle (z.B. Niro EV) schalten bei geringen Ladeströmen (< 8A) intern auf 2 Phasen um. In den Fällen, in denen die Wallbox auch die Phasenströme misst, führt das zu unerwünschten Schwankungen der Ladeleistung. Abhilfe schafft hier, den Mindestladestrom auf 8A zu setzen.
params:

View file

@ -26,18 +26,15 @@ func init() {
func NewHyundaiFromConfig(other map[string]interface{}) (api.Vehicle, error) {
settings := bluelink.Config{
URI: "https://prd.eu-ccapi.hyundai.com:8080",
BasicToken: "NmQ0NzdjMzgtM2NhNC00Y2YzLTk1NTctMmExOTI5YTk0NjU0OktVeTQ5WHhQekxwTHVvSzB4aEJDNzdXNlZYaG10UVI5aVFobUlGampvWTRJcHhzVg==",
BasicToken: "KUy49XxPzLpLuoK0xhBC77W6VXhmtQR9iQhmIFjjoY4IpxsV",
CCSPServiceID: "6d477c38-3ca4-4cf3-9557-2a1929a94654",
CCSPApplicationID: bluelink.HyundaiAppID,
AuthClientID: "64621b96-0f0d-11ec-82a8-0242ac130003",
BrandAuthUrl: "https://eu-account.hyundai.com/auth/realms/euhyundaiidm/protocol/openid-connect/auth?client_id=%s&scope=openid+profile+email+phone&response_type=code&hkid_session_reset=true&redirect_uri=%s/api/v1/user/integration/redirect/login&ui_locales=%s&state=%s:%s",
PushType: "GCM",
Cfb: "RFtoRq/vDXJmRndoZaZQyfOot7OrIqGVFj96iY2WL3yyH5Z/pUvlUhqmCxD2t+D65SQ=",
Brand: "hyundai",
// for oauth2??
// LoginFormHost: "https://idpconnect-eu.hyundai.com",
// AuthClientID: "6d477c38-3ca4-4cf3-9557-2a1929a94654",
// BrandAuthUrl: "%s/auth/api/v2/user/oauth2/authorize?response_type=code&client_id=%s&redirect_uri=%s/api/v1/user/oauth2/redirect&lang=%s&state=ccsp",
LoginFormHost: "https://idpconnect-eu.hyundai.com",
BrandAuthUrl: "%s/auth/api/v2/user/oauth2/authorize?response_type=code&client_id=%s&redirect_uri=%s/api/v1/user/oauth2/redirect&lang=%s&state=ccsp",
}
return newBluelinkFromConfig("hyundai", other, settings)

View file

@ -5,20 +5,17 @@ import (
"errors"
"fmt"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/oauth"
"github.com/evcc-io/evcc/util/request"
"github.com/google/uuid"
"github.com/samber/lo"
"golang.org/x/net/publicsuffix"
"golang.org/x/oauth2"
)
@ -106,289 +103,6 @@ func (v *Identity) getDeviceID() (string, error) {
return res.ResMsg.DeviceID, err
}
func (v *Identity) getCookies() (cookieClient *request.Helper, err error) {
cookieClient = request.NewHelper(v.log)
cookieClient.Client.Jar, _ = cookiejar.New(&cookiejar.Options{
PublicSuffixList: publicsuffix.List,
})
// TODO: check whether &lang= is necessary
uri := fmt.Sprintf(
"%s/api/v1/user/oauth2/authorize?response_type=code&state=test&client_id=%s&redirect_uri=%s/api/v1/user/oauth2/redirect",
v.config.URI,
v.config.CCSPServiceID,
v.config.URI,
)
resp, err := cookieClient.Get(uri)
if err == nil {
resp.Body.Close()
}
return cookieClient, err
}
func (v *Identity) setLanguage(cookieClient *request.Helper, language string) error {
data := map[string]interface{}{
"lang": language,
}
req, err := request.New(http.MethodPost, v.config.URI+LanguageURL, request.MarshalJSON(data), request.JSONEncoding)
if err == nil {
var resp *http.Response
if resp, err = cookieClient.Do(req); err == nil {
resp.Body.Close()
}
}
return err
}
func (v *Identity) brandLoginHyundaiEU(cookieClient *request.Helper, user, password string) (string, error) {
req, err := request.New(http.MethodGet, v.config.URI+IntegrationInfoURL, nil, request.JSONEncoding)
var info struct {
UserId string `json:"userId"`
ServiceId string `json:"serviceId"`
}
if err == nil {
err = cookieClient.DoJSON(req, &info)
}
var action string
var resp *http.Response
if err == nil {
uri := fmt.Sprintf(v.config.BrandAuthUrl, v.config.AuthClientID, v.config.URI, "en", info.ServiceId, info.UserId)
req, err = request.New(http.MethodGet, uri, nil)
if err == nil {
if resp, err = cookieClient.Do(req); err == nil {
defer resp.Body.Close()
var doc *goquery.Document
if doc, err = goquery.NewDocumentFromReader(resp.Body); err == nil {
err = errors.New("form not found")
if form := doc.Find("form"); form != nil && form.Length() == 1 {
var ok bool
if action, ok = form.Attr("action"); ok {
err = nil
}
}
}
}
}
}
if err == nil {
data := url.Values{
"username": {user},
"password": {password},
"credentialId": {""},
"rememberMe": {"on"},
}
req, err = request.New(http.MethodPost, action, strings.NewReader(data.Encode()), request.URLEncoding)
if err == nil {
cookieClient.CheckRedirect = request.DontFollow
if resp, err = cookieClient.Do(req); err == nil {
defer resp.Body.Close()
// need 302
if resp.StatusCode != http.StatusFound {
err = errors.New("missing redirect")
if doc, err2 := goquery.NewDocumentFromReader(resp.Body); err2 == nil {
if span := doc.Find("span[class=kc-feedback-text]"); span != nil && span.Length() == 1 {
err = errors.New(span.Text())
}
}
}
}
cookieClient.CheckRedirect = nil
}
}
if err == nil {
resp, err = cookieClient.Get(resp.Header.Get("Location"))
if err == nil {
defer resp.Body.Close()
}
}
var code string
if err == nil {
data := map[string]string{
"intUserId": "",
}
req, err = request.New(http.MethodPost, v.config.URI+SilentSigninURL, request.MarshalJSON(data), request.JSONEncoding)
if err == nil {
req.Header.Set("ccsp-service-id", v.config.CCSPServiceID)
cookieClient.CheckRedirect = request.DontFollow
var res struct {
RedirectUrl string `json:"redirectUrl"`
}
if err = cookieClient.DoJSON(req, &res); err == nil {
var uri *url.URL
if uri, err = url.Parse(res.RedirectUrl); err == nil {
if code = uri.Query().Get("code"); len(code) == 0 {
err = errors.New("code not found")
}
}
}
}
}
return code, err
}
/* Unused for now
func (v *Identity) brandLoginKiaEU(user, password string) (string, error) {
cookieClient := request.NewHelper(v.log)
cookieClient.Client.Jar, _ = cookiejar.New(&cookiejar.Options{
PublicSuffixList: publicsuffix.List,
})
headers := map[string]string{
"content-type": "application/x-www-form-urlencoded",
"User-Agent": "Mozilla/5.0 (Linux; Android 4.1.1; Galaxy Nexus Build/JRO03C) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Mobile Safari/535.19_CCS_APP_AOS",
// "User-Agent": "Mozilla/5.0 (Linux; Android 4.1.1; Galaxy Nexus Build/JRO03C) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Mobile Safari/535.19",
}
data := url.Values{
"client_id": {"peukiaidm-online-sales"},
"encryptedPassword": {"false"},
"password": {password},
"redirect_uri": {"https://www.kia.com/api/bin/oneid/login"},
"state": {"aHR0cHM6Ly93d3cua2lhLmNvbTo0NDMvZGUvP3ZlZD0yYWhVS0V3akI2ZFc3dDQtUEF4WFBSZkVESGNDQ0J4UVFnVTk2QkFnY0VBZyZfdG09MTc1NTg1NTY2ODE2Mg==_default"},
"username": {user},
"remember_me": {"false"},
}
req, _ := request.New(http.MethodPost, "https://idpconnect-eu.kia.com/auth/account/signin", strings.NewReader(data.Encode()), headers)
if _, err := cookieClient.Do(req); err != nil {
return "", err
}
v.deviceID, _ = v.getDeviceID()
// get the connector_session_key
uri := fmt.Sprintf(v.config.BrandAuthUrl, v.config.LoginFormHost, v.config.CCSPServiceID, v.config.URI, "en")
headers = map[string]string{
"ccsp-application-id": v.config.CCSPApplicationID,
"ccsp-device-id": v.deviceID,
"ccsp-service-id": v.config.CCSPServiceID,
"User-Agent": "Mozilla/5.0 (Linux; Android 4.1.1; Galaxy Nexus Build/JRO03C) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Mobile Safari/535.19",
}
req, _ = request.New(http.MethodGet, uri, nil, headers)
resp, err := cookieClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
// get redirect URL from request
nextUri := resp.Request.URL.Query().Get("next_uri")
if nextUri == "" {
return "", errors.New("empty redirect url on connector session key request")
}
// create a client that doesn't honor redirects so we receive the original response
// no idea how to do that with the internal request.New(...) function
sc := http.Client{
Jar: cookieClient.Client.Jar,
Transport: request.NewTripper(v.log, http.DefaultTransport),
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
req, err = request.New(http.MethodGet, nextUri, nil, headers)
if err != nil {
return "", err
}
resp, err = sc.Do(req)
if err != nil {
return "", err
}
location := resp.Header.Get("Location")
if location == "" {
return "", errors.New("missing location header")
}
locationUrl, err := url.Parse(location)
if err != nil {
return "", err
}
code := locationUrl.Query().Get("code")
if code == "" {
return "", errors.New("missing code")
}
return code, nil
}
*/
func (v *Identity) bluelinkLogin(cookieClient *request.Helper, user, password string) (string, error) {
data := map[string]interface{}{
"email": user,
"password": password,
}
req, err := request.New(http.MethodPost, v.config.URI+LoginURL, request.MarshalJSON(data), request.JSONEncoding)
if err != nil {
return "", err
}
var res struct {
RedirectURL string `json:"redirectUrl"`
ErrCode string `json:"errCode"`
ErrMsg string `json:"errMsg"`
}
var accCode string
if err = cookieClient.DoJSON(req, &res); err == nil {
if parsed, err := url.Parse(res.RedirectURL); err == nil {
accCode = parsed.Query().Get("code")
}
} else if res.ErrCode != "" {
err = fmt.Errorf("%w: %s (%s)", err, res.ErrMsg, res.ErrCode)
}
return accCode, err
}
func (v *Identity) exchangeCodeHyundaiEU(accCode string) (*oauth2.Token, error) {
headers := map[string]string{
"Authorization": "Basic " + v.config.BasicToken,
"Content-type": "application/x-www-form-urlencoded",
"User-Agent": "okhttp/3.10.0",
}
data := url.Values{
"grant_type": {"authorization_code"},
"redirect_uri": {v.config.URI + "/api/v1/user/oauth2/redirect"},
"code": {accCode},
}
var token oauth2.Token
req, _ := request.New(http.MethodPost, v.config.URI+TokenURL, strings.NewReader(data.Encode()), headers)
err := v.DoJSON(req, &token)
return util.TokenWithExpiry(&token), err
}
func (v *Identity) exchangeCodeKiaEURefreshToken(accCode string) (*oauth2.Token, error) {
uri := v.config.LoginFormHost + "/auth/api/v2/user/oauth2/token"
headers := map[string]string{
@ -413,30 +127,6 @@ func (v *Identity) exchangeCodeKiaEURefreshToken(accCode string) (*oauth2.Token,
return util.TokenWithExpiry(&token), err
}
/* Unused for now
func (v *Identity) exchangeCodeKiaEU(accCode string) (*oauth2.Token, error) {
uri := v.config.LoginFormHost + "/auth/api/v2/user/oauth2/token"
headers := map[string]string{
"Content-type": "application/x-www-form-urlencoded",
"User-Agent": "okhttp/3.10.0",
}
data := url.Values{
"grant_type": {"authorization_code"},
"code": {accCode},
"redirect_uri": {v.config.URI + "/api/v1/user/oauth2/redirect"},
"client_id": {v.config.CCSPServiceID},
"client_secret": {"secret"},
}
var token oauth2.Token
req, _ := request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), headers)
err := v.DoJSON(req, &token)
return util.TokenWithExpiry(&token), err
}
*/
// RefreshToken implements oauth.TokenRefresher
func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
var res oauth2.Token
@ -450,8 +140,7 @@ func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
headers = map[string]string{
"Authorization": "Basic " + v.config.BasicToken,
"Content-type": "application/x-www-form-urlencoded",
// "User-Agent": "Mozilla/5.0 (Linux; Android 4.1.1; Galaxy Nexus Build/JRO03C) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Mobile Safari/535.19_CCS_APP_AOS",
"User-Agent": "okhttp/3.10.0",
"User-Agent": "okhttp/3.10.0",
}
data = url.Values{
@ -473,7 +162,7 @@ func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
"client_secret": {"secret"},
}
default:
err = errors.New("Unsupported brand")
err = errors.New("unsupported brand")
}
// request token only if we didn't run unto the default branch
@ -499,43 +188,25 @@ func (v *Identity) Login(user, password, language, brand string) (err error) {
if user == "" || password == "" {
return api.ErrMissingCredentials
}
var code string
// var code string
switch brand {
case "kia":
// the "password" now is the refresh token ...
// code, err = v.brandLoginKiaEU(user, password)
// if err == nil {
var token *oauth2.Token
token, err = v.exchangeCodeKiaEURefreshToken(password)
if err == nil {
v.TokenSource = oauth.RefreshTokenSource(token, v)
v.deviceID, err = v.getDeviceID()
}
// }
case "hyundai":
v.deviceID, err = v.getDeviceID()
var cookieClient *request.Helper
var token *oauth2.Token
token, err = v.exchangeCodeKiaEURefreshToken(password)
if err == nil {
cookieClient, err = v.getCookies()
v.TokenSource = oauth.RefreshTokenSource(token, v)
v.deviceID, err = v.getDeviceID()
}
if err == nil {
err = v.setLanguage(cookieClient, language)
}
if err == nil {
// try new login first, then fallback
if code, err = v.brandLoginHyundaiEU(cookieClient, user, password); err != nil {
code, err = v.bluelinkLogin(cookieClient, user, password)
}
if err == nil {
var token *oauth2.Token
if token, err = v.exchangeCodeHyundaiEU(code); err == nil {
v.TokenSource = oauth.RefreshTokenSource(token, v)
}
}
}
default:
err = fmt.Errorf("unknown brand (%s)", brand)
}