From cf8dcf4cb99acb16dc11dd312dab3463d2862f86 Mon Sep 17 00:00:00 2001 From: Rustam <16064414+rusq@users.noreply.github.com> Date: Sun, 23 Nov 2025 19:54:15 +1000 Subject: [PATCH 1/3] Implement Mobile link signin --- cmd/playground/main.go | 70 ++++++++++++++--- go.mod | 3 + go.sum | 6 ++ hijacker.go | 26 ++++--- internal/qrslack/qrslack.go | 87 ++++++++++++++++++++++ internal/qrslack/qrslack_test.go | 124 +++++++++++++++++++++++++++++++ login_manual.go | 2 +- mobile_signin.go | 68 +++++++++++++++++ slackauth.go | 87 +++++++++++++++++----- 9 files changed, 431 insertions(+), 42 deletions(-) create mode 100644 internal/qrslack/qrslack.go create mode 100644 internal/qrslack/qrslack_test.go create mode 100644 mobile_signin.go diff --git a/cmd/playground/main.go b/cmd/playground/main.go index f18dfa4..8bdbd53 100644 --- a/cmd/playground/main.go +++ b/cmd/playground/main.go @@ -20,6 +20,9 @@ import ( "github.com/joho/godotenv" "github.com/rusq/chttp" + + "github.com/rusq/slackauth/qrslack" + "github.com/rusq/slackauth" ) @@ -27,6 +30,7 @@ var _ = godotenv.Load() var ( auto = flag.Bool("auto", false, "attempt auto login") + qr = flag.Bool("qr", false, "attempt qr code login") forceNew = flag.Bool("force-user", false, "force open a user browser, instead of the clean one") bundled = flag.Bool("bundled", false, "force using a bundled browser") isDebug = flag.Bool("d", os.Getenv("DEBUG") == "1", "enable debug") @@ -73,7 +77,7 @@ func run(ctx context.Context) error { defer trace.Stop() } - c, err := initClient(envOrScan("AUTH_WORKSPACE", "Enter workspace: "), *isDebug) + c, err := initClient(envOrScan(ctx, "AUTH_WORKSPACE", "Enter workspace: "), *isDebug) if err != nil { return err } @@ -92,11 +96,14 @@ func run(ctx context.Context) error { token string cookies []*http.Cookie ) - if *auto { - username := envOrScan("EMAIL", "Enter email: ") - password := envOrScan("PASSWORD", "Enter password: ") + switch { + case *auto: + username := envOrScan(ctx, "EMAIL", "Enter email: ") + password := envOrScan(ctx, "PASSWORD", "Enter password: ") token, cookies, err = autoLogin(ctx, c, username, password) - } else { + case *qr: + token, cookies, err = qrLogin(ctx, c) + default: token, cookies, err = browserLogin(ctx, c) } if err != nil { @@ -202,6 +209,31 @@ func autoLogin(ctx context.Context, c *slackauth.Client, username, password stri return token, cookies, nil } +func qrLogin(ctx context.Context, c *slackauth.Client) (string, []*http.Cookie, error) { + ctx, task := trace.NewTask(ctx, "qrLogin") + defer task.End() + // read image data from stdin + imgData := envOrScan(ctx, "QR_CODE", "Paste encoded image data:") + + // ctx, cancel := context.WithTimeoutCause(ctx, 180*time.Second, errors.New("user too slow")) + // defer cancel() + // _ = ctx + loginURL, err := qrslack.Decode(imgData) + if err != nil { + return "", nil, err + } + fmt.Println("Decoded:", loginURL) + start := time.Now() + token, cookies, err := c.QRAuth(ctx, imgData) + if err != nil { + return "", nil, err + } + slog.Info("login duration", "d", time.Since(start)) + fmt.Println(token) + printCookies(cookies) + return token, cookies, nil +} + func printCookies(cookies []*http.Cookie) { tw := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0) defer tw.Flush() @@ -211,14 +243,32 @@ func printCookies(cookies []*http.Cookie) { } } -func envOrScan(env, prompt string) string { +func envOrScan(ctx context.Context, env, prompt string) string { v := os.Getenv(env) if v != "" { return v } - for v == "" { - fmt.Print(prompt) - fmt.Scanln(&v) + resC := make(chan string) + errC := make(chan error) + go func() { + for v == "" { + fmt.Print(prompt + " ") + _, err := fmt.Scanln(&v) + if err != nil { + errC <- err + return + } + } + resC <- v + }() + select { + case <-ctx.Done(): + return "" + case v := <-resC: + return v + case err := <-errC: + fmt.Println("user chose not to continue this journey:", err) + os.Exit(2) } - return v + return "" //should never get here } diff --git a/go.mod b/go.mod index 020a33f..8ac0aed 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/rusq/slackauth go 1.22 require ( + github.com/caiguanhao/readqr v1.0.0 github.com/go-rod/rod v0.116.2 github.com/joho/godotenv v1.5.1 github.com/rusq/chttp v1.0.2 @@ -19,5 +20,7 @@ require ( github.com/ysmood/gson v0.7.3 // indirect github.com/ysmood/leakless v0.9.0 // indirect golang.org/x/net v0.30.0 // indirect + golang.org/x/text v0.19.0 // indirect + golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index eda8024..12d75db 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/caiguanhao/readqr v1.0.0 h1:axynewywpUyqZxFjKPtEbr97PzSOMrJsfn9bKkp+22w= +github.com/caiguanhao/readqr v1.0.0/go.mod h1:oaAqEl5Zt0XzeIJf7nCEzJFz4is8rfE+Vgiw8b07vMM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= @@ -28,6 +30,10 @@ go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f h1:GGU+dLjvlC3qDwqYgL6UgRmHXhOOgns0bZu2Ty5mm6U= +golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/hijacker.go b/hijacker.go index 8b142d9..db3b05a 100644 --- a/hijacker.go +++ b/hijacker.go @@ -10,15 +10,15 @@ import ( "github.com/go-rod/rod" ) -// hijacker is a helper for hijacking requests. +// hijacker is a contraption to hijack the request holding the token. Once the +// request is captured, token value is extracted and sent on the credsC +// channel. Caller may retrieve it by calling [Token] method. type hijacker struct { r *rod.HijackRouter credsC chan creds lg Logger } -// creds holds token and an error, and is communicated through the credsC -// channel of the hijacker. type creds struct { Token string Err error @@ -39,20 +39,21 @@ func newHijacker(ctx context.Context, page *rod.Page, lg Logger) (*hijacker, err return hj, nil } -func (hj *hijacker) hook(h *rod.Hijack) { - hj.lg.Debug("hijack api.features") +func (h *hijacker) hook(rh *rod.Hijack) { + h.lg.Debug("hijack api.features") - r := h.Request.Req() + r := rh.Request.Req() token, err := extractToken(r) if err != nil { - hj.credsC <- creds{Err: fmt.Errorf("error parsing token out of request: %v", err)} + h.credsC <- creds{Err: fmt.Errorf("error parsing token out of request: %v", err)} return } - hj.credsC <- creds{Token: token} + h.credsC <- creds{Token: token} } +// Stop terminates the hijacker and disables request hooks. func (h *hijacker) Stop() error { defer close(h.credsC) if err := h.r.Stop(); err != nil { @@ -61,7 +62,8 @@ func (h *hijacker) Stop() error { return nil } -// Token waits for the hijacker to receive a token or an error. +// Token returns the token value or an error. If the token has not yet been +// captured, it blocks until hijacker has captured the token value. func (h *hijacker) Token(ctx context.Context) (string, error) { ctx, task := trace.NewTask(ctx, "Token") defer task.End() @@ -74,12 +76,12 @@ func (h *hijacker) Token(ctx context.Context) (string, error) { } const ( - maxMem = 131072 - paramToken = "token" + maxFormParseMem = 131072 // maximum memory for the multipart form parser + paramToken = "token" // token form field name ) func extractToken(r *http.Request) (string, error) { - if err := r.ParseMultipartForm(maxMem); err != nil { + if err := r.ParseMultipartForm(maxFormParseMem); err != nil { return "", fmt.Errorf("error parsing request: %w", err) } tok := strings.TrimSpace(r.Form.Get(paramToken)) diff --git a/internal/qrslack/qrslack.go b/internal/qrslack/qrslack.go new file mode 100644 index 0000000..1dd51e5 --- /dev/null +++ b/internal/qrslack/qrslack.go @@ -0,0 +1,87 @@ +// Package qrslack contains slack QR decode logic. +package qrslack + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "image" + "io" + "strings" + + "github.com/caiguanhao/readqr" + + "image/png" +) + +const ( + maxDataSz = 1 << 16 +) + +var ( + ErrInvalidQR = errors.New("invalid QR code") + + errHdrLen = errors.New("unexpected header length") + errInvalidHdr = errors.New("invalid header") + errNoData = errors.New("no image data") +) + +func Decode(urlImgData string) (string, error) { + pngbytes, err := decodeB64(strings.NewReader(urlImgData)) + if err != nil { + return "", err + } + img, err := decodeImage(bytes.NewReader(pngbytes)) + if err != nil { + return "", err + } + return decodeQR(img) +} + +func decodeB64(r io.Reader) ([]byte, error) { + const ( + hdr = `data:image/png;base64,` + hdrLen = int64(len(hdr)) + ) + // read first 22 bytes + data, err := io.ReadAll(io.LimitReader(r, hdrLen)) + if err != nil { + if errors.Is(err, io.EOF) { + return nil, errHdrLen + } + return nil, fmt.Errorf("read header: %w", err) + } + if !strings.EqualFold(hdr, string(data)) { + return nil, errInvalidHdr + } + + b64r := base64.NewDecoder(base64.StdEncoding, io.LimitReader(r, maxDataSz)) + encoded, err := io.ReadAll(b64r) + if err != nil { + if errors.Is(err, io.EOF) { + return nil, errNoData + } + return nil, fmt.Errorf("read data: %w", err) + } + return encoded, nil +} + +func decodeImage(r io.Reader) (image.Image, error) { + img, err := png.Decode(r) + if err != nil { + return nil, err + } + if img.Bounds().Dx() != img.Bounds().Dy() { + return nil, ErrInvalidQR + } + return img, nil +} + +func decodeQR(m image.Image) (string, error) { + result, err := readqr.DecodeImage(m) + if err != nil { + return "", err + } + return result, nil +} diff --git a/internal/qrslack/qrslack_test.go b/internal/qrslack/qrslack_test.go new file mode 100644 index 0000000..69f117e --- /dev/null +++ b/internal/qrslack/qrslack_test.go @@ -0,0 +1,124 @@ +package qrslack + +import ( + "bytes" + "image" + "image/png" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +const testqr = `data:image/png;base64,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` + +func Test_decodeB64(t *testing.T) { + type args struct { + r io.Reader + } + tests := []struct { + name string + args args + wantLen int + wantErr bool + }{ + { + "decodes a valid payload", + args{strings.NewReader(testqr)}, + 4545, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := decodeB64(tt.args.r) + if (err != nil) != tt.wantErr { + t.Fatalf("decodeB64() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if len(got) != tt.wantLen { + t.Errorf("decodeB64() = length mismatch: %d, want %d", len(got), tt.wantLen) + } + }) + } +} + +func Test_decodeImage(t *testing.T) { + decodedData, err := decodeB64(strings.NewReader(testqr)) + if err != nil { + t.Fatalf("test data QR corrupt: %s", err) + } + type args struct { + r io.Reader + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + "successful load of QR png", + args{bytes.NewReader(decodedData)}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := decodeImage(tt.args.r) + if (err != nil) != tt.wantErr { + t.Fatalf("decodeImage() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + }) + } +} + +var testQRPNGFile = filepath.Join("fixtures", "test.png") + +func Test_decodeQR(t *testing.T) { + f, err := os.Open(testQRPNGFile) + if err != nil { + t.Fatal(err) + } + img, err := png.Decode(f) + if err != nil { + t.Fatal(err) + } + f.Close() + + type args struct { + m image.Image + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + "decodes QR code", + args{img}, + "https://app.slack.com/t/ora600/login/z-app-610187951300-9981196591425-e95b38836efcfc97428861b24e65f8b62aca253d0ed2880e06d34f74de4b40fa?src=qr_code&user_id=UHSD97ZA5&team_id=THY5HTZ8U", + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := decodeQR(tt.args.m) + if (err != nil) != tt.wantErr { + t.Fatalf("decodeQR() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if got != tt.want { + t.Errorf("decodeQR() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/login_manual.go b/login_manual.go index e011c37..cc9cbc7 100644 --- a/login_manual.go +++ b/login_manual.go @@ -12,7 +12,7 @@ import ( // Deprecated: Use [Client.Manual] instead. var Browser = Manual -// Browser initiates a login flow in a browser (manual login). +// Manual initiates a login flow in a browser (manual login). // // Deprecated: Use [Client.Manual] instead. func Manual(ctx context.Context, workspace string, opt ...Option) (string, []*http.Cookie, error) { diff --git a/mobile_signin.go b/mobile_signin.go new file mode 100644 index 0000000..f3ec708 --- /dev/null +++ b/mobile_signin.go @@ -0,0 +1,68 @@ +package slackauth + +import ( + "context" + "errors" + "net/http" + "runtime/trace" + "strings" + + "github.com/rusq/slackauth/internal/qrslack" +) + +var ErrLinkExpired = errors.New("login link expired") + +func (c *Client) QRAuth(ctx context.Context, imageData string) (string, []*http.Cookie, error) { + ctx, task := trace.NewTask(ctx, "QRAuth") + defer task.End() + + loginURL, err := qrslack.Decode(imageData) + if err != nil { + return "", nil, err + } + + browser, err := c.startBrowser(ctx) + if err != nil { + return "", nil, err + } + page, h, err := c.blankPage(ctx, browser) + if err != nil { + return "", nil, err + } + + if err := c.openURL(ctx, page, loginURL); err != nil { + return "", nil, err + } + + ctx, cancel := withTabGuard(ctx, browser, page.TargetID, c.opts.lg) + defer cancel(nil) + + // trap the redirect page and click it, if it appears. + _, stopTrap := c.trapRedirect(ctx, page) + defer stopTrap(errors.New("login finished")) + + title := page.MustElement("title").MustEval(`() => this.innerText`).String() + if strings.Contains(title, "Link expired") { + return "", nil, ErrLinkExpired + } + + // blocks till it sees the token + token, err := h.Token(ctx) + if err != nil { + return "", nil, err + } + + var cookies []*http.Cookie + cookies, err = convertCookies(browser.GetCookies()) + if c.opts.forceUser { + // we need not store all cookies from the user browser. + trace.WithRegion(ctx, "filterCookies", func() { + cookies = filterCookies(cookies) + }) + } + if err != nil { + return "", nil, ErrBrowser{Err: err, FailedTo: "extract cookies"} + } + + return token, cookies, nil +} diff --git a/slackauth.go b/slackauth.go index c743622..97e6dc7 100644 --- a/slackauth.go +++ b/slackauth.go @@ -1,3 +1,29 @@ +// Package slackauth provides functions for automated and automatic logins into +// Slack Workspace. +// +// # Security and Liability Disclaimer +// +// The `slackauth` package is provided "as is", without warranty of any kind, +// express or implied, including but not limited to the warranties of +// merchantability, fitness for a particular purpose and noninfringement. +// +// The author and contributors do not guarantee that this package is secure, +// free from vulnerabilities, or suitable for any particular environment. You +// are solely responsible for: +// +// - Reviewing the code and assessing its suitability for your use case. +// - Configuring and deploying it in a secure manner. +// - Complying with all applicable laws, regulations, and terms of service +// (including, but not limited to, Slack’s terms and policies). +// +// In no event shall the author or contributors be liable for any claim, +// damages, losses, or other liability (including, without limitation, loss of +// data, security breaches, or unauthorised access to systems or accounts) +// arising from, out of, or in connection with the software or the use, +// inability to use, or misuse of the software. By using this package, you +// accept full responsibility for any and all consequences. educational +// purposes only. Use this package only on those workspaces that you have +// permissions package slackauth import ( @@ -332,29 +358,12 @@ func (c *Client) openSlackAuthTab(ctx context.Context, b *rod.Browser) (*rod.Pag ctx, task := trace.NewTask(ctx, "openSlackAuthTab") defer task.End() - if err := setCookies(b, c.opts.cookies); err != nil { - return nil, nil, err - } - // we open the empty page first to be able to setup everything that we // desire before hitting slack workspace login page. - pg, err := b.Page(proto.TargetCreateTarget{}) + pg, h, err := c.blankPage(ctx, b) if err != nil { return nil, nil, ErrBrowser{Err: err, FailedTo: "create blank page"} } - wait := pg.MustWaitNavigation() - - // set up the request hijacker - h, err := newHijacker(ctx, pg, c.opts.lg) - if err != nil { - return nil, nil, ErrBrowser{Err: err, FailedTo: "create hijacker"} - } - c.atClose(h.Stop) - // patch the user agent if needed - if err := c.opts.setUserAgent(pg); err != nil { - return nil, nil, ErrBrowser{Err: err, FailedTo: "set user agent"} - } - wait() // now we're ready, navigating to the slack workspace. If we're running // in the user browser, the traps for the requests are already in place, @@ -367,7 +376,6 @@ func (c *Client) openSlackAuthTab(ctx context.Context, b *rod.Browser) (*rod.Pag if err := pg.WaitLoad(); err != nil { return nil, nil, ErrBrowser{Err: err, FailedTo: "load page"} } - c.atClose(pg.Close) return pg, h, nil } @@ -436,3 +444,44 @@ func click(el *rod.Element) error { } return nil } + +func (c *Client) blankPage(ctx context.Context, b *rod.Browser) (*rod.Page, *hijacker, error) { + if err := setCookies(b, c.opts.cookies); err != nil { + return nil, nil, err + } + + // we open the empty page first to be able to setup everything that we + // desire before hitting slack workspace login page. + pg, err := b.Page(proto.TargetCreateTarget{}) + if err != nil { + return nil, nil, ErrBrowser{Err: err, FailedTo: "create blank page"} + } + c.atClose(pg.Close) + + wait := pg.MustWaitNavigation() + + // set up the request hijacker + h, err := newHijacker(ctx, pg, c.opts.lg) + if err != nil { + return nil, nil, ErrBrowser{Err: err, FailedTo: "create hijacker"} + } + c.atClose(h.Stop) + // patch the user agent if needed + if err := c.opts.setUserAgent(pg); err != nil { + return nil, nil, ErrBrowser{Err: err, FailedTo: "set user agent"} + } + wait() + + return pg, h, nil +} + +func (c *Client) openURL(ctx context.Context, pg *rod.Page, URL string) error { + ctx, task := trace.NewTask(ctx, "openURL") + defer task.End() + + if err := pg.Navigate(URL); err != nil { + return ErrBrowser{Err: err, FailedTo: "navigate to login page"} + } + + return nil +} From b9cee0f184d424422e7ef4200059f8e025f0c0e3 Mon Sep 17 00:00:00 2001 From: Rustam <16064414+rusq@users.noreply.github.com> Date: Sun, 23 Nov 2025 20:13:11 +1000 Subject: [PATCH 2/3] update pkg path --- cmd/playground/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/playground/main.go b/cmd/playground/main.go index 8bdbd53..d92d3de 100644 --- a/cmd/playground/main.go +++ b/cmd/playground/main.go @@ -21,7 +21,7 @@ import ( "github.com/joho/godotenv" "github.com/rusq/chttp" - "github.com/rusq/slackauth/qrslack" + "github.com/rusq/slackauth/internal/qrslack" "github.com/rusq/slackauth" ) From 6b28848bef375d6c8c30bedd6fbe90e79c1c8a87 Mon Sep 17 00:00:00 2001 From: Rustam <16064414+rusq@users.noreply.github.com> Date: Sun, 23 Nov 2025 20:17:33 +1000 Subject: [PATCH 3/3] add a test file --- internal/qrslack/fixtures/test.png | Bin 0 -> 4545 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 internal/qrslack/fixtures/test.png diff --git a/internal/qrslack/fixtures/test.png b/internal/qrslack/fixtures/test.png new file mode 100644 index 0000000000000000000000000000000000000000..1275e5cda573a88640e7ac1d864dbc84c99256fc GIT binary patch literal 4545 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 Vnqgcg^8I~s