From c499639a42460649f94f919c569610abab6696cb Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Wed, 12 Aug 2026 16:54:39 +0100 Subject: [PATCH] Map Reddit login errors and stop matching errors by substring The login goroutine's error was handed straight back to the provisioning API, which replaced it with a generic 500 M_UNKNOWN "Internal error in login step". That was doubly bad here: redditchat's status errors embed up to 500 bytes of raw response body, which local bridges surface directly to the user. redditchat distinguished SSO-only accounts, blocked browser verification and rejected credentials, but only as bare errors.New values with no exported sentinel, so none of it could be matched on. Export sentinels for those cases plus rejected OTP codes, and map them onto declared RespErrors. This also removes the substring match on "otp required" that gated the whole 2FA branch. It tested err.Error() against a message built inline at the call site, so rewording that message would have silently broken OTP login rather than failing loudly. It now uses errors.Is against the sentinel. Also use bridgev2.ErrInvalidLoginFlowID for unknown flow IDs so they 404 instead of 500. --- pkg/connector/login.go | 20 +++++++---- pkg/connector/loginerrors.go | 67 ++++++++++++++++++++++++++++++++++++ pkg/redditchat/login.go | 14 +++++--- 3 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 pkg/connector/loginerrors.go diff --git a/pkg/connector/login.go b/pkg/connector/login.go index e1fc67d..bbc79e5 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -46,7 +46,7 @@ func (rc *RedditConnector) GetLoginFlows() []bridgev2.LoginFlow { func (rc *RedditConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { if flowID != FlowIDPassword { - return nil, fmt.Errorf("unknown login flow ID: %s", flowID) + return nil, bridgev2.ErrInvalidLoginFlowID } return &PasswordLogin{main: rc, user: user}, nil } @@ -142,7 +142,11 @@ func (p *PasswordLogin) SubmitUserInput(ctx context.Context, input map[string]st username := strings.TrimSpace(input[FieldUsername]) password := input[FieldPassword] if username == "" || password == "" { - return nil, errors.New("username and password are required") + return nil, bridgev2.RespError{ + ErrCode: "COM.BEEPER.REDDIT.MISSING_CREDENTIALS", + Err: "Username and password are required.", + StatusCode: http.StatusBadRequest, + } } p.username = username p.password = password @@ -160,7 +164,11 @@ func (p *PasswordLogin) SubmitUserInput(ctx context.Context, input map[string]st // Second call: OTP code from the user. otp := strings.TrimSpace(input[FieldOTPCode]) if otp == "" { - return nil, errors.New("OTP code is required") + return nil, bridgev2.RespError{ + ErrCode: "COM.BEEPER.REDDIT.MISSING_OTP", + Err: "A two-factor code is required.", + StatusCode: http.StatusBadRequest, + } } // The redditchat library reads the OTP from RedditLoginOptions.TOTPCode, // not via a callback. So we restart the login flow with the OTP populated; @@ -172,7 +180,7 @@ func (p *PasswordLogin) SubmitUserInput(ctx context.Context, input map[string]st func (p *PasswordLogin) SubmitCookies(ctx context.Context, cookies map[string]string) (*bridgev2.LoginStep, error) { token := strings.TrimSpace(cookies[FieldRecaptchaToken]) if token == "" { - return nil, errors.New("missing reCAPTCHA token") + return nil, ErrLoginCaptchaFailed } clientVersion := strings.TrimSpace(cookies[FieldClientVersion]) // Send the token to the waiting login goroutine. @@ -197,7 +205,7 @@ func (p *PasswordLogin) awaitNextStep(ctx context.Context) (*bridgev2.LoginStep, if errors.Is(p.loginErr, errLoginNeedsOTP) { return p.buildOTPStep(), nil } - return nil, p.loginErr + return nil, wrapRedditLoginError(p.loginErr) } return p.completeStep(ctx) case <-ctx.Done(): @@ -299,7 +307,7 @@ func (p *PasswordLogin) runLogin(ctx context.Context) { // captcha token and tries to read the OTP. With TOTPCode unset, it fails // with "otp required". We surface that as errLoginNeedsOTP so the caller // restarts with the OTP filled in. - if err != nil && strings.Contains(err.Error(), "otp required") { + if errors.Is(err, redditchat.ErrOTPRequired) { err = errLoginNeedsOTP } diff --git a/pkg/connector/loginerrors.go b/pkg/connector/loginerrors.go new file mode 100644 index 0000000..0a594d3 --- /dev/null +++ b/pkg/connector/loginerrors.go @@ -0,0 +1,67 @@ +package connector + +import ( + "errors" + "fmt" + "net/http" + + "maunium.net/go/mautrix/bridgev2" + + "github.com/beeper/reddit/pkg/redditchat" +) + +var ( + ErrLoginInvalidCredentials = bridgev2.RespError{ + ErrCode: "COM.BEEPER.REDDIT.INVALID_CREDENTIALS", + Err: "Reddit rejected that username or password. Please check them and try again.", + StatusCode: http.StatusUnauthorized, + } + ErrLoginInvalidOTP = bridgev2.RespError{ + ErrCode: "COM.BEEPER.REDDIT.INVALID_OTP", + Err: "That two-factor code wasn't accepted. Please try again.", + StatusCode: http.StatusUnauthorized, + } + ErrLoginSSORequired = bridgev2.RespError{ + ErrCode: "COM.BEEPER.REDDIT.SSO_REQUIRED", + Err: "This Reddit account signs in with Google or Apple, which this bridge can't use. Set a Reddit password on reddit.com, then try again.", + StatusCode: http.StatusBadRequest, + } + ErrLoginVerificationBlocked = bridgev2.RespError{ + ErrCode: "COM.BEEPER.REDDIT.VERIFICATION_BLOCKED", + Err: "Reddit blocked the sign-in with a verification check. Please wait a few minutes and try again.", + StatusCode: http.StatusForbidden, + } + ErrLoginCaptchaFailed = bridgev2.RespError{ + ErrCode: "COM.BEEPER.REDDIT.CAPTCHA_FAILED", + Err: "The CAPTCHA couldn't be completed. Please try again.", + StatusCode: http.StatusBadRequest, + } + ErrLoginUnknown = bridgev2.RespError{ + ErrCode: "M_UNKNOWN", + Err: "Internal error logging in to Reddit", + StatusCode: http.StatusInternalServerError, + } +) + +// wrapRedditLoginError translates a redditchat error into one the client can act on. +// The original error is kept in the chain with %w — importantly, redditchat's status +// errors embed the raw response body, which must never be shown to a user. +func wrapRedditLoginError(err error) error { + if err == nil { + return nil + } + mapped := ErrLoginUnknown + switch { + case errors.Is(err, redditchat.ErrInvalidCredentials): + mapped = ErrLoginInvalidCredentials + case errors.Is(err, redditchat.ErrInvalidOTP): + mapped = ErrLoginInvalidOTP + case errors.Is(err, redditchat.ErrSSORequired): + mapped = ErrLoginSSORequired + case errors.Is(err, redditchat.ErrBrowserVerificationBlocked): + mapped = ErrLoginVerificationBlocked + case errors.Is(err, redditchat.ErrCaptchaRequired): + mapped = ErrLoginCaptchaFailed + } + return fmt.Errorf("%w: %w", mapped, err) +} diff --git a/pkg/redditchat/login.go b/pkg/redditchat/login.go index 25d5de2..0672868 100644 --- a/pkg/redditchat/login.go +++ b/pkg/redditchat/login.go @@ -45,6 +45,10 @@ const ( var ( ErrCaptchaRequired = errors.New("reddit login: captcha token provider required") ErrBrowserVerificationBlocked = errors.New("reddit login: browser verification blocked") + ErrOTPRequired = errors.New("reddit login: otp required; provide TOTPCode or TOTPSecret") + ErrInvalidOTP = errors.New("reddit login: otp code rejected") + ErrInvalidCredentials = errors.New("reddit login: username or password rejected") + ErrSSORequired = errors.New("reddit login: account requires SSO login") ) type CaptchaRequest struct { @@ -399,7 +403,7 @@ func (s *RedditSession) checkOIDCRequired(ctx context.Context, username, loginPa return err } if resp.IsSSO { - return errors.New("reddit login: account requires SSO login") + return ErrSSORequired } return nil } @@ -453,9 +457,11 @@ func (s *RedditSession) submitPassword(ctx context.Context, opts RedditLoginOpti return err } if resp.StatusCode != http.StatusOK { - return redditStatusError(resp, body) + return fmt.Errorf("%w: %w", ErrInvalidOTP, redditStatusError(resp, body)) } return nil + case http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden: + return fmt.Errorf("%w: %w", ErrInvalidCredentials, redditStatusError(resp, body)) default: return redditStatusError(resp, body) } @@ -575,7 +581,7 @@ func (opts RedditLoginOptions) otpCode(now time.Time) (string, error) { return opts.TOTPCode, nil } if opts.TOTPSecret == "" { - return "", errors.New("reddit login: otp required; provide TOTPCode or TOTPSecret") + return "", ErrOTPRequired } return GenerateTOTP(opts.TOTPSecret, now) } @@ -725,7 +731,7 @@ func checkLoginResponseBody(ctx context.Context, body []byte) error { if len(msg) > 500 { msg = msg[:500] } - return fmt.Errorf("reddit login: password step rejected: %s", msg) + return fmt.Errorf("%w: %s", ErrInvalidCredentials, msg) } return nil }