Kia/Hyundai EU: add OneApp/CCI login to fix new-login WAF block (#32868)
Some checks failed
Release / Validate Tag (push) Has been cancelled
Release / call-build-workflow (push) Has been cancelled
Release / Publish Docker :release (push) Has been cancelled
Release / Github & APT (push) Has been cancelled
Release / Demo (push) Has been cancelled
Release / Hassio Addon (push) Has been cancelled
Release / Delete Release Branch (push) Has been cancelled

This commit is contained in:
TMA84 2026-08-16 10:09:14 +02:00 • committed by GitHub
parent 0bbf4dc5c7
commit bda5ad71db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1111 additions and 11 deletions

View file

@ -6,12 +6,12 @@ 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/Hyundai-Kia:-Refresh-Token)).
The password field accepts either a `refresh_token` ([instructions](https://github.com/evcc-io/evcc/wiki/Hyundai-Kia:-Refresh-Token)) or, for the Europe region, your account's password.
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-Token)).
In das Passwort-Feld kann entweder ein `refresh_token` ([Anleitung](https://github.com/evcc-io/evcc/wiki/Hyundai-Kia:-Refresh-Token)) oder, für die Region Europa, das Passwort deines Kontos eingetragen werden.
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

View file

@ -6,12 +6,12 @@ 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/Hyundai-Kia:-Refresh-Token)).
The password field accepts either your account's password or a `refresh_token` ([instructions](https://github.com/evcc-io/evcc/wiki/Hyundai-Kia:-Refresh-Token)).
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/Hyundai-Kia:-Refresh-Token)).
In das Passwort-Feld kann entweder das Passwort deines Kontos oder ein `refresh_token` ([Anleitung](https://github.com/evcc-io/evcc/wiki/Hyundai-Kia:-Refresh-Token)) eingetragen werden.
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:
- preset: vehicle-base

View file

@ -50,6 +50,17 @@ func NewHyundaiFromConfig(other map[string]any) (api.Vehicle, error) {
PushType: "GCM",
LoginFormHost: "https://idpconnect-eu.hyundai.com",
Brand: "hyundai",
// OneApp/CCI login (bypasses the IDPConnect WAF block on the legacy
// authorize endpoint, see vehicle/bluelink/cci.go)
CCI: &bluelink.CCIConfig{
OneAppClientID: "4f4953b5-02e1-4dbc-8599-87e983ee1be5",
OneAppRedirectURI: "https://oneapp.hyundai.com/redirect",
APIURL: "https://cci-api-eu.hyundai.com",
PackageID: "com.hyundai.oneapp.eu",
ClientName: "hyundai",
OSVersion: "18.7",
NotificationProvider: "APNS",
},
}
case "australia", "new zealand":
settings = bluelink.Config{
@ -84,6 +95,17 @@ func NewKiaFromConfig(other map[string]any) (api.Vehicle, error) {
LoginFormHost: "https://idpconnect-eu.kia.com",
PushType: "APNS",
Brand: "kia",
// OneApp/CCI login (bypasses the IDPConnect WAF block on the legacy
// authorize endpoint, see vehicle/bluelink/cci.go)
CCI: &bluelink.CCIConfig{
OneAppClientID: "01b36c86-79e8-486c-8009-15f2ad88d670",
OneAppRedirectURI: "https://oneapp.kia.com/redirect",
APIURL: "https://cci-api-eu.kia.com",
PackageID: "com.kia.oneapp.eu",
ClientName: "kia",
OSVersion: "27",
NotificationProvider: "IOS_APPSTORE",
},
}
return newBluelinkFromConfig("kia", other, settings)

440
vehicle/bluelink/cci.go Normal file
View file

@ -0,0 +1,440 @@
package bluelink
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"time"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util/request"
"github.com/google/uuid"
"golang.org/x/oauth2"
)
// CCIConfig holds the OneApp/CCI login parameters. Only set for EU Kia/Hyundai,
// where the legacy IDPConnect authorize endpoint is WAF-blocked.
type CCIConfig struct {
OneAppClientID string // OneApp OAuth2 client_id (not on the WAF block list)
OneAppRedirectURI string
APIURL string // e.g. https://cci-api-eu.kia.com
PackageID string // "client-id" header value (mobile app bundle id)
ClientName string // "client-name" header value
OSVersion string // "client-os-version" header value
NotificationProvider string // "client-notification-provider-type" header value
}
const (
cciClientVersion = "1.3.3"
cciMobileUserAgent = "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"
cciSettingsKeyFmt = "bluelink-cci.%s.%s"
)
// cciBundle is the CCI/CCS token state. AccessToken is the CCS token used on
// the legacy ccapi endpoints, the remaining fields are required to refresh it.
type cciBundle struct {
AccessToken string `json:"access_token"` // CCS token (no "Bearer " prefix)
RefreshToken string `json:"refresh_token"` // CCI refresh token
Expiry time.Time `json:"expiry"` // CCS token expiry
DeviceID string `json:"device_id"` // client-device-id used for all CCI calls
CCIAccessToken string `json:"cci_access_token"`
ExchangeableToken string `json:"exchangeable_token"`
ExchangeableRefreshToken string `json:"exchangeable_refresh_token"`
NonCcsToken string `json:"non_ccs_token"`
NonCcsRefreshToken string `json:"non_ccs_refresh_token"`
IDToken string `json:"id_token"`
}
func (b cciBundle) token() *oauth2.Token {
return &oauth2.Token{
AccessToken: b.AccessToken,
RefreshToken: b.RefreshToken,
Expiry: b.Expiry,
}
}
// settingsKey returns the settings key the CCI token bundle is persisted under
func (v *Identity) settingsKey() string {
return fmt.Sprintf(cciSettingsKeyFmt, v.config.Brand, v.user)
}
// loginCCI obtains a CCS access token via the OneApp/CCI flow, preferring a
// bundle persisted from an earlier login over a fresh password login
func (v *Identity) loginCCI(password string) (*oauth2.Token, error) {
if err := settings.Json(v.settingsKey(), &v.bundle); err == nil {
if token := v.bundle.token(); token.Valid() {
v.log.DEBUG.Println("cci: using persisted token")
return token, nil
}
if v.bundle.RefreshToken != "" {
token, err := v.refreshCCI(nil)
if err == nil {
return token, nil
}
v.log.WARN.Printf("cci: refreshing persisted token failed: %v", err)
}
}
return v.loginCCIPassword(password)
}
// loginCCIPassword performs the headless OneApp/CCI password login: authorize,
// fetch RSA cert, signin, exchange the auth code for CCI and then CCS tokens
func (v *Identity) loginCCIPassword(password string) (*oauth2.Token, error) {
c := v.config.CCI
deviceID := uuid.NewString()
v.log.DEBUG.Println("cci: logging in via OneApp/CCI password login")
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
v.Client.Jar = jar
defer func() {
v.Client.Jar = nil
v.Client.CheckRedirect = nil
}()
// the OneApp client_id is not on the WAF block list
authURL := fmt.Sprintf(
"%s/auth/api/v2/user/oauth2/authorize?response_type=code&client_id=%s&redirect_uri=%s&lang=en&state=ccsp&country=de",
v.config.LoginFormHost, c.OneAppClientID, c.OneAppRedirectURI,
)
authReq, err := request.New(http.MethodGet, authURL, nil, map[string]string{
"User-Agent": cciMobileUserAgent,
})
if err != nil {
return nil, err
}
authResp, err := v.Client.Do(authReq)
if err != nil {
return nil, err
}
authBody, _ := io.ReadAll(authResp.Body)
authResp.Body.Close()
if strings.Contains(strings.ToLower(string(authBody)), "abusing") ||
strings.Contains(authResp.Request.URL.String(), "/error?status=400") {
return nil, errors.New("authorize rejected as 'abusing request' — server-side WAF block, not a credentials problem")
}
if authResp.StatusCode >= http.StatusBadRequest {
return nil, fmt.Errorf("authorize failed: HTTP %d (%s)", authResp.StatusCode, request.Truncate(string(authBody)))
}
// rsa public key used to encrypt the password for signin
certReq, err := request.New(http.MethodGet, v.config.LoginFormHost+"/auth/api/v1/accounts/certs", nil, map[string]string{
"User-Agent": cciMobileUserAgent,
"Accept": request.JSONContent,
})
if err != nil {
return nil, err
}
var certRes struct {
RetValue struct {
Kid string
N string
E string
}
}
if err := v.DoJSON(certReq, &certRes); err != nil {
return nil, fmt.Errorf("fetching rsa certs failed: %w", err)
}
encryptedPassword, err := encryptCCIPassword(certRes.RetValue.N, certRes.RetValue.E, password)
if err != nil {
return nil, fmt.Errorf("encrypting password failed: %w", err)
}
data := url.Values{
"client_id": {c.OneAppClientID},
"encryptedPassword": {"true"},
"password": {encryptedPassword},
"redirect_uri": {c.OneAppRedirectURI},
"scope": {""},
"nonce": {""},
"state": {"ccsp"},
"username": {v.user},
"connector_session_key": {""},
"kid": {certRes.RetValue.Kid},
"_csrf": {""},
}
signinReq, err := request.New(http.MethodPost, v.config.LoginFormHost+"/auth/account/signin", strings.NewReader(data.Encode()), map[string]string{
"Content-Type": request.FormContent,
"User-Agent": cciMobileUserAgent,
})
if err != nil {
return nil, err
}
// the auth code arrives in the Location header, so redirects must not be followed
v.Client.CheckRedirect = request.DontFollow
signinResp, err := v.Client.Do(signinReq)
v.Client.CheckRedirect = nil
if err != nil {
return nil, err
}
signinBody, _ := io.ReadAll(signinResp.Body)
signinResp.Body.Close()
if signinResp.StatusCode != http.StatusFound {
return nil, fmt.Errorf("signin failed: HTTP %d (%s)", signinResp.StatusCode, request.Truncate(string(signinBody)))
}
loc, err := signinResp.Location()
if err != nil {
return nil, fmt.Errorf("signin returned no valid redirect: %w", err)
}
code := loc.Query().Get("code")
if code == "" {
switch {
case strings.Contains(loc.Path, "/web/v1/user/authorization"):
return nil, errors.New("account consent required: log in via the manufacturer app once to accept the terms, then retry")
case loc.Query().Get("error_description") != "":
return nil, fmt.Errorf("signin rejected: %s", loc.Query().Get("error_description"))
default:
return nil, fmt.Errorf("unexpected redirect after signin: %s", request.Truncate(loc.String()))
}
}
bundle, err := v.exchangeCCIToken(deviceID, code)
if err != nil {
return nil, err
}
bundle.DeviceID = deviceID
bundle.AccessToken, bundle.Expiry, err = v.exchangeCCSToken(deviceID, bundle.CCIAccessToken, bundle.NonCcsToken, bundle.ExchangeableToken)
if err != nil {
return nil, err
}
v.persistBundle(bundle)
v.log.DEBUG.Println("cci: login successful")
return bundle.token(), nil
}
// refreshCCI refreshes the CCI token set and re-exchanges the CCS token. The
// token set lives on the Identity, so the passed oauth2 token is ignored.
func (v *Identity) refreshCCI(_ *oauth2.Token) (*oauth2.Token, error) {
v.log.DEBUG.Println("cci: refreshing token")
bundle := v.bundle
headers := v.cciHeaders(bundle.DeviceID, bundle.CCIAccessToken, bundle.NonCcsToken, bundle.ExchangeableToken, request.JSONContent)
body := map[string]string{
"accessToken": bundle.CCIAccessToken,
"refreshToken": bundle.RefreshToken,
"exchangeableAccessToken": bundle.ExchangeableToken,
"exchangeableRefreshToken": bundle.ExchangeableRefreshToken,
"nonCcsToken": bundle.NonCcsToken,
"nonCcsRefreshToken": bundle.NonCcsRefreshToken,
"idToken": bundle.IDToken,
}
uri := v.config.CCI.APIURL + "/domain/api/v2/auth/token-refresh"
req, err := request.New(http.MethodPost, uri, request.MarshalJSON(body), headers)
if err != nil {
return nil, err
}
var res cciTokenResponse
if err := v.DoJSON(req, &res); err != nil {
return nil, fmt.Errorf("cci token refresh failed: %w", err)
}
res.apply(&bundle)
bundle.AccessToken, bundle.Expiry, err = v.exchangeCCSToken(bundle.DeviceID, bundle.CCIAccessToken, bundle.NonCcsToken, bundle.ExchangeableToken)
if err != nil {
return nil, err
}
v.persistBundle(bundle)
v.log.DEBUG.Println("cci: refresh successful")
return bundle.token(), nil
}
func (v *Identity) persistBundle(bundle cciBundle) {
v.bundle = bundle
if err := settings.SetJson(v.settingsKey(), bundle); err != nil {
v.log.WARN.Printf("cci: persisting token failed: %v", err)
}
}
// cciTokenResponse is the response of the CCI token and token-refresh endpoints
type cciTokenResponse struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
NonCcsToken string `json:"nonCcsToken"`
ExchangeableAccessToken string `json:"exchangeableAccessToken"`
ExchangeableRefreshToken string `json:"exchangeableRefreshToken"`
NonCcsRefreshToken string `json:"nonCcsRefreshToken"`
IDToken string `json:"idToken"`
}
// apply copies the non-empty response fields into bundle, keeping unchanged ones
func (res cciTokenResponse) apply(bundle *cciBundle) {
for _, f := range []struct {
val string
dst *string
}{
{res.AccessToken, &bundle.CCIAccessToken},
{res.RefreshToken, &bundle.RefreshToken},
{res.NonCcsToken, &bundle.NonCcsToken},
{res.ExchangeableAccessToken, &bundle.ExchangeableToken},
{res.ExchangeableRefreshToken, &bundle.ExchangeableRefreshToken},
{res.NonCcsRefreshToken, &bundle.NonCcsRefreshToken},
{res.IDToken, &bundle.IDToken},
} {
if f.val != "" {
*f.dst = f.val
}
}
}
// exchangeCCIToken exchanges an authorization code for the CCI token set
func (v *Identity) exchangeCCIToken(deviceID, code string) (cciBundle, error) {
uri := v.config.CCI.APIURL + "/domain/api/v1/auth/token?code=" + url.QueryEscape(code)
req, err := request.New(http.MethodPost, uri, nil, v.cciHeaders(deviceID, "", "", "", ""))
if err != nil {
return cciBundle{}, err
}
var res cciTokenResponse
if err := v.DoJSON(req, &res); err != nil {
return cciBundle{}, fmt.Errorf("cci token exchange failed: %w", err)
}
var bundle cciBundle
res.apply(&bundle)
return bundle, nil
}
// exchangeCCSToken exchanges a CCI access token for a CCS token, which the
// legacy ccapi vehicle/control endpoints accept as Bearer access_token
func (v *Identity) exchangeCCSToken(deviceID, cciAccessToken, nonCcsToken, exchangeableToken string) (string, time.Time, error) {
uri := v.config.CCI.APIURL + "/domain/api/v1/auth/token-exchange?serviceType=CCS"
headers := v.cciHeaders(deviceID, cciAccessToken, nonCcsToken, exchangeableToken, "")
req, err := request.New(http.MethodPost, uri, nil, headers)
if err != nil {
return "", time.Time{}, err
}
var res struct {
AccessToken string `json:"accessToken"`
ExpiresTime int64 `json:"expiresTime"` // unix seconds
}
if err := v.DoJSON(req, &res); err != nil {
return "", time.Time{}, fmt.Errorf("ccs token exchange failed: %w", err)
}
if res.AccessToken == "" {
return "", time.Time{}, errors.New("ccs token exchange returned no access token")
}
return res.AccessToken, parseCCSExpiry(res.ExpiresTime), nil
}
const (
ccsExpiryFallback = time.Hour // used when expiresTime is missing or implausible
ccsExpiryMaxValidity = 24 * time.Hour // upper bound of a plausible expiry
)
// parseCCSExpiry interprets the token-exchange expiresTime, falling back to a
// fixed lifetime rather than risking an always-expired token
func parseCCSExpiry(expiresTime int64) time.Time {
now := time.Now()
if t := time.Unix(expiresTime, 0); t.After(now) && t.Before(now.Add(ccsExpiryMaxValidity)) {
return t
}
return now.Add(ccsExpiryFallback)
}
// cciHeaders builds the headers required by the CCI API. The token parameters
// are empty before the initial code exchange, contentType for bodyless requests
func (v *Identity) cciHeaders(deviceID, cciAccessToken, nonCcsToken, exchangeableToken, contentType string) map[string]string {
c := v.config.CCI
headers := map[string]string{
"client-id": c.PackageID,
"client-name": c.ClientName,
"client-version": cciClientVersion,
"client-os-code": "ios",
"client-os-version": c.OSVersion,
"client-device-id": deviceID,
"client-device-model": "iPhone",
"client-notification-provider-type": c.NotificationProvider,
"locale": strings.ToUpper(v.language),
"timezone": time.Now().Format("-07:00"),
"Accept": request.JSONContent,
"Accept-Language": v.language,
"User-Agent": cciMobileUserAgent,
}
if nonCcsToken != "" {
headers["Authentication"] = nonCcsToken
}
if cciAccessToken != "" {
headers["authorization"] = "Bearer " + strings.TrimPrefix(strings.TrimSpace(cciAccessToken), "Bearer ")
}
if exchangeableToken != "" {
headers["exchangeable-token"] = exchangeableToken
headers["non-ccs-token"] = nonCcsToken
}
if contentType != "" {
headers["Content-Type"] = contentType
}
return headers
}
// encryptCCIPassword RSA/PKCS1v15-encrypts password using the JWK public key
// returned by the /accounts/certs endpoint and hex-encodes the ciphertext
func encryptCCIPassword(nb64, eb64, password string) (string, error) {
nBytes, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(nb64, "="))
if err != nil {
return "", fmt.Errorf("invalid rsa modulus: %w", err)
}
eBytes, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(eb64, "="))
if err != nil {
return "", fmt.Errorf("invalid rsa exponent: %w", err)
}
e := 0
for _, b := range eBytes {
e = e<<8 | int(b)
}
pub := &rsa.PublicKey{
N: new(big.Int).SetBytes(nBytes),
E: e,
}
ciphertext, err := rsa.EncryptPKCS1v15(rand.Reader, pub, []byte(password))
if err != nil {
return "", err
}
return hex.EncodeToString(ciphertext), nil
}

View file

@ -0,0 +1,377 @@
package bluelink
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// unreachable is a host nobody listens on, used to prove that a code path
// does not perform any HTTP call (fast-failing rather than a slow DNS lookup).
const unreachable = "http://127.0.0.1:1"
// newTestIdentity creates an Identity for the given test, using the test name
// as the settings key discriminator (identity.user) so that parallel/repeated
// test runs never share a server/db/settings entry with one another.
func newTestIdentity(t *testing.T, loginURL, cciURL string) *Identity {
t.Helper()
config := Config{
// Brand carries the test name so each (sub)test gets its own
// server/db/settings key (see settingsKey), without changing the
// username actually sent in login requests.
Brand: "kia-test:" + t.Name(),
LoginFormHost: loginURL,
CCI: &CCIConfig{
OneAppClientID: "test-client-id",
OneAppRedirectURI: "https://oneapp.kia.com/redirect",
APIURL: cciURL,
PackageID: "com.kia.oneapp.eu",
ClientName: "kia",
OSVersion: "27",
NotificationProvider: "IOS_APPSTORE",
},
}
identity := NewIdentity(util.NewLogger("test"), config)
identity.user = "test@example.com"
identity.language = "en"
// server/db/settings.Delete requires an initialised gorm DB, which these
// unit tests don't set up. SetString only ever touches the in-memory
// cache (see server/db/settings/setting.go), so blanking the key this way
// is enough to keep tests isolated without needing a real database.
t.Cleanup(func() { settings.SetString(identity.settingsKey(), "") })
return identity
}
func jwkParam(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
func okHandler(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("<html>login</html>"))
}
func certsHandler(priv *rsa.PrivateKey) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
n := jwkParam(priv.PublicKey.N.Bytes())
e := jwkParam(big.NewInt(int64(priv.PublicKey.E)).Bytes())
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"retValue":{"kid":"test-kid","n":%q,"e":%q}}`, n, e)
}
}
// TestParseCCSExpiry is a regression test for a bug found against the real Kia
// backend: an expiresTime read in the wrong unit makes every token look expired
func TestParseCCSExpiry(t *testing.T) {
now := time.Now()
tests := []struct {
name string
expiresTime int64
wantWithin time.Duration // expected to be within [now, now+wantWithin]
}{
{"zero falls back to default", 0, ccsExpiryFallback + time.Minute},
{"negative falls back to default", -1, ccsExpiryFallback + time.Minute},
{"second epoch", now.Add(time.Hour).Unix(), 2 * time.Hour},
{"millisecond epoch falls back to default", now.Add(time.Hour).UnixMilli(), ccsExpiryFallback + time.Minute},
{"implausible value falls back to default", 123, ccsExpiryFallback + time.Minute},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseCCSExpiry(tt.expiresTime)
assert.True(t, got.After(now), "expiry must be in the future, got %v", got)
assert.True(t, got.Before(now.Add(tt.wantWithin)), "expiry too far out, got %v", got)
})
}
}
func TestLoginCCIPasswordSuccess(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
const password = "s3cret-Passw0rd!"
loginMux := http.NewServeMux()
loginMux.HandleFunc("/auth/api/v2/user/oauth2/authorize", okHandler)
loginMux.HandleFunc("/auth/api/v1/accounts/certs", certsHandler(priv))
loginMux.HandleFunc("/auth/account/signin", func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, r.ParseForm())
ciphertext, err := hex.DecodeString(r.FormValue("password"))
require.NoError(t, err)
plain, err := rsa.DecryptPKCS1v15(rand.Reader, priv, ciphertext)
require.NoError(t, err)
assert.Equal(t, password, string(plain))
assert.Equal(t, "true", r.FormValue("encryptedPassword"))
assert.Equal(t, "test@example.com", r.FormValue("username"))
w.Header().Set("Location", "https://oneapp.kia.com/redirect?code=testcode&state=ccsp")
w.WriteHeader(http.StatusFound)
})
loginSrv := httptest.NewServer(loginMux)
defer loginSrv.Close()
cciMux := http.NewServeMux()
cciMux.HandleFunc("/domain/api/v1/auth/token", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "testcode", r.URL.Query().Get("code"))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"accessToken": "cci-access-1",
"refreshToken": "cci-refresh-1",
"nonCcsToken": "non-ccs-1",
"exchangeableAccessToken": "exch-access-1",
"exchangeableRefreshToken": "exch-refresh-1",
"nonCcsRefreshToken": "non-ccs-refresh-1",
"idToken": "id-1",
"expiresIn": 3599,
})
})
cciMux.HandleFunc("/domain/api/v1/auth/token-exchange", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "CCS", r.URL.Query().Get("serviceType"))
assert.Equal(t, "Bearer cci-access-1", r.Header.Get("authorization"))
assert.Equal(t, "non-ccs-1", r.Header.Get("Authentication"))
assert.Equal(t, "exch-access-1", r.Header.Get("exchangeable-token"))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"accessToken": "ccs-token-1",
"expiresTime": time.Now().Add(time.Hour).Unix(),
})
})
cciSrv := httptest.NewServer(cciMux)
defer cciSrv.Close()
identity := newTestIdentity(t, loginSrv.URL, cciSrv.URL)
token, err := identity.loginCCIPassword(password)
require.NoError(t, err)
assert.Equal(t, "ccs-token-1", token.AccessToken)
assert.Equal(t, "cci-refresh-1", token.RefreshToken)
assert.True(t, token.Valid())
assert.Equal(t, "cci-access-1", identity.bundle.CCIAccessToken)
assert.Equal(t, "exch-access-1", identity.bundle.ExchangeableToken)
assert.Equal(t, "non-ccs-1", identity.bundle.NonCcsToken)
assert.NotEmpty(t, identity.bundle.DeviceID)
// persisted so a restart can reuse/refresh it instead of logging in again
var persisted cciBundle
require.NoError(t, settings.Json(identity.settingsKey(), &persisted))
assert.Equal(t, "ccs-token-1", persisted.AccessToken)
assert.Equal(t, "cci-refresh-1", persisted.RefreshToken)
}
func TestLoginCCIPasswordWAFBlocked(t *testing.T) {
loginMux := http.NewServeMux()
loginMux.HandleFunc("/auth/api/v2/user/oauth2/authorize", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("Your request looks like abusing our system"))
})
loginSrv := httptest.NewServer(loginMux)
defer loginSrv.Close()
identity := newTestIdentity(t, loginSrv.URL, unreachable)
_, err := identity.loginCCIPassword("whatever")
require.Error(t, err)
assert.Contains(t, err.Error(), "WAF")
}
func TestLoginCCIPasswordSigninRejected(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
loginMux := http.NewServeMux()
loginMux.HandleFunc("/auth/api/v2/user/oauth2/authorize", okHandler)
loginMux.HandleFunc("/auth/api/v1/accounts/certs", certsHandler(priv))
loginMux.HandleFunc("/auth/account/signin", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("invalid credentials"))
})
loginSrv := httptest.NewServer(loginMux)
defer loginSrv.Close()
identity := newTestIdentity(t, loginSrv.URL, unreachable)
_, err = identity.loginCCIPassword("wrong-password")
require.Error(t, err)
assert.Contains(t, err.Error(), "signin failed")
}
func TestLoginCCIPasswordConsentRequired(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
loginMux := http.NewServeMux()
loginMux.HandleFunc("/auth/api/v2/user/oauth2/authorize", okHandler)
loginMux.HandleFunc("/auth/api/v1/accounts/certs", certsHandler(priv))
loginMux.HandleFunc("/auth/account/signin", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Location", "https://idpconnect-eu.kia.com/web/v1/user/authorization?foo=bar")
w.WriteHeader(http.StatusFound)
})
loginSrv := httptest.NewServer(loginMux)
defer loginSrv.Close()
identity := newTestIdentity(t, loginSrv.URL, unreachable)
_, err = identity.loginCCIPassword("whatever")
require.Error(t, err)
assert.Contains(t, err.Error(), "consent")
}
func TestRefreshCCI(t *testing.T) {
cciMux := http.NewServeMux()
cciMux.HandleFunc("/domain/api/v2/auth/token-refresh", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
var body map[string]string
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
assert.Equal(t, "old-cci-refresh", body["refreshToken"])
_ = json.NewEncoder(w).Encode(map[string]any{
"accessToken": "cci-access-2",
"refreshToken": "cci-refresh-2",
"nonCcsToken": "non-ccs-2",
"exchangeableAccessToken": "exch-access-2",
"exchangeableRefreshToken": "exch-refresh-2",
"nonCcsRefreshToken": "non-ccs-refresh-2",
"idToken": "id-2",
})
})
cciMux.HandleFunc("/domain/api/v1/auth/token-exchange", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Bearer cci-access-2", r.Header.Get("authorization"))
_ = json.NewEncoder(w).Encode(map[string]any{
"accessToken": "ccs-token-2",
"expiresTime": time.Now().Add(time.Hour).Unix(),
})
})
cciSrv := httptest.NewServer(cciMux)
defer cciSrv.Close()
identity := newTestIdentity(t, unreachable, cciSrv.URL)
old := cciBundle{
AccessToken: "ccs-token-1",
RefreshToken: "old-cci-refresh",
Expiry: time.Now().Add(-time.Hour), // expired
DeviceID: "device-abc",
CCIAccessToken: "old-cci-access",
ExchangeableToken: "old-exch",
ExchangeableRefreshToken: "old-exch-refresh",
NonCcsToken: "old-non-ccs",
NonCcsRefreshToken: "old-non-ccs-refresh",
IDToken: "old-id",
}
identity.bundle = old
newToken, err := identity.refreshCCI(nil)
require.NoError(t, err)
assert.Equal(t, "ccs-token-2", newToken.AccessToken)
assert.Equal(t, "cci-refresh-2", newToken.RefreshToken)
assert.True(t, newToken.Valid())
var persisted cciBundle
require.NoError(t, settings.Json(identity.settingsKey(), &persisted))
assert.Equal(t, "ccs-token-2", persisted.AccessToken)
assert.Equal(t, "device-abc", persisted.DeviceID) // device id carried over unchanged
}
func TestLoginCCIUsesPersistedBundleWithoutContactingServer(t *testing.T) {
identity := newTestIdentity(t, unreachable, unreachable)
valid := cciBundle{
AccessToken: "still-valid-ccs",
RefreshToken: "still-valid-refresh",
Expiry: time.Now().Add(time.Hour),
DeviceID: "device-xyz",
}
require.NoError(t, settings.SetJson(identity.settingsKey(), valid))
token, err := identity.loginCCI("irrelevant-password-value")
require.NoError(t, err)
assert.Equal(t, "still-valid-ccs", token.AccessToken)
}
// TestLoginCCIFallsBackToPasswordLoginWhenPersistedBundleUnusable covers a
// persisted bundle that is expired and unrefreshable: loginCCI must not get
// stuck on that stale state but fall back to the full password login
func TestLoginCCIFallsBackToPasswordLoginWhenPersistedBundleUnusable(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
const password = "s3cret-Passw0rd!"
loginMux := http.NewServeMux()
loginMux.HandleFunc("/auth/api/v2/user/oauth2/authorize", okHandler)
loginMux.HandleFunc("/auth/api/v1/accounts/certs", certsHandler(priv))
loginMux.HandleFunc("/auth/account/signin", func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, r.ParseForm())
ciphertext, err := hex.DecodeString(r.FormValue("password"))
require.NoError(t, err)
plain, err := rsa.DecryptPKCS1v15(rand.Reader, priv, ciphertext)
require.NoError(t, err)
assert.Equal(t, password, string(plain))
w.Header().Set("Location", "https://oneapp.kia.com/redirect?code=testcode&state=ccsp")
w.WriteHeader(http.StatusFound)
})
loginSrv := httptest.NewServer(loginMux)
defer loginSrv.Close()
cciMux := http.NewServeMux()
cciMux.HandleFunc("/domain/api/v1/auth/token", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"accessToken": "fresh-cci-access", "refreshToken": "fresh-cci-refresh",
"nonCcsToken": "fresh-non-ccs", "exchangeableAccessToken": "fresh-exch-access",
"exchangeableRefreshToken": "fresh-exch-refresh", "nonCcsRefreshToken": "fresh-non-ccs-refresh",
"idToken": "fresh-id", "expiresIn": 3599,
})
})
cciMux.HandleFunc("/domain/api/v1/auth/token-exchange", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"accessToken": "fresh-ccs-token",
"expiresTime": time.Now().Add(time.Hour).Unix(),
})
})
cciSrv := httptest.NewServer(cciMux)
defer cciSrv.Close()
identity := newTestIdentity(t, loginSrv.URL, cciSrv.URL)
// stale/corrupted settings state: expired and no refresh token
stale := cciBundle{
AccessToken: "stale-ccs-token",
Expiry: time.Now().Add(-time.Hour),
DeviceID: "stale-device-id",
}
require.NoError(t, settings.SetJson(identity.settingsKey(), stale))
token, err := identity.loginCCI(password)
require.NoError(t, err)
assert.Equal(t, "fresh-ccs-token", token.AccessToken)
assert.NotEqual(t, "stale-ccs-token", token.AccessToken)
var persisted cciBundle
require.NoError(t, settings.Json(identity.settingsKey(), &persisted))
assert.Equal(t, "fresh-ccs-token", persisted.AccessToken)
assert.NotEqual(t, "stale-device-id", persisted.DeviceID, "a fresh login must not carry over the stale bundle's device id")
}

View file

@ -44,6 +44,9 @@ type Config struct {
Brand string
TokenURL string
UseBasicAuth bool
// CCI is set for brands affected by the IDPConnect WAF block on the legacy
// authorize endpoint (EU Kia/Hyundai), nil for Genesis EU and Hyundai AU
CCI *CCIConfig
}
// Identity implements the Kia/Hyundai bluelink identity.
@ -53,6 +56,9 @@ type Identity struct {
log *util.Logger
config Config
deviceID string
user string
language string
bundle cciBundle
oauth2.TokenSource
}
@ -155,7 +161,7 @@ func (v *Identity) refreshToken(token *oauth2.Token) (*oauth2.Token, error) {
return util.TokenWithExpiry(&res), err
}
func (v *Identity) Login(user, password, language, brand string) (err error) {
func (v *Identity) Login(user, password, language, brand string) error {
if user == "" || password == "" {
return api.ErrMissingCredentials
}
@ -168,18 +174,35 @@ func (v *Identity) Login(user, password, language, brand string) (err error) {
return fmt.Errorf("unknown brand (%s)", brand)
}
v.user = user
v.language = language
refresher := v.refreshToken
token, err := v.refreshToken(&oauth2.Token{RefreshToken: password})
if err == nil && !token.Valid() {
err = errors.New("no access token")
}
// CCI-capable brands (EU Kia/Hyundai) additionally accept the account
// password, as generating a legacy refresh_token is WAF-blocked
if err != nil && v.config.CCI != nil {
refresher = v.refreshCCI
token, err = v.loginCCI(password)
}
if err != nil {
return fmt.Errorf("login failed: %w", err)
}
v.TokenSource = oauth.RefreshTokenSource(token, v.refreshToken)
v.TokenSource = oauth.RefreshTokenSource(token, refresher)
v.deviceID, err = v.getDeviceID()
if err != nil {
return fmt.Errorf("error getting device id: %w", err)
}
return err
return nil
}
// Request decorates requests with authorization headers

View file

@ -0,0 +1,238 @@
package bluelink
import (
"crypto/rand"
"crypto/rsa"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testCCSPApplicationID/testCfb are copied from the real production Kia EU
// config (vehicle/bluelink.go) so Identity.stamp() succeeds during Login's
// getDeviceID call - not secrets, both are already public there.
const (
testCCSPApplicationID = "a2b8469b-30a3-4361-8e13-6fceea8fbe74"
testCfb = "wLTVxwidmH8CfJYBWSnHD6E0huk0ozdiuygB4hLkM5XCgzAL1Dk5sE36d/bx5PFMbZs="
)
// deviceIDHandler serves Identity.getDeviceID's device registration endpoint,
// which Login always calls after either auth path succeeds.
func deviceIDHandler(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"RetCode":"S","ResMsg":{"DeviceID":"test-device-id"}}`)
}
// legacyTokenHandler serves the legacy refresh_token grant endpoint used by
// Identity.refreshToken.
func legacyTokenHandler(accessToken, refreshToken string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"access_token":%q,"refresh_token":%q,"expires_in":3600}`, accessToken, refreshToken)
}
}
// newLoginTestIdentity builds an Identity suitable for exercising the public
// Login() method end to end, including the legacy getDeviceID call it always
// makes. cci == nil mirrors a brand without CCI support (e.g. Genesis EU).
func newLoginTestIdentity(t *testing.T, loginURL, deviceURL, cciURL string, cci *CCIConfig) *Identity {
t.Helper()
config := Config{
Brand: "kia-login-test:" + t.Name(),
URI: deviceURL,
CCSPServiceID: "test-ccsp-service-id",
CCSPServiceSecret: "test-ccsp-secret",
CCSPApplicationID: testCCSPApplicationID,
Cfb: testCfb,
LoginFormHost: loginURL,
PushType: "APNS",
CCI: cci,
}
if cci != nil {
config.CCI.APIURL = cciURL
}
return NewIdentity(util.NewLogger("test"), config)
}
func testCCIConfig() *CCIConfig {
return &CCIConfig{
OneAppClientID: "test-client-id",
OneAppRedirectURI: "https://oneapp.kia.com/redirect",
PackageID: "com.kia.oneapp.eu",
ClientName: "kia",
OSVersion: "27",
NotificationProvider: "IOS_APPSTORE",
}
}
// TestLoginUsesLegacyWhenCCIIsNil covers brands without CCI support (Genesis
// EU, Hyundai AU): Login must always use the unmodified legacy refreshToken path
func TestLoginUsesLegacyWhenCCIIsNil(t *testing.T) {
for _, tt := range []struct {
name string
password string
}{
{"legacy-shaped password", strings.Repeat("A", 48)},
{"real-looking password", "s3cret-Passw0rd!"},
} {
t.Run(tt.name, func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/auth/api/v2/user/oauth2/token", legacyTokenHandler("legacy-access", "legacy-refresh"))
loginSrv := httptest.NewServer(mux)
defer loginSrv.Close()
deviceSrv := httptest.NewServer(http.HandlerFunc(deviceIDHandler))
defer deviceSrv.Close()
identity := newLoginTestIdentity(t, loginSrv.URL, deviceSrv.URL, "", nil)
require.NoError(t, identity.Login("user@example.com", tt.password, "en", "kia"))
token, err := identity.Token()
require.NoError(t, err)
assert.Equal(t, "legacy-access", token.AccessToken)
})
}
}
// TestLoginPrefersLegacyToken covers a CCI-capable brand whose configured
// password is still a valid legacy refresh_token: the CCI path must not be used
// (its endpoint is left unreachable, so routing there would fail the test)
func TestLoginPrefersLegacyToken(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/auth/api/v2/user/oauth2/token", legacyTokenHandler("legacy-access-2", "legacy-refresh-2"))
loginSrv := httptest.NewServer(mux)
defer loginSrv.Close()
deviceSrv := httptest.NewServer(http.HandlerFunc(deviceIDHandler))
defer deviceSrv.Close()
identity := newLoginTestIdentity(t, loginSrv.URL, deviceSrv.URL, unreachable, testCCIConfig())
require.NoError(t, identity.Login("user@example.com", strings.Repeat("A", 48), "en", "kia"))
token, err := identity.Token()
require.NoError(t, err)
assert.Equal(t, "legacy-access-2", token.AccessToken)
}
// TestLoginUsesCCIAndWiresRefreshCCI covers a CCI-capable brand with an account
// password: Login must fall back to the CCI login and wire TokenSource to refreshCCI
func TestLoginUsesCCIAndWiresRefreshCCI(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
loginMux := http.NewServeMux()
loginMux.HandleFunc("/auth/api/v2/user/oauth2/authorize", okHandler)
loginMux.HandleFunc("/auth/api/v1/accounts/certs", certsHandler(priv))
loginMux.HandleFunc("/auth/account/signin", func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, r.ParseForm())
ciphertext, err := hex.DecodeString(r.FormValue("password"))
require.NoError(t, err)
_, err = rsa.DecryptPKCS1v15(rand.Reader, priv, ciphertext)
require.NoError(t, err)
w.Header().Set("Location", "https://oneapp.kia.com/redirect?code=testcode&state=ccsp")
w.WriteHeader(http.StatusFound)
})
loginSrv := httptest.NewServer(loginMux)
defer loginSrv.Close()
var refreshCalls int32
var exchangeCalls int32
cciMux := http.NewServeMux()
cciMux.HandleFunc("/domain/api/v1/auth/token", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"accessToken": "cci-access", "refreshToken": "cci-refresh",
"nonCcsToken": "non-ccs", "exchangeableAccessToken": "exch-access",
"exchangeableRefreshToken": "exch-refresh", "nonCcsRefreshToken": "non-ccs-refresh",
"idToken": "id-1", "expiresIn": 3599,
})
})
cciMux.HandleFunc("/domain/api/v1/auth/token-exchange", func(w http.ResponseWriter, _ *http.Request) {
// the first (login) exchange returns a token expiring inside oauth2's
// 10s buffer, so the next Token() call triggers exactly one refresh
resp := map[string]any{"accessToken": "ccs-token-2", "expiresTime": time.Now().Add(time.Hour).Unix()}
if atomic.AddInt32(&exchangeCalls, 1) == 1 {
resp["accessToken"] = "ccs-token-1"
resp["expiresTime"] = time.Now().Add(5 * time.Second).Unix()
}
_ = json.NewEncoder(w).Encode(resp)
})
cciMux.HandleFunc("/domain/api/v2/auth/token-refresh", func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&refreshCalls, 1)
_ = json.NewEncoder(w).Encode(map[string]any{
"accessToken": "cci-access-2", "refreshToken": "cci-refresh-2",
})
})
cciSrv := httptest.NewServer(cciMux)
defer cciSrv.Close()
deviceSrv := httptest.NewServer(http.HandlerFunc(deviceIDHandler))
defer deviceSrv.Close()
identity := newLoginTestIdentity(t, loginSrv.URL, deviceSrv.URL, cciSrv.URL, testCCIConfig())
require.NoError(t, identity.Login("user@example.com", "s3cret-Passw0rd!", "en", "kia"))
// the freshly issued token is inside the expiry buffer, so this Token()
// call must trigger exactly one refresh against the CCI endpoint
token, err := identity.Token()
require.NoError(t, err)
assert.Equal(t, "ccs-token-2", token.AccessToken)
assert.EqualValues(t, 1, atomic.LoadInt32(&refreshCalls), "expected TokenSource to be wired to refreshCCI")
// the now long-lived token must not trigger a further refresh
_, err = identity.Token()
require.NoError(t, err)
assert.EqualValues(t, 1, atomic.LoadInt32(&refreshCalls), "unexpected additional refresh")
}
// TestLoginPropagatesLegacyError covers the legacy path: a failure there must
// surface through Login prefixed with evcc's existing "login failed: " convention.
func TestLoginPropagatesLegacyError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/auth/api/v2/user/oauth2/token", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid_grant"}`))
})
loginSrv := httptest.NewServer(mux)
defer loginSrv.Close()
identity := newLoginTestIdentity(t, loginSrv.URL, unreachable, "", nil)
err := identity.Login("user@example.com", strings.Repeat("A", 48), "en", "kia")
require.Error(t, err)
assert.True(t, strings.HasPrefix(err.Error(), "login failed:"), "got: %s", err.Error())
}
// TestLoginPropagatesCCIError covers the CCI path: a failure there must surface
// prefixed with "login failed: ", the same convention as the legacy path
func TestLoginPropagatesCCIError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/auth/api/v2/user/oauth2/authorize", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("Your request looks like abusing our system"))
})
loginSrv := httptest.NewServer(mux)
defer loginSrv.Close()
identity := newLoginTestIdentity(t, loginSrv.URL, unreachable, unreachable, testCCIConfig())
err := identity.Login("user@example.com", "s3cret-Passw0rd!", "en", "kia")
require.Error(t, err)
assert.True(t, strings.HasPrefix(err.Error(), "login failed:"), "got: %s", err.Error())
assert.Contains(t, err.Error(), "WAF")
}