Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

v2 authui #346

Merged
merged 2 commits into from
Nov 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions auth/auth_ui/auth_ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ const (
LInteractive LoginType = iota
// LHeadless is the email/password login type.
LHeadless
// LGoogleAuth is the google auth option
LGoogleAuth
// LUserBrowser is the google auth option
LUserBrowser
// LCancel should be returned if the user cancels the login intent.
LCancel
)
Expand Down
2 changes: 1 addition & 1 deletion auth/auth_ui/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func (cl *CLI) RequestLoginType(w io.Writer) (LoginType, error) {
value LoginType
}{
{"Email", LHeadless},
{"Google", LGoogleAuth},
{"Google", LUserBrowser},
{"Apple", LInteractive},
{"Login with Single-Sign-On (SSO)", LInteractive},
{"Other/Manual", LInteractive},
Expand Down
185 changes: 148 additions & 37 deletions auth/auth_ui/huh.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,25 @@ import (
"strconv"

"github.com/charmbracelet/huh"
"github.com/rusq/slackauth"
)

// Huh is the Auth UI that uses the huh library to provide a terminal UI.
type Huh struct {
theme huh.Theme
}
type Huh struct{}

func (*Huh) RequestWorkspace(w io.Writer) (string, error) {
func (h *Huh) RequestWorkspace(w io.Writer) (string, error) {
var workspace string
err := huh.NewInput().
Title("Enter Slack workspace name").
Value(&workspace).
Validate(valRequired).
Description("The workspace name is the part of the URL that comes before `.slack.com' in\nhttps://<workspace>.slack.com/. Both workspace name or URL are acceptable.").
Run()
err := huh.NewForm(huh.NewGroup(
huh.NewInput().
Title("Enter Slack workspace name").
Value(&workspace).
Validate(valWorkspace).
Description("The workspace name is the part of the URL that comes before `.slack.com' in\nhttps://<workspace>.slack.com/. Both workspace name or URL are acceptable."),
)).Run()
if err != nil {
return "", err
}
return Sanitize(workspace)
return workspace, nil
}

func (*Huh) Stop() {}
Expand All @@ -49,36 +49,147 @@ func (*Huh) RequestCreds(w io.Writer, workspace string) (email string, passwd st
return
}

func (*Huh) RequestLoginType(w io.Writer) (LoginType, error) {
var loginType LoginType
err := huh.NewSelect[LoginType]().Title("Select login type").
Options(
huh.NewOption("Email (manual)", LInteractive),
huh.NewOption("Email (automatic)", LHeadless),
huh.NewOption("Google", LGoogleAuth),
huh.NewOption("Apple", LInteractive),
huh.NewOption("Login with Single-Sign-On (SSO)", LInteractive),
huh.NewOption("Other/Manual", LInteractive),
huh.NewOption("------", LoginType(-1)),
huh.NewOption("Cancel", LCancel),
).
Value(&loginType).
type methodMenuItem struct {
MenuItem string
ShortDesc string
Type LoginType
}

func (m methodMenuItem) String() string {
return fmt.Sprintf("%-20s - %s", m.MenuItem, m.ShortDesc)
}

var methods = []methodMenuItem{
{
"Interactive",
"Works with most authentication schemes, except Google.",
LInteractive,
},
{
"Automatic",
"Only suitable for email/password auth",
LHeadless,
},
{
"User Browser",
"Loads your user profile, works with Google Auth",
LUserBrowser,
},
}

type LoginOpts struct {
Workspace string
Type LoginType
BrowserPath string
}

func valWorkspace(s string) error {
if err := valRequired(s); err != nil {
return err
}
_, err := Sanitize(s)
return err
}

func (*Huh) RequestLoginType(w io.Writer, workspace string) (LoginOpts, error) {
var ret = LoginOpts{
Workspace: workspace,
Type: LInteractive,
BrowserPath: "",
}

var opts = make([]huh.Option[LoginType], 0, len(methods))
for _, m := range methods {
opts = append(opts, huh.NewOption(m.String(), m.Type))
}
opts = append(opts,
huh.NewOption("------", LoginType(-1)),
huh.NewOption("Cancel", LCancel),
)
var fields []huh.Field
if workspace == "" {
fields = append(fields, huh.NewInput().
Title("Enter Slack workspace name").
Value(&ret.Workspace).
Validate(valWorkspace).
Description("The workspace name is the part of the URL that comes before `.slack.com' in\nhttps://<workspace>.slack.com/. Both workspace name or URL are acceptable."),
)
}

fields = append(fields, huh.NewSelect[LoginType]().
TitleFunc(func() string {
return fmt.Sprintf("Select login type for [%s]", ret.Workspace)
}, &ret.Workspace).
Options(opts...).
Value(&ret.Type).
Validate(valSepEaster()).
Description("If you are not sure, select 'Other'.").
Run()
return loginType, err
DescriptionFunc(func() string {
switch ret.Type {
case LInteractive:
return "Clean browser will open on a Slack Login page."
case LHeadless:
return "You will be prompted to enter your email and password, login is automated."
case LUserBrowser:
return "System browser will open on a Slack Login page."
case LCancel:
return "Cancel the login process."
default:
return ""
}
}, &ret.Type))
if err := huh.NewForm(huh.NewGroup(fields...)).Run(); err != nil {
return ret, err
}
if ret.Type == LUserBrowser {
path, err := chooseBrowser()
if err != nil {
return ret, err
}
ret.BrowserPath = path
return ret, err
}
return ret, nil
}

func chooseBrowser() (string, error) {
browsers, err := slackauth.ListBrowsers()
if err != nil {
return "", err
}
var opts = make([]huh.Option[int], 0, len(browsers))
for i, b := range browsers {
opts = append(opts, huh.NewOption(b.Name, i))
}

var selection int
err = huh.NewForm(huh.NewGroup(
huh.NewSelect[int]().
Title("Detected browsers on your system").
Options(opts...).
Value(&selection).
DescriptionFunc(func() string {
return browsers[selection].Path
}, &selection),
)).Run()
if err != nil {
return "", err
}
return browsers[selection].Path, nil
}

// ConfirmationCode asks the user to input the confirmation code, does some
// validation on it and returns it as an int.
func (*Huh) ConfirmationCode(email string) (int, error) {
var strCode string
q := huh.NewInput().
CharLimit(6).
Title(fmt.Sprintf("Enter confirmation code sent to %s", email)).
Description("Slack did not recognise the browser, and sent a confirmation code. Please enter the confirmation code below.").
Value(&strCode).
Validate(valSixDigits)
q := huh.NewForm(huh.NewGroup(
huh.NewInput().
CharLimit(6).
Placeholder("00000").
Title(fmt.Sprintf("Enter confirmation code sent to %s", email)).
Description("Slack did not recognise the browser, and sent a confirmation code. Please enter the confirmation code below.").
Value(&strCode).
Validate(valSixDigits),
))
if err := q.Run(); err != nil {
return 0, err
}
Expand All @@ -92,8 +203,8 @@ func (*Huh) ConfirmationCode(email string) (int, error) {
var numChlgRE = regexp.MustCompile(`^\d{6}$`)

func valSixDigits(s string) error {
if numChlgRE.MatchString(s) {
return nil
if !numChlgRE.MatchString(s) {
return errors.New("confirmation code must be a sequence of six digits")
}
return errors.New("confirmation code must be a sequence of six digits")
return nil
}
41 changes: 20 additions & 21 deletions auth/rod.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"time"

"github.com/charmbracelet/huh/spinner"
"github.com/rusq/slackauth"

"github.com/rusq/slackdump/v2/auth/auth_ui"
Expand Down Expand Up @@ -60,11 +61,10 @@ func (ro rodOpts) slackauthOpts() []slackauth.Option {
}

type browserAuthUIExt interface {
BrowserAuthUI
// RequestLoginType should request the login type from the user and return
// one of the [auth_ui.LoginType] constants. The implementation should
// provide a way to cancel the login flow, returning [auth_ui.LoginCancel].
RequestLoginType(w io.Writer) (auth_ui.LoginType, error)
RequestLoginType(w io.Writer, workspace string) (auth_ui.LoginOpts, error)
// RequestCreds should request the user's email and password and return
// them.
RequestCreds(w io.Writer, workspace string) (email string, passwd string, err error)
Expand All @@ -89,35 +89,25 @@ func NewRODAuth(ctx context.Context, opts ...Option) (RodAuth, error) {
for _, opt := range opts {
opt(&r.opts)
}
if r.opts.workspace == "" {
var err error
r.opts.workspace, err = r.opts.ui.RequestWorkspace(os.Stdout)
if err != nil {
return r, err
}
if r.opts.workspace == "" {
return r, fmt.Errorf("workspace cannot be empty")
}
}
if wsp, err := auth_ui.Sanitize(r.opts.workspace); err != nil {
return r, err
} else {
r.opts.workspace = wsp
}

resp, err := r.opts.ui.RequestLoginType(os.Stdout)
resp, err := r.opts.ui.RequestLoginType(os.Stdout, r.opts.workspace)
if err != nil {
return r, err
}
sopts := r.opts.slackauthOpts()
if resp == auth_ui.LGoogleAuth {
if resp.Type == auth_ui.LUserBrowser {
// it doesn't need to know that this browser is just a puppet in the
// masterful hands.
sopts = append(sopts, slackauth.WithForceUser())
sopts = append(sopts, slackauth.WithForceUser(), slackauth.WithLocalBrowser(resp.BrowserPath))
}

cl, err := slackauth.New(
r.opts.workspace,
resp.Workspace,
sopts...,
)
if err != nil {
Expand All @@ -128,23 +118,22 @@ func NewRODAuth(ctx context.Context, opts ...Option) (RodAuth, error) {
lg := logger.FromContext(ctx)
t := time.Now()
var sp simpleProvider
switch resp {
case auth_ui.LInteractive, auth_ui.LGoogleAuth:
switch resp.Type {
case auth_ui.LInteractive, auth_ui.LUserBrowser:
lg.Printf("ℹ️ Initialising browser, once the browser appears, login as usual")
var err error
sp.Token, sp.Cookie, err = cl.Manual(ctx)
if err != nil {
return r, err
}
case auth_ui.LHeadless:
sp, err = headlessFlow(ctx, cl, r.opts.workspace, r.opts.ui)
sp, err = headlessFlow(ctx, cl, resp.Workspace, r.opts.ui)
if err != nil {
return r, err
}
case auth_ui.LCancel:
return r, ErrCancelled
}

lg.Printf("✅ authenticated (time taken: %s)", time.Since(t))

return RodAuth{
Expand All @@ -163,12 +152,22 @@ func headlessFlow(ctx context.Context, cl *slackauth.Client, workspace string, u
if password == "" {
return sp, fmt.Errorf("password cannot be empty")
}
logger.FromContext(ctx).Println("⏳ Logging in to Slack, depending on your connection speed, it will take 25-40 seconds...")

sctx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
_ = spinner.New().
Type(spinner.Dots).
Title("Logging in to Slack, it will take 25-40 seconds").
Context(sctx).
Run()
}()

var loginErr error
sp.Token, sp.Cookie, loginErr = cl.Headless(ctx, username, password)
if loginErr != nil {
return sp, loginErr
}

return
}
5 changes: 2 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.23
require (
github.com/MercuryEngineering/CookieMonster v0.0.0-20180304172713-1584578b3403
github.com/charmbracelet/huh v0.6.0
github.com/charmbracelet/huh/spinner v0.0.0-20241028115900-20a4d21717a8
github.com/denisbrodbeck/machineid v1.0.1
github.com/fatih/color v1.17.0
github.com/joho/godotenv v1.5.1
Expand All @@ -13,7 +14,7 @@ require (
github.com/rusq/dlog v1.4.0
github.com/rusq/osenv/v2 v2.0.1
github.com/rusq/secure v0.0.4
github.com/rusq/slackauth v0.4.0
github.com/rusq/slackauth v0.5.1
github.com/rusq/tracer v1.0.1
github.com/schollz/progressbar/v3 v3.13.0
github.com/slack-go/slack v0.14.0
Expand All @@ -34,7 +35,6 @@ require (
github.com/charmbracelet/x/ansi v0.3.2 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20241004173542-8f764644629a // indirect
github.com/charmbracelet/x/term v0.2.0 // indirect
github.com/containerd/console v1.0.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/deckarep/golang-set/v2 v2.6.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
Expand All @@ -53,7 +53,6 @@ require (
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
Expand Down
Loading