Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Volvo: switch to self-hosted Connected Car api #18260

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,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
// SetCallbackParams(baseURL, redirectURL string, authenticated chan<- bool)
LoginHandler(authenticated chan<- bool) http.HandlerFunc
RedirectHandler() http.HandlerFunc
LogoutHandler() http.HandlerFunc
}

Expand Down
14 changes: 8 additions & 6 deletions cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -962,23 +962,25 @@ func configureAuth(conf globalconfig.Network, vehicles []api.Vehicle, router *mu
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
basePath := fmt.Sprintf("vehicles/%d", id)
callbackPath := fmt.Sprintf("%s/callback", basePath)
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())
HandlerFunc(provider.LoginHandler(ap.Handler()))
auth.
Methods(http.MethodGet).
Path(callbackPath).
HandlerFunc(provider.RedirectHandler())
auth.
Methods(http.MethodPost).
Path(fmt.Sprintf("/%s/logout", basePath)).
HandlerFunc(provider.LogoutHandler())

callbackURI := fmt.Sprintf("%s/%s/callback", baseAuthURI, basePath)
log.INFO.Printf("ensure the oauth client redirect/callback is configured for %s: %s", v.Title(), callbackURI)
}
}
Expand Down
7 changes: 2 additions & 5 deletions templates/definition/vehicle/volvo-connected.yaml
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
template: volvo-connected
products:
- brand: Volvo
description:
de: aktuell
en: latest
params:
- preset: vehicle-base
- preset: vehicle-common
- name: vccapikey
required: true
help:
Expand All @@ -14,4 +11,4 @@ params:
render: |
type: volvo-connected
vccapikey: {{ .vccapikey }}
{{ include "vehicle-base" . }}
{{ include "vehicle-common" . }}
1 change: 1 addition & 0 deletions templates/definition/vehicle/volvo.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
template: volvo
deprecated: true
products:
- brand: Volvo
description:
Expand Down
9 changes: 9 additions & 0 deletions vehicle/types.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package vehicle

import (
"crypto/sha256"
"encoding/hex"
"errors"
"time"

Expand All @@ -26,6 +28,13 @@ func (c *ClientCredentials) Error() error {
return nil
}

// Hash returns a hash of the client credentials, including optional key qualifier
func (c *ClientCredentials) Hash(key string) string {
sha256 := sha256.New()
sha256.Write([]byte(c.ID + c.Secret + key))
return hex.EncodeToString(sha256.Sum(nil))
}

// Tokens contains access and refresh tokens
type Tokens struct {
Access, Refresh string
Expand Down
56 changes: 21 additions & 35 deletions vehicle/volvo-connected.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
package vehicle

import (
"fmt"
"os"
"time"

"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/vehicle/volvo/connected"

Check failure on line 10 in vehicle/volvo-connected.go

View workflow job for this annotation

GitHub Actions / Lint

could not import github.com/evcc-io/evcc/vehicle/volvo/connected (-: # github.com/evcc-io/evcc/vehicle/volvo/connected
"golang.org/x/oauth2"
)

// VolvoConnected is an api.Vehicle implementation for Volvo Connected Car vehicles
type VolvoConnected struct {
*embed
*connected.Identity
*connected.Provider
}

Expand All @@ -21,13 +25,13 @@
// NewVolvoConnectedFromConfig creates a new VolvoConnected vehicle
func NewVolvoConnectedFromConfig(other map[string]interface{}) (api.Vehicle, error) {
cc := struct {
embed `mapstructure:",squash"`
User, Password string
VIN string
// ClientID, ClientSecret string
// Sandbox bool
VccApiKey string
Cache time.Duration
embed `mapstructure:",squash"`
User string
VIN string
Credentials ClientCredentials
RedirectUri string
VccApiKey string
Cache time.Duration
}{
Cache: interval,
}
Expand All @@ -36,48 +40,30 @@
return nil, err
}

if cc.User == "" || cc.Password == "" {
return nil, api.ErrMissingCredentials
if err := cc.Credentials.Error(); err != nil {
return nil, err
}

// if cc.ClientID == "" && cc.ClientSecret == "" {
// return nil, errors.New("missing credentials")
// }

// var options []VolvoConnected.IdentityOptions
log := util.NewLogger("volvo-cc").Redact(cc.Credentials.ID, cc.Credentials.Secret, cc.VIN, cc.VccApiKey)

// TODO Load tokens from a persistence storage and use those during startup
// e.g. persistence.Load("key")
// if tokens != nil {
// options = append(options, VolvoConnected.WithToken(&oauth2.Token{
// AccessToken: tokens.Access,
// RefreshToken: tokens.Refresh,
// Expiry: tokens.Expiry,
// }))
// }
oc := connected.Oauth2Config(log, cc.Credentials.ID, cc.Credentials.Secret, cc.RedirectUri)

log := util.NewLogger("volvo-cc").Redact(cc.User, cc.Password, cc.VIN, cc.VccApiKey)
ts := connected.NewIdentity(log, oc)

// identity, err := connected.NewIdentity(log, cc.ClientID, cc.ClientSecret)
identity, err := connected.NewIdentity(log)
if err != nil {
return nil, err
}

ts, err := identity.Login(cc.User, cc.Password)
if err != nil {
return nil, err
}
cv := oauth2.GenerateVerifier()
fmt.Println(oc.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(cv)))
os.Exit(1)

// api := connected.NewAPI(log, identity, cc.Sandbox)
api := connected.NewAPI(log, ts, cc.VccApiKey)

var err error
cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles)

v := &VolvoConnected{
embed: &cc.embed,
Identity: ts,
Provider: connected.NewProvider(api, cc.VIN, cc.Cache),
}

return v, err

Check failure on line 68 in vehicle/volvo-connected.go

View workflow job for this annotation

GitHub Actions / Lint

cannot use v (variable of type *VolvoConnected) as "github.com/evcc-io/evcc/api".Vehicle value in return statement: *VolvoConnected does not implement "github.com/evcc-io/evcc/api".Vehicle (missing method Soc) (typecheck)
}
126 changes: 78 additions & 48 deletions vehicle/volvo/connected/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,85 +4,115 @@
"net/http"
"net/url"
"strings"
"time"
"sync"

"github.com/coreos/go-oidc/v3/oidc"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/oauth"
"github.com/evcc-io/evcc/util/request"
"github.com/samber/lo"
"golang.org/x/oauth2"
)

var Oauth2Config = oauth2.Config{
Endpoint: oauth2.Endpoint{
AuthURL: "https://volvoid.eu.volvocars.com/as/authorization.oauth2",
TokenURL: "https://volvoid.eu.volvocars.com/as/token.oauth2",
},
Scopes: []string{
oidc.ScopeOpenID, "vehicle:attributes",
"energy:recharge_status", "energy:battery_charge_level", "energy:electric_range", "energy:estimated_charging_time", "energy:charging_connection_status", "energy:charging_system_status",
"conve:fuel_status", "conve:odometer_status", "conve:environment",
},
type oauth2Config struct {
*oauth2.Config
h *request.Helper
}

const (
managerId = "JWTh4Yf0b"
basicAuth = "Basic aDRZZjBiOlU4WWtTYlZsNnh3c2c1WVFxWmZyZ1ZtSWFEcGhPc3kxUENhVXNpY1F0bzNUUjVrd2FKc2U0QVpkZ2ZJZmNMeXc="
)
func Oauth2Config(log *util.Logger, id, secret, redirect string) *oauth2Config {
return &oauth2Config{
h: request.NewHelper(log),
Config: &oauth2.Config{
ClientID: id,
ClientSecret: secret,
RedirectURL: redirect,
Endpoint: oauth2.Endpoint{
AuthURL: "https://volvoid.eu.volvocars.com/as/authorization.oauth2",
TokenURL: "https://volvoid.eu.volvocars.com/as/token.oauth2",
},
Scopes: []string{
oidc.ScopeOpenID,
// "vehicle:attributes",
"energy:recharge_status", "energy:battery_charge_level", "energy:electric_range", "energy:estimated_charging_time", "energy:charging_connection_status", "energy:charging_system_status",
// "conve:fuel_status", "conve:odometer_status", "conve:environment",
},
},
}
}

// func (oc *oauth2Config) TokenSource(ctx context.Context, token *oauth2.Token) oauth2.TokenSource {
// return oauth.RefreshTokenSource(token, oc)
// }

type Identity struct {
log *util.Logger
*request.Helper
oc *oauth2Config
ts oauth2.TokenSource
mu sync.Mutex
cv string
authenticated chan<- bool
}

func NewIdentity(log *util.Logger) (*Identity, error) {
v := &Identity{
log: log,
func NewIdentity(log *util.Logger, config *oauth2Config) *Identity {
return &Identity{
Helper: request.NewHelper(log),
oc: config,
}

return v, nil
}

func (v *Identity) Login(user, password string) (oauth2.TokenSource, error) {
data := url.Values{
"username": {user},
"password": {password},
"access_token_manager_id": {managerId},
"grant_type": {"password"},
"scope": {strings.Join(Oauth2Config.Scopes, " ")},
var _ api.AuthProvider = (*Identity)(nil)

// func (v *Identity) SetCallbackParams(baseURL, redirectURL string, authenticated chan<- bool) {
// v.authenticated = authenticated
// fmt.Println(baseURL, redirectURL)
// }

func (v *Identity) LoginHandler(authenticated chan<- bool) http.HandlerFunc {
v.authenticated = authenticated

return func(w http.ResponseWriter, req *http.Request) {
v.cv = oauth2.GenerateVerifier()
state := lo.RandomString(16, lo.AlphanumericCharset)
url := v.oc.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(v.cv))
http.Redirect(w, req, url, http.StatusTemporaryRedirect)
}
}

req, err := request.New(http.MethodPost, Oauth2Config.Endpoint.TokenURL, strings.NewReader(data.Encode()), map[string]string{
"Content-Type": request.FormContent,
"Authorization": basicAuth,
})
if err != nil {
return nil, err
func (v *Identity) RedirectHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
token, err := v.oc.Exchange(r.Context(), code, oauth2.VerifierOption(v.cv))

Check failure on line 84 in vehicle/volvo/connected/identity.go

View workflow job for this annotation

GitHub Actions / Build

declared and not used: token

Check failure on line 84 in vehicle/volvo/connected/identity.go

View workflow job for this annotation

GitHub Actions / Lint

declared and not used: token (typecheck)

Check failure on line 84 in vehicle/volvo/connected/identity.go

View workflow job for this annotation

GitHub Actions / Lint

declared and not used: token) (typecheck)

Check failure on line 84 in vehicle/volvo/connected/identity.go

View workflow job for this annotation

GitHub Actions / Integration

declared and not used: token

Check failure on line 84 in vehicle/volvo/connected/identity.go

View workflow job for this annotation

GitHub Actions / Test

declared and not used: token
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}

var tok oauth2.Token
if err := v.DoJSON(req, &tok); err != nil {
return nil, err
func (v *Identity) LogoutHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
v.mu.Lock()
defer v.mu.Unlock()
v.ts = nil
}
}

token := util.TokenWithExpiry(&tok)
ts := oauth2.ReuseTokenSourceWithExpiry(token, oauth.RefreshTokenSource(token, v), 15*time.Minute)
go oauth.Refresh(v.log, token, ts)
var _ oauth2.TokenSource = (*Identity)(nil)

return ts, nil
func (v *Identity) Token() (*oauth2.Token, error) {
return nil, api.ErrMissingToken
}

func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
data := url.Values{
"access_token_manager_id": {managerId},
"grant_type": {"refresh_token"},
"refresh_token": {token.RefreshToken},
// "access_token_manager_id": {managerId},
"grant_type": {"refresh_token"},
"refresh_token": {token.RefreshToken},
}

req, err := request.New(http.MethodPost, Oauth2Config.Endpoint.TokenURL, strings.NewReader(data.Encode()), map[string]string{
"Content-Type": request.FormContent,
"Authorization": basicAuth,
req, err := request.New(http.MethodPost, v.oc.Endpoint.TokenURL, strings.NewReader(data.Encode()), map[string]string{
"Content-Type": request.FormContent,
// "Authorization": basicAuth,
})
if err != nil {
return nil, err
Expand Down
2 changes: 1 addition & 1 deletion vehicle/volvo.go → vehicle/volvo_deprecated.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"github.com/evcc-io/evcc/vehicle/volvo"
)

// Volvo is an api.Vehicle implementation for Volvo. cars
// Volvo is an api.Vehicle implementation for Volvo cars
type Volvo struct {
*embed
*request.Helper
Expand Down
Loading