Make ID implementation share VW identity service
This commit is contained in:
parent
def4c8a565
commit
2637ebd6cc
6 changed files with 334 additions and 297 deletions
300
vehicle/id.go
300
vehicle/id.go
|
|
@ -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
136
vehicle/id/api.go
Normal 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
54
vehicle/id/provider.go
Normal 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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
18
vehicle/vw/idtokens.go
Normal 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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue