diff --git a/vehicle/vag/loginapps/endpoint.go b/vehicle/vag/loginapps/endpoint.go deleted file mode 100644 index c2acc2ca7..000000000 --- a/vehicle/vag/loginapps/endpoint.go +++ /dev/null @@ -1,96 +0,0 @@ -package loginapps - -import ( - "net/http" - "net/url" - "strings" - "time" - - "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" - "github.com/evcc-io/evcc/vehicle/vag/cariad" - "golang.org/x/oauth2" -) - -var Endpoint = &oauth2.Endpoint{ - AuthURL: cariad.BaseURL + "/user-login/login/v1", - TokenURL: cariad.BaseURL + "/user-login/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) { - body := url.Values{ - "grant_type": []string{"refresh_token"}, - "refresh_token": []string{token.RefreshToken}, - "client_id": []string{cariad.ClientID}, - } - - req, err := request.New( - http.MethodPost, - cariad.BaseURL+"/auth/v1/idk/oidc/token", - strings.NewReader(body.Encode()), - request.URLEncoding, - map[string]string{ - "Connection": "keep-alive", - "User-Agent": cariad.UserAgent, - "Accept": "application/json", - "x-android-package-name": cariad.AndroidPackageName, - }, - ) - - var res oauth2.Token - if err == nil { - err = v.DoJSON(req, &res) - } - - res.Expiry = time.Now().Add(time.Duration(res.ExpiresIn) * time.Second) - - t := Token(res) - return &t, err -} - -// refreshToken renews the LoginApps token -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.refreshToken) -} diff --git a/vehicle/vag/loginapps/token.go b/vehicle/vag/loginapps/token.go deleted file mode 100644 index cd1c6ebc3..000000000 --- a/vehicle/vag/loginapps/token.go +++ /dev/null @@ -1,28 +0,0 @@ -package loginapps - -import ( - "encoding/json" - "time" - - "golang.org/x/oauth2" -) - -// Token is the loginapps token -type Token oauth2.Token - -func (t *Token) UnmarshalJSON(data []byte) error { - var s struct { - AccessToken string - RefreshToken string - } - - err := json.Unmarshal(data, &s) - if err == nil { - t.TokenType = "bearer" - t.AccessToken = s.AccessToken - t.RefreshToken = s.RefreshToken - t.Expiry = time.Now().Add(time.Hour) - } - - return err -} diff --git a/vehicle/vag/loginapps/token_test.go b/vehicle/vag/loginapps/token_test.go deleted file mode 100644 index ef4fb12e0..000000000 --- a/vehicle/vag/loginapps/token_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package loginapps - -import ( - "encoding/json" - "testing" -) - -func TestUnmarshalJSON(t *testing.T) { - var tok Token - str := `{"accesstoken":"access","refreshtoken":"refresh"}` - - if err := json.Unmarshal([]byte(str), &tok); err != nil { - t.Error(err) - } - - if tok.AccessToken != "access" { - t.Error("AccessToken") - } - - if tok.RefreshToken != "refresh" { - t.Error("RefreshToken") - } - - if tok.TokenType != "bearer" { - t.Error("TokenType") - } - - if tok.Expiry.IsZero() { - t.Error("Expiry") - } -} diff --git a/vehicle/vw.go b/vehicle/vw.go index 5807f5857..4d5b3c92d 100644 --- a/vehicle/vw.go +++ b/vehicle/vw.go @@ -6,7 +6,6 @@ import ( "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/loginapps" "github.com/evcc-io/evcc/vehicle/vag/vwidentity" "github.com/evcc-io/evcc/vehicle/vw/weconnect" ) @@ -49,18 +48,17 @@ func NewVWFromConfig(other map[string]any) (api.Vehicle, error) { log := util.NewLogger("vw").Redact(cc.User, cc.Password, cc.VIN) - q, err := vwidentity.LoginWithAuthURL(log, weconnect.LoginURL, weconnect.AuthParams, cc.User, cc.Password) + q, err := vwidentity.Login(log, weconnect.AuthParams, cc.User, cc.Password) if err != nil { return nil, err } - apps := loginapps.New(log) - token, err := apps.Exchange(q) + token, err := weconnect.ExchangeCode(log, q) if err != nil { return nil, err } - api := weconnect.NewAPI(log, apps.TokenSource(token)) + api := weconnect.NewAPI(log, weconnect.TokenSource(log, token)) api.Client.Timeout = cc.Timeout vehicle, err := ensureVehicleEx( diff --git a/vehicle/vw/weconnect/oauth.go b/vehicle/vw/weconnect/oauth.go new file mode 100644 index 000000000..206e7a893 --- /dev/null +++ b/vehicle/vw/weconnect/oauth.go @@ -0,0 +1,71 @@ +package weconnect + +import ( + "net/http" + "net/url" + "strings" + "time" + + "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" + "github.com/evcc-io/evcc/vehicle/vag/cariad" + "golang.org/x/oauth2" +) + +const tokenURL = cariad.BaseURL + "/auth/v1/idk/oidc/token" + +// ExchangeCode swaps the authorization code from the OIDC callback for an +// access/refresh token pair at the cariad BFF OIDC token endpoint. Replaces +// the legacy WeConnect SSO exchange (/user-login/login/v1), which VW removed +// in May 2026. +func ExchangeCode(log *util.Logger, q url.Values) (*oauth2.Token, error) { + if err := urlvalues.Require(q, "code", "code_verifier"); err != nil { + return nil, err + } + + data := url.Values{ + "grant_type": {"authorization_code"}, + "code": {q.Get("code")}, + "code_verifier": {q.Get("code_verifier")}, + "redirect_uri": {"weconnect://authenticated"}, + "client_id": {cariad.ClientID}, + } + + return postToken(log, data) +} + +// TokenSource returns a refreshing oauth2.TokenSource that swaps refresh +// tokens for fresh access tokens at the same OIDC token endpoint. +func TokenSource(log *util.Logger, token *oauth2.Token) oauth2.TokenSource { + return oauth.RefreshTokenSource(token, func(t *oauth2.Token) (*oauth2.Token, error) { + data := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {t.RefreshToken}, + "client_id": {cariad.ClientID}, + } + return postToken(log, data) + }) +} + +func postToken(log *util.Logger, data url.Values) (*oauth2.Token, error) { + req, err := request.New(http.MethodPost, tokenURL, strings.NewReader(data.Encode()), + map[string]string{ + "Content-Type": request.FormContent, + "Accept": request.JSONContent, + "User-Agent": cariad.UserAgent, + "x-android-package-name": cariad.AndroidPackageName, + }) + if err != nil { + return nil, err + } + + var token oauth2.Token + if err := request.NewHelper(log).DoJSON(req, &token); err != nil { + return nil, err + } + + token.Expiry = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second) + return &token, nil +} diff --git a/vehicle/vw/weconnect/params.go b/vehicle/vw/weconnect/params.go index a6bf9f2a9..65f41fb68 100644 --- a/vehicle/vw/weconnect/params.go +++ b/vehicle/vw/weconnect/params.go @@ -6,11 +6,13 @@ import ( "github.com/evcc-io/evcc/vehicle/vag/cariad" ) -const LoginURL = cariad.BaseURL + "/user-login/v1/authorize" - +// AuthParams are the OIDC authorize parameters for the WeConnect ID client. +// The authorize endpoint is the legacy identity.vwgroup.io OIDC entry point +// (vwidentity.Config.AuthURL); the token endpoint is the cariad BFF (see +// oauth.go). var AuthParams = url.Values{ "response_type": {"code id_token token"}, - "client_id": {"a24fba63-34b3-4d43-b181-942111e6bda8@apps_vw-dilab_com"}, + "client_id": {cariad.ClientID}, "redirect_uri": {"weconnect://authenticated"}, "scope": {"openid profile badge cars vin"}, // dealers }