feat: add twitch tv login flow and auth state ui

This commit is contained in:
2026-04-28 21:56:43 +02:00
parent f738833aa8
commit 37ece958f3
8 changed files with 1187 additions and 10 deletions
+4
View File
@@ -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()
+43 -1
View File
@@ -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),
+552
View File
@@ -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...))
}
+298
View File
@@ -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
}
+117
View File
@@ -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
}
}
+5
View File
@@ -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, "#")
+127 -7
View File
@@ -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
}
}
+41 -2
View File
@@ -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)
}
}