backseat-music/backseat/components/spotifyAuth/spotifyAuth.go
2025-07-22 20:35:12 +01:00

128 lines
3.3 KiB
Go

package spotifyAuth
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"git.akpain.net/codemicro/backseat-music/backseat/config"
"git.akpain.net/codemicro/backseat-music/backseat/data"
"git.akpain.net/codemicro/backseat-music/backseat/httpUtil"
"golang.org/x/oauth2"
"net/http"
"net/url"
"time"
)
var oauthConf = &oauth2.Config{
ClientID: config.SpotifyClientID,
ClientSecret: config.SpotifyClientSecret,
Scopes: []string{"playlist-read-private", "playlist-modify-public", "playlist-modify-private", "ugc-image-upload", "user-read-recently-played"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://accounts.spotify.com/authorize",
TokenURL: "https://accounts.spotify.com/api/token",
},
}
type SpotifyAuth struct {
mux *http.ServeMux
datastore *data.Store
}
var _ http.Handler = (*SpotifyAuth)(nil)
func New(datastore *data.Store) *SpotifyAuth {
s := &SpotifyAuth{
mux: http.NewServeMux(),
datastore: datastore,
}
s.mux.Handle("GET /spotify/oauth/outbound", httpUtil.HandleErrors(s.oauthOutbound))
s.mux.Handle("GET /spotify/oauth/inbound", httpUtil.HandleErrors(s.oauthInbound))
return s
}
func (s *SpotifyAuth) ServeHTTP(rw http.ResponseWriter, rq *http.Request) {
s.mux.ServeHTTP(rw, rq)
}
const oauthStateCookie = "backseat-oauth"
func (s *SpotifyAuth) oauthOutbound(rw http.ResponseWriter, rq *http.Request) error {
state, err := generateState()
if err != nil {
return fmt.Errorf("generate state: %w", err)
}
http.SetCookie(rw, &http.Cookie{
Name: oauthStateCookie,
Value: state,
HttpOnly: true,
})
rw.Header().Set("Location", oauthConf.AuthCodeURL(
state,
oauth2.SetAuthURLParam("redirect_uri", deriveOauthRedirectURL(rq)),
oauth2.SetAuthURLParam("show_dialog", "true"),
))
rw.WriteHeader(301)
return nil
}
func generateState() (string, error) {
randomData := make([]byte, 64)
_, err := rand.Read(randomData)
if err != nil {
return "", nil
}
return hex.EncodeToString(randomData), nil
}
func (s *SpotifyAuth) oauthInbound(rw http.ResponseWriter, rq *http.Request) error {
code := rq.URL.Query().Get("code")
if code == "" {
_, _ = rw.Write([]byte("missing code"))
rw.WriteHeader(400)
return nil
}
state := rq.URL.Query().Get("state")
stateCookie, err := rq.Cookie(oauthStateCookie) // the only reason we might have an err is if the cookie doesn't exist
if state == "" || err != nil || subtle.ConstantTimeCompare([]byte(state), []byte(stateCookie.Value)) == 0 {
_, _ = rw.Write([]byte("bad state"))
rw.WriteHeader(400)
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
tok, err := oauthConf.Exchange(ctx, code,
oauth2.SetAuthURLParam("grant_type", "authorization_code"),
oauth2.SetAuthURLParam("redirect_uri", deriveOauthRedirectURL(rq)))
if err != nil {
return fmt.Errorf("oauth2 exchange: %w", err)
}
if err := s.datastore.SetSpotifyOAuthToken(tok); err != nil {
return fmt.Errorf("save oauth2 token: %w", err)
}
rw.Header().Set("Location", httpUtil.GetNextURL(rq))
rw.WriteHeader(http.StatusFound)
return nil
}
func deriveOauthRedirectURL(rq *http.Request) string {
proto := "https"
if rq.TLS == nil {
proto = "http"
}
return (&url.URL{
Scheme: proto,
Host: rq.Host,
Path: "/spotify/oauth/inbound",
}).String()
}