]> git.openfl.eu Git - twitch-auth.git/commitdiff
initial commit
authorFl_GUI <flor.guilini@hotmail.com>
Sat, 7 Dec 2024 14:04:44 +0000 (15:04 +0100)
committerFl_GUI <flor.guilini@hotmail.com>
Sat, 7 Dec 2024 14:04:44 +0000 (15:04 +0100)
13 files changed:
device/authorize.go [new file with mode: 0644]
device/errors.go [new file with mode: 0644]
device/errors_test.go [new file with mode: 0644]
device/return.go [new file with mode: 0644]
go.mod [new file with mode: 0644]
implicit/closehandler.go [new file with mode: 0644]
implicit/config.go [new file with mode: 0644]
implicit/handler.go [new file with mode: 0644]
implicit/implicit.go [new file with mode: 0644]
implicit/implicit_test.go [new file with mode: 0644]
implicit/response.go [new file with mode: 0644]
x/device/main.go [new file with mode: 0644]
x/implicit/main.go [new file with mode: 0644]

diff --git a/device/authorize.go b/device/authorize.go
new file mode 100644 (file)
index 0000000..a1ab86b
--- /dev/null
@@ -0,0 +1,135 @@
+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()))
+}
diff --git a/device/errors.go b/device/errors.go
new file mode 100644 (file)
index 0000000..8e96d40
--- /dev/null
@@ -0,0 +1,19 @@
+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
+}
diff --git a/device/errors_test.go b/device/errors_test.go
new file mode 100644 (file)
index 0000000..c1b52e2
--- /dev/null
@@ -0,0 +1,24 @@
+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")
+       }
+}
diff --git a/device/return.go b/device/return.go
new file mode 100644 (file)
index 0000000..4232621
--- /dev/null
@@ -0,0 +1,17 @@
+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"`
+}
diff --git a/go.mod b/go.mod
new file mode 100644 (file)
index 0000000..c8adb90
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module go.openfl.eu/twitch-auth
+
+go 1.23.1
diff --git a/implicit/closehandler.go b/implicit/closehandler.go
new file mode 100644 (file)
index 0000000..492d249
--- /dev/null
@@ -0,0 +1,10 @@
+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>"))
+}
diff --git a/implicit/config.go b/implicit/config.go
new file mode 100644 (file)
index 0000000..7a1986b
--- /dev/null
@@ -0,0 +1,8 @@
+package implicit
+
+type Config struct {
+       ClientId    string
+       Port        string
+       Scopes      []string
+       ForceVerify bool
+}
diff --git a/implicit/handler.go b/implicit/handler.go
new file mode 100644 (file)
index 0000000..b5b4a8d
--- /dev/null
@@ -0,0 +1,34 @@
+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}
+}
diff --git a/implicit/implicit.go b/implicit/implicit.go
new file mode 100644 (file)
index 0000000..84bbd69
--- /dev/null
@@ -0,0 +1,60 @@
+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())
+
+}
diff --git a/implicit/implicit_test.go b/implicit/implicit_test.go
new file mode 100644 (file)
index 0000000..8598875
--- /dev/null
@@ -0,0 +1,36 @@
+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)
+               }
+
+       }
+
+}
diff --git a/implicit/response.go b/implicit/response.go
new file mode 100644 (file)
index 0000000..f7c1c9c
--- /dev/null
@@ -0,0 +1,6 @@
+package implicit
+
+type AuthResponse struct {
+       AccessCode string
+       Err        error
+}
diff --git a/x/device/main.go b/x/device/main.go
new file mode 100644 (file)
index 0000000..9f90d42
--- /dev/null
@@ -0,0 +1,55 @@
+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)
+               }
+       }
+}
diff --git a/x/implicit/main.go b/x/implicit/main.go
new file mode 100644 (file)
index 0000000..8c4b5ee
--- /dev/null
@@ -0,0 +1,22 @@
+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{})
+}