From 42eda8496eacd670e8dbe2aca6b976e5859b433e Mon Sep 17 00:00:00 2001 From: Fl_GUI Date: Sat, 7 Dec 2024 15:04:44 +0100 Subject: [PATCH 1/1] initial commit --- device/authorize.go | 135 ++++++++++++++++++++++++++++++++++++++ device/errors.go | 19 ++++++ device/errors_test.go | 24 +++++++ device/return.go | 17 +++++ go.mod | 3 + implicit/closehandler.go | 10 +++ implicit/config.go | 8 +++ implicit/handler.go | 34 ++++++++++ implicit/implicit.go | 60 +++++++++++++++++ implicit/implicit_test.go | 36 ++++++++++ implicit/response.go | 6 ++ x/device/main.go | 55 ++++++++++++++++ x/implicit/main.go | 22 +++++++ 13 files changed, 429 insertions(+) create mode 100644 device/authorize.go create mode 100644 device/errors.go create mode 100644 device/errors_test.go create mode 100644 device/return.go create mode 100644 go.mod create mode 100644 implicit/closehandler.go create mode 100644 implicit/config.go create mode 100644 implicit/handler.go create mode 100644 implicit/implicit.go create mode 100644 implicit/implicit_test.go create mode 100644 implicit/response.go create mode 100644 x/device/main.go create mode 100644 x/implicit/main.go diff --git a/device/authorize.go b/device/authorize.go new file mode 100644 index 0000000..a1ab86b --- /dev/null +++ b/device/authorize.go @@ -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 index 0000000..8e96d40 --- /dev/null +++ b/device/errors.go @@ -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 index 0000000..c1b52e2 --- /dev/null +++ b/device/errors_test.go @@ -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 index 0000000..4232621 --- /dev/null +++ b/device/return.go @@ -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 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 index 0000000..492d249 --- /dev/null +++ b/implicit/closehandler.go @@ -0,0 +1,10 @@ +package implicit + +import "net/http" + +type CloseHandler struct { +} + +func (*CloseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("hello")) +} diff --git a/implicit/config.go b/implicit/config.go new file mode 100644 index 0000000..7a1986b --- /dev/null +++ b/implicit/config.go @@ -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 index 0000000..b5b4a8d --- /dev/null +++ b/implicit/handler.go @@ -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 index 0000000..84bbd69 --- /dev/null +++ b/implicit/implicit.go @@ -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 index 0000000..8598875 --- /dev/null +++ b/implicit/implicit_test.go @@ -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 index 0000000..f7c1c9c --- /dev/null +++ b/implicit/response.go @@ -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 index 0000000..9f90d42 --- /dev/null +++ b/x/device/main.go @@ -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 index 0000000..8c4b5ee --- /dev/null +++ b/x/implicit/main.go @@ -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{}) +} -- 2.47.1