Skip to content

Commit

Permalink
Polestar: fix authentication (#17683)
Browse files Browse the repository at this point in the history
loebse authored Dec 10, 2024
1 parent 3aa2915 commit 4bffaa5
Showing 2 changed files with 139 additions and 118 deletions.
250 changes: 135 additions & 115 deletions vehicle/polestar/identity.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package polestar

import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"net/http/cookiejar"
@@ -10,174 +12,192 @@ import (
"time"

"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/oauth"
"github.com/evcc-io/evcc/util/request"
"github.com/hasura/go-graphql-client"
"github.com/samber/lo"
"golang.org/x/net/publicsuffix"
"golang.org/x/oauth2"
)

// https://github.com/TA2k/ioBroker.polestar

const OAuthURI = "https://polestarid.eu.polestar.com"

// https://polestarid.eu.polestar.com/.well-known/openid-configuration
var OAuth2Config = &oauth2.Config{
ClientID: "l3oopkc_10",
RedirectURL: "https://www.polestar.com/sign-in-callback",
Endpoint: oauth2.Endpoint{
AuthURL: OAuthURI + "/as/authorization.oauth2",
TokenURL: OAuthURI + "/as/token.oauth2",
},
Scopes: []string{
"openid", "profile", "email", "customer:attributes",
// "conve:recharge_status", "conve:fuel_status", "conve:odometer_status",
// "energy:charging_connection_status", "energy:electric_range", "energy:estimated_charging_time", "energy:recharge_status",
// "energy:battery_charge_level", "energy:charging_system_status", "energy:charging_timer", "energy:electric_range", "energy:recharge_status",
// "energy:battery_charge_level",
},
}
const (
OAuthURI = "https://polestarid.eu.polestar.com"
ClientID = "l3oopkc_10"
RedirectURI = "https://www.polestar.com/sign-in-callback"
)

type Identity struct {
*request.Helper
user, password string
jar *cookiejar.Jar
log *util.Logger
}

// NewIdentity creates Polestar identity
func NewIdentity(log *util.Logger, user, password string) (oauth2.TokenSource, error) {
func NewIdentity(log *util.Logger, user, password string) (*Identity, error) {
v := &Identity{
Helper: request.NewHelper(log),
user: user,
password: password,
log: log,
}

v.Client.Jar, _ = cookiejar.New(&cookiejar.Options{
log.DEBUG.Printf("initializing polestar identity with user: %s", user)

jar, err := cookiejar.New(&cookiejar.Options{
PublicSuffixList: publicsuffix.List,
})
if err != nil {
return nil, err
}
v.jar = jar
v.Client.Jar = jar

token, err := v.login()
if err != nil {
return nil, err
}

v.Client.Transport = &oauth2.Transport{
Source: oauth2.StaticTokenSource(token),
Base: v.Client.Transport,
}

return oauth.RefreshTokenSource(token, v), err
return v, nil
}

func (v *Identity) login() (*oauth2.Token, error) {
state := lo.RandomString(16, lo.AlphanumericCharset)
uri := OAuth2Config.AuthCodeURL(state, oauth2.AccessTypeOffline)
// generates code verifier for PKCE
func generateCodeVerifier() string {
b := make([]byte, 32)
rand.Read(b)
return strings.TrimRight(base64.URLEncoding.EncodeToString(b), "=")
}

var param request.InterceptResult
v.Client.CheckRedirect, param = request.InterceptRedirect("resumePath", true)
defer func() { v.Client.CheckRedirect = nil }()
// generates code challenge from verifier
func generateCodeChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier))
return strings.TrimRight(base64.URLEncoding.EncodeToString(hash[:]), "=")
}

if _, err := v.Get(uri); err != nil {
func (v *Identity) login() (*oauth2.Token, error) {
state := lo.RandomString(16, lo.AlphanumericCharset)
codeVerifier := generateCodeVerifier()
codeChallenge := generateCodeChallenge(codeVerifier)

// Build authorization URI with all required scopes
authURL := fmt.Sprintf("%s/as/authorization.oauth2"+
"?client_id=%s"+
"&redirect_uri=%s"+
"&response_type=code"+
"&state=%s"+
"&scope=openid%%20profile%%20email"+
"&code_challenge=%s"+
"&code_challenge_method=S256",
OAuthURI, ClientID, RedirectURI, state, codeChallenge)

// Get resume path with browser-like headers
req, err := request.New(http.MethodGet, authURL, nil, map[string]string{
"Accept": "application/json",
})
if err != nil {
return nil, err
}

resume, err := param()
resp, err := v.Do(req)
if err != nil {
return nil, err
}
v.log.TRACE.Printf("auth response URL: %s", resp.Request.URL.String())
resp.Body.Close()

params := url.Values{
"pf.username": []string{v.user},
"pf.pass": []string{v.password},
// Extract resume path from redirect URL
if resp.Request.URL == nil {
return nil, fmt.Errorf("no redirect url")
}

uri = fmt.Sprintf("%s/as/%s/resume/as/authorization.ping?client_id=%s", OAuthURI, resume, OAuth2Config.ClientID)

var code string
var uid string
v.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
code = req.URL.Query().Get("code")
uid = req.URL.Query().Get("uid")
return nil
}
defer func() { v.Client.CheckRedirect = nil }()

if _, err := v.Post(uri, request.FormContent, strings.NewReader(params.Encode())); err != nil {
return nil, err
}
// 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, fmt.Errorf("resume path not found in login URL: %s", resp.Request.URL.String())
}
v.log.TRACE.Printf("got resume path: %s", resumePath)

// Submit credentials directly to the login endpoint
loginURL := fmt.Sprintf("%s/as/%s/resume/as/authorization.ping", OAuthURI, resumePath)
data := url.Values{
"pf.username": []string{v.user},
"pf.pass": []string{v.password},
"client_id": []string{ClientID},
}

// If the authorization code is empty, this indicates that user consent must be handled
// before the code can be obtained. The `confirmConsentAndGetCode` method is called as a
// workaround to guide the user through the consent process and retrieve the authorization code.
if code == "" {
code, err = v.confirmConsentAndGetCode(resume, uid)
req, err = request.New(http.MethodPost, loginURL, strings.NewReader(data.Encode()), map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
})
if err != nil {
return nil, err
}
}

var res struct {
Token `graphql:"getAuthToken(code: $code)"`
}

if err := graphql.NewClient(ApiURI+"/auth", v.Client).
Query(context.Background(), &res, map[string]any{
"code": code,
}, graphql.OperationName("getAuthToken")); err != nil {
return nil, err
}
resp, err = v.Do(req)
if err != nil {
return nil, err
}
v.log.TRACE.Printf("login response URL: %s", resp.Request.URL.String())
resp.Body.Close()

token := &oauth2.Token{
AccessToken: res.AccessToken,
RefreshToken: res.RefreshToken,
Expiry: time.Now().Add(time.Duration(res.ExpiresIn) * time.Second),
if resp.Request.URL == nil {
return nil, fmt.Errorf("no redirect url after login")
}
}

return token, err
}

func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
var res struct {
Token `graphql:"refreshAuthToken(token: $token)"`
// After login, we should get the authorization code directly
query := resp.Request.URL.Query()
code := query.Get("code")
if code != "" {
v.log.TRACE.Printf("got authorization code directly")
goto exchange
}

err := graphql.NewClient(ApiURI+"/auth", v.Client).WithRequestModifier(func(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
}).Query(context.Background(), &res, map[string]any{
"token": token.RefreshToken,
}, graphql.OperationName("refreshAuthToken"))
return nil, fmt.Errorf("authorization code not found in URL: %s", resp.Request.URL.String())

exchange:
// Exchange code for token
data := url.Values{
"grant_type": []string{"authorization_code"},
"code": []string{code},
"code_verifier": []string{codeVerifier},
"client_id": []string{ClientID},
"redirect_uri": []string{RedirectURI},
}

var token Token
req, err = 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",
},
)
if err == nil {
return &oauth2.Token{
AccessToken: res.AccessToken,
RefreshToken: res.RefreshToken,
Expiry: time.Now().Add(time.Duration(res.ExpiresIn) * time.Second),
}, nil
err = v.DoJSON(req, &token)
}

return v.login()
return &oauth2.Token{
AccessToken: token.AccessToken,
TokenType: "Bearer",
RefreshToken: token.RefreshToken,
Expiry: time.Now().Add(time.Duration(token.ExpiresIn) * time.Second),
}, err
}

func (v *Identity) confirmConsentAndGetCode(resume, uid string) (string, error) {
// Extract the user ID (UID) from the redirect parameters
if uid == "" {
return "", fmt.Errorf("failed to extract user ID")
}

// Confirm user consent by submitting the consent form, which rejects cookies
data := url.Values{
"pf.submit": []string{"true"},
"subject": []string{uid},
}

// Retrieve the authorization code after consent has been confirmed
var param request.InterceptResult
v.Client.CheckRedirect, param = request.InterceptRedirect("code", true)
defer func() { v.Client.CheckRedirect = nil }()

// Make a POST request to confirm the user consent
if _, err := v.Post(fmt.Sprintf("%s/as/%s/resume/as/authorization.ping", OAuthURI, resume), request.FormContent, strings.NewReader(data.Encode())); err != nil {
return "", fmt.Errorf("failed confirming user consent: %w", err)
}

// Extract the authorization code from the response
code, err := param()
if err != nil || code == "" {
return "", fmt.Errorf("failed extracting authorisation code: %w", err)
}
// TokenSource implements oauth.TokenSource
func (v *Identity) TokenSource() oauth2.TokenSource {
return oauth2.ReuseTokenSource(nil, v)
}

// Return the retrieved code
return code, nil
// Token implements oauth.TokenSource
func (v *Identity) Token() (*oauth2.Token, error) {
return v.login()
}
7 changes: 4 additions & 3 deletions vehicle/polestar/types.go
Original file line number Diff line number Diff line change
@@ -3,9 +3,10 @@ package polestar
import "time"

type Token struct {
AccessToken string `graphql:"access_token"`
RefreshToken string `graphql:"refresh_token"`
ExpiresIn int `graphql:"expires_in"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}

type ConsumerCar struct {

0 comments on commit 4bffaa5

Please sign in to comment.