diff --git a/vehicle/bmw/api.go b/vehicle/bmw/api.go index b6575b16c..63cb7b815 100644 --- a/vehicle/bmw/api.go +++ b/vehicle/bmw/api.go @@ -41,19 +41,30 @@ func NewAPI(log *util.Logger, brand string, identity oauth2.TokenSource) *API { return v } +func (v *API) eadrax() (VehiclesStatusResponse, error) { + var res VehiclesStatusResponse + uri := fmt.Sprintf("%s/eadrax-vcs/v1/vehicles?apptimezone=60&appDateTime=%d", CocoApiURI, time.Now().Unix()) + + req, err := request.New(http.MethodGet, uri, nil, map[string]string{ + "Content-Type": request.JSONContent, + "X-User-Agent": v.xUserAgent, + }) + if err == nil { + err = v.DoJSON(req, &res) + } + + return res, err +} + // Vehicles implements returns the /user/vehicles api func (v *API) Vehicles() ([]string, error) { - var resp VehiclesResponse - uri := fmt.Sprintf("%s/user/vehicles", ApiURI) - // uri := fmt.Sprintf("%s/eadrax-vcs/v1/vehicles", CocoApiURI, vin) - - req, err := http.NewRequest(http.MethodGet, uri, nil) - if err == nil { - err = v.DoJSON(req, &resp) + resp, err := v.eadrax() + if err != nil { + return nil, err } var vehicles []string - for _, v := range resp.Vehicles { + for _, v := range resp { vehicles = append(vehicles, v.VIN) } @@ -62,16 +73,7 @@ func (v *API) Vehicles() ([]string, error) { // Status implements the /user/vehicles//status api func (v *API) Status(vin string) (VehicleStatus, error) { - var resp VehiclesStatusResponse - uri := fmt.Sprintf("%s/eadrax-vcs/v1/vehicles?apptimezone=60&appDateTime=%d", CocoApiURI, time.Now().Unix()) - - req, err := request.New(http.MethodGet, uri, nil, map[string]string{ - "X-User-Agent": v.xUserAgent, - }) - if err == nil { - err = v.DoJSON(req, &resp) - } - + resp, err := v.eadrax() if err == nil { for _, res := range resp { if res.VIN == vin { diff --git a/vehicle/bmw/identity.go b/vehicle/bmw/identity.go index 2dd27ad09..9c754f078 100644 --- a/vehicle/bmw/identity.go +++ b/vehicle/bmw/identity.go @@ -1,22 +1,26 @@ package bmw import ( - "encoding/json" "errors" "fmt" "net/http" + "net/http/cookiejar" "net/url" - "strconv" "strings" "time" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/oauth" "github.com/evcc-io/evcc/util/request" + cv "github.com/nirasan/go-oauth-pkce-code-verifier" + "golang.org/x/net/publicsuffix" "golang.org/x/oauth2" ) -const AuthURI = "https://customer.bmwgroup.com/gcdm/oauth/authenticate" +const ( + AuthURI = "https://customer.bmwgroup.com/gcdm/oauth" + RedirectURI = "com.bmw.connected://oauth" +) type Identity struct { *request.Helper @@ -46,62 +50,133 @@ func (v *Identity) Login(user, password string) error { return err } -func (v *Identity) RefreshToken(_ *oauth2.Token) (*oauth2.Token, error) { - data := url.Values{ - "username": []string{v.user}, - "password": []string{v.password}, - "client_id": []string{"31c357a0-7a1d-4590-aa99-33b97244d048"}, - "redirect_uri": []string{"com.bmw.connected://oauth"}, - "response_type": []string{"token"}, - "scope": []string{"authenticate_user vehicle_data remote_services"}, - "nonce": []string{"login_nonce"}, +func (v *Identity) retrieveToken(data url.Values) (*oauth2.Token, error) { + var tok struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` } - req, err := request.New(http.MethodPost, AuthURI, strings.NewReader(data.Encode()), request.URLEncoding) + uri := fmt.Sprintf("%s/token", AuthURI) + req, err := request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), map[string]string{ + "Content-Type": request.FormContent, + "Authorization": "Basic MzFjMzU3YTAtN2ExZC00NTkwLWFhOTktMzNiOTcyNDRkMDQ4OmMwZTMzOTNkLTcwYTItNGY2Zi05ZDNjLTg1MzBhZjY0ZDU1Mg==", + }) + + if err == nil { + err = v.DoJSON(req, &tok) + } + + token := &oauth2.Token{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + Expiry: time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second), + } + + return token, err +} + +func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) { + if token == nil || token.RefreshToken == "" { + return v.login() + } + + data := url.Values{ + "redirect_uri": []string{RedirectURI}, + "refresh_token": []string{token.RefreshToken}, + "grant_type": []string{"refresh_token"}, + } + + return v.retrieveToken(data) +} + +func (v *Identity) login() (*oauth2.Token, error) { + // don't follow redirects + v.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } + defer func() { v.Client.CheckRedirect = nil }() + + cv, err := cv.CreateCodeVerifier() if err != nil { return nil, err } - // don't follow redirects - v.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } - defer func() { v.Client.CheckRedirect = nil }() + v.Jar, err = cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + if err != nil { + return nil, err + } + + data := url.Values{ + "client_id": []string{"31c357a0-7a1d-4590-aa99-33b97244d048"}, + "response_type": []string{"code"}, + "redirect_uri": []string{RedirectURI}, + "state": []string{"cwU-gIE27j67poy2UcL3KQ"}, + "scope": []string{"authenticate_user vehicle_data remote_services"}, + "nonce": []string{"login_nonce"}, + "code_challenge_method": []string{"S256"}, + "code_challenge": []string{cv.CodeChallengeS256()}, + "username": []string{v.user}, + "password": []string{v.password}, + "grant_type": []string{"authorization_code"}, + } + + uri := fmt.Sprintf("%s/authenticate", AuthURI) + req, err := request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding) + if err != nil { + return nil, err + } + + var res struct { + RedirectTo string `json:"redirect_to"` + } + + if err := v.DoJSON(req, &res); err != nil { + return nil, err + } + + query, err := url.ParseQuery(strings.TrimPrefix(res.RedirectTo, "redirect_uri=com.bmw.connected://oauth?")) + if err != nil { + return nil, err + } + + auth := query.Get("authorization") + if auth == "" { + return nil, errors.New("authorization code not found") + } + + data.Set("authorization", auth) + delete(data, "username") + delete(data, "password") + delete(data, "grant_type") + + req, err = request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding) + if err != nil { + return nil, err + } resp, err := v.Do(req) if err != nil { return nil, err } - defer resp.Body.Close() + resp.Body.Close() - if resp.StatusCode >= http.StatusBadRequest { - var desc struct { - Error string - ErrorDescription string `json:"error_description"` - } - - if err := json.NewDecoder(resp.Body).Decode(&desc); err != nil { - return nil, fmt.Errorf("could not obtain token") - } - - return nil, fmt.Errorf("%s:%s", desc.Error, desc.ErrorDescription) + uri = resp.Header.Get("Location") + if uri == "" { + return nil, errors.New("authorization code not found") } - uri := resp.Header.Get("Location") - - query, err := url.ParseQuery(strings.TrimPrefix(uri, "com.bmw.connected://oauth#")) + query, err = url.ParseQuery(strings.TrimPrefix(uri, "com.bmw.connected://oauth?")) if err != nil { return nil, err } - at := query.Get("access_token") - expires, err := strconv.Atoi(query.Get("expires_in")) - if err != nil || at == "" || expires == 0 { - return nil, errors.New("could not obtain token") + data = url.Values{ + "code": []string{query.Get("code")}, + "redirect_uri": []string{RedirectURI}, + "grant_type": []string{"authorization_code"}, + "code_verifier": []string{cv.CodeChallengePlain()}, } - token := &oauth2.Token{ - AccessToken: at, - Expiry: time.Now().Add(time.Duration(expires) * time.Second), - } - - return token, nil + return v.retrieveToken(data) }