From 44189a433ca0a7ae6ca1499964606a8f8795b28d Mon Sep 17 00:00:00 2001 From: Zayan Date: Mon, 19 Jan 2026 18:12:33 +0000 Subject: [PATCH] feat: add auth cookie paste fallback - Add `spogo auth paste` to save cookies from DevTools values - Make `--no-input` effective (disable prompts) - Improve connect auth error when `sp_t` missing - Docs: update README + CLI spec --- README.md | 20 ++- docs/spec.md | 5 + internal/app/context.go | 1 + internal/app/context_test.go | 41 ++++++ internal/cli/auth.go | 192 ++++++++++++++++++++++++-- internal/cli/auth_test.go | 200 ++++++++++++++++++++++++---- internal/cli/cli.go | 1 + internal/spotify/connect_session.go | 2 +- 8 files changed, 424 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 59152a8..5c51ec9 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Env overrides: Commands: -- `auth status|import|clear` +- `auth status|import|paste|clear` - `search track|album|artist|playlist|show|episode` - `track info`, `album info`, `artist info`, `playlist info`, `show info`, `episode info` - `play [] [--type ...]`, `pause`, `next`, `prev`, `seek`, `volume`, `shuffle`, `repeat`, `status` @@ -98,6 +98,24 @@ spogo auth import --browser chrome Defaults: Chrome + Default profile. Cookies are stored under your config directory (per profile). +### Manual cookie paste (WSL fallback) + +If WSL cookie import/decryption is broken, paste cookies from Chrome DevTools: + +1) Developer Tools -> Application tab -> Cookies -> `https://open.spotify.com` +2) Copy `sp_dc` (required), `sp_key` (optional), `sp_t` (recommended for connect playback) +3) Run: + +```bash +spogo auth paste +``` + +Non-interactive: + +```bash +printf '%s\n%s\n' "sp_dc=..." "sp_t=..." | spogo auth paste --no-input +``` + ## Auto engine notes - `auto` tries connect first, then falls back to web on unsupported features or rate limits. diff --git a/docs/spec.md b/docs/spec.md index 2a41cce..3d817a6 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -42,6 +42,11 @@ spogo [global flags] [args] - `--browser-profile ` - `--cookie-path ` - `--domain ` default `spotify.com` +- `spogo auth paste` + - reads cookie values from stdin (prompts when interactive) + - `--cookie-path ` + - `--domain ` default `spotify.com` + - `--path ` default `/` - `spogo auth clear` ### search diff --git a/internal/app/context.go b/internal/app/context.go index 106f4b3..4c9d439 100644 --- a/internal/app/context.go +++ b/internal/app/context.go @@ -27,6 +27,7 @@ type Settings struct { Quiet bool Verbose bool Debug bool + NoInput bool } type Context struct { diff --git a/internal/app/context_test.go b/internal/app/context_test.go index a7065c7..2606f3e 100644 --- a/internal/app/context_test.go +++ b/internal/app/context_test.go @@ -34,6 +34,47 @@ func TestNewContextLoadsProfile(t *testing.T) { } } +func TestNewContextUsesDefaultPath(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + ctx, err := NewContext(Settings{Format: output.FormatPlain}) + if err != nil { + t.Fatalf("new context: %v", err) + } + want := filepath.Join(dir, "spogo", "config.toml") + if ctx.ConfigPath != want { + t.Fatalf("expected config path %q, got %q", want, ctx.ConfigPath) + } + if ctx.ProfileKey == "" { + t.Fatalf("expected profile key") + } +} + +func TestNewContextAppliesSettingsOverrides(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + cfg := config.Default() + cfg.SetProfile("default", config.Profile{Market: "DE", Language: "de"}) + if err := config.Save(path, cfg); err != nil { + t.Fatalf("save: %v", err) + } + ctx, err := NewContext(Settings{ + ConfigPath: path, + Format: output.FormatPlain, + Market: "US", + Language: "en", + Device: "device", + Engine: "web", + }) + if err != nil { + t.Fatalf("new context: %v", err) + } + if ctx.Profile.Market != "US" || ctx.Profile.Language != "en" || ctx.Profile.Device != "device" || ctx.Profile.Engine != "web" { + t.Fatalf("unexpected profile: %#v", ctx.Profile) + } +} + func TestResolveCookiePath(t *testing.T) { ctx := &Context{ConfigPath: "/tmp/spogo/config.toml", ProfileKey: "default"} path := ctx.ResolveCookiePath() diff --git a/internal/cli/auth.go b/internal/cli/auth.go index aa3c2eb..53ffe6f 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -1,12 +1,17 @@ package cli import ( + "bufio" "context" + "errors" "fmt" + "io" "net/http" - "os/exec" + "net/url" + "os" "strings" + "github.com/mattn/go-isatty" "github.com/steipete/spogo/internal/app" "github.com/steipete/spogo/internal/cookies" "github.com/steipete/spogo/internal/output" @@ -15,6 +20,7 @@ import ( type AuthCmd struct { Status AuthStatusCmd `kong:"cmd,help='Show cookie status.'"` Import AuthImportCmd `kong:"cmd,help='Import browser cookies.'"` + Paste AuthPasteCmd `kong:"cmd,help='Paste cookie values from the browser.'"` Clear AuthClearCmd `kong:"cmd,help='Clear stored cookies.'"` } @@ -27,11 +33,19 @@ type AuthImportCmd struct { Domain string `help:"Cookie domain suffix." default:"spotify.com"` } +type AuthPasteCmd struct { + CookiePath string `help:"Cookie cache file path."` + Domain string `help:"Cookie domain suffix." default:"spotify.com"` + Path string `help:"Cookie path." default:"/"` +} + type AuthClearCmd struct{} type authStatusPayload struct { CookieCount int `json:"cookie_count"` HasSPDC bool `json:"has_sp_dc"` + HasSPT bool `json:"has_sp_t"` + HasSPKey bool `json:"has_sp_key"` Source string `json:"source"` } @@ -41,24 +55,40 @@ func (cmd *AuthStatusCmd) Run(ctx *app.Context) error { return err } hasSPDC := false + hasSPT := false + hasSPKey := false for _, cookie := range cookiesList { - if cookie.Name == "sp_dc" { + switch cookie.Name { + case "sp_dc": hasSPDC = true - break + case "sp_t": + hasSPT = true + case "sp_key": + hasSPKey = true } } payload := authStatusPayload{ CookieCount: len(cookiesList), HasSPDC: hasSPDC, + HasSPT: hasSPT, + HasSPKey: hasSPKey, Source: sourceLabel, } - plain := []string{fmt.Sprintf("%d\t%t\t%s", payload.CookieCount, payload.HasSPDC, payload.Source)} + plain := []string{fmt.Sprintf("%d\t%t\t%t\t%t\t%s", payload.CookieCount, payload.HasSPDC, payload.HasSPT, payload.HasSPKey, payload.Source)} human := []string{fmt.Sprintf("Cookies: %d (%s)", payload.CookieCount, payload.Source)} if hasSPDC { human = append(human, "Session cookie: sp_dc") } else { human = append(human, "Session cookie: missing sp_dc") } + if hasSPT { + human = append(human, "Device cookie: sp_t") + } else { + human = append(human, "Device cookie: missing sp_t (needed for connect playback)") + } + if hasSPKey { + human = append(human, "Optional cookie: sp_key") + } return ctx.Output.Emit(payload, plain, human) } @@ -114,16 +144,104 @@ func (cmd *AuthImportCmd) Run(ctx *app.Context) error { return ctx.Output.Emit(payload, plain, human) } +func (cmd *AuthPasteCmd) Run(ctx *app.Context) error { + reader := bufio.NewReader(os.Stdin) + stdinIsTTY := isatty.IsTerminal(os.Stdin.Fd()) + if ctx.Settings.NoInput && stdinIsTTY { + return errors.New("--no-input set; pipe cookie values via stdin") + } + interactive := stdinIsTTY && !ctx.Settings.NoInput + + spdc, err := readCookieValue(reader, ctx.Output, "sp_dc", true, interactive) + if err != nil { + return err + } + spkey, err := readCookieValue(reader, ctx.Output, "sp_key", false, interactive) + if err != nil { + return err + } + spt, err := readCookieValue(reader, ctx.Output, "sp_t", false, interactive) + if err != nil { + return err + } + + if spdc == "" { + return errors.New("sp_dc required") + } + + domain := normalizeCookieDomain(cmd.Domain) + path := strings.TrimSpace(cmd.Path) + if path == "" { + path = "/" + } + + cookiesList := []*http.Cookie{{ + Name: "sp_dc", + Value: spdc, + Domain: domain, + Path: path, + Secure: true, + HttpOnly: true, + }} + if spkey != "" { + cookiesList = append(cookiesList, &http.Cookie{ + Name: "sp_key", + Value: spkey, + Domain: domain, + Path: path, + Secure: true, + HttpOnly: true, + }) + } + if spt != "" { + cookiesList = append(cookiesList, &http.Cookie{ + Name: "sp_t", + Value: spt, + Domain: domain, + Path: path, + Secure: true, + HttpOnly: true, + }) + } else if strings.EqualFold(strings.TrimSpace(ctx.Profile.Engine), "") || strings.EqualFold(strings.TrimSpace(ctx.Profile.Engine), "connect") || strings.EqualFold(strings.TrimSpace(ctx.Profile.Engine), "auto") { + _, _ = fmt.Fprintln(ctx.Output.Err, "warning: missing sp_t; playback may fail (grab sp_t from DevTools)") + } + + pathOut := cmd.CookiePath + if pathOut == "" { + pathOut = ctx.ResolveCookiePath() + } + if err := cookies.Write(pathOut, cookiesList); err != nil { + return err + } + profileCfg := ctx.Profile + profileCfg.CookiePath = pathOut + if err := ctx.SaveProfile(profileCfg); err != nil { + return err + } + human := []string{fmt.Sprintf("Saved %d cookies to %s", len(cookiesList), pathOut)} + plain := []string{fmt.Sprintf("%d\t%s", len(cookiesList), pathOut)} + payload := map[string]any{ + "cookie_count": len(cookiesList), + "path": pathOut, + } + return ctx.Output.Emit(payload, plain, human) +} + func (cmd *AuthClearCmd) Run(ctx *app.Context) error { path := ctx.ResolveCookiePath() if path == "" { return fmt.Errorf("no cookie path configured") } - if err := trashFile(path); err != nil { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + profileCfg := ctx.Profile + profileCfg.CookiePath = "" + if err := ctx.SaveProfile(profileCfg); err != nil { return err } plain := []string{"ok"} - human := []string{fmt.Sprintf("Moved %s to Trash", path)} + human := []string{fmt.Sprintf("Removed %s", path)} return ctx.Output.Emit(map[string]string{"status": "ok"}, plain, human) } @@ -150,13 +268,61 @@ func readCookies(ctx *app.Context) ([]*http.Cookie, string, error) { return browserCookies, "browser", nil } -func trashFile(path string) error { - if _, err := exec.LookPath("trash"); err != nil { - return fmt.Errorf("trash command not found; delete %s manually", path) +func readCookieValue(reader *bufio.Reader, out *output.Writer, name string, required bool, showPrompt bool) (string, error) { + if reader == nil { + reader = bufio.NewReader(os.Stdin) + } + promptText := fmt.Sprintf("Paste %s value: ", name) + if out != nil && showPrompt { + _, _ = fmt.Fprint(out.Err, promptText) } - cmd := exec.Command("trash", path) - if err := cmd.Run(); err != nil { - return fmt.Errorf("trash failed: %w", err) + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + value := normalizeCookieValue(line, name) + if value == "" && required { + return "", fmt.Errorf("%s required", name) + } + return value, nil +} + +func normalizeCookieDomain(domain string) string { + trimmed := strings.TrimSpace(domain) + if trimmed == "" { + trimmed = "spotify.com" + } + if strings.Contains(trimmed, "://") { + if parsed, err := url.Parse(trimmed); err == nil && parsed.Hostname() != "" { + trimmed = parsed.Hostname() + } + } + if !strings.HasPrefix(trimmed, ".") { + trimmed = "." + trimmed + } + return trimmed +} + +func normalizeCookieValue(value string, name string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + trimmed = strings.Trim(trimmed, "\"'") + for _, part := range strings.Split(trimmed, ";") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + key, val, found := strings.Cut(part, "=") + if !found { + continue + } + if strings.EqualFold(strings.TrimSpace(key), name) { + return strings.Trim(strings.TrimSpace(val), "\"'") + } } - return nil + prefix := name + "=" + trimmed = strings.TrimPrefix(trimmed, prefix) + return strings.TrimSpace(trimmed) } diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index 27c882e..65ec0ec 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -1,10 +1,12 @@ package cli import ( + "bufio" "context" "net/http" "os" "path/filepath" + "strings" "testing" "github.com/steipete/spogo/internal/config" @@ -14,6 +16,25 @@ import ( "github.com/steipete/sweetcookie" ) +func withStdin(t *testing.T, contents string, fn func()) { + t.Helper() + old := os.Stdin + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + if _, err := w.WriteString(contents); err != nil { + _ = r.Close() + _ = w.Close() + t.Fatalf("write: %v", err) + } + _ = w.Close() + os.Stdin = r + t.Cleanup(func() { os.Stdin = old }) + fn() + _ = r.Close() +} + func TestAuthStatusCmd(t *testing.T) { ctx, out, _ := testutil.NewTestContext(t, output.FormatPlain) path := filepath.Join(t.TempDir(), "cookies.json") @@ -98,6 +119,159 @@ func TestAuthImportCmdDefaultPath(t *testing.T) { } } +func TestNormalizeCookieDomain(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"", ".spotify.com"}, + {"spotify.com", ".spotify.com"}, + {".spotify.com", ".spotify.com"}, + {"https://open.spotify.com/", ".open.spotify.com"}, + } + for _, tc := range cases { + if got := normalizeCookieDomain(tc.in); got != tc.want { + t.Fatalf("normalizeCookieDomain(%q)=%q, want %q", tc.in, got, tc.want) + } + } +} + +func TestNormalizeCookieValue(t *testing.T) { + if got := normalizeCookieValue("sp_dc=token; Path=/; Secure", "sp_dc"); got != "token" { + t.Fatalf("expected token, got %q", got) + } + if got := normalizeCookieValue("\"token\"", "sp_dc"); got != "token" { + t.Fatalf("expected token, got %q", got) + } + if got := normalizeCookieValue("token", "sp_dc"); got != "token" { + t.Fatalf("expected token, got %q", got) + } +} + +func TestReadCookieValueEOF(t *testing.T) { + reader := bufio.NewReader(strings.NewReader("sp_dc=token")) + value, err := readCookieValue(reader, nil, "sp_dc", true, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if value != "token" { + t.Fatalf("expected token, got %q", value) + } +} + +func TestReadCookieValueNilReaderUsesStdin(t *testing.T) { + withStdin(t, "sp_dc=token\n", func() { + value, err := readCookieValue(nil, nil, "sp_dc", true, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if value != "token" { + t.Fatalf("expected token, got %q", value) + } + }) +} + +func TestReadCookieValueRequiredRejectsEmpty(t *testing.T) { + reader := bufio.NewReader(strings.NewReader("\n")) + _, err := readCookieValue(reader, nil, "sp_dc", true, false) + if err == nil { + t.Fatalf("expected error") + } +} + +func TestAuthPasteCmdFromStdin(t *testing.T) { + ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain) + ctx.Config = config.Default() + ctx.ConfigPath = filepath.Join(t.TempDir(), "config.toml") + ctx.ProfileKey = "default" + + dest := filepath.Join(t.TempDir(), "out.json") + withStdin(t, "sp_dc=token\nsp_key=key\nsp_t=device\n", func() { + cmd := AuthPasteCmd{ + CookiePath: dest, + Domain: "spotify.com", + } + if err := cmd.Run(ctx); err != nil { + t.Fatalf("run: %v", err) + } + }) + cookiesList, err := cookies.Read(dest) + if err != nil { + t.Fatalf("read cookies: %v", err) + } + if len(cookiesList) != 3 { + t.Fatalf("expected 3 cookies, got %d", len(cookiesList)) + } + if ctx.Profile.CookiePath != dest { + t.Fatalf("expected profile cookie path %s, got %s", dest, ctx.Profile.CookiePath) + } +} + +func TestAuthPasteCmdWarnsWhenMissingSPT(t *testing.T) { + ctx, _, errOut := testutil.NewTestContext(t, output.FormatPlain) + ctx.Config = config.Default() + ctx.ConfigPath = filepath.Join(t.TempDir(), "config.toml") + ctx.ProfileKey = "default" + ctx.Profile = config.Profile{Engine: "connect"} + + dest := filepath.Join(t.TempDir(), "out.json") + withStdin(t, "sp_dc=token\n", func() { + cmd := AuthPasteCmd{CookiePath: dest} + if err := cmd.Run(ctx); err != nil { + t.Fatalf("run: %v", err) + } + }) + if !strings.Contains(errOut.String(), "missing sp_t") { + t.Fatalf("expected warning in stderr, got %q", errOut.String()) + } +} + +func TestGlobalsSettingsPassesNoInput(t *testing.T) { + settings, err := (Globals{NoInput: true}).Settings() + if err != nil { + t.Fatalf("settings: %v", err) + } + if !settings.NoInput { + t.Fatalf("expected no_input true") + } +} + +func TestGlobalsSettingsRejectsPlainAndJSON(t *testing.T) { + _, err := (Globals{JSON: true, Plain: true}).Settings() + if err == nil { + t.Fatalf("expected error") + } +} + +func TestAuthPasteCmdRequiresSPDC(t *testing.T) { + ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain) + withStdin(t, "", func() { + cmd := AuthPasteCmd{} + if err := cmd.Run(ctx); err == nil { + t.Fatalf("expected error") + } + }) +} + +func TestAuthPasteCmdNoInputFromStdin(t *testing.T) { + ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain) + ctx.Config = config.Default() + ctx.ConfigPath = filepath.Join(t.TempDir(), "config.toml") + ctx.ProfileKey = "default" + ctx.Settings.NoInput = true + + dest := filepath.Join(t.TempDir(), "out.json") + withStdin(t, "sp_dc=token\nsp_t=device\n", func() { + cmd := AuthPasteCmd{CookiePath: dest} + if err := cmd.Run(ctx); err != nil { + t.Fatalf("run: %v", err) + } + }) + if _, err := cookies.Read(dest); err != nil { + t.Fatalf("expected cookies file") + } +} + func TestAuthClearCmdNoPath(t *testing.T) { ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain) cmd := AuthClearCmd{} @@ -109,6 +283,7 @@ func TestAuthClearCmdNoPath(t *testing.T) { func TestAuthClearCmdSuccess(t *testing.T) { ctx, _, _ := testutil.NewTestContext(t, output.FormatPlain) dir := t.TempDir() + ctx.Config = config.Default() ctx.ConfigPath = filepath.Join(dir, "config.toml") ctx.ProfileKey = "default" path := filepath.Join(dir, "cookies", "default.json") @@ -118,32 +293,11 @@ func TestAuthClearCmdSuccess(t *testing.T) { if err := os.WriteFile(path, []byte("[]"), 0o644); err != nil { t.Fatalf("write: %v", err) } - script := filepath.Join(dir, "trash") - if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { - t.Fatalf("write: %v", err) - } - t.Setenv("PATH", dir) cmd := AuthClearCmd{} if err := cmd.Run(ctx); err != nil { t.Fatalf("run: %v", err) } -} - -func TestTrashFileMissing(t *testing.T) { - t.Setenv("PATH", "") - if err := trashFile("/tmp/missing"); err == nil { - t.Fatalf("expected error") - } -} - -func TestTrashFileSuccess(t *testing.T) { - dir := t.TempDir() - script := filepath.Join(dir, "trash") - if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { - t.Fatalf("write: %v", err) - } - t.Setenv("PATH", dir) - if err := trashFile("/tmp/missing"); err != nil { - t.Fatalf("expected success") + if ctx.Profile.CookiePath != "" { + t.Fatalf("expected profile cookie path cleared, got %q", ctx.Profile.CookiePath) } } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index e54d119..d46aedd 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -79,6 +79,7 @@ func (g Globals) Settings() (app.Settings, error) { Quiet: g.Quiet, Verbose: g.Verbose, Debug: g.Debug, + NoInput: g.NoInput, }, nil } diff --git a/internal/spotify/connect_session.go b/internal/spotify/connect_session.go index 8c468c9..1d5e48e 100644 --- a/internal/spotify/connect_session.go +++ b/internal/spotify/connect_session.go @@ -100,7 +100,7 @@ func (s *connectSession) ensureAppConfigLocked(ctx context.Context) error { } } if deviceID == "" { - return errors.New("missing sp_t cookie") + return errors.New("missing sp_t cookie (run `spogo auth paste` and include sp_t from DevTools)") } jar, err := cookiejar.New(nil) if err != nil {