Skip to content

shared authentication for easee chargers#29502

Draft
tolot27 wants to merge 6 commits into
evcc-io:masterfrom
tolot27:easee_authplugin
Draft

shared authentication for easee chargers#29502
tolot27 wants to merge 6 commits into
evcc-io:masterfrom
tolot27:easee_authplugin

Conversation

@tolot27
Copy link
Copy Markdown
Contributor

@tolot27 tolot27 commented Apr 30, 2026

This PR implements an authentication provider for Easee charges. Only a single authentication will be necessary for all chargers of the same user (bound to the email address).

Fixes #29501

open tasks:

  • GUI enhancements (Account config/selection)
  • in-depth test with connection monitoring

@tolot27 tolot27 marked this pull request as draft April 30, 2026 06:39
Comment thread plugin/auth/easee.go Outdated
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • In plugin/auth/easee_test.go the tests call newEaseeFromConfig(t.Context(), ...), but *testing.T does not provide a Context() method in the standard library; consider switching to context.Background() (or a helper that returns a context) so the tests compile cleanly.
  • The new (*tokenSource).TokenSourceWithInitial method in charger/easee/identity.go appears unused; if it's not part of an immediately upcoming change, consider removing it to avoid dead code and keep the identity API minimal.
  • In plugin/auth/easee.go there is a commented-out import line for github.com/evcc-io/evcc/api; since the package is already imported normally, you can safely delete the commented line to keep the imports tidy.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `plugin/auth/easee_test.go` the tests call `newEaseeFromConfig(t.Context(), ...)`, but `*testing.T` does not provide a `Context()` method in the standard library; consider switching to `context.Background()` (or a helper that returns a context) so the tests compile cleanly.
- The new `(*tokenSource).TokenSourceWithInitial` method in `charger/easee/identity.go` appears unused; if it's not part of an immediately upcoming change, consider removing it to avoid dead code and keep the identity API minimal.
- In `plugin/auth/easee.go` there is a commented-out import line for `github.com/evcc-io/evcc/api`; since the package is already imported normally, you can safely delete the commented line to keep the imports tidy.

## Individual Comments

### Comment 1
<location path="plugin/auth/easee.go" line_range="42-44" />
<code_context>
+}
+
+// easeeSubject derives a stable settings DB key from the user email.
+func easeeSubject(user string) string {
+	h := sha256.Sum256([]byte(user))
+	return "easee-" + hex.EncodeToString(h[:])[:8]
+}
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Keying the shared token source only by user may cause issues when the password changes.

Because easeeSubject is derived only from the user string, NewEaseeTokenSource will reuse an in-memory TokenSource even after that user’s password changes. If a refresh triggers a re-login, it will keep using the old password and can fail repeatedly until the process restarts and the cache resets. Consider including a password-derived or versioned component in the key so that a password change leads to a new TokenSource, while still avoiding logging or persisting the actual password.
</issue_to_address>

### Comment 2
<location path="charger/easee/identity.go" line_range="68" />
<code_context>
-		token, err := c.authenticate()
+// TokenSourceWithInitial creates a token source, using initialToken if non-nil,
+// or performing a fresh login otherwise. Unlike TokenSource, this does not cache.
+func (c *tokenSource) TokenSourceWithInitial(initialToken *oauth2.Token) (oauth2.TokenSource, error) {
+	if initialToken == nil {
+		token, err := c.Authenticate()
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying the identity API by removing the extra login/TokenSourceWithInitial helpers and letting callers compose Authenticate and RefreshToken directly.

You can keep the new functionality (external auth + initial tokens) but reduce surface area and hops by:

1. **Remove the `login()` hop and reuse `Authenticate()` in `RefreshToken`**

`login()` only exists to convert `*Token``*oauth2.Token` via `AsOAuth2Token()`, and `RefreshToken` can simply call `Authenticate()` on refresh failure.

```go
// Authenticate performs the initial username/password login and returns an oauth2.Token.
func (c *tokenSource) Authenticate() (*oauth2.Token, error) {
	data := struct {
		Username string `json:"userName"`
		Password string `json:"password"`
	}{
		Username: c.user,
		Password: c.password,
	}

	uri := fmt.Sprintf("%s/%s", API, "accounts/login")
	req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding)
	if err != nil {
		return nil, err
	}

	var token Token
	if err := c.DoJSON(req, &token); err != nil {
		return nil, err
	}

	return token.AsOAuth2Token(), nil
}

// RefreshToken refreshes an existing oauth2 token, falls back to a full re-login.
func (c *tokenSource) RefreshToken(oauthToken *oauth2.Token) (*oauth2.Token, error) {
	data := struct {
		AccessToken  string `json:"accessToken"`
		RefreshToken string `json:"refreshToken"`
	}{
		AccessToken:  oauthToken.AccessToken,
		RefreshToken: oauthToken.RefreshToken,
	}

	uri := fmt.Sprintf("%s/%s", API, "accounts/refresh_token")
	req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding)
	if err != nil {
		return nil, err
	}

	var token *Token
	if err := c.DoJSON(req, &token); err != nil {
		// re-login on refresh failure
		return c.Authenticate()
	}

	return token.AsOAuth2Token(), nil
}
```

Then you can delete `login()` entirely, reducing one public/semipublic concept (`Authenticate` vs `login`) without changing behavior.

2. **Inline `TokenSourceWithInitial` policy at the caller**

`TokenSourceWithInitial` encodes a very specific policy (“use initial token if present, otherwise authenticate, then wrap in refresh source”) that’s only needed by plugin auth. The same behavior can be expressed directly where it’s used, keeping `tokenSource` focused on login + refresh.

Instead of:

```go
id := NewIdentity(log, user, password)
src, err := id.TokenSourceWithInitial(initialToken)
```

do at the call site:

```go
id := NewIdentity(log, user, password)

token := initialToken
if token == nil {
	var err error
	token, err = id.Authenticate()
	if err != nil {
		return nil, err
	}
}

src := oauth.RefreshTokenSource(token, id.RefreshToken)
```

Then you can remove `TokenSourceWithInitial` from `tokenSource`, which shrinks the API and keeps the “token source wiring” logic in the plugin layer where the policy actually belongs.

These two changes keep all current capabilities (cached token source, external auth, initial tokens, refresh with re-login) but consolidate responsibilities into:

- `NewIdentity(log, user, password)`
- `(*tokenSource).Authenticate()`
- `(*tokenSource).RefreshToken()`

with callers composing them into token sources as needed.
</issue_to_address>

### Comment 3
<location path="plugin/auth/easee.go" line_range="47" />
<code_context>
+	return "easee-" + hex.EncodeToString(h[:])[:8]
+}
+
+// NewEaseeTokenSource returns a shared, persistent oauth2.TokenSource for the
+// given Easee account. Multiple callers with the same user email share one
+// token source so the Easee cloud API is not called unnecessarily.
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the token load/save logic from `NewEaseeTokenSource` into small helper functions to narrow its responsibilities and simplify its control flow.

You can keep the same behavior while making `NewEaseeTokenSource` narrower in scope by extracting the persistence concerns into small helpers. That reduces the number of responsibilities in the function and makes the side effects more obvious and testable, without changing the shared/persistent semantics.

### 1. Extract loading/persisting helpers

Pull DB interaction out of `NewEaseeTokenSource`:

```go
func loadEaseeToken(subject string) *oauth2.Token {
	if !settings.Exists(subject) {
		return nil
	}

	var storedToken oauth2.Token
	if err := settings.Json(subject, &storedToken); err != nil {
		return nil
	}
	if storedToken.RefreshToken == "" {
		return nil
	}

	return &storedToken
}

func persistEaseeToken(log *util.Logger, subject string, token *oauth2.Token) {
	if err := settings.SetJson(subject, token); err != nil {
		log.WARN.Printf("failed to persist Easee token: %v", err)
	}
}
```

### 2. Use those helpers in `NewEaseeTokenSource`

This keeps `NewEaseeTokenSource` mostly as an orchestration function:

```go
func NewEaseeTokenSource(user, password string) (oauth2.TokenSource, error) {
	easeeInstancesMu.Lock()
	defer easeeInstancesMu.Unlock()

	subject := easeeSubject(user)
	if ts, ok := easeeInstances[subject]; ok {
		return ts, nil
	}

	log := util.NewLogger("easee").Redact(user, password)
	id := easee.NewIdentity(log, user, password)

	initialToken := loadEaseeToken(subject)
	if initialToken == nil {
		token, err := id.Authenticate()
		if err != nil {
			return nil, err
		}
		persistEaseeToken(log, subject, token)
		initialToken = token
	}

	refreshWithPersist := func(token *oauth2.Token) (*oauth2.Token, error) {
		newToken, err := id.RefreshToken(token)
		if err != nil {
			return nil, err
		}
		persistEaseeToken(log, subject, newToken)
		return newToken, nil
	}

	ts := oauth.RefreshTokenSource(initialToken, refreshWithPersist)
	easeeInstances[subject] = ts
	return ts, nil
}
```

This keeps all current behavior (shared map, subject hashing, persisted tokens, refresh + persist), but the main function is shorter and easier to follow, and DB interactions are isolated in small, focused helpers.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread plugin/auth/easee.go Outdated
Comment thread charger/easee/identity.go Outdated
Comment thread plugin/auth/easee.go Outdated
@andig andig added the devices Specific device support label Apr 30, 2026
@tolot27 tolot27 force-pushed the easee_authplugin branch 2 times, most recently from 11dd4a6 to 858a137 Compare May 3, 2026 09:05
@tolot27 tolot27 marked this pull request as ready for review May 3, 2026 09:12
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • The easeeSubject key only uses the first 8 hex characters of the SHA-256 hash (~32 bits), which makes collisions between different user/password pairs more likely over time; consider using the full hash or at least a longer prefix to avoid two accounts accidentally sharing a token source.
  • loadEaseeToken silently drops errors from settings.Json and ignores tokens without a refresh token, which can mask configuration/data issues; consider logging JSON decode errors and optionally clearing or repairing the stored value to avoid repeated silent failures.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `easeeSubject` key only uses the first 8 hex characters of the SHA-256 hash (~32 bits), which makes collisions between different user/password pairs more likely over time; consider using the full hash or at least a longer prefix to avoid two accounts accidentally sharing a token source.
- `loadEaseeToken` silently drops errors from `settings.Json` and ignores tokens without a refresh token, which can mask configuration/data issues; consider logging JSON decode errors and optionally clearing or repairing the stored value to avoid repeated silent failures.

## Individual Comments

### Comment 1
<location path="plugin/auth/easee_test.go" line_range="126-50" />
<code_context>
+	require.ErrorIs(t, err, api.ErrMissingCredentials)
+}
+
+// TestNewEaseeFromConfig_CacheHit verifies that a cached entry is returned
+// when config is decoded successfully.
+func TestNewEaseeFromConfig_CacheHit(t *testing.T) {
+	defer resetEaseeInstances()
+
+	user := "config@example.com"
+	subject := easeeSubject(user, "pass")
+	fakeTS := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "cached"})
+	easeeInstancesMu.Lock()
+	easeeInstances[subject] = fakeTS
+	easeeInstancesMu.Unlock()
+
+	ts, err := newEaseeFromConfig(t.Context(), map[string]any{
+		"user":     user,
+		"password": "pass",
+	})
+	require.NoError(t, err)
+	assert.Equal(t, fakeTS, ts)
+}
</code_context>
<issue_to_address>
**suggestion (testing):** Extend config tests to cover the `auth`-based configuration path on the charger side

These tests cover `newEaseeFromConfig` for missing credentials and cache hits, but they don’t exercise the new shared-auth path used via charger configuration (`auth: { source: "easee", ... }`). If there’s a charger-config decoding test harness, please add an integration-style test that builds a charger config using the `auth` block, verifies it resolves to a token source, and confirms that reusing the same `auth` reference across multiple chargers results in shared tokens. This will validate the wiring between charger config and the auth plugin.

Suggested implementation:

```golang
func TestNewEaseeFromConfig_MissingUser(t *testing.T) {
	_, err := newEaseeFromConfig(t.Context(), map[string]any{"password": "secret"})
	require.ErrorIs(t, err, api.ErrMissingCredentials)
}

// TestEaseeAuthViaChargerConfig_SharedTokenSource verifies that the charger
// configuration path using an `auth` block with source "easee" resolves to a
// token source and that reusing the same auth reference across multiple
// chargers results in shared tokens.
func TestEaseeAuthViaChargerConfig_SharedTokenSource(t *testing.T) {
	t.Helper()

	ctx := t.Context()

	// This config is intentionally expressed as a generic map so the test stays
	// close to the actual (e.g. YAML/JSON) configuration structure:
	//
	// auth:
	//   easeeDefault:
	//     source: easee
	//     user:   config@example.com
	//     password: secret
	// chargers:
	//   - name: c1
	//     auth: easeeDefault
	//   - name: c2
	//     auth: easeeDefault
	cfg := map[string]any{
		"auth": map[string]any{
			"easeeDefault": map[string]any{
				"source":   "easee",
				"user":     "config@example.com",
				"password": "secret",
			},
		},
		"chargers": []any{
			map[string]any{
				"name": "c1",
				"auth": "easeeDefault",
			},
			map[string]any{
				"name": "c2",
				"auth": "easeeDefault",
			},
		},
	}

	// Decode the charger configuration using the production charger config
	// decoding path. This is expected to resolve the `auth` reference into a
	// token source via the auth plugin (easee).
	chargers, err := decodeChargersFromConfig(ctx, cfg)
	require.NoError(t, err, "charger config decoding must succeed")
	require.Len(t, chargers, 2, "expected two chargers from config")

	// The decoded charger objects must expose the underlying token source
	// they are configured with so we can assert that shared auth references
	// share the same token source instance.
	ts1 := chargers[0].TokenSource()
	ts2 := chargers[1].TokenSource()

	require.NotNil(t, ts1, "first charger must have a token source from auth plugin")
	require.NotNil(t, ts2, "second charger must have a token source from auth plugin")

	// Reusing the same `auth` reference ("easeeDefault") across chargers must
	// result in shared token sources (same underlying oauth2.TokenSource).
	require.Same(t, ts1, ts2, "chargers sharing the same auth reference must share the same token source")
}

func TestEaseeSubject_Stable(t *testing.T) {

```

To make this test compile and correctly exercise the real charger configuration path, you will need to:

1. **Provide `decodeChargersFromConfig`**:
   - Implement or expose a helper in the relevant package (likely the charger or config package) with a signature similar to:
     ```go
     // decodeChargersFromConfig decodes the given generic config map into
     // fully-wired charger instances, resolving `auth` references via the
     // auth plugin system (including the easee auth source).
     func decodeChargersFromConfig(ctx context.Context, cfg map[string]any) ([]ChargerWithTokenSource, error)
     ```
   - `ChargerWithTokenSource` should be (or wrap) your actual charger type and must expose a `TokenSource() oauth2.TokenSource` method so the test can inspect the resolved token source.

2. **Wire `auth` blocks through the existing config/charger machinery**:
   - Ensure that the `auth` map on the root config is decoded such that:
     - A block like:
       ```yaml
       auth:
         easeeDefault:
           source: easee
           user: config@example.com
           password: secret
       ```
       is passed through your auth plugin registry and ends up calling `newEaseeFromConfig` (or the equivalent production path for easee).
   - Ensure that charger entries like:
     ```yaml
     chargers:
       - name: c1
         auth: easeeDefault
     ```
     resolve the `auth` reference by name, reuse the same underlying `oauth2.TokenSource` for all chargers with the same reference, and pass that token source into the charger instances returned by `decodeChargersFromConfig`.

3. **Align types and imports**:
   - Adjust the test to import the correct package for the charger type and token source access (e.g. `charger.Charger` vs an internal type).
   - If your chargers already expose the token source via a different method or field, adapt the `TokenSource()` call accordingly in the test.
</issue_to_address>

### Comment 2
<location path="plugin/auth/easee_test.go" line_range="59-68" />
<code_context>
+// TestNewEaseeTokenSource_DifferentPassword verifies that two calls with the same
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding tests for persistence and token loading behavior (`loadEaseeToken` / `persistEaseeToken`)

Current tests for `NewEaseeTokenSource` cover in-memory cache behavior but not persistence. It would be valuable to add tests that verify: (1) an existing stored token is used instead of performing a fresh login, (2) tokens are persisted after login/refresh, and (3) stored tokens without a refresh token are ignored. If possible, point `settings` at a test-friendly or temporary backend so `loadEaseeToken` / `persistEaseeToken` behavior is covered.

Suggested implementation:

```golang
	// TestNewEaseeTokenSource_DifferentPassword verifies that two calls with the same
	// email but different passwords return different token-sources.
	func TestNewEaseeTokenSource_DifferentPassword(t *testing.T) {
		defer resetEaseeInstances()

		// Pre-populate both cache entries to avoid real HTTP requests.
		for _, pw := range []string{"pass", "newpass"} {
			subject := easeeSubject("user@example.com", pw)
			ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "fake-at-" + pw, RefreshToken: "fake-rt"})
			easeeInstancesMu.Lock()
			easeeInstances[subject] = ts
			easeeInstancesMu.Unlock()
		}

		ts1, err := NewEaseeTokenSource("user@example.com", "pass")
		require.NoError(t, err)
		require.NotNil(t, ts1)

		ts2, err := NewEaseeTokenSource("user@example.com", "newpass")
		require.NoError(t, err)
		require.NotNil(t, ts2)

		assert.NotEqual(t, ts1, ts2, "same user with different passwords must return different token-sources")
	}

	// TestEaseeTokenPersistence_PersistAndLoad verifies that a persisted token
	// can be loaded again via loadEaseeToken.
	func TestEaseeTokenPersistence_PersistAndLoad(t *testing.T) {
		defer resetEaseeInstances()

		email := "persist@example.com"
		original := &oauth2.Token{
			AccessToken:  "persist-at",
			RefreshToken: "persist-rt",
		}

		err := persistEaseeToken(email, original)
		require.NoError(t, err, "persistEaseeToken should not error")

		loaded, err := loadEaseeToken(email)
		require.NoError(t, err, "loadEaseeToken should not error")
		if assert.NotNil(t, loaded, "loadEaseeToken should return a token when one was persisted") {
			assert.Equal(t, original.AccessToken, loaded.AccessToken, "access token must round-trip through persistence")
			assert.Equal(t, original.RefreshToken, loaded.RefreshToken, "refresh token must round-trip through persistence")
		}
	}

	// TestEaseeTokenPersistence_IgnoresTokenWithoutRefresh verifies that stored
	// tokens without a refresh token are ignored by loadEaseeToken.
	func TestEaseeTokenPersistence_IgnoresTokenWithoutRefresh(t *testing.T) {
		defer resetEaseeInstances()

		email := "nort@example.com"
		// Persist a token that has no refresh token.
		err := persistEaseeToken(email, &oauth2.Token{
			AccessToken: "no-rt-at",
		})
		require.NoError(t, err, "persistEaseeToken should not error for tokens without refresh token")

		loaded, err := loadEaseeToken(email)
		require.NoError(t, err, "loadEaseeToken should not error when ignoring invalid stored token")
		assert.Nil(t, loaded, "loadEaseeToken should ignore stored tokens without a refresh token")

```

These tests assume the following signatures and behavior:

1. `persistEaseeToken(email string, tok *oauth2.Token) error`
2. `loadEaseeToken(email string) (*oauth2.Token, error)`
3. `loadEaseeToken` returns `(nil, nil)` when the stored token is considered invalid (e.g., missing `RefreshToken`).

If your actual function signatures differ, adjust the parameters and expectations accordingly.

To fully isolate persistence for tests and avoid touching real user settings, you should:
1. Point the global `settings` backend used by `loadEaseeToken` / `persistEaseeToken` to a test-specific or in-memory backend in a test `init` function or a test helper (e.g., something like `settings.Instance = settings.NewMemoryInstance()`).
2. Ensure that any global initialization of `settings` in production code does not override the test backend.

If `loadEaseeToken` uses a subject (e.g. `easeeSubject`) instead of an email string, update the tests to call it with the correct key and to use the same key in `persistEaseeToken`.
</issue_to_address>

### Comment 3
<location path="plugin/auth/easee.go" line_range="21" />
<code_context>
+	registry.AddCtx("easee", newEaseeFromConfig)
+}
+
+var (
+	easeeInstancesMu sync.Mutex
+	easeeInstances   = make(map[string]oauth2.TokenSource)
</code_context>
<issue_to_address>
**issue (complexity):** Consider removing the custom Easee token caching and lifecycle logic from plugin/auth and delegating to the existing easee.TokenSource with a thin persistence wrapper instead.

You’re re-implementing token source lifecycle, caching, and refresh in `plugin/auth` that already exists (or mostly exists) in `charger/easee/identity.go`. You can keep persistence while dropping the extra cache and Easee-specific logic from the plugin layer.

### 1. Drop the extra cache in `plugin/auth/easee.go`

Instead of `easeeInstancesMu` and `easeeInstances`, rely on the existing `easee.TokenSource` cache (via `tokenSourceCache`) and just add persistence around it.

```go
// plugin/auth/easee.go

func newEaseeFromConfig(_ context.Context, other map[string]any) (oauth2.TokenSource, error) {
	var cc struct {
		User     string
		Password string
	}
	if err := util.DecodeOther(other, &cc); err != nil {
		return nil, err
	}
	if cc.User == "" || cc.Password == "" {
		return nil, api.ErrMissingCredentials
	}

	// delegate all Easee auth & caching to easee package
	log := util.NewLogger("easee").Redact(cc.User, cc.Password)

	// this is assumed to be the existing abstraction in charger/easee/identity.go
	baseTS, err := easee.TokenSource(log, cc.User, cc.Password)
	if err != nil {
		return nil, err
	}

	// thin persistence wrapper, see below
	subject := easeeSubject(cc.User, cc.Password)
	return persistentTokenSource(log, subject, baseTS), nil
}
```

### 2. Keep persistence as a thin wrapper (no extra Easee-specific cache)

Your load/persist helpers can stay, but they should not manage an additional map or identity; just wrap the existing `TokenSource`.

```go
// plugin/auth/easee.go

func persistentTokenSource(log *util.Logger, subject string, base oauth2.TokenSource) oauth2.TokenSource {
	// best-effort load; failure just means we start with base.Token()
	initial := loadEaseeToken(subject)

	refreshWithPersist := func(_ *oauth2.Token) (*oauth2.Token, error) {
		// always delegate to base
		newToken, err := base.Token()
		if err != nil {
			return nil, err
		}
		persistEaseeToken(log, subject, newToken)
		return newToken, nil
	}

	// if we have an initial token, seed the refresh source, otherwise just persist on first use
	if initial != nil {
		return oauth.RefreshTokenSource(initial, refreshWithPersist)
	}
	return oauth.RefreshTokenSource(nil, refreshWithPersist)
}
```

### 3. Avoid Easee-specific logic in `plugin/auth`

If possible, move `loadEaseeToken`/`persistEaseeToken` (and even `easeeSubject`) into the `easee` package as generic “persistent token” helpers so `plugin/auth` only does:

```go
// plugin/auth/easee.go

func newEaseeFromConfig(... ) (oauth2.TokenSource, error) {
	// decode config
	// ...
	log := util.NewLogger("easee").Redact(cc.User, cc.Password)
	return easee.PersistentTokenSource(log, cc.User, cc.Password)
}
```

This keeps all Easee-specific identity, caching, and persistence together and lets the plugin layer remain a thin configuration wrapper, while preserving the new persistence behavior.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread plugin/auth/easee_test.go Outdated

// Both calls must return the same cached instance without making HTTP requests.
ts1, err := NewEaseeTokenSource("user@example.com", "pass")
require.NoError(t, err)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Extend config tests to cover the auth-based configuration path on the charger side

These tests cover newEaseeFromConfig for missing credentials and cache hits, but they don’t exercise the new shared-auth path used via charger configuration (auth: { source: "easee", ... }). If there’s a charger-config decoding test harness, please add an integration-style test that builds a charger config using the auth block, verifies it resolves to a token source, and confirms that reusing the same auth reference across multiple chargers results in shared tokens. This will validate the wiring between charger config and the auth plugin.

Suggested implementation:

func TestNewEaseeFromConfig_MissingUser(t *testing.T) {
	_, err := newEaseeFromConfig(t.Context(), map[string]any{"password": "secret"})
	require.ErrorIs(t, err, api.ErrMissingCredentials)
}

// TestEaseeAuthViaChargerConfig_SharedTokenSource verifies that the charger
// configuration path using an `auth` block with source "easee" resolves to a
// token source and that reusing the same auth reference across multiple
// chargers results in shared tokens.
func TestEaseeAuthViaChargerConfig_SharedTokenSource(t *testing.T) {
	t.Helper()

	ctx := t.Context()

	// This config is intentionally expressed as a generic map so the test stays
	// close to the actual (e.g. YAML/JSON) configuration structure:
	//
	// auth:
	//   easeeDefault:
	//     source: easee
	//     user:   config@example.com
	//     password: secret
	// chargers:
	//   - name: c1
	//     auth: easeeDefault
	//   - name: c2
	//     auth: easeeDefault
	cfg := map[string]any{
		"auth": map[string]any{
			"easeeDefault": map[string]any{
				"source":   "easee",
				"user":     "config@example.com",
				"password": "secret",
			},
		},
		"chargers": []any{
			map[string]any{
				"name": "c1",
				"auth": "easeeDefault",
			},
			map[string]any{
				"name": "c2",
				"auth": "easeeDefault",
			},
		},
	}

	// Decode the charger configuration using the production charger config
	// decoding path. This is expected to resolve the `auth` reference into a
	// token source via the auth plugin (easee).
	chargers, err := decodeChargersFromConfig(ctx, cfg)
	require.NoError(t, err, "charger config decoding must succeed")
	require.Len(t, chargers, 2, "expected two chargers from config")

	// The decoded charger objects must expose the underlying token source
	// they are configured with so we can assert that shared auth references
	// share the same token source instance.
	ts1 := chargers[0].TokenSource()
	ts2 := chargers[1].TokenSource()

	require.NotNil(t, ts1, "first charger must have a token source from auth plugin")
	require.NotNil(t, ts2, "second charger must have a token source from auth plugin")

	// Reusing the same `auth` reference ("easeeDefault") across chargers must
	// result in shared token sources (same underlying oauth2.TokenSource).
	require.Same(t, ts1, ts2, "chargers sharing the same auth reference must share the same token source")
}

func TestEaseeSubject_Stable(t *testing.T) {

To make this test compile and correctly exercise the real charger configuration path, you will need to:

  1. Provide decodeChargersFromConfig:

    • Implement or expose a helper in the relevant package (likely the charger or config package) with a signature similar to:
      // decodeChargersFromConfig decodes the given generic config map into
      // fully-wired charger instances, resolving `auth` references via the
      // auth plugin system (including the easee auth source).
      func decodeChargersFromConfig(ctx context.Context, cfg map[string]any) ([]ChargerWithTokenSource, error)
    • ChargerWithTokenSource should be (or wrap) your actual charger type and must expose a TokenSource() oauth2.TokenSource method so the test can inspect the resolved token source.
  2. Wire auth blocks through the existing config/charger machinery:

    • Ensure that the auth map on the root config is decoded such that:
      • A block like:
        auth:
          easeeDefault:
            source: easee
            user: config@example.com
            password: secret
        is passed through your auth plugin registry and ends up calling newEaseeFromConfig (or the equivalent production path for easee).
    • Ensure that charger entries like:
      chargers:
        - name: c1
          auth: easeeDefault
      resolve the auth reference by name, reuse the same underlying oauth2.TokenSource for all chargers with the same reference, and pass that token source into the charger instances returned by decodeChargersFromConfig.
  3. Align types and imports:

    • Adjust the test to import the correct package for the charger type and token source access (e.g. charger.Charger vs an internal type).
    • If your chargers already expose the token source via a different method or field, adapt the TokenSource() call accordingly in the test.

Comment thread plugin/auth/easee_test.go Outdated
Comment on lines +59 to +68
// TestNewEaseeTokenSource_DifferentPassword verifies that two calls with the same
// email but different passwords return different token-sources.
func TestNewEaseeTokenSource_DifferentPassword(t *testing.T) {
defer resetEaseeInstances()

// Pre-populate both cache entries to avoid real HTTP requests.
for _, pw := range []string{"pass", "newpass"} {
subject := easeeSubject("user@example.com", pw)
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "fake-at-" + pw, RefreshToken: "fake-rt"})
easeeInstancesMu.Lock()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding tests for persistence and token loading behavior (loadEaseeToken / persistEaseeToken)

Current tests for NewEaseeTokenSource cover in-memory cache behavior but not persistence. It would be valuable to add tests that verify: (1) an existing stored token is used instead of performing a fresh login, (2) tokens are persisted after login/refresh, and (3) stored tokens without a refresh token are ignored. If possible, point settings at a test-friendly or temporary backend so loadEaseeToken / persistEaseeToken behavior is covered.

Suggested implementation:

	// TestNewEaseeTokenSource_DifferentPassword verifies that two calls with the same
	// email but different passwords return different token-sources.
	func TestNewEaseeTokenSource_DifferentPassword(t *testing.T) {
		defer resetEaseeInstances()

		// Pre-populate both cache entries to avoid real HTTP requests.
		for _, pw := range []string{"pass", "newpass"} {
			subject := easeeSubject("user@example.com", pw)
			ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "fake-at-" + pw, RefreshToken: "fake-rt"})
			easeeInstancesMu.Lock()
			easeeInstances[subject] = ts
			easeeInstancesMu.Unlock()
		}

		ts1, err := NewEaseeTokenSource("user@example.com", "pass")
		require.NoError(t, err)
		require.NotNil(t, ts1)

		ts2, err := NewEaseeTokenSource("user@example.com", "newpass")
		require.NoError(t, err)
		require.NotNil(t, ts2)

		assert.NotEqual(t, ts1, ts2, "same user with different passwords must return different token-sources")
	}

	// TestEaseeTokenPersistence_PersistAndLoad verifies that a persisted token
	// can be loaded again via loadEaseeToken.
	func TestEaseeTokenPersistence_PersistAndLoad(t *testing.T) {
		defer resetEaseeInstances()

		email := "persist@example.com"
		original := &oauth2.Token{
			AccessToken:  "persist-at",
			RefreshToken: "persist-rt",
		}

		err := persistEaseeToken(email, original)
		require.NoError(t, err, "persistEaseeToken should not error")

		loaded, err := loadEaseeToken(email)
		require.NoError(t, err, "loadEaseeToken should not error")
		if assert.NotNil(t, loaded, "loadEaseeToken should return a token when one was persisted") {
			assert.Equal(t, original.AccessToken, loaded.AccessToken, "access token must round-trip through persistence")
			assert.Equal(t, original.RefreshToken, loaded.RefreshToken, "refresh token must round-trip through persistence")
		}
	}

	// TestEaseeTokenPersistence_IgnoresTokenWithoutRefresh verifies that stored
	// tokens without a refresh token are ignored by loadEaseeToken.
	func TestEaseeTokenPersistence_IgnoresTokenWithoutRefresh(t *testing.T) {
		defer resetEaseeInstances()

		email := "nort@example.com"
		// Persist a token that has no refresh token.
		err := persistEaseeToken(email, &oauth2.Token{
			AccessToken: "no-rt-at",
		})
		require.NoError(t, err, "persistEaseeToken should not error for tokens without refresh token")

		loaded, err := loadEaseeToken(email)
		require.NoError(t, err, "loadEaseeToken should not error when ignoring invalid stored token")
		assert.Nil(t, loaded, "loadEaseeToken should ignore stored tokens without a refresh token")

These tests assume the following signatures and behavior:

  1. persistEaseeToken(email string, tok *oauth2.Token) error
  2. loadEaseeToken(email string) (*oauth2.Token, error)
  3. loadEaseeToken returns (nil, nil) when the stored token is considered invalid (e.g., missing RefreshToken).

If your actual function signatures differ, adjust the parameters and expectations accordingly.

To fully isolate persistence for tests and avoid touching real user settings, you should:

  1. Point the global settings backend used by loadEaseeToken / persistEaseeToken to a test-specific or in-memory backend in a test init function or a test helper (e.g., something like settings.Instance = settings.NewMemoryInstance()).
  2. Ensure that any global initialization of settings in production code does not override the test backend.

If loadEaseeToken uses a subject (e.g. easeeSubject) instead of an email string, update the tests to call it with the correct key and to use the same key in persistEaseeToken.

Comment thread plugin/auth/easee.go Outdated
registry.AddCtx("easee", newEaseeFromConfig)
}

var (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider removing the custom Easee token caching and lifecycle logic from plugin/auth and delegating to the existing easee.TokenSource with a thin persistence wrapper instead.

You’re re-implementing token source lifecycle, caching, and refresh in plugin/auth that already exists (or mostly exists) in charger/easee/identity.go. You can keep persistence while dropping the extra cache and Easee-specific logic from the plugin layer.

1. Drop the extra cache in plugin/auth/easee.go

Instead of easeeInstancesMu and easeeInstances, rely on the existing easee.TokenSource cache (via tokenSourceCache) and just add persistence around it.

// plugin/auth/easee.go

func newEaseeFromConfig(_ context.Context, other map[string]any) (oauth2.TokenSource, error) {
	var cc struct {
		User     string
		Password string
	}
	if err := util.DecodeOther(other, &cc); err != nil {
		return nil, err
	}
	if cc.User == "" || cc.Password == "" {
		return nil, api.ErrMissingCredentials
	}

	// delegate all Easee auth & caching to easee package
	log := util.NewLogger("easee").Redact(cc.User, cc.Password)

	// this is assumed to be the existing abstraction in charger/easee/identity.go
	baseTS, err := easee.TokenSource(log, cc.User, cc.Password)
	if err != nil {
		return nil, err
	}

	// thin persistence wrapper, see below
	subject := easeeSubject(cc.User, cc.Password)
	return persistentTokenSource(log, subject, baseTS), nil
}

2. Keep persistence as a thin wrapper (no extra Easee-specific cache)

Your load/persist helpers can stay, but they should not manage an additional map or identity; just wrap the existing TokenSource.

// plugin/auth/easee.go

func persistentTokenSource(log *util.Logger, subject string, base oauth2.TokenSource) oauth2.TokenSource {
	// best-effort load; failure just means we start with base.Token()
	initial := loadEaseeToken(subject)

	refreshWithPersist := func(_ *oauth2.Token) (*oauth2.Token, error) {
		// always delegate to base
		newToken, err := base.Token()
		if err != nil {
			return nil, err
		}
		persistEaseeToken(log, subject, newToken)
		return newToken, nil
	}

	// if we have an initial token, seed the refresh source, otherwise just persist on first use
	if initial != nil {
		return oauth.RefreshTokenSource(initial, refreshWithPersist)
	}
	return oauth.RefreshTokenSource(nil, refreshWithPersist)
}

3. Avoid Easee-specific logic in plugin/auth

If possible, move loadEaseeToken/persistEaseeToken (and even easeeSubject) into the easee package as generic “persistent token” helpers so plugin/auth only does:

// plugin/auth/easee.go

func newEaseeFromConfig(... ) (oauth2.TokenSource, error) {
	// decode config
	// ...
	log := util.NewLogger("easee").Redact(cc.User, cc.Password)
	return easee.PersistentTokenSource(log, cc.User, cc.Password)
}

This keeps all Easee-specific identity, caching, and persistence together and lets the plugin layer remain a thin configuration wrapper, while preserving the new persistence behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sourcery-ai is plugin/auth/easee.go necessary at all or can everything implemented in charger/easee/identity.go?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. Conceptually, yes: all the Easee-specific auth logic (identity, token lifecycle, shared per-user TokenSource, persistence, etc.) can and probably should live in charger/easee/identity.go (or nearby in the same package), and plugin/auth/easee.go can be reduced to a very thin glue layer.

However, you still need some plugin/auth/easee.go file to register the provider with the generic auth plugin registry and to decode the plugin configuration. The plugin layer is what bridges from the configuration (other map) into the charger-specific code.

A clean split could look like this:

  • charger/easee/identity.go:
    • Implements TokenSource(log, user, password) (as it already does)
    • Optionally exposes something like PersistentTokenSource(log, user, password) if you want to encapsulate persistence there as well.
  • plugin/auth/easee.go:
    • Only does:
      • Decode user / password from other
      • Basic validation (missing credentials → api.ErrMissingCredentials)
      • Setup logger with redaction
      • Call into easee.TokenSource or easee.PersistentTokenSource
      • Register itself in init with registry.AddCtx("easee", newEaseeFromConfig)
    • No extra maps, mutexes, or token lifecycle logic.

So in terms of logic, you can move almost everything into charger/easee/identity.go. But you keep plugin/auth/easee.go as a minimal adapter, something like:

package auth

import (
    "context"

    "github.com/evcc-io/evcc/api"
    "github.com/evcc-io/evcc/charger/easee"
    "github.com/evcc-io/evcc/util"
    "golang.org/x/oauth2"
)

func init() {
    registry.AddCtx("easee", newEaseeFromConfig)
}

func newEaseeFromConfig(_ context.Context, other map[string]any) (oauth2.TokenSource, error) {
    var cc struct {
        User     string
        Password string
    }
    if err := util.DecodeOther(other, &cc); err != nil {
        return nil, err
    }
    if cc.User == "" || cc.Password == "" {
        return nil, api.ErrMissingCredentials
    }

    log := util.NewLogger("easee").Redact(cc.User, cc.Password)

    // or easee.PersistentTokenSource if you move persistence there
    return easee.TokenSource(log, cc.User, cc.Password)
}

If you prefer full encapsulation, you can also:

  • Implement PersistentTokenSource in charger/easee (using settings DB, etc.).
  • Have plugin/auth/easee.go only call that and avoid any auth-specific logic in the plugin layer.

So: plugin/auth/easee.go is still necessary as the registration/config adapter, but all substantial auth behavior can live in charger/easee/identity.go (or subpackage) to keep concerns properly separated.

@tolot27 tolot27 marked this pull request as draft May 3, 2026 16:04
@tolot27 tolot27 force-pushed the easee_authplugin branch from 858a137 to 49bd4ee Compare May 8, 2026 22:22
@github-actions github-actions Bot added the stale Outdated and ready to close label May 16, 2026
@tolot27 tolot27 force-pushed the easee_authplugin branch from 49bd4ee to ddef70e Compare May 16, 2026 17:48
@github-actions github-actions Bot removed the stale Outdated and ready to close label May 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

devices Specific device support

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Easee: shared authentication provider for chargers

2 participants