diff --git a/tariff/octopusde.go b/tariff/octopusde.go index aa8e80699..286ac8bf3 100644 --- a/tariff/octopusde.go +++ b/tariff/octopusde.go @@ -13,11 +13,9 @@ import ( ) type OctopusDe struct { - log *util.Logger - email string - password string - accountNumber string - data *util.Monitor[api.Rates] + log *util.Logger + gqlClient *octoDeGql.OctopusDeGraphQLClient + data *util.Monitor[api.Rates] } var _ api.Tariff = (*OctopusDe)(nil) @@ -45,8 +43,6 @@ func buildOctopusDeFromConfig(other map[string]any) (*OctopusDe, error) { AccountNumber string } - logger := util.NewLogger("octopus-de") - if err := util.DecodeOther(other, &cc); err != nil { return nil, err } @@ -63,12 +59,18 @@ func buildOctopusDeFromConfig(other map[string]any) (*OctopusDe, error) { return nil, errors.New("missing account number") } + log := util.NewLogger("octopus-de") + + // Create GraphQL client + gqlClient, err := octoDeGql.NewClient(log, cc.Email, cc.Password, cc.AccountNumber) + if err != nil { + return nil, err + } + t := &OctopusDe{ - log: logger, - email: cc.Email, - password: cc.Password, - accountNumber: cc.AccountNumber, - data: util.NewMonitor[api.Rates](2 * time.Hour), + log: log, + gqlClient: gqlClient, + data: util.NewMonitor[api.Rates](2 * time.Hour), } return t, nil @@ -77,20 +79,12 @@ func buildOctopusDeFromConfig(other map[string]any) (*OctopusDe, error) { func (t *OctopusDe) run(done chan error) { var once sync.Once - // Create GraphQL client - gqlClient, err := octoDeGql.NewClient(t.log, t.email, t.password, t.accountNumber) - if err != nil { - once.Do(func() { done <- err }) - t.log.ERROR.Println(err) - return - } - for tick := time.Tick(time.Hour); ; <-tick { var rates []octoDeGql.RatePeriod if err := backoff.Retry(func() error { var err error - rates, err = gqlClient.UnitRateForecast() + rates, err = t.gqlClient.UnitRateForecast() return backoffPermanentError(err) }, bo()); err != nil { once.Do(func() { done <- err }) diff --git a/tariff/octopusde/graphql/api.go b/tariff/octopusde/graphql/api.go index 2f82c8f2b..e27423410 100644 --- a/tariff/octopusde/graphql/api.go +++ b/tariff/octopusde/graphql/api.go @@ -6,11 +6,12 @@ import ( "fmt" "net/http" "strconv" - "sync" "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/transport" "github.com/hasura/go-graphql-client" ) @@ -20,95 +21,44 @@ const BaseURI = "https://api.oeg-kraken.energy/v1/graphql/" // OctopusDeGraphQLClient provides an interface for communicating with Octopus Energy Germany's Kraken platform. type OctopusDeGraphQLClient struct { - *graphql.Client - - // Local logging utility. log *util.Logger - - // email is the Octopus Energy Germany account email - email string - - // password is the Octopus Energy Germany account password - password string - - // token is the GraphQL token used for communication with kraken - token *string - // tokenExpiration tracks the expiry of the acquired token - tokenExpiration time.Time - // tokenMtx should be held when requesting a new token - tokenMtx sync.Mutex - - // accountNumber is the Octopus Energy Germany account number + *graphql.Client accountNumber string } // NewClient returns a new, authenticated instance of OctopusDeGraphQLClient. func NewClient(log *util.Logger, email, password, accountNumber string) (*OctopusDeGraphQLClient, error) { + ts := oauth.RefreshTokenSource(nil, &TokenSource{ + log: log, + email: email, + password: password, + }) + cli := request.NewClient(log) + cli.Transport = &transport.Decorator{ + Decorator: func(req *http.Request) error { + token, err := ts.Token() + if err != nil { + return err + } + // Kraken API requires Authorization header without "Bearer" prefix + req.Header.Set("Authorization", token.AccessToken) + return nil + }, + Base: cli.Transport, + } gq := &OctopusDeGraphQLClient{ - Client: graphql.NewClient(BaseURI, cli), log: log, - email: email, - password: password, accountNumber: accountNumber, + Client: graphql.NewClient(BaseURI, cli), } - if err := gq.refreshToken(); err != nil { - return nil, err - } - - // Future requests must have the appropriate Authorization header set - gq.Client = gq.Client.WithRequestModifier(func(r *http.Request) { - gq.tokenMtx.Lock() - defer gq.tokenMtx.Unlock() - if gq.token != nil { - r.Header.Add("Authorization", *gq.token) - } - }) - return gq, nil } -// refreshToken updates the GraphQL token from the email and password. -// Basic caching is provided - it will not update the token if it hasn't expired yet. -func (c *OctopusDeGraphQLClient) refreshToken() error { - // take a lock against the token mutex for the refresh - c.tokenMtx.Lock() - defer c.tokenMtx.Unlock() - - if time.Until(c.tokenExpiration) > 5*time.Minute { - return nil - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) - defer cancel() - - // Create a temporary client without authentication for the initial token request - cli := request.NewClient(c.log) - tempClient := graphql.NewClient(BaseURI, cli) - - var q krakenTokenAuthentication - if err := tempClient.Mutate(ctx, &q, map[string]any{ - "email": c.email, - "password": c.password, - }); err != nil { - return fmt.Errorf("authentication failed: %w", err) - } - - c.token = &q.ObtainKrakenToken.Token - c.tokenExpiration = time.Now().Add(time.Hour) - c.log.TRACE.Println("GraphQL: refreshed token, now expires", c.tokenExpiration) - return nil -} - // UnitRateForecast queries the day-ahead price forecast for the account func (c *OctopusDeGraphQLClient) UnitRateForecast() ([]RatePeriod, error) { - // Update refresh token (if necessary) - if err := c.refreshToken(); err != nil { - return nil, err - } - ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() @@ -156,13 +106,11 @@ func (c *OctopusDeGraphQLClient) UnitRateForecast() ([]RatePeriod, error) { // Parse string values to float64 netRate, err := parseFloat(rate.NetUnitRateCentsPerKwh) if err != nil { - c.log.DEBUG.Printf("failed to parse net unit rate '%s': %v", rate.NetUnitRateCentsPerKwh, err) return nil, fmt.Errorf("failed to parse net unit rate: %w", err) } grossRate, err := parseFloat(rate.LatestGrossUnitRateCentsPerKwh) if err != nil { - c.log.DEBUG.Printf("failed to parse gross unit rate '%s': %v", rate.LatestGrossUnitRateCentsPerKwh, err) return nil, fmt.Errorf("failed to parse gross unit rate: %w", err) } @@ -180,7 +128,6 @@ func (c *OctopusDeGraphQLClient) UnitRateForecast() ([]RatePeriod, error) { return nil, errors.New("no rate forecast available") } - c.log.TRACE.Printf("GraphQL: retrieved %d rate periods", len(rates)) return rates, nil } diff --git a/tariff/octopusde/graphql/tokensource.go b/tariff/octopusde/graphql/tokensource.go new file mode 100644 index 000000000..6cc4fa158 --- /dev/null +++ b/tariff/octopusde/graphql/tokensource.go @@ -0,0 +1,55 @@ +package graphql + +import ( + "context" + "fmt" + "time" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/golang-jwt/jwt/v5" + "github.com/hasura/go-graphql-client" + "golang.org/x/oauth2" +) + +type TokenSource struct { + log *util.Logger + email, password string +} + +// RefreshToken implements oauth.TokenRefresher to obtain a new JWT token. +// It parses the JWT to extract the actual expiry time from the token claims. +func (c *TokenSource) RefreshToken(_ *oauth2.Token) (*oauth2.Token, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + // Create a temporary client without authentication for the token request + cli := request.NewClient(c.log) + tempClient := graphql.NewClient(BaseURI, cli) + + var q krakenTokenAuthentication + if err := tempClient.Mutate(ctx, &q, map[string]any{ + "email": c.email, + "password": c.password, + }); err != nil { + return nil, fmt.Errorf("authentication failed: %w", err) + } + + // Parse JWT to extract expiry time using RegisteredClaims + // We use ParseUnverified since we don't have the signing key and trust the token from the API + var claims jwt.RegisteredClaims + if _, _, err := jwt.NewParser(jwt.WithoutClaimsValidation()).ParseUnverified(q.ObtainKrakenToken.Token, &claims); err != nil { + return nil, fmt.Errorf("failed to parse JWT: %w", err) + } + + // Extract expiry from JWT claims + expiry := time.Now().Add(time.Hour) + if claims.ExpiresAt != nil { + expiry = claims.ExpiresAt.Time + } + + return &oauth2.Token{ + AccessToken: q.ObtainKrakenToken.Token, + Expiry: expiry, + }, nil +} diff --git a/tariff/octopusde_test.go b/tariff/octopusde_test.go index 7d143cf01..18d4ae66e 100644 --- a/tariff/octopusde_test.go +++ b/tariff/octopusde_test.go @@ -3,13 +3,10 @@ package tariff import ( "testing" - "github.com/evcc-io/evcc/util/test" "github.com/stretchr/testify/require" ) func TestOctopusDeConfigParse(t *testing.T) { - test.SkipCI(t) - validConfig := map[string]any{ "email": "test@example.com", "password": "testpassword", @@ -19,9 +16,6 @@ func TestOctopusDeConfigParse(t *testing.T) { tariff, err := buildOctopusDeFromConfig(validConfig) require.NoError(t, err) require.NotNil(t, tariff) - require.Equal(t, "test@example.com", tariff.email) - require.Equal(t, "testpassword", tariff.password) - require.Equal(t, "A-12345678", tariff.accountNumber) missingEmailConfig := map[string]any{ "password": "testpassword",