diff --git a/pkg/connector/login.go b/pkg/connector/login.go index d279f08..e1fc67d 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -31,6 +31,7 @@ const ( FieldPassword = "password" FieldOTPCode = "otp_code" FieldRecaptchaToken = "recaptcha_token" + FieldClientVersion = "client_version" ) func (rc *RedditConnector) GetLoginFlows() []bridgev2.LoginFlow { @@ -85,8 +86,9 @@ type PasswordLogin struct { } type captchaResponse struct { - token string - err error + token string + clientVersion string + err error } var ( @@ -172,9 +174,10 @@ func (p *PasswordLogin) SubmitCookies(ctx context.Context, cookies map[string]st if token == "" { return nil, errors.New("missing reCAPTCHA token") } + clientVersion := strings.TrimSpace(cookies[FieldClientVersion]) // Send the token to the waiting login goroutine. select { - case p.captchaResp <- captchaResponse{token: token}: + case p.captchaResp <- captchaResponse{token: token, clientVersion: clientVersion}: case <-ctx.Done(): return nil, ctx.Err() } @@ -226,6 +229,20 @@ func (p *PasswordLogin) buildCaptchaStep(req redditchat.CaptchaRequest) *bridgev {Type: bridgev2.LoginCookieTypeSpecial, Name: "recaptcha"}, }, }, + { + // Sniff the real X-Reddit-Client-Version off the shreddit + // page's own /svc/ requests. Optional: when the client can't + // capture it, redditchat falls back to RedditClientVersion. + ID: FieldClientVersion, + Required: false, + Sources: []bridgev2.LoginCookieFieldSource{ + { + Type: bridgev2.LoginCookieTypeRequestHeader, + Name: "X-Reddit-Client-Version", + RequestURLRegex: `https://www\.reddit\.com/svc/`, + }, + }, + }, }, ExtractJS: js, }, @@ -253,20 +270,20 @@ func (p *PasswordLogin) buildOTPStep() *bridgev2.LoginStep { // runLogin executes the redditchat login flow on a goroutine. The // CaptchaTokenProvider blocks until SubmitCookies pumps a token in. func (p *PasswordLogin) runLogin(ctx context.Context) { - provider := func(ctx context.Context, req redditchat.CaptchaRequest) (string, error) { + provider := func(ctx context.Context, req redditchat.CaptchaRequest) (redditchat.CaptchaResult, error) { select { case p.captchaReq <- req: case <-ctx.Done(): - return "", ctx.Err() + return redditchat.CaptchaResult{}, ctx.Err() } select { case resp := <-p.captchaResp: if resp.err != nil { - return "", resp.err + return redditchat.CaptchaResult{}, resp.err } - return resp.token, nil + return redditchat.CaptchaResult{Token: resp.token, ClientVersion: resp.clientVersion}, nil case <-ctx.Done(): - return "", ctx.Err() + return redditchat.CaptchaResult{}, ctx.Err() } } @@ -305,20 +322,20 @@ func (p *PasswordLogin) restartWithOTP(ctx context.Context, otp string) (*bridge p.cancelFn = cancel go func() { - provider := func(ctx context.Context, req redditchat.CaptchaRequest) (string, error) { + provider := func(ctx context.Context, req redditchat.CaptchaRequest) (redditchat.CaptchaResult, error) { select { case p.captchaReq <- req: case <-ctx.Done(): - return "", ctx.Err() + return redditchat.CaptchaResult{}, ctx.Err() } select { case resp := <-p.captchaResp: if resp.err != nil { - return "", resp.err + return redditchat.CaptchaResult{}, resp.err } - return resp.token, nil + return redditchat.CaptchaResult{Token: resp.token, ClientVersion: resp.clientVersion}, nil case <-ctx.Done(): - return "", ctx.Err() + return redditchat.CaptchaResult{}, ctx.Err() } } opts := redditchat.RedditLoginOptions{ diff --git a/pkg/redditchat/auth.go b/pkg/redditchat/auth.go index 4fe1bce..a6be8c6 100644 --- a/pkg/redditchat/auth.go +++ b/pkg/redditchat/auth.go @@ -154,8 +154,12 @@ func CredentialsFromChromeDebugURL(ctx context.Context, debugURL string) (Creden } func CaptchaTokenProviderFromChromeDebugURL(debugURL string) CaptchaTokenProvider { - return func(ctx context.Context, req CaptchaRequest) (string, error) { - return CaptchaTokenFromChromeDebugURL(ctx, debugURL, req) + return func(ctx context.Context, req CaptchaRequest) (CaptchaResult, error) { + token, err := CaptchaTokenFromChromeDebugURL(ctx, debugURL, req) + if err != nil { + return CaptchaResult{}, err + } + return CaptchaResult{Token: token}, nil } } diff --git a/pkg/redditchat/login.go b/pkg/redditchat/login.go index 8983d25..25d5de2 100644 --- a/pkg/redditchat/login.go +++ b/pkg/redditchat/login.go @@ -20,6 +20,8 @@ import ( "strings" "sync" "time" + + "github.com/rs/zerolog" ) const ( @@ -28,6 +30,9 @@ const ( RedditLoginCaptchaSiteKey = "6LfirrMoAAAAAHZOipvza4kpp_VtTwLNuXVwURNQ" RedditLoginCaptchaAction = "v1/web/login_with_password" + + // DefaultRedditClientVersion is the X-Reddit-Client-Version header value + DefaultRedditClientVersion = "2026-06-24T12:00Z~unknown" ) type CaptchaStep string @@ -49,7 +54,12 @@ type CaptchaRequest struct { Step CaptchaStep } -type CaptchaTokenProvider func(ctx context.Context, req CaptchaRequest) (string, error) +type CaptchaResult struct { + Token string + ClientVersion string +} + +type CaptchaTokenProvider func(ctx context.Context, req CaptchaRequest) (CaptchaResult, error) func (r CaptchaRequest) EnterpriseScriptURL() string { return "https://www.google.com/recaptcha/enterprise.js?render=" + url.QueryEscape(firstNonEmpty(r.SiteKey, RedditLoginCaptchaSiteKey)) @@ -110,6 +120,16 @@ type RedditSession struct { BaseURL string UserAgent string CSRFToken string + // ClientVersion is the X-Reddit-Client-Version sniffed from the login + // webview (see captchaToken). Empty until the first captcha step completes. + ClientVersion string +} + +func (s *RedditSession) clientVersion() string { + if s.ClientVersion != "" { + return s.ClientVersion + } + return DefaultRedditClientVersion } type RedditChatToken struct { @@ -164,18 +184,18 @@ func NewFromRedditLogin(ctx context.Context, opts RedditLoginOptions) (*Client, func StaticCaptchaTokenProvider(tokens ...string) CaptchaTokenProvider { var mu sync.Mutex var next int - return func(ctx context.Context, req CaptchaRequest) (string, error) { + return func(ctx context.Context, req CaptchaRequest) (CaptchaResult, error) { mu.Lock() defer mu.Unlock() if next >= len(tokens) { - return "", &CaptchaRequiredError{Request: req} + return CaptchaResult{}, &CaptchaRequiredError{Request: req} } token := strings.TrimSpace(tokens[next]) next++ if token == "" { - return "", errors.New("reddit login: empty captcha token") + return CaptchaResult{}, errors.New("reddit login: empty captcha token") } - return token, nil + return CaptchaResult{Token: token}, nil } } @@ -283,6 +303,22 @@ func (s *RedditSession) RefreshChatToken(ctx context.Context) (RedditChatToken, req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", s.BaseURL) req.Header.Set("Referer", s.BaseURL+"/chat/") + req.Header.Set("X-Original-Referer", s.BaseURL+"/chat/") + req.Header.Set("X-Reddit-Client-Version", s.clientVersion()) + // Diagnostic: confirm the session looks authenticated before the token POST. + log := zerolog.Ctx(ctx) + if log != nil { + var cookieNames []string + if u, perr := url.Parse(s.BaseURL); perr == nil && s.Client != nil && s.Client.Jar != nil { + for _, c := range s.Client.Jar.Cookies(u) { + cookieNames = append(cookieNames, c.Name) + } + } + log.Debug(). + Bool("has_csrf", csrf != ""). + Strs("cookie_names", cookieNames). + Msg("Refreshing reddit chat token") + } var token RedditChatToken if err := s.doJSON(req, &token); err != nil { return RedditChatToken{}, err @@ -373,10 +409,13 @@ func (s *RedditSession) submitPassword(ctx context.Context, opts RedditLoginOpti if err != nil { return err } + if captcha.ClientVersion != "" { + s.ClientVersion = captcha.ClientVersion + } form := url.Values{ "username": {opts.Username}, "password": {opts.Password}, - "recaptcha_token": {captcha}, + "recaptcha_token": {captcha.Token}, "recaptcha_use_checkbox": {"false"}, "recaptcha_action": {RedditLoginCaptchaAction}, "csrf_token": {s.csrfToken()}, @@ -387,7 +426,7 @@ func (s *RedditSession) submitPassword(ctx context.Context, opts RedditLoginOpti } switch resp.StatusCode { case http.StatusOK: - return nil + return checkLoginResponseBody(ctx, body) case http.StatusAccepted: otp, err := opts.otpCode(time.Now()) if err != nil { @@ -397,9 +436,12 @@ func (s *RedditSession) submitPassword(ctx context.Context, opts RedditLoginOpti if err != nil { return err } + if captcha.ClientVersion != "" { + s.ClientVersion = captcha.ClientVersion + } form := url.Values{ "appOtp": {otp}, - "recaptcha_token": {captcha}, + "recaptcha_token": {captcha.Token}, "recaptcha_use_checkbox": {"false"}, "recaptcha_action": {RedditLoginCaptchaAction}, "username": {opts.Username}, @@ -419,7 +461,7 @@ func (s *RedditSession) submitPassword(ctx context.Context, opts RedditLoginOpti } } -func (opts RedditLoginOptions) captchaToken(ctx context.Context, baseURL string, step CaptchaStep) (string, error) { +func (opts RedditLoginOptions) captchaToken(ctx context.Context, baseURL string, step CaptchaStep) (CaptchaResult, error) { req := CaptchaRequest{ SiteKey: RedditLoginCaptchaSiteKey, Action: RedditLoginCaptchaAction, @@ -427,17 +469,17 @@ func (opts RedditLoginOptions) captchaToken(ctx context.Context, baseURL string, Step: step, } if opts.CaptchaTokenProvider == nil { - return "", &CaptchaRequiredError{Request: req} + return CaptchaResult{}, &CaptchaRequiredError{Request: req} } - token, err := opts.CaptchaTokenProvider(ctx, req) + result, err := opts.CaptchaTokenProvider(ctx, req) if err != nil { - return "", err + return CaptchaResult{}, err } - token = strings.TrimSpace(token) - if token == "" { - return "", errors.New("reddit login: empty captcha token") + result.Token = strings.TrimSpace(result.Token) + if result.Token == "" { + return CaptchaResult{}, errors.New("reddit login: empty captcha token") } - return token, nil + return result, nil } func (s *RedditSession) postLoginForm(ctx context.Context, path string, form url.Values) (*http.Response, []byte, error) { @@ -449,7 +491,7 @@ func (s *RedditSession) postLoginForm(ctx context.Context, path string, form url req.Header.Set("Origin", s.BaseURL) req.Header.Set("Referer", s.BaseURL+"/login/") req.Header.Set("X-Original-Referer", s.BaseURL+"/login/") - req.Header.Set("X-Reddit-Client-Version", "2026-06-04T14:59Z~4fb66808") + req.Header.Set("X-Reddit-Client-Version", s.clientVersion()) return s.do(req) } @@ -655,6 +697,39 @@ func matrixWhoamiDevice(ctx context.Context, httpClient *http.Client, token stri return whoami.DeviceID, nil } +func checkLoginResponseBody(ctx context.Context, body []byte) error { + if log := zerolog.Ctx(ctx); log != nil { + preview := strings.TrimSpace(string(body)) + if len(preview) > 300 { + preview = preview[:300] + } + log.Debug(). + Int("body_len", len(body)). + Str("body_preview", preview). + Msg("Reddit password step returned 200") + } + var parsed struct { + Success *bool `json:"success"` + Error json.RawMessage `json:"error"` + Reason json.RawMessage `json:"reason"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + // Non-JSON 200 (e.g. empty body) — nothing to fail on here. + return nil + } + failed := (parsed.Success != nil && !*parsed.Success) || + (len(parsed.Error) > 0 && string(parsed.Error) != "null") || + (len(parsed.Reason) > 0 && string(parsed.Reason) != "null") + if failed { + msg := strings.TrimSpace(string(body)) + if len(msg) > 500 { + msg = msg[:500] + } + return fmt.Errorf("reddit login: password step rejected: %s", msg) + } + return nil +} + func redditStatusError(resp *http.Response, body []byte) error { msg := strings.TrimSpace(string(body)) if len(msg) > 500 { @@ -663,7 +738,16 @@ func redditStatusError(resp *http.Response, body []byte) error { if msg == "" { msg = resp.Status } - return fmt.Errorf("reddit login: %s %s failed: %s", resp.Request.Method, resp.Request.URL.String(), msg) + contentType := resp.Header.Get("Content-Type") + location := resp.Header.Get("Location") + extra := "" + for _, h := range []string{"Cf-Ray", "Cf-Mitigated", "X-Ratelimit-Remaining"} { + if v := resp.Header.Get(h); v != "" { + extra += fmt.Sprintf(" %s=%q", strings.ToLower(h), v) + } + } + return fmt.Errorf("reddit login: %s %s failed: status=%d content-type=%q location=%q%s body=%s", + resp.Request.Method, resp.Request.URL.String(), resp.StatusCode, contentType, location, extra, msg) } func findFirst(text string, patterns ...string) string { diff --git a/pkg/redditchat/login_test.go b/pkg/redditchat/login_test.go deleted file mode 100644 index 1a0fc11..0000000 --- a/pkg/redditchat/login_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package redditchat - -import ( - "encoding/base64" - "errors" - "strings" - "testing" - "time" -) - -func TestGenerateTOTP(t *testing.T) { - code, err := GenerateTOTP("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", time.Unix(59, 0)) - if err != nil { - t.Fatal(err) - } - if code != "287082" { - t.Fatalf("unexpected code: got %s want 287082", code) - } -} - -func TestCredentialsFromChatToken(t *testing.T) { - token := "header." + base64.RawURLEncoding.EncodeToString([]byte(`{"lid":"t2_example"}`)) + ".sig" - creds, err := credentialsFromChatToken(token) - if err != nil { - t.Fatal(err) - } - if creds.Homeserver != DefaultHomeserver { - t.Fatalf("homeserver = %q", creds.Homeserver) - } - if creds.UserID != "@t2_example:reddit.com" { - t.Fatalf("user ID = %q", creds.UserID) - } - if creds.AccessToken != token { - t.Fatal("access token was not preserved") - } -} - -func TestStaticCaptchaTokenProvider(t *testing.T) { - provider := StaticCaptchaTokenProvider(" first ", "second") - token, err := provider(t.Context(), CaptchaRequest{}) - if err != nil { - t.Fatal(err) - } - if token != "first" { - t.Fatalf("first token = %q", token) - } - token, err = provider(t.Context(), CaptchaRequest{}) - if err != nil { - t.Fatal(err) - } - if token != "second" { - t.Fatalf("second token = %q", token) - } - _, err = provider(t.Context(), CaptchaRequest{SiteKey: "site", Action: "act", PageURL: "page", Step: CaptchaStepOTP}) - if !errors.Is(err, ErrCaptchaRequired) { - t.Fatalf("expected ErrCaptchaRequired, got %v", err) - } - var captchaErr *CaptchaRequiredError - if !errors.As(err, &captchaErr) { - t.Fatalf("expected CaptchaRequiredError, got %T", err) - } - if captchaErr.Request.SiteKey != "site" || captchaErr.Request.Action != "act" || captchaErr.Request.PageURL != "page" || captchaErr.Request.Step != CaptchaStepOTP { - t.Fatalf("request not preserved: %#v", captchaErr.Request) - } -} - -func TestCaptchaTokenRequiredError(t *testing.T) { - _, err := RedditLoginOptions{}.captchaToken(t.Context(), DefaultRedditURL, CaptchaStepPassword) - if !errors.Is(err, ErrCaptchaRequired) { - t.Fatalf("expected ErrCaptchaRequired, got %v", err) - } - var captchaErr *CaptchaRequiredError - if !errors.As(err, &captchaErr) { - t.Fatalf("expected CaptchaRequiredError, got %T", err) - } - if captchaErr.Request.SiteKey != RedditLoginCaptchaSiteKey { - t.Fatalf("site key = %q", captchaErr.Request.SiteKey) - } - if captchaErr.Request.Action != RedditLoginCaptchaAction { - t.Fatalf("action = %q", captchaErr.Request.Action) - } - if captchaErr.Request.PageURL != DefaultRedditURL+"/login/" { - t.Fatalf("page url = %q", captchaErr.Request.PageURL) - } - if captchaErr.Request.Step != CaptchaStepPassword { - t.Fatalf("step = %q", captchaErr.Request.Step) - } -} - -func TestCaptchaRequestHelpers(t *testing.T) { - req := CaptchaRequest{SiteKey: "site key", Action: "act"} - if got := req.EnterpriseScriptURL(); got != "https://www.google.com/recaptcha/enterprise.js?render=site+key" { - t.Fatalf("script url = %q", got) - } - js := req.EnterpriseExecuteJavaScript() - for _, want := range []string{"grecaptcha.enterprise.execute", `"site key"`, `"act"`} { - if !strings.Contains(js, want) { - t.Fatalf("execute js missing %q: %s", want, js) - } - } -}