Repository: evcc-io/evcc
Pull request #2968 (2022-03-23 06:44:15)

Refactor VAG integration
This PR is a full rewrite of the VAG integration. Based on that it provides support for

    type: etron

which supports the sofar problematic Audi Q4.

Pjg-Git: tested. works.
Thank you.

Patch:
diff --git a/util/urlvalues/url.go b/util/urlvalues/url.go
new file mode 100644
index 00000000..5fa26209
--- /dev/null
+++ b/util/urlvalues/url.go
@@ -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
+}
diff --git a/vehicle/audi-etron.go b/vehicle/audi-etron.go
new file mode 100644
index 00000000..b766b2a5
--- /dev/null
+++ b/vehicle/audi-etron.go
@@ -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
+}
diff --git a/vehicle/audi.go b/vehicle/audi.go
index f3c0f44c..6151bc16 100644
--- a/vehicle/audi.go
+++ b/vehicle/audi.go
@@ -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)
diff --git a/vehicle/audi/etron/api.go b/vehicle/audi/etron/api.go
new file mode 100644
index 00000000..794edbdd
--- /dev/null
+++ b/vehicle/audi/etron/api.go
@@ -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
+}
diff --git a/vehicle/audi/etron/params.go b/vehicle/audi/etron/params.go
new file mode 100644
index 00000000..00e6d79c
--- /dev/null
+++ b/vehicle/audi/etron/params.go
@@ -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"
diff --git a/vehicle/audi/params.go b/vehicle/audi/params.go
index b321a88f..2d672b04 100644
--- a/vehicle/audi/params.go
+++ b/vehicle/audi/params.go
@@ -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:///"},
+}
diff --git a/vehicle/id/identity.go b/vehicle/id/identity.go
deleted file mode 100644
index 480d7c99..00000000
--- a/vehicle/id/identity.go
+++ /dev/null
@@ -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
-}
diff --git a/vehicle/id/params.go b/vehicle/id/params.go
index 213e28d5..e0dec7cf 100644
--- a/vehicle/id/params.go
+++ b/vehicle/id/params.go
@@ -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
-})
+}
diff --git a/vehicle/seat.go b/vehicle/seat.go
index 8766240f..ddfc8f95 100644
--- a/vehicle/seat.go
+++ b/vehicle/seat.go
@@ -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)
diff --git a/vehicle/seat/params.go b/vehicle/seat/params.go
index c1f04989..457b4fe6 100644
--- a/vehicle/seat/params.go
+++ b/vehicle/seat/params.go
@@ -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"},
+}
diff --git a/vehicle/enyaq.go b/vehicle/skoda-enyaq.go
similarity index 66%
rename from vehicle/enyaq.go
rename to vehicle/skoda-enyaq.go
index 2d841a45..2ea8f4b8 100644
--- a/vehicle/enyaq.go
+++ b/vehicle/skoda-enyaq.go
@@ -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)
diff --git a/vehicle/skoda.go b/vehicle/skoda.go
index 7627ecaa..e5f78d31 100644
--- a/vehicle/skoda.go
+++ b/vehicle/skoda.go
@@ -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)
diff --git a/vehicle/skoda/connect/params.go b/vehicle/skoda/connect/params.go
new file mode 100644
index 00000000..fa93a04f
--- /dev/null
+++ b/vehicle/skoda/connect/params.go
@@ -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
+}
diff --git a/vehicle/skoda/identity.go b/vehicle/skoda/identity.go
deleted file mode 100644
index 229c4509..00000000
--- a/vehicle/skoda/identity.go
+++ /dev/null
@@ -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
-}
diff --git a/vehicle/skoda/params.go b/vehicle/skoda/params.go
index fe83537e..187a8350 100644
--- a/vehicle/skoda/params.go
+++ b/vehicle/skoda/params.go
@@ -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"},
+}
diff --git a/vehicle/vag/aazsproxy/endpoint.go b/vehicle/vag/aazsproxy/endpoint.go
new file mode 100644
index 00000000..0fcefb6b
--- /dev/null
+++ b/vehicle/vag/aazsproxy/endpoint.go
@@ -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)
+}
diff --git a/vehicle/vag/challenge.go b/vehicle/vag/challenge.go
new file mode 100644
index 00000000..716d1361
--- /dev/null
+++ b/vehicle/vag/challenge.go
@@ -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())
+	}
+}
diff --git a/vehicle/vag/idkproxy/endpoint.go b/vehicle/vag/idkproxy/endpoint.go
new file mode 100644
index 00000000..eccacb32
--- /dev/null
+++ b/vehicle/vag/idkproxy/endpoint.go
@@ -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)
+}
diff --git a/vehicle/vag/loginapps/endpoint.go b/vehicle/vag/loginapps/endpoint.go
new file mode 100644
index 00000000..08e0df76
--- /dev/null
+++ b/vehicle/vag/loginapps/endpoint.go
@@ -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)
+}
diff --git a/vehicle/id/token.go b/vehicle/vag/loginapps/token.go
similarity index 88%
rename from vehicle/id/token.go
rename to vehicle/vag/loginapps/token.go
index fec51529..cd1c6ebc 100644
--- a/vehicle/id/token.go
+++ b/vehicle/vag/loginapps/token.go
@@ -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 {
diff --git a/vehicle/vag/mbb/endpoint.go b/vehicle/vag/mbb/endpoint.go
new file mode 100644
index 00000000..be3b4280
--- /dev/null
+++ b/vehicle/vag/mbb/endpoint.go
@@ -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)
+}
diff --git a/vehicle/vag/service/azs.go b/vehicle/vag/service/azs.go
new file mode 100644
index 00000000..1c226dfb
--- /dev/null
+++ b/vehicle/vag/service/azs.go
@@ -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
+}
diff --git a/vehicle/vag/service/mbb.go b/vehicle/vag/service/mbb.go
new file mode 100644
index 00000000..6257bedb
--- /dev/null
+++ b/vehicle/vag/service/mbb.go
@@ -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
+}
diff --git a/vehicle/vag/service/tokenrefreshservice.go b/vehicle/vag/service/tokenrefreshservice.go
new file mode 100644
index 00000000..2ac19614
--- /dev/null
+++ b/vehicle/vag/service/tokenrefreshservice.go
@@ -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
+}
diff --git a/vehicle/vw/token.go b/vehicle/vag/token.go
similarity index 78%
rename from vehicle/vw/token.go
rename to vehicle/vag/token.go
index 3b8dc89f..dd30f98a 100644
--- a/vehicle/vw/token.go
+++ b/vehicle/vag/token.go
@@ -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))
diff --git a/vehicle/vag/tokenrefreshservice/endpoint.go b/vehicle/vag/tokenrefreshservice/endpoint.go
new file mode 100644
index 00000000..367c62b6
--- /dev/null
+++ b/vehicle/vag/tokenrefreshservice/endpoint.go
@@ -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)
+}
diff --git a/vehicle/vag/tokensource.go b/vehicle/vag/tokensource.go
new file mode 100644
index 00000000..d6427e1b
--- /dev/null
+++ b/vehicle/vag/tokensource.go
@@ -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
+}
diff --git a/vehicle/vw/idtokenprovider.go b/vehicle/vag/vwidentity/endpoint.go
similarity index 71%
rename from vehicle/vw/idtokenprovider.go
rename to vehicle/vag/vwidentity/endpoint.go
index 32e45aa2..d1b48bcc 100644
--- a/vehicle/vw/idtokenprovider.go
+++ b/vehicle/vag/vwidentity/endpoint.go
@@ -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
 }
 
-// Login performs the identity.vwgroup.io login
-func (v *IDTokenProvider) Login() (url.Values, error) {
-	if v.user == "" || v.password == "" {
-		return nil, api.ErrMissingCredentials
+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 *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)
 	}
 
diff --git a/vehicle/vw/forms.go b/vehicle/vag/vwidentity/forms.go
similarity index 99%
rename from vehicle/vw/forms.go
rename to vehicle/vag/vwidentity/forms.go
index 92ad3ab5..dc341339 100644
--- a/vehicle/vw/forms.go
+++ b/vehicle/vag/vwidentity/forms.go
@@ -1,4 +1,4 @@
-package vw
+package vwidentity
 
 import (
 	"encoding/json"
diff --git a/vehicle/id.go b/vehicle/vw-id.go
similarity index 75%
rename from vehicle/id.go
rename to vehicle/vw-id.go
index 7c3f7568..deed55e6 100644
--- a/vehicle/id.go
+++ b/vehicle/vw-id.go
@@ -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
+	}
+
+	apps := loginapps.New(log)
+	token, err := apps.Exchange(q)
+	if err != nil {
+		return nil, err
 	}
 
-	api := id.NewAPI(log, identity)
+	api := id.NewAPI(log, apps.TokenSource(token))
 	api.Client.Timeout = cc.Timeout
 
 	cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles)
diff --git a/vehicle/vw.go b/vehicle/vw.go
index 96053924..3ec49bbb 100644
--- a/vehicle/vw.go
+++ b/vehicle/vw.go
@@ -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)
diff --git a/vehicle/vw/functions.go b/vehicle/vw/functions.go
deleted file mode 100644
index 8e75786f..00000000
--- a/vehicle/vw/functions.go
+++ /dev/null
@@ -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
-}
diff --git a/vehicle/vw/identity.go b/vehicle/vw/identity.go
deleted file mode 100644
index 93433485..00000000
--- a/vehicle/vw/identity.go
+++ /dev/null
@@ -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
-}
diff --git a/vehicle/vw/params.go b/vehicle/vw/params.go
index d1eb2163..436b6d97 100644
--- a/vehicle/vw/params.go
+++ b/vehicle/vw/params.go
@@ -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"},
+}
diff --git a/vehicle/vw/tokenrefresher.go b/vehicle/vw/tokenrefresher.go
deleted file mode 100644
index 5303fcde..00000000
--- a/vehicle/vw/tokenrefresher.go
+++ /dev/null
@@ -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
-}


Test patch:
diff --git a/vehicle/vag/idkproxy/qmauth_test.go b/vehicle/vag/idkproxy/qmauth_test.go
new file mode 100644
index 00000000..d09a4794
--- /dev/null
+++ b/vehicle/vag/idkproxy/qmauth_test.go
@@ -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)
+	}
+}
diff --git a/vehicle/id/token_test.go b/vehicle/vag/loginapps/token_test.go
similarity index 96%
rename from vehicle/id/token_test.go
rename to vehicle/vag/loginapps/token_test.go
index 20fc8a69..ef4fb12e 100644
--- a/vehicle/id/token_test.go
+++ b/vehicle/vag/loginapps/token_test.go
@@ -1,4 +1,4 @@
-package id
+package loginapps
 
 import (
 	"encoding/json"
diff --git a/vehicle/vw/token_test.go b/vehicle/vag/token_test.go
similarity index 82%
rename from vehicle/vw/token_test.go
rename to vehicle/vag/token_test.go
index c33118bc..96d22dfa 100644
--- a/vehicle/vw/token_test.go
+++ b/vehicle/vag/token_test.go
@@ -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")
 	}
diff --git a/vehicle/vag/tokensource_test.go b/vehicle/vag/tokensource_test.go
new file mode 100644
index 00000000..121435d3
--- /dev/null
+++ b/vehicle/vag/tokensource_test.go
@@ -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)
+	}
+}