package auth import ( "encoding/json" "github.com/nicklaw5/helix/v2" "context" "log" "net/http" "os" "os/exec" "path/filepath" "runtime" "time" ) // TODO; Handle scopes properly type Auth struct { HaveAuth bool client *helix.Client context *context.Context } var authTimeout time.Duration = time.Second * 60 func New(ctx *context.Context, c *helix.Client, s []string) (*Auth, error) { var auth *Auth = &Auth{context: ctx, client: c} // See about getting a token c.SetRedirectURI("http://localhost:3000") // See if there are any stored credentials. creds, err := getUserToken() if err != nil { // Caller needs to get followup to get credentials via StartAuth() return auth, nil } // Validate token in case what we've got on file is still good. validate := func() error { valid, resp, err := c.ValidateToken(creds.AccessToken) if valid { return nil } if err != nil { log.Printf("Validation error: %v\n", err) } _ = resp return err } // Attempt to refresh the token if possible. refresh := func() (*helix.AccessCredentials, error) { resp, err := c.RefreshUserAccessToken(creds.RefreshToken) if err != nil { log.Printf("Error refreshing token: %v\n", err) return nil, err } return &resp.Data, nil } // FIXME: This should return an error. if err := validate(); err != nil { return auth, err } refreshedToken, err := refresh() if err != nil { return auth, err } // XXX: Maybe wrap this in a function. c.SetUserAccessToken(refreshedToken.AccessToken) c.SetRefreshToken(refreshedToken.RefreshToken) storeUserToken(refreshedToken) auth.HaveAuth = true return auth, nil } // Returns path to a token file. Creates it if it does nt exist. func tokenFilePath() (string, error) { storePath, err := os.UserConfigDir() if err != nil || storePath == "" { // Fallback to HOME-based path if UserConfigDir fails or returns empty home := os.Getenv("HOME") if home == "" { log.Printf("UserConfigDir failed and HOME not set: %v", err) return "", os.ErrNotExist } // Use platform-appropriate config directory if runtime.GOOS == "darwin" { storePath = filepath.Join(home, "Library", "Application Support") } else { storePath = filepath.Join(home, ".config") } } storePath = filepath.Join(storePath, "tremble") err = os.MkdirAll(storePath, 0755) if err != nil { log.Printf("Error creating %s: %v\n", storePath, err) return "", err } return filepath.Join(storePath, "token.json"), nil } // Only reporting here for now since it's probably not worth trying to recover // from an error here. func storeUserToken(token *helix.AccessCredentials) { tf, err := tokenFilePath() if err != nil { return } b, err := json.Marshal(token) if err != nil { log.Printf("Couldn't marshal %+v: %v\n", token, err) return } err = os.WriteFile(tf, b, 0644) if err != nil { log.Printf("Error writing token: %v\n", err) return } } func getUserToken() (creds helix.AccessCredentials, err error) { tf, err := tokenFilePath() if err != nil { return } b, err := os.ReadFile(tf) if err != nil { if err != os.ErrNotExist { log.Printf("Couldn't read token file: %v\n", err) } return } if err = json.Unmarshal(b, &creds); err != nil { log.Printf("Error unmarshalling token: %v\n", err) return } return } func (a *Auth) updateCode(code string) error { resp, err := a.client.RequestUserAccessToken(code) if err != nil { log.Printf("error requesting user access token: %v\n", err) return err } log.Printf("%+v\n", resp.Data) a.client.SetUserAccessToken(resp.Data.AccessToken) storeUserToken(&resp.Data) return nil } func (a *Auth) StartAuth(userId string, s []string) (chan int, error) { // Need to get authorization authUrl := a.client.GetAuthorizationURL(&helix.AuthorizationURLParams{ ResponseType: "code", Scopes: s, State: userId, ForceVerify: false, }) osType := runtime.GOOS // Open browser and do the auth. Let the auth listener take care of the // rest. switch osType { case "linux": cmd := exec.Command("xdg-open", authUrl) if err := cmd.Run(); err != nil { return nil, err } case "windows": cmd := exec.Command("rundll32", "url.dll,FileProtocolHandler", authUrl) if err := cmd.Run(); err != nil { return nil, err } case "darwin": cmd := exec.Command("open", authUrl) if err := cmd.Run(); err != nil { return nil, err } default: log.Printf("Please open a browser and go to: %s\n", authUrl) } notify := make(chan int) a.waitForAuth(notify) return notify, nil } func (a *Auth) waitForAuth(notify chan int) { authCtx, cancel := context.WithTimeout(*a.context, authTimeout) srv := &http.Server{Addr: ":3000"} var code string = "" http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { select { case <-authCtx.Done(): // Authenticated or timed out return default: // Just continue } code = r.URL.Query().Get("code") if code != "" { cancel() err := srv.Shutdown(authCtx) if err != nil { log.Fatalf("Error shutting down auth server: %v\n", err) } a.updateCode(code) notify <- 1 } }) go func() { defer srv.Close() err := http.ListenAndServe(":3000", nil) if err != nil { log.Fatalf("Error running auth server: %v\n", err) } }() }