Porsche: switch identity provider (#8546)

This commit is contained in:
andig 2023-06-25 13:01:57 +02:00 • committed by GitHub
parent 6f90409a3c
commit 2ffb050fb4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 133 additions and 155 deletions

View file

@ -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

View file

@ -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"`

View file

@ -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()

View file

@ -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()),
)

View file

@ -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,

View file

@ -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
}

View file

@ -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
}