Repository: evcc-io/evcc
Pull request #2960 (2022-03-21 16:34:05)

Porsche: simplify login


Patch:
diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml
index f86e5982..dd8ce622 100644
--- a/.github/workflows/default.yml
+++ b/.github/workflows/default.yml
@@ -17,7 +17,7 @@ jobs:
     - name: Set up Go
       uses: actions/setup-go@v2
       with:
-        go-version: ^1.17
+        go-version: ^1.18
       id: go
 
     - name: Set up Node
@@ -64,6 +64,7 @@ jobs:
         version: latest
         skip-pkg-cache: true
         skip-build-cache: true
+
     - name: Test
       run: make test
 
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 68947b6e..e6bda1a4 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -83,7 +83,7 @@ jobs:
     - name: Set up Go
       uses: actions/setup-go@v2
       with:
-        go-version: ^1.17
+        go-version: ^1.18
       id: go
 
     - name: Set up Node
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3bc4edbf..d12d345c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -63,7 +63,7 @@ jobs:
     - name: Setup Go
       uses: actions/setup-go@v2
       with:
-        go-version: ^1.17
+        go-version: ^1.18
       id: go
 
     - name: Setup Node
diff --git a/.golangci.yml b/.golangci.yml
index 36346e31..6723efec 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,4 +1,10 @@
+run:
+  go: 1.18
 issues:
   exclude:
   - .regActualCurrent. is unused
   - "`routeLogger` is unused"
+linters-settings:
+  staticcheck:
+    checks:
+      - '-SA1019'
diff --git a/Dockerfile b/Dockerfile
index 308dd113..d39799ea 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -18,7 +18,7 @@ RUN make clean ui
 
 
 # STEP 2 build executable binary
-FROM golang:1.17-alpine as builder
+FROM golang:1.18-alpine as builder
 
 # Install git + SSL ca certificates.
 # Git is required for fetching the dependencies.
diff --git a/README.md b/README.md
index 1ef0bb7a..41bd549a 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
 # evcc 🚘☀️
 
-[![Open in Visual Studio Code](https://open.vscode.dev/badges/open-in-vscode.svg)](https://open.vscode.dev/evcc-io/evcc)
+[![Open in Visual Studio Code](https://img.shields.io/static/v1?logo=visualstudiocode&label=&message=Open%20in%20Visual%20Studio%20Code&labelColor=2c2c32&color=007acc&logoColor=007acc)](https://open.vscode.dev/evcc-io/evcc)
 [![Build Status](https://github.com/evcc-io/evcc/workflows/Build/badge.svg)](https://github.com/evcc-io/evcc/actions?query=workflow%3ABuild)
 [![Code Quality](https://goreportcard.com/badge/github.com/evcc-io/evcc)](https://goreportcard.com/report/github.com/evcc-io/evcc)
 [![Latest Version](https://img.shields.io/github/release/evcc-io/evcc.svg)](https://github.com/evcc-io/evcc/releases)
@@ -14,17 +14,22 @@ Evcc is an extensible EV Charge Controller with PV integration implemented in [G
 ## Features
 
 - simple and clean user interface
-- multiple [chargers](https://docs.evcc.io/docs/devices/chargers):
-  - ABL eMH1, Alfen Eve, cFos PowerBrain, Daheimladen, [EVSEWifi/ smartWB](https://www.evse-wifi.de), go-eCharger, HardyBarth (eCB1, cPH1, cPH2) Heidelberg Energy Control, Innogy (eBox), KEBA/BMW, Menneckes  Amtron (Xtra/Premium), NRGkick, [openWB (includes Pro)](https://openwb.de/), PC Electric (includes Garo), Vestel, Wallbe, Webasto Live, Mobile Charger Connect, EEBUS (experimental)
+- wide range of supported [chargers](https://docs.evcc.io/docs/devices/chargers):
+  - ABL eMH1, Alfen Eve, cFos PowerBrain, Daheimladen, [EVSEWifi/ smartWB](https://www.evse-wifi.de), go-eCharger, HardyBarth (eCB1, cPH1, cPH2) Heidelberg Energy Control, Innogy (eBox), KEBA/BMW, Menneckes  Amtron (Xtra/Premium), NRGkick, [openWB (includes Pro)](https://openwb.de/), PC Electric (includes Garo), Vestel, Wallbe, Webasto Live, Mobile Charger Connect
+  - experimental EEBus support (PMCC)
   - Build-your-own: Phoenix (includes ESL Walli), [EVSE DIN](https://www.evse-wifi.de/produkt-schlagwort/simple-evse-wb/)
   - Smart-Home outlets: FritzDECT, Shelly, Tasmota, TP-Link
-- multiple [meters](https://docs.evcc.io/docs/devices/meters): ModBus (Eastron SDM, MPM3PM, SBC ALE3 and many more), Discovergy (using HTTP plugin), SMA Sunny Home Manager and Energy Meter, KOSTAL Smart Energy Meter (KSEM, EMxx), any Sunspec-compatible inverter or home battery devices (Fronius, SMA, SolarEdge, KOSTAL, STECA, E3DC, ...), Tesla PowerWall, LG ESS HOME, OpenEMS (FENECON)
+- wide range of supported [meters](https://docs.evcc.io/docs/devices/meters) for grid, pv, battery and charger:
+  - ModBus: Eastron SDM, MPM3PM, ORNO WE, SBC ALE3 and many more, see <https://github.com/volkszaehler/mbmd#supported-devices> for a complete list
+  - Integrated systems: SMA Sunny Home Manager and Energy Meter, KOSTAL Smart Energy Meter (KSEM, EMxx)
+  - Sunspec-compatible inverter or home battery devices: Fronius, SMA, SolarEdge, KOSTAL, STECA, E3DC, ...
+  - and various others: Discovergy, Tesla PowerWall, LG ESS HOME, OpenEMS (FENECON)
 - [vehicle](https://docs.evcc.io/docs/devices/vehicles) integration (state of charge, remote charge, battery and preconditioning status):
   - Audi, BMW, Citroën, Dacia, Fiat, Ford, Hyundai, Jaguar, Kia, Landrover, Mercedes, Mini, Nissan, Opel, Peugeot, Porsche, Renault, Seat, Smart, Skoda, Tesla, Volkswagen, Volvo
   - Services: OVMS, Tronity
   - Scooters: Niu, Silence
-
-- [plugins](https://docs.evcc.io/docs/reference/plugins) for integrating with any charger/ meter/ vehicle: Modbus (meters and grid inverters), HTTP, MQTT, Javascript, WebSockets and shell scripts
+- [plugins](https://docs.evcc.io/docs/reference/plugins) for integrating with any charger/ meter/ vehicle:
+  - Modbus, HTTP, MQTT, Javascript, WebSockets and shell scripts
 - status [notifications](https://docs.evcc.io/docs/reference/configuration/messaging) using [Telegram](https://telegram.org), [PushOver](https://pushover.net) and [many more](https://containrrr.dev/shoutrrr/)
 - logging using [InfluxDB](https://www.influxdata.com) and [Grafana](https://grafana.com/grafana/)
 - granular charge power control down to mA steps with supported chargers (labeled by e.g. smartWB als [OLC](https://board.evse-wifi.de/viewtopic.php?f=16&t=187))
diff --git a/charger/alfen.go b/charger/alfen.go
index af8d9db9..42765473 100644
--- a/charger/alfen.go
+++ b/charger/alfen.go
@@ -117,8 +117,14 @@ func (wb *Alfen) Status() (api.ChargeStatus, error) {
 	}
 
 	switch r := rune(b[0]); r {
-	case 'A', 'B', 'C', 'D', 'E', 'F':
+	case 'A', 'B', 'D', 'E', 'F':
 		return api.ChargeStatus(r), nil
+	case 'C':
+		// C1 is "connected"
+		if rune(b[1]) == '1' {
+			return api.StatusB, nil
+		}
+		return api.StatusC, nil
 	default:
 		return api.StatusNone, fmt.Errorf("invalid status: %0x", b[:1])
 	}
diff --git a/charger/nrgble.go b/charger/nrgble.go
index 5fe50899..bb93c12f 100644
--- a/charger/nrgble.go
+++ b/charger/nrgble.go
@@ -1,5 +1,4 @@
 //go:build !linux
-// +build !linux
 
 package charger
 
diff --git a/cmd/demoport.go b/cmd/demoport.go
index 9c435676..f6994770 100644
--- a/cmd/demoport.go
+++ b/cmd/demoport.go
@@ -1,5 +1,4 @@
 //go:build !gokrazy
-// +build !gokrazy
 
 package cmd
 
diff --git a/cmd/demoport_gokrazy.go b/cmd/demoport_gokrazy.go
index b90c53cd..3ae82ea4 100644
--- a/cmd/demoport_gokrazy.go
+++ b/cmd/demoport_gokrazy.go
@@ -1,5 +1,4 @@
 //go:build gokrazy
-// +build gokrazy
 
 package cmd
 
diff --git a/cmd/gokrazy.go b/cmd/gokrazy.go
index 85d5890b..c84a7207 100644
--- a/cmd/gokrazy.go
+++ b/cmd/gokrazy.go
@@ -1,5 +1,4 @@
 //go:build gokrazy
-// +build gokrazy
 
 package cmd
 
diff --git a/cmd/health.go b/cmd/health.go
index 68dffdc9..3d9ca2e0 100644
--- a/cmd/health.go
+++ b/cmd/health.go
@@ -1,5 +1,4 @@
 //go:build !windows
-// +build !windows
 
 package cmd
 
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 0bea1878..db7f96e2 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -46,6 +46,8 @@ const (
 	minActiveCurrent      = 1.0 // minimum current at which a phase is treated as active
 	vehicleDetectInterval = 3 * time.Minute
 	vehicleDetectDuration = 10 * time.Minute
+
+	guardGracePeriod = 10 * time.Second // allow out of sync during this timespan
 )
 
 // elapsed is the time an expired timer will be set to
@@ -563,7 +565,9 @@ func (lp *LoadPoint) syncCharger() {
 	enabled, err := lp.charger.Enabled()
 	if err == nil {
 		if enabled != lp.enabled {
-			lp.log.WARN.Printf("charger out of sync: expected %vd, got %vd", status[lp.enabled], status[enabled])
+			if time.Since(lp.guardUpdated) > guardGracePeriod {
+				lp.log.WARN.Printf("charger out of sync: expected %vd, got %vd", status[lp.enabled], status[enabled])
+			}
 			err = lp.charger.Enable(lp.enabled)
 		}
 
diff --git a/docker/ci.Dockerfile b/docker/ci.Dockerfile
index 366a79da..93c55d4f 100644
--- a/docker/ci.Dockerfile
+++ b/docker/ci.Dockerfile
@@ -1,10 +1,8 @@
 # executes binary build excluding UI
 
-# STEP 2 build executable binary
-FROM golang:1.17-alpine as builder
-
 # define RELEASE=1 to hide commit hash
 ARG RELEASE={{ env "RELEASE" }}
+FROM golang:1.18-alpine as builder
 
 WORKDIR /build
 
diff --git a/docker/tmpl.Dockerfile b/docker/tmpl.Dockerfile
index 61c63e88..38d1ead4 100644
--- a/docker/tmpl.Dockerfile
+++ b/docker/tmpl.Dockerfile
@@ -20,10 +20,10 @@ RUN make clean ui
 
 
 # STEP 2 build executable binary
-FROM golang:1.17-alpine as builder
 
 # define RELEASE=1 to hide commit hash
 ARG RELEASE={{ env "RELEASE" }}
+FROM golang:1.18-alpine as builder
 
 # Install git + SSL ca certificates.
 # Git is required for fetching the dependencies.
diff --git a/server/http_live.go b/server/http_live.go
index 7ca18755..6d9fc478 100644
--- a/server/http_live.go
+++ b/server/http_live.go
@@ -1,5 +1,4 @@
 //go:build !release
-// +build !release
 
 package server
 
diff --git a/server/uds.go b/server/uds.go
index f775057a..dc819038 100644
--- a/server/uds.go
+++ b/server/uds.go
@@ -1,5 +1,4 @@
 //go:build !windows
-// +build !windows
 
 package server
 
diff --git a/server/uds_windows.go b/server/uds_windows.go
index f4c49093..c05a969d 100644
--- a/server/uds_windows.go
+++ b/server/uds_windows.go
@@ -1,5 +1,4 @@
 //go:build windows
-// +build windows
 
 package server
 
diff --git a/server/updater/gokrazy.go b/server/updater/gokrazy.go
index 7eb33f25..62b16df7 100644
--- a/server/updater/gokrazy.go
+++ b/server/updater/gokrazy.go
@@ -1,5 +1,4 @@
 //go:build gokrazy
-// +build gokrazy
 
 package updater
 
diff --git a/server/updater/run.go b/server/updater/run.go
index da464c99..df4ea71f 100644
--- a/server/updater/run.go
+++ b/server/updater/run.go
@@ -1,5 +1,4 @@
 //go:build !gokrazy
-// +build !gokrazy
 
 package updater
 
diff --git a/server/updater/run_gokrazy.go b/server/updater/run_gokrazy.go
index cf0760e6..49ea3b21 100644
--- a/server/updater/run_gokrazy.go
+++ b/server/updater/run_gokrazy.go
@@ -1,5 +1,4 @@
 //go:build gokrazy
-// +build gokrazy
 
 package updater
 
diff --git a/templates/definition/charger/tinkerforge-warp-pro.yaml b/templates/definition/charger/tinkerforge-warp-pro.yaml
index af522879..5ca08d8a 100644
--- a/templates/definition/charger/tinkerforge-warp-pro.yaml
+++ b/templates/definition/charger/tinkerforge-warp-pro.yaml
@@ -28,5 +28,4 @@ render: |
   type: warp
   broker: {{ .host }}:{{ .port }}
   topic: {{ .topic }}
-  useMeter: true
   timeout: {{ .timeout }}
diff --git a/templates/definition/charger/tinkerforge-warp.yaml b/templates/definition/charger/tinkerforge-warp.yaml
index 302163fa..454eafdf 100644
--- a/templates/definition/charger/tinkerforge-warp.yaml
+++ b/templates/definition/charger/tinkerforge-warp.yaml
@@ -5,7 +5,7 @@ products:
     generic: WARP Charger
 requirements:
   description:
-    en: A MQTT Broker software needs to be running on a computer. The MQTT connection also needs to be configured in the wallbox.
+    en: An MQTT Broker software needs to be running on a computer. The MQTT connection also needs to be configured in the wallbox.
     de: Es muss ein sogennanter MQTT Broker auf einem Computer installiert sein. Die MQTT Verbindung muss in der Wallbox eingerichtet sein.
   uri: https://docs.evcc.io/docs/devices/chargers#tinkerforge-warp-charger
 params:
@@ -28,6 +28,4 @@ render: |
   type: warp
   broker: {{ .host }}:{{ .port }}
   topic: {{ .topic }}
-  useMeter: false
   timeout: {{ .timeout }}
-  
\ No newline at end of file
diff --git a/tools.go b/tools.go
index c1d143b8..e8726aaf 100644
--- a/tools.go
+++ b/tools.go
@@ -1,5 +1,4 @@
 //go:build tools
-// +build tools
 
 package main
 
diff --git a/util/goversion/version.go b/util/goversion/version.go
index 637a8029..281aafc5 100644
--- a/util/goversion/version.go
+++ b/util/goversion/version.go
@@ -1,5 +1,4 @@
 //go:build go1.17
-// +build go1.17
 
 package goversion
 
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/bluelink/identity.go b/vehicle/bluelink/identity.go
index 4c145c98..35d23a9f 100644
--- a/vehicle/bluelink/identity.go
+++ b/vehicle/bluelink/identity.go
@@ -64,11 +64,11 @@ func (v *Identity) getDeviceID() (string, error) {
 		return "", err
 	}
 
-	uniID, _ := uuid.NewUUID()
+	uuid := uuid.NewString()
 	data := map[string]interface{}{
 		"pushRegId": "1",
 		"pushType":  "GCM",
-		"uuid":      uniID.String(),
+		"uuid":      uuid,
 	}
 
 	headers := map[string]string{
@@ -278,11 +278,11 @@ func (v *Identity) exchangeCode(accCode string) (oauth.Token, error) {
 		"User-Agent":    "okhttp/3.10.0",
 	}
 
-	data := url.Values(map[string][]string{
+	data := url.Values{
 		"grant_type":   {"authorization_code"},
 		"redirect_uri": {v.config.URI + "/api/v1/user/oauth2/redirect"},
 		"code":         {accCode},
-	})
+	}
 
 	var token oauth.Token
 
@@ -302,11 +302,11 @@ func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
 		"User-Agent":    "okhttp/3.10.0",
 	}
 
-	data := url.Values(map[string][]string{
+	data := url.Values{
 		"grant_type":    {"refresh_token"},
 		"redirect_uri":  {"https://www.getpostman.com/oauth2/callback"},
 		"refresh_token": {token.RefreshToken},
-	})
+	}
 
 	req, err := request.New(http.MethodPost, v.config.URI+TokenURL, strings.NewReader(data.Encode()), headers)
 
diff --git a/vehicle/bmw/identity.go b/vehicle/bmw/identity.go
index 9c754f07..991dbfd8 100644
--- a/vehicle/bmw/identity.go
+++ b/vehicle/bmw/identity.go
@@ -108,17 +108,17 @@ func (v *Identity) login() (*oauth2.Token, error) {
 	}
 
 	data := url.Values{
-		"client_id":             []string{"31c357a0-7a1d-4590-aa99-33b97244d048"},
-		"response_type":         []string{"code"},
-		"redirect_uri":          []string{RedirectURI},
-		"state":                 []string{"cwU-gIE27j67poy2UcL3KQ"},
-		"scope":                 []string{"authenticate_user vehicle_data remote_services"},
-		"nonce":                 []string{"login_nonce"},
-		"code_challenge_method": []string{"S256"},
-		"code_challenge":        []string{cv.CodeChallengeS256()},
-		"username":              []string{v.user},
-		"password":              []string{v.password},
-		"grant_type":            []string{"authorization_code"},
+		"client_id":             {"31c357a0-7a1d-4590-aa99-33b97244d048"},
+		"response_type":         {"code"},
+		"redirect_uri":          {RedirectURI},
+		"state":                 {"cwU-gIE27j67poy2UcL3KQ"},
+		"scope":                 {"authenticate_user vehicle_data remote_services"},
+		"nonce":                 {"login_nonce"},
+		"code_challenge_method": {"S256"},
+		"code_challenge":        {cv.CodeChallengeS256()},
+		"username":              {v.user},
+		"password":              {v.password},
+		"grant_type":            {"authorization_code"},
 	}
 
 	uri := fmt.Sprintf("%s/authenticate", AuthURI)
@@ -172,10 +172,10 @@ func (v *Identity) login() (*oauth2.Token, error) {
 	}
 
 	data = url.Values{
-		"code":          []string{query.Get("code")},
-		"redirect_uri":  []string{RedirectURI},
-		"grant_type":    []string{"authorization_code"},
-		"code_verifier": []string{cv.CodeChallengePlain()},
+		"code":          {query.Get("code")},
+		"redirect_uri":  {RedirectURI},
+		"grant_type":    {"authorization_code"},
+		"code_verifier": {cv.CodeChallengePlain()},
 	}
 
 	return v.retrieveToken(data)
diff --git a/vehicle/fiat/identity.go b/vehicle/fiat/identity.go
index 68cde5f7..9a6c70be 100644
--- a/vehicle/fiat/identity.go
+++ b/vehicle/fiat/identity.go
@@ -47,13 +47,13 @@ func (v *Identity) Login() error {
 
 	uri := fmt.Sprintf("%s/accounts.webSdkBootstrap", LoginURI)
 
-	data := url.Values(map[string][]string{
+	data := url.Values{
 		"APIKey":   {ApiKey},
 		"pageURL":  {"https://myuconnect.fiat.com/de/de/vehicle-services"},
 		"sdk":      {"js_latest"},
 		"sdkBuild": {"12234"},
 		"format":   {"json"},
-	})
+	}
 
 	headers := map[string]string{
 		"Accept": "*/*",
@@ -82,7 +82,7 @@ func (v *Identity) Login() error {
 	if err == nil {
 		uri = fmt.Sprintf("%s/accounts.login", LoginURI)
 
-		data := url.Values(map[string][]string{
+		data := url.Values{
 			"loginID":           {v.user},
 			"password":          {v.password},
 			"sessionExpiration": {"7776000"},
@@ -98,7 +98,7 @@ func (v *Identity) Login() error {
 			"lang":              {"de0de"},
 			"source":            {"showScreenSet"},
 			"authMode":          {"cookie"},
-		})
+		}
 
 		headers := map[string]string{
 			"Accept":       "*/*",
@@ -122,7 +122,7 @@ func (v *Identity) Login() error {
 	if err == nil {
 		uri = fmt.Sprintf("%s/accounts.getJWT", LoginURI)
 
-		data := url.Values(map[string][]string{
+		data := url.Values{
 			"fields":      {"profile.firstName,profile.lastName,profile.email,country,locale,data.disclaimerCodeGSDP"}, // data.GSDPisVerified
 			"APIKey":      {ApiKey},
 			"pageURL":     {"https://myuconnect.fiat.com/de/de/dashboard"},
@@ -131,7 +131,7 @@ func (v *Identity) Login() error {
 			"format":      {"json"},
 			"login_token": {res.SessionInfo.LoginToken},
 			"authMode":    {"cookie"},
-		})
+		}
 
 		headers := map[string]string{
 			"Accept": "*/*",
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/mb/identity.go b/vehicle/mb/identity.go
index 28b7c0c5..f16f7878 100644
--- a/vehicle/mb/identity.go
+++ b/vehicle/mb/identity.go
@@ -10,10 +10,10 @@ import (
 	"net/url"
 	"strings"
 
+	"context"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/request"
 	cv "github.com/nirasan/go-oauth-pkce-code-verifier"
-	"golang.org/x/net/context"
 	"golang.org/x/net/publicsuffix"
 	"golang.org/x/oauth2"
 )
diff --git a/vehicle/nissan/identity.go b/vehicle/nissan/identity.go
index 21322c14..4f43aaf0 100644
--- a/vehicle/nissan/identity.go
+++ b/vehicle/nissan/identity.go
@@ -81,11 +81,11 @@ func (v *Identity) Login(user, password string) error {
 
 	if err == nil {
 		data := url.Values{
-			"client_id":     []string{ClientID},
-			"redirect_uri":  []string{RedirectURI},
-			"response_type": []string{"code"},
-			"scope":         []string{Scope},
-			"nonce":         []string{"sdfdsfez"},
+			"client_id":     {ClientID},
+			"redirect_uri":  {RedirectURI},
+			"response_type": {"code"},
+			"scope":         {Scope},
+			"nonce":         {"sdfdsfez"},
 		}
 
 		uri := fmt.Sprintf("%s/oauth2/%s/authorize?%s", AuthBaseURL, realm, data.Encode())
@@ -108,11 +108,11 @@ func (v *Identity) Login(user, password string) error {
 	var token oauth.Token
 	if err == nil {
 		data := url.Values{
-			"code":          []string{code},
-			"client_id":     []string{ClientID},
-			"client_secret": []string{ClientSecret},
-			"redirect_uri":  []string{RedirectURI},
-			"grant_type":    []string{"authorization_code"},
+			"code":          {code},
+			"client_id":     {ClientID},
+			"client_secret": {ClientSecret},
+			"redirect_uri":  {RedirectURI},
+			"grant_type":    {"authorization_code"},
 		}
 
 		uri = fmt.Sprintf("%s/oauth2/%s/access_token?%s", AuthBaseURL, realm, data.Encode())
@@ -131,8 +131,8 @@ func (v *Identity) Login(user, password string) error {
 
 func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
 	data := url.Values{
-		"client_id":     []string{ClientID},
-		"client_secret": []string{ClientSecret},
+		"client_id":     {ClientID},
+		"client_secret": {ClientSecret},
 		"grant_type":    {"refresh_token"},
 		"refresh_token": {token.RefreshToken},
 	}
diff --git a/vehicle/porsche.go b/vehicle/porsche.go
index b1edb58b..6069936a 100644
--- a/vehicle/porsche.go
+++ b/vehicle/porsche.go
@@ -73,32 +73,11 @@ func NewPorscheFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		return nil, errors.New("vehicle is not paired with the My Porsche account")
 	}
 
-	// check if vehicle provides status:
-	// some PHEVs do not provide any data
-	statusAvailable := false
-	if _, err := mobile.Status(cc.VIN); err == nil {
-		statusAvailable = true
-	}
-
-	if _, err := api.Status(cc.VIN); err == nil {
-		statusAvailable = true
-	}
-
-	if !statusAvailable {
-		return nil, errors.New("vehicle is not capable of providing data")
-	}
-
 	// get eMobility capabilities
-
-	// Note: As of 27.10.21 the capabilities API needs to be called AFTER a
-	//   call to status() as it otherwise returns an HTTP 502 error.
-	//   The reason is unknown, even when tested with 100% identical Headers.
-	//   It seems to be a new backend related issue.
-	var emobility *porsche.EmobilityAPI
-	var capabilities porsche.CapabilitiesResponse
-	if _, err := api.Status(cc.VIN); err == nil {
-		emobility = porsche.NewEmobilityAPI(log, identity.EmobilitySource)
-		capabilities, _ = emobility.Capabilities(cc.VIN)
+	emobility := porsche.NewEmobilityAPI(log, identity.EmobilitySource)
+	capabilities, err := emobility.Capabilities(cc.VIN)
+	if err != nil {
+		return nil, err
 	}
 
 	provider := porsche.NewProvider(log, api, emobility, mobile, cc.VIN, capabilities.CarModel, cc.Cache)
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/tokenprovider.go b/vehicle/vag/vwidentity/endpoint.go
similarity index 71%
rename from vehicle/vw/tokenprovider.go
rename to vehicle/vag/vwidentity/endpoint.go
index 32e45aa2..d1b48bcc 100644
--- a/vehicle/vw/tokenprovider.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 d2208f42..00000000
--- a/vehicle/vw/identity.go
+++ /dev/null
@@ -1,116 +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
-	idtp     *IDTokenProvider
-	clientID string
-	oauth2.TokenSource
-}
-
-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),
-		idtp:     NewIDTokenProvider(log, uri, user, password),
-		clientID: 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) {
-	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)
-	}
-
-	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"},
+}


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)
+	}
+}