Files
parasocial/internal/auth/auth.go
2026-07-18 00:17:39 +00:00

475 lines
14 KiB
Go

// 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"
"slices"
"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"`
}
// 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"`
}
// 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.Login = validation.Login
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.Login = validation.Login
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.Login = validation.Login
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
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Language", "en-US")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Client-Id", ClientID)
req.Header.Set("Origin", tvOrigin)
req.Header.Set("Pragma", "no-cache")
req.Header.Set("Referer", tvReferer)
req.Header.Set("User-Agent", TVUserAgent)
req.Header.Set("X-Device-Id", deviceID)
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, ", ")
}
// hasAllScopes reports whether the token satisfies the full required scope set.
func hasAllScopes(scopes []string, required []string) bool {
for _, scope := range required {
if !slices.Contains(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
}
}
// 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...))
}