Skip to content
Open
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
20 changes: 14 additions & 6 deletions pkg/connector/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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.
Expand All @@ -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():
Expand Down Expand Up @@ -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
}

Expand Down
67 changes: 67 additions & 0 deletions pkg/connector/loginerrors.go
Original file line number Diff line number Diff line change
@@ -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)
}
14 changes: 10 additions & 4 deletions pkg/redditchat/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down
Loading