diff --git a/api/error.go b/api/error.go index a55541d33..8bfa18a96 100644 --- a/api/error.go +++ b/api/error.go @@ -2,6 +2,7 @@ package api import ( "errors" + "net/url" "github.com/cenkalti/backoff/v4" ) @@ -43,6 +44,25 @@ func (err *ErrLoginRequired) Error() string { return "login required" } +// ErrUrl indicates that the error contains an extractable URL +type ErrUrl struct { + err string + url *url.URL +} + +// UrlError creates an error message containing an url +func UrlError(err string, url *url.URL) *ErrUrl { + return &ErrUrl{err, url} +} + +func (err *ErrUrl) Error() string { + return err.err +} + +func (err *ErrUrl) URL() *url.URL { + return err.url +} + // ErrAsleep indicates that vehicle is asleep. Caller may chose to wake up the vehicle and retry. var ErrAsleep error = errAsleep{} diff --git a/util/error.go b/util/error.go index 178e1b4a4..cc432cff9 100644 --- a/util/error.go +++ b/util/error.go @@ -13,6 +13,7 @@ func ErrorAsJson(err error) any { Error string `json:"error"` Line int `json:"line,omitempty"` LoginRequired string `json:"loginRequired,omitempty"` + URI string `json:"uri,omitempty"` }{ Error: err.Error(), } @@ -21,6 +22,10 @@ func ErrorAsJson(err error) any { res.LoginRequired = ae.ProviderAuth } + if ue := new(api.ErrUrl); errors.As(err, &ue) { + res.URI = ue.URL().String() + } + var ( ype *yaml.ParserError yue *yaml.UnmarshalError diff --git a/vehicle/vag/cariad/const.go b/vehicle/vag/cariad/const.go index 7b59b0ab3..d735cb08a 100644 --- a/vehicle/vag/cariad/const.go +++ b/vehicle/vag/cariad/const.go @@ -1,3 +1,7 @@ package cariad -const BaseURL = "https://emea.bff.cariad.digital" +const ( + BaseURL = "https://emea.bff.cariad.digital" + AndroidPackageName = "com.volkswagen.weconnect" + UserAgent = "Volkswagen/3.51.1-android/14" +) diff --git a/vehicle/vag/vwidentity/endpoint.go b/vehicle/vag/vwidentity/endpoint.go index 7b21ae9bd..817abcced 100644 --- a/vehicle/vag/vwidentity/endpoint.go +++ b/vehicle/vag/vwidentity/endpoint.go @@ -1,14 +1,18 @@ package vwidentity import ( + "bytes" "errors" "fmt" + "io" "net/http" "net/http/cookiejar" "net/url" "strings" + "github.com/PuerkitoBio/goquery" "github.com/coreos/go-oidc/v3/oidc" + "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" @@ -91,79 +95,173 @@ func (v *Service) Login(uri, user, password string) (url.Values, error) { "state": {uuid.NewString()}, } - 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) - if err == nil { - vars, err = FormValues(resp.Body, "form#emailPasswordForm") - resp.Body.Close() + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err } + // Try to extract legacy form, but don't fail if it's not found + if vars, err := FormValues(bytes.NewReader(body), "form#emailPasswordForm"); err == nil { + return v.loginLegacy(vars, user, password) + } + + return v.loginNew(body, user, password) +} + +// loginLegacy performs the legacy VW identity login flow +func (v *Service) loginLegacy(vars FormVars, user, password string) (url.Values, error) { var params CredentialParams // POST identity.vwgroup.io/signin-service/v1/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com/login/identifier - if err == nil { - data := url.Values{ - "_csrf": {vars.Inputs["_csrf"]}, - "relayState": {vars.Inputs["relayState"]}, - "hmac": {vars.Inputs["hmac"]}, - "email": {user}, - } + data := url.Values{ + "_csrf": {vars.Inputs["_csrf"]}, + "relayState": {vars.Inputs["relayState"]}, + "hmac": {vars.Inputs["hmac"]}, + "email": {user}, + } - 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) - } - resp.Body.Close() - } + uri := BaseURL + vars.Action + resp, err := v.PostForm(uri, data) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if params, err = ParseCredentialsPage(resp.Body); err == nil && params.TemplateModel.Error != "" { + err = errors.New(params.TemplateModel.Error) + } + + if err != nil { + return nil, err } // POST identity.vwgroup.io/signin-service/v1/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com/login/authenticate - if err == nil { - data := url.Values{ - "_csrf": {params.CsrfToken}, - "relayState": {params.TemplateModel.RelayState}, - "hmac": {params.TemplateModel.Hmac}, - "email": {user}, - "password": {password}, - } + data = url.Values{ + "_csrf": {params.CsrfToken}, + "relayState": {params.TemplateModel.RelayState}, + "hmac": {params.TemplateModel.Hmac}, + "email": {user}, + "password": {password}, + } - // reuse url from identifier step before - uri = strings.ReplaceAll(uri, params.TemplateModel.IdentifierUrl, params.TemplateModel.PostAction) + // reuse url from identifier step before + uri = strings.ReplaceAll(uri, params.TemplateModel.IdentifierUrl, params.TemplateModel.PostAction) - if resp, err = v.PostForm(uri, data); err == nil { - resp.Body.Close() + resp, err = v.PostForm(uri, data) + if err != nil { + return nil, err + } + defer resp.Body.Close() - if resp.StatusCode >= http.StatusBadRequest { - err = errors.New(resp.Status) - } - } - - if err == nil { - if e := resp.Request.URL.Query().Get("error"); e != "" { - err = errors.New(e) - } - - if consent := resp.Request.URL.Query().Get("updated") != "" || strings.Contains(resp.Request.URL.Path, "/consent/"); err == nil && consent { - err = errors.New("terms of service updated- please open app or website and confirm: " + resp.Request.URL.String()) - } - } + if resp.StatusCode >= http.StatusBadRequest { + return nil, errors.New(resp.Status) } // GET identity.vwgroup.io/oidc/v1/oauth/sso?clientId=b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com&relayState=15404cb51c8b4cc5efeee1d2c2a73e5b41562faa&userId=bca09cc0-8eba-4110-af71-7242868e1bf1&HMAC=2b01ce6a351fad4dd97dc8110d0967b46c95889ab5010c660a616462e66a83ca // GET identity.vwgroup.io/signin-service/v1/consent/users/bca09cc0-8eba-4110-af71-7242868e1bf1/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com?scopes=openid%20profile%20birthdate%20nickname%20address%20phone%20cars%20mbb&relayState=15404cb51c8b4cc5efeee1d2c2a73e5b41562faa&callback=https://identity.vwgroup.io/oidc/v1/oauth/client/callback&hmac=a590931ca3cd9dc3a27f1d1c0c162bf1e5c5c32c9f5b40fcb36d4c6edc631e03 // GET identity.vwgroup.io/oidc/v1/oauth/client/callback/success?user_id=bca09cc0-8eba-4110-af71-7242868e1bf1&client_id=b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com&scopes=openid%20profile%20birthdate%20nickname%20address%20phone%20cars%20mbb&consentedScopes=openid%20profile%20birthdate%20nickname%20address%20phone%20cars%20mbb&relayState=f89a0b750c93e278a7ace170ce374e9cb9eb0a74&hmac=2b728f463c3cfe80f3271fbb35680e5e5218ca70025a46e7fadf7c7982decc2b - var location *url.URL - if err == nil { - loc := strings.ReplaceAll(resp.Header.Get("Location"), "#", "?") // convert to parseable url - if location, err = url.Parse(loc); err == nil { - return location.Query(), nil - } + parsed, err := url.Parse(resp.Header.Get("Location")) + if err != nil { + return nil, err + } + return parseAuthLocation(parsed) +} + +// loginNew performs the new VW identity login flow +func (v *Service) loginNew(body []byte, user, password string) (url.Values, error) { + state, err := extractState(body) + if err != nil { + return nil, err } - return nil, err + // POST to new login endpoint + loginData := url.Values{ + "username": {user}, + "password": {password}, + "state": {state}, + } + + uri := fmt.Sprintf("%s/u/login?state=%s", BaseURL, state) + resp, err := v.PostForm(uri, loginData) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + location := resp.Header.Get("Location") + if resp.StatusCode >= http.StatusBadRequest { + return nil, errors.New(resp.Status) + } + + if location == "" { + return nil, errors.New("no redirect location in new login flow") + } + + redirectURL, err := resolveLocation(resp.Request.URL, location) + if err != nil { + return nil, err + } + + if redirectURL.Scheme == "https" || redirectURL.Scheme == "http" { + return nil, fmt.Errorf("unexpected redirect URL scheme: %s, expected app specific url (e.g. 'weconnect://...')", redirectURL.Scheme) + } + + return parseAuthLocation(redirectURL) +} + +func resolveLocation(base *url.URL, location string) (*url.URL, error) { + locURL, err := url.Parse(location) + if err != nil { + return nil, err + } + if locURL.IsAbs() { + return locURL, nil + } + return base.ResolveReference(locURL), nil +} + +func parseAuthLocation(u *url.URL) (url.Values, error) { + u.RawQuery = u.Fragment + + if errStr := u.Query().Get("error"); errStr != "" { + return nil, errors.New(errStr) + } + + if consent := u.Query().Get("updated") != "" || strings.Contains(u.Path, "/consent/"); consent { + return nil, api.UrlError( + fmt.Sprintf("terms of service updated- please open app or website and confirm: %s", u.String()), + u, + ) + } + + return u.Query(), nil +} + +func extractState(body []byte) (string, error) { + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(body)) + if err != nil { + return "", err + } + + stateInput := doc.Find("input[name=state]").First() + if stateInput.Length() == 0 { + return "", errors.New("state parameter not found") + } + + state, ok := stateInput.Attr("value") + if !ok || state == "" { + return "", errors.New("state value not found") + } + + return state, nil }