Make ID implementation share VW identity service

This commit is contained in:
andig 2020-12-28 11:29:26 +01:00
parent def4c8a565
commit 2637ebd6cc
6 changed files with 334 additions and 297 deletions

View file

@ -1,46 +1,22 @@
package vehicle
import (
"errors"
"fmt"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
"time"
"github.com/andig/evcc/api"
"github.com/andig/evcc/provider"
"github.com/andig/evcc/util"
"github.com/andig/evcc/util/request"
"github.com/andig/evcc/vehicle/oidc"
"github.com/andig/evcc/vehicle/id"
"github.com/andig/evcc/vehicle/vw"
"golang.org/x/net/publicsuffix"
)
// ID is an api.Vehicle implementation for VW ID cars
// https://github.com/TA2k/ioBroker.vw-connect
// ID is an api.Vehicle implementation for ID cars
type ID struct {
*embed
*request.Helper
user, password string
vin string
userInfo UserInfo
csrf string
carTokens oidc.Tokens
weTokens oidc.Tokens
chargerG func() (interface{}, error)
}
// UserInfo is the https://www.volkswagen.de/app/authproxy/vw-de/user response
type UserInfo struct {
Sub string
Name string
GivenName string
FamilyName string
Email string
EmailVerified bool
UpdatedAt int64
*id.Provider // provides the api implementations
}
func init() {
@ -62,263 +38,33 @@ func NewIDFromConfig(other map[string]interface{}) (api.Vehicle, error) {
return nil, err
}
log := util.NewLogger("id")
v := &ID{
embed: &embed{cc.Title, cc.Capacity},
Helper: request.NewHelper(log),
user: cc.User,
password: cc.Password,
vin: strings.ToUpper(cc.VIN),
embed: &embed{cc.Title, cc.Capacity},
}
var err error
jar, err := cookiejar.New(&cookiejar.Options{
PublicSuffixList: publicsuffix.List,
log := util.NewLogger("id")
identity := vw.NewIdentity(log, "")
query := url.Values(map[string][]string{
"response_type": {"code id_token token"},
"client_id": {"a24fba63-34b3-4d43-b181-942111e6bda8@apps_vw-dilab_com"},
"redirect_uri": {"weconnect://authenticated"},
"scope": {"openid profile badge cars dealers vin"},
})
// track cookies and follow all redirects
v.Client.Jar = jar
v.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return nil
}
err := identity.Login(query, cc.User, cc.Password)
if err == nil {
err = v.authFlow()
}
api := id.NewAPI(log, identity)
if err == nil && cc.VIN == "" {
v.vin, err = findVehicle(v.vehicles())
if err == nil {
log.DEBUG.Printf("found vehicle: %v", v.vin)
if cc.VIN == "" {
cc.VIN, err = findVehicle(api.Vehicles())
if err == nil {
log.DEBUG.Printf("found vehicle: %v", cc.VIN)
}
}
}
v.chargerG = provider.NewCached(v.chargeState, cc.Cache).InterfaceGetter()
v.Provider = id.NewProvider(api, strings.ToUpper(cc.VIN), cc.Cache)
}
return v, err
}
func (v *ID) authFlow() error {
var uri string
var req *http.Request
uri = "https://www.volkswagen.de/app/authproxy/login?fag=vw-de,vwag-weconnect&scope-vw-de=profile,address,phone,carConfigurations,dealers,cars,vin,profession&scope-vwag-weconnect=openid&prompt-vw-de=login&prompt-vwag-weconnect=none&redirectUrl=https://www.volkswagen.de/de/besitzer-und-nutzer/myvolkswagen.html"
resp, err := v.Get(uri)
var vars vw.FormVars
if err == nil {
vars, err = vw.FormValues(resp.Body, "form#emailPasswordForm")
resp.Body.Close()
}
// POST identity.vwgroup.io/signin-service/v1/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com/login/identifier
if err == nil {
data := url.Values(map[string][]string{
"_csrf": {vars.Csrf},
"relayState": {vars.RelayState},
"hmac": {vars.Hmac},
"email": {v.user},
})
uri = vw.IdentityURI + vars.Action
if resp, err = v.PostForm(uri, data); err == nil {
vars, err = vw.FormValues(resp.Body, "form#credentialsForm")
resp.Body.Close()
}
}
// POST identity.vwgroup.io/signin-service/v1/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com/login/authenticate
if err == nil {
data := url.Values(map[string][]string{
"_csrf": {vars.Csrf},
"relayState": {vars.RelayState},
"hmac": {vars.Hmac},
"email": {v.user},
"password": {v.password},
})
uri = vw.IdentityURI + vars.Action
if resp, err = v.PostForm(uri, data); err == nil {
resp.Body.Close()
vwDomain, _ := url.Parse("https://www.volkswagen.de/")
cookies := v.Client.Jar.Cookies(vwDomain)
for _, c := range cookies {
if c.Name == "csrf_token" {
v.csrf = c.Value
break
}
}
if v.csrf == "" {
err = errors.New("missing csrf token")
}
}
}
if err == nil {
req, err = request.New(http.MethodGet, "https://www.volkswagen.de/app/authproxy/vw-de/user", nil, map[string]string{
"Accept": "application/json",
"X-csrf-token": v.csrf,
})
if err == nil {
err = v.DoJSON(req, &v.userInfo)
}
}
if err == nil {
uri = "https://www.volkswagen.de/app/authproxy/vw-de/tokens"
req, err = request.New(http.MethodGet, uri, nil, map[string]string{
"Accept": "application/json",
"X-csrf-token": v.csrf,
})
if err == nil {
if err = v.DoJSON(req, &v.carTokens); err == nil {
if v.carTokens.AccessToken == "" {
err = errors.New("missing vw-de access token")
}
}
}
}
if err == nil {
uri = "https://www.volkswagen.de/app/authproxy/vwag-weconnect/tokens"
req, err = request.New(http.MethodGet, uri, nil, map[string]string{
"Accept": "application/json",
"X-csrf-token": v.csrf,
})
if err == nil {
if err = v.DoJSON(req, &v.weTokens); err == nil {
if v.weTokens.AccessToken == "" {
err = errors.New("missing vwag-weconnect access token")
}
}
}
if err == nil {
uri = "https://myvw-idk-token-exchanger.apps.emea.vwapps.io/token-exchange?isWcar=true"
req, err = request.New(http.MethodGet, uri, nil, map[string]string{
"Accept": "applicaton/json, text/plain",
"Authorization": "Bearer " + v.weTokens.AccessToken,
})
if err == nil {
if resp, err = v.Do(req); err == nil {
var body []byte
if body, err = request.ReadBody(resp); err == nil {
v.weTokens.AccessToken = string(body)
}
}
}
}
}
return err
}
func (v *ID) vehicles() (res []string, err error) {
uri := "https://w1hub-backend-production.apps.emea.vwapps.io/cars"
req, err := request.New(http.MethodGet, uri, nil, map[string]string{
"Accept": "application/json",
"Authorization": "Bearer " + v.carTokens.AccessToken,
})
var vehicles []struct {
VIN string
}
if err == nil {
err = v.DoJSON(req, &vehicles)
for _, v := range vehicles {
res = append(res, v.VIN)
}
}
return res, err
}
type idData struct {
Error struct {
Code int
Message string
}
Data []struct {
ID string
CarCapturedTimestamp string
Properties []struct {
Name, Value string // engineType: electric, remainingRange_km, currentSOC_pct
}
}
}
func (v *ID) chargeState() (interface{}, error) {
uri := fmt.Sprintf("https://cardata.apps.emea.vwapps.io/vehicles/%s/fuel/status", v.vin)
req, err := request.New(http.MethodGet, uri, nil, map[string]string{
"Accept": "application/json",
"Authorization": "Bearer " + v.weTokens.AccessToken,
"User-Id": v.userInfo.Sub,
})
var state idData
if err == nil {
err = v.DoJSON(req, &state)
if err != nil {
// handle http 401, 403
if se, ok := err.(request.StatusError); ok && se.HasStatus(http.StatusUnauthorized, http.StatusForbidden) {
if err = v.authFlow(); err == nil {
// re-do request with new token
req.Header.Set("Authorization", "Bearer "+v.weTokens.AccessToken)
err = v.DoJSON(req, &state)
}
}
}
}
return state, err
}
func (v *ID) extractProperty(data idData, property string) (int64, error) {
for _, d := range data.Data {
for _, p := range d.Properties {
if p.Name == property {
i, err := strconv.Atoi(p.Value)
return int64(i), err
}
}
}
return 0, fmt.Errorf("missing %s", property)
}
// ChargeState implements the Vehicle.ChargeState interface
func (v *ID) ChargeState() (float64, error) {
res, err := v.chargerG()
if res, ok := res.(idData); err == nil && ok {
var i int64
if i, err = v.extractProperty(res, "currentSOC_pct"); err == nil {
return float64(i), nil
}
}
return 0, err
}
// Range implements the Vehicle.Range interface
func (v *ID) Range() (int64, error) {
res, err := v.chargerG()
if res, ok := res.(idData); err == nil && ok {
var i int64
if i, err = v.extractProperty(res, "remainingRange_km"); err == nil {
return i, nil
}
}
return 0, err
}

136
vehicle/id/api.go Normal file
View file

@ -0,0 +1,136 @@
package id
import (
"fmt"
"net/http"
"github.com/andig/evcc/util"
"github.com/andig/evcc/util/request"
"github.com/andig/evcc/vehicle/vw"
)
// API is an api.Vehicle implementation for VW ID cars
type API struct {
*request.Helper
identity *vw.Identity
}
// NewAPI creates a new vehicle
func NewAPI(log *util.Logger, identity *vw.Identity) *API {
v := &API{
Helper: request.NewHelper(log),
identity: identity,
}
return v
}
// Vehicles implements the /vehicles response
func (v *API) Vehicles() (res []string, err error) {
uri := "https://mobileapi.apps.emea.vwapps.io/vehicles"
req, err := request.New(http.MethodGet, uri, nil, map[string]string{
"Accept": "application/json",
"Authorization": "Bearer " + v.identity.Token(),
})
var vehicles struct {
Data []struct {
VIN string
}
}
if err == nil {
err = v.DoJSON(req, &vehicles)
for _, v := range vehicles.Data {
res = append(res, v.VIN)
}
}
return res, err
}
// Status is the /status api
type Status struct {
Data struct {
BatteryStatus BatteryStatus
ChargingStatus ChargingStatus
PlugStatus PlugStatus
RangeStatus RangeStatus
ClimatisationSettings ClimatisationSettings
// ClimatisationStatus ClimatisationStatus // currently not available
}
}
// BatteryStatus is the /status.batteryStatus api
type BatteryStatus struct {
CarCapturedTimestamp string
CurrentSOCPercent int `json:"currentSOC_pct"`
CruisingRangeElectricKm int `json:"cruisingRangeElectric_km"`
}
// ChargingStatus is the /status.chargingStatus api
type ChargingStatus struct {
CarCapturedTimestamp string
ChargingState string
RemainingChargingTimeToCompleteMin int `json:"remainingChargingTimeToComplete_min"`
ChargePowerKW int `json:"chargePower_kW"`
ChargeRateKmph int `json:"chargeRate_kmph"`
}
// ChargingSettings is the /status.chargingSettings api
type ChargingSettings struct {
CarCapturedTimestamp string
MaxChargeCurrentAC string
AutoUnlockPlugWhenCharged string
TargetSOCPercent int `json:"targetSOC_pct"`
}
// PlugStatus is the /status.plugStatus api
type PlugStatus struct {
CarCapturedTimestamp string
PlugConnectionState string
PlugLockState string
}
// ClimatisationSettings is the /status.climatisationSettings api
type ClimatisationSettings struct {
CarCapturedTimestamp string
TargetTemperatureK float64 `json:"targetTemperature_K"`
TargetTemperatureC float64 `json:"targetTemperature_C"`
ClimatisationWithoutExternalPower bool
ClimatizationAtUnlock bool
WindowHeatingEnabled bool
ZoneFrontLeftEnabled bool
ZoneFrontRightEnabled bool
ZoneRearLeftEnabled bool
ZoneRearRightEnabled bool
}
// RangeStatus is the /status.rangeStatus api
type RangeStatus struct {
CarCapturedTimestamp string
CarType string
PrimaryEngine struct {
Type string
CurrentSOCPercent int `json:"currentSOC_pct"`
RemainingRangeKm int `json:"remainingRange_km"`
}
TotalRangeKm int `json:"totalRange_km"`
}
// Status implements the /status response
func (v *API) Status(vin string) (res Status, err error) {
uri := fmt.Sprintf("https://mobileapi.apps.emea.vwapps.io/vehicles/%s/status", vin)
req, err := request.New(http.MethodGet, uri, nil, map[string]string{
"Accept": "application/json",
"Authorization": "Bearer " + v.identity.Token(),
})
if err == nil {
err = v.DoJSON(req, &res)
}
return res, err
}

54
vehicle/id/provider.go Normal file
View file

@ -0,0 +1,54 @@
package id
import (
"time"
"github.com/andig/evcc/provider"
)
// Provider is an api.Vehicle implementation for VW ID cars
type Provider struct {
statusG func() (interface{}, error)
}
// NewProvider creates a new vehicle
func NewProvider(api *API, vin string, cache time.Duration) *Provider {
impl := &Provider{
statusG: provider.NewCached(func() (interface{}, error) {
return api.Status(vin)
}, cache).InterfaceGetter(),
}
return impl
}
// ChargeState implements the Vehicle.ChargeState interface
func (v *Provider) ChargeState() (float64, error) {
res, err := v.statusG()
if res, ok := res.(Status); err == nil && ok {
return float64(res.Data.BatteryStatus.CurrentSOCPercent), nil
}
return 0, err
}
// FinishTime implements the Vehicle.ChargeFinishTimer interface
func (v *Provider) FinishTime() (time.Time, error) {
res, err := v.statusG()
if res, ok := res.(Status); err == nil && ok {
cst := res.Data.ChargingStatus
timestamp, err := time.Parse(time.RFC3339, cst.CarCapturedTimestamp)
return timestamp.Add(time.Duration(cst.RemainingChargingTimeToCompleteMin) * time.Minute), err
}
return time.Time{}, err
}
// Range implements the Vehicle.Range interface
func (v *Provider) Range() (int64, error) {
res, err := v.statusG()
if res, ok := res.(Status); err == nil && ok {
return int64(res.Data.BatteryStatus.CruisingRangeElectricKm), nil
}
return 0, err
}

View file

@ -5,7 +5,7 @@ import "time"
// Tokens is an OAuth tokens response
type Tokens struct {
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
ExpiresIn int `json:"expires_in"` // expiration time in seconds
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`

View file

@ -6,6 +6,7 @@ import (
"net/http/cookiejar"
"net/url"
"strings"
"time"
"github.com/andig/evcc/util"
"github.com/andig/evcc/util/request"
@ -26,6 +27,7 @@ const (
// Identity provides the identity.vwgroup.io login
type Identity struct {
log *util.Logger
*request.Helper
clientID string
tokens oidc.Tokens
@ -34,6 +36,7 @@ type Identity struct {
// NewIdentity creates VW identity
func NewIdentity(log *util.Logger, clientID string) *Identity {
v := &Identity{
log: log,
Helper: request.NewHelper(log),
clientID: clientID,
}
@ -83,6 +86,19 @@ func (v *Identity) Login(query url.Values, user, password string) error {
resp.Body.Close()
}
// ID - get login url (previous request is ignored)
if v.clientID == "" {
uri := "https://login.apps.emea.vwapps.io/authorize?nonce=NZ2Q3T6jak0E5pDh&redirect_uri=weconnect://authenticated"
if resp, err = v.Get(uri); err == nil {
resp.Body.Close()
uri = resp.Header.Get("Location")
if resp, err = v.Get(uri); err == nil {
resp.Body.Close()
}
}
}
// GET identity.vwgroup.io/signin-service/v1/signin/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com?relayState=15404cb51c8b4cc5efeee1d2c2a73e5b41562faa
if err == nil {
uri = resp.Header.Get("Location")
@ -147,33 +163,58 @@ func (v *Identity) Login(query url.Values, user, password string) error {
resp, err = v.redirect(resp, err)
}
var location *url.URL
if err == nil {
loc := strings.ReplaceAll(resp.Header.Get("Location"), "#", "?") // convert to parseable url
location, err = url.Parse(loc)
var location *url.URL
if location, err = url.Parse(loc); err == nil {
v.tokens.IDToken = location.Query().Get("id_token")
if v.tokens.IDToken == "" {
err = errors.New("missing id token")
}
if err == nil && location.Query().Get("id_token") == "" {
err = errors.New("missing id token")
}
}
if err == nil {
// VW or Audi
if err == nil && v.clientID != "" {
data := url.Values(map[string][]string{
"grant_type": {"id_token"},
"scope": {"sc2:fal"},
"token": {v.tokens.IDToken},
"token": {location.Query().Get("id_token")},
})
req, err = request.New(http.MethodPost, OauthTokenURI, strings.NewReader(data.Encode()), map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"X-Client-Id": v.clientID,
})
if err == nil {
if err = v.DoJSON(req, &v.tokens); err == nil && v.tokens.AccessToken == "" {
err = errors.New("missing access token")
var tokens oidc.Tokens
if err = v.DoJSON(req, &tokens); err == nil {
err = v.validateTokens(tokens)
}
}
}
// ID
if err == nil && v.clientID == "" {
q := location.Query()
data := map[string]string{
"state": q.Get("state"),
"id_token": q.Get("id_token"),
"redirect_uri": "weconnect://authenticated",
"region": "emea",
"access_token": q.Get("access_token"),
"authorizationCode": q.Get("code"),
}
uri := "https://login.apps.emea.vwapps.io/login/v1"
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())
}
}
}
@ -181,8 +222,31 @@ func (v *Identity) Login(query url.Values, user, password string) error {
return err
}
// Token returns the access token
// 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 {
if time.Since(v.tokens.Valid) > 0 {
if err := v.RefreshToken(); err != nil {
v.log.ERROR.Printf("token refresh failed: %v", err)
}
}
return v.tokens.AccessToken
}
@ -192,6 +256,10 @@ func (v *Identity) RefreshToken() error {
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},
@ -207,13 +275,28 @@ func (v *Identity) RefreshToken() error {
if err == nil {
var tokens oidc.Tokens
err = v.DoJSON(req, &tokens)
if err == nil {
v.tokens.AccessToken = tokens.AccessToken
if tokens.RefreshToken != "" {
v.tokens.RefreshToken = tokens.RefreshToken
}
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())
}
}

18
vehicle/vw/idtokens.go Normal file
View file

@ -0,0 +1,18 @@
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,
}
}