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 }