From 9f265600756eae268683d8abe79caaef402d43b5 Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 4 Oct 2025 15:51:27 +0200 Subject: [PATCH] Provider authorization: handle updates async (#24097) --- plugin/auth/oauth.go | 52 +++++++++++---- plugin/auth/viessmann.go | 70 ++------------------- server/providerauth/handler.go | 42 +++++++------ server/providerauth/providerauth.go | 28 +++++---- templates/definition/charger/vaillant.yaml | 2 +- templates/definition/charger/viessmann.yaml | 21 +------ vehicle/volvo-connected.go | 5 -- 7 files changed, 88 insertions(+), 132 deletions(-) diff --git a/plugin/auth/oauth.go b/plugin/auth/oauth.go index 19a06ce1a..ca9c1ce73 100644 --- a/plugin/auth/oauth.go +++ b/plugin/auth/oauth.go @@ -16,6 +16,7 @@ import ( "github.com/evcc-io/evcc/server/providerauth" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/oauth" + "github.com/evcc-io/evcc/util/request" "golang.org/x/oauth2" ) @@ -27,6 +28,7 @@ type OAuth struct { subject string cv string ctx context.Context + onlineC chan<- bool deviceFlow bool tokenRetriever func(string, *oauth2.Token) error @@ -107,11 +109,16 @@ func NewOauth(ctx context.Context, name string, oc *oauth2.Config, opts ...oauth return instance, nil } - // create new instance + log := util.NewLogger("oauth-" + hash) + + if ctx.Value(oauth2.HTTPClient) == nil { + ctx = context.WithValue(ctx, oauth2.HTTPClient, request.NewClient(log)) + } + o := &OAuth{ subject: subject, oc: oc, - log: util.NewLogger("oauth"), + log: log, ctx: ctx, } @@ -142,12 +149,18 @@ func NewOauth(ctx context.Context, name string, oc *oauth2.Config, opts ...oauth o.TokenSource = oauth.RefreshTokenSource(&token, o) + // register auth redirect + onlineC, err := providerauth.Register(subject, o) + if err != nil { + return nil, err + } + o.onlineC = onlineC + + o.onlineC <- token.Valid() + // add instance addInstance(o.subject, o) - // register auth redirect - providerauth.Register(subject, o) - return o, nil } @@ -163,19 +176,20 @@ func (o *OAuth) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) { token, err := o.oc.TokenSource(o.ctx, token).Token() if err != nil { if strings.Contains(err.Error(), "invalid_grant") && settings.Exists(o.subject) { + o.onlineC <- false settings.Delete(o.subject) } return nil, err } - err = settings.SetJson(o.subject, token) + err = o.updateToken(token) return token, err } // updateToken must only be called when lock is held -func (o *OAuth) updateToken(token *oauth2.Token) { +func (o *OAuth) updateToken(token *oauth2.Token) error { var store any = token // tokenStorer allows persisting the token together with it's extra properties @@ -184,7 +198,19 @@ func (o *OAuth) updateToken(token *oauth2.Token) { } if err := settings.SetJson(o.subject, store); err != nil { + return err + } + + o.onlineC <- token.Valid() + + return nil +} + +// updateTokenSource must only be called when lock is held +func (o *OAuth) updateTokenSource(token *oauth2.Token) { + if err := o.updateToken(token); err != nil { o.log.ERROR.Printf("error saving token: %v", err) + return } o.TokenSource = oauth.RefreshTokenSource(token, o) @@ -202,7 +228,7 @@ func (o *OAuth) HandleCallback(params url.Values) error { return err } - o.updateToken(token) + o.updateTokenSource(token) return nil } @@ -215,15 +241,13 @@ func (o *OAuth) Login(state string) (string, error) { o.cv = oauth2.GenerateVerifier() if o.deviceFlow { - ctx := context.Background() - - da, err := o.oc.DeviceAuth(ctx, oauth2.S256ChallengeOption(o.cv)) + da, err := o.oc.DeviceAuth(o.ctx, oauth2.S256ChallengeOption(o.cv)) if err != nil { return "", err } go func() { - ctx, cancel := context.WithTimeout(ctx, time.Minute) + ctx, cancel := context.WithTimeout(o.ctx, 5*time.Minute) defer cancel() token, err := o.oc.DeviceAccessToken(ctx, da, oauth2.VerifierOption(o.cv)) @@ -235,7 +259,7 @@ func (o *OAuth) Login(state string) (string, error) { o.mu.Lock() defer o.mu.Unlock() - o.updateToken(token) + o.updateTokenSource(token) }() return da.VerificationURIComplete, nil @@ -261,7 +285,9 @@ func (o *OAuth) Logout() error { o.mu.Lock() defer o.mu.Unlock() + o.onlineC <- false o.TokenSource = oauth.RefreshTokenSource(nil, o) + return nil } diff --git a/plugin/auth/viessmann.go b/plugin/auth/viessmann.go index d5a359076..a900c4911 100644 --- a/plugin/auth/viessmann.go +++ b/plugin/auth/viessmann.go @@ -2,15 +2,9 @@ package auth import ( "context" - "fmt" - "net/http" - "net/http/cookiejar" - "net/url" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" - "github.com/evcc-io/evcc/util/transport" - "github.com/samber/lo" "golang.org/x/oauth2" ) @@ -20,7 +14,7 @@ const ( // ^ the value of RedirectURI doesn't matter, but it must be the same between requests ) -func OAuth2Config(clientID string) *oauth2.Config { +func oauth2Config(clientID string) *oauth2.Config { return &oauth2.Config{ ClientID: clientID, Endpoint: oauth2.Endpoint{ @@ -37,69 +31,17 @@ func init() { registry.AddCtx("viessmann", NewViessmannFromConfig) } -type Viessmann struct { - client *http.Client -} - func NewViessmannFromConfig(ctx context.Context, other map[string]any) (oauth2.TokenSource, error) { var cc struct { - ClientID string - User, Password string + ClientID string } + if err := util.DecodeOther(other, &cc); err != nil { return nil, err } - v := &Viessmann{ - client: request.NewClient(util.NewLogger("viessmann")), - } + log := util.NewLogger("viessmann").Redact(cc.ClientID) + ctx = context.WithValue(ctx, oauth2.HTTPClient, request.NewClient(log)) - oc := OAuth2Config(cc.ClientID) - - ctx = context.WithValue(ctx, oauth2.HTTPClient, v.client) - token, err := v.login(ctx, oc, cc.User, cc.Password) - if err != nil { - return nil, err - } - - return oc.TokenSource(ctx, token), nil -} - -func (v *Viessmann) login(ctx context.Context, oc *oauth2.Config, user, password string) (*oauth2.Token, error) { - cv := oauth2.GenerateVerifier() - - state := lo.RandomString(16, lo.AlphanumericCharset) - uri := oc.AuthCodeURL(state, oauth2.S256ChallengeOption(cv)) - - v.client.Jar, _ = cookiejar.New(nil) - v.client.CheckRedirect = request.DontFollow - defer func() { - v.client.Jar = nil - v.client.CheckRedirect = nil - }() - - req, _ := request.New(http.MethodGet, uri, nil, map[string]string{ - "Authorization": transport.BasicAuthHeader(user, password), - }) - - resp, err := v.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusFound { - return nil, fmt.Errorf("unexpected status %d", resp.StatusCode) - } - - loc, err := url.Parse(resp.Header.Get("Location")) - if err != nil { - return nil, err - } - code := loc.Query().Get("code") - - ctx, cancel := context.WithTimeout(ctx, request.Timeout) - defer cancel() - - return oc.Exchange(ctx, code, oauth2.VerifierOption(cv)) + return NewOauth(ctx, "Viessmann", oauth2Config(cc.ClientID)) } diff --git a/server/providerauth/handler.go b/server/providerauth/handler.go index cf6a6bf0b..fe2cf3107 100644 --- a/server/providerauth/handler.go +++ b/server/providerauth/handler.go @@ -19,44 +19,46 @@ import ( // by request state obtained from the request and delegates to the registered handler. type Handler struct { mu sync.Mutex + log *util.Logger secret []byte providers map[string]api.AuthProvider states map[string]string - log *util.Logger + updateC chan string } -func (a *Handler) Publish(paramC chan<- util.Param) { - a.mu.Lock() - defer a.mu.Unlock() +// TODO get status from update channel +func (a *Handler) run(paramC chan<- util.Param) { + for range <-a.updateC { + a.mu.Lock() - apMap := make(map[string]*AuthProvider) - - for id, provider := range a.providers { - ap := &AuthProvider{ - ID: url.QueryEscape(id), - Authenticated: provider.Authenticated(), + res := make(map[string]*AuthProvider) + for id, provider := range a.providers { + res[provider.DisplayName()] = &AuthProvider{ + ID: url.QueryEscape(id), + Authenticated: provider.Authenticated(), + } } - apMap[provider.DisplayName()] = ap + + a.mu.Unlock() + + a.log.TRACE.Printf("publishing %d auth providers", len(res)) + + // publish the updated auth providers + paramC <- util.Param{Key: keys.AuthProviders, Val: res} } - - a.log.TRACE.Printf("publishing %d auth providers", len(apMap)) - - // publish the updated auth providers - paramC <- util.Param{Key: keys.AuthProviders, Val: apMap} } -func (a *Handler) register(name string, handler api.AuthProvider) error { +func (a *Handler) register(name string, handler api.AuthProvider) (chan<- string, error) { a.mu.Lock() defer a.mu.Unlock() if a.providers[name] != nil { - return fmt.Errorf("provider already registered: %s", name) + return nil, fmt.Errorf("provider already registered: %s", name) } - a.log.DEBUG.Printf("registering provider: %s", name) a.providers[name] = handler - return nil + return a.updateC, nil } func (a *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { diff --git a/server/providerauth/providerauth.go b/server/providerauth/providerauth.go index 01fb8144a..d3239a41e 100644 --- a/server/providerauth/providerauth.go +++ b/server/providerauth/providerauth.go @@ -4,8 +4,6 @@ import ( "crypto/rand" "io" "net/http" - "sync" - "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" @@ -26,11 +24,11 @@ func init() { } instance = &Handler{ - mu: sync.Mutex{}, + log: util.NewLogger("providerauth"), secret: secret[:], providers: make(map[string]api.AuthProvider), states: make(map[string]string), - log: util.NewLogger("providerauth"), + updateC: make(chan string, 1), } } @@ -43,16 +41,24 @@ func Setup(router *mux.Router, paramC chan<- util.Param) { // logout?id=... router.Methods(http.MethodGet).Path("/logout").HandlerFunc(instance.handleLogout) - ticker := time.NewTicker(10 * time.Second) + go instance.run(paramC) +} + +// Register registers a specific AuthProvider by name +// The returned online channel is used to asynchronously update authorization status +func Register(name string, handler api.AuthProvider) (chan<- bool, error) { + updateC, err := instance.register(name, handler) + if err != nil { + return nil, err + } + + onlineC := make(chan bool) go func() { - for range ticker.C { - instance.Publish(paramC) + for range onlineC { + updateC <- name } }() -} -// Register registers a specific AuthProvider. Returns login path as string. -func Register(name string, handler api.AuthProvider) error { - return instance.register(name, handler) + return onlineC, nil } diff --git a/templates/definition/charger/vaillant.yaml b/templates/definition/charger/vaillant.yaml index e9feb4635..0d792eec4 100644 --- a/templates/definition/charger/vaillant.yaml +++ b/templates/definition/charger/vaillant.yaml @@ -2,7 +2,7 @@ template: vaillant products: - brand: Vaillant description: - generic: SensoNET (SG Ready) + generic: SensoNET (API) group: heating requirements: # evcc: ["sponsorship"] diff --git a/templates/definition/charger/viessmann.yaml b/templates/definition/charger/viessmann.yaml index 5c9b848dc..099058425 100644 --- a/templates/definition/charger/viessmann.yaml +++ b/templates/definition/charger/viessmann.yaml @@ -2,7 +2,7 @@ template: viessmann products: - brand: Viessmann description: - generic: Heatpump (SG Ready) + generic: Heatpump (API) group: heating requirements: # evcc: ["sponsorship"] @@ -14,20 +14,11 @@ requirements: One-time hot water preparation. The device automatically decides whether to use the heat pump or the auxiliary electric heater (if available). params: - name: user - required: true - help: - de: Registrieren auf [app.developer.viessmann-climatesolutions.com](https://app.developer.viessmann-climatesolutions.com) - en: Register at [app.developer.viessmann-climatesolutions.com](https://app.developer.viessmann-climatesolutions.com) + deprecated: true - name: password - required: true - help: - de: Für den konfigurierten Viessmann Account. - en: For the configured Viessmann account. + deprecated: true - name: clientid required: true - description: - de: Client ID - en: Client ID help: de: Konfigurieren in [app.developer.viessmann-climatesolutions.com](https://app.developer.viessmann-climatesolutions.com) en: Configure at [app.developer.viessmann-climatesolutions.com](https://app.developer.viessmann-climatesolutions.com) @@ -158,8 +149,6 @@ render: | cache: 2s # to prevent making two identical requests straight after each other for "getmode" auth: source: viessmann - user: {{ .user }} - password: {{ .password }} clientid: {{ .clientid }} jq: '.data.properties.active.value | if . == false then 2 elif . == true then 3 else . end' # false -> oneTimeCharge is disabled -> normal mode -> 2 @@ -180,8 +169,6 @@ render: | - content-type: application/json auth: source: viessmann - user: {{ .user }} - password: {{ .password }} clientid: {{ .clientid }} body: > { } @@ -194,8 +181,6 @@ render: | - content-type: application/json auth: source: viessmann - user: {{ .user }} - password: {{ .password }} clientid: {{ .clientid }} body: > { } diff --git a/vehicle/volvo-connected.go b/vehicle/volvo-connected.go index 08cd6ef0c..d96af176b 100644 --- a/vehicle/volvo-connected.go +++ b/vehicle/volvo-connected.go @@ -7,9 +7,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/plugin/auth" "github.com/evcc-io/evcc/util" - "github.com/evcc-io/evcc/util/request" "github.com/evcc-io/evcc/vehicle/volvo/connected" - "golang.org/x/oauth2" ) // VolvoConnected is an api.Vehicle implementation for Volvo Connected Car vehicles @@ -41,10 +39,7 @@ func NewVolvoConnectedFromConfig(ctx context.Context, other map[string]interface log := util.NewLogger("volvo-connected").Redact(cc.VIN, cc.VccApiKey) - // create oauth2 config oc := connected.Oauth2Config(cc.Credentials.ID, cc.Credentials.Secret, cc.RedirectUri) - ctx = context.WithValue(ctx, oauth2.HTTPClient, request.NewClient(log)) - ts, err := auth.NewOauth(ctx, cc.embed.GetTitle(), oc) if err != nil { return nil, err