Skip to content

Commit

Permalink
Porsche: switch identity provider (evcc-io#8546)
Browse files Browse the repository at this point in the history
  • Loading branch information
andig authored Jun 25, 2023
1 parent 6f90409 commit 2ffb050
Show file tree
Hide file tree
Showing 7 changed files with 114 additions and 136 deletions.
5 changes: 5 additions & 0 deletions util/request/intercept.go → util/request/redirect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions vehicle/bluelink/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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"`
Expand Down
3 changes: 1 addition & 2 deletions vehicle/bmw/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 0 additions & 1 deletion vehicle/ford/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
)

Expand Down
17 changes: 11 additions & 6 deletions vehicle/porsche.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand Down
208 changes: 86 additions & 122 deletions vehicle/porsche/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
resp, err := v.Client.Get(uri)
if err != nil {
return nil, err
return err
}
resp.Body.Close()
defer resp.Body.Close()

query, err := url.ParseQuery(resp.Request.URL.RawQuery)
u, err := url.Parse(resp.Header.Get("Location"))
if err != nil {
return nil, err
return 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"},
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)

// process the auth so the session is authenticated
resp, err = v.PostForm(uri, dataLoginAuth)
uri = fmt.Sprintf("%s/usernamepassword/login", OAuthURI)
resp, err = v.PostForm(uri, query)
if err != nil {
return nil, err
return err
}
resp.Body.Close()

// get the token for the generic API
token, err := v.fetchToken(OAuth2Config)
if err == nil {
v.defaultToken = token
defer resp.Body.Close()

if token, err = v.fetchToken(EmobilityOAuth2Config); err == nil {
v.emobilityToken = token
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)
}

return v.defaultToken, err
}

func (v *Identity) fetchToken(oc *oauth2.Config) (*oauth2.Token, error) {
cv, err := cv.CreateCodeVerifier()
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, err
return 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
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)
}
return nil
}
})

resp, err := v.Client.Get(uri)
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 nil, err
return err
}
resp.Body.Close()

query, err := url.ParseQuery(resp.Request.URL.RawQuery)
code, err := param()
if err != nil {
return nil, err
}

code := query.Get("code")
if code == "" {
return nil, errors.New("no auth code")
return err
}

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()),
)
if err != nil {
return err
}

return token, err
}

type emobilityAdapter struct {
tr *Identity
}
v.TokenSource = oc.TokenSource(cctx, token)

func (v *emobilityAdapter) RefreshToken(_ *oauth2.Token) (*oauth2.Token, error) {
token, err := v.tr.RefreshToken(nil)
if err == nil {
token = v.tr.emobilityToken
}
return token, err
return nil
}
Loading

0 comments on commit 2ffb050

Please sign in to comment.