Further simplify OAuth handling for VW vehicles (#761)
This commit is contained in:
parent
2fd6345434
commit
3f8aac467a
10 changed files with 206 additions and 167 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
70
vehicle/id/token.go
Normal 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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
61
vehicle/vw/token.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue