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() }