From 4adbf141d616b7238f3baa3ecd454ce6cf34c6ac Mon Sep 17 00:00:00 2001 From: Tilo Alexander <59500358+tilalx@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:47:06 +0200 Subject: [PATCH] Provider auth: require authenticated session for OAuth login/logout (#33115) Co-authored-by: Michael Geers --- cmd/root.go | 7 ++--- cmd/setup.go | 6 ++--- server/http.go | 6 ++--- server/http_auth.go | 3 ++- server/http_auth_test.go | 12 +++++++++ server/providerauth/providerauth.go | 14 +++++----- server/providerauth/providerauth_test.go | 34 ++++++++++++++++++++++++ 7 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 server/providerauth/providerauth_test.go diff --git a/cmd/root.go b/cmd/root.go index ce309ab61..165578aa1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -481,10 +481,11 @@ func runRoot(cmd *cobra.Command, args []string) { once.Do(func() { close(stopC) }) // signal loop to end }() - // allow web access for vehicles - configureAuth(httpd.Router(), valueChan) - authObject := auth.New() + + // allow web access for vehicles + configureAuth(httpd.Router(), server.EnsureAuthHandler(authObject), valueChan) + if ok, _ := cmd.Flags().GetBool(flagDisableAuth); ok { log.WARN.Println("❗❗❗ Authentication is disabled. This is dangerous. Your data and credentials are not protected.") authObject.SetAuthMode(auth.Disabled) diff --git a/cmd/setup.go b/cmd/setup.go index 74cb577e1..950dbebb3 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -1544,7 +1544,7 @@ func configureLoadpoints(conf globalconfig.All) error { } // configureAuth handles routing for devices. For now only api.AuthProvider related routes -func configureAuth(router *mux.Router, paramC chan<- util.Param) { +func configureAuth(router *mux.Router, authMiddleware mux.MiddlewareFunc, paramC chan<- util.Param) { auth := router.PathPrefix("/providerauth").Subrouter() auth.Use(handlers.CompressHandler) auth.Use(handlers.CORS( @@ -1554,8 +1554,8 @@ func configureAuth(router *mux.Router, paramC chan<- util.Param) { // backwards-compatible revert of https://github.com/evcc-io/evcc/pull/21266 router.PathPrefix("/oauth").Handler(auth) - // wire the handler - providerauth.Setup(auth, paramC) + // wire the handler; login/logout require an authenticated session + providerauth.Setup(auth, paramC, authMiddleware) } // isExperimental returns if experimental features are enabled diff --git a/server/http.go b/server/http.go index ed41b811f..7d55c32bd 100644 --- a/server/http.go +++ b/server/http.go @@ -320,14 +320,14 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti } // API key endpoints require an authenticated session. - ensureAuth := ensureAuthHandler(auth) + ensureAuth := EnsureAuthHandler(auth) api.Methods("GET").Path("/apikey").Handler(ensureAuth(apiKeyStatusHandler(auth))) api.Methods("POST").Path("/apikey").Handler(ensureAuth(regenerateApiKeyHandler(auth))) } { // api/config api := api.PathPrefix("/config").Subrouter() - api.Use(ensureAuthHandler(auth)) + api.Use(EnsureAuthHandler(auth)) routes := map[string]route{ "auth": {"POST", "/auth", authHandler}, @@ -423,7 +423,7 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti { // api/system api := api.PathPrefix("/system").Subrouter() - api.Use(ensureAuthHandler(auth)) + api.Use(EnsureAuthHandler(auth)) routes := map[string]route{ "log": {"GET", "/log", logHandler}, diff --git a/server/http_auth.go b/server/http_auth.go index 9aaeae36b..ca9356680 100644 --- a/server/http_auth.go +++ b/server/http_auth.go @@ -186,7 +186,8 @@ func logoutHandler(w http.ResponseWriter, r *http.Request) { }) } -func ensureAuthHandler(authObject auth.Auth) mux.MiddlewareFunc { +// EnsureAuthHandler returns middleware that rejects unauthenticated requests +func EnsureAuthHandler(authObject auth.Auth) mux.MiddlewareFunc { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if next == nil { diff --git a/server/http_auth_test.go b/server/http_auth_test.go index 4a60f0c69..9fbc651a3 100644 --- a/server/http_auth_test.go +++ b/server/http_auth_test.go @@ -75,3 +75,15 @@ func TestRequireCriticalConfig(t *testing.T) { }) } } + +func TestEnsureAuthHandler(t *testing.T) { + get := func(a auth.Auth) int { + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + rec := httptest.NewRecorder() + EnsureAuthHandler(a)(next).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) + return rec.Code + } + + assert.Equal(t, http.StatusUnauthorized, get(fakeAuth{mode: auth.Enabled}), "enabled must reject without credentials") + assert.Equal(t, http.StatusOK, get(fakeAuth{mode: auth.Disabled}), "disabled must pass through") +} diff --git a/server/providerauth/providerauth.go b/server/providerauth/providerauth.go index 3330f8fee..14cc53556 100644 --- a/server/providerauth/providerauth.go +++ b/server/providerauth/providerauth.go @@ -32,14 +32,14 @@ func init() { } } -// Setup connects the redirect handler to the router and registers the callback channel -func Setup(router *mux.Router, paramC chan<- util.Param) { - // callback?code=...&state=... +// Setup connects the redirect handler to the router and registers the callback channel. +// Callback stays open: the cross-site IdP redirect carries no session cookie, the state token gates it. +func Setup(router *mux.Router, paramC chan<- util.Param, authMiddleware mux.MiddlewareFunc) { + gate := func(h http.HandlerFunc) http.Handler { return authMiddleware(h) } + router.Methods(http.MethodGet).Path("/callback").HandlerFunc(instance.handleCallback) - // login?id=... - router.Methods(http.MethodGet).Path("/login").HandlerFunc(instance.handleLogin) - // logout?id=... - router.Methods(http.MethodGet).Path("/logout").HandlerFunc(instance.handleLogout) + router.Methods(http.MethodGet).Path("/login").Handler(gate(instance.handleLogin)) + router.Methods(http.MethodGet).Path("/logout").Handler(gate(instance.handleLogout)) go instance.run(paramC) } diff --git a/server/providerauth/providerauth_test.go b/server/providerauth/providerauth_test.go new file mode 100644 index 000000000..3bfa006df --- /dev/null +++ b/server/providerauth/providerauth_test.go @@ -0,0 +1,34 @@ +package providerauth + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/evcc-io/evcc/util" + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" +) + +// stand-in auth middleware rejecting every request +func blockAll(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + }) +} + +// login/logout are gated, callback stays open (302 to error page, not middleware 401) +func TestSetupGating(t *testing.T) { + router := mux.NewRouter() + Setup(router, make(chan util.Param, 1), blockAll) + + get := func(path string) int { + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + return rec.Code + } + + assert.Equal(t, http.StatusUnauthorized, get("/login?id=x"), "login must be gated") + assert.Equal(t, http.StatusUnauthorized, get("/logout?id=x"), "logout must be gated") + assert.Equal(t, http.StatusFound, get("/callback"), "callback must stay open (gated by state token, not session)") +}