diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index c1804f7..f1e3811 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -131,8 +131,8 @@ func (app *App) SpotifyToken(username, token string) error { return app.withCredentials(session.SpotifyTokenCredentials{Username: username, Token: token}) } -func (app *App) UserPass(username, password string) error { - return app.withCredentials(session.UserPassCredentials{Username: username, Password: password}) +func (app *App) Interactive(callbackPort int) error { + return app.withCredentials(session.InteractiveCredentials{CallbackPort: callbackPort}) } type storedCredentialsFile struct { @@ -141,16 +141,7 @@ type storedCredentialsFile struct { } func (app *App) withCredentials(creds any) (err error) { - var username string - switch creds := creds.(type) { - case session.SpotifyTokenCredentials: - username = creds.Username - case session.UserPassCredentials: - username = creds.Username - default: - return fmt.Errorf("unsupported credentials for reuse") - } - + var storedUsername string var storedCredentials []byte if content, err := os.ReadFile(app.cfg.CredentialsPath); err == nil { var file storedCredentialsFile @@ -158,19 +149,16 @@ func (app *App) withCredentials(creds any) (err error) { return fmt.Errorf("failed unmarshalling stored credentials file: %w", err) } + storedUsername = file.Username + storedCredentials = file.Data log.Debugf("stored credentials found for %s", file.Username) - if file.Username == username { - storedCredentials = file.Data - } else { - log.Warnf("stored credentials found for wrong username %s != %s", file.Username, username) - } } else { log.Debugf("stored credentials not found") } return app.withAppPlayer(func() (*AppPlayer, error) { if len(storedCredentials) > 0 { - return app.newAppPlayer(session.StoredCredentials{Username: username, Data: storedCredentials}) + return app.newAppPlayer(session.StoredCredentials{Username: storedUsername, Data: storedCredentials}) } else { appPlayer, err := app.newAppPlayer(creds) if err != nil { @@ -178,6 +166,7 @@ func (app *App) withCredentials(creds any) (err error) { } // store credentials outside this context in case we get called again + storedUsername = appPlayer.sess.Username() storedCredentials = appPlayer.sess.StoredCredentials() if content, err := json.Marshal(&storedCredentialsFile{ @@ -349,11 +338,10 @@ type Config struct { AllowOrigin string `yaml:"allow_origin"` } `yaml:"server"` Credentials struct { - Type string `yaml:"type"` - UserPass struct { - Username string `yaml:"username"` - Password string `yaml:"password"` - } `yaml:"user_pass"` + Type string `yaml:"type"` + Interactive struct { + CallbackPort int `yaml:"callback_port"` + } `yaml:"interactive"` SpotifyToken struct { Username string `yaml:"username"` AccessToken string `yaml:"access_token"` @@ -458,9 +446,9 @@ func main() { if err := app.Zeroconf(); err != nil { log.WithError(err).Fatal("failed running zeroconf") } - case "user_pass": - if err := app.UserPass(cfg.Credentials.UserPass.Username, cfg.Credentials.UserPass.Password); err != nil { - log.WithError(err).Fatal("failed running with username and password") + case "interactive": + if err := app.Interactive(cfg.Credentials.Interactive.CallbackPort); err != nil { + log.WithError(err).Fatal("failed running with interactive auth") } case "spotify_token": if err := app.SpotifyToken(cfg.Credentials.SpotifyToken.Username, cfg.Credentials.SpotifyToken.AccessToken); err != nil { diff --git a/config_schema.json b/config_schema.json index eb8d411..67f3d12 100644 --- a/config_schema.json +++ b/config_schema.json @@ -112,23 +112,19 @@ "type": "string", "description": "The authentication method", "enum": [ - "user_pass", + "interactive", "spotify_token", "zeroconf" ], "default": "zeroconf" }, - "user_pass": { + "interactive": { "type": "object", - "description": "Authentication with username and password", + "description": "Authenticate with the interactive browser UI", "properties": { - "username": { - "type": "string", - "default": "" - }, - "password": { - "type": "string", - "default": "" + "callback_port": { + "type": "integer", + "default": 0 } } }, diff --git a/go.mod b/go.mod index e8e2bb8..a307f8a 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/miekg/dns v1.1.54 // indirect golang.org/x/mod v0.11.0 // indirect golang.org/x/net v0.11.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/sys v0.10.0 // indirect golang.org/x/tools v0.10.0 // indirect ) diff --git a/go.sum b/go.sum index b8daab6..093083c 100644 --- a/go.sum +++ b/go.sum @@ -87,6 +87,8 @@ golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU= golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/login5/login5.go b/login5/login5.go index a156280..38cd54b 100644 --- a/login5/login5.go +++ b/login5/login5.go @@ -146,6 +146,28 @@ func (c *Login5) Login(credentials proto.Message) error { } } +func (c *Login5) Username() string { + c.loginOkLock.RLock() + defer c.loginOkLock.RUnlock() + + if c.loginOk == nil { + panic("login5 not authenticated") + } + + return c.loginOk.Username +} + +func (c *Login5) StoredCredential() []byte { + c.loginOkLock.RLock() + defer c.loginOkLock.RUnlock() + + if c.loginOk == nil { + panic("login5 not authenticated") + } + + return c.loginOk.StoredCredential +} + func (c *Login5) AccessToken() librespot.GetLogin5TokenFunc { return func(force bool) (string, error) { c.loginOkLock.RLock() diff --git a/session/oauth2.go b/session/oauth2.go new file mode 100644 index 0000000..b67d5ea --- /dev/null +++ b/session/oauth2.go @@ -0,0 +1,39 @@ +package session + +import ( + "context" + "fmt" + log "github.com/sirupsen/logrus" + "net" + "net/http" +) + +func NewOAuth2Server(ctx context.Context, callbackPort int) (int, chan string, error) { + lis, err := net.Listen("tcp", fmt.Sprintf(":%d", callbackPort)) + if err != nil { + return 0, nil, fmt.Errorf("failed to listen: %w", err) + } + + errCh := make(chan error, 1) + resCh := make(chan string, 1) + go func() { + errCh <- http.Serve(lis, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + resCh <- r.URL.Query().Get("code") + _, _ = rw.Write([]byte("Go back to go-librespot!")) + })) + }() + + go func() { + select { + case <-ctx.Done(): + _ = lis.Close() + case err := <-errCh: + if err != nil { + log.WithError(err).Errorf("failed service oauth2 server") + resCh <- "" + } + } + }() + + return lis.Addr().(*net.TCPAddr).Port, resCh, nil +} diff --git a/session/options.go b/session/options.go index 37d775d..b4be41a 100644 --- a/session/options.go +++ b/session/options.go @@ -19,9 +19,8 @@ type Options struct { Resolver *apresolve.ApResolver } -type UserPassCredentials struct { - Username string - Password string +type InteractiveCredentials struct { + CallbackPort int } type SpotifyTokenCredentials struct { diff --git a/session/session.go b/session/session.go index 946fc10..ab830ee 100644 --- a/session/session.go +++ b/session/session.go @@ -1,8 +1,11 @@ package session import ( + "context" "encoding/hex" "fmt" + log "github.com/sirupsen/logrus" + librespot "go-librespot" "go-librespot/ap" "go-librespot/apresolve" "go-librespot/audio" @@ -11,6 +14,8 @@ import ( devicespb "go-librespot/proto/spotify/connectstate/devices" credentialspb "go-librespot/proto/spotify/login5/v3/credentials" "go-librespot/spclient" + "golang.org/x/oauth2" + spotifyoauth2 "golang.org/x/oauth2/spotify" ) type Session struct { @@ -79,9 +84,64 @@ func NewSessionFromOptions(opts *Options) (*Session, error) { if err := s.ap.ConnectStored(creds.Username, creds.Data); err != nil { return nil, fmt.Errorf("failed authenticating accesspoint with stored credentials: %w", err) } - case UserPassCredentials: - if err := s.ap.ConnectUserPass(creds.Username, creds.Password); err != nil { - return nil, fmt.Errorf("failed authenticating accesspoint with username and password: %w", err) + case InteractiveCredentials: + ctx := context.Background() + serverCtx, serverCancel := context.WithCancel(ctx) + + callbackPort, codeCh, err := NewOAuth2Server(serverCtx, creds.CallbackPort) + if err != nil { + serverCancel() + return nil, fmt.Errorf("failed initializing oauth2 server: %w", err) + } + + oauthConf := &oauth2.Config{ + ClientID: librespot.ClientIdHex, + RedirectURL: fmt.Sprintf("http://127.0.0.1:%d/login", callbackPort), + Scopes: []string{ + "app-remote-control", + "playlist-modify", + "playlist-modify-private", + "playlist-modify-public", + "playlist-read", + "playlist-read-collaborative", + "playlist-read-private", + "streaming", + "ugc-image-upload", + "user-follow-modify", + "user-follow-read", + "user-library-modify", + "user-library-read", + "user-modify", + "user-modify-playback-state", + "user-modify-private", + "user-personalized", + "user-read-birthdate", + "user-read-currently-playing", + "user-read-email", + "user-read-play-history", + "user-read-playback-position", + "user-read-playback-state", + "user-read-private", + "user-read-recently-played", + "user-top-read", + }, + Endpoint: spotifyoauth2.Endpoint, + } + + verifier := oauth2.GenerateVerifier() + url := oauthConf.AuthCodeURL("", oauth2.S256ChallengeOption(verifier)) + log.Infof("to complete authentication visit the following link: %s", url) + + code := <-codeCh + serverCancel() + + token, err := oauthConf.Exchange(ctx, code, oauth2.VerifierOption(verifier)) + if err != nil { + return nil, fmt.Errorf("failed exchanging oauth2 code: %w", err) + } + + if err := s.ap.ConnectSpotifyToken(token.Extra("username").(string), token.AccessToken); err != nil { + return nil, fmt.Errorf("failed authenticating accesspoint interactively: %w", err) } case SpotifyTokenCredentials: if err := s.ap.ConnectSpotifyToken(creds.Username, creds.Token); err != nil {