From 2ffb050fb440a28b8d58aaa701834008c0de1ad5 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 25 Jun 2023 13:01:57 +0200 Subject: [PATCH] Porsche: switch identity provider (#8546) --- util/request/{intercept.go => redirect.go} | 5 + vehicle/bluelink/identity.go | 4 +- vehicle/bmw/identity.go | 3 +- vehicle/ford/identity.go | 1 - vehicle/porsche.go | 17 +- vehicle/porsche/identity.go | 246 +++++++++------------ vehicle/porsche/provider.go | 12 +- 7 files changed, 133 insertions(+), 155 deletions(-) rename util/request/{intercept.go => redirect.go} (79%) diff --git a/util/request/intercept.go b/util/request/redirect.go similarity index 79% rename from util/request/intercept.go rename to util/request/redirect.go index 7bb6d3a35..57830054a 100644 --- a/util/request/intercept.go +++ b/util/request/redirect.go @@ -5,6 +5,11 @@ import ( "net/http" ) +// DontFollow is a redirect policy that does not follow redirects +func DontFollow(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse +} + type InterceptResult = func() (string, error) // InterceptRedirect captures a redirect url parameter diff --git a/vehicle/bluelink/identity.go b/vehicle/bluelink/identity.go index d3cd9eac8..580efeb8a 100644 --- a/vehicle/bluelink/identity.go +++ b/vehicle/bluelink/identity.go @@ -181,7 +181,7 @@ func (v *Identity) brandLogin(cookieClient *request.Helper, user, password strin req, err = request.New(http.MethodPost, action, strings.NewReader(data.Encode()), request.URLEncoding) if err == nil { - cookieClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } // don't follow redirects + cookieClient.CheckRedirect = request.DontFollow if resp, err = cookieClient.Do(req); err == nil { defer resp.Body.Close() @@ -217,7 +217,7 @@ func (v *Identity) brandLogin(cookieClient *request.Helper, user, password strin req, err = request.New(http.MethodPost, v.config.URI+SilentSigninURL, request.MarshalJSON(data), request.JSONEncoding) if err == nil { req.Header.Set("ccsp-service-id", v.config.CCSPServiceID) - cookieClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } // don't follow redirects + cookieClient.CheckRedirect = request.DontFollow var res struct { RedirectUrl string `json:"redirectUrl"` diff --git a/vehicle/bmw/identity.go b/vehicle/bmw/identity.go index 32abfdae9..2e47a43e4 100644 --- a/vehicle/bmw/identity.go +++ b/vehicle/bmw/identity.go @@ -91,8 +91,7 @@ func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) { } 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 } + v.Client.CheckRedirect = request.DontFollow defer func() { v.Client.CheckRedirect = nil }() cv, err := cv.CreateCodeVerifier() diff --git a/vehicle/ford/identity.go b/vehicle/ford/identity.go index fbf3acf52..4a28c4c0c 100644 --- a/vehicle/ford/identity.go +++ b/vehicle/ford/identity.go @@ -144,7 +144,6 @@ func (v *Identity) login() (*oauth.Token, error) { } tok, err := OAuth2Config.Exchange(ctx, code, - oauth2.SetAuthURLParam("grant_type", "authorization_code"), oauth2.SetAuthURLParam("code_verifier", cv.CodeChallengePlain()), ) diff --git a/vehicle/porsche.go b/vehicle/porsche.go index 0eb50d0b5..acb0e5e29 100644 --- a/vehicle/porsche.go +++ b/vehicle/porsche.go @@ -40,14 +40,14 @@ func NewPorscheFromConfig(other map[string]interface{}) (api.Vehicle, error) { } log := util.NewLogger("porsche").Redact(cc.User, cc.Password, cc.VIN) - identity := porsche.NewIdentity(log, cc.User, cc.Password) + identity := porsche.NewIdentity(log) - err := identity.Login() + err := identity.Login(porsche.OAuth2Config, cc.User, cc.Password) if err != nil { return nil, fmt.Errorf("login failed: %w", err) } - api := porsche.NewAPI(log, identity.DefaultSource) + api := porsche.NewAPI(log, identity) cc.VIN, err = ensureVehicle(cc.VIN, func() ([]string, error) { vehicles, err := api.Vehicles() @@ -66,13 +66,18 @@ func NewPorscheFromConfig(other map[string]interface{}) (api.Vehicle, error) { } // get eMobility capabilities - emobility := porsche.NewEmobilityAPI(log, identity.EmobilitySource) - capabilities, err := emobility.Capabilities(cc.VIN) + emobIdentity := porsche.NewIdentity(log) + if err := emobIdentity.Login(porsche.EmobilityOAuth2Config, cc.User, cc.Password); err != nil { + return nil, fmt.Errorf("emobility login failed: %w", err) + } + + emobApi := porsche.NewEmobilityAPI(log, emobIdentity) + capabilities, err := emobApi.Capabilities(cc.VIN) if err != nil { return nil, err } - provider := porsche.NewProvider(log, api, emobility, cc.VIN, capabilities.CarModel, cc.Cache) + provider := porsche.NewProvider(log, api, emobApi, cc.VIN, capabilities.CarModel, cc.Cache) v := &Porsche{ embed: &cc.embed, diff --git a/vehicle/porsche/identity.go b/vehicle/porsche/identity.go index 69657b7f7..d8dfb80e9 100644 --- a/vehicle/porsche/identity.go +++ b/vehicle/porsche/identity.go @@ -2,203 +2,167 @@ package porsche import ( "context" + "encoding/json" "errors" "fmt" "net/http" "net/http/cookiejar" "net/url" + "github.com/PuerkitoBio/goquery" "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" + "github.com/samber/lo" "golang.org/x/oauth2" ) const ( - OAuthURI = "https://login.porsche.com" + OAuthURI = "https://identity.porsche.com" ) -// https://login.porsche.com/.well-known/openid-configuration +// https://identity.porsche.com/.well-known/openid-configuration var ( + endpoint = oauth2.Endpoint{ + AuthURL: OAuthURI + "/authorize", + TokenURL: OAuthURI + "/oauth/token", + AuthStyle: oauth2.AuthStyleInParams, + } + OAuth2Config = &oauth2.Config{ - ClientID: "4mPO3OE5Srjb1iaUGWsbqKBvvesya8oA", - RedirectURL: "https://my.porsche.com/core/de/de_DE/", - Endpoint: oauth2.Endpoint{ - AuthURL: OAuthURI + "/as/authorization.oauth2", - TokenURL: OAuthURI + "/as/token.oauth2", - }, - Scopes: []string{"openid"}, + ClientID: "UYsK00My6bCqJdbQhTQ0PbWmcSdIAMig", + RedirectURL: "https://my.porsche.com/", + Endpoint: endpoint, + Scopes: []string{"openid", "offline_access"}, } EmobilityOAuth2Config = &oauth2.Config{ - ClientID: "NJOxLv4QQNrpZnYQbb7mCvdiMxQWkHDq", + ClientID: OAuth2Config.ClientID, RedirectURL: "https://my.porsche.com/myservices/auth/auth.html", - Endpoint: OAuth2Config.Endpoint, - Scopes: []string{"openid"}, + Endpoint: endpoint, + Scopes: []string{"openid", "offline_access"}, } ) // Identity is the Porsche Identity client type Identity struct { *request.Helper - user, password string - defaultToken, emobilityToken *oauth2.Token - DefaultSource, EmobilitySource oauth2.TokenSource + oauth2.TokenSource } // NewIdentity creates Porsche identity -func NewIdentity(log *util.Logger, user, password string) *Identity { +func NewIdentity(log *util.Logger) *Identity { v := &Identity{ - Helper: request.NewHelper(log), - user: user, - password: password, + Helper: request.NewHelper(log), } return v } -func (v *Identity) Login() error { - _, err := v.RefreshToken(nil) - - if err == nil { - v.DefaultSource = oauth.RefreshTokenSource(v.defaultToken, v) - v.EmobilitySource = oauth.RefreshTokenSource(v.emobilityToken, &emobilityAdapter{v}) - } - - return err -} - -// RefreshToken performs new login and creates default and emobility tokens -func (v *Identity) RefreshToken(_ *oauth2.Token) (*oauth2.Token, error) { - jar, err := cookiejar.New(&cookiejar.Options{ - PublicSuffixList: publicsuffix.List, - }) +func (v *Identity) Login(oc *oauth2.Config, user, password string) error { + cv, err := cv.CreateCodeVerifier() if err != nil { - return nil, err + return err } - // track cookies and follow all (>10) redirects - v.Client.Jar = jar - v.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return nil - } + state := lo.RandomString(16, lo.AlphanumericCharset) + uri := OAuth2Config.AuthCodeURL(state, + oauth2.SetAuthURLParam("audience", ApiURI), + oauth2.SetAuthURLParam("ui_locales", "de-DE"), + oauth2.SetAuthURLParam("code_challenge", cv.CodeChallengeS256()), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ) + + v.Client.Jar, _ = cookiejar.New(nil) + v.Client.CheckRedirect = request.DontFollow defer func() { v.Client.Jar = nil v.Client.CheckRedirect = nil }() - preLogin := url.Values{ - "sec": []string{""}, - "resume": []string{""}, - "thirdPartyId": []string{""}, - "state": []string{""}, - "username": []string{v.user}, - "password": []string{v.password}, - "keeploggedin": []string{"false"}, - } - - // get the login page - uri := fmt.Sprintf("%s/auth/api/v1/de/de_DE/public/login", OAuthURI) - resp, err := v.PostForm(uri, preLogin) - if err != nil { - return nil, err - } - resp.Body.Close() - - query, err := url.ParseQuery(resp.Request.URL.RawQuery) - if err != nil { - return nil, err - } - - dataLoginAuth := url.Values{ - "sec": []string{query.Get("sec")}, - "resume": []string{query.Get("resume")}, - "thirdPartyId": []string{query.Get("thirdPartyID")}, - "state": []string{query.Get("state")}, - "username": []string{v.user}, - "password": []string{v.password}, - "keeploggedin": []string{"false"}, - } - - // process the auth so the session is authenticated - resp, err = v.PostForm(uri, dataLoginAuth) - if err != nil { - return nil, err - } - resp.Body.Close() - - // get the token for the generic API - token, err := v.fetchToken(OAuth2Config) - if err == nil { - v.defaultToken = token - - if token, err = v.fetchToken(EmobilityOAuth2Config); err == nil { - v.emobilityToken = token - } - } - - return v.defaultToken, err -} - -func (v *Identity) fetchToken(oc *oauth2.Config) (*oauth2.Token, error) { - cv, err := cv.CreateCodeVerifier() - if err != nil { - return nil, err - } - - uri := oc.AuthCodeURL("uvobn7XJs1", oauth2.AccessTypeOffline, - oauth2.SetAuthURLParam("code_challenge", cv.CodeChallengeS256()), - oauth2.SetAuthURLParam("code_challenge_method", "S256"), - oauth2.SetAuthURLParam("country", "de"), - oauth2.SetAuthURLParam("locale", "de_DE"), - ) - - v.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - if req.URL.Scheme != "https" { - return http.ErrUseLastResponse - } - return nil - } - resp, err := v.Client.Get(uri) if err != nil { - return nil, err + return err + } + defer resp.Body.Close() + + u, err := url.Parse(resp.Header.Get("Location")) + if err != nil { + return err + } + + query := u.Query() + for _, p := range []string{"client_id", "code_challenge", "scope", "protocol"} { + query.Del(p) + } + for k, v := range map[string]string{ + "connection": "Username-Password-Authentication", + "tenant": "porsche-production", + "sec": "high", + } { + query.Set(k, v) + } + query.Set("client_id", oc.ClientID) + query.Set("username", user) + query.Set("password", password) + + uri = fmt.Sprintf("%s/usernamepassword/login", OAuthURI) + resp, err = v.PostForm(uri, query) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var res struct { + Description string `json:"description"` + } + if err := json.NewDecoder(resp.Body).Decode(&res); err == nil && res.Description != "" { + return errors.New(res.Description) + } + return fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return err + } + + query = make(url.Values) + doc.Find("input[type=hidden]").Each(func(_ int, el *goquery.Selection) { + if name, ok := el.Attr("name"); ok { + val, _ := el.Attr("value") + query.Set(name, val) + } + }) + + var param request.InterceptResult + v.Client.CheckRedirect, param = request.InterceptRedirect("code", true) + + uri = fmt.Sprintf("%s/login/callback", OAuthURI) + resp, err = v.PostForm(uri, query) + if err != nil { + return err } resp.Body.Close() - query, err := url.ParseQuery(resp.Request.URL.RawQuery) + code, err := param() if err != nil { - return nil, err + return err } - code := query.Get("code") - if code == "" { - return nil, errors.New("no auth code") - } - - ctx, cancel := context.WithTimeout( - context.WithValue(context.Background(), oauth2.HTTPClient, v.Client), - request.Timeout, - ) + cctx := context.WithValue(context.Background(), oauth2.HTTPClient, v.Client) + ctx, cancel := context.WithTimeout(cctx, request.Timeout) defer cancel() token, err := oc.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", cv.CodeChallengePlain()), ) - - return token, err -} - -type emobilityAdapter struct { - tr *Identity -} - -func (v *emobilityAdapter) RefreshToken(_ *oauth2.Token) (*oauth2.Token, error) { - token, err := v.tr.RefreshToken(nil) - if err == nil { - token = v.tr.emobilityToken + if err != nil { + return err } - return token, err + + v.TokenSource = oc.TokenSource(cctx, token) + + return nil } diff --git a/vehicle/porsche/provider.go b/vehicle/porsche/provider.go index c01aa5f06..5a08176c7 100644 --- a/vehicle/porsche/provider.go +++ b/vehicle/porsche/provider.go @@ -17,10 +17,10 @@ type Provider struct { } // NewProvider creates a vehicle api provider -func NewProvider(log *util.Logger, api *API, emobility *EmobilityAPI, vin, carModel string, cache time.Duration) *Provider { +func NewProvider(log *util.Logger, connect *API, emobility *EmobilityAPI, vin, carModel string, cache time.Duration) *Provider { impl := &Provider{ statusG: provider.Cached(func() (StatusResponse, error) { - return api.Status(vin) + return connect.Status(vin) }, cache), emobilityG: provider.Cached(func() (EmobilityResponse, error) { @@ -31,7 +31,7 @@ func NewProvider(log *util.Logger, api *API, emobility *EmobilityAPI, vin, carMo }, cache), wakeup: func() error { - _, _ = api.Status(vin) + _, _ = connect.Status(vin) if carModel != "" { _, _ = emobility.Status(vin, carModel) } @@ -39,6 +39,12 @@ func NewProvider(log *util.Logger, api *API, emobility *EmobilityAPI, vin, carMo }, } + if emobility == nil { + impl.emobilityG = func() (EmobilityResponse, error) { + return EmobilityResponse{}, api.ErrNotAvailable + } + } + return impl }