Skip to content
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
43 changes: 30 additions & 13 deletions pkg/connector/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const (
FieldPassword = "password"
FieldOTPCode = "otp_code"
FieldRecaptchaToken = "recaptcha_token"
FieldClientVersion = "client_version"
)

func (rc *RedditConnector) GetLoginFlows() []bridgev2.LoginFlow {
Expand Down Expand Up @@ -85,8 +86,9 @@ type PasswordLogin struct {
}

type captchaResponse struct {
token string
err error
token string
clientVersion string
err error
}

var (
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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{
Expand Down
8 changes: 6 additions & 2 deletions pkg/redditchat/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
120 changes: 102 additions & 18 deletions pkg/redditchat/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import (
"strings"
"sync"
"time"

"github.com/rs/zerolog"
)

const (
Expand All @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()},
Expand All @@ -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 {
Expand All @@ -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},
Expand All @@ -419,25 +461,25 @@ 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,
PageURL: strings.TrimRight(firstNonEmpty(baseURL, DefaultRedditURL), "/") + "/login/",
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) {
Expand All @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
Loading
Loading