diff --git a/util/request/functions.go b/util/request/functions.go index 39d900db9..a0abdf2fb 100644 --- a/util/request/functions.go +++ b/util/request/functions.go @@ -17,6 +17,11 @@ var ( "Content-Type": "application/json", "Accept": "application/json", } + + // AcceptJSON accepting application/json + AcceptJSON = map[string]string{ + "Accept": "application/json", + } ) // StatusError indicates unsuccessful http response diff --git a/vehicle/ford.go b/vehicle/ford.go index 7c1ef9f9d..d6d8424d8 100644 --- a/vehicle/ford.go +++ b/vehicle/ford.go @@ -24,7 +24,7 @@ type Ford struct { *embed *request.Helper user, password, vin string - tokens oidc.Tokens + tokens oidc.Token chargeStateG func() (float64, error) } @@ -84,9 +84,8 @@ func (v *Ford) login(user, password string) error { return err } - var tokens oidc.Tokens + var tokens oidc.Token if err = v.DoJSON(req, &tokens); err == nil { - tokens.Valid = time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second) v.tokens = tokens } @@ -94,7 +93,7 @@ func (v *Ford) login(user, password string) error { } func (v *Ford) request(uri string) (*http.Request, error) { - if v.tokens.AccessToken == "" || time.Since(v.tokens.Valid) > 0 { + if v.tokens.AccessToken == "" || time.Until(v.tokens.Expiry) < time.Minute { if err := v.login(v.user, v.password); err != nil { return nil, err } diff --git a/vehicle/id/api.go b/vehicle/id/api.go index 57ad09603..aff3d3222 100644 --- a/vehicle/id/api.go +++ b/vehicle/id/api.go @@ -7,7 +7,7 @@ import ( "github.com/andig/evcc/util" "github.com/andig/evcc/util/request" - "github.com/andig/evcc/vehicle/vw" + "golang.org/x/oauth2" ) // https://identity-userinfo.vwgroup.io/oidc/userinfo @@ -19,7 +19,6 @@ const BaseURL = "https://mobileapi.apps.emea.vwapps.io" // API is an api.Vehicle implementation for VW ID cars type API struct { *request.Helper - identity *vw.Identity } // Actions and action values @@ -35,11 +34,17 @@ const ( ) // NewAPI creates a new vehicle -func NewAPI(log *util.Logger, identity *vw.Identity) *API { - v := &API{ - Helper: request.NewHelper(log), - identity: identity, +func NewAPI(log *util.Logger, identity oauth2.TokenSource) *API { + helper := request.NewHelper(log) + helper.Client.Transport = &oauth2.Transport{ + Source: identity, + Base: helper.Transport, } + + v := &API{ + Helper: helper, + } + return v } @@ -47,10 +52,7 @@ func NewAPI(log *util.Logger, identity *vw.Identity) *API { func (v *API) Vehicles() (res []string, err error) { uri := fmt.Sprintf("%s/vehicles", BaseURL) - req, err := request.New(http.MethodGet, uri, nil, map[string]string{ - "Accept": "application/json", - "Authorization": "Bearer " + v.identity.Token(), - }) + req, err := request.New(http.MethodGet, uri, nil, request.AcceptJSON) var vehicles struct { Data []struct { @@ -151,10 +153,7 @@ type RangeStatus struct { func (v *API) Status(vin string) (res Status, err error) { uri := fmt.Sprintf("%s/vehicles/%s/status", BaseURL, vin) - req, err := request.New(http.MethodGet, uri, nil, map[string]string{ - "Accept": "application/json", - "Authorization": "Bearer " + v.identity.Token(), - }) + req, err := request.New(http.MethodGet, uri, nil, request.AcceptJSON) if err == nil { err = v.DoJSON(req, &res) @@ -167,10 +166,7 @@ func (v *API) Status(vin string) (res Status, err error) { func (v *API) Action(vin, action, value string) error { uri := fmt.Sprintf("%s/vehicles/%s/%s/%s", BaseURL, vin, action, value) - req, err := request.New(http.MethodPost, uri, nil, map[string]string{ - "Accept": "application/json", - "Authorization": "Bearer " + v.identity.Token(), - }) + req, err := request.New(http.MethodPost, uri, nil, request.AcceptJSON) if err == nil { var res interface{} @@ -186,10 +182,7 @@ func (v *API) Any(uri, vin string) (interface{}, error) { uri = fmt.Sprintf(uri, vin) } - req, err := request.New(http.MethodGet, uri, nil, map[string]string{ - "Accept": "application/json", - "Authorization": "Bearer " + v.identity.Token(), - }) + req, err := request.New(http.MethodGet, uri, nil, request.AcceptJSON) var res interface{} if err == nil { diff --git a/vehicle/id/token.go b/vehicle/id/token.go new file mode 100644 index 000000000..4ace7c468 --- /dev/null +++ b/vehicle/id/token.go @@ -0,0 +1,70 @@ +package id + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/andig/evcc/util" + "github.com/andig/evcc/util/request" + "golang.org/x/oauth2" +) + +// Token is the VW ID token +type Token oauth2.Token + +func (t *Token) UnmarshalJSON(data []byte) error { + var s struct { + AccessToken string + RefreshToken string + IDToken string + } + + err := json.Unmarshal(data, &s) + if err == nil { + t.AccessToken = s.AccessToken + t.RefreshToken = s.RefreshToken + t.Expiry = time.Now().Add(time.Hour) + } + + return err +} + +func (t *Token) TokenSource(log *util.Logger) oauth2.TokenSource { + return &TokenSource{ + Helper: request.NewHelper(log), + token: t, + } +} + +type TokenSource struct { + *request.Helper + token *Token +} + +func (ts *TokenSource) Token() (*oauth2.Token, error) { + var err error + if time.Until(ts.token.Expiry) < time.Minute { + err = ts.refreshToken() + } + + return (*oauth2.Token)(ts.token), err +} + +func (ts *TokenSource) refreshToken() error { + uri := "https://login.apps.emea.vwapps.io/refresh/v1" + + req, err := request.New(http.MethodGet, uri, nil, map[string]string{ + "Accept": "application/json", + "Authorization": "Bearer " + ts.token.RefreshToken, + }) + + if err == nil { + var token Token + if err = ts.DoJSON(req, &token); err == nil { + ts.token = &token + } + } + + return err +} diff --git a/vehicle/nissan.go b/vehicle/nissan.go index a340c4b0a..32037cc54 100644 --- a/vehicle/nissan.go +++ b/vehicle/nissan.go @@ -47,7 +47,7 @@ type Nissan struct { log *util.Logger user, password, vin string userID string - tokens oidc.Tokens + tokens oidc.Token *kamereon.API } @@ -253,7 +253,7 @@ func (v *Nissan) refreshToken() error { uri += "?" + data.Encode() req, err := request.New(http.MethodPost, uri, nil, request.URLEncoding) if err == nil { - var tokens oidc.Tokens + var tokens oidc.Token if err = v.DoJSON(req, &tokens); err == nil && v.tokens.AccessToken == "" { err = errors.New("missing access token") } diff --git a/vehicle/oidc/oidc.go b/vehicle/oidc/oidc.go index 1863f909a..c312e8e57 100644 --- a/vehicle/oidc/oidc.go +++ b/vehicle/oidc/oidc.go @@ -1,24 +1,32 @@ package oidc -import "time" +import ( + "encoding/json" + "time" -// Tokens is an OAuth tokens response -type Tokens struct { - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` // expiration time in seconds - IDToken string `json:"id_token"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - Valid time.Time // helper to store validity timestamp + "golang.org/x/oauth2" +) + +// Token is an OAuth2 token which includes decoding the expires_in attribute +type Token struct { + oauth2.Token + ExpiresIn int `json:"expires_in"` // expiration time in seconds } -// OIDCResponse is the well-known OIDC provider response -// https://{oauth-provider-hostname}/.well-known/openid-configuration -type OIDCResponse struct { - Issuer string `json:"issuer"` - AuthURL string `json:"authorization_endpoint"` - TokenURL string `json:"token_endpoint"` - JWKSURL string `json:"jwks_uri"` - UserInfoURL string `json:"userinfo_endpoint"` - Algorithms []string `json:"id_token_signing_alg_values_supported"` +func (t *Token) UnmarshalJSON(data []byte) error { + var s struct { + oauth2.Token + ExpiresIn int64 `json:"expires_in,omitempty"` + } + + err := json.Unmarshal(data, &s) + if err == nil { + t.Token = s.Token + + if s.Expiry.IsZero() && s.ExpiresIn != 0 { + t.Expiry = time.Now().Add(time.Second * time.Duration(s.ExpiresIn)) + } + } + + return err } diff --git a/vehicle/vw/api.go b/vehicle/vw/api.go index b526acaeb..5a0624c2f 100644 --- a/vehicle/vw/api.go +++ b/vehicle/vw/api.go @@ -7,6 +7,7 @@ import ( "github.com/andig/evcc/util" "github.com/andig/evcc/util/request" + "golang.org/x/oauth2" ) // DefaultBaseURI is the VW api base URI @@ -35,28 +36,30 @@ func Temp2Float(val int) float64 { // API is the VW api client type API struct { *request.Helper - identity *Identity brand, country string baseURI string } // NewAPI creates a new api client func NewAPI(log *util.Logger, identity *Identity, brand, country string) *API { - v := &API{ - Helper: request.NewHelper(log), - identity: identity, - brand: brand, - country: country, - baseURI: DefaultBaseURI, + helper := request.NewHelper(log) + helper.Client.Transport = &oauth2.Transport{ + Source: identity, + Base: helper.Transport, } + + v := &API{ + Helper: helper, + brand: brand, + country: country, + baseURI: DefaultBaseURI, + } + return v } func (v *API) getJSON(uri string, res interface{}) error { - req, err := request.New(http.MethodGet, uri, nil, map[string]string{ - "Accept": "application/json", - "Authorization": "Bearer " + v.identity.Token(), - }) + req, err := request.New(http.MethodGet, uri, nil, request.AcceptJSON) if err == nil { err = v.DoJSON(req, &res) diff --git a/vehicle/vw/identity.go b/vehicle/vw/identity.go index eb2d4e125..5154f80c8 100644 --- a/vehicle/vw/identity.go +++ b/vehicle/vw/identity.go @@ -6,12 +6,12 @@ import ( "net/http/cookiejar" "net/url" "strings" - "time" "github.com/andig/evcc/util" "github.com/andig/evcc/util/request" - "github.com/andig/evcc/vehicle/oidc" + "github.com/andig/evcc/vehicle/id" "golang.org/x/net/publicsuffix" + "golang.org/x/oauth2" ) const ( @@ -25,12 +25,12 @@ const ( OauthRevokeURI = "https://mbboauth-1d.prd.ece.vwg-connect.com/mbbcoauth/mobile/oauth2/v1/revoke" ) -// Identity provides the identity.vwgroup.io login +// Identity provides the identity.vwgroup.io login token source type Identity struct { log *util.Logger *request.Helper clientID string - tokens oidc.Tokens + oauth2.TokenSource } // NewIdentity creates VW identity @@ -148,9 +148,9 @@ func (v *Identity) Login(query url.Values, user, password string) error { }) if err == nil { - var tokens oidc.Tokens - if err = v.DoJSON(req, &tokens); err == nil { - err = v.validateTokens(tokens) + var token Token + if err = v.DoJSON(req, &token); err == nil { + v.TokenSource = token.TokenSource(v.log, v.clientID) } } } @@ -172,94 +172,12 @@ func (v *Identity) Login(query url.Values, user, password string) error { req, err = request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding) if err == nil { - var tokens idTokens - if err = v.DoJSON(req, &tokens); err == nil { - err = v.validateTokens(tokens.AsOIDC()) + var token id.Token + if err = v.DoJSON(req, &token); err == nil { + v.TokenSource = token.TokenSource(v.log) } } } return err } - -// validateTokens checks if token is present and sets valid time -func (v *Identity) validateTokens(tokens oidc.Tokens) error { - if tokens.AccessToken == "" { - return errors.New("missing access token") - } - - v.tokens.AccessToken = tokens.AccessToken - v.tokens.Valid = time.Now().Add(time.Second * time.Duration(tokens.ExpiresIn)) - - // re-use refresh token - if tokens.RefreshToken != "" { - v.tokens.RefreshToken = tokens.RefreshToken - } - - return nil -} - -// Token returns the access token, refreshed if necessary -func (v *Identity) Token() string { - // give some extra time of 1m to safely trigger new tokens before they expire - if time.Until(v.tokens.Valid) < time.Minute { - if err := v.RefreshToken(); err != nil { - v.log.ERROR.Printf("token refresh failed: %v", err) - } - } - - return v.tokens.AccessToken -} - -// RefreshToken uses the refresh token to obtain a new access token -func (v *Identity) RefreshToken() error { - if v.tokens.RefreshToken == "" { - return errors.New("missing refresh token") - } - - if v.clientID == "" { - return v.refreshIDToken() - } - - data := url.Values(map[string][]string{ - "grant_type": {"refresh_token"}, - "refresh_token": {v.tokens.RefreshToken}, - "scope": {"sc2:fal"}, - }) - - headers := map[string]string{ - "Content-Type": "application/x-www-form-urlencoded", - "X-Client-Id": v.clientID, - } - - req, err := request.New(http.MethodPost, OauthTokenURI, strings.NewReader(data.Encode()), headers) - - if err == nil { - var tokens oidc.Tokens - if err = v.DoJSON(req, &tokens); err == nil { - err = v.validateTokens(tokens) - } - } - - return err -} - -func (v *Identity) refreshIDToken() error { - uri := "https://login.apps.emea.vwapps.io/refresh/v1" - - headers := map[string]string{ - "Accept": "application/json", - "Authorization": "Bearer " + v.tokens.RefreshToken, - } - - req, err := request.New(http.MethodGet, uri, nil, headers) - - if err == nil { - var tokens idTokens - if err = v.DoJSON(req, &tokens); err == nil { - err = v.validateTokens(tokens.AsOIDC()) - } - } - - return err -} diff --git a/vehicle/vw/idtokens.go b/vehicle/vw/idtokens.go deleted file mode 100644 index dc7121458..000000000 --- a/vehicle/vw/idtokens.go +++ /dev/null @@ -1,18 +0,0 @@ -package vw - -import "github.com/andig/evcc/vehicle/oidc" - -// idTokens is the non-OIDC compliant VW ID token structure -type idTokens struct { - AccessToken, RefreshToken, IDToken string -} - -// AsOIDC converts id tokens to OIDC tokens -func (tokens idTokens) AsOIDC() oidc.Tokens { - return oidc.Tokens{ - IDToken: tokens.IDToken, - AccessToken: tokens.AccessToken, - RefreshToken: tokens.RefreshToken, - ExpiresIn: 3600, - } -} diff --git a/vehicle/vw/token.go b/vehicle/vw/token.go new file mode 100644 index 000000000..622692094 --- /dev/null +++ b/vehicle/vw/token.go @@ -0,0 +1,61 @@ +package vw + +import ( + "net/http" + "net/url" + "strings" + "time" + + "github.com/andig/evcc/util" + "github.com/andig/evcc/util/request" + "github.com/andig/evcc/vehicle/oidc" + "golang.org/x/oauth2" +) + +// Token is the VW token +type Token oidc.Token + +func (t *Token) TokenSource(log *util.Logger, clientID string) oauth2.TokenSource { + return &TokenSource{ + Helper: request.NewHelper(log), + clientID: clientID, + token: t, + } +} + +type TokenSource struct { + *request.Helper + clientID string + token *Token +} + +func (ts *TokenSource) Token() (*oauth2.Token, error) { + var err error + if time.Until(ts.token.Expiry) < time.Minute { + err = ts.refreshToken() + } + + return &ts.token.Token, err +} + +func (ts *TokenSource) refreshToken() error { + data := url.Values(map[string][]string{ + "grant_type": {"refresh_token"}, + "refresh_token": {ts.token.RefreshToken}, + "scope": {"sc2:fal"}, + }) + + req, err := request.New(http.MethodPost, OauthTokenURI, strings.NewReader(data.Encode()), map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + "X-Client-Id": ts.clientID, + }) + + if err == nil { + var token Token + if err = ts.DoJSON(req, &token); err == nil { + ts.token = &token + } + } + + return err +}