Skip to content

Commit

Permalink
feat: deprecate user-pass login in favor of OAuth2 flow
Browse files Browse the repository at this point in the history
  • Loading branch information
devgianlu committed Jul 30, 2024
1 parent d1cf460 commit 1e82a27
Show file tree
Hide file tree
Showing 8 changed files with 149 additions and 42 deletions.
40 changes: 14 additions & 26 deletions cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -141,43 +141,32 @@ 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
if err := json.Unmarshal(content, &file); err != nil {
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 {
return nil, err
}

// 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{
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 6 additions & 10 deletions config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
},
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
22 changes: 22 additions & 0 deletions login5/login5.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
39 changes: 39 additions & 0 deletions session/oauth2.go
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 2 additions & 3 deletions session/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
66 changes: 63 additions & 3 deletions session/session.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down

0 comments on commit 1e82a27

Please sign in to comment.