315 lines
8.0 KiB
Go
315 lines
8.0 KiB
Go
package spotify
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type AuthConfig struct {
|
|
ClientID string
|
|
RedirectURI string
|
|
Scopes []string
|
|
ManualCode bool
|
|
}
|
|
|
|
type Token struct {
|
|
AccessToken string `json:"access_token"`
|
|
TokenType string `json:"token_type"`
|
|
Scope string `json:"scope"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
}
|
|
|
|
func RefreshAccessToken(ctx context.Context, clientID, refreshToken string) (Token, error) {
|
|
body := url.Values{
|
|
"client_id": []string{strings.TrimSpace(clientID)},
|
|
"grant_type": []string{"refresh_token"},
|
|
"refresh_token": []string{strings.TrimSpace(refreshToken)},
|
|
}
|
|
|
|
tok, err := requestToken(ctx, body)
|
|
if err != nil {
|
|
return Token{}, fmt.Errorf("spotify refresh failed: %w", err)
|
|
}
|
|
if tok.RefreshToken == "" {
|
|
tok.RefreshToken = strings.TrimSpace(refreshToken)
|
|
}
|
|
return tok, nil
|
|
}
|
|
|
|
func LoginWithPKCE(ctx context.Context, cfg AuthConfig) (Token, error) {
|
|
redirectURL, err := url.Parse(cfg.RedirectURI)
|
|
if err != nil {
|
|
return Token{}, fmt.Errorf("invalid redirect URI: %w", err)
|
|
}
|
|
|
|
codeVerifier, err := randomURLSafe(64)
|
|
if err != nil {
|
|
return Token{}, err
|
|
}
|
|
state, err := randomURLSafe(24)
|
|
if err != nil {
|
|
return Token{}, err
|
|
}
|
|
|
|
h := sha256.Sum256([]byte(codeVerifier))
|
|
codeChallenge := base64.RawURLEncoding.EncodeToString(h[:])
|
|
|
|
authURL := "https://accounts.spotify.com/authorize?" + url.Values{
|
|
"response_type": []string{"code"},
|
|
"client_id": []string{cfg.ClientID},
|
|
"scope": []string{strings.Join(cfg.Scopes, " ")},
|
|
"redirect_uri": []string{cfg.RedirectURI},
|
|
"state": []string{state},
|
|
"code_challenge_method": []string{"S256"},
|
|
"code_challenge": []string{codeChallenge},
|
|
}.Encode()
|
|
|
|
if cfg.ManualCode {
|
|
return loginManual(ctx, cfg, authURL, state, codeVerifier)
|
|
}
|
|
|
|
tok, err := loginWithCallbackServer(ctx, cfg, redirectURL, authURL, state, codeVerifier)
|
|
if err == nil {
|
|
return tok, nil
|
|
}
|
|
|
|
if strings.Contains(strings.ToLower(err.Error()), "listen callback server") {
|
|
fmt.Println("Local callback server unavailable; falling back to manual code entry.")
|
|
return loginManual(ctx, cfg, authURL, state, codeVerifier)
|
|
}
|
|
|
|
return Token{}, err
|
|
}
|
|
|
|
func loginWithCallbackServer(ctx context.Context, cfg AuthConfig, redirectURL *url.URL, authURL, state, codeVerifier string) (Token, error) {
|
|
codeCh := make(chan string, 1)
|
|
errCh := make(chan error, 1)
|
|
|
|
mux := http.NewServeMux()
|
|
server := &http.Server{
|
|
Addr: redirectURL.Host,
|
|
Handler: mux,
|
|
}
|
|
|
|
mux.HandleFunc(redirectURL.Path, func(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
if q.Get("state") != state {
|
|
http.Error(w, "Invalid state", http.StatusBadRequest)
|
|
select {
|
|
case errCh <- fmt.Errorf("state mismatch in spotify callback"):
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
if e := q.Get("error"); e != "" {
|
|
http.Error(w, "Spotify authorization failed", http.StatusBadRequest)
|
|
select {
|
|
case errCh <- fmt.Errorf("spotify auth error: %s", e):
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
code := q.Get("code")
|
|
if code == "" {
|
|
http.Error(w, "Missing authorization code", http.StatusBadRequest)
|
|
select {
|
|
case errCh <- fmt.Errorf("spotify callback missing code"):
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
|
|
_, _ = io.WriteString(w, "Spotify authorization complete. You can close this tab.")
|
|
select {
|
|
case codeCh <- code:
|
|
default:
|
|
}
|
|
})
|
|
|
|
ln, err := net.Listen("tcp", redirectURL.Host)
|
|
if err != nil {
|
|
return Token{}, fmt.Errorf("listen callback server: %w", err)
|
|
}
|
|
|
|
go func() {
|
|
if serveErr := server.Serve(ln); serveErr != nil && serveErr != http.ErrServerClosed {
|
|
select {
|
|
case errCh <- serveErr:
|
|
default:
|
|
}
|
|
}
|
|
}()
|
|
|
|
_ = openBrowser(authURL)
|
|
fmt.Printf("Open this URL in your browser if it did not open automatically:\n%s\n\n", authURL)
|
|
|
|
var code string
|
|
select {
|
|
case <-ctx.Done():
|
|
_ = server.Shutdown(context.Background())
|
|
return Token{}, ctx.Err()
|
|
case e := <-errCh:
|
|
_ = server.Shutdown(context.Background())
|
|
return Token{}, e
|
|
case code = <-codeCh:
|
|
}
|
|
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_ = server.Shutdown(shutdownCtx)
|
|
|
|
return exchangeCode(ctx, cfg, code, codeVerifier)
|
|
}
|
|
|
|
func loginManual(ctx context.Context, cfg AuthConfig, authURL, expectedState, codeVerifier string) (Token, error) {
|
|
_ = openBrowser(authURL)
|
|
fmt.Printf("Open this URL in your browser if it did not open automatically:\n%s\n\n", authURL)
|
|
fmt.Printf("After Spotify redirects to %s, copy the full URL from your browser and paste it here.\n", cfg.RedirectURI)
|
|
fmt.Println("If your browser only shows a code, you can paste that code directly.")
|
|
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
for {
|
|
fmt.Print("Paste callback URL/code: ")
|
|
if !scanner.Scan() {
|
|
if scanner.Err() != nil {
|
|
return Token{}, scanner.Err()
|
|
}
|
|
return Token{}, fmt.Errorf("stdin closed before spotify auth code was provided")
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return Token{}, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
input := strings.TrimSpace(scanner.Text())
|
|
if input == "" {
|
|
continue
|
|
}
|
|
|
|
code, err := extractAuthCode(input, expectedState)
|
|
if err != nil {
|
|
fmt.Printf("Could not parse code: %v\n", err)
|
|
continue
|
|
}
|
|
|
|
return exchangeCode(ctx, cfg, code, codeVerifier)
|
|
}
|
|
}
|
|
|
|
func extractAuthCode(input, expectedState string) (string, error) {
|
|
input = strings.TrimSpace(input)
|
|
|
|
if !strings.Contains(input, "code=") && !strings.Contains(input, "://") {
|
|
return input, nil
|
|
}
|
|
|
|
queryString := ""
|
|
if strings.Contains(input, "://") {
|
|
u, err := url.Parse(input)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid callback URL")
|
|
}
|
|
queryString = u.RawQuery
|
|
} else {
|
|
queryString = strings.TrimPrefix(input, "?")
|
|
}
|
|
|
|
q, err := url.ParseQuery(queryString)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid callback query")
|
|
}
|
|
|
|
if e := q.Get("error"); e != "" {
|
|
return "", fmt.Errorf("spotify returned error: %s", e)
|
|
}
|
|
|
|
if gotState := q.Get("state"); expectedState != "" && gotState != "" && gotState != expectedState {
|
|
return "", fmt.Errorf("state mismatch")
|
|
}
|
|
|
|
code := strings.TrimSpace(q.Get("code"))
|
|
if code == "" {
|
|
return "", fmt.Errorf("missing code parameter")
|
|
}
|
|
return code, nil
|
|
}
|
|
|
|
func exchangeCode(ctx context.Context, cfg AuthConfig, code, codeVerifier string) (Token, error) {
|
|
body := url.Values{
|
|
"client_id": []string{cfg.ClientID},
|
|
"grant_type": []string{"authorization_code"},
|
|
"code": []string{code},
|
|
"redirect_uri": []string{cfg.RedirectURI},
|
|
"code_verifier": []string{codeVerifier},
|
|
}
|
|
return requestToken(ctx, body)
|
|
}
|
|
|
|
func requestToken(ctx context.Context, body url.Values) (Token, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://accounts.spotify.com/api/token", strings.NewReader(body.Encode()))
|
|
if err != nil {
|
|
return Token{}, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return Token{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return Token{}, fmt.Errorf("spotify token exchange failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
|
}
|
|
|
|
var tok Token
|
|
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
|
|
return Token{}, err
|
|
}
|
|
if tok.AccessToken == "" {
|
|
return Token{}, fmt.Errorf("spotify token response missing access_token")
|
|
}
|
|
return tok, nil
|
|
}
|
|
|
|
func randomURLSafe(n int) (string, error) {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
func openBrowser(u string) error {
|
|
var cmd *exec.Cmd
|
|
switch runtime.GOOS {
|
|
case "linux":
|
|
cmd = exec.Command("xdg-open", u)
|
|
case "darwin":
|
|
cmd = exec.Command("open", u)
|
|
case "windows":
|
|
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", u)
|
|
default:
|
|
return fmt.Errorf("unsupported OS for auto-open")
|
|
}
|
|
return cmd.Start()
|
|
}
|