feat: add twitch tv login flow and auth state ui
This commit is contained in:
@@ -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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,6 +14,7 @@ import (
|
|||||||
"parasocial/internal/app"
|
"parasocial/internal/app"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// main sets up process cancellation and reports fatal startup errors to stderr.
|
||||||
func main() {
|
func main() {
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|||||||
+43
-1
@@ -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
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"parasocial/internal/auth"
|
||||||
"parasocial/internal/config"
|
"parasocial/internal/config"
|
||||||
"parasocial/internal/tui"
|
"parasocial/internal/tui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Run loads the application configuration and starts the terminal UI.
|
// Run loads the application configuration and starts the terminal UI.
|
||||||
func Run(ctx context.Context) error {
|
func Run(ctx context.Context) error {
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
cfg, err := config.LoadDefault()
|
cfg, err := config.LoadDefault()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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(
|
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.WithContext(ctx),
|
||||||
tea.WithInput(os.Stdin),
|
tea.WithInput(os.Stdin),
|
||||||
tea.WithOutput(os.Stdout),
|
tea.WithOutput(os.Stdout),
|
||||||
|
|||||||
@@ -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...))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -44,6 +47,7 @@ func Load(path string) (Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeStreamers deduplicates streamer names after applying input cleanup rules.
|
||||||
func normalizeStreamers(streamers []string) []string {
|
func normalizeStreamers(streamers []string) []string {
|
||||||
seen := make(map[string]struct{}, len(streamers))
|
seen := make(map[string]struct{}, len(streamers))
|
||||||
normalized := make([]string, 0, len(streamers))
|
normalized := make([]string, 0, len(streamers))
|
||||||
@@ -63,6 +67,7 @@ func normalizeStreamers(streamers []string) []string {
|
|||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeStreamer trims formatting noise and canonicalizes a single streamer name.
|
||||||
func normalizeStreamer(streamer string) string {
|
func normalizeStreamer(streamer string) string {
|
||||||
streamer = strings.TrimSpace(streamer)
|
streamer = strings.TrimSpace(streamer)
|
||||||
streamer = strings.TrimPrefix(streamer, "#")
|
streamer = strings.TrimPrefix(streamer, "#")
|
||||||
|
|||||||
+126
-6
@@ -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
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
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 {
|
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.
|
// New returns a Bubble Tea model for auth and streamer display.
|
||||||
func New(streamers []string) Model {
|
func New(options Options) Model {
|
||||||
return Model{streamers: append([]string(nil), streamers...)}
|
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 {
|
func (m Model) Init() tea.Cmd {
|
||||||
|
if m.mode == authView {
|
||||||
|
return startAuthSession(m.startAuth)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update applies auth progress events and handles the global quit keys.
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
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:
|
case tea.KeyMsg:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c", "esc", "q":
|
case "ctrl+c", "esc", "q":
|
||||||
@@ -33,14 +111,56 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// View renders either the login log screen or the authenticated streamer list.
|
||||||
func (m Model) View() string {
|
func (m Model) View() string {
|
||||||
var builder strings.Builder
|
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 {
|
for i, streamer := range m.streamers {
|
||||||
fmt.Fprintf(&builder, "%d. %s\n", i+1, streamer)
|
fmt.Fprintf(&builder, "%d. %s\n", i+1, streamer)
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.WriteString("\n")
|
builder.WriteString("\n")
|
||||||
return builder.String()
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"parasocial/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
func TestViewDisplaysStreamers(t *testing.T) {
|
func TestViewDisplaysStreamers(t *testing.T) {
|
||||||
model := New([]string{"alpha", "beta"})
|
model := New(Options{
|
||||||
|
Streamers: []string{"alpha", "beta"},
|
||||||
|
})
|
||||||
|
model.mode = streamerView
|
||||||
|
|
||||||
got := model.View()
|
got := model.View()
|
||||||
want := "Streamers\n1. alpha\n2. beta\n\n"
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user