diff --git a/charger/zaptec.go b/charger/zaptec.go index 7c6b105ce..a58673c6e 100644 --- a/charger/zaptec.go +++ b/charger/zaptec.go @@ -26,12 +26,12 @@ import ( "sort" "time" - "github.com/coreos/go-oidc/v3/oidc" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/charger/zaptec" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" "github.com/evcc-io/evcc/util/sponsor" + "github.com/evcc-io/evcc/util/transport" "golang.org/x/oauth2" ) @@ -55,18 +55,6 @@ func init() { registry.AddCtx("zaptec", NewZaptecFromConfig) } -// passwordTokenSource implements oauth2.TokenSource for password grant flow -type passwordTokenSource struct { - ctx context.Context - config *oauth2.Config - user string - pass string -} - -func (p passwordTokenSource) Token() (*oauth2.Token, error) { - return p.config.PasswordCredentialsToken(p.ctx, p.user, p.pass) -} - //go:generate go tool decorate -f decorateZaptec -b *Zaptec -r api.Charger -t "api.PhaseSwitcher,Phases1p3p,func(int) error" // NewZaptecFromConfig creates a Zaptec Pro charger from generic config @@ -107,6 +95,14 @@ func NewZaptec(ctx context.Context, user, password, id string, priority bool, pa passive: passive, } + // Add User-Agent header for Zaptec API compliance + c.Client.Transport = &transport.Decorator{ + Decorator: transport.DecorateHeaders(map[string]string{ + "User-Agent": "evcc/" + util.Version, + }), + Base: c.Client.Transport, + } + // setup cached values c.statusG = util.ResettableCached(func() (zaptec.StateResponse, error) { var res zaptec.StateResponse @@ -117,43 +113,18 @@ func NewZaptec(ctx context.Context, user, password, id string, priority bool, pa return res, err }, cache) - provider, err := oidc.NewProvider(ctx, zaptec.ApiURL+"/") - if err != nil { - return nil, fmt.Errorf("failed to initialize OIDC provider: %s", err) - } - - oc := &oauth2.Config{ - Endpoint: provider.Endpoint(), - Scopes: []string{ - oidc.ScopeOpenID, - }, - } - // Create a separate HTTP client for OAuth token requests to avoid circular dependency // (c.Transport will be modified to use oauth2.Transport, which would create a loop) - tokenClient := &http.Client{ + tsCtx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{ Transport: c.Transport, - } + }) - oauthCtx := context.WithValue( - ctx, - oauth2.HTTPClient, - tokenClient, - ) - - token, err := oc.PasswordCredentialsToken(oauthCtx, user, password) + // Get shared token source for this user (per-user uniqueness) + ts, err := zaptec.GetTokenSource(tsCtx, user, password) if err != nil { return nil, err } - // Create custom token source that always uses password grant (no refresh tokens) - ts := oauth2.ReuseTokenSource(token, passwordTokenSource{ - ctx: oauthCtx, - config: oc, - user: user, - pass: password, - }) - c.Transport = &oauth2.Transport{ Source: ts, Base: c.Transport, diff --git a/charger/zaptec/auth.go b/charger/zaptec/auth.go new file mode 100644 index 000000000..201fb0f69 --- /dev/null +++ b/charger/zaptec/auth.go @@ -0,0 +1,88 @@ +package zaptec + +import ( + "context" + "fmt" + "sync" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// passwordTokenSource implements oauth2.TokenSource for password grant flow +type passwordTokenSource struct { + ctx context.Context + config *oauth2.Config + user string + pass string +} + +// Token returns a token or an error. +// Implements oauth2.TokenSource interface +func (p *passwordTokenSource) Token() (*oauth2.Token, error) { + return p.config.PasswordCredentialsToken(p.ctx, p.user, p.pass) +} + +// tokenSourceCache stores per-user token sources +var ( + tokenSourceMu sync.Mutex + tokenSourceCache = make(map[string]oauth2.TokenSource) + + oidcProvider *oidc.Provider + oidcProviderOnce sync.Once + oidcProviderErr error +) + +// getOIDCProvider returns the cached OIDC provider, initializing it once if needed +func getOIDCProvider(ctx context.Context) (*oidc.Provider, error) { + oidcProviderOnce.Do(func() { + oidcProvider, oidcProviderErr = oidc.NewProvider(ctx, ApiURL+"/") + }) + return oidcProvider, oidcProviderErr +} + +// GetTokenSource returns a shared oauth2.TokenSource for the given user credentials. +// Multiple chargers using the same user credentials will share the same TokenSource, +// ensuring tokens are reused and authentication is deduplicated. +func GetTokenSource(ctx context.Context, user, pass string) (oauth2.TokenSource, error) { + tokenSourceMu.Lock() + defer tokenSourceMu.Unlock() + + // Use username as the cache key (assuming username is unique) + if ts, exists := tokenSourceCache[user]; exists { + return ts, nil + } + + // Get the cached OIDC provider (initialized once) + provider, err := getOIDCProvider(ctx) + if err != nil { + return nil, fmt.Errorf("failed to initialize OIDC provider: %w", err) + } + + oc := &oauth2.Config{ + Endpoint: provider.Endpoint(), + Scopes: []string{ + oidc.ScopeOpenID, + }, + } + + // Create the password token source + pts := &passwordTokenSource{ + ctx: ctx, + config: oc, + user: user, + pass: pass, + } + + // Get initial token + token, err := pts.Token() + if err != nil { + return nil, err + } + + // Wrap with ReuseTokenSource to cache tokens + ts := oauth2.ReuseTokenSource(token, pts) + tokenSourceCache[user] = ts + + return ts, nil +}