diff --git a/cmd/token.go b/cmd/token.go index 4df390053..05e785a0a 100644 --- a/cmd/token.go +++ b/cmd/token.go @@ -51,11 +51,19 @@ func runToken(cmd *cobra.Command, args []string) { var token *oauth2.Token var err error - switch strings.ToLower(vehicleConf.Type) { + typ := strings.ToLower(vehicleConf.Type) + if typ == "template" { + typ = strings.ToLower(vehicleConf.Other["template"].(string)) + } + + switch typ { case "mercedes": token, err = mercedesToken() case "tronity": token, err = tronityToken(conf, vehicleConf) + case "citroen", "ds", "opel", "peugeot": + token, err = psaToken(typ) + default: log.FATAL.Fatalf("vehicle type '%s' does not support token authentication", vehicleConf.Type) } diff --git a/cmd/token_psa.go b/cmd/token_psa.go new file mode 100644 index 000000000..62e82c7fc --- /dev/null +++ b/cmd/token_psa.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/vehicle/psa" + "golang.org/x/oauth2" +) + +func psaToken(brand string) (*oauth2.Token, error) { + var country string + prompt_country := &survey.Input{ + Message: "Please enter your country code:", + } + if err := survey.AskOne(prompt_country, &country, survey.WithValidator(survey.Required)); err != nil { + return nil, err + } + + cv := oauth2.GenerateVerifier() + oc := psa.Oauth2Config(brand, strings.ToLower(country)) + + authorize_url := oc.AuthCodeURL("", oauth2.S256ChallengeOption(cv)) + + fmt.Println("Please visit: ", authorize_url) + fmt.Println("And grab the authorization code like described here: https://github.com/flobz/psa_car_controller/discussions/779") + + var code string + prompt_code := &survey.Input{ + Message: "Please enter your authorization code:", + } + if err := survey.AskOne(prompt_code, &code, survey.WithValidator(survey.Required)); err != nil { + return nil, err + } + + client := request.NewClient(util.NewLogger(brand)) + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client) + + return oc.Exchange(ctx, code, oauth2.VerifierOption(cv)) +} diff --git a/templates/definition/vehicle/citroen.yaml b/templates/definition/vehicle/citroen.yaml index 421456e8f..59fff56e4 100644 --- a/templates/definition/vehicle/citroen.yaml +++ b/templates/definition/vehicle/citroen.yaml @@ -1,10 +1,44 @@ template: citroen products: - brand: Citroën +requirements: + description: + de: | + Benötigt `access` und `refresh` Tokens. Diese können über den Befehl "evcc token [name]" generiert werden. + en: | + Citroën `access` and `refresh` tokens are required. These can be generated with command "evcc token [name]". params: - - preset: vehicle-base + - name: title + - name: icon + default: car + advanced: true + - name: user + required: true + - name: password + deprecated: true + - name: tokens + required: true + mask: true + help: + en: "See https://docs.evcc.io/en/docs/devices/vehicles#citroen" + de: "Siehe https://docs.evcc.io/docs/devices/vehicles#citroen" + - name: vin + example: V... + - name: capacity + - name: phases + advanced: true + - name: password + deprecated: true - preset: vehicle-identify render: | type: citroen - {{ include "vehicle-base" . }} + title: {{ .title }} + icon: {{ .icon }} + user: {{ .user }} + tokens: + access: {{ .accessToken }} + refresh: {{ .refreshToken }} + capacity: {{ .capacity }} + phases: {{ .phases }} + vin: {{ .vin }} {{ include "vehicle-identify" . }} diff --git a/templates/definition/vehicle/ds.yaml b/templates/definition/vehicle/ds.yaml index 11982df4e..e1c5f5775 100644 --- a/templates/definition/vehicle/ds.yaml +++ b/templates/definition/vehicle/ds.yaml @@ -1,10 +1,44 @@ template: ds products: - brand: DS +requirements: + description: + de: | + Benötigt `access` und `refresh` Tokens. Diese können über den Befehl "evcc token [name]" generiert werden. + en: | + DS `access` and `refresh` tokens are required. These can be generated with command "evcc token [name]". params: - - preset: vehicle-base + - name: title + - name: icon + default: car + advanced: true + - name: user + required: true + - name: password + deprecated: true + - name: tokens + required: true + mask: true + help: + en: "See https://docs.evcc.io/en/docs/devices/vehicles#ds" + de: "Siehe https://docs.evcc.io/docs/devices/vehicles#ds" + - name: vin + example: V... + - name: capacity + - name: phases + advanced: true + - name: password + deprecated: true - preset: vehicle-identify render: | type: ds - {{ include "vehicle-base" . }} + title: {{ .title }} + icon: {{ .icon }} + user: {{ .user }} + tokens: + access: {{ .accessToken }} + refresh: {{ .refreshToken }} + capacity: {{ .capacity }} + phases: {{ .phases }} + vin: {{ .vin }} {{ include "vehicle-identify" . }} diff --git a/templates/definition/vehicle/opel.yaml b/templates/definition/vehicle/opel.yaml index f7370cf4e..7c3401661 100644 --- a/templates/definition/vehicle/opel.yaml +++ b/templates/definition/vehicle/opel.yaml @@ -1,12 +1,44 @@ template: opel products: - brand: Opel +requirements: + description: + de: | + Benötigt `access` und `refresh` Tokens. Diese können über den Befehl "evcc token [name]" generiert werden. + en: | + Opel `access` and `refresh` tokens are required. These can be generated with command "evcc token [name]". params: - - preset: vehicle-base - - preset: vehicle-identify + - name: title + - name: icon + default: car + advanced: true + - name: user + required: true + - name: password + deprecated: true + - name: tokens + required: true + mask: true + help: + en: "See https://docs.evcc.io/en/docs/devices/vehicles#opel" + de: "Siehe https://docs.evcc.io/docs/devices/vehicles#opel" - name: vin - example: WP0... + example: V... + - name: capacity + - name: phases + advanced: true + - name: password + deprecated: true + - preset: vehicle-identify render: | type: opel - {{ include "vehicle-base" . }} + title: {{ .title }} + icon: {{ .icon }} + user: {{ .user }} + tokens: + access: {{ .accessToken }} + refresh: {{ .refreshToken }} + capacity: {{ .capacity }} + phases: {{ .phases }} + vin: {{ .vin }} {{ include "vehicle-identify" . }} diff --git a/templates/definition/vehicle/peugeot.yaml b/templates/definition/vehicle/peugeot.yaml index 4bf8dfb89..c79645e71 100644 --- a/templates/definition/vehicle/peugeot.yaml +++ b/templates/definition/vehicle/peugeot.yaml @@ -1,10 +1,44 @@ template: peugeot products: - brand: Peugeot +requirements: + description: + de: | + Benötigt `access` und `refresh` Tokens. Diese können über den Befehl "evcc token [name]" generiert werden. + en: | + Peugeot `access` and `refresh` tokens are required. These can be generated with command "evcc token [name]". params: - - preset: vehicle-base + - name: title + - name: icon + default: car + advanced: true + - name: user + required: true + - name: password + deprecated: true + - name: tokens + required: true + mask: true + help: + en: "See https://docs.evcc.io/en/docs/devices/vehicles#peugeot" + de: "Siehe https://docs.evcc.io/docs/devices/vehicles#peugeot" + - name: vin + example: V... + - name: capacity + - name: phases + advanced: true + - name: password + deprecated: true - preset: vehicle-identify render: | type: peugeot - {{ include "vehicle-base" . }} + title: {{ .title }} + icon: {{ .icon }} + user: {{ .user }} + tokens: + access: {{ .accessToken }} + refresh: {{ .refreshToken }} + capacity: {{ .capacity }} + phases: {{ .phases }} + vin: {{ .vin }} {{ include "vehicle-identify" . }} diff --git a/vehicle/psa.go b/vehicle/psa.go index e0ddfd488..36d0ea9b8 100644 --- a/vehicle/psa.go +++ b/vehicle/psa.go @@ -1,7 +1,7 @@ package vehicle import ( - "fmt" + "strings" "time" "github.com/evcc-io/evcc/api" @@ -12,46 +12,18 @@ import ( // https://github.com/TA2k/ioBroker.psa func init() { - registry.Add("citroen", NewCitroenFromConfig) - registry.Add("ds", NewDSFromConfig) - registry.Add("opel", NewOpelFromConfig) - registry.Add("peugeot", NewPeugeotFromConfig) -} - -// NewCitroenFromConfig creates a new vehicle -func NewCitroenFromConfig(other map[string]interface{}) (api.Vehicle, error) { - log := util.NewLogger("citroen") - return newPSA(log, - "citroen.com", "clientsB2CCitroen", - "5364defc-80e6-447b-bec6-4af8d1542cae", "iE0cD8bB0yJ0dS6rO3nN1hI2wU7uA5xR4gP7lD6vM0oH0nS8dN", - other) -} - -// NewDSFromConfig creates a new vehicle -func NewDSFromConfig(other map[string]interface{}) (api.Vehicle, error) { - log := util.NewLogger("ds") - return newPSA(log, - "driveds.com", "clientsB2CDS", - "cbf74ee7-a303-4c3d-aba3-29f5994e2dfa", "X6bE6yQ3tH1cG5oA6aW4fS6hK0cR0aK5yN2wE4hP8vL8oW5gU3", - other) -} - -// NewOpelFromConfig creates a new vehicle -func NewOpelFromConfig(other map[string]interface{}) (api.Vehicle, error) { - log := util.NewLogger("opel") - return newPSA(log, - "opel.com", "clientsB2COpel", - "07364655-93cb-4194-8158-6b035ac2c24c", "F2kK7lC5kF5qN7tM0wT8kE3cW1dP0wC5pI6vC0sQ5iP5cN8cJ8", - other) -} - -// NewPeugeotFromConfig creates a new vehicle -func NewPeugeotFromConfig(other map[string]interface{}) (api.Vehicle, error) { - log := util.NewLogger("peugeot") - return newPSA(log, - "peugeot.com", "clientsB2CPeugeot", - "1eebc2d5-5df3-459b-a624-20abfcf82530", "T5tP7iS0cO8sC0lA2iE2aR7gK6uE5rF3lJ8pC3nO1pR7tL8vU1", - other) + registry.Add("citroen", func(other map[string]any) (api.Vehicle, error) { + return newPSA("citroen", "clientsB2CCitroen", other) + }) + registry.Add("ds", func(other map[string]any) (api.Vehicle, error) { + return newPSA("ds", "clientsB2CDS", other) + }) + registry.Add("opel", func(other map[string]any) (api.Vehicle, error) { + return newPSA("opel", "clientsB2COpel", other) + }) + registry.Add("peugeot", func(other map[string]any) (api.Vehicle, error) { + return newPSA("peugeot", "clientsB2CPeugeot", other) + }) } // PSA is an api.Vehicle implementation for PSA cars @@ -61,17 +33,16 @@ type PSA struct { } // newPSA creates a new vehicle -func newPSA(log *util.Logger, brand, realm, id, secret string, other map[string]interface{}) (api.Vehicle, error) { +func newPSA(brand, realm string, other map[string]interface{}) (api.Vehicle, error) { cc := struct { - embed `mapstructure:",squash"` - Credentials ClientCredentials - User, Password, VIN string - Cache time.Duration + embed `mapstructure:",squash"` + VIN string + User string + Password string `mapstructure:"password"` + Country string + Tokens Tokens + Cache time.Duration }{ - Credentials: ClientCredentials{ - ID: id, - Secret: secret, - }, Cache: interval, } @@ -79,22 +50,30 @@ func newPSA(log *util.Logger, brand, realm, id, secret string, other map[string] return nil, err } - if cc.User == "" || cc.Password == "" { + if cc.User == "" { return nil, api.ErrMissingCredentials } + token, err := cc.Tokens.Token() + if err != nil { + return nil, err + } + v := &PSA{ embed: &cc.embed, } - log.Redact(cc.User, cc.Password, cc.VIN) - identity := psa.NewIdentity(log, brand, cc.Credentials.ID, cc.Credentials.Secret) + log := util.NewLogger(brand) + log.Redact(cc.User, cc.Tokens.Access, cc.Tokens.Refresh) - if err := identity.Login(cc.User, cc.Password); err != nil { - return v, fmt.Errorf("login failed: %w", err) + oc := psa.Oauth2Config(brand, strings.ToLower(cc.Country)) + identity, err := psa.NewIdentity(log, brand, cc.User, oc, token) + if err != nil { + return nil, err } - api := psa.NewAPI(log, identity, realm, cc.Credentials.ID) + // TODO still needed? + api := psa.NewAPI(log, identity, realm, oc.ClientID) vehicle, err := ensureVehicleEx( cc.VIN, api.Vehicles, diff --git a/vehicle/psa/helper.go b/vehicle/psa/helper.go new file mode 100644 index 000000000..c08604257 --- /dev/null +++ b/vehicle/psa/helper.go @@ -0,0 +1,20 @@ +package psa + +import ( + "sync" + + "golang.org/x/oauth2" +) + +var ( + mu sync.Mutex + identities = make(map[string]oauth2.TokenSource) +) + +func getInstance(subject string) oauth2.TokenSource { + return identities[subject] +} + +func addInstance(subject string, identity oauth2.TokenSource) { + identities[subject] = identity +} diff --git a/vehicle/psa/identity.go b/vehicle/psa/identity.go index f239401cf..3437f1599 100644 --- a/vehicle/psa/identity.go +++ b/vehicle/psa/identity.go @@ -2,48 +2,80 @@ package psa import ( "context" - "fmt" + "errors" + "strings" + "sync" + "github.com/evcc-io/evcc/server/db/settings" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" "github.com/evcc-io/evcc/util/request" "golang.org/x/oauth2" ) type Identity struct { - *request.Helper - oc *oauth2.Config oauth2.TokenSource + mu sync.Mutex + oc *oauth2.Config + log *util.Logger + subject string } // NewIdentity creates PSA identity -func NewIdentity(log *util.Logger, brand, id, secret string) *Identity { - return &Identity{ - Helper: request.NewHelper(log), - oc: &oauth2.Config{ - ClientID: id, - ClientSecret: secret, - Endpoint: oauth2.Endpoint{ - AuthURL: "https://api.mpsa.com/api/connectedcar/v2/oauth/authorize", - TokenURL: fmt.Sprintf("https://idpcvs.%s/am/oauth2/access_token", brand), - AuthStyle: oauth2.AuthStyleInHeader, - }, - Scopes: []string{"openid profile"}, - }, - } -} +func NewIdentity(log *util.Logger, brand, user string, oc *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) { + // serialise instance handling + mu.Lock() + defer mu.Unlock() -func (v *Identity) Login(user, password string) error { - ctx := context.WithValue( - context.Background(), - oauth2.HTTPClient, - v.Client, - ) - - // replace client with authenticated oauth client - token, err := v.oc.PasswordCredentialsToken(ctx, user, password) - if err == nil { - v.TokenSource = v.oc.TokenSource(ctx, token) + // reuse identity instance + subject := "psa." + strings.ToLower(brand) + "." + strings.ToLower(user) + if instance := getInstance(subject); instance != nil { + return instance, nil } - return err + v := &Identity{ + log: log, + oc: oc, + subject: subject, + } + + var tok oauth2.Token + if err := settings.Json(v.subject, &tok); err == nil { + token = &tok + } + + if !token.Valid() { + if tok, err := v.RefreshToken(token); err == nil { + token = tok + } + } + + if !token.Valid() { + return nil, errors.New("token expired") + } + + v.TokenSource = oauth.RefreshTokenSource(token, v) + + // add instance + addInstance(v.subject, v) + + return v, nil +} + +func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) { + v.mu.Lock() + defer v.mu.Unlock() + + client := request.NewClient(v.log) + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client) + + tok, err := v.oc.TokenSource(ctx, token).Token() + if err != nil { + return nil, err + } + + v.TokenSource = oauth.RefreshTokenSource(tok, v) + err = settings.SetJson(v.subject, tok) + + return tok, err } diff --git a/vehicle/psa/oauth2.go b/vehicle/psa/oauth2.go new file mode 100644 index 000000000..6758d2587 --- /dev/null +++ b/vehicle/psa/oauth2.go @@ -0,0 +1,66 @@ +package psa + +import ( + "fmt" + + "golang.org/x/oauth2" +) + +func Oauth2Config(brand, country string) *oauth2.Config { + switch brand { + case "citroen": + return &oauth2.Config{ + ClientID: "5364defc-80e6-447b-bec6-4af8d1542cae", + ClientSecret: "iE0cD8bB0yJ0dS6rO3nN1hI2wU7uA5xR4gP7lD6vM0oH0nS8dN", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://idpcvs.citroen.com/am/oauth2/authorize", + TokenURL: "https://idpcvs.citroen.com/am/oauth2/access_token", + AuthStyle: oauth2.AuthStyleInHeader, + }, + RedirectURL: fmt.Sprintf("mymacsdk://oauth2redirect/%s", country), + Scopes: []string{"openid", "profile"}, + } + + case "ds": + return &oauth2.Config{ + ClientID: "cbf74ee7-a303-4c3d-aba3-29f5994e2dfa", + ClientSecret: "X6bE6yQ3tH1cG5oA6aW4fS6hK0cR0aK5yN2wE4hP8vL8oW5gU3", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://idpcvs.driveds.com/am/oauth2/authorize", + TokenURL: "https://idpcvs.driveds.com/am/oauth2/access_token", + AuthStyle: oauth2.AuthStyleInHeader, + }, + RedirectURL: fmt.Sprintf("mymdssdk://oauth2redirect/%s", country), + Scopes: []string{"openid", "profile"}, + } + + case "opel": + return &oauth2.Config{ + ClientID: "07364655-93cb-4194-8158-6b035ac2c24c", + ClientSecret: "F2kK7lC5kF5qN7tM0wT8kE3cW1dP0wC5pI6vC0sQ5iP5cN8cJ8", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://idpcvs.opel.com/am/oauth2/authorize", + TokenURL: "https://idpcvs.opel.com/am/oauth2/access_token", + AuthStyle: oauth2.AuthStyleInHeader, + }, + RedirectURL: fmt.Sprintf("mymopsdk://oauth2redirect/%s", country), + Scopes: []string{"openid", "profile"}, + } + + case "peugeot": + return &oauth2.Config{ + ClientID: "1eebc2d5-5df3-459b-a624-20abfcf82530", + ClientSecret: "T5tP7iS0cO8sC0lA2iE2aR7gK6uE5rF3lJ8pC3nO1pR7tL8vU1", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://idpcvs.peugeot.com/am/oauth2/authorize", + TokenURL: "https://idpcvs.peugeot.com/am/oauth2/access_token", + AuthStyle: oauth2.AuthStyleInHeader, + }, + RedirectURL: fmt.Sprintf("mymap://oauth2redirect/%s", country), + Scopes: []string{"openid", "profile"}, + } + + default: + panic("invalid brand: " + brand) + } +}