Skip to content

Commit 429b0d4

Browse files
authored
Merge pull request #520 from rusq/i519-cook-token
Fetch Token based on d= cookie
2 parents a5cc754 + b4fd7d0 commit 429b0d4

File tree

9 files changed

+678
-2
lines changed

9 files changed

+678
-2
lines changed

auth/auth.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/charmbracelet/huh/spinner"
1313
"github.com/rusq/chttp"
1414
"github.com/rusq/slack"
15+
"github.com/rusq/slackauth"
1516
)
1617

1718
const SlackURL = "https://slack.com"
@@ -127,7 +128,7 @@ func (s simpleProvider) Test(ctx context.Context) (*slack.AuthTestResponse, erro
127128
}
128129

129130
func (s simpleProvider) HTTPClient() (*http.Client, error) {
130-
return chttp.New(SlackURL, s.Cookies())
131+
return chttp.New(SlackURL, s.Cookies(), chttp.WithUserAgent(slackauth.DefaultUserAgent))
131132
}
132133

133134
func pleaseWait(ctx context.Context, msg string) func() {

auth/testdata/redirect.html

Lines changed: 398 additions & 0 deletions
Large diffs are not rendered by default.

auth/token.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package auth
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"regexp"
11+
"strings"
12+
13+
"github.com/rusq/slackauth"
14+
)
15+
16+
var ssbURI = func(workspace string) string {
17+
return "https://" + workspace + ".slack.com/ssb/redirect"
18+
}
19+
20+
func getTokenByCookie(ctx context.Context, workspaceName string, dCookie string) (string, []*http.Cookie, error) {
21+
if dCookie == "" {
22+
return "", nil, ErrNoCookies
23+
}
24+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ssbURI(workspaceName), nil)
25+
if err != nil {
26+
return "", nil, fmt.Errorf("failed to create request: %w", err)
27+
}
28+
req.Header.Add("User-Agent", slackauth.DefaultUserAgent)
29+
req.Header.Add("Cookie", "d="+dCookie)
30+
resp, err := http.DefaultClient.Do(req)
31+
if err != nil {
32+
return "", nil, fmt.Errorf("failed to make request: %w", err)
33+
}
34+
defer resp.Body.Close()
35+
if resp.StatusCode != http.StatusOK {
36+
return "", nil, fmt.Errorf("request failed with status code %d", resp.StatusCode)
37+
}
38+
token, err := extractToken(resp.Body)
39+
if err != nil {
40+
return "", nil, err
41+
}
42+
cookies := append(resp.Cookies(), makeCookie("d", dCookie))
43+
return token, cookies, nil
44+
}
45+
46+
var tokenRegex = regexp.MustCompile(`"api_token":"([^"]+)"`)
47+
48+
var errNoToken = errors.New("token not found")
49+
50+
// extractToken extracts the API token from the provided reader.
51+
// It expects that reader points to an HTML page retrieved from
52+
// /ssb/redirect
53+
func extractToken(r io.Reader) (string, error) {
54+
var token string
55+
br := bufio.NewReader(r)
56+
for {
57+
line, err := br.ReadString('\n')
58+
if err != nil {
59+
if errors.Is(err, io.EOF) {
60+
return token, errNoToken
61+
}
62+
return "", fmt.Errorf("read: %w", err)
63+
}
64+
text := strings.TrimSpace(line)
65+
if !strings.Contains(text, "api_token") {
66+
continue
67+
}
68+
matches := tokenRegex.FindStringSubmatch(text)
69+
if len(matches) < 2 || (len(matches) == 2 && matches[1] == "") {
70+
return "", errNoToken
71+
}
72+
token = matches[1]
73+
break
74+
}
75+
return token, nil
76+
}

auth/token_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package auth
2+
3+
import (
4+
"bytes"
5+
"context"
6+
_ "embed"
7+
"io"
8+
"net/http"
9+
"net/http/httptest"
10+
"strings"
11+
"testing"
12+
"time"
13+
14+
"github.com/stretchr/testify/assert"
15+
)
16+
17+
func Test_getTokenByCookie(t *testing.T) {
18+
oldTimeFunc := timeFunc
19+
timeFunc = func() time.Time {
20+
return time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
21+
}
22+
t.Cleanup(func() {
23+
timeFunc = oldTimeFunc
24+
})
25+
26+
type args struct {
27+
ctx context.Context
28+
workspaceName string
29+
dCookie string
30+
}
31+
tests := []struct {
32+
name string
33+
args args
34+
testBody []byte
35+
want string
36+
want1 []*http.Cookie
37+
wantErr bool
38+
}{
39+
{
40+
name: "finds the token and cookies",
41+
args: args{
42+
ctx: context.Background(),
43+
workspaceName: "test",
44+
dCookie: "dcookie",
45+
},
46+
testBody: testBody,
47+
want: "xoxc-000000000300-604451271345-8802919159412-ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
48+
want1: []*http.Cookie{
49+
{Name: "unit", Value: "test", Raw: "unit=test"},
50+
makeCookie("d", "dcookie"),
51+
},
52+
wantErr: false,
53+
},
54+
}
55+
for _, tt := range tests {
56+
t.Run(tt.name, func(t *testing.T) {
57+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58+
http.SetCookie(w, &http.Cookie{Name: "unit", Value: "test"})
59+
io.Copy(w, bytes.NewReader(tt.testBody))
60+
}))
61+
ssbURI = func(string) string {
62+
return srv.URL
63+
}
64+
got, got1, err := getTokenByCookie(tt.args.ctx, tt.args.workspaceName, tt.args.dCookie)
65+
if (err != nil) != tt.wantErr {
66+
t.Errorf("getTokenByCookie() error = %v, wantErr %v", err, tt.wantErr)
67+
return
68+
}
69+
if got != tt.want {
70+
t.Errorf("getTokenByCookie() got = %v, want %v", got, tt.want)
71+
}
72+
assert.EqualExportedValues(t, tt.want1, got1)
73+
})
74+
}
75+
}
76+
77+
//go:embed testdata/redirect.html
78+
var testBody []byte
79+
80+
func Test_extractToken(t *testing.T) {
81+
type args struct {
82+
r io.Reader
83+
}
84+
tests := []struct {
85+
name string
86+
args args
87+
want string
88+
wantErr bool
89+
}{
90+
{
91+
name: "extracts token from the HTML body",
92+
args: args{r: bytes.NewReader(testBody)},
93+
want: "xoxc-000000000300-604451271345-8802919159412-ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
94+
wantErr: false,
95+
},
96+
{
97+
name: "no token is an error",
98+
args: args{strings.NewReader("first line\nsecond line\n")},
99+
want: "",
100+
wantErr: true,
101+
},
102+
}
103+
for _, tt := range tests {
104+
t.Run(tt.name, func(t *testing.T) {
105+
got, err := extractToken(tt.args.r)
106+
if (err != nil) != tt.wantErr {
107+
t.Errorf("extractToken() error = %v, wantErr %v", err, tt.wantErr)
108+
return
109+
}
110+
if got != tt.want {
111+
t.Errorf("extractToken() = %v, want %v", got, tt.want)
112+
}
113+
})
114+
}
115+
}

auth/value.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package auth
22

33
import (
4+
"context"
45
"fmt"
56
"net/http"
67
"net/url"
@@ -59,6 +60,19 @@ func NewValueCookiesAuth(token string, cookies []*http.Cookie) (ValueAuth, error
5960
}}, nil
6061
}
6162

63+
// NewCookieOnlyAuth uses workspace name and dCookie to get the token value and returns
64+
// a ValueAuth.
65+
func NewCookieOnlyAuth(ctx context.Context, workspace, dCookie string) (ValueAuth, error) {
66+
if dCookie == "" {
67+
return ValueAuth{}, ErrNoCookies
68+
}
69+
token, cookies, err := getTokenByCookie(ctx, workspace, dCookie)
70+
if err != nil {
71+
return ValueAuth{}, err
72+
}
73+
return NewValueCookiesAuth(token, cookies)
74+
}
75+
6276
var timeFunc = time.Now
6377

6478
func makeCookie(key, val string) *http.Cookie {

cmd/slackdump/internal/workspace/workspaceui/tokencookie.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ package workspaceui
22

33
import (
44
"context"
5+
"errors"
6+
"strings"
57

68
"github.com/charmbracelet/huh"
9+
710
"github.com/rusq/slackdump/v3/auth"
811
"github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui"
912
"github.com/rusq/slackdump/v3/internal/structures"
@@ -131,3 +134,63 @@ func prgTokenCookieFile(ctx context.Context, mgr manager) error {
131134

132135
return success(ctx, workspace)
133136
}
137+
138+
func prgCookieOnly(ctx context.Context, mgr manager) error {
139+
var (
140+
wspname string
141+
cookie string
142+
confirmed bool
143+
)
144+
145+
newCookieProvFn := func(wsp, cookie string) (auth.ValueAuth, error) {
146+
return auth.NewCookieOnlyAuth(ctx, wsp, cookie)
147+
}
148+
149+
for !confirmed {
150+
f := huh.NewForm(huh.NewGroup(
151+
huh.NewInput().Title("Workspace").
152+
Description("Workspace Name (just the name, not the URL)").
153+
Placeholder("my-team-workspace").
154+
Value(&wspname).
155+
Validate(validateWspName),
156+
huh.NewInput().Title("Cookie").
157+
Description("Session cookie").
158+
Placeholder("xoxd-...").
159+
Value(&cookie),
160+
huh.NewConfirm().Title("Confirm creation of workspace?").
161+
Description("Once confirmed this will check the credentials for validity, fetch the token\nand create a new workspace").
162+
Value(&confirmed).
163+
Validate(makeValidator(ctx, &wspname, &cookie, newCookieProvFn)),
164+
)).WithTheme(ui.HuhTheme()).WithKeyMap(ui.DefaultHuhKeymap)
165+
if err := f.RunWithContext(ctx); err != nil {
166+
return err
167+
}
168+
if !confirmed {
169+
return nil
170+
}
171+
172+
prov, err := auth.NewCookieOnlyAuth(ctx, wspname, cookie)
173+
if err != nil {
174+
return err
175+
}
176+
name, err := mgr.CreateAndSelect(ctx, prov)
177+
if err != nil {
178+
confirmed = false
179+
retry := askRetry(ctx, name, err)
180+
if !retry {
181+
return nil
182+
}
183+
} else {
184+
break
185+
}
186+
}
187+
188+
return success(ctx, wspname)
189+
}
190+
191+
func validateWspName(s string) error {
192+
if strings.Contains(s, "://") || strings.Contains(s, "slack.com") {
193+
return errors.New("workspace name, not URL, i.e. for https://my-team.slack.com, enter my-team")
194+
}
195+
return nil
196+
}

cmd/slackdump/internal/workspace/workspaceui/workspaceui.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ func ShowUI(ctx context.Context, opts ...UIOption) error {
5252
}
5353
const (
5454
actLogin = "ezlogin"
55+
actCookie = "cookie"
5556
actToken = "token"
5657
actTokenFile = "tokenfile"
5758
actSecrets = "secrets"
@@ -83,6 +84,11 @@ func ShowUI(ctx context.Context, opts ...UIOption) error {
8384
{
8485
Separator: true,
8586
},
87+
{
88+
ID: actCookie,
89+
Name: "Cookie and Workspace Name",
90+
Help: "Enter d= cookie and workspace name, token fetched automatically",
91+
},
8692
{
8793
ID: actToken,
8894
Name: "Token/Cookie",
@@ -124,6 +130,7 @@ func ShowUI(ctx context.Context, opts ...UIOption) error {
124130
// new workspace methods
125131
methods := map[string]func(context.Context, manager) error{
126132
actLogin: brwsLogin(),
133+
actCookie: prgCookieOnly,
127134
actToken: prgTokenCookie,
128135
actTokenFile: prgTokenCookieFile,
129136
actSecrets: fileWithSecrets,

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ require (
2727
github.com/playwright-community/playwright-go v0.5200.0
2828
github.com/pressly/goose/v3 v3.24.3
2929
github.com/pterm/pterm v0.12.80
30-
github.com/rusq/chttp v1.0.2
30+
github.com/rusq/chttp v1.1.0
3131
github.com/rusq/encio v0.2.0
3232
github.com/rusq/fsadapter v1.1.0
3333
github.com/rusq/osenv/v2 v2.0.1

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
203203
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
204204
github.com/rusq/chttp v1.0.2 h1:bc8FTKE/l318Kie3sb2KrGi7Fu5tSDQY+JiXMsq4fO8=
205205
github.com/rusq/chttp v1.0.2/go.mod h1:bmuoQMUFs9fmigUmT7xbp8s0rHyzUrf7+78yLklr1so=
206+
github.com/rusq/chttp v1.1.0 h1:lfUALJ51uRLgb4tc7joXFOgz9pzKBmc4vGq0UDu3dmk=
207+
github.com/rusq/chttp v1.1.0/go.mod h1:bmuoQMUFs9fmigUmT7xbp8s0rHyzUrf7+78yLklr1so=
206208
github.com/rusq/encio v0.2.0 h1:+EbYnoLrX/mfwjBp0HqozdfOB2EplNDgbA2vIQvnCuY=
207209
github.com/rusq/encio v0.2.0/go.mod h1:AP3lDpo/BkcHcOMNduBlZdd0sbwhruq6+NZtYm5Mxb0=
208210
github.com/rusq/fsadapter v1.1.0 h1:/tuzrPNGr4Tx2f8fPK+WudSRBLDvjjDaqVvto1yrVdk=

0 commit comments

Comments
 (0)