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
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:
parent
0bbf4dc5c7
commit
bda5ad71db
7 changed files with 1111 additions and 11 deletions
|
|
@ -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/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:
|
||||
|
|
|
|||
|
|
@ -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/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:
|
||||
|
|
|
|||
|
|
@ -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
440
vehicle/bluelink/cci.go
Normal 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
|
||||
}
|
||||
377
vehicle/bluelink/cci_test.go
Normal file
377
vehicle/bluelink/cci_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
238
vehicle/bluelink/identity_test.go
Normal file
238
vehicle/bluelink/identity_test.go
Normal 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")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue