--- /dev/null
+package device
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+type Config struct {
+ ClientId string
+ Scopes []string
+}
+
+type AuthResponse struct {
+ AccessCode string
+ Err error
+}
+
+func Authenticate(c *Config) <-chan AuthResponse {
+ responses := make(chan AuthResponse, 1)
+ go authenticate(c, responses)
+ return responses
+}
+
+func authenticate(c *Config, tokens chan<- AuthResponse) {
+ deviceInfo := doDeviceRequest(c, tokens)
+ if deviceInfo == nil {
+ return
+ }
+
+ tokens <- AuthResponse{"", AuthorizationPendingError{deviceInfo.VerificationUri}}
+
+ tokenInfo := doTokenRequest(c, tokens, deviceInfo)
+ if tokenInfo == nil {
+ return
+ }
+
+ tokens <- AuthResponse{tokenInfo.AccessToken, nil}
+}
+
+func doDeviceRequest(c *Config, tokens chan<- AuthResponse) *deviceResponse {
+ // prepare device request
+ makeRequest := func() *http.Request {
+ request, err := makeDeviceRequest(c)
+ if err != nil {
+ tokens <- AuthResponse{"", err}
+ return nil
+ }
+ return request
+ }
+
+ resp := repeatRequest(makeRequest, func(inner string) error {
+ return fmt.Errorf("could not send device authorization request: %s", string(inner))
+ }, tokens)
+ defer resp.Body.Close()
+
+ // parse device response
+ var deviceResp deviceResponse
+ err := json.NewDecoder(resp.Body).Decode(&deviceResp)
+ if err != nil {
+ tokens <- AuthResponse{"", err}
+ return nil
+ }
+ return &deviceResp
+}
+
+func doTokenRequest(c *Config, tokens chan<- AuthResponse, deviceResp *deviceResponse) *tokenResponse {
+ // send token request
+ makeRequest := func() *http.Request {
+ request, err := makeTokenRequest(c, deviceResp)
+ if err != nil {
+ tokens <- AuthResponse{"", err}
+ return nil
+ }
+ return request
+ }
+
+ resp := repeatRequest(makeRequest, func(inner string) error {
+ return fmt.Errorf("could not send access token request: %s", string(inner))
+ }, tokens)
+ defer resp.Body.Close()
+
+ // parse token response
+ var tokenResponse tokenResponse
+ err := json.NewDecoder(resp.Body).Decode(&tokenResponse)
+ if err != nil {
+ tokens <- AuthResponse{"", err}
+ return nil
+ }
+ return &tokenResponse
+}
+
+func repeatRequest(req func() *http.Request, errFn func(string) error, tokens chan<- AuthResponse) *http.Response {
+ resp, err := http.DefaultClient.Do(req())
+ for err != nil || resp.StatusCode != http.StatusOK {
+ if err == nil {
+ content, err := ioutil.ReadAll(resp.Body)
+ resp.Body.Close()
+ if err == nil {
+ err = errFn(string(content))
+ }
+ tokens <- AuthResponse{"", err}
+ } else {
+ tokens <- AuthResponse{"", err}
+ }
+ <-time.NewTimer(time.Second * 5).C
+ resp, err = http.DefaultClient.Do(req())
+ }
+ return resp
+}
+
+func makeDeviceRequest(c *Config) (*http.Request, error) {
+ vals := url.Values{}
+ vals.Add("client_id", c.ClientId)
+ joinedScopes := strings.Join(c.Scopes, " ")
+ vals.Add("scopes", joinedScopes)
+
+ return http.NewRequest(http.MethodPost, "https://id.twitch.tv/oauth2/device", strings.NewReader(vals.Encode()))
+}
+
+func makeTokenRequest(c *Config, deviceResponse *deviceResponse) (*http.Request, error) {
+ vals := url.Values{}
+ vals.Add("client_id", c.ClientId)
+ for _, scope := range c.Scopes {
+ vals.Add("scopes", scope)
+ }
+ vals.Add("device_code", deviceResponse.DeviceCode)
+ vals.Add("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
+
+ return http.NewRequest(http.MethodPost, "https://id.twitch.tv/oauth2/token", strings.NewReader(vals.Encode()))
+}
--- /dev/null
+package device
+
+import "fmt"
+
+type AuthorizationPendingError struct {
+ Url string
+}
+
+func (e AuthorizationPendingError) Error() string {
+ return fmt.Sprintf("authorization is pending on %s", e.Url)
+}
+
+func (e AuthorizationPendingError) Is(target error) bool {
+ err, ok := target.(AuthorizationPendingError)
+ if !ok {
+ return false
+ }
+ return err.Url == e.Url
+}
--- /dev/null
+package device
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestErrorEquality(t *testing.T) {
+ var err1 error = AuthorizationPendingError{"foo"}
+ var err2 error = AuthorizationPendingError{"foo"}
+ var err3 error = AuthorizationPendingError{"bar"}
+
+ if !errors.Is(err1, err1) {
+ t.Fatalf("expected the same error to equal itself")
+ }
+
+ if !errors.Is(err1, err2) {
+ t.Fatalf("expected different errors with the same url to be equal")
+ }
+
+ if errors.Is(err1, err3) {
+ t.Fatalf("expected different errors to not be equal")
+ }
+}
--- /dev/null
+package device
+
+type deviceResponse struct {
+ DeviceCode string `json:"device_code"`
+ Expires int `json:"expires_in"`
+ Interval int `json:"interval"`
+ UserCode string `json:"user_code"`
+ VerificationUri string `json:"verification_uri"`
+}
+
+type tokenResponse struct {
+ AccessToken string `json:"access_token"`
+ Expires int `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ Scopes []string `json:"scope"`
+ TokenType string `json:"token_type"`
+}
--- /dev/null
+module go.openfl.eu/twitch-auth
+
+go 1.23.1
--- /dev/null
+package implicit
+
+import "net/http"
+
+type CloseHandler struct {
+}
+
+func (*CloseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte("<html><head></head><body>hello</body></html>"))
+}
--- /dev/null
+package implicit
+
+type Config struct {
+ ClientId string
+ Port string
+ Scopes []string
+ ForceVerify bool
+}
--- /dev/null
+package implicit
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+)
+
+type Handler struct {
+ response chan AuthResponse
+ server **http.Server
+ passThrough http.Handler
+}
+
+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ h.response <- extractFromFragment(r.URL.Fragment)
+ h.passThrough.ServeHTTP(w, r)
+ close(h.response)
+ (*h.server).Close()
+}
+
+func extractFromFragment(fragment string) AuthResponse {
+ q, err := url.ParseQuery(fragment)
+ if err != nil {
+ return AuthResponse{"", err}
+ }
+
+ if q.Has("error") {
+ return AuthResponse{"", fmt.Errorf("%s: %s", q.Get("error"), q.Get("error_description"))}
+ }
+
+ accessToken := q.Get("access_token")
+ return AuthResponse{accessToken, nil}
+}
--- /dev/null
+package implicit
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+func Authenticate(c *Config) (string, <-chan AuthResponse) {
+ return AuthenticateWithHandler(c, &CloseHandler{})
+}
+
+// There will be none or one AuthResponse on the channel.
+// When there is one, the following actions are taken:
+// 1) The response is written to the channel
+// 2) The handler serves a request
+// 3) The channel is closed
+// 4) Internal resources are cleaned up
+func AuthenticateWithHandler(c *Config, handler http.Handler) (string, <-chan AuthResponse) {
+ responses := make(chan AuthResponse, 1)
+
+ go func() {
+ defer func() {
+ if r := recover(); r != nil {
+ fmt.Println("Recovering in imlicit.AuthenticateWithHandler:", r)
+ }
+ }()
+
+ var s *http.Server
+ s = &http.Server{Addr: c.Port, Handler: &Handler{responses, &s, handler}}
+ fmt.Println("listening on:", s.Addr)
+ err := s.ListenAndServe()
+ if !errors.Is(err, http.ErrServerClosed) {
+ responses <- AuthResponse{"", err}
+ }
+ }()
+
+ authUrl := makeUrl(c)
+
+ return authUrl, responses
+}
+
+func makeUrl(c *Config) string {
+ redirectUrl := fmt.Sprintf("http://localhost%s", c.Port)
+
+ query := make(url.Values)
+ query.Add("client_id", c.ClientId)
+ if c.ForceVerify {
+ query.Add("force_verify", "true")
+ }
+ query.Add("redirect_uri", redirectUrl)
+ query.Add("response_type", "token")
+ query.Add("scope", strings.Join(c.Scopes, " "))
+ query.Add("state", "")
+
+ return fmt.Sprintf("https://id.twitch.tv/oauth2/authorize?%s", query.Encode())
+
+}
--- /dev/null
+package implicit
+
+import (
+ "net/url"
+ "testing"
+)
+
+func TestMakeUrl(t *testing.T) {
+ out := makeUrl(&Config{
+ "hof5gwx0su6owfnys0yan9c87zr6t",
+ ":1337",
+ []string{"channel:manage:polls", "channel:read:polls"}, true})
+ expect := "https://id.twitch.tv/oauth2/authorize?response_type=token&client_id=hof5gwx0su6owfnys0yan9c87zr6t&redirect_uri=http://localhost:1337&scope=channel%3Amanage%3Apolls+channel%3Aread%3Apolls"
+
+ parsedUrl, err := url.Parse(out)
+ if err != nil {
+ t.Fatalf("parse(%s) gave error %v", out, err)
+ }
+ parsedExpected, err := url.Parse(expect)
+ if err != nil {
+ t.Fatalf("parse(%s) gave error %v", expect, err)
+ }
+ if parsedUrl.Hostname() != parsedExpected.Hostname() {
+ t.Errorf("got hostname %s but expected %s", parsedUrl.Hostname(), parsedExpected.Hostname())
+ }
+
+ parsedQuery := parsedUrl.Query()
+ for k, expectedValue := range parsedExpected.Query() {
+ value := parsedQuery[k]
+ if len(expectedValue) != len(value) || value[0] != expectedValue[0] {
+ t.Errorf("different query value for %s: %#v instead of %#v", k, value, expectedValue)
+ }
+
+ }
+
+}
--- /dev/null
+package implicit
+
+type AuthResponse struct {
+ AccessCode string
+ Err error
+}
--- /dev/null
+package main
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+
+ "go.openfl.eu/twitch-auth/device"
+)
+
+var input *bufio.Scanner
+
+func prompt(p string) string {
+ fmt.Fprintln(os.Stdout, p)
+ if !input.Scan() {
+ return ""
+ }
+ resp := input.Text()
+ fmt.Fprintf(os.Stdout, "\n")
+ return resp
+}
+
+func promptMany(p string) []string {
+ fmt.Fprintln(os.Stdout, p)
+ resp := make([]string, 1)
+
+ for input.Scan() && input.Text() != "" {
+ resp = append(resp, input.Text())
+ }
+ fmt.Fprintf(os.Stdout, "\n")
+ return resp
+}
+
+func main() {
+ input = bufio.NewScanner(os.Stdin)
+ input.Split(bufio.ScanLines)
+
+ /*c := &device.Config{
+ prompt("Client id please"),
+ promptMany("Scopes please"),
+ }
+ */
+ c := &device.Config{
+ "9mcopb33ssgli53u6cor8ou2pvyb0g",
+ []string{},
+ }
+
+ for token := range device.Authenticate(c) {
+ if token.Err != nil {
+ fmt.Fprintf(os.Stderr, "%s\n", token.Err)
+ } else {
+ fmt.Fprintf(os.Stdout, "%s\n", token.AccessCode)
+ }
+ }
+}
--- /dev/null
+package main
+
+import (
+ "fmt"
+
+ "go.openfl.eu/twitch-auth/implicit"
+)
+
+func main() {
+ c := &implicit.Config{
+ "po7tssvlu80ow6c9ewdibq4jdgyzzb",
+ ":1337",
+ []string{},
+ false,
+ }
+ url, response := implicit.Authenticate(c)
+ fmt.Println(url)
+ for r := range response {
+ fmt.Printf("%#v\n", r)
+ }
+ <-make(chan struct{})
+}