Provider authorization: handle updates async (#24097)

This commit is contained in:
andig 2025-10-04 15:51:27 +02:00 • committed by GitHub
parent 1e79b43ab6
commit 9f26560075
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 88 additions and 132 deletions

View file

@ -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
}

View file

@ -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))
}

View file

@ -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) {

View file

@ -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
}

View file

@ -2,7 +2,7 @@ template: vaillant
products:
- brand: Vaillant
description:
generic: SensoNET (SG Ready)
generic: SensoNET (API)
group: heating
requirements:
# evcc: ["sponsorship"]

View file

@ -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: >
{ }

View file

@ -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