Skip to content

Commit a11b322

Browse files
committed
Implement QR login flow in auth package
1 parent 0f3f489 commit a11b322

5 files changed

Lines changed: 90 additions & 19 deletions

File tree

auth/auth_ui/auth_ui.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ const (
1111
LHeadless
1212
// LUserBrowser is the google auth option
1313
LUserBrowser
14+
// LMobileSignin allows to sign in using QR Code
15+
LMobileSignin
1416
// LCancel should be returned if the user cancels the login intent.
1517
LCancel
1618
)

auth/auth_ui/huh.go

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"io"
88
"regexp"
99
"strconv"
10+
"strings"
1011

1112
"github.com/charmbracelet/bubbles/key"
1213
"github.com/charmbracelet/huh"
@@ -65,22 +66,27 @@ func (m methodMenuItem) String() string {
6566
return fmt.Sprintf("%-20s - %s", m.MenuItem, m.ShortDesc)
6667
}
6768

68-
var methods = []methodMenuItem{
69+
var gMethods = []methodMenuItem{
6970
{
7071
"Interactive",
7172
"Works with most authentication schemes, except Google.",
7273
LInteractive,
7374
},
7475
{
7576
"Automatic",
76-
"Only suitable for email/password auth",
77+
"Only suitable for email/password auth.",
7778
LHeadless,
7879
},
7980
{
8081
"User Browser",
81-
"Loads your user profile, works with Google Auth",
82+
"Loads your user profile, works with Google Auth.",
8283
LUserBrowser,
8384
},
85+
{
86+
"QR Code",
87+
"Login using Sign in on Mobile QR code, works with Google Auth.",
88+
LMobileSignin,
89+
},
8490
}
8591

8692
type LoginOpts struct {
@@ -95,15 +101,15 @@ func init() {
95101
keymap.Quit = key.NewBinding(key.WithKeys("esc", "ctrl+c"), key.WithHelp("esc", "Quit"))
96102
}
97103

98-
func (*Huh) RequestLoginType(ctx context.Context, w io.Writer, workspace string) (LoginOpts, error) {
104+
func (*Huh) RequestLoginType(ctx context.Context, _ io.Writer, workspace string) (LoginOpts, error) {
99105
ret := LoginOpts{
100106
Workspace: workspace,
101107
Type: LInteractive,
102108
BrowserPath: "",
103109
}
104110

105-
opts := make([]huh.Option[LoginType], 0, len(methods))
106-
for _, m := range methods {
111+
opts := make([]huh.Option[LoginType], 0, len(gMethods))
112+
for _, m := range gMethods {
107113
opts = append(opts, huh.NewOption(m.String(), m.Type))
108114
}
109115
opts = append(opts,
@@ -139,6 +145,8 @@ func (*Huh) RequestLoginType(ctx context.Context, w io.Writer, workspace string)
139145
return "You will be prompted to enter your email and password, login is automated."
140146
case LUserBrowser:
141147
return "System browser will open on a Slack Login page."
148+
case LMobileSignin:
149+
return "Sign in using 'Sign in on Mobile' QR code."
142150
case LCancel:
143151
return "Cancel the login process."
144152
default:
@@ -226,3 +234,35 @@ func valSixDigits(s string) error {
226234
}
227235
return nil
228236
}
237+
238+
const (
239+
maxEncImgSz = 7000
240+
imgPrefix = "data:image/png;base64,"
241+
)
242+
243+
func (*Huh) RequestQR(ctx context.Context, _ io.Writer) (string, error) {
244+
const description = `In logged in Slack Client or Web:
245+
1. click the username in the upper left corner;
246+
2. choose 'Sign in on mobile';
247+
3. right-click the QR code image;
248+
4. choose Copy Image.`
249+
var imageData string
250+
q := huh.NewForm(huh.NewGroup(
251+
huh.NewText().
252+
CharLimit(maxEncImgSz).
253+
Value(&imageData).
254+
Validate(func(s string) error {
255+
if !strings.HasPrefix(s, imgPrefix) {
256+
return errors.New("image data must start with " + imgPrefix)
257+
}
258+
return nil
259+
}).
260+
Placeholder(imgPrefix + "...").
261+
Title("Paste QR code image data into this field").
262+
Description(""),
263+
))
264+
if err := q.Run(); err != nil {
265+
return "", err
266+
}
267+
return imageData, nil
268+
}

auth/rod.go

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ type browserAuthUIExt interface {
7373
// return it. Callback function is called to indicate that the code is
7474
// requested.
7575
ConfirmationCode(email string) (code int, err error)
76+
// RequestQR should request the URL-encoded png image and return it.
77+
RequestQR(ctx context.Context, w io.Writer) (encImage string, err error)
7678
}
7779

7880
// NewRODAuth constructs new RodAuth provider.
@@ -123,22 +125,22 @@ func NewRODAuth(ctx context.Context, opts ...Option) (RodAuth, error) {
123125
lg := slog.Default()
124126
t := time.Now()
125127
var sp simpleProvider
128+
126129
switch resp.Type {
127130
case auth_ui.LInteractive, auth_ui.LUserBrowser:
128131
lg.InfoContext(ctx, "ℹ️ Initialising browser, once the browser appears, login as usual")
129-
var err error
130132
sp.Token, sp.Cookie, err = cl.Manual(ctx)
131-
if err != nil {
132-
return r, err
133-
}
134133
case auth_ui.LHeadless:
135134
sp, err = headlessFlow(ctx, cl, resp.Workspace, r.opts.ui)
136-
if err != nil {
137-
return r, err
138-
}
135+
case auth_ui.LMobileSignin:
136+
sp, err = qrFlow(ctx, cl, r.opts.ui)
139137
case auth_ui.LCancel:
140138
return r, ErrCancelled
141139
}
140+
if err != nil {
141+
return r, err
142+
}
143+
142144
lg.InfoContext(ctx, "✅ authenticated", "time_taken", time.Since(t).String())
143145

144146
return RodAuth{
@@ -167,3 +169,18 @@ func headlessFlow(ctx context.Context, cl *slackauth.Client, workspace string, u
167169
}
168170
return
169171
}
172+
173+
func qrFlow(ctx context.Context, cl *slackauth.Client, ui browserAuthUIExt) (sp simpleProvider, err error) {
174+
imageData, err := ui.RequestQR(ctx, os.Stdout)
175+
if err != nil {
176+
return sp, err
177+
}
178+
tok, cook, err := cl.QRAuth(ctx, imageData)
179+
if err != nil {
180+
return sp, err
181+
}
182+
return simpleProvider{
183+
Token: tok,
184+
Cookie: cook,
185+
}, nil
186+
}

go.mod

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ require (
3333
github.com/rusq/osenv/v2 v2.0.1
3434
github.com/rusq/rbubbles v0.0.2
3535
github.com/rusq/slack v0.9.6-0.20250408103104-dd80d1b6337f
36-
github.com/rusq/slackauth v0.6.1
36+
github.com/rusq/slackauth v0.7.0
3737
github.com/rusq/tagops v0.1.1
3838
github.com/rusq/tracer v1.0.1
3939
github.com/schollz/progressbar/v3 v3.18.0
@@ -55,13 +55,14 @@ require (
5555
atomicgo.dev/schedule v0.1.0 // indirect
5656
github.com/atotto/clipboard v0.1.4 // indirect
5757
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
58+
github.com/caiguanhao/readqr v1.0.0 // indirect
5859
github.com/catppuccin/go v0.3.0 // indirect
5960
github.com/charmbracelet/colorprofile v0.3.3 // indirect
60-
github.com/charmbracelet/x/ansi v0.11.0 // indirect
61+
github.com/charmbracelet/x/ansi v0.11.1 // indirect
6162
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
62-
github.com/charmbracelet/x/exp/strings v0.0.0-20251110210702-903592506081 // indirect
63+
github.com/charmbracelet/x/exp/strings v0.0.0-20251118172736-77d017256798 // indirect
6364
github.com/charmbracelet/x/term v0.2.2 // indirect
64-
github.com/clipperhouse/displaywidth v0.5.0 // indirect
65+
github.com/clipperhouse/displaywidth v0.6.0 // indirect
6566
github.com/clipperhouse/stringish v0.1.1 // indirect
6667
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
6768
github.com/cloudflare/circl v1.6.1 // indirect
@@ -109,6 +110,7 @@ require (
109110
golang.org/x/net v0.47.0 // indirect
110111
golang.org/x/sys v0.38.0 // indirect
111112
golang.org/x/tools v0.38.0 // indirect
113+
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f // indirect
112114
gopkg.in/yaml.v3 v3.0.1 // indirect
113115
modernc.org/libc v1.66.6 // indirect
114116
modernc.org/mathutil v1.7.1 // indirect

go.sum

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
3232
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
3333
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
3434
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
35+
github.com/caiguanhao/readqr v1.0.0 h1:axynewywpUyqZxFjKPtEbr97PzSOMrJsfn9bKkp+22w=
36+
github.com/caiguanhao/readqr v1.0.0/go.mod h1:oaAqEl5Zt0XzeIJf7nCEzJFz4is8rfE+Vgiw8b07vMM=
3537
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
3638
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
3739
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
@@ -48,6 +50,8 @@ github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoF
4850
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
4951
github.com/charmbracelet/x/ansi v0.11.0 h1:uuIVK7GIplwX6UBIz8S2TF8nkr7xRlygSsBRjSJqIvA=
5052
github.com/charmbracelet/x/ansi v0.11.0/go.mod h1:uQt8bOrq/xgXjlGcFMc8U2WYbnxyjrKhnvTQluvfCaE=
53+
github.com/charmbracelet/x/ansi v0.11.1 h1:iXAC8SyMQDJgtcz9Jnw+HU8WMEctHzoTAETIeA3JXMk=
54+
github.com/charmbracelet/x/ansi v0.11.1/go.mod h1:M49wjzpIujwPceJ+t5w3qh2i87+HRtHohgb5iTyepL0=
5155
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
5256
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
5357
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
@@ -58,6 +62,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payR
5862
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
5963
github.com/charmbracelet/x/exp/strings v0.0.0-20251110210702-903592506081 h1:pTHy/fb1lG8MTw0FizbBQV9HHXEO2+MtPXkcE0S44nM=
6064
github.com/charmbracelet/x/exp/strings v0.0.0-20251110210702-903592506081/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
65+
github.com/charmbracelet/x/exp/strings v0.0.0-20251118172736-77d017256798 h1:g0RVaqkUdTikWLqrBdk2ZvJ9oTQOS0HZlYjYE8Tu7yg=
66+
github.com/charmbracelet/x/exp/strings v0.0.0-20251118172736-77d017256798/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
6167
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
6268
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
6369
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
@@ -68,6 +74,8 @@ github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7m
6874
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
6975
github.com/clipperhouse/displaywidth v0.5.0 h1:AIG5vQaSL2EKqzt0M9JMnvNxOCRTKUc4vUnLWGgP89I=
7076
github.com/clipperhouse/displaywidth v0.5.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
77+
github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
78+
github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
7179
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
7280
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
7381
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
@@ -221,8 +229,8 @@ github.com/rusq/secure v0.0.4 h1:svpiZHfHnx89eEDCCFI9OXG1Y8hL9kUWUG6fJbrWUOI=
221229
github.com/rusq/secure v0.0.4/go.mod h1:F1QilMKreuFRjov0UY7DZSIXn77/8RqMVGu2zV0RtqY=
222230
github.com/rusq/slack v0.9.6-0.20250408103104-dd80d1b6337f h1:w4klfw1A3iZv5qWg1YHcRF2bJuRDV7aOpsF6sLLSs0A=
223231
github.com/rusq/slack v0.9.6-0.20250408103104-dd80d1b6337f/go.mod h1:gULX17QqyNX4BF001nHKlSe0uKYI+MAKiDQ7oi80BYI=
224-
github.com/rusq/slackauth v0.6.1 h1:s09G3WHSA1yz6H9dHT+Yo6DCZF34ClY31tQz849B++Q=
225-
github.com/rusq/slackauth v0.6.1/go.mod h1:wAtNCbeKH0pnaZnqJjG5RKY3e5BF9F2L/YTzhOjBIb0=
232+
github.com/rusq/slackauth v0.7.0 h1:BLqrehKIbs2z4exc38zXecganFrqgnJIJQnL+cYdoFY=
233+
github.com/rusq/slackauth v0.7.0/go.mod h1:UOqfnUaJeygO9rYShAhsLxAZjbbEBNaLZpsdw03W3R0=
226234
github.com/rusq/tagops v0.1.1 h1:R5MHPR822lSg3LFr0RS3DFS0CapRiqtuHVD5NlOMOvY=
227235
github.com/rusq/tagops v0.1.1/go.mod h1:mUJ5WoHxrSv9wreCrHQkAeMevt5aXFadlOdLM6UsoHc=
228236
github.com/rusq/tracer v1.0.1 h1:5u4PCV8NGO97VuAINQA4gOVRkPoqHimLE2jpezRVNMU=
@@ -332,6 +340,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
332340
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
333341
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
334342
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
343+
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f h1:GGU+dLjvlC3qDwqYgL6UgRmHXhOOgns0bZu2Ty5mm6U=
344+
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
335345
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
336346
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
337347
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=

0 commit comments

Comments
 (0)