diff --git a/util/homeassistant/instance.go b/util/homeassistant/instance.go index c28c0ad87..519c7c6ba 100644 --- a/util/homeassistant/instance.go +++ b/util/homeassistant/instance.go @@ -4,9 +4,43 @@ import ( "fmt" "sync" + "github.com/evcc-io/evcc/plugin/auth" + "github.com/evcc-io/evcc/util" "golang.org/x/oauth2" ) +func init() { + auth.Register("homeassistant", NewHomeAssistantFromConfig) +} + +// NewHomeAssistantFromConfig creates a Home Assistant token source from configuration +func NewHomeAssistantFromConfig(other map[string]any) (oauth2.TokenSource, error) { + var cc struct { + URI string + Home string // TODO remove deprecated + Insecure bool + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + uri := cc.URI + + if uri == "" && cc.Home != "" { + uri = instanceUriByName(cc.Home) + if uri == "" { + return nil, fmt.Errorf("unknown instance: %s", cc.Home) + } + } + + if ts, ok := supervisorTokenSource(uri); ok { + return ts, nil + } + + return NewOAuth(uri, cc.Insecure) +} + type proxyInstance struct { mu sync.Mutex home, uri string @@ -43,12 +77,15 @@ func (inst *proxyInstance) Token() (*oauth2.Token, error) { defer inst.mu.Unlock() if inst.TokenSource == nil { - ts, err := NewHomeAssistant(uri, inst.insecure) - if err != nil { - return nil, err + if ts, ok := supervisorTokenSource(uri); ok { + inst.TokenSource = ts + } else { + ts, err := NewOAuth(uri, inst.insecure) + if err != nil { + return nil, err + } + inst.TokenSource = ts } - - inst.TokenSource = ts } return inst.TokenSource.Token() diff --git a/util/homeassistant/oauth2.go b/util/homeassistant/oauth2.go index f021eb493..c3bbd0a95 100644 --- a/util/homeassistant/oauth2.go +++ b/util/homeassistant/oauth2.go @@ -2,7 +2,6 @@ package homeassistant import ( "context" - "fmt" "net" "net/http" "net/url" @@ -17,34 +16,8 @@ import ( // https://developers.home-assistant.io/docs/auth_api -func init() { - auth.Register("homeassistant", NewHomeAssistantFromConfig) -} - -func NewHomeAssistantFromConfig(other map[string]any) (oauth2.TokenSource, error) { - var cc struct { - URI string - Home string // TODO remove deprecated - Insecure bool - } - - if err := util.DecodeOther(other, &cc); err != nil { - return nil, err - } - - uri := cc.URI - - if uri == "" && cc.Home != "" { - uri = instanceUriByName(cc.Home) - if uri == "" { - return nil, fmt.Errorf("unknown instance: %s", cc.Home) - } - } - - return NewHomeAssistant(uri, cc.Insecure) -} - -func NewHomeAssistant(uri string, insecure bool) (oauth2.TokenSource, error) { +// NewOAuth creates a Home Assistant OAuth token source +func NewOAuth(uri string, insecure bool) (oauth2.TokenSource, error) { uri = strings.TrimRight(uri, "/") // normalize extUrl := network.Config().ExternalURL() diff --git a/util/homeassistant/supervisor.go b/util/homeassistant/supervisor.go new file mode 100644 index 000000000..a441e9706 --- /dev/null +++ b/util/homeassistant/supervisor.go @@ -0,0 +1,34 @@ +package homeassistant + +import ( + "os" + "strings" + + "golang.org/x/oauth2" +) + +const ( + // SupervisorURI is the Home Assistant Core API endpoint when running as a Home Assistant add-on + SupervisorURI = "http://supervisor/core" + // SupervisorToken is the environment variable name containing the bearer token + SupervisorToken = "SUPERVISOR_TOKEN" + // SupervisorInstance is the discovered instance name for the Supervisor integration + SupervisorInstance = "HomeAssistant Host" +) + +func init() { + if hasSupervisorToken() { + addInstance(SupervisorInstance, SupervisorURI) + } +} + +func hasSupervisorToken() bool { + return os.Getenv(SupervisorToken) != "" +} + +func supervisorTokenSource(uri string) (oauth2.TokenSource, bool) { + if token := os.Getenv(SupervisorToken); token != "" && strings.TrimRight(uri, "/") == SupervisorURI { + return oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}), true + } + return nil, false +} diff --git a/util/homeassistant/supervisor_test.go b/util/homeassistant/supervisor_test.go new file mode 100644 index 000000000..ffe011396 --- /dev/null +++ b/util/homeassistant/supervisor_test.go @@ -0,0 +1,92 @@ +package homeassistant + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSupervisorToken(t *testing.T) { + t.Setenv(SupervisorToken, "test_supervisor_token") + + ts, ok := supervisorTokenSource(SupervisorURI) + require.True(t, ok) + + tok, err := ts.Token() + require.NoError(t, err) + assert.Equal(t, "test_supervisor_token", tok.AccessToken) + + // SupervisorURI variant with trailing slash should still match + tsSlash, ok := supervisorTokenSource(SupervisorURI + "/") + require.True(t, ok) + + tokSlash, err := tsSlash.Token() + require.NoError(t, err) + assert.Equal(t, "test_supervisor_token", tokSlash.AccessToken) + + // empty uri does not match supervisor + _, ok = supervisorTokenSource("") + assert.False(t, ok) + + // other uri does not match supervisor + _, ok = supervisorTokenSource("http://homeassistant.local:8123") + assert.False(t, ok) + + // from config with SupervisorURI + ts3, err := NewHomeAssistantFromConfig(map[string]any{"uri": SupervisorURI}) + require.NoError(t, err) + + tok3, err := ts3.Token() + require.NoError(t, err) + assert.Equal(t, "test_supervisor_token", tok3.AccessToken) + + // when SUPERVISOR_TOKEN is unset, NewHomeAssistantFromConfig should fall back to the standard OAuth token source + t.Setenv(SupervisorToken, "") + + ts4, err := NewHomeAssistantFromConfig(map[string]any{"uri": SupervisorURI}) + require.NoError(t, err) + + _, err = ts4.Token() + require.Error(t, err) + var elr *api.ErrLoginRequired + assert.ErrorAs(t, err, &elr) + // connection requires uri + _, err = NewConnection(util.NewLogger("test"), "", "", false) + assert.Error(t, err) + + conn, err := NewConnection(util.NewLogger("test"), SupervisorURI, "", false) + require.NoError(t, err) + assert.Equal(t, SupervisorURI, conn.URI()) + + // test authenticated request using connection + var authHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`[{"entity_id":"sensor.test","state":"10"}]`)) + })) + defer srv.Close() + + testConn, err := NewConnection(util.NewLogger("test"), srv.URL, "", false) + require.NoError(t, err) + // override instance token source directly or test via proxyInstance + testConn.instance.TokenSource = ts + + states, err := testConn.GetStates() + require.NoError(t, err) + assert.Len(t, states, 1) + assert.Equal(t, "Bearer test_supervisor_token", authHeader) +} + +func TestSupervisorDiscovery(t *testing.T) { + t.Setenv(SupervisorToken, "test_supervisor_token") + + addInstance(SupervisorInstance, SupervisorURI) + assert.Equal(t, SupervisorURI, instanceUriByName(SupervisorInstance)) + assert.Equal(t, SupervisorInstance, instanceNameByUri(SupervisorURI)) +}