Volvo Connected: add oauth using generic plugin (#21003)
This commit is contained in:
parent
18de1f0f88
commit
326970a0a4
17 changed files with 367 additions and 292 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
37
cmd/setup.go
37
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
169
plugin/auth/oauth.go
Normal file
169
plugin/auth/oauth.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <auto-name>` 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 <car-name>` 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" . }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
24
vehicle/volvo/connected/oauth2.go
Normal file
24
vehicle/volvo/connected/oauth2.go
Normal file
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue