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