diff --git a/cmd/parasocial/main.go b/cmd/parasocial/main.go index c60c11d..2557ec3 100644 --- a/cmd/parasocial/main.go +++ b/cmd/parasocial/main.go @@ -1,3 +1,6 @@ +// main.go is the executable entrypoint for the rewritten CLI. +// It owns process-level concerns such as signal handling, exit codes, +// and delegating the actual application startup to internal/app. package main import ( @@ -11,6 +14,7 @@ import ( "parasocial/internal/app" ) +// main sets up process cancellation and reports fatal startup errors to stderr. func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() diff --git a/internal/app/app.go b/internal/app/app.go index bab0843..a822397 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,24 +1,66 @@ +// app.go wires together config loading, auth bootstrap, and the Bubble Tea program. +// It decides whether cached Twitch auth can be reused and, when needed, +// connects the interactive login flow to the TUI through auth update messages. package app import ( "context" + "fmt" + "net/http" "os" + "strings" + "time" tea "github.com/charmbracelet/bubbletea" + "parasocial/internal/auth" "parasocial/internal/config" "parasocial/internal/tui" ) // Run loads the application configuration and starts the terminal UI. func Run(ctx context.Context) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + cfg, err := config.LoadDefault() if err != nil { return err } + httpClient := &http.Client{Timeout: 30 * time.Second} + authClient := auth.NewClient(httpClient) + authPath := auth.DefaultPath() + + state, err := authClient.ReuseAuth(ctx, authPath) + if err != nil { + return err + } + program := tea.NewProgram( - tui.New(cfg.Streamers), + tui.New(tui.Options{ + Streamers: cfg.Streamers, + AuthState: state, + StartAuth: func(ch chan<- tui.AuthUpdate) { + go func() { + defer close(ch) + + state, err := authClient.EnsureAuth(ctx, authPath, func(line string) { + ch <- tui.AuthUpdate{Line: strings.TrimRight(line, "\n")} + }) + if err != nil { + ch <- tui.AuthUpdate{ + Line: fmt.Sprintf("Authentication failed: %v", err), + Err: err, + Done: true, + } + return + } + + ch <- tui.AuthUpdate{State: state, Done: true} + }() + }, + }), tea.WithContext(ctx), tea.WithInput(os.Stdin), tea.WithOutput(os.Stdout), diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..176bb51 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,552 @@ +// auth.go implements the Twitch TV device login flow and cached token validation. +// It owns the HTTP interaction with Twitch OAuth endpoints, the polling loop, +// and the status messages consumed by the TUI while authentication is in progress. +package auth + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + ClientID = "ue6666qo983tsx6so1t0vnawi233wa" + + deviceIDLength = 32 + contentTypeForm = "application/x-www-form-urlencoded" + activateURL = "https://www.twitch.tv/activate" + pollGrantType = "urn:ietf:params:oauth:grant-type:device_code" + + tvOrigin = "https://android.tv.twitch.tv" + tvReferer = "https://android.tv.twitch.tv/" + tvUserAgent = "Mozilla/5.0 (Linux; Android 7.1; Smart Box C1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" +) + +var RequiredScopes = []string{ + "channel_read", + "chat:read", + "user_blocks_edit", + "user_blocks_read", + "user_follows_edit", + "user_read", +} + +var DefaultEndpoints = Endpoints{ + Device: "https://id.twitch.tv/oauth2/device", + Token: "https://id.twitch.tv/oauth2/token", + Validate: "https://id.twitch.tv/oauth2/validate", +} + +// Endpoints groups the Twitch OAuth URLs used by the TV device flow client. +type Endpoints struct { + Device string + Token string + Validate string +} + +// Client performs token validation and TV device-flow authentication against Twitch. +type Client struct { + HTTPClient *http.Client + Endpoints Endpoints + Now func() time.Time + Sleep func(context.Context, time.Duration) error +} + +// deviceCodeResponse models the device-code payload returned by Twitch. +type deviceCodeResponse struct { + DeviceCode string `json:"device_code"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` +} + +// tokenResponse models the token payload returned after device authorization succeeds. +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Scope []string `json:"scope"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +// validationResponse models the token introspection data returned by Twitch validate. +type validationResponse struct { + ClientID string `json:"client_id"` + Login string `json:"login"` + Scopes []string `json:"scopes"` + UserID string `json:"user_id"` + ExpiresIn int `json:"expires_in"` +} + +// oauthErrorResponse captures structured OAuth errors returned by Twitch endpoints. +type oauthErrorResponse struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + Message string `json:"message"` + Status int `json:"status"` +} + +type StatusFunc func(string) + +// StatusError reports a non-200 HTTP response together with its response body. +type StatusError struct { + StatusCode int + Body string +} + +// Error formats the HTTP status failure in a way that preserves the response body. +func (e *StatusError) Error() string { + body := strings.TrimSpace(e.Body) + if body == "" { + return fmt.Sprintf("unexpected status %d", e.StatusCode) + } + return fmt.Sprintf("unexpected status %d: %s", e.StatusCode, body) +} + +// NewClient constructs an auth client with default endpoints and timing hooks. +func NewClient(httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = http.DefaultClient + } + return &Client{ + HTTPClient: httpClient, + Endpoints: DefaultEndpoints, + Now: time.Now, + Sleep: sleepContext, + } +} + +// ReuseAuth validates a saved auth bundle and returns it only when it is still usable. +func (c *Client) ReuseAuth(ctx context.Context, path string) (*State, error) { + state, err := LoadState(path) + if err != nil { + return nil, fmt.Errorf("load auth state: %w", err) + } + if state == nil || state.AccessToken == "" { + return nil, nil + } + if state.DeviceID == "" { + deviceID, err := randomAlphaNumeric(deviceIDLength) + if err != nil { + return nil, fmt.Errorf("create device ID: %w", err) + } + state.DeviceID = deviceID + } + + validation, err := c.ValidateToken(ctx, state.AccessToken) + if err != nil || !hasAllScopes(validation.Scopes, RequiredScopes) { + return nil, nil + } + + state.applyValidation(validation, c.now()) + if err := SaveState(path, state); err != nil { + return nil, fmt.Errorf("save validated auth state: %w", err) + } + return state, nil +} + +// EnsureAuth reuses cached auth when possible and otherwise runs the TV device flow. +func (c *Client) EnsureAuth(ctx context.Context, path string, status StatusFunc) (*State, error) { + state, err := LoadState(path) + if err != nil { + return nil, fmt.Errorf("load auth state: %w", err) + } + if state == nil { + state = &State{} + } + if state.DeviceID == "" { + deviceID, err := randomAlphaNumeric(deviceIDLength) + if err != nil { + return nil, fmt.Errorf("create device ID: %w", err) + } + state.DeviceID = deviceID + } + + if state.AccessToken != "" { + c.status(status, "Validating cached token from %s", path) + validation, err := c.ValidateToken(ctx, state.AccessToken) + if err == nil && hasAllScopes(validation.Scopes, RequiredScopes) { + state.applyValidation(validation, c.now()) + if err := SaveState(path, state); err != nil { + return nil, fmt.Errorf("save validated auth state: %w", err) + } + c.status(status, "Cached token is valid; authenticated as %s", state.Login) + return state, nil + } + if err != nil { + c.status(status, "Cached token could not be reused: %v", err) + } else { + c.status(status, "Cached token is missing required scopes; activation is required") + } + } + + tokenResp, err := c.activate(ctx, state.DeviceID, status) + if err != nil { + return nil, err + } + + validation, err := c.ValidateToken(ctx, tokenResp.AccessToken) + if err != nil { + return nil, fmt.Errorf("validate new token: %w", err) + } + if !hasAllScopes(validation.Scopes, RequiredScopes) { + return nil, fmt.Errorf("new token is missing required scopes") + } + + state.AccessToken = tokenResp.AccessToken + state.RefreshToken = tokenResp.RefreshToken + state.TokenType = tokenResp.TokenType + if state.TokenType == "" { + state.TokenType = "bearer" + } + state.Scopes = tokenResp.Scope + state.ExpiresIn = tokenResp.ExpiresIn + state.applyValidation(validation, c.now()) + + if err := SaveState(path, state); err != nil { + return nil, fmt.Errorf("save auth state: %w", err) + } + c.status(status, "Saved auth state to %s", path) + c.status(status, "Authenticated as %s", state.Login) + return state, nil +} + +// ValidateToken asks Twitch whether an OAuth access token is still valid. +func (c *Client) ValidateToken(ctx context.Context, accessToken string) (*validationResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoints().Validate, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "OAuth "+accessToken) + + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, &StatusError{StatusCode: resp.StatusCode, Body: string(body)} + } + + var validation validationResponse + if err := json.Unmarshal(body, &validation); err != nil { + return nil, fmt.Errorf("parse validation response: %w", err) + } + if validation.Login == "" { + return nil, errors.New("validation response missing login") + } + if validation.UserID == "" { + return nil, errors.New("validation response missing user_id") + } + if validation.ClientID == "" { + return nil, errors.New("validation response missing client_id") + } + return &validation, nil +} + +// activate requests a device code and waits for the user to authorize it. +func (c *Client) activate(ctx context.Context, deviceID string, status StatusFunc) (*tokenResponse, error) { + deviceResp, err := c.requestDeviceCode(ctx, deviceID) + if err != nil { + return nil, fmt.Errorf("request device code: %w", err) + } + + interval := time.Duration(deviceResp.Interval) * time.Second + if interval <= 0 { + interval = 5 * time.Second + } + deadline := c.now().Add(time.Duration(deviceResp.ExpiresIn) * time.Second) + + c.status(status, "== Twitch activation ==") + c.status(status, "Open page: %s", deviceResp.VerificationURI) + c.status(status, "User code: %s", deviceResp.UserCode) + c.status(status, "Polling interval: %s", interval) + c.status(status, "Expires at: %s", deadline.Format(time.RFC3339)) + + return c.pollForToken(ctx, deviceID, deviceResp.DeviceCode, interval, deadline, status) +} + +// requestDeviceCode starts the Twitch TV device flow and returns the activation instructions. +func (c *Client) requestDeviceCode(ctx context.Context, deviceID string) (*deviceCodeResponse, error) { + form := url.Values{} + form.Set("client_id", ClientID) + form.Set("scopes", strings.Join(RequiredScopes, " ")) + + body, statusCode, err := c.postForm(ctx, c.endpoints().Device, deviceID, form) + if err != nil { + return nil, err + } + if statusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d from device endpoint: %s", statusCode, strings.TrimSpace(string(body))) + } + + var resp deviceCodeResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("parse device-code response: %w", err) + } + if resp.VerificationURI == "" { + resp.VerificationURI = activateURL + } + if err := validateDeviceCodeResponse(&resp); err != nil { + return nil, err + } + return &resp, nil +} + +// pollForToken repeatedly checks the token endpoint until the device flow resolves. +func (c *Client) pollForToken(ctx context.Context, deviceID, deviceCode string, interval time.Duration, deadline time.Time, status StatusFunc) (*tokenResponse, error) { + attempt := 1 + for { + if !c.now().Before(deadline) { + return nil, fmt.Errorf("device code expired at %s before authorization completed", deadline.Format(time.RFC3339)) + } + + remaining := deadline.Sub(c.now()).Round(time.Second) + c.status(status, "[poll %d] waiting %s before token request (%s remaining)", attempt, interval, remaining) + if err := c.sleep(ctx, interval); err != nil { + return nil, err + } + + form := url.Values{} + form.Set("client_id", ClientID) + form.Set("device_code", deviceCode) + form.Set("grant_type", pollGrantType) + + body, statusCode, err := c.postForm(ctx, c.endpoints().Token, deviceID, form) + if err != nil { + return nil, fmt.Errorf("poll request failed on attempt %d: %w", attempt, err) + } + + if statusCode == http.StatusOK { + var resp tokenResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("parse token response: %w", err) + } + if resp.AccessToken == "" { + return nil, errors.New("token response missing access_token") + } + c.status(status, "[poll %d] authorization succeeded", attempt) + return &resp, nil + } + + oauthErr := parseOAuthError(body) + switch oauthErr.Error { + case "authorization_pending", "": + c.status(status, "[poll %d] authorization pending (status %d)", attempt, statusCode) + case "slow_down": + interval += 5 * time.Second + c.status(status, "[poll %d] Twitch requested slow_down; new interval %s", attempt, interval) + case "access_denied": + return nil, fmt.Errorf("authorization denied by user: %s", oauthErr.summary()) + case "expired_token", "invalid_device_code": + return nil, fmt.Errorf("device flow expired or invalid: %s", oauthErr.summary()) + default: + return nil, fmt.Errorf("token endpoint returned status %d: %s", statusCode, oauthErr.summary()) + } + + attempt++ + } +} + +// postForm sends a form-encoded Twitch OAuth request with the TV client headers attached. +func (c *Client) postForm(ctx context.Context, endpoint, deviceID string, form url.Values) ([]byte, int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, 0, err + } + for key, value := range defaultOAuthHeaders(deviceID) { + req.Header.Set(key, value) + } + req.Header.Set("Content-Type", contentTypeForm) + + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + return body, resp.StatusCode, err +} + +// validateDeviceCodeResponse rejects incomplete or unusable device-code payloads. +func validateDeviceCodeResponse(resp *deviceCodeResponse) error { + switch { + case resp.DeviceCode == "": + return errors.New("device_code is missing") + case resp.UserCode == "": + return errors.New("user_code is missing") + case resp.VerificationURI == "": + return errors.New("verification_uri is missing") + case resp.ExpiresIn <= 0: + return errors.New("expires_in must be greater than zero") + case resp.Interval <= 0: + return errors.New("interval must be greater than zero") + default: + return nil + } +} + +// parseOAuthError decodes a structured OAuth error or falls back to raw response text. +func parseOAuthError(body []byte) oauthErrorResponse { + var resp oauthErrorResponse + if err := json.Unmarshal(body, &resp); err != nil { + resp.Message = strings.TrimSpace(string(body)) + } + return resp +} + +// summary compresses an OAuth error payload into one log-friendly string. +func (r oauthErrorResponse) summary() string { + parts := []string{} + if r.Error != "" { + parts = append(parts, fmt.Sprintf("error=%s", r.Error)) + } + if r.ErrorDescription != "" { + parts = append(parts, fmt.Sprintf("description=%s", r.ErrorDescription)) + } + if r.Message != "" { + parts = append(parts, fmt.Sprintf("message=%s", r.Message)) + } + if r.Status != 0 { + parts = append(parts, fmt.Sprintf("status=%d", r.Status)) + } + if len(parts) == 0 { + return "no JSON error payload" + } + return strings.Join(parts, ", ") +} + +// defaultOAuthHeaders builds the Twitch TV request headers used for auth requests. +func defaultOAuthHeaders(deviceID string) map[string]string { + return map[string]string{ + "Accept": "application/json", + "Accept-Language": "en-US", + "Cache-Control": "no-cache", + "Client-Id": ClientID, + "Origin": tvOrigin, + "Pragma": "no-cache", + "Referer": tvReferer, + "User-Agent": TVUserAgent(), + "X-Device-Id": deviceID, + } +} + +// TVUserAgent returns the Twitch TV user agent used to impersonate the device client. +func TVUserAgent() string { + return tvUserAgent +} + +// applyValidation copies validated account metadata back onto the persisted auth state. +func (s *State) applyValidation(validation *validationResponse, validatedAt time.Time) { + s.Login = validation.Login + s.UserID = validation.UserID + s.ClientID = validation.ClientID + s.Scopes = validation.Scopes + s.ExpiresIn = validation.ExpiresIn + s.ValidatedAt = validatedAt + s.Cookies = s.persistedCookies() +} + +// hasScope reports whether the token scopes include one exact scope string. +func hasScope(scopes []string, scope string) bool { + for _, candidate := range scopes { + if candidate == scope { + return true + } + } + return false +} + +// hasAllScopes reports whether the token satisfies the full required scope set. +func hasAllScopes(scopes []string, required []string) bool { + for _, scope := range required { + if !hasScope(scopes, scope) { + return false + } + } + return true +} + +// randomAlphaNumeric creates a device identifier using Twitch-safe alphanumeric characters. +func randomAlphaNumeric(length int) (string, error) { + const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + var builder strings.Builder + builder.Grow(length) + + max := big.NewInt(int64(len(alphabet))) + for i := 0; i < length; i++ { + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + builder.WriteByte(alphabet[n.Int64()]) + } + return builder.String(), nil +} + +// sleepContext waits for a duration unless the surrounding context is canceled first. +func sleepContext(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +// httpClient returns the configured HTTP client or falls back to the default client. +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return http.DefaultClient +} + +// endpoints returns the configured endpoint set or the package defaults. +func (c *Client) endpoints() Endpoints { + if c.Endpoints == (Endpoints{}) { + return DefaultEndpoints + } + return c.Endpoints +} + +// now returns the current time using the injected clock when one is configured. +func (c *Client) now() time.Time { + if c.Now != nil { + return c.Now() + } + return time.Now() +} + +// sleep delegates waiting to the injected hook when tests need deterministic timing. +func (c *Client) sleep(ctx context.Context, d time.Duration) error { + if c.Sleep != nil { + return c.Sleep(ctx, d) + } + return sleepContext(ctx, d) +} + +// status formats and emits one auth progress line when a status sink is attached. +func (c *Client) status(status StatusFunc, format string, args ...any) { + if status == nil { + return + } + status(fmt.Sprintf(format, args...)) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..4591439 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,298 @@ +package auth + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" +) + +func TestSaveStatePersistsCookieEntries(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cookies.json") + state := &State{ + AccessToken: "token", + TokenType: "bearer", + Scopes: RequiredScopes, + Login: "viewer", + UserID: "123", + ClientID: ClientID, + ExpiresIn: 3600, + DeviceID: "device", + } + if err := SaveState(path, state); err != nil { + t.Fatal(err) + } + + loaded, err := LoadState(path) + if err != nil { + t.Fatal(err) + } + if loaded.AccessToken != "token" { + t.Fatalf("AccessToken = %q", loaded.AccessToken) + } + + gotNames := []string{} + for _, cookie := range loaded.Cookies { + gotNames = append(gotNames, cookie.Name) + } + wantNames := []string{"auth-token", "login", "persistent"} + if !slices.Equal(gotNames, wantNames) { + t.Fatalf("cookie names = %#v, want %#v", gotNames, wantNames) + } +} + +func TestLoadStateMissingFileReturnsNil(t *testing.T) { + t.Parallel() + + state, err := LoadState(filepath.Join(t.TempDir(), "missing.json")) + if err != nil { + t.Fatal(err) + } + if state != nil { + t.Fatalf("LoadState() = %#v, want nil", state) + } +} + +func TestLoadStateRejectsPartialAuthBundle(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "cookies.json") + if err := os.WriteFile(path, []byte(`{"access_token":"token"}`), 0o600); err != nil { + t.Fatal(err) + } + + _, err := LoadState(path) + if err == nil { + t.Fatal("LoadState() error = nil, want error") + } + if !strings.Contains(err.Error(), "missing token_type") { + t.Fatalf("error = %q", err) + } +} + +func TestReuseAuthReusesValidCachedToken(t *testing.T) { + t.Parallel() + + var validateCalls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/validate" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + validateCalls++ + if got := r.Header.Get("Authorization"); got != "OAuth cached-token" { + t.Fatalf("Authorization = %q", got) + } + writeJSON(t, w, validationResponse{ + ClientID: ClientID, + Login: "viewer", + UserID: "123", + Scopes: RequiredScopes, + ExpiresIn: 3600, + }) + })) + defer server.Close() + + tokenFile := filepath.Join(t.TempDir(), "cookies.json") + if err := SaveState(tokenFile, &State{ + AccessToken: "cached-token", + TokenType: "bearer", + Scopes: RequiredScopes, + Login: "viewer", + UserID: "123", + ClientID: ClientID, + ExpiresIn: 3600, + DeviceID: "device", + }); err != nil { + t.Fatal(err) + } + + client := NewClient(server.Client()) + client.Endpoints = Endpoints{Validate: server.URL + "/validate"} + client.Now = func() time.Time { return time.Unix(100, 0).UTC() } + + state, err := client.ReuseAuth(context.Background(), tokenFile) + if err != nil { + t.Fatal(err) + } + if validateCalls != 1 { + t.Fatalf("validateCalls = %d", validateCalls) + } + if state == nil || state.Login != "viewer" { + t.Fatalf("state = %#v", state) + } +} + +func TestEnsureAuthActivatesWhenCachedTokenMissingChatRead(t *testing.T) { + t.Parallel() + + validateCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/validate": + validateCalls++ + if validateCalls == 1 { + writeJSON(t, w, validationResponse{ + ClientID: ClientID, + Login: "viewer", + UserID: "123", + Scopes: []string{"user:read:email"}, + ExpiresIn: 100, + }) + return + } + if got := r.Header.Get("Authorization"); got != "OAuth new-token" { + t.Fatalf("Authorization = %q", got) + } + writeJSON(t, w, validationResponse{ + ClientID: ClientID, + Login: "viewer", + UserID: "123", + Scopes: RequiredScopes, + ExpiresIn: 3600, + }) + case "/device": + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if got := r.Form.Get("scopes"); got != "channel_read chat:read user_blocks_edit user_blocks_read user_follows_edit user_read" { + t.Fatalf("scopes = %q", got) + } + writeJSON(t, w, deviceCodeResponse{ + DeviceCode: "device-code", + ExpiresIn: 60, + Interval: 1, + UserCode: "ABCD-EFGH", + VerificationURI: "https://www.twitch.tv/activate", + }) + case "/token": + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + values, err := url.ParseQuery(string(body)) + if err != nil { + t.Fatal(err) + } + if got := values.Get("device_code"); got != "device-code" { + t.Fatalf("device_code = %q", got) + } + writeJSON(t, w, tokenResponse{ + AccessToken: "new-token", + RefreshToken: "refresh-token", + Scope: RequiredScopes, + TokenType: "bearer", + ExpiresIn: 3600, + }) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + tokenFile := filepath.Join(t.TempDir(), "cookies.json") + if err := SaveState(tokenFile, &State{ + AccessToken: "wrong-scope-token", + TokenType: "bearer", + Scopes: []string{"user:read:email"}, + Login: "viewer", + UserID: "123", + ClientID: ClientID, + ExpiresIn: 3600, + DeviceID: "device", + }); err != nil { + t.Fatal(err) + } + + client := NewClient(server.Client()) + client.Endpoints = Endpoints{ + Device: server.URL + "/device", + Token: server.URL + "/token", + Validate: server.URL + "/validate", + } + client.Now = func() time.Time { return time.Unix(100, 0).UTC() } + client.Sleep = func(context.Context, time.Duration) error { return nil } + + var logs []string + state, err := client.EnsureAuth(context.Background(), tokenFile, func(line string) { + logs = append(logs, line) + }) + if err != nil { + t.Fatal(err) + } + if validateCalls != 2 { + t.Fatalf("validateCalls = %d", validateCalls) + } + if state.AccessToken != "new-token" { + t.Fatalf("AccessToken = %q", state.AccessToken) + } + if !containsLine(logs, "User code: ABCD-EFGH") { + t.Fatalf("logs = %#v", logs) + } +} + +func TestEnsureAuthReportsAccessDenied(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/device": + writeJSON(t, w, deviceCodeResponse{ + DeviceCode: "device-code", + ExpiresIn: 60, + Interval: 1, + UserCode: "ABCD-EFGH", + VerificationURI: "https://www.twitch.tv/activate", + }) + case "/token": + w.WriteHeader(http.StatusBadRequest) + writeJSON(t, w, oauthErrorResponse{Error: "access_denied", ErrorDescription: "user said no"}) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + client := NewClient(server.Client()) + client.Endpoints = Endpoints{ + Device: server.URL + "/device", + Token: server.URL + "/token", + Validate: server.URL + "/validate", + } + client.Now = func() time.Time { return time.Unix(100, 0).UTC() } + client.Sleep = func(context.Context, time.Duration) error { return nil } + + _, err := client.EnsureAuth(context.Background(), filepath.Join(t.TempDir(), "cookies.json"), nil) + if err == nil { + t.Fatal("EnsureAuth() error = nil, want error") + } + if !strings.Contains(err.Error(), "authorization denied by user") { + t.Fatalf("error = %q", err) + } +} + +func writeJSON(t *testing.T, w http.ResponseWriter, value any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(value); err != nil { + t.Fatal(err) + } +} + +func containsLine(lines []string, want string) bool { + for _, line := range lines { + if line == want { + return true + } + } + return false +} diff --git a/internal/auth/store.go b/internal/auth/store.go new file mode 100644 index 0000000..2862f1e --- /dev/null +++ b/internal/auth/store.go @@ -0,0 +1,117 @@ +// store.go defines the persisted auth bundle stored as cookies.json in the cwd. +// It handles loading, saving, and validating the auth state so cached login data +// can be reused across runs without redoing the Twitch device flow every time. +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +const authFileName = "cookies.json" + +// Cookie stores one persisted cookie-like name/value entry in the auth bundle. +type Cookie struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// State is the persisted cwd auth bundle for the app. +type State struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + TokenType string `json:"token_type"` + Scopes []string `json:"scopes"` + Login string `json:"login"` + UserID string `json:"user_id"` + ClientID string `json:"client_id"` + ExpiresIn int `json:"expires_in"` + ValidatedAt time.Time `json:"validated_at"` + DeviceID string `json:"device_id"` + Cookies []Cookie `json:"cookies,omitempty"` +} + +// DefaultPath returns the cwd auth bundle path. +func DefaultPath() string { + return authFileName +} + +// LoadState reads a persisted auth bundle from disk when one exists. +func LoadState(path string) (*State, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + + var state State + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + if err := state.validate(); err != nil { + return nil, fmt.Errorf("validate auth state %s: %w", path, err) + } + return &state, nil +} + +// SaveState writes the validated auth bundle to the configured cwd path. +func SaveState(path string, state *State) error { + if state == nil { + return fmt.Errorf("auth state is nil") + } + if err := state.validate(); err != nil { + return fmt.Errorf("validate auth state: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + + copy := *state + copy.Cookies = copy.persistedCookies() + + data, err := json.MarshalIndent(copy, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o600) +} + +// persistedCookies rebuilds the cookie-like entries that must travel with the saved auth bundle. +func (s *State) persistedCookies() []Cookie { + return []Cookie{ + {Name: "auth-token", Value: s.AccessToken}, + {Name: "login", Value: s.Login}, + {Name: "persistent", Value: s.UserID}, + } +} + +// validate rejects partial auth bundles so loading and saving use one canonical shape. +func (s *State) validate() error { + switch { + case s.AccessToken == "": + return fmt.Errorf("missing access_token") + case s.TokenType == "": + return fmt.Errorf("missing token_type") + case s.Login == "": + return fmt.Errorf("missing login") + case s.UserID == "": + return fmt.Errorf("missing user_id") + case s.ClientID == "": + return fmt.Errorf("missing client_id") + case len(s.Scopes) == 0: + return fmt.Errorf("missing scopes") + case s.ExpiresIn <= 0: + return fmt.Errorf("expires_in must be greater than zero") + case s.DeviceID == "": + return fmt.Errorf("missing device_id") + default: + return nil + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 39f50f1..cb80bb7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,3 +1,6 @@ +// config.go loads and validates the cwd config.toml file for the new CLI. +// It currently owns streamer list parsing, normalization, and the default +// config path behavior used by application startup. package config import ( @@ -44,6 +47,7 @@ func Load(path string) (Config, error) { return cfg, nil } +// normalizeStreamers deduplicates streamer names after applying input cleanup rules. func normalizeStreamers(streamers []string) []string { seen := make(map[string]struct{}, len(streamers)) normalized := make([]string, 0, len(streamers)) @@ -63,6 +67,7 @@ func normalizeStreamers(streamers []string) []string { return normalized } +// normalizeStreamer trims formatting noise and canonicalizes a single streamer name. func normalizeStreamer(streamer string) string { streamer = strings.TrimSpace(streamer) streamer = strings.TrimPrefix(streamer, "#") diff --git a/internal/tui/model.go b/internal/tui/model.go index 0f9a142..195a38b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1,28 +1,106 @@ +// model.go contains the Bubble Tea state machine for the current terminal UI. +// It switches between the Twitch login log view and the streamer list view, +// and applies auth progress messages emitted by the app layer. package tui import ( + "errors" "fmt" "strings" tea "github.com/charmbracelet/bubbletea" + + "parasocial/internal/auth" ) -// Model is the initial terminal UI for parasocial. +type viewMode int + +const ( + authView viewMode = iota + streamerView +) + +type StartAuthFunc func(chan<- AuthUpdate) + +// AuthUpdate carries one incremental auth log line or completion result into the TUI. +type AuthUpdate struct { + Line string + State *auth.State + Err error + Done bool +} + +// Options configures the initial streamer list, auth state, and auth starter hook. +type Options struct { + Streamers []string + AuthState *auth.State + StartAuth StartAuthFunc +} + +// authStartedMsg hands the model the channel that will stream auth updates. +type authStartedMsg struct { + Updates <-chan AuthUpdate +} + +// Model is the terminal UI for auth and streamer display. type Model struct { - streamers []string + streamers []string + authState *auth.State + startAuth StartAuthFunc + authUpdates <-chan AuthUpdate + authLogs []string + authErr error + mode viewMode } -// New returns a Bubble Tea model that displays configured streamers. -func New(streamers []string) Model { - return Model{streamers: append([]string(nil), streamers...)} +// New returns a Bubble Tea model for auth and streamer display. +func New(options Options) Model { + model := Model{ + streamers: append([]string(nil), options.Streamers...), + authState: options.AuthState, + startAuth: options.StartAuth, + authLogs: []string{}, + mode: authView, + } + if options.AuthState != nil { + model.mode = streamerView + } + return model } +// Init kicks off authentication only when the UI starts in the login state. func (m Model) Init() tea.Cmd { + if m.mode == authView { + return startAuthSession(m.startAuth) + } return nil } +// Update applies auth progress events and handles the global quit keys. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case authStartedMsg: + m.authUpdates = msg.Updates + return m, waitForAuthUpdate(msg.Updates) + case AuthUpdate: + if msg.Line != "" { + m.authLogs = append(m.authLogs, msg.Line) + } + if msg.Done { + if msg.Err != nil { + m.authErr = msg.Err + return m, nil + } + if msg.State != nil { + m.authState = msg.State + m.authErr = nil + m.mode = streamerView + } + return m, nil + } + if m.authUpdates != nil { + return m, waitForAuthUpdate(m.authUpdates) + } case tea.KeyMsg: switch msg.String() { case "ctrl+c", "esc", "q": @@ -33,14 +111,56 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +// View renders either the login log screen or the authenticated streamer list. func (m Model) View() string { var builder strings.Builder - builder.WriteString("Streamers\n") + if m.mode == authView { + builder.WriteString("Twitch Login\n") + if len(m.authLogs) == 0 { + builder.WriteString("Starting authentication...\n") + } else { + for _, line := range m.authLogs { + builder.WriteString(line) + builder.WriteByte('\n') + } + } + if m.authErr != nil { + builder.WriteString("\nLogin did not complete. Press q to quit.\n") + } + builder.WriteString("\n") + return builder.String() + } + if m.authState != nil && m.authState.Login != "" { + fmt.Fprintf(&builder, "Logged in as %s\n\n", m.authState.Login) + } + builder.WriteString("Streamers\n") for i, streamer := range m.streamers { fmt.Fprintf(&builder, "%d. %s\n", i+1, streamer) } - builder.WriteString("\n") return builder.String() } + +// startAuthSession starts the background auth worker and returns its update channel to Bubble Tea. +func startAuthSession(start StartAuthFunc) tea.Cmd { + return func() tea.Msg { + if start == nil { + return AuthUpdate{Err: errors.New("auth start function is nil"), Done: true} + } + updates := make(chan AuthUpdate, 32) + start(updates) + return authStartedMsg{Updates: updates} + } +} + +// waitForAuthUpdate blocks until the next auth update is available from the worker. +func waitForAuthUpdate(updates <-chan AuthUpdate) tea.Cmd { + return func() tea.Msg { + update, ok := <-updates + if !ok { + return AuthUpdate{Err: errors.New("auth updates ended unexpectedly"), Done: true} + } + return update + } +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 85fb719..19b80c7 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -1,9 +1,16 @@ package tui -import "testing" +import ( + "testing" + + "parasocial/internal/auth" +) func TestViewDisplaysStreamers(t *testing.T) { - model := New([]string{"alpha", "beta"}) + model := New(Options{ + Streamers: []string{"alpha", "beta"}, + }) + model.mode = streamerView got := model.View() want := "Streamers\n1. alpha\n2. beta\n\n" @@ -11,3 +18,35 @@ func TestViewDisplaysStreamers(t *testing.T) { t.Fatalf("View() = %q, want %q", got, want) } } + +func TestAuthUpdateAppendsLogLine(t *testing.T) { + model := New(Options{Streamers: []string{"alpha"}}) + + updated, cmd := model.Update(AuthUpdate{Line: "Open page: https://www.twitch.tv/activate"}) + if cmd != nil { + t.Fatal("expected nil cmd after auth update without channel") + } + + next := updated.(Model) + got := next.View() + want := "Twitch Login\nOpen page: https://www.twitch.tv/activate\n\n" + if got != want { + t.Fatalf("View() = %q, want %q", got, want) + } +} + +func TestAuthSuccessSwitchesToStreamerView(t *testing.T) { + model := New(Options{Streamers: []string{"alpha"}}) + + updated, _ := model.Update(AuthUpdate{ + State: &auth.State{Login: "viewer"}, + Done: true, + }) + + next := updated.(Model) + got := next.View() + want := "Logged in as viewer\n\nStreamers\n1. alpha\n\n" + if got != want { + t.Fatalf("View() = %q, want %q", got, want) + } +}