Further simplify OAuth handling for VW vehicles (#761)

This commit is contained in:
andig 2021-03-14 21:16:38 +01:00 • committed by GitHub
parent 2fd6345434
commit 3f8aac467a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 206 additions and 167 deletions

View file

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

View file

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

View file

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

70
vehicle/id/token.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

61
vehicle/vw/token.go Normal file
View file

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