Repository: evcc-io/evcc
Pull request #20081: Polestar: API change reading resume path
Author: login: loebse
Labels: name: vehicles
description: Specific vehicle support
Created: 2025-03-24T20:05:06Z | Merged: 2025-03-25T17:29:27Z

Polestar has changed the way how you have to connect to the API again. We now read the required resume path from the HTML body. I also added some logic for the auth token handling.

This should fix https://github.com/evcc-io/evcc/issues/20070


Diff:
diff --git a/vehicle/polestar/api.go b/vehicle/polestar/api.go
index 5acbfa9b2f6..b13dbba2f38 100644
--- a/vehicle/polestar/api.go
+++ b/vehicle/polestar/api.go
@@ -2,7 +2,6 @@ package polestar
 
 import (
 	"context"
-	"net/http"
 
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/request"
@@ -43,8 +42,7 @@ func (v *API) Vehicles(ctx context.Context) ([]ConsumerCar, error) {
 		GetConsumerCarsV2 []ConsumerCar `graphql:"getConsumerCarsV2"`
 	}
 
-	err := v.client.WithRequestModifier(func(req *http.Request) {
-	}).Query(ctx, &res, nil, graphql.OperationName("getCars"))
+	err := v.client.Query(ctx, &res, nil, graphql.OperationName("getCars"))
 
 	return res.GetConsumerCarsV2, err
 }
diff --git a/vehicle/polestar/identity.go b/vehicle/polestar/identity.go
index 1de07af9464..5caf21133dc 100644
--- a/vehicle/polestar/identity.go
+++ b/vehicle/polestar/identity.go
@@ -3,12 +3,15 @@ package polestar
 import (
 	"errors"
 	"fmt"
+	"io"
 	"net/http"
 	"net/http/cookiejar"
 	"net/url"
+	"regexp"
 	"strings"
 
 	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/oauth"
 	"github.com/evcc-io/evcc/util/request"
 	"github.com/samber/lo"
 	"golang.org/x/net/publicsuffix"
@@ -17,6 +20,7 @@ import (
 
 // https://github.com/TA2k/ioBroker.polestar
 
+// OAuth endpoints and credentials
 const (
 	OAuthURI    = "https://polestarid.eu.polestar.com"
 	ClientID    = "l3oopkc_10"
@@ -25,6 +29,7 @@ const (
 
 type Identity struct {
 	*request.Helper
+	oauth2.TokenSource
 	user, password string
 	log            *util.Logger
 }
@@ -46,15 +51,13 @@ func NewIdentity(log *util.Logger, user, password string) (*Identity, error) {
 	}
 	v.Client.Jar = jar
 
+	// Get initial token
 	token, err := v.login()
 	if err != nil {
 		return nil, err
 	}
 
-	v.Client.Transport = &oauth2.Transport{
-		Source: oauth2.StaticTokenSource(token),
-		Base:   v.Client.Transport,
-	}
+	v.TokenSource = oauth.RefreshTokenSource(token, v)
 
 	return v, nil
 }
@@ -72,47 +75,49 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		"code_challenge_method": {"S256"},
 	}
 
-	// Get resume path with browser-like headers
+	// Request authorization URL with browser-like headers
 	uri := fmt.Sprintf("%s/as/authorization.oauth2?%s", OAuthURI, data.Encode())
 	req, _ := request.New(http.MethodGet, uri, nil, map[string]string{
-		"Accept": "application/json",
+		"Accept": "text/html,application/xhtml+xml,application/xml;",
 	})
 
 	resp, err := v.Do(req)
 	if err != nil {
 		return nil, err
 	}
-	resp.Body.Close()
-
-	// First we get redirected to the login page
-	if strings.Contains(resp.Request.URL.Path, "/PolestarLogin/login") {
-		// Extract resumePath from the login URL
-		resumePath := resp.Request.URL.Query().Get("resumePath")
-		if resumePath == "" {
-			return nil, errors.New("missing resume path in login url")
-		}
-
-		// Submit credentials directly to the login endpoint
-		loginURL := fmt.Sprintf("%s/as/%s/resume/as/authorization.ping", OAuthURI, resumePath)
-		data := url.Values{
-			"pf.username": {v.user},
-			"pf.pass":     {v.password},
-			"client_id":   {ClientID},
-		}
-
-		req, _ = request.New(http.MethodPost, loginURL, strings.NewReader(data.Encode()), map[string]string{
-			"Content-Type": "application/x-www-form-urlencoded",
-			"Accept":       "application/json",
-		})
+	defer resp.Body.Close()
+
+	// Extract resume path from HTML response
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	matches := regexp.MustCompile(`url:\s*"/as/(.+?)/resume/as/authorization\.ping"`).FindStringSubmatch(string(body))
+	if len(matches) < 2 {
+		return nil, errors.New("could not find resume path")
+	}
+
+	// Submit credentials to login endpoint
+	data = url.Values{
+		"pf.username": {v.user},
+		"pf.pass":     {v.password},
+		"client_id":   {ClientID},
+	}
 
-		resp, err = v.Do(req)
-		if err != nil {
-			return nil, err
-		}
-		resp.Body.Close()
+	uri = fmt.Sprintf("%s/as/%s/resume/as/authorization.ping", OAuthURI, matches[1])
+	req, _ = request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), map[string]string{
+		"Content-Type": "application/x-www-form-urlencoded",
+		"Accept":       "application/json",
+	})
+
+	resp, err = v.Do(req)
+	if err != nil {
+		return nil, err
 	}
+	defer resp.Body.Close()
 
-	// After login, we should get the authorization code directly
+	// Extract authorization code from response
 	code := resp.Request.URL.Query().Get("code")
 	if code == "" {
 		return nil, errors.New("missing authorization code")
@@ -136,16 +141,34 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		},
 	)
 
-	err = v.DoJSON(req, &token)
-	return util.TokenWithExpiry(&token), err
-}
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
+	}
 
-// TokenSource implements oauth.TokenSource
-func (v *Identity) TokenSource() oauth2.TokenSource {
-	return oauth2.ReuseTokenSource(nil, v)
+	return util.TokenWithExpiry(&token), nil
 }
 
 // Token implements oauth.TokenSource
-func (v *Identity) Token() (*oauth2.Token, error) {
+func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
+	data := url.Values{
+		"grant_type":    {"refresh_token"},
+		"refresh_token": {token.RefreshToken},
+		"client_id":     {ClientID},
+	}
+
+	req, _ := request.New(http.MethodPost, OAuthURI+"/as/token.oauth2",
+		strings.NewReader(data.Encode()),
+		map[string]string{
+			"Content-Type": "application/x-www-form-urlencoded",
+			"Accept":       "application/json",
+		},
+	)
+
+	var res oauth2.Token
+	if err := v.DoJSON(req, &res); err == nil {
+		return util.TokenWithExpiry(&res), nil
+	}
+
+	// Full login as fallback
 	return v.login()
 }


Reviews:
user: login: andig
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T06:57:05Z
user: login: andig
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T07:04:09Z
user: login: loebse
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T12:00:20Z
user: login: loebse
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T13:03:13Z
user: login: loebse
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T13:11:48Z
user: login: loebse
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T13:17:44Z
user: login: loebse
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T13:24:14Z
user: login: loebse
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T13:27:45Z
user: login: andig
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T13:54:34Z
user: login: andig
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T13:55:39Z
user: login: loebse
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T14:17:21Z
user: login: loebse
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T14:32:31Z
user: login: loebse
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T14:33:58Z
user: login: andig
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T15:11:04Z
user: login: andig
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T15:17:47Z
user: login: andig
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T15:24:18Z
user: login: loebse
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T17:30:09Z
user: login: andig
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T19:19:55Z

Review comments:
diff_hunk: @@ -72,47 +76,60 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		"code_challenge_method": {"S256"},
 	}
 
-	// Get resume path with browser-like headers
+	// Request authorization URL with browser-like headers
 	uri := fmt.Sprintf("%s/as/authorization.oauth2?%s", OAuthURI, data.Encode())
 	req, _ := request.New(http.MethodGet, uri, nil, map[string]string{
-		"Accept": "application/json",
+		"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
path: vehicle/polestar/identity.go
user: login: andig
body: Alles vor dem ; sollte genügen?
created_at: 2025-03-25T06:50:09Z
author_association: MEMBER
diff_hunk: @@ -72,47 +76,60 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		"code_challenge_method": {"S256"},
 	}
 
-	// Get resume path with browser-like headers
+	// Request authorization URL with browser-like headers
 	uri := fmt.Sprintf("%s/as/authorization.oauth2?%s", OAuthURI, data.Encode())
 	req, _ := request.New(http.MethodGet, uri, nil, map[string]string{
-		"Accept": "application/json",
+		"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
 	})
 
 	resp, err := v.Do(req)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("initial request failed: %w", err)
path: vehicle/polestar/identity.go
user: login: andig
body: ```suggestion
		return nil, err
```
created_at: 2025-03-25T06:50:26Z
author_association: MEMBER
diff_hunk: @@ -72,47 +76,60 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		"code_challenge_method": {"S256"},
 	}
 
-	// Get resume path with browser-like headers
+	// Request authorization URL with browser-like headers
 	uri := fmt.Sprintf("%s/as/authorization.oauth2?%s", OAuthURI, data.Encode())
 	req, _ := request.New(http.MethodGet, uri, nil, map[string]string{
-		"Accept": "application/json",
+		"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
 	})
 
 	resp, err := v.Do(req)
 	if err != nil {
 		return nil, err
 	}
-	resp.Body.Close()
+	defer resp.Body.Close()
 
-	// First we get redirected to the login page
-	if strings.Contains(resp.Request.URL.Path, "/PolestarLogin/login") {
-		// Extract resumePath from the login URL
-		resumePath := resp.Request.URL.Query().Get("resumePath")
-		if resumePath == "" {
-			return nil, errors.New("missing resume path in login url")
-		}
+	// Extract resume path from HTML response
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
 
-		// Submit credentials directly to the login endpoint
-		loginURL := fmt.Sprintf("%s/as/%s/resume/as/authorization.ping", OAuthURI, resumePath)
-		data := url.Values{
-			"pf.username": {v.user},
-			"pf.pass":     {v.password},
-			"client_id":   {ClientID},
+	htmlContent := string(body)
+	resumePath := ""
+	if strings.Contains(htmlContent, "url: \"") {
path: vehicle/polestar/identity.go
user: login: andig
body: Das wäre einfacher mit einer Regex?
created_at: 2025-03-25T06:54:19Z
author_association: MEMBER
diff_hunk: @@ -136,16 +153,34 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		},
 	)
 
-	err = v.DoJSON(req, &token)
-	return util.TokenWithExpiry(&token), err
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
+	}
+
+	// Configure transport for API requests
+	v.Client.Transport = &oauth2.Transport{
path: vehicle/polestar/identity.go
user: login: andig
body: Das ist merkwürdig. Wir geben einen Token zurück _und_ verwenden ihn intern. Wofür ist das notwendig? Wo braucht die Identity diesen Transport?
created_at: 2025-03-25T06:56:12Z
author_association: MEMBER
diff_hunk: @@ -136,16 +153,34 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		},
 	)
 
-	err = v.DoJSON(req, &token)
-	return util.TokenWithExpiry(&token), err
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
+	}
+
+	// Configure transport for API requests
+	v.Client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&token),
+		Base: &http.Transport{
+			Proxy: http.ProxyFromEnvironment,
path: vehicle/polestar/identity.go
user: login: andig
body: Bitte raus- Proxy gibts im ganzen Rest von evcc auch nicht.
created_at: 2025-03-25T06:56:26Z
author_association: MEMBER
diff_hunk: @@ -136,16 +153,34 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		},
 	)
 
-	err = v.DoJSON(req, &token)
-	return util.TokenWithExpiry(&token), err
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
+	}
+
+	// Configure transport for API requests
+	v.Client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&token),
+		Base: &http.Transport{
+			Proxy: http.ProxyFromEnvironment,
+		},
+	}
+
+	return util.TokenWithExpiry(&token), nil
 }
 
 // TokenSource implements oauth.TokenSource
 func (v *Identity) TokenSource() oauth2.TokenSource {
-	return oauth2.ReuseTokenSource(nil, v)
+	return oauth2.ReuseTokenSource(v.token, v)
 }
 
 // Token implements oauth.TokenSource
 func (v *Identity) Token() (*oauth2.Token, error) {
-	return v.login()
+	if v.token == nil || !v.token.Valid() {
path: vehicle/polestar/identity.go
user: login: andig
body: Warum brauchts diese Logikänderung? Außer Resumepath hat sich doch nichts geändert?
created_at: 2025-03-25T06:57:02Z
author_association: MEMBER
diff_hunk: @@ -136,16 +153,34 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		},
 	)
 
-	err = v.DoJSON(req, &token)
-	return util.TokenWithExpiry(&token), err
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
+	}
+
+	// Configure transport for API requests
+	v.Client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&token),
+		Base: &http.Transport{
+			Proxy: http.ProxyFromEnvironment,
+		},
+	}
+
+	return util.TokenWithExpiry(&token), nil
 }
 
 // TokenSource implements oauth.TokenSource
 func (v *Identity) TokenSource() oauth2.TokenSource {
path: vehicle/polestar/identity.go
user: login: andig
body: This also looks wrong (from previous code). This method is never used.
created_at: 2025-03-25T07:04:09Z
author_association: MEMBER
diff_hunk: @@ -72,47 +76,60 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		"code_challenge_method": {"S256"},
 	}
 
-	// Get resume path with browser-like headers
+	// Request authorization URL with browser-like headers
 	uri := fmt.Sprintf("%s/as/authorization.oauth2?%s", OAuthURI, data.Encode())
 	req, _ := request.New(http.MethodGet, uri, nil, map[string]string{
-		"Accept": "application/json",
+		"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
path: vehicle/polestar/identity.go
user: login: loebse
body: Ja, reicht aus [22d310a](https://github.com/evcc-io/evcc/pull/20081/commits/22d310a2bce342fbebc4b5c4c19749ba08fb5e9f)
created_at: 2025-03-25T12:00:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -136,16 +153,34 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		},
 	)
 
-	err = v.DoJSON(req, &token)
-	return util.TokenWithExpiry(&token), err
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
+	}
+
+	// Configure transport for API requests
+	v.Client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&token),
+		Base: &http.Transport{
+			Proxy: http.ProxyFromEnvironment,
path: vehicle/polestar/identity.go
user: login: loebse
body: Entfernt: [53941c0](https://github.com/evcc-io/evcc/pull/20081/commits/53941c0112608a4a5aebe6e8b799ae687c71a517)
created_at: 2025-03-25T13:03:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -136,16 +153,34 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		},
 	)
 
-	err = v.DoJSON(req, &token)
-	return util.TokenWithExpiry(&token), err
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
+	}
+
+	// Configure transport for API requests
+	v.Client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&token),
+		Base: &http.Transport{
+			Proxy: http.ProxyFromEnvironment,
+		},
+	}
+
+	return util.TokenWithExpiry(&token), nil
 }
 
 // TokenSource implements oauth.TokenSource
 func (v *Identity) TokenSource() oauth2.TokenSource {
-	return oauth2.ReuseTokenSource(nil, v)
+	return oauth2.ReuseTokenSource(v.token, v)
 }
 
 // Token implements oauth.TokenSource
 func (v *Identity) Token() (*oauth2.Token, error) {
-	return v.login()
+	if v.token == nil || !v.token.Valid() {
path: vehicle/polestar/identity.go
user: login: loebse
body: Die Änderung ist nicht zwingend notwendig. Bisher wurde aber bei jeder Abfrage neu angemeldet und ein neuer Token generiert, auch wenn der bestehende Token eigentlich noch gültig war. Das habe ich gleich mit angepasst. Es wird geprüft, ob der Token noch gültig ist, dann wird er verwendet, sonst neuer Token. Es gab allerdings wirklich noch einen Fehler, hier korrigiert: [8d72acc](https://github.com/evcc-io/evcc/pull/20081/commits/8d72acca5213e2767ebc18400dd6e8f10a03c682)
created_at: 2025-03-25T13:11:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -136,16 +153,34 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		},
 	)
 
-	err = v.DoJSON(req, &token)
-	return util.TokenWithExpiry(&token), err
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
+	}
+
+	// Configure transport for API requests
+	v.Client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&token),
+		Base: &http.Transport{
+			Proxy: http.ProxyFromEnvironment,
+		},
+	}
+
+	return util.TokenWithExpiry(&token), nil
 }
 
 // TokenSource implements oauth.TokenSource
 func (v *Identity) TokenSource() oauth2.TokenSource {
path: vehicle/polestar/identity.go
user: login: loebse
body: Switching to English 😉 

This function handles a specific case where:

- The token exists (token != nil)
- The token's Expiry field is not set (token.Expiry.IsZero())
- But the token has an ExpiresIn value (token.ExpiresIn != 0)

In this case, it properly sets the Expiry time based on the ExpiresIn duration. 
created_at: 2025-03-25T13:17:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -136,16 +153,34 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		},
 	)
 
-	err = v.DoJSON(req, &token)
-	return util.TokenWithExpiry(&token), err
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
+	}
+
+	// Configure transport for API requests
+	v.Client.Transport = &oauth2.Transport{
path: vehicle/polestar/identity.go
user: login: loebse
body: Der wird tatsächlich nicht gebraucht, korrekt: [3e20206](https://github.com/evcc-io/evcc/pull/20081/commits/3e20206d1a7d9993c693143df5fbc4c3be0b6412)
created_at: 2025-03-25T13:24:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -72,47 +76,60 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		"code_challenge_method": {"S256"},
 	}
 
-	// Get resume path with browser-like headers
+	// Request authorization URL with browser-like headers
 	uri := fmt.Sprintf("%s/as/authorization.oauth2?%s", OAuthURI, data.Encode())
 	req, _ := request.New(http.MethodGet, uri, nil, map[string]string{
-		"Accept": "application/json",
+		"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
 	})
 
 	resp, err := v.Do(req)
 	if err != nil {
 		return nil, err
 	}
-	resp.Body.Close()
+	defer resp.Body.Close()
 
-	// First we get redirected to the login page
-	if strings.Contains(resp.Request.URL.Path, "/PolestarLogin/login") {
-		// Extract resumePath from the login URL
-		resumePath := resp.Request.URL.Query().Get("resumePath")
-		if resumePath == "" {
-			return nil, errors.New("missing resume path in login url")
-		}
+	// Extract resume path from HTML response
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
 
-		// Submit credentials directly to the login endpoint
-		loginURL := fmt.Sprintf("%s/as/%s/resume/as/authorization.ping", OAuthURI, resumePath)
-		data := url.Values{
-			"pf.username": {v.user},
-			"pf.pass":     {v.password},
-			"client_id":   {ClientID},
+	htmlContent := string(body)
+	resumePath := ""
+	if strings.Contains(htmlContent, "url: \"") {
path: vehicle/polestar/identity.go
user: login: loebse
body: Ja, ist es: [0914071](https://github.com/evcc-io/evcc/pull/20081/commits/0914071bf2e95f30e91428922b6631e972070878)
created_at: 2025-03-25T13:27:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -136,16 +153,34 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		},
 	)
 
-	err = v.DoJSON(req, &token)
-	return util.TokenWithExpiry(&token), err
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
+	}
+
+	// Configure transport for API requests
+	v.Client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&token),
+		Base: &http.Transport{
+			Proxy: http.ProxyFromEnvironment,
+		},
+	}
+
+	return util.TokenWithExpiry(&token), nil
 }
 
 // TokenSource implements oauth.TokenSource
 func (v *Identity) TokenSource() oauth2.TokenSource {
path: vehicle/polestar/identity.go
user: login: andig
body: maybe- got this function isn't being called anywhere!
created_at: 2025-03-25T13:54:34Z
author_association: MEMBER
diff_hunk: @@ -136,16 +153,34 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		},
 	)
 
-	err = v.DoJSON(req, &token)
-	return util.TokenWithExpiry(&token), err
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
+	}
+
+	// Configure transport for API requests
+	v.Client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&token),
+		Base: &http.Transport{
+			Proxy: http.ProxyFromEnvironment,
+		},
+	}
+
+	return util.TokenWithExpiry(&token), nil
 }
 
 // TokenSource implements oauth.TokenSource
 func (v *Identity) TokenSource() oauth2.TokenSource {
-	return oauth2.ReuseTokenSource(nil, v)
+	return oauth2.ReuseTokenSource(v.token, v)
 }
 
 // Token implements oauth.TokenSource
 func (v *Identity) Token() (*oauth2.Token, error) {
-	return v.login()
+	if v.token == nil || !v.token.Valid() {
path: vehicle/polestar/identity.go
user: login: andig
body: > Bisher wurde aber bei jeder Abfrage neu angemeldet und ein neuer Token generiert, auch wenn der bestehende Token eigentlich noch gültig war

Ok, das ist blöd. Aber der Weg hier ist falsch:
- wie lange gelten die Tokens?
- kann man sie refreshen (dann sollten wir das tun) oder ist immer ein Login notwendig?

Tecgnischen Vorschlag kann ihc machen, dieser hier ist nicht elegant ;)
created_at: 2025-03-25T13:55:38Z
author_association: MEMBER
diff_hunk: @@ -136,16 +153,34 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		},
 	)
 
-	err = v.DoJSON(req, &token)
-	return util.TokenWithExpiry(&token), err
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
+	}
+
+	// Configure transport for API requests
+	v.Client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&token),
+		Base: &http.Transport{
+			Proxy: http.ProxyFromEnvironment,
+		},
+	}
+
+	return util.TokenWithExpiry(&token), nil
 }
 
 // TokenSource implements oauth.TokenSource
 func (v *Identity) TokenSource() oauth2.TokenSource {
-	return oauth2.ReuseTokenSource(nil, v)
+	return oauth2.ReuseTokenSource(v.token, v)
 }
 
 // Token implements oauth.TokenSource
 func (v *Identity) Token() (*oauth2.Token, error) {
-	return v.login()
+	if v.token == nil || !v.token.Valid() {
path: vehicle/polestar/identity.go
user: login: loebse
body: Hmm... ich glaube wir haben zwei Möglichkeiten:

1. wie bisher immer den kompletten Login Flow
2. wir nutzen Access und Refresh Tokens.

Bei Nr 2 ist das Problem, dass von Polestar ein refresh token ohne ``Expiry Time`` kommt aber mit ``expires_in``mit 299 Sekunden sprich ca .5 Minuten

Daher die ``util.TokenWithExpiry``um das entsprechend zu berechnen.

Ich habe mal übergangsweise Logging eingebaut, um das zu verifizieren:
`[polestar] DEBUG 2025/03/25 14:12:44 Raw token fields: access_token=true, refresh_token=true, expiry=0001-01-01 00:00:00 +0000 UTC, expires_in=299, token_type=Bearer`

`[polestar] DEBUG 2025/03/25 14:12:44 Token after TokenWithExpiry: access_token=true, refresh_token=true, expiry=2025-03-25 14:17:43.218740296 +0000 UTC m=+300.258085293, expires_in=299, token_type=Bearer`
created_at: 2025-03-25T14:17:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -136,16 +153,34 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		},
 	)
 
-	err = v.DoJSON(req, &token)
-	return util.TokenWithExpiry(&token), err
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
+	}
+
+	// Configure transport for API requests
+	v.Client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&token),
+		Base: &http.Transport{
+			Proxy: http.ProxyFromEnvironment,
+		},
+	}
+
+	return util.TokenWithExpiry(&token), nil
 }
 
 // TokenSource implements oauth.TokenSource
 func (v *Identity) TokenSource() oauth2.TokenSource {
path: vehicle/polestar/identity.go
user: login: loebse
body: Well, this was a misunderstanding. You are talking about ``TokenSource()``. This was actually a left over, I removed it: [9d005be](https://github.com/evcc-io/evcc/pull/20081/commits/9d005be842ae17961156a796d234881f4672feba)
created_at: 2025-03-25T14:32:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -136,16 +153,34 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		},
 	)
 
-	err = v.DoJSON(req, &token)
-	return util.TokenWithExpiry(&token), err
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
+	}
+
+	// Configure transport for API requests
+	v.Client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&token),
+		Base: &http.Transport{
+			Proxy: http.ProxyFromEnvironment,
+		},
+	}
+
+	return util.TokenWithExpiry(&token), nil
 }
 
 // TokenSource implements oauth.TokenSource
 func (v *Identity) TokenSource() oauth2.TokenSource {
-	return oauth2.ReuseTokenSource(nil, v)
+	return oauth2.ReuseTokenSource(v.token, v)
 }
 
 // Token implements oauth.TokenSource
 func (v *Identity) Token() (*oauth2.Token, error) {
-	return v.login()
+	if v.token == nil || !v.token.Valid() {
path: vehicle/polestar/identity.go
user: login: loebse
body: Ich habe es noch einmal ein bisschen angepasst, aus meiner Sicht passt es: [9d005be](https://github.com/evcc-io/evcc/pull/20081/commits/9d005be842ae17961156a796d234881f4672feba)

Von elegant bin ich aber weit entfernt 😉 
created_at: 2025-03-25T14:33:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -136,16 +153,34 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		},
 	)
 
-	err = v.DoJSON(req, &token)
-	return util.TokenWithExpiry(&token), err
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
+	}
+
+	// Configure transport for API requests
+	v.Client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&token),
+		Base: &http.Transport{
+			Proxy: http.ProxyFromEnvironment,
+		},
+	}
+
+	return util.TokenWithExpiry(&token), nil
 }
 
 // TokenSource implements oauth.TokenSource
 func (v *Identity) TokenSource() oauth2.TokenSource {
-	return oauth2.ReuseTokenSource(nil, v)
+	return oauth2.ReuseTokenSource(v.token, v)
 }
 
 // Token implements oauth.TokenSource
 func (v *Identity) Token() (*oauth2.Token, error) {
-	return v.login()
+	if v.token == nil || !v.token.Valid() {
path: vehicle/polestar/identity.go
user: login: andig
body: Na dan probieren wir es mal ;)
created_at: 2025-03-25T15:11:04Z
author_association: MEMBER
diff_hunk: @@ -136,16 +153,34 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		},
 	)
 
-	err = v.DoJSON(req, &token)
-	return util.TokenWithExpiry(&token), err
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
+	}
+
+	// Configure transport for API requests
+	v.Client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&token),
+		Base: &http.Transport{
+			Proxy: http.ProxyFromEnvironment,
+		},
+	}
+
+	return util.TokenWithExpiry(&token), nil
 }
 
 // TokenSource implements oauth.TokenSource
 func (v *Identity) TokenSource() oauth2.TokenSource {
-	return oauth2.ReuseTokenSource(nil, v)
+	return oauth2.ReuseTokenSource(v.token, v)
 }
 
 // Token implements oauth.TokenSource
 func (v *Identity) Token() (*oauth2.Token, error) {
-	return v.login()
+	if v.token == nil || !v.token.Valid() {
path: vehicle/polestar/identity.go
user: login: andig
body: Jetzt wäre es noch eleganter, wenn wir davon ausgehen könnten, dass der Refresh immer funktioniert (was auch normal wäre!)- dann könnte man noch auf Speicherung von user/pw verzichten.
created_at: 2025-03-25T15:17:46Z
author_association: MEMBER
diff_hunk: @@ -72,47 +75,49 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		"code_challenge_method": {"S256"},
 	}
 
-	// Get resume path with browser-like headers
+	// Request authorization URL with browser-like headers
 	uri := fmt.Sprintf("%s/as/authorization.oauth2?%s", OAuthURI, data.Encode())
 	req, _ := request.New(http.MethodGet, uri, nil, map[string]string{
-		"Accept": "application/json",
+		"Accept": "text/html,application/xhtml+xml,application/xml;",
 	})
 
 	resp, err := v.Do(req)
 	if err != nil {
 		return nil, err
 	}
-	resp.Body.Close()
-
-	// First we get redirected to the login page
-	if strings.Contains(resp.Request.URL.Path, "/PolestarLogin/login") {
-		// Extract resumePath from the login URL
-		resumePath := resp.Request.URL.Query().Get("resumePath")
-		if resumePath == "" {
-			return nil, errors.New("missing resume path in login url")
-		}
-
-		// Submit credentials directly to the login endpoint
-		loginURL := fmt.Sprintf("%s/as/%s/resume/as/authorization.ping", OAuthURI, resumePath)
-		data := url.Values{
-			"pf.username": {v.user},
-			"pf.pass":     {v.password},
-			"client_id":   {ClientID},
-		}
-
-		req, _ = request.New(http.MethodPost, loginURL, strings.NewReader(data.Encode()), map[string]string{
-			"Content-Type": "application/x-www-form-urlencoded",
-			"Accept":       "application/json",
-		})
+	defer resp.Body.Close()
+
+	// Extract resume path from HTML response
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	matches := regexp.MustCompile(`url:\s*"/as/(.*?)/resume/as/authorization\.ping"`).FindStringSubmatch(string(body))
path: vehicle/polestar/identity.go
user: login: andig
body: ```suggestion
	matches := regexp.MustCompile(`url:\s*"/as/(.+?)/resume/as/authorization\.ping"`).FindStringSubmatch(string(body))
```
created_at: 2025-03-25T15:24:18Z
author_association: MEMBER
diff_hunk: @@ -136,16 +153,34 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		},
 	)
 
-	err = v.DoJSON(req, &token)
-	return util.TokenWithExpiry(&token), err
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
+	}
+
+	// Configure transport for API requests
+	v.Client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&token),
+		Base: &http.Transport{
+			Proxy: http.ProxyFromEnvironment,
+		},
+	}
+
+	return util.TokenWithExpiry(&token), nil
 }
 
 // TokenSource implements oauth.TokenSource
 func (v *Identity) TokenSource() oauth2.TokenSource {
-	return oauth2.ReuseTokenSource(nil, v)
+	return oauth2.ReuseTokenSource(v.token, v)
 }
 
 // Token implements oauth.TokenSource
 func (v *Identity) Token() (*oauth2.Token, error) {
-	return v.login()
+	if v.token == nil || !v.token.Valid() {
path: vehicle/polestar/identity.go
user: login: loebse
body: Wie meinst du das? Für den initialen Login bzw. Login nachdem der Token (warum auch immer) nicht mehr gültig ist, brauchen wir doch User und PW.
created_at: 2025-03-25T17:30:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -136,16 +153,34 @@ func (v *Identity) login() (*oauth2.Token, error) {
 		},
 	)
 
-	err = v.DoJSON(req, &token)
-	return util.TokenWithExpiry(&token), err
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
+	}
+
+	// Configure transport for API requests
+	v.Client.Transport = &oauth2.Transport{
+		Source: oauth2.StaticTokenSource(&token),
+		Base: &http.Transport{
+			Proxy: http.ProxyFromEnvironment,
+		},
+	}
+
+	return util.TokenWithExpiry(&token), nil
 }
 
 // TokenSource implements oauth.TokenSource
 func (v *Identity) TokenSource() oauth2.TokenSource {
-	return oauth2.ReuseTokenSource(nil, v)
+	return oauth2.ReuseTokenSource(v.token, v)
 }
 
 // Token implements oauth.TokenSource
 func (v *Identity) Token() (*oauth2.Token, error) {
-	return v.login()
+	if v.token == nil || !v.token.Valid() {
path: vehicle/polestar/identity.go
user: login: andig
body: Das warum auch immer sollte in der Realität nicht vorkommen. Und wenns das nicht tut brauchts u/p ausser am Anfang nicht. 
created_at: 2025-03-25T19:19:55Z
author_association: MEMBER

PR comments:
user: login: Lerua123
created_at: 2025-03-24T20:39:17Z
body: Sorry if I missed something, I’m still a beginner.
Is there already a solution, and if so, what do I need to do?
author_association: NONE
user: login: ottomitschke
created_at: 2025-03-24T20:50:58Z
body: > Sorry if I missed something, I’m still a beginner. Is there already a solution, and if so, what do I need to do?

Wait until tomorrow or so and try with the nightly build if the issue is still there. - Normally such fixes will be implemented first in the nightly and some days later in the stable build


Thanks @loebse , you are great. Looking forward to check with the nightly build if it is working again. 
author_association: NONE
user: login: loebse
created_at: 2025-03-25T13:30:10Z
body: @andig I did a few changes based on your comments. In general, I tried many things yesterday until it finally worked and did not clean up properly 😉 I am also not super familiar with the overall evcc project structure yet. Hope this is better now
author_association: CONTRIBUTOR
user: login: andig
created_at: 2025-03-25T15:23:29Z
body: Hab auch die regex nochmal angefasst- hoffe ich hab nix kaputt gemacht :O 
author_association: MEMBER
user: login: loebse
created_at: 2025-03-25T17:27:14Z
body: > Hab auch die regex nochmal angefasst- hoffe ich hab nix kaputt gemacht :O

Geht noch 😉 
author_association: CONTRIBUTOR