Volvo Connected: add oauth using generic plugin (#21003)

This commit is contained in:
lehmanju 2025-05-15 18:39:31 +02:00 • committed by GitHub
parent 18de1f0f88
commit 326970a0a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 367 additions and 292 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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",
},
}
}