VAG: rewrite integration and add Audi e-tron/Q4 support (#2968)

This commit is contained in:
andig 2022-03-24 16:45:32 +01:00 • committed by GitHub
parent b65f05e95f
commit 1d33c54db2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 1109 additions and 488 deletions

37
util/urlvalues/url.go Normal file
View file

@ -0,0 +1,37 @@
// Package urlvalues provides functions for working with url.Values
package urlvalues
import (
"errors"
"net/url"
"strings"
)
// Copy creates a deep copy of url values
func Copy(q url.Values) url.Values {
res := make(url.Values, len(q))
for k, v := range q {
res[k] = append([]string{}, v...)
}
return res
}
// Merge copies multiple from url values into to
func Merge(to url.Values, from ...url.Values) {
for _, vv := range from {
for k, v := range vv {
to[k] = append([]string{}, v...)
}
}
}
// Require verifies that url contains the required non-nil values
func Require(q url.Values, keys ...string) error {
for _, k := range keys {
if strings.TrimSpace(q.Get(k)) == "" {
return errors.New("missing " + k)
}
}
return nil
}

78
vehicle/audi-etron.go Normal file
View file

@ -0,0 +1,78 @@
package vehicle
import (
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/vehicle/audi/etron"
"github.com/evcc-io/evcc/vehicle/id"
"github.com/evcc-io/evcc/vehicle/vag"
"github.com/evcc-io/evcc/vehicle/vag/idkproxy"
"github.com/evcc-io/evcc/vehicle/vag/service"
"github.com/evcc-io/evcc/vehicle/vag/vwidentity"
)
// https://github.com/TA2k/ioBroker.vw-connect
// https://github.com/arjenvrh/audi_connect_ha/blob/master/custom_components/audiconnect/audi_services.py
// Etron is an api.Vehicle implementation for Audi eTron cars
type Etron struct {
*embed
*id.Provider // provides the api implementations
}
func init() {
registry.Add("etron", NewEtronFromConfig)
}
// NewEtronFromConfig creates a new vehicle
func NewEtronFromConfig(other map[string]interface{}) (api.Vehicle, error) {
cc := struct {
embed `mapstructure:",squash"`
User, Password, VIN string
Cache time.Duration
Timeout time.Duration
}{
Cache: interval,
Timeout: request.Timeout,
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
v := &Etron{
embed: &cc.embed,
}
log := util.NewLogger("etron").Redact(cc.User, cc.Password, cc.VIN)
// get initial VW identity id_token
q, err := vwidentity.Login(log, etron.AuthParams, cc.User, cc.Password)
if err != nil {
return nil, err
}
// exchange initial VW identity id_token for Audi AAZS token
idk := idkproxy.New(log, etron.IDKParams)
ats, its, err := service.AAZSTokenSource(log, idk, etron.AZSConfig, q)
if err != nil {
return nil, err
}
// use the etron API for list of vehicles
api := etron.NewAPI(log, ats)
cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles)
if err == nil {
api := id.NewAPI(log, vag.IDTokenSource(its))
api.Client.Timeout = cc.Timeout
v.Provider = id.NewProvider(api, cc.VIN, cc.Cache)
}
return v, err
}

View file

@ -1,13 +1,14 @@
package vehicle
import (
"fmt"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/vehicle/audi"
"github.com/evcc-io/evcc/vehicle/vag/idkproxy"
"github.com/evcc-io/evcc/vehicle/vag/service"
"github.com/evcc-io/evcc/vehicle/vw"
)
@ -18,7 +19,6 @@ import (
type Audi struct {
*embed
*vw.Provider // provides the api implementations
// audiProvider *audi.Provider
}
func init() {
@ -46,14 +46,14 @@ func NewAudiFromConfig(other map[string]interface{}) (api.Vehicle, error) {
}
log := util.NewLogger("audi").Redact(cc.User, cc.Password, cc.VIN)
identity := vw.NewIdentity(log, audi.AuthClientID, audi.AuthParams, cc.User, cc.Password)
err := identity.Login()
idk := idkproxy.New(log, audi.IDKParams)
ts, err := service.MbbTokenSource(log, idk, audi.AuthClientID, audi.AuthParams, cc.User, cc.Password)
if err != nil {
return v, fmt.Errorf("login failed: %w", err)
return nil, err
}
api := vw.NewAPI(log, identity, audi.Brand, audi.Country)
api := vw.NewAPI(log, ts, audi.Brand, audi.Country)
api.Client.Timeout = cc.Timeout
cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles)

54
vehicle/audi/etron/api.go Normal file
View file

@ -0,0 +1,54 @@
package etron
import (
"context"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/shurcooL/graphql"
"github.com/thoas/go-funk"
"golang.org/x/oauth2"
)
const ApiURI = "https://app-api.live-my.audi.com/vgql/v1/graphql"
// API is the VW api client
type API struct {
client *graphql.Client
}
// NewAPI creates a new api client
func NewAPI(log *util.Logger, ts oauth2.TokenSource) *API {
ctx := context.WithValue(
context.Background(),
oauth2.HTTPClient,
request.NewHelper(log).Client,
)
v := &API{
client: graphql.NewClient(ApiURI, oauth2.NewClient(ctx, ts)),
}
return v
}
// Vehicles implements the /vehicles response
func (v *API) Vehicles() ([]string, error) {
type vehicle struct {
VIN, Type, Nickname string
}
var res struct {
UserVehicles []vehicle `json:"userVehicles"`
}
var vins []string
err := v.client.Query(context.Background(), &res, nil)
if err == nil {
vins = funk.Map(res.UserVehicles, func(v vehicle) string {
return v.VIN
}).([]string)
}
return vins, err
}

View file

@ -0,0 +1,20 @@
package etron
import "net/url"
// Authorization parameters
var AuthParams = url.Values{
"response_type": {"code id_token token"},
"client_id": {"f4d0934f-32bf-4ce4-b3c4-699a7049ad26@apps_vw-dilab_com"},
"redirect_uri": {"myaudi:///"},
"scope": {"openid profile mbb"}, // vin badge birthdate nickname email address phone name picture
"prompt": {"login"},
"ui_locales": {"de-DE"},
}
var IDKParams = url.Values{
"client_id": {"f4d0934f-32bf-4ce4-b3c4-699a7049ad26@apps_vw-dilab_com"},
"redirect_uri": {"myaudi:///"},
}
const AZSConfig = "audi"

View file

@ -11,11 +11,16 @@ const (
)
// Authorization parameters
var AuthParams = url.Values(map[string][]string{
"response_type": {"id_token token"},
var AuthParams = url.Values{
"response_type": {"code id_token token"},
"client_id": {"09b6cbec-cd19-4589-82fd-363dfa8c24da@apps_vw-dilab_com"},
"redirect_uri": {"myaudi:///"},
"scope": {"openid profile mbb"}, // vin badge birthdate nickname email address phone name picture
"prompt": {"login"},
"ui_locales": {"de-DE"},
})
}
var IDKParams = url.Values{
"client_id": {"09b6cbec-cd19-4589-82fd-363dfa8c24da@apps_vw-dilab_com"},
"redirect_uri": {"myaudi:///"},
}

View file

@ -1,99 +0,0 @@
package id
import (
"errors"
"fmt"
"net/http"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/oauth"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/vehicle/vw"
"golang.org/x/oauth2"
)
const (
// OauthTokenURI is the login uri for ID vehicles
OauthTokenURI = "https://login.apps.emea.vwapps.io"
)
type Identity struct {
*request.Helper
idtp *vw.IDTokenProvider
oauth2.TokenSource
}
func NewIdentity(log *util.Logger, user, password string) *Identity {
uri := fmt.Sprintf("%s/authorize?%s", OauthTokenURI, AuthParams.Encode())
return &Identity{
Helper: request.NewHelper(log),
idtp: vw.NewIDTokenProvider(log, uri, user, password),
}
}
func (v *Identity) Login() error {
token, err := v.login()
if err != nil {
return err
}
v.TokenSource = oauth.RefreshTokenSource((*oauth2.Token)(&token), v)
return nil
}
func (v *Identity) login() (Token, error) {
q, err := v.idtp.Login()
if err == nil {
for _, k := range []string{"state", "id_token", "access_token", "code"} {
if q.Get(k) == "" {
err = errors.New("missing " + k)
break
}
}
}
var token Token
if err == nil {
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"),
}
var req *http.Request
uri := fmt.Sprintf("%s/login/v1", OauthTokenURI)
req, err = request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding)
if err == nil {
err = v.DoJSON(req, &token)
}
}
return token, err
}
func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
uri := fmt.Sprintf("%s/refresh/v1", OauthTokenURI)
req, err := request.New(http.MethodGet, uri, nil, map[string]string{
"Accept": "application/json",
"Authorization": "Bearer " + token.RefreshToken,
})
var res Token
if err == nil {
err = v.DoJSON(req, &res)
}
if se, ok := err.(request.StatusError); ok && se.HasStatus(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden) {
res, err = v.login()
}
return (*oauth2.Token)(&res), err
}

View file

@ -1,10 +1,16 @@
package id
import "net/url"
import (
"net/url"
var AuthParams = url.Values(map[string][]string{
"github.com/evcc-io/evcc/vehicle/vag/loginapps"
)
const LoginURL = loginapps.BaseURL + "/authorize"
var AuthParams = url.Values{
"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 vin"}, // dealers
})
}

View file

@ -1,13 +1,14 @@
package vehicle
import (
"fmt"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/vehicle/seat"
"github.com/evcc-io/evcc/vehicle/vag/service"
"github.com/evcc-io/evcc/vehicle/vag/tokenrefreshservice"
"github.com/evcc-io/evcc/vehicle/vw"
)
@ -45,14 +46,14 @@ func NewSeatFromConfig(other map[string]interface{}) (api.Vehicle, error) {
}
log := util.NewLogger("seat").Redact(cc.User, cc.Password, cc.VIN)
identity := vw.NewIdentity(log, seat.AuthClientID, seat.AuthParams, cc.User, cc.Password)
err := identity.Login()
trs := tokenrefreshservice.New(log, seat.TRSParams)
ts, err := service.MbbTokenSource(log, trs, seat.AuthClientID, seat.AuthParams, cc.User, cc.Password)
if err != nil {
return v, fmt.Errorf("login failed: %w", err)
return nil, err
}
api := vw.NewAPI(log, identity, seat.Brand, seat.Country)
api := vw.NewAPI(log, ts, seat.Brand, seat.Country)
api.Client.Timeout = cc.Timeout
cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles)

View file

@ -11,9 +11,14 @@ const (
)
// Authorization parameters
var AuthParams = url.Values(map[string][]string{
var AuthParams = url.Values{
"response_type": {"code id_token"},
"client_id": {"50f215ac-4444-4230-9fb1-fe15cd1a9bcc@apps_vw-dilab_com"},
"redirect_uri": {"seatconnect://identity-kit/login"},
"scope": {"openid profile mbb"}, // cars birthdate nickname address phone
})
}
// TokenRefreshService parameters
var TRSParams = url.Values{
"brand": {"seat"},
}

View file

@ -1,13 +1,14 @@
package vehicle
import (
"fmt"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/vehicle/skoda"
"github.com/evcc-io/evcc/vehicle/skoda/connect"
"github.com/evcc-io/evcc/vehicle/vag/service"
)
// https://github.com/lendy007/skodaconnect
@ -45,22 +46,20 @@ func NewEnyaqFromConfig(other map[string]interface{}) (api.Vehicle, error) {
var err error
log := util.NewLogger("enyaq").Redact(cc.User, cc.Password, cc.VIN)
if cc.VIN == "" {
ts := skoda.NewIdentity(log, skoda.AuthParams, cc.User, cc.Password)
if err = ts.Login(); err != nil {
return v, fmt.Errorf("login failed: %w", err)
}
api := skoda.NewAPI(log, ts)
api.Client.Timeout = cc.Timeout
cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles)
// use Skoda credentials to resolve list of vehicles
ts, err := service.TokenRefreshServiceTokenSource(log, skoda.TRSParams, skoda.AuthParams, cc.User, cc.Password)
if err != nil {
return nil, err
}
api := skoda.NewAPI(log, ts)
cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles)
// use Connect credentials to build provider
if err == nil {
ts := skoda.NewIdentity(log, skoda.ConnectAuthParams, cc.User, cc.Password)
if err = ts.Login(); err != nil {
return v, fmt.Errorf("login failed: %w", err)
ts, err := service.TokenRefreshServiceTokenSource(log, skoda.TRSParams, connect.AuthParams, cc.User, cc.Password)
if err != nil {
return nil, err
}
api := skoda.NewAPI(log, ts)

View file

@ -1,13 +1,14 @@
package vehicle
import (
"fmt"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/vehicle/skoda"
"github.com/evcc-io/evcc/vehicle/vag/service"
"github.com/evcc-io/evcc/vehicle/vag/tokenrefreshservice"
"github.com/evcc-io/evcc/vehicle/vw"
)
@ -45,14 +46,14 @@ func NewSkodaFromConfig(other map[string]interface{}) (api.Vehicle, error) {
}
log := util.NewLogger("skoda").Redact(cc.User, cc.Password, cc.VIN)
identity := vw.NewIdentity(log, skoda.AuthClientID, skoda.AuthParams, cc.User, cc.Password)
err := identity.Login()
trs := tokenrefreshservice.New(log, skoda.TRSParams)
ts, err := service.MbbTokenSource(log, trs, skoda.AuthClientID, skoda.AuthParams, cc.User, cc.Password)
if err != nil {
return v, fmt.Errorf("login failed: %w", err)
return nil, err
}
api := vw.NewAPI(log, identity, skoda.Brand, skoda.Country)
api := vw.NewAPI(log, ts, skoda.Brand, skoda.Country)
api.Client.Timeout = cc.Timeout
cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles)

View file

@ -0,0 +1,13 @@
package connect
import (
"net/url"
)
// Skoda connect api
var AuthParams = url.Values{
"response_type": {"code id_token"},
"redirect_uri": {"skodaconnect://oidc.login/"},
"client_id": {"7f045eee-7003-4379-9968-9355ed2adb06@apps_vw-dilab_com"},
"scope": {"openid profile mbb"}, // phone address cars email birthdate badge dealers driversLicense
}

View file

@ -1,107 +0,0 @@
package skoda
import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/oauth"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/vehicle/vw"
"golang.org/x/oauth2"
)
const (
// OauthTokenURI is the token service uri for Skoda Enyaq vehicles
OauthTokenURI = "https://tokenrefreshservice.apps.emea.vwapps.io"
)
type Identity struct {
*request.Helper
idtp *vw.IDTokenProvider
oauth2.TokenSource
}
func NewIdentity(log *util.Logger, query url.Values, user, password string) *Identity {
uri := fmt.Sprintf("%s/oidc/v1/authorize?%s", vw.IdentityURI, query.Encode())
return &Identity{
Helper: request.NewHelper(log),
idtp: vw.NewIDTokenProvider(log, uri, user, password),
}
}
func (v *Identity) Login() error {
token, err := v.login()
if err != nil {
return err
}
v.TokenSource = oauth.RefreshTokenSource(&token.Token, v)
return nil
}
func (v *Identity) login() (vw.Token, error) {
q, err := v.idtp.Login()
if err == nil {
for _, k := range []string{"id_token", "code"} {
if q.Get(k) == "" {
err = errors.New("missing " + k)
break
}
}
}
var token vw.Token
if err == nil {
data := url.Values(map[string][]string{
"auth_code": {q.Get("code")},
"id_token": {q.Get("id_token")},
"brand": {"skoda"},
})
var req *http.Request
uri := fmt.Sprintf("%s/exchangeAuthCode", OauthTokenURI)
req, err = request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding)
if err == nil {
err = v.DoJSON(req, &token)
}
// check if token response contained error
if errT := token.Error(); err != nil && errT != nil {
err = fmt.Errorf("token exchange: %w", errT)
}
}
return token, err
}
// RefreshToken implements oauth.TokenRefresher
func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
uri := fmt.Sprintf("%s/refreshTokens", OauthTokenURI)
data := url.Values(map[string][]string{
"grant_type": {"refresh_token"},
"refresh_token": {token.RefreshToken},
"brand": {"skoda"},
})
req, err := request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding)
var res vw.Token
if err == nil {
err = v.DoJSON(req, &res)
}
if se, ok := err.(request.StatusError); ok && se.HasStatus(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden) {
res, err = v.login()
}
return &res.Token, err
}

View file

@ -11,17 +11,14 @@ const (
)
// Skoda native api
var AuthParams = url.Values(map[string][]string{
var AuthParams = url.Values{
"response_type": {"code id_token"},
"client_id": {"f9a2359a-b776-46d9-bd0c-db1904343117@apps_vw-dilab_com"},
"redirect_uri": {"skodaconnect://oidc.login/"},
"scope": {"openid mbb profile"},
})
}
// Skoda connect api
var ConnectAuthParams = url.Values(map[string][]string{
"response_type": {"code id_token"},
"redirect_uri": {"skodaconnect://oidc.login/"},
"client_id": {"7f045eee-7003-4379-9968-9355ed2adb06@apps_vw-dilab_com"},
"scope": {"openid profile mbb"}, // phone address cars email birthdate badge dealers driversLicense
})
// TokenRefreshService parameters
var TRSParams = url.Values{
"brand": {"skoda"},
}

View file

@ -0,0 +1,56 @@
package aazsproxy
import (
"net/http"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/vehicle/vag"
"golang.org/x/oauth2"
)
const BaseURL = "https://aazsproxy-service.apps.emea.vwapps.io"
var Endpoint = &oauth2.Endpoint{
AuthURL: BaseURL + "/token",
}
type Service struct {
*request.Helper
}
func New(log *util.Logger) *Service {
return &Service{
Helper: request.NewHelper(log),
}
}
// Exchange exchanges an VAG identity or IDK token for an AAZS token
func (v *Service) Exchange(config, token string) (*vag.Token, error) {
// TODO make configurable
data := struct {
Token string `json:"token"`
GrantType string `json:"grant_type"`
Stage string `json:"stage"`
Config string `json:"config"`
}{
Token: token,
GrantType: "id_token",
Stage: "live",
Config: config,
}
var res vag.Token
req, err := request.New(http.MethodPost, Endpoint.AuthURL, request.MarshalJSON(data), request.JSONEncoding)
if err == nil {
err = v.DoJSON(req, &res)
}
return &res, err
}
// TokenSource creates token source. Token is NOT refreshed but will expire.
func (v *Service) TokenSource(token *vag.Token) vag.TokenSource {
return vag.RefreshTokenSource(token, nil)
}

18
vehicle/vag/challenge.go Normal file
View file

@ -0,0 +1,18 @@
package vag
import (
"net/url"
cv "github.com/nirasan/go-oauth-pkce-code-verifier"
)
func ChallengeAndVerifier(q url.Values) func(url.Values) {
cvc, _ := cv.CreateCodeVerifier()
q.Set("code_challenge_method", "S256")
q.Set("code_challenge", cvc.CodeChallengeS256())
return func(q url.Values) {
q.Set("code_verifier", cvc.CodeChallengePlain())
}
}

View file

@ -0,0 +1,117 @@
package idkproxy
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/urlvalues"
"github.com/evcc-io/evcc/vehicle/vag"
"github.com/evcc-io/evcc/vehicle/vag/vwidentity"
"golang.org/x/oauth2"
)
const (
BaseURL = "https://idkproxy-service.apps.emea.vwapps.io"
WellKnown = BaseURL + "/v1/emea/openid-configuration"
)
var Endpoint = &oauth2.Endpoint{
AuthURL: vwidentity.Endpoint.AuthURL,
TokenURL: BaseURL + "/v1/emea/token",
}
type Service struct {
*request.Helper
data url.Values
}
func New(log *util.Logger, q url.Values) *Service {
return &Service{
Helper: request.NewHelper(log),
data: q,
}
}
var secret = []byte{55, 24, 256 - 56, 256 - 96, 256 - 72, 256 - 110, 57, 256 - 87, 3, 256 - 86, 256 - 41, 256 - 103, 33, 256 - 30, 99, 103, 81, 125, 256 - 39, 256 - 39, 71, 18, 256 - 107, 256 - 112, 256 - 120, 256 - 12, 256 - 104, 89, 103, 113, 256 - 128, 256 - 91}
func qmauth(ts int64) string {
fmt.Println(ts)
hash := hmac.New(sha256.New, secret)
hash.Write([]byte(strconv.FormatInt(ts, 10)))
b := hash.Sum(nil)
return hex.EncodeToString(b)
}
func qmauthNow() string {
ts := time.Now().Unix() / 100
return "v1:55f755b0:" + qmauth(ts)
}
// Exchange exchanges an VAG identity token for an IDK token
func (v *Service) Exchange(q url.Values) (*vag.Token, error) {
if err := urlvalues.Require(q, "code", "code_verifier"); err != nil {
return nil, err
}
data := url.Values{
"grant_type": {"authorization_code"},
"response_type": {"token id_token"},
"code": {q.Get("code")},
"code_verifier": {q.Get("code_verifier")},
}
urlvalues.Merge(data, v.data)
var res vag.Token
req, err := request.New(http.MethodPost, Endpoint.TokenURL, strings.NewReader(data.Encode()), map[string]string{
"Content-Type": request.FormContent,
"Accept": request.JSONContent,
"x-qmauth": qmauthNow(),
})
if err == nil {
err = v.DoJSON(req, &res)
}
return &res, err
}
// Refresh refreshes an IDK token
func (v *Service) Refresh(token *vag.Token) (*vag.Token, error) {
data := url.Values{
"grant_type": {"refresh_token"},
"response_type": {"token id_token"},
"refresh_token": {token.RefreshToken},
}
urlvalues.Merge(data, v.data)
var res vag.Token
req, err := request.New(http.MethodPost, Endpoint.TokenURL, strings.NewReader(data.Encode()), map[string]string{
"Content-Type": request.FormContent,
"Accept": request.JSONContent,
"x-qmauth": qmauthNow(),
})
if err == nil {
err = v.DoJSON(req, &res)
}
return &res, err
}
// TokenSource creates token source. Token is refreshed automatically.
func (v *Service) TokenSource(token *vag.Token) vag.TokenSource {
return vag.RefreshTokenSource(token, v.Refresh)
}

View file

@ -0,0 +1,12 @@
package idkproxy
import "testing"
func TestQmAuth(t *testing.T) {
res := qmauth(16473775)
exp := "d1d93982777c56ed2f0d07c4b66435e821fd69a771c3777e43c3cdc219819107"
if res != exp {
t.Errorf("expected %s, got %s", exp, res)
}
}

View file

@ -0,0 +1,80 @@
package loginapps
import (
"net/http"
"net/url"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/oauth"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/urlvalues"
"golang.org/x/oauth2"
)
const (
BaseURL = "https://login.apps.emea.vwapps.io"
)
var Endpoint = &oauth2.Endpoint{
AuthURL: BaseURL + "/login/v1",
TokenURL: BaseURL + "/refresh/v1",
}
type Service struct {
*request.Helper
}
func New(log *util.Logger) *Service {
return &Service{
Helper: request.NewHelper(log),
}
}
func (v *Service) Exchange(q url.Values) (*Token, error) {
if err := urlvalues.Require(q, "state", "id_token", "access_token", "code"); err != nil {
return nil, err
}
data := map[string]string{
"region": "emea",
"redirect_uri": "weconnect://authenticated",
"state": q.Get("state"),
"id_token": q.Get("id_token"),
"access_token": q.Get("access_token"),
"authorizationCode": q.Get("code"),
}
var res Token
req, err := request.New(http.MethodPost, Endpoint.AuthURL, request.MarshalJSON(data), request.JSONEncoding)
if err == nil {
err = v.DoJSON(req, &res)
}
return &res, err
}
func (v *Service) Refresh(token *Token) (*Token, error) {
req, err := request.New(http.MethodGet, Endpoint.TokenURL, nil, map[string]string{
"Accept": "application/json",
"Authorization": "Bearer " + token.RefreshToken,
})
var res Token
if err == nil {
err = v.DoJSON(req, &res)
}
return &res, err
}
// RefreshToken implements oauth.TokenRefresher
func (v *Service) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
res, err := v.Refresh((*Token)(token))
return (*oauth2.Token)(res), err
}
// TokenSource creates a refreshing oauth2 token source
func (v *Service) TokenSource(token *Token) oauth2.TokenSource {
return oauth.RefreshTokenSource((*oauth2.Token)(token), v)
}

View file

@ -1,4 +1,4 @@
package id
package loginapps
import (
"encoding/json"
@ -7,7 +7,7 @@ import (
"golang.org/x/oauth2"
)
// Token is the VW ID token
// Token is the loginapps token
type Token oauth2.Token
func (t *Token) UnmarshalJSON(data []byte) error {

View file

@ -1,4 +1,4 @@
package id
package loginapps
import (
"encoding/json"

View file

@ -0,0 +1,84 @@
package mbb
import (
"fmt"
"net/http"
"net/url"
"strings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/urlvalues"
"github.com/evcc-io/evcc/vehicle/vag"
)
const (
BaseURL = "https://mbboauth-1d.prd.ece.vwg-connect.com"
TokenURL = BaseURL + "/mbbcoauth/mobile/oauth2/v1/token"
)
type Service struct {
*request.Helper
clientID string
}
func New(log *util.Logger, clientID string) *Service {
return &Service{
Helper: request.NewHelper(log),
clientID: clientID,
}
}
func (v *Service) Exchange(q url.Values) (*vag.Token, error) {
if err := urlvalues.Require(q, "id_token"); err != nil {
return nil, err
}
data := url.Values{
"grant_type": {"id_token"},
"token": {q.Get("id_token")},
"scope": {"sc2:fal"},
}
req, err := request.New(http.MethodPost, TokenURL, strings.NewReader(data.Encode()), map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"X-Client-Id": v.clientID,
})
var res vag.Token
if err == nil {
err = v.DoJSON(req, &res)
}
// check if token response contained error
if errT := res.Error(); err != nil && errT != nil {
err = fmt.Errorf("token exchange: %w", errT)
}
return &res, err
}
func (v *Service) Refresh(token *vag.Token) (*vag.Token, error) {
data := url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {token.RefreshToken},
"scope": {"sc2:fal"},
}
req, err := request.New(http.MethodPost, TokenURL, strings.NewReader(data.Encode()), map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"X-Client-Id": v.clientID,
})
var res vag.Token
if err == nil {
err = v.DoJSON(req, &res)
}
return &res, err
}
// TokenSource creates token source. Token is refreshed automatically.
func (v *Service) TokenSource(token *vag.Token) vag.TokenSource {
return vag.RefreshTokenSource(token, v.Refresh)
}

View file

@ -0,0 +1,42 @@
package service
import (
"net/url"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/vehicle/vag"
"github.com/evcc-io/evcc/vehicle/vag/aazsproxy"
)
// AAZSTokenSource creates a refreshing token source for use with the AAZS api.
// Once the AAZS token expires, it is recreated from the token exchanger (either TokenRefreshService or IDK).
// Return values are the AAZS and token exchanger (TRS or IDK) token sources.
func AAZSTokenSource(log *util.Logger, tox vag.TokenExchanger, azsConfig string, q url.Values) (vag.TokenSource, vag.TokenSource, error) {
token, err := tox.Exchange(q)
if err != nil {
return nil, nil, err
}
trs := tox.TokenSource(token)
azs := aazsproxy.New(log)
mts := vag.MetaTokenSource(func() (*vag.Token, error) {
// get TRS token from refreshing TRS token source
itoken, err := trs.TokenEx()
if err != nil {
return nil, err
}
// exchange TRS id_token for AAZS token
atoken, err := azs.Exchange(azsConfig, itoken.IDToken)
if err != nil {
return nil, err
}
return atoken, err
// produce tokens from refresh MBB token source
}, azs.TokenSource)
return mts, trs, nil
}

View file

@ -0,0 +1,47 @@
package service
import (
"net/url"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/vehicle/vag"
"github.com/evcc-io/evcc/vehicle/vag/mbb"
"github.com/evcc-io/evcc/vehicle/vag/vwidentity"
)
// MbbTokenSource creates a refreshing token source for use with the MBB api.
// Once the MBB token expires, it is recreated from the token exchanger (either TokenRefreshService or IDK)
func MbbTokenSource(log *util.Logger, tox vag.TokenExchanger, clientID string, q url.Values, user, password string) (vag.TokenSource, error) {
q, err := vwidentity.Login(log, q, user, password)
if err != nil {
return nil, err
}
token, err := tox.Exchange(q)
if err != nil {
return nil, err
}
trs := tox.TokenSource(token)
mbb := mbb.New(log, clientID)
mts := vag.MetaTokenSource(func() (*vag.Token, error) {
// get TRS token from refreshing TRS token source
itoken, err := trs.TokenEx()
if err != nil {
return nil, err
}
// exchange TRS id_token for MBB token
mtoken, err := mbb.Exchange(url.Values{"id_token": {itoken.IDToken}})
if err != nil {
return nil, err
}
return mtoken, err
// produce tokens from refresh MBB token source
}, mbb.TokenSource)
return mts, nil
}

View file

@ -0,0 +1,25 @@
package service
import (
"net/url"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/vehicle/vag/tokenrefreshservice"
"github.com/evcc-io/evcc/vehicle/vag/vwidentity"
"golang.org/x/oauth2"
)
func TokenRefreshServiceTokenSource(log *util.Logger, data, q url.Values, user, password string) (oauth2.TokenSource, error) {
q, err := vwidentity.Login(log, q, user, password)
if err != nil {
return nil, err
}
trs := tokenrefreshservice.New(log, data)
token, err := trs.Exchange(q)
if err != nil {
return nil, err
}
return trs.TokenSource(token), nil
}

View file

@ -1,4 +1,4 @@
package vw
package vag
import (
"encoding/json"
@ -11,13 +11,15 @@ import (
// Token is an OAuth2-compatible token that supports the expires_in attribute
type Token struct {
oauth2.Token
err error
IDToken string `json:"id_token,omitempty"`
err error
}
func (t *Token) UnmarshalJSON(data []byte) error {
var s struct {
oauth2.Token
ExpiresIn int64 `json:"expires_in,omitempty"`
IDToken string `json:"id_token,omitempty"`
ExpiresIn int64 `json:"expires_in,omitempty"`
Error *string
ErrorDescription *string `json:"error_description"`
}
@ -25,6 +27,7 @@ func (t *Token) UnmarshalJSON(data []byte) error {
err := json.Unmarshal(data, &s)
if err == nil {
t.Token = s.Token
t.IDToken = s.IDToken
if s.Expiry.IsZero() && s.ExpiresIn != 0 {
t.Expiry = time.Now().Add(time.Second * time.Duration(s.ExpiresIn))

View file

@ -1,4 +1,4 @@
package vw
package vag
import (
"encoding/json"
@ -7,12 +7,16 @@ import (
func TestUnmarshalJSON(t *testing.T) {
var tok Token
data := `{"access_token":"access","refresh_token":"refresh","token_type":"bearer","expires_in":3600}`
data := `{"id_token":"id","access_token":"access","refresh_token":"refresh","token_type":"bearer","expires_in":3600}`
if err := json.Unmarshal([]byte(data), &tok); err != nil {
t.Error(err)
}
if tok.IDToken != "id" {
t.Error("IDToken")
}
if tok.AccessToken != "access" {
t.Error("AccessToken")
}

View file

@ -0,0 +1,75 @@
package tokenrefreshservice
import (
"net/http"
"net/url"
"strings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/urlvalues"
"github.com/evcc-io/evcc/vehicle/vag"
)
const (
BaseURL = "https://tokenrefreshservice.apps.emea.vwapps.io"
CodeExchangeURL = BaseURL + "/exchangeAuthCode"
RefreshTokenURL = BaseURL + "/refreshTokens"
)
type Service struct {
*request.Helper
data url.Values
}
func New(log *util.Logger, q url.Values) *Service {
return &Service{
Helper: request.NewHelper(log),
data: q,
}
}
func (v *Service) Exchange(q url.Values) (*vag.Token, error) {
if err := urlvalues.Require(q, "id_token", "code"); err != nil {
return nil, err
}
data := url.Values{
"auth_code": {q.Get("code")},
"id_token": {q.Get("id_token")},
}
urlvalues.Merge(data, v.data, q)
var res vag.Token
req, err := request.New(http.MethodPost, CodeExchangeURL, strings.NewReader(data.Encode()), request.URLEncoding)
if err == nil {
err = v.DoJSON(req, &res)
}
return &res, err
}
func (v *Service) Refresh(token *vag.Token) (*vag.Token, error) {
data := url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {token.RefreshToken},
}
urlvalues.Merge(data, v.data)
var res vag.Token
req, err := request.New(http.MethodPost, RefreshTokenURL, strings.NewReader(data.Encode()), request.URLEncoding)
if err == nil {
err = v.DoJSON(req, &res)
}
return &res, err
}
// TokenSource creates token source. Token is refreshed automatically.
func (v *Service) TokenSource(token *vag.Token) vag.TokenSource {
return vag.RefreshTokenSource(token, v.Refresh)
}

144
vehicle/vag/tokensource.go Normal file
View file

@ -0,0 +1,144 @@
package vag
import (
"net/url"
"sync"
"time"
"github.com/imdario/mergo"
"golang.org/x/oauth2"
)
// TokenSource is a VAG token source compatible with oauth2.TokenSource
type TokenSource interface {
// Token returns an OAuth2 compatible token (id_token omitted)
Token() (*oauth2.Token, error)
// TokenEx returns the extended VAG token (id_token included)
TokenEx() (*Token, error)
}
// TokenExchanger exchanges a VW identity response into a (refreshing) VAG token source
type TokenExchanger interface {
Exchange(q url.Values) (*Token, error)
TokenSource(token *Token) TokenSource
}
// TokenRefresher refreshes a token
type TokenRefresher func(*Token) (*Token, error)
var _ TokenSource = (*tokenSource)(nil)
type tokenSource struct {
mu sync.Mutex
token *Token
new TokenRefresher
}
func RefreshTokenSource(token *Token, refresher TokenRefresher) *tokenSource {
return &tokenSource{token: token, new: refresher}
}
// Token returns an oauth2 token or an error
func (ts *tokenSource) Token() (*oauth2.Token, error) {
token, err := ts.TokenEx()
if err != nil {
return nil, err
}
return &token.Token, err
}
func (ts *tokenSource) TokenEx() (*Token, error) {
ts.mu.Lock()
defer ts.mu.Unlock()
var err error
if time.Until(ts.token.Expiry) < time.Minute {
var token *Token
if token, err = ts.new(ts.token); err == nil {
err = ts.mergeToken(token)
}
}
return ts.token, err
}
// mergeToken updates a token while preventing wiping the refresh token
func (ts *tokenSource) mergeToken(t *Token) error {
return mergo.Merge(ts.token, t, mergo.WithOverride)
}
type metaTokenSource struct {
mu sync.Mutex
ts TokenSource
newT func() (*Token, error)
newTS func(*Token) TokenSource
}
// MetaTokenSource creates a token source that is created using the
// `newTS` function or recreated once it fails to return tokens.
// The recreation uses a new bootstrap token provided by the `newT` function.
func MetaTokenSource(newT func() (*Token, error), newTS func(*Token) TokenSource) *metaTokenSource {
return &metaTokenSource{
newT: newT,
newTS: newTS,
}
}
// Token returns an oauth2 token or an error
func (ts *metaTokenSource) Token() (*oauth2.Token, error) {
token, err := ts.TokenEx()
if err != nil {
return nil, err
}
return &token.Token, err
}
// Token returns a vag token or an error
func (ts *metaTokenSource) TokenEx() (*Token, error) {
ts.mu.Lock()
defer ts.mu.Unlock()
if ts.ts == nil {
token, err := ts.newT()
if err != nil {
return nil, err
}
ts.ts = ts.newTS(token)
}
token, err := ts.ts.TokenEx()
if err != nil {
// token source doesn't work anymore, reset it
ts.ts = nil
}
return token, err
}
type idTokenSource struct {
ts TokenSource
}
// IDTokenSource provides an oauth2 token source with access_token populated from vag id_token
func IDTokenSource(ts TokenSource) oauth2.TokenSource {
return &idTokenSource{ts}
}
// Token returns an oauth2 token or an error
func (ts *idTokenSource) Token() (*oauth2.Token, error) {
token, err := ts.ts.TokenEx()
if err != nil {
return nil, err
}
idToken := &oauth2.Token{
TokenType: token.TokenType,
AccessToken: token.IDToken,
Expiry: token.Expiry,
}
return idToken, err
}

View file

@ -0,0 +1,40 @@
package vag
import (
"testing"
"golang.org/x/oauth2"
)
func TestMerge(t *testing.T) {
ts := &tokenSource{
token: &Token{
IDToken: "id",
Token: oauth2.Token{
AccessToken: "access",
RefreshToken: "refresh",
},
},
}
r := &Token{
IDToken: "foo",
Token: oauth2.Token{
AccessToken: "bar",
},
}
if err := ts.mergeToken(r); err != nil {
t.Error(err)
}
if ts.token.IDToken != "foo" {
t.Error("unexpected id token", ts.token)
}
if ts.token.AccessToken != "bar" {
t.Error("unexpected access token", ts.token)
}
if ts.token.RefreshToken != "refresh" {
t.Error("unexpected refresh token", ts.token)
}
}

View file

@ -1,4 +1,4 @@
package vw
package vwidentity
import (
"errors"
@ -8,38 +8,66 @@ import (
"net/url"
"strings"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/urlvalues"
"github.com/evcc-io/evcc/vehicle/vag"
"github.com/google/uuid"
"golang.org/x/net/publicsuffix"
"golang.org/x/oauth2"
)
// IDTokenProvider provides the identity.vwgroup.io login token source
type IDTokenProvider struct {
log *util.Logger
*request.Helper
uri, user, password string
const (
BaseURL = "https://identity.vwgroup.io"
WellKnown = BaseURL + "/.well-known/openid-configuration"
)
var Endpoint = &oauth2.Endpoint{
AuthURL: BaseURL + "/oidc/v1/authorize",
TokenURL: BaseURL + "/oidc/v1/token",
}
// NewIDTokenProvider creates VW identity
func NewIDTokenProvider(log *util.Logger, uri, user, password string) *IDTokenProvider {
v := &IDTokenProvider{
log: log,
Helper: request.NewHelper(log),
uri: uri,
user: user,
password: password,
// Login performs VW identity login with optional code challenge
func Login(log *util.Logger, q url.Values, user, password string) (url.Values, error) {
return LoginWithAuthURL(log, Endpoint.AuthURL, q, user, password)
}
func LoginWithAuthURL(log *util.Logger, uri string, q url.Values, user, password string) (url.Values, error) {
var verify func(url.Values)
// add code challenge
q = urlvalues.Copy(q)
if rt := q.Get("response_type"); strings.Contains(rt, "code") {
verify = vag.ChallengeAndVerifier(q)
}
return v
uri = fmt.Sprintf("%s?%s", uri, q.Encode())
vwi := New(log)
q, err := vwi.Login(uri, user, password)
if err != nil {
return nil, err
}
if verify != nil {
verify(q)
}
return q, nil
}
type Service struct {
*request.Helper
}
func New(log *util.Logger) *Service {
return &Service{
Helper: request.NewHelper(log),
}
}
// Login performs the identity.vwgroup.io login
func (v *IDTokenProvider) Login() (url.Values, error) {
if v.user == "" || v.password == "" {
return nil, api.ErrMissingCredentials
}
func (v *Service) Login(uri, user, password string) (url.Values, error) {
// track cookies and don't follow redirects
jar, _ := cookiejar.New(&cookiejar.Options{
PublicSuffixList: publicsuffix.List,
@ -55,14 +83,14 @@ func (v *IDTokenProvider) Login() (url.Values, error) {
return nil
}
var vars FormVars
// add nonce and state
query := url.Values{
"nonce": []string{util.RandomString(43)},
"state": []string{util.RandomString(43)},
"state": []string{uuid.NewString()},
}
uri := v.uri + "&" + query.Encode()
var vars FormVars
uri = uri + "&" + query.Encode()
// GET identity.vwgroup.io/signin-service/v1/signin/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com?relayState=15404cb51c8b4cc5efeee1d2c2a73e5b41562faa
resp, err := v.Get(uri)
@ -75,14 +103,14 @@ func (v *IDTokenProvider) Login() (url.Values, error) {
// 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{
data := url.Values{
"_csrf": {vars.Inputs["_csrf"]},
"relayState": {vars.Inputs["relayState"]},
"hmac": {vars.Inputs["hmac"]},
"email": {v.user},
})
"email": {user},
}
uri = IdentityURI + vars.Action
uri = BaseURL + vars.Action
if resp, err = v.PostForm(uri, data); err == nil {
if params, err = ParseCredentialsPage(resp.Body); err == nil && params.TemplateModel.Error != "" {
err = errors.New(params.TemplateModel.Error)
@ -93,13 +121,13 @@ func (v *IDTokenProvider) Login() (url.Values, error) {
// 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{
data := url.Values{
"_csrf": {params.CsrfToken},
"relayState": {params.TemplateModel.RelayState},
"hmac": {params.TemplateModel.Hmac},
"email": {v.user},
"password": {v.password},
})
"email": {user},
"password": {password},
}
// reuse url from identifier step before
uri = strings.ReplaceAll(uri, params.TemplateModel.IdentifierUrl, params.TemplateModel.PostAction)
@ -118,9 +146,10 @@ func (v *IDTokenProvider) Login() (url.Values, error) {
}
if u := resp.Request.URL.Query().Get("updated"); err == nil && u != "" {
v.log.WARN.Println("accepting updated tos", u)
if resp, err = v.postTos(resp.Request.URL.String()); err == nil {
resp.Body.Close()
} else {
err = fmt.Errorf("updated ToS: %w", err)
}
}
}
@ -141,7 +170,7 @@ func (v *IDTokenProvider) Login() (url.Values, error) {
return nil, err
}
func (v *IDTokenProvider) postTos(uri string) (*http.Response, error) {
func (v *Service) postTos(uri string) (*http.Response, error) {
var vars FormVars
resp, err := v.Get(uri)
if err == nil {
@ -154,7 +183,7 @@ func (v *IDTokenProvider) postTos(uri string) (*http.Response, error) {
data.Set(k, v)
}
uri := IdentityURI + vars.Action
uri := BaseURL + vars.Action
resp, err = v.PostForm(uri, data)
}

View file

@ -1,4 +1,4 @@
package vw
package vwidentity
import (
"encoding/json"

View file

@ -1,13 +1,14 @@
package vehicle
import (
"fmt"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/vehicle/id"
"github.com/evcc-io/evcc/vehicle/vag/loginapps"
"github.com/evcc-io/evcc/vehicle/vag/vwidentity"
)
// https://github.com/TA2k/ioBroker.vw-connect
@ -43,14 +44,19 @@ func NewIDFromConfig(other map[string]interface{}) (api.Vehicle, error) {
}
log := util.NewLogger("id").Redact(cc.User, cc.Password, cc.VIN)
identity := id.NewIdentity(log, cc.User, cc.Password)
err := identity.Login()
q, err := vwidentity.LoginWithAuthURL(log, id.LoginURL, id.AuthParams, cc.User, cc.Password)
if err != nil {
return v, fmt.Errorf("login failed: %w", err)
return nil, err
}
api := id.NewAPI(log, identity)
apps := loginapps.New(log)
token, err := apps.Exchange(q)
if err != nil {
return nil, err
}
api := id.NewAPI(log, apps.TokenSource(token))
api.Client.Timeout = cc.Timeout
cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles)

View file

@ -1,12 +1,13 @@
package vehicle
import (
"fmt"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/vehicle/vag/service"
"github.com/evcc-io/evcc/vehicle/vag/tokenrefreshservice"
"github.com/evcc-io/evcc/vehicle/vw"
)
@ -45,13 +46,13 @@ func NewVWFromConfig(other map[string]interface{}) (api.Vehicle, error) {
log := util.NewLogger("vw").Redact(cc.User, cc.Password, cc.VIN)
identity := vw.NewIdentity(log, vw.AuthClientID, vw.AuthParams, cc.User, cc.Password)
err := identity.Login()
trs := tokenrefreshservice.New(log, vw.TRSParams)
ts, err := service.MbbTokenSource(log, trs, vw.AuthClientID, vw.AuthParams, cc.User, cc.Password)
if err != nil {
return v, fmt.Errorf("login failed: %w", err)
return nil, err
}
api := vw.NewAPI(log, identity, vw.Brand, vw.Country)
api := vw.NewAPI(log, ts, vw.Brand, vw.Country)
api.Client.Timeout = cc.Timeout
cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles)

View file

@ -1,22 +0,0 @@
package vw
import (
"crypto/sha256"
"encoding/base64"
"math/rand"
)
// ChallengeVerifier returns a challenge/verifier base64-encoded code combination
func ChallengeVerifier() (string, string, error) {
bytes := make([]byte, 32)
n, err := rand.Read(bytes)
if n != 32 || err != nil {
return "", "", err
}
verifier := base64.RawURLEncoding.EncodeToString(bytes)
sha := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(sha[:])
return challenge, verifier, nil
}

View file

@ -1,109 +0,0 @@
package vw
import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/oauth"
"github.com/evcc-io/evcc/util/request"
"golang.org/x/oauth2"
)
// https://identity.vwgroup.io/.well-known/openid-configuration
const (
// IdentityURI is the VW OIDC identity provider uri
IdentityURI = "https://identity.vwgroup.io"
// OauthTokenURI is used for refreshing tokens
OauthTokenURI = "https://mbboauth-1d.prd.ece.vwg-connect.com/mbbcoauth/mobile/oauth2/v1/token"
// OauthRevokeURI is used for revoking tokens
OauthRevokeURI = "https://mbboauth-1d.prd.ece.vwg-connect.com/mbbcoauth/mobile/oauth2/v1/revoke"
)
type Identity struct {
*request.Helper
oauth2.TokenSource
idtp *IDTokenProvider
clientID string
refresher oauth.TokenRefresher
}
func NewIdentity(log *util.Logger, clientID string, query url.Values, user, password string) *Identity {
uri := fmt.Sprintf("%s/oidc/v1/authorize?%s", IdentityURI, query.Encode())
return &Identity{
Helper: request.NewHelper(log),
clientID: clientID,
idtp: NewIDTokenProvider(log, uri, user, password),
refresher: NewTokenRefresher(log, clientID),
}
}
func (v *Identity) Login() error {
token, err := v.login()
if err != nil {
return err
}
v.TokenSource = oauth.RefreshTokenSource(&token.Token, v)
return nil
}
// login performs the login using the brand-specific subflow for obtaining the
// id token and finally exchanges the id token for access and refresh tokens
func (v *Identity) login() (Token, error) {
q, err := v.idtp.Login()
if err == nil && q.Get("id_token") == "" {
err = errors.New("missing id_token")
}
var token Token
if err == nil {
data := url.Values(map[string][]string{
"grant_type": {"id_token"},
"scope": {"sc2:fal"},
"token": {q.Get("id_token")},
})
var req *http.Request
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 {
err = v.DoJSON(req, &token)
}
// check if token response contained error
if errT := token.Error(); err != nil && errT != nil {
err = fmt.Errorf("token exchange: %w", errT)
}
}
return token, err
}
// RefreshToken implements oauth.TokenRefresher
func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
token, err := v.refresher.RefreshToken(token)
if err == nil {
return token, nil
}
// re-login
var res Token
if se, ok := err.(request.StatusError); ok && se.HasStatus(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden) {
res, err = v.login()
}
return &res.Token, err
}

View file

@ -11,9 +11,14 @@ const (
)
// Authorization parameters
var AuthParams = url.Values(map[string][]string{
"response_type": {"id_token token"},
var AuthParams = url.Values{
"response_type": {"code id_token token"},
"client_id": {"9496332b-ea03-4091-a224-8c746b885068@apps_vw-dilab_com"},
"redirect_uri": {"carnet://identity-kit/login"},
"scope": {"openid profile mbb"}, // cars birthdate nickname address phone
})
}
// TokenRefreshService parameters
var TRSParams = url.Values{
"brand": {"vw"},
}

View file

@ -1,46 +0,0 @@
package vw
import (
"net/http"
"net/url"
"strings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"golang.org/x/oauth2"
)
// TokenRefresher performs the MBBOauth token refresh without re-login
type TokenRefresher struct {
*request.Helper
clientID string
}
// NewTokenRefresher creates an MBBOauth token refresher
func NewTokenRefresher(log *util.Logger, clientID string) *TokenRefresher {
return &TokenRefresher{
Helper: request.NewHelper(log),
clientID: clientID,
}
}
// RefreshToken implements oauth.TokenRefresher
func (v *TokenRefresher) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
data := url.Values(map[string][]string{
"grant_type": {"refresh_token"},
"refresh_token": {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": v.clientID,
})
var res Token
if err == nil {
err = v.DoJSON(req, &res)
}
return &res.Token, err
}