feat: add twitch tv login flow and auth state ui
This commit is contained in:
@@ -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...))
|
||||
}
|
||||
Reference in New Issue
Block a user