From 95cc0862268ed61c684acab4370ff2527fcfcc33 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 3 Oct 2020 16:30:06 +0200 Subject: [PATCH] Replace VW We with VW Id api (#367) --- provider/cache.go | 17 ++ util/request/functions.go | 26 ++- util/request/helper.go | 22 +- vehicle/audi.go | 234 +++++++-------------- vehicle/vw.go | 275 +++++++++++-------------- vehicle/vw/api.go | 142 +++++++++++++ vehicle/{vwidentity => vw}/forms.go | 4 +- vehicle/vw/functions.go | 32 +++ vehicle/{vwidentity => vw}/identity.go | 33 ++- 9 files changed, 462 insertions(+), 323 deletions(-) create mode 100644 vehicle/vw/api.go rename vehicle/{vwidentity => vw}/forms.go (91%) create mode 100644 vehicle/vw/functions.go rename vehicle/{vwidentity => vw}/identity.go (79%) diff --git a/provider/cache.go b/provider/cache.go index 17d5ac298..be90d820e 100644 --- a/provider/cache.go +++ b/provider/cache.go @@ -129,3 +129,20 @@ func (c *Cached) TimeGetter() func() (time.Time, error) { return c.val.(time.Time), c.err } } + +// InterfaceGetter gets interface value +func (c *Cached) InterfaceGetter() func() (interface{}, error) { + g, ok := c.getter.(func() (interface{}, error)) + if !ok { + c.log.FATAL.Fatalf("invalid type: %T", c.getter) + } + + return func() (interface{}, error) { + if c.clock.Since(c.updated) > c.cache { + c.val, c.err = g() + c.updated = c.clock.Now() + } + + return c.val, c.err + } +} diff --git a/util/request/functions.go b/util/request/functions.go index 79ca08c67..ab6918ad8 100644 --- a/util/request/functions.go +++ b/util/request/functions.go @@ -17,6 +17,30 @@ var ( JSONEncoding = map[string]string{"Content-Type": "application/json"} ) +// StatusError indicates unsuccessful http response +type StatusError struct { + resp *http.Response +} + +// NewStatusError create new StatusError for given response +func NewStatusError(resp *http.Response) StatusError { + return StatusError{resp: resp} +} + +func (e StatusError) Error() string { + return fmt.Sprintf("unexpected status: %d", e.resp.StatusCode) +} + +// Response returns the respose with the unexpected error +func (e StatusError) Response() *http.Response { + return e.resp +} + +// StatusCode returns the respose's status code +func (e StatusError) StatusCode() int { + return e.resp.StatusCode +} + // ReadBody reads HTTP response and returns error on response codes other than HTTP 2xx func ReadBody(resp *http.Response) ([]byte, error) { defer resp.Body.Close() @@ -30,7 +54,7 @@ func ReadBody(resp *http.Response) ([]byte, error) { resp.Body = ioutil.NopCloser(bytes.NewBuffer(b)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return b, fmt.Errorf("unexpected response %d: %s", resp.StatusCode, string(b)) + return b, StatusError{resp: resp} } return b, nil diff --git a/util/request/helper.go b/util/request/helper.go index bcf43d55e..68f2d2ca6 100644 --- a/util/request/helper.go +++ b/util/request/helper.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "net/http" + "net/http/httputil" "strings" "time" @@ -41,6 +42,7 @@ func (r *Helper) LastResponse() *http.Response { type helperTransport struct { log *log.Logger + detailed bool lastResponse func(*http.Response) roundTripper http.RoundTripper } @@ -52,9 +54,23 @@ func (r *helperTransport) RoundTrip(req *http.Request) (*http.Response, error) { if r.log != nil { msg := fmt.Sprintf("%s %s", req.Method, req.URL) if resp != nil { - msg += "\n" + resp.Status - if body, _ := ReadBody(resp); len(body) > 0 { - msg += "\n" + strings.TrimSpace(string(body)) + if r.detailed { + if body, err := httputil.DumpResponse(resp, true); err == nil { + msg += "\n" + strings.TrimSpace(string(body)) + } + } else { + msg += "\n" + resp.Status + + if body, _ := ReadBody(resp); len(body) > 0 { + const max = 2048 + + str := string(body) + if len(str) >= max { + str = str[:max] + } + + msg += "\n" + strings.TrimSpace(str) + } } } r.log.Println(msg) diff --git a/vehicle/audi.go b/vehicle/audi.go index 8691a480f..7e79cd254 100644 --- a/vehicle/audi.go +++ b/vehicle/audi.go @@ -2,7 +2,6 @@ package vehicle import ( "errors" - "fmt" "net/http" "net/http/cookiejar" "net/url" @@ -13,71 +12,26 @@ import ( "github.com/andig/evcc/provider" "github.com/andig/evcc/util" "github.com/andig/evcc/util/request" - "github.com/andig/evcc/vehicle/vwidentity" + "github.com/andig/evcc/vehicle/vw" "golang.org/x/net/publicsuffix" ) -const ( - vwAPI = "https://msg.volkswagen.de/fs-car" -) - -// OIDCResponse is the well-known OIDC provider response -// https://{oauth-provider-hostname}/.well-known/openid-configuration -type OIDCResponse struct { - Issuer string `json:"issuer"` - AuthURL string `json:"authorization_endpoint"` - TokenURL string `json:"token_endpoint"` - JWKSURL string `json:"jwks_uri"` - UserInfoURL string `json:"userinfo_endpoint"` - Algorithms []string `json:"id_token_signing_alg_values_supported"` -} - -type audiTokenResponse struct { - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - IDToken string `json:"id_token"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` -} - -type audiVehiclesResponse struct { - UserVehicles struct { - Vehicle []string - } -} - -type audiChargerResponse struct { - Charger struct { - Status struct { - BatteryStatusData struct { - StateOfCharge struct { - Content int - Timestamp string - } - RemainingChargingTime struct { - Content int - Timestamp string - } - } - } - } -} - // Audi is an api.Vehicle implementation for Audi cars type Audi struct { *embed *request.Helper - user, password, vin string - brand, country string - tokens audiTokenResponse - chargeStateG func() (float64, error) - finishTimeG func() (time.Time, error) + user, password string + tokens vw.Tokens + api *vw.API + apiG func() (interface{}, error) } func init() { registry.Add("audi", NewAudiFromConfig) } +const audiClientID = "77869e21-e30a-4a92-b016-48ab7d3db1d8" + // NewAudiFromConfig creates a new vehicle func NewAudiFromConfig(other map[string]interface{}) (api.Vehicle, error) { cc := struct { @@ -97,13 +51,10 @@ func NewAudiFromConfig(other map[string]interface{}) (api.Vehicle, error) { Helper: request.NewHelper(log), user: cc.User, password: cc.Password, - vin: strings.ToUpper(cc.VIN), - brand: "Audi", - country: "DE", } - v.chargeStateG = provider.NewCached(v.chargeState, cc.Cache).FloatGetter() - v.finishTimeG = provider.NewCached(v.finishTime, cc.Cache).TimeGetter() + v.api = vw.NewAPI(v.Helper, &v.tokens, v.authFlow, v.refreshHeaders, strings.ToUpper(cc.VIN), "Audi", "DE") + v.apiG = provider.NewCached(v.apiCall, cc.Cache).InterfaceGetter() var err error jar, err := cookiejar.New(&cookiejar.Options{ @@ -121,9 +72,9 @@ func NewAudiFromConfig(other map[string]interface{}) (api.Vehicle, error) { } if err == nil && cc.VIN == "" { - v.vin, err = findVehicle(v.vehicles()) + v.api.VIN, err = findVehicle(v.api.Vehicles()) if err == nil { - log.DEBUG.Printf("found vehicle: %v", v.vin) + log.DEBUG.Printf("found vehicle: %v", v.api.VIN) } } @@ -131,18 +82,32 @@ func NewAudiFromConfig(other map[string]interface{}) (api.Vehicle, error) { } func (v *Audi) authFlow() error { - var uri, body string + var err error + var uri string var req *http.Request + var resp *http.Response + var tokens vw.Tokens - uri = "https://identity.vwgroup.io/oidc/v1/authorize?" + - "response_type=code&client_id=09b6cbec-cd19-4589-82fd-363dfa8c24da%40apps_vw-dilab_com&" + - "redirect_uri=myaudi%3A%2F%2F%2F&scope=address%20profile%20badge%20birthdate%20birthplace%20nationalIdentifier%20nationality%20profession%20email%20vin%20phone%20nickname%20name%20picture%20mbb%20gallery%20openid&" + - "state=7f8260b5-682f-4db8-b171-50a5189a1c08&nonce=583b9af2-7799-4c72-9cb0-e6c0f42b87b3&prompt=login&ui_locales=de-DE" + const clientID = "09b6cbec-cd19-4589-82fd-363dfa8c24da@apps_vw-dilab_com" + const redirectURI = "myaudi:///" - identity := &vwidentity.Identity{Client: v.Client} - resp, err := identity.Login(uri, v.user, v.password) + query := url.Values(map[string][]string{ + "response_type": {"code"}, + "client_id": {clientID}, + "redirect_uri": {redirectURI}, + "scope": {"address profile badge birthdate birthplace nationalIdentifier nationality profession email vin phone nickname name picture mbb gallery openid"}, + "state": {"7f8260b5-682f-4db8-b171-50a5189a1c08"}, + "nonce": {"7f8260b5-682f-4db8-b171-50a5189a1c08"}, + "prompt": {"login"}, + "ui_locales": {"de-DE"}, + }) + + uri = "https://identity.vwgroup.io/oidc/v1/authorize?" + query.Encode() + if err == nil { + identity := &vw.Identity{Client: v.Client} + resp, err = identity.Login(uri, v.user, v.password) + } - var tokens audiTokenResponse if err == nil { var code string if location, err := url.Parse(resp.Header.Get("Location")); err == nil { @@ -150,134 +115,79 @@ func (v *Audi) authFlow() error { } data := url.Values(map[string][]string{ - "client_id": {"09b6cbec-cd19-4589-82fd-363dfa8c24da@apps_vw-dilab_com"}, + "client_id": {clientID}, "grant_type": {"authorization_code"}, "code": {code}, - "redirect_uri": {"myaudi:///"}, + "redirect_uri": {redirectURI}, "response_type": {"token id_token"}, }) uri = "https://app-api.my.audi.com/myaudiappidk/v1/token" req, err = request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding) if err == nil { - err = v.DoJSON(req, &tokens) + if err = v.DoJSON(req, &tokens); err == nil && tokens.IDToken == "" { + err = errors.New("missing id token (1)") + } } } if err == nil { - body = fmt.Sprintf("grant_type=%s&token=%s&scope=%s", "id_token", tokens.IDToken, "sc2:fal") - headers := map[string]string{ - "Content-Type": "application/x-www-form-urlencoded", - "X-App-Version": "3.14.0", - "X-App-Name": "myAudi", - "X-Client-Id": "77869e21-e30a-4a92-b016-48ab7d3db1d8", - } + data := url.Values(map[string][]string{ + "grant_type": {"id_token"}, + "scope": {"sc2:fal"}, + "token": {tokens.IDToken}, + }) - req, err = request.New(http.MethodPost, vwidentity.OauthTokenURI, strings.NewReader(body), headers) + req, err = request.New(http.MethodPost, vw.OauthTokenURI, strings.NewReader(data.Encode()), map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + "X-Client-Id": audiClientID, + }) if err == nil { - err = v.DoJSON(req, &tokens) - v.tokens = tokens + if err = v.DoJSON(req, &v.tokens); err == nil && tokens.IDToken == "" { + err = errors.New("missing id token (2)") + } } } return err } -func (v *Audi) refreshToken() error { - if v.tokens.RefreshToken == "" { - return errors.New("missing refresh token") - } - - body := fmt.Sprintf("grant_type=%s&refresh_token=%s&scope=%s", "refresh_token", v.tokens.RefreshToken, "sc2:fal") - headers := map[string]string{ +func (v *Audi) refreshHeaders() map[string]string { + return map[string]string{ "Content-Type": "application/x-www-form-urlencoded", "X-App-Version": "3.14.0", "X-App-Name": "myAudi", - "X-Client-Id": "77869e21-e30a-4a92-b016-48ab7d3db1d8", + "X-Client-Id": audiClientID, } - - req, err := request.New(http.MethodPost, vwidentity.OauthTokenURI, strings.NewReader(body), headers) - if err == nil { - var tokens audiTokenResponse - err = v.DoJSON(req, &tokens) - if err == nil { - v.tokens = tokens - } - } - - return err } -func (v *Audi) getJSON(uri string, res interface{}) error { - req, err := request.New(http.MethodGet, uri, nil, map[string]string{ - "Accept": "application/json", - "Authorization": "Bearer " + v.tokens.AccessToken, - }) - - if err == nil { - err = v.DoJSON(req, &res) - - // token expired? - if err != nil { - resp := v.LastResponse() - - // handle http 401 - if resp != nil && resp.StatusCode == http.StatusUnauthorized { - // use refresh token - err = v.refreshToken() - - // re-run auth flow - if err != nil { - err = v.authFlow() - } - } - - // retry original requests - if err == nil { - req.Header.Set("Authorization", "Bearer "+v.tokens.AccessToken) - err = v.DoJSON(req, &res) - } - } - } - - return err -} - -func (v *Audi) vehicles() ([]string, error) { - var res audiVehiclesResponse - uri := fmt.Sprintf("%s/usermanagement/users/v1/Audi/DE/vehicles", vwAPI) - err := v.getJSON(uri, &res) - return res.UserVehicles.Vehicle, err -} - -// chargeState implements the Vehicle.ChargeState interface -func (v *Audi) chargeState() (float64, error) { - var res audiChargerResponse - uri := fmt.Sprintf("%s/bs/batterycharge/v1/%s/%s/vehicles/%s/charger", vwAPI, v.brand, v.country, v.vin) - err := v.getJSON(uri, &res) - return float64(res.Charger.Status.BatteryStatusData.StateOfCharge.Content), err +// apiCall provides charger api response +func (v *Audi) apiCall() (interface{}, error) { + res, err := v.api.Charger() + return res, err } // ChargeState implements the Vehicle.ChargeState interface func (v *Audi) ChargeState() (float64, error) { - return v.chargeStateG() -} - -// finishTime implements the Vehicle.ChargeFinishTimer interface -func (v *Audi) finishTime() (time.Time, error) { - var res audiChargerResponse - uri := fmt.Sprintf("%s/bs/batterycharge/v1/%s/%s/vehicles/%s/charger", vwAPI, v.brand, v.country, v.vin) - err := v.getJSON(uri, &res) - - var timestamp time.Time - if err == nil { - timestamp, err = time.Parse(time.RFC3339, res.Charger.Status.BatteryStatusData.RemainingChargingTime.Timestamp) + res, err := v.apiG() + if res, ok := res.(vw.ChargerResponse); err == nil && ok { + return float64(res.Charger.Status.BatteryStatusData.StateOfCharge.Content), nil } - return timestamp.Add(time.Duration(res.Charger.Status.BatteryStatusData.RemainingChargingTime.Content) * time.Minute), err + return 0, err } // FinishTime implements the Vehicle.ChargeFinishTimer interface func (v *Audi) FinishTime() (time.Time, error) { - return v.finishTimeG() + res, err := v.apiG() + if res, ok := res.(vw.ChargerResponse); err == nil && ok { + var timestamp time.Time + if err == nil { + timestamp, err = time.Parse(time.RFC3339, res.Charger.Status.BatteryStatusData.RemainingChargingTime.Timestamp) + } + + return timestamp.Add(time.Duration(res.Charger.Status.BatteryStatusData.RemainingChargingTime.Content) * time.Minute), err + } + + return time.Time{}, err } diff --git a/vehicle/vw.go b/vehicle/vw.go index 02a0da215..fd3bd252d 100644 --- a/vehicle/vw.go +++ b/vehicle/vw.go @@ -1,7 +1,7 @@ package vehicle import ( - "fmt" + "errors" "net/http" "net/http/cookiejar" "net/url" @@ -12,50 +12,21 @@ import ( "github.com/andig/evcc/provider" "github.com/andig/evcc/util" "github.com/andig/evcc/util/request" - "github.com/andig/evcc/vehicle/vwidentity" + "github.com/andig/evcc/vehicle/vw" "golang.org/x/net/publicsuffix" ) -type vwVehiclesResponse struct { - FullyLoadedVehiclesResponse struct { - CompleteVehicles []struct { - VIN string - } - VehiclesNotFullyLoaded []struct { - VIN string - } - } -} - -type vwChargerResponse struct { - ErrorCode int `json:",string"` - // /-/emanager/get-emanager - EManager struct { - RBC struct { - Status struct { - BatteryPercentage int - ChargingRemaningHour int `json:",string"` - ChargingRemaningMinute int `json:",string"` - } - } - } - // /-/vsr/get-vsr - VehicleStatusData struct { - BatteryRange int - BatteryLevel int - } -} - -// based on https://github.com/wez3/volkswagen-carnet-client +// https://github.com/trocotronic/weconnect // VW is an api.Vehicle implementation for VW cars type VW struct { *embed *request.Helper - user, password, vin string - baseURI, csrf string - chargeStateG func() (float64, error) - finishTimeG func() (time.Time, error) + user, password string + clientID string + tokens vw.Tokens + api *vw.API + apiG func() (interface{}, error) } func init() { @@ -81,11 +52,10 @@ func NewVWFromConfig(other map[string]interface{}) (api.Vehicle, error) { Helper: request.NewHelper(log), user: cc.User, password: cc.Password, - vin: cc.VIN, } - v.chargeStateG = provider.NewCached(v.chargeState, cc.Cache).FloatGetter() - v.finishTimeG = provider.NewCached(v.finishTime, cc.Cache).TimeGetter() + v.api = vw.NewAPI(v.Helper, &v.tokens, v.authFlow, v.refreshHeaders, strings.ToUpper(cc.VIN), "VW", "DE") + v.apiG = provider.NewCached(v.apiCall, cc.Cache).InterfaceGetter() var err error jar, err := cookiejar.New(&cookiejar.Options{ @@ -103,9 +73,9 @@ func NewVWFromConfig(other map[string]interface{}) (api.Vehicle, error) { } if err == nil && cc.VIN == "" { - v.vin, err = findVehicle(v.vehicles()) + v.api.VIN, err = findVehicle(v.api.Vehicles()) if err == nil { - log.DEBUG.Printf("found vehicle: %v", v.vin) + log.DEBUG.Printf("found vehicle: %v", v.api.VIN) } } @@ -114,72 +84,109 @@ func NewVWFromConfig(other map[string]interface{}) (api.Vehicle, error) { func (v *VW) authFlow() error { var err error - var uri, body string - var vars vwidentity.FormVars + var uri string var req *http.Request var resp *http.Response - - // GET www.portal.volkswagen-we.com/portal/de_DE/web/guest/home - uri = "https://www.portal.volkswagen-we.com/portal/de_DE/web/guest/home" - resp, err = v.Get(uri) - - if err == nil { - vars, err = vwidentity.FormValues(resp.Body, "meta") - } - - // POST www.portal.volkswagen-we.com/portal/en_GB/web/guest/home/-/csrftokenhandling/get-login-url - if err == nil { - uri = "https://www.portal.volkswagen-we.com/portal/en_GB/web/guest/home/-/csrftokenhandling/get-login-url" - if req, err = request.New(http.MethodPost, uri, nil, map[string]string{"X-CSRF-Token": vars.Csrf}); err == nil { - - res := struct { - ErrorCode int `json:",string"` - LoginURL struct { - Path string - } - }{} - - if err = v.DoJSON(req, &res); err == nil { - uri = strings.ReplaceAll(res.LoginURL.Path, " ", "%20") - if res.ErrorCode != 0 { - err = fmt.Errorf("login url error code: %d", res.ErrorCode) - } - } - } - } + var idToken string // execute login + challenge, verifier, err := vw.ChallengeVerifier() + query := url.Values(map[string][]string{ + "prompt": {"login"}, + "state": {vw.RandomString(43)}, + "response_type": {"code id_token token"}, + "code_challenge_method": {"s256"}, + "scope": {"openid profile mbb cars birthdate nickname address phone"}, + "code_challenge": {challenge}, + "redirect_uri": {"carnet://identity-kit/login"}, + "client_id": {"9496332b-ea03-4091-a224-8c746b885068@apps_vw-dilab_com"}, + "nonce": {vw.RandomString(43)}, + }) + + uri = "https://identity.vwgroup.io/oidc/v1/authorize?" + query.Encode() if err == nil { - identity := &vwidentity.Identity{Client: v.Client} + identity := &vw.Identity{Client: v.Client} resp, err = identity.Login(uri, v.user, v.password) } - // get base url if err == nil { - var code, state string - var locationURL *url.URL + // var code string - location := resp.Header.Get("Location") - - if locationURL, err = url.Parse(location); err == nil { - code = locationURL.Query().Get("code") - state = locationURL.Query().Get("state") - - uri = fmt.Sprintf( - "%s?p_auth=%s&p_p_id=33_WAR_cored5portlet&p_p_lifecycle=1&p_p_state=normal&p_p_mode=view&p_p_col_id=column-1&p_p_col_count=1&_33_WAR_cored5portlet_javax.portlet.action=getLoginStatus", - locationURL.Scheme+"://"+locationURL.Host+locationURL.Path, - state, - ) + loc := strings.ReplaceAll(resp.Header.Get("Location"), "#", "?") // convert to parsable url + if locationURL, err := url.Parse(loc); err == nil { + // code = locationURL.Query().Get("code") + idToken = locationURL.Query().Get("id_token") } - body = fmt.Sprintf("_33_WAR_cored5portlet_code=%s", url.QueryEscape(code)) + _ = verifier + // if err == nil { + // data := url.Values(map[string][]string{ + // "auth_code": {code}, + // "code_verifier": {verifier}, + // "id_token": {idToken}, + // }) - if req, err = request.New(http.MethodPost, uri, strings.NewReader(body), request.URLEncoding); err == nil { - if resp, err = v.Do(req); err == nil { - uri = resp.Header.Get("Location") + // uri := "https://tokenrefreshservice.apps.emea.vwapps.io/exchangeAuthCode" + // req, err = request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding) - v.baseURI = uri - v.csrf = vars.Csrf + // // if err == nil { + // // err = v.DoJSON(req, &tokens) + // // if err == nil && tokens.AccessToken == "" { + // // err = errors.New("missing access token") + // // } + // // } + // } + } + + // get client id + if err == nil { + data := struct { + AppID string `json:"appId"` + AppName string `json:"appName"` + AppVersion string `json:"appVersion"` + ClientBrand string `json:"client_brand"` + ClientName string `json:"client_name"` + Platform string `json:"platform"` + }{ + AppID: "de.volkswagen.car-net.eu.e-remote", + AppName: "We Connect", + AppVersion: "5.3.2", + ClientBrand: "VW", + ClientName: "iPhone", + Platform: "iOS", + } + + uri = "https://mbboauth-1d.prd.ece.vwg-connect.com/mbbcoauth/mobile/register/v1" + req, err = request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding) + + if err == nil { + data := struct { + ClientID string `json:"client_id"` + }{} + + if err = v.DoJSON(req, &data); err == nil && data.ClientID == "" { + err = errors.New("missing client id") + } + + v.clientID = data.ClientID + } + + if err == nil { + data := url.Values(map[string][]string{ + "grant_type": {"id_token"}, + "scope": {"sc2:fal"}, + "token": {idToken}, + }) + + req, err = request.New(http.MethodPost, vw.OauthTokenURI, strings.NewReader(data.Encode()), map[string]string{ + "Content-type": "application/x-www-form-urlencoded", + "X-Client-Id": v.clientID, + }) + + if err == nil { + if err = v.DoJSON(req, &v.tokens); err == nil && v.tokens.AccessToken == "" { + err = errors.New("missing access token") + } } } } @@ -187,74 +194,40 @@ func (v *VW) authFlow() error { return err } -func (v *VW) vehicles() ([]string, error) { - uri := v.baseURI + "/-/mainnavigation/get-fully-loaded-cars" - req, err := request.New(http.MethodPost, uri, nil, map[string]string{ - "Accept": "application/json", - "X-CSRF-Token": v.csrf, - }) - - var res vwVehiclesResponse - if err == nil { - err = v.DoJSON(req, &res) +func (v *VW) refreshHeaders() map[string]string { + return map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + "X-Client-Id": v.clientID, } - - vehicles := make([]string, 0) - for _, v := range res.FullyLoadedVehiclesResponse.CompleteVehicles { - vehicles = append(vehicles, v.VIN) - } - for _, v := range res.FullyLoadedVehiclesResponse.VehiclesNotFullyLoaded { - vehicles = append(vehicles, v.VIN) - } - - return vehicles, err } -// chargeState implements the Vehicle.ChargeState interface -func (v *VW) chargeState() (float64, error) { - uri := v.baseURI + "/-/emanager/get-emanager" - req, err := request.New(http.MethodPost, uri, nil, map[string]string{ - "Accept": "application/json", - "X-CSRF-Token": v.csrf, - }) - - var res vwChargerResponse - if err == nil { - err = v.DoJSON(req, &res) - } - - return float64(res.EManager.RBC.Status.BatteryPercentage), err +// apiCall provides charger api response +func (v *VW) apiCall() (interface{}, error) { + res, err := v.api.Charger() + return res, err } // ChargeState implements the Vehicle.ChargeState interface func (v *VW) ChargeState() (float64, error) { - return v.chargeStateG() -} - -// finishTime implements the Vehicle.ChargeFinishTimer interface -func (v *VW) finishTime() (time.Time, error) { - uri := v.baseURI + "/-/emanager/get-emanager" - req, err := request.New(http.MethodPost, uri, nil, map[string]string{ - "Accept": "application/json", - "X-CSRF-Token": v.csrf, - }) - - var res vwChargerResponse - if err == nil { - err = v.DoJSON(req, &res) + res, err := v.apiG() + if res, ok := res.(vw.ChargerResponse); err == nil && ok { + return float64(res.Charger.Status.BatteryStatusData.StateOfCharge.Content), nil } - var duration time.Duration - if err == nil { - hour := res.EManager.RBC.Status.ChargingRemaningHour - min := res.EManager.RBC.Status.ChargingRemaningMinute - duration = time.Hour*time.Duration(hour) + time.Minute*time.Duration(min) - } - - return time.Now().Add(duration), err + return 0, err } // FinishTime implements the Vehicle.ChargeFinishTimer interface func (v *VW) FinishTime() (time.Time, error) { - return v.finishTimeG() + res, err := v.apiG() + if res, ok := res.(vw.ChargerResponse); err == nil && ok { + var timestamp time.Time + if err == nil { + timestamp, err = time.Parse(time.RFC3339, res.Charger.Status.BatteryStatusData.RemainingChargingTime.Timestamp) + } + + return timestamp.Add(time.Duration(res.Charger.Status.BatteryStatusData.RemainingChargingTime.Content) * time.Minute), err + } + + return time.Time{}, err } diff --git a/vehicle/vw/api.go b/vehicle/vw/api.go new file mode 100644 index 000000000..a245075b4 --- /dev/null +++ b/vehicle/vw/api.go @@ -0,0 +1,142 @@ +package vw + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/andig/evcc/util/request" +) + +// BaseURI is the VW api base URI +const BaseURI = "https://msg.volkswagen.de/fs-car" + +// VehiclesResponse is the /usermanagement/users/v1/%s/%s/vehicles api +type VehiclesResponse struct { + UserVehicles struct { + Vehicle []string + } +} + +// ChargerResponse is the /bs/batterycharge/v1/%s/%s/vehicles/%s/charger api +type ChargerResponse struct { + Charger struct { + Status struct { + BatteryStatusData struct { + StateOfCharge struct { + Content int + Timestamp string + } + RemainingChargingTime struct { + Content int + Timestamp string + } + } + } + } +} + +// API is the VW api client +type API struct { + *request.Helper + tokens *Tokens + authFlow func() error + refreshHeaders func() map[string]string + brand, country string + VIN string +} + +// NewAPI creates a new api client +func NewAPI( + helper *request.Helper, tokens *Tokens, + authFlow func() error, refreshHeaders func() map[string]string, + vin, brand, country string, +) *API { + v := &API{ + Helper: helper, + tokens: tokens, + authFlow: authFlow, + refreshHeaders: refreshHeaders, + VIN: vin, + brand: brand, + country: country, + } + return v +} + +func (v *API) refreshToken() error { + if v.tokens.RefreshToken == "" { + return errors.New("missing refresh token") + } + + data := url.Values(map[string][]string{ + "grant_type": {"refresh_token"}, + "refresh_token": {v.tokens.RefreshToken}, + "scope": {"sc2:fal"}, + }) + + req, err := request.New(http.MethodPost, OauthTokenURI, strings.NewReader(data.Encode()), v.refreshHeaders()) + + if err == nil { + var tokens Tokens + + err = v.DoJSON(req, &tokens) + if err == nil { + v.tokens.AccessToken = tokens.AccessToken + if tokens.RefreshToken != "" { + v.tokens.RefreshToken = tokens.RefreshToken + } + } + } + + return err +} + +func (v *API) getJSON(uri string, res interface{}) error { + req, err := request.New(http.MethodGet, uri, nil, map[string]string{ + "Accept": "application/json", + "Authorization": "Bearer " + v.tokens.AccessToken, + }) + + if err == nil { + err = v.DoJSON(req, &res) + + // token expired? + if err != nil { + // handle http 401 + if se, ok := err.(request.StatusError); ok && se.StatusCode() == http.StatusUnauthorized { + // use refresh token + if err = v.refreshToken(); err != nil { + // re-run full auth flow + err = v.authFlow() + } + } + + // retry original requests + if err == nil { + req.Header.Set("Authorization", "Bearer "+v.tokens.AccessToken) + err = v.DoJSON(req, &res) + } + } + } + + return err +} + +// Vehicles implements the /vehicles response +func (v *API) Vehicles() ([]string, error) { + var res VehiclesResponse + uri := fmt.Sprintf("%s/usermanagement/users/v1/%s/%s/vehicles", BaseURI, v.brand, v.country) + err := v.getJSON(uri, &res) + return res.UserVehicles.Vehicle, err +} + +// Charger implements the /charger response +func (v *API) Charger() (ChargerResponse, error) { + var res ChargerResponse + uri := fmt.Sprintf("%s/bs/batterycharge/v1/%s/%s/vehicles/%s/charger", BaseURI, v.brand, v.country, v.VIN) + err := v.getJSON(uri, &res) + return res, err +} diff --git a/vehicle/vwidentity/forms.go b/vehicle/vw/forms.go similarity index 91% rename from vehicle/vwidentity/forms.go rename to vehicle/vw/forms.go index c590307b1..19457c0d5 100644 --- a/vehicle/vwidentity/forms.go +++ b/vehicle/vw/forms.go @@ -1,4 +1,4 @@ -package vwidentity +package vw import ( "errors" @@ -7,6 +7,7 @@ import ( "github.com/PuerkitoBio/goquery" ) +// FormVars holds HTML form input values required for login type FormVars struct { Action string Csrf string @@ -14,6 +15,7 @@ type FormVars struct { Hmac string } +// FormValues extracts FormVars from given HTML document func FormValues(reader io.Reader, id string) (FormVars, error) { vars := FormVars{} diff --git a/vehicle/vw/functions.go b/vehicle/vw/functions.go new file mode 100644 index 000000000..057e0db82 --- /dev/null +++ b/vehicle/vw/functions.go @@ -0,0 +1,32 @@ +package vw + +import ( + "crypto/sha256" + "encoding/base64" + "math/rand" +) + +// RandomString creates random string of N integers +func RandomString(n int) string { + var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + s := make([]rune, n) + for i := range s { + s[i] = letters[rand.Intn(len(letters))] + } + return string(s) +} + +// ChallengeVerifier returns a challenge/verifier base64-encoded code combination +func ChallengeVerifier() (string, string, error) { + bytes := make([]byte, 32) + n, err := rand.Read(bytes) + if n != 32 || err != nil { + return "", "", err + } + + verifier := base64.RawURLEncoding.EncodeToString(bytes) + sha := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(sha[:]) + + return challenge, verifier, nil +} diff --git a/vehicle/vwidentity/identity.go b/vehicle/vw/identity.go similarity index 79% rename from vehicle/vwidentity/identity.go rename to vehicle/vw/identity.go index 42bf35dad..43e3f3777 100644 --- a/vehicle/vwidentity/identity.go +++ b/vehicle/vw/identity.go @@ -1,4 +1,4 @@ -package vwidentity +package vw import ( "net/http" @@ -9,10 +9,33 @@ import ( ) const ( - RootURI = "https://identity.vwgroup.io" + // IdentityURI is the VW OIDC identidy provider uri + IdentityURI = "https://identity.vwgroup.io" + + // OauthTokenURI is used for refreshing tokens OauthTokenURI = "https://mbboauth-1d.prd.ece.vwg-connect.com/mbbcoauth/mobile/oauth2/v1/token" ) +// Tokens is an OAuth tokens response +type Tokens struct { + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + IDToken string `json:"id_token"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +// OIDCResponse is the well-known OIDC provider response +// https://{oauth-provider-hostname}/.well-known/openid-configuration +type OIDCResponse struct { + Issuer string `json:"issuer"` + AuthURL string `json:"authorization_endpoint"` + TokenURL string `json:"token_endpoint"` + JWKSURL string `json:"jwks_uri"` + UserInfoURL string `json:"userinfo_endpoint"` + Algorithms []string `json:"id_token_signing_alg_values_supported"` +} + // Identity provides the identity.vwgroup.io login type Identity struct { *http.Client @@ -54,7 +77,7 @@ func (v *Identity) Login(uri, user, password string) (*http.Response, error) { "email": {user}, }) - uri = RootURI + vars.Action + uri = IdentityURI + vars.Action req, err = request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding) if err == nil { @@ -64,7 +87,7 @@ func (v *Identity) Login(uri, user, password string) (*http.Response, error) { // GET identity.vwgroup.io/signin-service/v1/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com/login/authenticate?relayState=15404cb51c8b4cc5efeee1d2c2a73e5b41562faa&email=... if err == nil { - uri = RootURI + resp.Header.Get("Location") + uri = IdentityURI + resp.Header.Get("Location") req, err = http.NewRequest(http.MethodGet, uri, nil) if err == nil { @@ -86,7 +109,7 @@ func (v *Identity) Login(uri, user, password string) (*http.Response, error) { "password": {password}, }) - uri = RootURI + vars.Action + uri = IdentityURI + vars.Action req, err = request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding) if err == nil {