diff --git a/util/urlvalues/url.go b/util/urlvalues/url.go new file mode 100644 index 000000000..5fa26209e --- /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 000000000..b766b2a50 --- /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 f3c0f44cb..6151bc166 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 000000000..794edbdd1 --- /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 000000000..00e6d79c8 --- /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 b321a88f8..2d672b04f 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 480d7c990..000000000 --- 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 213e28d56..e0dec7cfd 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 8766240fd..ddfc8f95e 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 c1f04989d..457b4fe6b 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 2d841a45f..2ea8f4b8b 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 7627ecaa6..e5f78d310 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 000000000..fa93a04fb --- /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 229c45093..000000000 --- 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 fe83537ed..187a83502 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 000000000..0fcefb6bf --- /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 000000000..716d13619 --- /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 000000000..eccacb320 --- /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/idkproxy/qmauth_test.go b/vehicle/vag/idkproxy/qmauth_test.go new file mode 100644 index 000000000..d09a4794e --- /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/vag/loginapps/endpoint.go b/vehicle/vag/loginapps/endpoint.go new file mode 100644 index 000000000..08e0df76a --- /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 fec515295..cd1c6ebc3 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/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 20fc8a691..ef4fb12e0 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/vag/mbb/endpoint.go b/vehicle/vag/mbb/endpoint.go new file mode 100644 index 000000000..be3b42803 --- /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 000000000..1c226dfb9 --- /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 000000000..6257bedb3 --- /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 000000000..2ac196148 --- /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 3b8dc89f4..dd30f98a2 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/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 c33118bce..96d22dfa2 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/tokenrefreshservice/endpoint.go b/vehicle/vag/tokenrefreshservice/endpoint.go new file mode 100644 index 000000000..367c62b6c --- /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 000000000..d6427e1b0 --- /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/vag/tokensource_test.go b/vehicle/vag/tokensource_test.go new file mode 100644 index 000000000..121435d31 --- /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) + } +} 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 32e45aa22..d1b48bcc2 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 +} + +type Service struct { + *request.Helper +} + +func New(log *util.Logger) *Service { + return &Service{ + Helper: request.NewHelper(log), + } } // Login performs the identity.vwgroup.io login -func (v *IDTokenProvider) Login() (url.Values, error) { - if v.user == "" || v.password == "" { - return nil, api.ErrMissingCredentials - } - +func (v *Service) Login(uri, user, password string) (url.Values, error) { // track cookies and don't follow redirects jar, _ := cookiejar.New(&cookiejar.Options{ PublicSuffixList: publicsuffix.List, @@ -55,14 +83,14 @@ func (v *IDTokenProvider) Login() (url.Values, error) { return nil } - var vars FormVars - // add nonce and state query := url.Values{ "nonce": []string{util.RandomString(43)}, - "state": []string{util.RandomString(43)}, + "state": []string{uuid.NewString()}, } - uri := v.uri + "&" + query.Encode() + + var vars FormVars + uri = uri + "&" + query.Encode() // GET identity.vwgroup.io/signin-service/v1/signin/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com?relayState=15404cb51c8b4cc5efeee1d2c2a73e5b41562faa resp, err := v.Get(uri) @@ -75,14 +103,14 @@ func (v *IDTokenProvider) Login() (url.Values, error) { // POST identity.vwgroup.io/signin-service/v1/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com/login/identifier if err == nil { - data := url.Values(map[string][]string{ + data := url.Values{ "_csrf": {vars.Inputs["_csrf"]}, "relayState": {vars.Inputs["relayState"]}, "hmac": {vars.Inputs["hmac"]}, - "email": {v.user}, - }) + "email": {user}, + } - uri = IdentityURI + vars.Action + uri = BaseURL + vars.Action if resp, err = v.PostForm(uri, data); err == nil { if params, err = ParseCredentialsPage(resp.Body); err == nil && params.TemplateModel.Error != "" { err = errors.New(params.TemplateModel.Error) @@ -93,13 +121,13 @@ func (v *IDTokenProvider) Login() (url.Values, error) { // POST identity.vwgroup.io/signin-service/v1/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com/login/authenticate if err == nil { - data := url.Values(map[string][]string{ + data := url.Values{ "_csrf": {params.CsrfToken}, "relayState": {params.TemplateModel.RelayState}, "hmac": {params.TemplateModel.Hmac}, - "email": {v.user}, - "password": {v.password}, - }) + "email": {user}, + "password": {password}, + } // reuse url from identifier step before uri = strings.ReplaceAll(uri, params.TemplateModel.IdentifierUrl, params.TemplateModel.PostAction) @@ -118,9 +146,10 @@ func (v *IDTokenProvider) Login() (url.Values, error) { } if u := resp.Request.URL.Query().Get("updated"); err == nil && u != "" { - v.log.WARN.Println("accepting updated tos", u) if resp, err = v.postTos(resp.Request.URL.String()); err == nil { resp.Body.Close() + } else { + err = fmt.Errorf("updated ToS: %w", err) } } } @@ -141,7 +170,7 @@ func (v *IDTokenProvider) Login() (url.Values, error) { return nil, err } -func (v *IDTokenProvider) postTos(uri string) (*http.Response, error) { +func (v *Service) postTos(uri string) (*http.Response, error) { var vars FormVars resp, err := v.Get(uri) if err == nil { @@ -154,7 +183,7 @@ func (v *IDTokenProvider) postTos(uri string) (*http.Response, error) { data.Set(k, v) } - uri := IdentityURI + vars.Action + uri := BaseURL + vars.Action resp, err = v.PostForm(uri, data) } 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 92ad3ab59..dc341339c 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 7c3f75686..deed55e6e 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 } - api := id.NewAPI(log, identity) + apps := loginapps.New(log) + token, err := apps.Exchange(q) + if err != nil { + return nil, err + } + + api := id.NewAPI(log, apps.TokenSource(token)) api.Client.Timeout = cc.Timeout cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles) diff --git a/vehicle/vw.go b/vehicle/vw.go index 960539246..3ec49bbbe 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 8e75786f7..000000000 --- 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 934334855..000000000 --- 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 d1eb2163a..436b6d974 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 5303fcdef..000000000 --- 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 -}