diff --git a/api/api.go b/api/api.go index 605de8959..45525086b 100644 --- a/api/api.go +++ b/api/api.go @@ -193,9 +193,9 @@ type Tariff interface { // AuthProvider is the ability to provide OAuth authentication through the ui type AuthProvider interface { - SetCallbackParams(baseURL, redirectURL string, authenticated chan<- bool) - LoginHandler() http.HandlerFunc - LogoutHandler() http.HandlerFunc + HandleCallback(r *http.Request) + HandleLogout(r *http.Request) + AuthCodeURL(state string) string } // IconDescriber optionally provides an icon diff --git a/cmd/root.go b/cmd/root.go index f6d4420d8..881ab96b3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,7 +19,6 @@ import ( "github.com/evcc-io/evcc/server/updater" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/auth" - "github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/pipe" "github.com/evcc-io/evcc/util/sponsor" "github.com/evcc-io/evcc/util/telemetry" @@ -293,7 +292,7 @@ func runRoot(cmd *cobra.Command, args []string) { }() // allow web access for vehicles - configureAuth(conf.Network, config.Instances(config.Vehicles().Devices()), httpd.Router(), valueChan) + configureAuth(httpd.Router()) auth := auth.New() if ok, _ := cmd.Flags().GetBool(flagDisableAuth); ok { diff --git a/cmd/setup.go b/cmd/setup.go index 968442f7e..31c7936dc 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "net/http" "os" "regexp" "slices" @@ -1049,7 +1048,7 @@ func configureLoadpoints(conf globalconfig.All) error { } // configureAuth handles routing for devices. For now only api.AuthProvider related routes -func configureAuth(conf globalconfig.Network, vehicles []api.Vehicle, router *mux.Router, paramC chan<- util.Param) { +func configureAuth(router *mux.Router) { auth := router.PathPrefix("/oauth").Subrouter() auth.Use(handlers.CompressHandler) auth.Use(handlers.CORS( @@ -1058,38 +1057,4 @@ func configureAuth(conf globalconfig.Network, vehicles []api.Vehicle, router *mu // wire the handler oauth2redirect.SetupRouter(auth) - - // initialize - authCollection := util.NewAuthCollection(paramC) - - baseURI := conf.URI() - baseAuthURI := fmt.Sprintf("%s/oauth", baseURI) - - var id int - for _, v := range vehicles { - if provider, ok := v.(api.AuthProvider); ok { - id += 1 - - basePath := fmt.Sprintf("vehicles/%d", id) - callbackURI := fmt.Sprintf("%s/%s/callback", baseAuthURI, basePath) - - // register vehicle - ap := authCollection.Register(fmt.Sprintf("oauth/%s", basePath), v.Title()) - - provider.SetCallbackParams(baseURI, callbackURI, ap.Handler()) - - auth. - Methods(http.MethodPost). - Path(fmt.Sprintf("/%s/login", basePath)). - HandlerFunc(provider.LoginHandler()) - auth. - Methods(http.MethodPost). - Path(fmt.Sprintf("/%s/logout", basePath)). - HandlerFunc(provider.LogoutHandler()) - - log.INFO.Printf("ensure the oauth client redirect/callback is configured for %s: %s", v.Title(), callbackURI) - } - } - - authCollection.Publish() } diff --git a/cmd/token.go b/cmd/token.go index f757de7db..3d8f41982 100644 --- a/cmd/token.go +++ b/cmd/token.go @@ -71,8 +71,6 @@ func runToken(cmd *cobra.Command, args []string) { token, err = tronityToken(conf, vehicleConf) case "citroen", "ds", "opel", "peugeot": token, err = psaToken(typ) - case "volvo-connected": - token, err = volvoToken(vehicleConf) default: log.FATAL.Fatalf("vehicle type '%s' does not support token authentication", vehicleConf.Type) diff --git a/cmd/token_volvo.go b/cmd/token_volvo.go deleted file mode 100644 index 7d5b600d9..000000000 --- a/cmd/token_volvo.go +++ /dev/null @@ -1,63 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - - "github.com/AlecAivazis/survey/v2" - "github.com/evcc-io/evcc/util" - "github.com/evcc-io/evcc/util/config" - "github.com/evcc-io/evcc/util/request" - "github.com/evcc-io/evcc/vehicle" - "github.com/evcc-io/evcc/vehicle/volvo/connected" - "github.com/samber/lo" - "golang.org/x/oauth2" -) - -func volvoToken(conf config.Named) (*oauth2.Token, error) { - var cc struct { - Credentials vehicle.ClientCredentials - } - - if err := util.DecodeOther(conf.Other, &cc); err != nil { - return nil, err - } - - if cc.Credentials.ID == "" { - if err := survey.AskOne(&survey.Input{ - Message: "Please enter your client id:", - }, &cc.Credentials.ID, survey.WithValidator(survey.Required)); err != nil { - return nil, err - } - } - - if cc.Credentials.Secret == "" { - if err := survey.AskOne(&survey.Input{ - Message: "Please enter your client secret:", - }, &cc.Credentials.Secret, survey.WithValidator(survey.Required)); err != nil { - return nil, err - } - } - - oc := connected.Oauth2Config(cc.Credentials.ID, cc.Credentials.Secret) - cv := oauth2.GenerateVerifier() - - state := lo.RandomString(16, lo.AlphanumericCharset) - authorize_url := oc.AuthCodeURL(state, 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("volvo-connected")) - ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client) - - return oc.Exchange(ctx, code, oauth2.VerifierOption(cv)) -} diff --git a/plugin/auth/api.go b/plugin/auth/api.go index ec2e235e3..26e4304f9 100644 --- a/plugin/auth/api.go +++ b/plugin/auth/api.go @@ -1,7 +1,9 @@ package auth -import "net/http" +import ( + "net/http" +) type Authorizer interface { - Transport(base http.RoundTripper) (http.RoundTripper, error) + Transport(base http.RoundTripper) http.RoundTripper } diff --git a/plugin/auth/nop.go b/plugin/auth/nop.go index e9cf17f30..5d829a868 100644 --- a/plugin/auth/nop.go +++ b/plugin/auth/nop.go @@ -22,6 +22,6 @@ func NewNopFromConfig(ctx context.Context, other map[string]any) (Authorizer, er return new(nop), nil } -func (p *nop) Transport(base http.RoundTripper) (http.RoundTripper, error) { - return base, nil +func (p *nop) Transport(base http.RoundTripper) http.RoundTripper { + return base } diff --git a/plugin/auth/oauth.go b/plugin/auth/oauth.go new file mode 100644 index 000000000..8861277ad --- /dev/null +++ b/plugin/auth/oauth.go @@ -0,0 +1,169 @@ +package auth + +// TODO +// - configurable redirect uri + +import ( + "context" + "net/http" + "strings" + "sync" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/server/db/settings" + "github.com/evcc-io/evcc/server/oauth2redirect" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" + "golang.org/x/oauth2" +) + +type OAuth struct { + oauth2.TokenSource + mu sync.Mutex + cc oauth2.Config + subject string + cv string + log *util.Logger + ctx context.Context +} + +var ( + oauthMu sync.Mutex + identities = make(map[string]*OAuth) +) + +func getInstance(subject string) *OAuth { + return identities[subject] +} + +func addInstance(subject string, identity *OAuth) { + identities[subject] = identity +} + +func init() { + registry.AddCtx("oauth", NewOauthFromConfig) +} + +func NewOauthFromConfig(ctx context.Context, other map[string]any) (Authorizer, error) { + oauthMu.Lock() + defer oauthMu.Unlock() + // parse oauth config from yaml + var cc oauth2.Config + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + return NewOauth(ctx, cc) +} + +func NewOauth(ctx context.Context, cc oauth2.Config) (*OAuth, error) { + // TODO subject should include hash of complete oauth2 config + subject := "oauth." + cc.ClientID + + // reuse instance + if instance := getInstance(subject); instance != nil { + return instance, nil + } + + log := util.NewLogger("oauth-generic") + + // create new instance + o := &OAuth{ + subject: subject, + cc: cc, + log: log, + ctx: ctx, + } + + // load token from db + var tok oauth2.Token + if settings.Exists(o.subject) { + if err := settings.Json(o.subject, &tok); err != nil { + return nil, err + } + } + + o.TokenSource = oauth.RefreshTokenSource(&tok, o) + + // add instance + addInstance(o.subject, o) + + // register authredirect + oauth2redirect.Register(o, subject) + + return o, nil +} + +func (o *OAuth) Transport(base http.RoundTripper) http.RoundTripper { + transport := oauth2.Transport{ + Base: base, + Source: o, + } + return &transport +} + +// RefreshToken implements oauth.RefreshTokenSource. +func (o *OAuth) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) { + o.mu.Lock() + defer o.mu.Unlock() + + if token.RefreshToken == "" { + return nil, api.ErrMissingToken + } + + // refresh token source + token, err := o.cc.TokenSource(o.ctx, token).Token() + if err != nil { + if strings.Contains(err.Error(), "invalid_grant") { + if settings.Exists(o.subject) { + settings.Delete(o.subject) + } + } + return nil, err + } + err = settings.SetJson(o.subject, token) + + return token, err +} + +// AuthCodeURL implements api.AuthProvider. +func (o *OAuth) AuthCodeURL(state string) string { + o.mu.Lock() + defer o.mu.Unlock() + + o.cv = oauth2.GenerateVerifier() + return o.cc.AuthCodeURL(state, oauth2.S256ChallengeOption(o.cv)) +} + +// HandleCallback implements api.AuthProvider. +func (o *OAuth) HandleCallback(r *http.Request) { + q := r.URL.Query() + code := q.Get("code") + + o.mu.Lock() + defer o.mu.Unlock() + + token, err := o.cc.Exchange(o.ctx, code, oauth2.VerifierOption(o.cv)) + if err != nil { + o.log.ERROR.Printf("error during oauth exchange: %s", err) + return + } + err = settings.SetJson(o.subject, token) + if err != nil { + o.log.ERROR.Printf("error saving token: %s", err) + } + + o.TokenSource = oauth.RefreshTokenSource(token, o) +} + +// HandleLogout implements api.AuthProvider. +func (o *OAuth) HandleLogout(r *http.Request) { + o.log.INFO.Printf("removing %s from database", o.subject) + if settings.Exists(o.subject) { + settings.Delete(o.subject) + } + + o.mu.Lock() + defer o.mu.Unlock() + o.TokenSource = oauth.RefreshTokenSource(nil, o) +} diff --git a/plugin/http_auth.go b/plugin/http_auth.go index db17c94cf..1595d273b 100644 --- a/plugin/http_auth.go +++ b/plugin/http_auth.go @@ -52,6 +52,6 @@ func (p *Auth) Transport(ctx context.Context, log *util.Logger, base http.RoundT return nil, err } - return authorizer.Transport(base) + return authorizer.Transport(base), nil } } diff --git a/server/oauth2redirect/redirect.go b/server/oauth2redirect/redirect.go index 246953fac..f19d5e1e5 100644 --- a/server/oauth2redirect/redirect.go +++ b/server/oauth2redirect/redirect.go @@ -2,11 +2,14 @@ package oauth2redirect import ( "crypto/rand" + "errors" "fmt" "io" "net/http" "sync" + "time" + "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" "github.com/gorilla/mux" ) @@ -18,52 +21,128 @@ var instance *Handler // On GET request the generic handler identifies route and target handler // by request state obtained from the request and delegates to the registered handler. type Handler struct { - mu sync.Mutex - secret []byte - routes map[string]http.HandlerFunc -} - -func generateSecret() ([]byte, error) { - var b [16]byte - _, err := io.ReadFull(rand.Reader, b[:]) - return b[:], err + mu sync.Mutex + secret []byte + providers map[string]api.AuthProvider + states map[string]string + log *util.Logger } func init() { - secret, err := generateSecret() + var secret [16]byte + _, err := io.ReadFull(rand.Reader, secret[:]) + if err != nil { panic(err) } instance = &Handler{ - secret: secret, - routes: make(map[string]http.HandlerFunc), + secret: secret[:], + providers: make(map[string]api.AuthProvider), + states: make(map[string]string), + log: util.NewLogger("oauth2redirect"), } } // SetupRouter connects the redirect handler to the router func SetupRouter(router *mux.Router) { - router.Methods(http.MethodGet).HandlerFunc(instance.handle) + // callback?code=...&state=... + router.Methods(http.MethodGet).Path("/callback").HandlerFunc(instance.handleCallback) + // login?id=... + router.Methods(http.MethodGet).Path("/login").HandlerFunc(instance.handleLogin) + // logout?id=... + router.Methods(http.MethodGet).Path("/logout").HandlerFunc(instance.handleLogout) } -// Register registers a specific handler with the redirect handler -func Register(handler http.HandlerFunc) string { - return instance.register(handler) +// Register registers a specific AuthProvider. Returns login path as string. +func Register(handler api.AuthProvider, name string) (string, error) { + return instance.register(handler, name) } -func (a *Handler) register(handler http.HandlerFunc) string { +func (a *Handler) register(handler api.AuthProvider, name string) (string, error) { a.mu.Lock() defer a.mu.Unlock() - state := util.NewState() - key := state.Encrypt(a.secret) - - a.routes[key] = handler - - return key + if a.providers[name] != nil { + a.log.ERROR.Printf("provider with name %s already registered", name) + return "", errors.New("provider already registered") + } + a.log.INFO.Printf("registering oauth provider at /oauth/login?id=%s", name) + a.providers[name] = handler + return "/oauth/login?id=" + name, nil } -func (a *Handler) handle(w http.ResponseWriter, r *http.Request) { +func (a *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { + // Find corresponding provider + q := r.URL.Query() + id := q.Get("id") + if id == "" { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "missing id") + return + } + + a.mu.Lock() + defer a.mu.Unlock() + + provider, ok := a.providers[id] + if !ok { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "invalid id") + return + } + + // Generate a new state and store the provider + state := util.NewState() + encryptedState := state.Encrypt(a.secret) + a.states[encryptedState] = id + + // Schedule cleanup for stale state entries after state becomes invalid + go func(state string) { + time.Sleep(util.StateValidity) + a.mu.Lock() + defer a.mu.Unlock() + delete(a.states, state) + }(encryptedState) + + // Build authorization URL + loginURL := provider.AuthCodeURL(encryptedState) + if loginURL == "" { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "invalid login URL") + return + } + + http.Redirect(w, r, loginURL, http.StatusFound) +} + +func (a *Handler) handleLogout(w http.ResponseWriter, r *http.Request) { + // Find corresponding provider + q := r.URL.Query() + id := q.Get("id") + if id == "" { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "missing id") + return + } + + a.mu.Lock() + defer a.mu.Unlock() + + provider, ok := a.providers[id] + if !ok { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "invalid id") + return + } + + // Handle logout + provider.HandleLogout(r) + + http.Redirect(w, r, "/", http.StatusFound) +} + +func (a *Handler) handleCallback(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() if q.Has("error") { @@ -72,7 +151,8 @@ func (a *Handler) handle(w http.ResponseWriter, r *http.Request) { return } - state, err := util.DecryptState(q.Get("state"), a.secret) + encryptedState := q.Get("state") + state, err := util.DecryptState(encryptedState, a.secret) if err != nil { w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, "failed to decrypt state") @@ -86,14 +166,28 @@ func (a *Handler) handle(w http.ResponseWriter, r *http.Request) { } a.mu.Lock() - handler := a.routes[q.Get("state")] - a.mu.Unlock() + defer a.mu.Unlock() - if handler == nil { + // Find the corresponding provider + id, ok := a.states[encryptedState] + if !ok { w.WriteHeader(http.StatusBadRequest) - fmt.Fprintf(w, "no handler found") + fmt.Fprintf(w, "no provider found for state") return } - handler(w, r) + provider, ok := a.providers[id] + if !ok { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "internal provider state unexpected") + return + } + + // Remove the state from the map + delete(a.states, encryptedState) + + // Handle the callback + provider.HandleCallback(r) + + http.Redirect(w, r, "/", http.StatusFound) } diff --git a/templates/definition/vehicle/volvo-connected.yaml b/templates/definition/vehicle/volvo-connected.yaml index f27405e80..c5cabf278 100644 --- a/templates/definition/vehicle/volvo-connected.yaml +++ b/templates/definition/vehicle/volvo-connected.yaml @@ -6,24 +6,28 @@ requirements: de: | Für die Nutzung mit EVCC benötigst du einen Volvo Account und einen Volvo Connected Car API Key. Erstelle dazu auf der [Account Seite](https://developer.volvocars.com/account/) eine neue Applikation und speichere den primären VCC API Key ab. - Veröffentliche nun deine Applikation und wähle unter "Scopes" die Berechtigungen "Connected Vehicle API -> conve:vehicle_relation" und "Energy API -> (alles)" aus. - Als Redirect URL kannst du erstmal "http://localhost:9999" (oder etwas anderes nicht erreichbares) verwenden. Der Authorisierungscode wird später händisch aus dem Browser gelesen und eingegeben. + Veröffentliche nun deine Applikation und wähle unter "Scopes" die Berechtigungen "Connected Vehicle API -> conve:vehicle-relation" und "Energy API -> (alles)" aus. + Als Redirect URL musst du die URL deiner EVCC Instanz eintragen, zb "http://evcc.local:7070/oauth/callback". Sobald die Applikation erstellt ist, wird sie als "Publication under Review" angezeigt. Das ist nicht weiter schlimm, es funktioniert trotzdem. - Erstelle danach einen Token mit `evcc token ` und speichere die Tokens in der Konfiguration. Falls Evcc wegen einem invaliden Refresh Token abbricht, lösche die Tokens mit `evcc settings` und generiere sie neu. + Beim Anlegen des Fahrzeugs über die UI wird ein Fehler angezeigt. + Schaue im Log nach der Meldung "registering oauth provider at /oauth/login?..." und öffne den Link "http://evcc.local:7070/oauth/login?..." in einem neuen Tab. + Melde dich mit deinem Volvo Account an und erlaube den Zugriff auf die Daten. Ist die Autorisierung erfolgreich, kann das Fahrzeug hinzugefügt werden. en: | To use with EVCC, you need a Volvo account and a Volvo Connected Car API Key. To do this, create a new application on the [Account page](https://developer.volvocars.com/account/) and save the primary VCC API key. - Now publish your application and select the permissions "Connected Vehicle API -> conve:vehicle_relation" and "Energy API -> (everything)" under "Scopes". - You can use "http://localhost:9999" (or something else unreachable) as the redirect URL for now. The authorization code will be read and entered manually from the browser later. + Now publish your application and select the permissions "Connected Vehicle API -> conve:vehicle-relation" and "Energy API -> (everything)" under "Scopes". + You must enter the URL of your EVCC instance as the redirect URL, e.g. "http://evcc.local:7070/oauth/callback". Once the application is created, it will be displayed as "Publication under Review". This is not a problem, it still works. - Then create a token with `evcc token ` and save the tokens in the configuration. If Evcc crashes due to an invalid refresh token, delete the tokens with `evcc settings` and generate them again. + When adding the vehicle via the UI, an error message is displayed. + Check the log for the message "registering oauth provider at /oauth/login?..." and open the link "http://evcc.local:7070/oauth/login?..." in a new tab. + Log in with your Volvo account and allow access to the data. If the authorization is successful, the vehicle can be added. params: - preset: vehicle-common - name: vccapikey required: true help: - en: "Volvo developer portal VCC API Key" - de: "Volvo developer portal VCC API Key" + en: "VCC API Key of your [Volvo Developer App](https://developer.volvocars.com/)." + de: "VCC API Key deiner [Volvo Developer App](https://developer.volvocars.com/)." - name: clientId required: true help: @@ -34,12 +38,11 @@ params: help: en: "Client Secret of your [Volvo Developer App](https://developer.volvocars.com/)." de: "Client Secret deiner [Volvo Developer App](https://developer.volvocars.com/)." - - name: accessToken + - name: redirectUri required: true - mask: true - - name: refreshToken - required: true - mask: true + help: + en: "Redirect URI of your EVCC instance, format: `http://evcc.local:7070/oauth/callback`. Must match the redirect URI set in your Volvo Developer App." + de: "Redirect-URI deiner EVCC-Instanz, Format: http://evcc.local:7070/oauth/callback. Muss mit der Redirect-URI übereinstimmen, die in deiner Volvo Developer App festgelegt ist." - name: vin example: WF0FXX... render: | @@ -48,8 +51,6 @@ render: | credentials: id: {{ .clientId }} secret: {{ .clientSecret }} - tokens: - access: {{ .accessToken }} - refresh: {{ .refreshToken }} + redirecturi: {{ .redirectUri }} vin: {{ .vin }} {{ include "vehicle-common" . }} diff --git a/util/state.go b/util/state.go index 0b0f60e82..326daa18f 100644 --- a/util/state.go +++ b/util/state.go @@ -14,7 +14,7 @@ import ( var ErrStateExpired = fmt.Errorf("state expired") -const stateValidity = 2 * time.Minute +const StateValidity = 2 * time.Minute type State struct { Time time.Time @@ -85,7 +85,7 @@ func (c *State) Encrypt(key []byte) string { } func (c *State) Validate() error { - if time.Since(c.Time) > stateValidity { + if time.Since(c.Time) <= StateValidity { return nil } diff --git a/vehicle/volvo-connected.go b/vehicle/volvo-connected.go index 96ef7bb9b..8c69c96ed 100644 --- a/vehicle/volvo-connected.go +++ b/vehicle/volvo-connected.go @@ -1,11 +1,15 @@ package vehicle import ( + "context" "time" "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/plugin/auth" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" "github.com/evcc-io/evcc/vehicle/volvo/connected" + "golang.org/x/oauth2" ) // VolvoConnected is an api.Vehicle implementation for Volvo Connected Car vehicles @@ -25,7 +29,7 @@ func NewVolvoConnectedFromConfig(other map[string]interface{}) (api.Vehicle, err VIN string VccApiKey string Credentials ClientCredentials - Tokens Tokens + RedirectUri string Cache time.Duration }{ Cache: interval, @@ -35,21 +39,17 @@ func NewVolvoConnectedFromConfig(other map[string]interface{}) (api.Vehicle, err return nil, err } - log := util.NewLogger("volvo-connected").Redact(cc.VIN, cc.VccApiKey, cc.Tokens.Access, cc.Tokens.Refresh) + log := util.NewLogger("volvo-connected").Redact(cc.VIN, cc.VccApiKey) - oc := connected.Oauth2Config(cc.Credentials.ID, cc.Credentials.Secret) - - token, err := cc.Tokens.Token() + // create oauth2 config + config := connected.Oauth2Config(cc.Credentials.ID, cc.Credentials.Secret, cc.RedirectUri) + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewClient(log)) + authorizer, err := auth.NewOauth(ctx, *config) if err != nil { return nil, err } - ts, err := connected.NewIdentity(log, oc, token) - if err != nil { - return nil, err - } - - api := connected.NewAPI(log, ts, cc.VccApiKey) + api := connected.NewAPI(log, cc.VccApiKey, authorizer) cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles) diff --git a/vehicle/volvo/connected/api.go b/vehicle/volvo/connected/api.go index 697710d71..be89beb12 100644 --- a/vehicle/volvo/connected/api.go +++ b/vehicle/volvo/connected/api.go @@ -4,11 +4,11 @@ import ( "fmt" "net/http" + "github.com/evcc-io/evcc/plugin/auth" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" "github.com/evcc-io/evcc/util/transport" "github.com/samber/lo" - "golang.org/x/oauth2" ) // api constants @@ -22,21 +22,18 @@ type API struct { } // NewAPI creates a new api client -func NewAPI(log *util.Logger, identity oauth2.TokenSource, vccapikey string) *API { +func NewAPI(log *util.Logger, vccapikey string, authorizer auth.Authorizer) *API { v := &API{ Helper: request.NewHelper(log), } - // replace client transport with authenticated transport - v.Client.Transport = &oauth2.Transport{ - Source: identity, - Base: &transport.Decorator{ - Base: v.Client.Transport, - Decorator: transport.DecorateHeaders(map[string]string{ - "vcc-api-key": vccapikey, - }), - }, + decoratedTransport := &transport.Decorator{ + Base: v.Client.Transport, + Decorator: transport.DecorateHeaders(map[string]string{ + "vcc-api-key": vccapikey, + }), } + v.Client.Transport = authorizer.Transport(decoratedTransport) return v } diff --git a/vehicle/volvo/connected/helper.go b/vehicle/volvo/connected/helper.go deleted file mode 100644 index b74639ab4..000000000 --- a/vehicle/volvo/connected/helper.go +++ /dev/null @@ -1,20 +0,0 @@ -package connected - -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/volvo/connected/identity.go b/vehicle/volvo/connected/identity.go deleted file mode 100644 index faeb97aa4..000000000 --- a/vehicle/volvo/connected/identity.go +++ /dev/null @@ -1,91 +0,0 @@ -package connected - -import ( - "context" - "errors" - "strings" - "sync" - - "github.com/coreos/go-oidc/v3/oidc" - "github.com/evcc-io/evcc/server/db/settings" - "github.com/evcc-io/evcc/util" - "github.com/evcc-io/evcc/util/request" - "golang.org/x/oauth2" -) - -func Oauth2Config(id, secret string) *oauth2.Config { - return &oauth2.Config{ - ClientID: id, - ClientSecret: secret, - RedirectURL: "http://localhost:7070/callback", - Endpoint: oauth2.Endpoint{ - AuthURL: "https://volvoid.eu.volvocars.com/as/authorization.oauth2", - TokenURL: "https://volvoid.eu.volvocars.com/as/token.oauth2", - AuthStyle: oauth2.AuthStyleInHeader, - }, - Scopes: []string{ - oidc.ScopeOpenID, - "conve:vehicle_relation", - "energy:recharge_status", "energy:battery_charge_level", "energy:electric_range", "energy:estimated_charging_time", "energy:charging_connection_status", "energy:charging_system_status", - }, - } -} - -type Identity struct { - ts oauth2.TokenSource - mu sync.Mutex - subject string -} - -func NewIdentity(log *util.Logger, config *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) { - // serialise instance handling - mu.Lock() - defer mu.Unlock() - // reuse instance - subject := "volvo-connected." + strings.ToLower(config.ClientID) - if instance := getInstance(subject); instance != nil { - return instance, nil - } - - v := &Identity{ - subject: subject, - } - - var tok oauth2.Token - if err := settings.Json(v.subject, &tok); err == nil { - token = &tok - } - - client := request.NewClient(log) - ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client) - - v.ts = config.TokenSource(ctx, token) - - if tok, err := v.Token(); err == nil { - token = tok - } else { - return nil, err - } - - if !token.Valid() { - return nil, errors.New("token expired and could not be refreshed") - } - - // add instance - addInstance(v.subject, v) - - return v, nil -} - -func (v *Identity) Token() (*oauth2.Token, error) { - v.mu.Lock() - defer v.mu.Unlock() - - tok, err := v.ts.Token() - if err != nil { - return nil, err - } - err = settings.SetJson(v.subject, tok) - - return tok, err -} diff --git a/vehicle/volvo/connected/oauth2.go b/vehicle/volvo/connected/oauth2.go new file mode 100644 index 000000000..75710836e --- /dev/null +++ b/vehicle/volvo/connected/oauth2.go @@ -0,0 +1,24 @@ +package connected + +import ( + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +func Oauth2Config(id, secret, redirecturi string) *oauth2.Config { + return &oauth2.Config{ + ClientID: id, + ClientSecret: secret, + RedirectURL: redirecturi, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://volvoid.eu.volvocars.com/as/authorization.oauth2", + TokenURL: "https://volvoid.eu.volvocars.com/as/token.oauth2", + AuthStyle: oauth2.AuthStyleInHeader, + }, + Scopes: []string{ + oidc.ScopeOpenID, + "conve:vehicle_relation", + "energy:recharge_status", "energy:battery_charge_level", "energy:electric_range", "energy:estimated_charging_time", "energy:charging_connection_status", "energy:charging_system_status", + }, + } +}