diff --git a/templates/definition/vehicle/hyundai.yaml b/templates/definition/vehicle/hyundai.yaml index 981fa1988..a7240d6a6 100644 --- a/templates/definition/vehicle/hyundai.yaml +++ b/templates/definition/vehicle/hyundai.yaml @@ -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 diff --git a/templates/definition/vehicle/kia.yaml b/templates/definition/vehicle/kia.yaml index 66b0946c1..0571afba2 100644 --- a/templates/definition/vehicle/kia.yaml +++ b/templates/definition/vehicle/kia.yaml @@ -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 diff --git a/vehicle/bluelink.go b/vehicle/bluelink.go index 423e52419..eab378244 100644 --- a/vehicle/bluelink.go +++ b/vehicle/bluelink.go @@ -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) diff --git a/vehicle/bluelink/cci.go b/vehicle/bluelink/cci.go new file mode 100644 index 000000000..5d31d631a --- /dev/null +++ b/vehicle/bluelink/cci.go @@ -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 +} diff --git a/vehicle/bluelink/cci_test.go b/vehicle/bluelink/cci_test.go new file mode 100644 index 000000000..020ab8ed5 --- /dev/null +++ b/vehicle/bluelink/cci_test.go @@ -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("login")) +} + +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") +} diff --git a/vehicle/bluelink/identity.go b/vehicle/bluelink/identity.go index c798c53e4..af9ef836a 100644 --- a/vehicle/bluelink/identity.go +++ b/vehicle/bluelink/identity.go @@ -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 diff --git a/vehicle/bluelink/identity_test.go b/vehicle/bluelink/identity_test.go new file mode 100644 index 000000000..2c75d76f1 --- /dev/null +++ b/vehicle/bluelink/identity_test.go @@ -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") +}