diff --git a/backends/auth.go b/backends/auth.go new file mode 100644 index 0000000..214379e --- /dev/null +++ b/backends/auth.go @@ -0,0 +1,60 @@ +/* +Copyright © 2023 Philipp Wolfer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package backends + +import ( + "net/url" + "strings" + + "github.com/spf13/viper" + "go.uploadedlobster.com/scotty/models" + "go.uploadedlobster.com/scotty/storage" +) + +func BuildRedirectURL(config *viper.Viper, backend string) (*url.URL, error) { + callbackHost, _ := strings.CutSuffix(config.GetString("oauth-host"), "/") + if callbackHost == "" { + callbackHost = "127.0.0.1:2369" + } + callbackPath := "/callback/" + backend + return url.Parse("http://" + callbackHost + callbackPath) +} + +func Authenticate(backend models.Backend, db storage.Database, config *viper.Viper) error { + authenticator, auth := backend.(models.OAuth2Authenticator) + if auth { + // FIXME + backendName := "spotify" + redirectURL, err := BuildRedirectURL(config, backendName) + if err != nil { + return err + } + // FIXME + token, err := db.GetOAuth2Token("spotify") + if err != nil { + return err + } + authenticator.OAuth2Setup(redirectURL.String(), token) + } + return nil +} diff --git a/backends/auth_test.go b/backends/auth_test.go new file mode 100644 index 0000000..8e0a307 --- /dev/null +++ b/backends/auth_test.go @@ -0,0 +1,49 @@ +/* +Copyright © 2023 Philipp Wolfer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package backends_test + +import ( + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uploadedlobster.com/scotty/backends" +) + +func TestBuildRedirectURL(t *testing.T) { + config := viper.New() + config.Set("oauth-host", "localhost:1234/") + url, err := backends.BuildRedirectURL(config, "foo") + require.NoError(t, err) + assert.Equal(t, "localhost:1234", url.Host) + assert.Equal(t, "/callback/foo", url.Path) +} + +func TestBuildRedirectURLDefaultHost(t *testing.T) { + config := viper.New() + url, err := backends.BuildRedirectURL(config, "foo") + require.NoError(t, err) + assert.Equal(t, "127.0.0.1:2369", url.Host) + assert.Equal(t, "/callback/foo", url.Path) +} diff --git a/backends/backends.go b/backends/backends.go index 98949cf..ae39c0e 100644 --- a/backends/backends.go +++ b/backends/backends.go @@ -60,6 +60,7 @@ func ResolveBackend[T interface{}](config *viper.Viper) (T, error) { err = errors.New( fmt.Sprintf("Backend %s does not implement %s", backendName, interfaceName)) } + return result, err } diff --git a/cmd/auth.go b/cmd/auth.go index f6ddb13..c987d5a 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -25,11 +25,11 @@ import ( "context" "fmt" "net/http" - "strings" "github.com/cli/browser" "github.com/spf13/cobra" "github.com/spf13/viper" + "go.uploadedlobster.com/scotty/backends" "go.uploadedlobster.com/scotty/storage" "golang.org/x/oauth2" "golang.org/x/oauth2/spotify" @@ -42,34 +42,30 @@ var authCmd = &cobra.Command{ Long: `For backends requiring authentication this command can be used to authenticate.`, Run: func(cmd *cobra.Command, args []string) { serviceName, serviceConfig := getConfigFromFlag(cmd, "service") - fmt.Print("HERE\n") backend := serviceConfig.GetString("backend") - callbackHost, _ := strings.CutSuffix(viper.GetString("oauth-host"), "/") - if callbackHost == "" { - callbackHost = "127.0.0.1:2369" - } - callbackPath := "/callback/" + backend + redirectURL, err := backends.BuildRedirectURL(viper.GetViper(), backend) + cobra.CheckErr(err) ctx := context.Background() conf := &oauth2.Config{ ClientID: serviceConfig.GetString("client-id"), ClientSecret: serviceConfig.GetString("client-secret"), Scopes: []string{"user-read-recently-played"}, - RedirectURL: "http://" + callbackHost + callbackPath, + RedirectURL: redirectURL.String(), Endpoint: spotify.Endpoint, } responseChan := make(chan string) // Start an HTTP server to listen for the response - http.HandleFunc(callbackPath, func(w http.ResponseWriter, r *http.Request) { + http.HandleFunc(redirectURL.Path, func(w http.ResponseWriter, r *http.Request) { code := r.URL.Query().Get("code") fmt.Fprint(w, "Token received, you can close this window now.") responseChan <- code }) - go http.ListenAndServe(callbackHost, nil) + go http.ListenAndServe(redirectURL.Host, nil) // use PKCE to protect against CSRF attacks // https://www.ietf.org/archive/id/draft-ietf-oauth-security-topics-22.html#name-countermeasures-6 @@ -80,7 +76,7 @@ var authCmd = &cobra.Command{ url := conf.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) fmt.Printf("Visit the URL for the auth dialog: %v\n", url) - err := browser.OpenURL(url) + err = browser.OpenURL(url) cobra.CheckErr(err) code := <-responseChan diff --git a/models/interfaces.go b/models/interfaces.go index 81dc4a5..bc2d6a2 100644 --- a/models/interfaces.go +++ b/models/interfaces.go @@ -35,15 +35,6 @@ type Backend interface { FromConfig(config *viper.Viper) Backend } -type OAuth2Backend interface { - Backend - // Returns OAuth2 config suitable for this backend - OAuth2Config(redirectUrl string) oauth2.Config - - // Setup the OAuth2 client - OAuth2Setup(redirectUrl string, token *oauth2.Token) error -} - type ImportBackend interface { Backend @@ -91,3 +82,12 @@ type LovesImport interface { // Imports the given list of loves. ImportLoves(export LovesResult, importResult ImportResult, progress chan Progress) (ImportResult, error) } + +// Must be implemented by backends requiring OAuth2 authentication +type OAuth2Authenticator interface { + // Returns OAuth2 config suitable for this backend + OAuth2Config(redirectUrl string) oauth2.Config + + // Setup the OAuth2 client + OAuth2Setup(redirectUrl string, token *oauth2.Token) error +}