// mautrix-discord - A Matrix-Discord puppeting bridge. // Copyright (C) 2026 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package discordauth import ( "context" "encoding/json" "errors" "fmt" "github.com/rs/zerolog" ) // An AuthMachine is a resumable state machine that embodies the core logic to // authenticate a user account with Discord. It is concerned with: // // - Detecting CAPTCHA challenges, reporting them, and attaching the subsequent // solution to the retried request. // - Recognizing conditions such as IP verification and other required // actions. // - Accruing the state and fingerprints necessary to send the correct set of // headers with each HTTP request. // // The machine converses purely in terms of [Prompt] (what we need from the // user) and [Answer] (what the user provided); it does not presuppose how this // information is presented to the user. // // Construct one with [NewAuthMachine], call [AuthMachine.Prepare] once, then // call [AuthMachine.Advance] repeatedly until a [LoginCompleted] is yielded, // which signals completion. // // An AuthMachine is single-use and not safe for use by multiple goroutines. type AuthMachine struct { log *zerolog.Logger http HTTP APIBase string Fingerprint Fingerprint InstallationID string Personality *Personality login string // phone number/email pending *pendingRequest // primary operation; logically, the "position in the login flow" interrupt *pendingRequest // op needed to resolve an interruption (phone verification) lastPrompt *Prompt // last prompt that was shown to the user mfa *LoginMFARequired // set upon entering MFA flow finished bool } func (am *AuthMachine) currentOp() *pendingRequest { if am.interrupt != nil { return am.interrupt } return am.pending } func NewAuthMachine( ctx context.Context, http HTTP, personality *Personality, ) *AuthMachine { log := zerolog.Ctx(ctx).With(). Str("component", "discord auth"). Logger() return &AuthMachine{ log: &log, http: http, APIBase: "https://discord.com/api/v9", Personality: personality, } } type CredsPrompt struct { Reason string } type MFACodePrompt struct { Type AuthenticatorType } type MFAChallengePrompt struct { *LoginMFARequired Reason string } type PhoneVerifyPrompt struct { Phone string // the phone number that the code was sent to Retrying bool // whether the previous attempt failed and we're asking again } // A Prompt is a request for user input or interaction; the [AuthMachine] is // suspended until the user's response is fed back to [AuthMachine.Advance] as // an [Answer]. Exactly one field is set (non-nil or non-zero). // // Most prompts are ordinary steps of the login flow, but Captcha, // PhoneVerifyPrompt, and EmailVerify are interruptions: they preempt whatever // operation was in flight, and answering them ends up retrying it instead of // starting a new one. type Prompt struct { // CredsPrompt is non-nil when we need to prompt for the user's email/phone // number. CredsPrompt *CredsPrompt // Captcha is non-nil when a CAPTCHA challenge must be solved before // proceeding. Captcha *Captcha // MFAChallengePrompt is non-nil when the initial credentials were // accepted, but MFA is required to log in. // // Use the contained information to have the user select an authenticator // type to log in with (TOTP, backup code, or SMS). MFAChallengePrompt *MFAChallengePrompt // MFACodePrompt is non-nil when the selected authenticator type has been // noted and the state machine is ready to accept the MFA code (TOTP, // backup code, or SMS code). MFACodePrompt *MFACodePrompt // PhoneVerifyPrompt is non-nil when a code was sent to the phone number // associated with the Discord account via SMS. // // The code is needed to verify our current IP address. PhoneVerifyPrompt *PhoneVerifyPrompt // EmailVerify is true if an email requesting IP verification has been sent // to the email associated with the Discord account. EmailVerify bool } // A CaptchaSolution contains a solution to a [Captcha]. type CaptchaSolution struct { Solution string } // An Answer responds to the last [Prompt] returned from // [AuthMachine.Advance]. Populate only the field corresponding to the field // that was set on the prompt: // // - [Prompt.CredsPrompt]: [Answer.Creds] // - [Prompt.MFAChallengePrompt]: [Answer.PickedMFAType] // - [Prompt.MFACodePrompt]: [Answer.MFAContinue] // - [Prompt.EmailVerify]: none (an empty Answer resumes the flow) // - [Prompt.PhoneVerifyPrompt]: [Answer.SMSCode] // - [Prompt.Captcha]: [Answer.Solution] type Answer struct { Creds *Creds PickedMFAType *AuthenticatorType MFAContinue *MFAContinue SMSCode string Solution *CaptchaSolution } // Advance consumes an [Answer] to the previously returned [Prompt] (nil on // the first call) and drives authentication forward, performing whatever // Discord API requests are needed. // // When the error is nil, exactly one of the other return values is non-nil: // the next [Prompt] to present, or a [LoginCompleted]. Once a [LoginCompleted] // has been returned, the state machine has completed and further calls to // Advance will error. // // An error does not advance the machine; it remains positioned at the last // prompt. func (am *AuthMachine) Advance(ctx context.Context, answer *Answer) (*Prompt, *LoginCompleted, error) { log := zerolog.Ctx(ctx).With(). Str("action", "advance discord auth state machine"). Logger() ctx = log.WithContext(ctx) if am.finished { return nil, nil, fmt.Errorf("cannot advance finished auth machine") } prompt, completed, err := am.advance(ctx, answer) if prompt != nil { // Remembering which prompt was presented last lets us know what to // expect out of the next [Answer] (see [AuthMachine.advance]). am.lastPrompt = prompt } if completed != nil { am.finished = true } return prompt, completed, err } func (am *AuthMachine) advance(ctx context.Context, answer *Answer) (*Prompt, *LoginCompleted, error) { log := zerolog.Ctx(ctx) lastPrompt := am.lastPrompt if lastPrompt == nil { // Initial state; prompt for email/phone number and password. return &Prompt{CredsPrompt: &CredsPrompt{}}, nil, nil } var captchaSolution *CaptchaSolution switch { case lastPrompt.CredsPrompt != nil: // The user submitted their email/phone number and password. am.login = answer.Creds.Login am.pending = loginOp(answer.Creds) case lastPrompt.MFAChallengePrompt != nil: // The user picked the MFA method they would like to proceed with. picked := *answer.PickedMFAType log.Info().Str("mfa_type", string(picked)).Msg("Continuing with MFA flow") if picked == AuthenticatorSMS { am.pending = sendMFASMSOp(&am.mfa.MFAState) // Fall into the pump to perform the request. break } return &Prompt{MFACodePrompt: &MFACodePrompt{ Type: picked, }}, nil, nil case lastPrompt.MFACodePrompt != nil: // After having picked the authenticator type, the user inputted their // MFA code (the TOTP, backup code, or SMS code). am.pending = continueMFAOp(answer.MFAContinue, am.mfa) case lastPrompt.EmailVerify: // The user has authorized our IP address. Retry the last request. case lastPrompt.PhoneVerifyPrompt != nil: // Our IP address needs to be verified via phone number. The user has // inputted the received SMS code. am.interrupt = verifyPhoneNumberOp(VerifyPhoneNumberRequest{ Phone: lastPrompt.PhoneVerifyPrompt.Phone, Code: answer.SMSCode, }) case lastPrompt.Captcha != nil: // The user solved the CAPTCHA challenge. Retry the last request. captchaSolution = answer.Solution default: return nil, nil, fmt.Errorf("cannot advance from unhandled prompt") } return am.pump(ctx, captchaSolution) } // pump drives the machine's operations forward until one of them yields a // [Prompt] for the user, the login is completed, or an error occurs. func (am *AuthMachine) pump( ctx context.Context, solution *CaptchaSolution, ) (*Prompt, *LoginCompleted, error) { needSolution := am.lastPrompt != nil && am.lastPrompt.Captcha != nil if needSolution && solution == nil { return nil, nil, fmt.Errorf("cannot proceed without a captcha solution") } for { op := am.currentOp() log := zerolog.Ctx(ctx).With(). Str("machine_op_name", op.name). Logger() opCtx := log.WithContext(ctx) // Build the request data. req, err := op.request(opCtx, am) if err != nil { return nil, nil, fmt.Errorf("constructing %s request: %w", op.name, err) } if needSolution { // Apply the CAPTCHA solution to the request. req.Header.Set(HeaderCaptchaKey, solution.Solution) am.lastPrompt.Captcha.UpdateHeaders(&req.Header) // Only apply the solution to the first request in this pump of the // state machine; don't smear it across all requests. (Another // CAPTCHA will terminate the current pump.) needSolution = false } // Send the request to Discord. body, err := am.exchange(opCtx, req) var capErr *CaptchaError var apiErr APIError // Here is where we perform uniform error handling for all // [pendingRequest]s. We presume that a CAPTCHA challenge may be presented // in lieu of a response to _any_ request. switch { case errors.As(err, &capErr): return &Prompt{Captcha: capErr.Captcha}, nil, nil case errors.As(err, &apiErr): if apiErr.RequiresEmailVerification() { return &Prompt{EmailVerify: true}, nil, nil } if apiErr.RequiresPhoneVerification() { // This presumes that the "code sent to phone via SMS, then IP // authorization" flow can only be triggered by logging in with // a phone number instead of an email. return &Prompt{PhoneVerifyPrompt: &PhoneVerifyPrompt{ Phone: am.login, }}, nil, nil } // Give the current [pendingRequest] a chance to handle the error. if op.fail != nil { prompt, err := op.fail(opCtx, am, apiErr) return prompt, nil, err } return nil, nil, apiErr case err != nil: return nil, nil, fmt.Errorf("making %s request: %w", op.name, err) } prompt, done, err := op.succeed(opCtx, am, body) if err != nil || prompt != nil || done != nil { return prompt, done, err } // If we're here, then the op we just finished wants to run more ops. // Verify that something changed pending or interrupt. nextOp := am.currentOp() if nextOp == op { // Don't infinitely loop on the same op. return nil, nil, fmt.Errorf("%s operation did not change the op", op.name) } } } // handleAuthResponse tries to consume a response body from /auth/login or // /auth/mfa/ as a successful login, handling any encountered MFA // challenge. func (am *AuthMachine) handleAuthResponse( ctx context.Context, body []byte, ) (*Prompt, *LoginCompleted, error) { log := zerolog.Ctx(ctx) var completed LoginCompleted err := json.Unmarshal(body, &completed) if err != nil { err = fmt.Errorf("unmarshaling login response: %w", err) return nil, nil, err } if !completed.HasToken() { log.Debug().Msg("Response lacked a token, attempting to handle as MFA") am.mfa = &LoginMFARequired{} err = json.Unmarshal(body, &am.mfa) if err != nil { err = fmt.Errorf("unmarshaling mfa required response: %w", err) return nil, nil, err } if !am.mfa.MFARequired { // Discord responded with something else that we don't know // about yet. err = fmt.Errorf("unknown login response") return nil, nil, err } log := log.With(). Str("mfa_login_instance_id", am.mfa.LoginInstanceID). Bool("mfa_accepting_backup_codes", am.mfa.BackupCodesAccepted). Bool("mfa_sms_enabled", am.mfa.SMSEnabled). Bool("mfa_totp_enabled", am.mfa.TOTPEnabled). Bool("mfa_has_webauthn_credential", am.mfa.WebAuthnCredential != nil). Logger() log.Info().Msg("Need to log in with MFA") return &Prompt{MFAChallengePrompt: &MFAChallengePrompt{ LoginMFARequired: am.mfa, }}, nil, nil } if am.mfa != nil && completed.UserID == "" { log.Debug().Msg("Fixing up login completion with the user ID from the MFA challenge") completed.UserID = am.mfa.UserID } log.Info(). Str("user_locale", completed.UserSettings.Locale). Msg("Logged in successfully") return nil, &completed, nil }