From 542a2c535ca0f23aa8c1ce02be325a1c318371a7 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev <1turbobaby@gmail.com> Date: Thu, 17 Sep 2026 12:31:03 +0700 Subject: [PATCH] fix(telegram-bridge): the code went to a set of zero devices and we told them to wait Four defects on one login screen, reported by Dmitrii . sentCodeTypeApp delivers to other logged-in sessions of the same number. A user whose only session is the one they are creating has nowhere to receive it. The bridge discarded auth.sentCode's Type, Timeout and NextType and let the UI guess "app/SMS", so that user was advised to watch for an SMS that could never arrive. SendCode now returns all four and the handler states the channel Telegram named. "Request a new code" called sendCode again, which is free to pick the same dead channel. It now calls auth.resendCode, honours Telegram's own timeout instead of a locally invented one, and refuses outright when Telegram nominated no next channel rather than pretending to try. QR login is added as the route that does not depend on code delivery at all: auth.exportLoginToken, polled, with SESSION_PASSWORD_NEEDED handed to the existing Check2FA so 2FA reuses the screen that already exists. The in-flight login lived in process memory and in a FileStorage on a container filesystem, so any redeploy between "enter phone" and "enter code" silently killed it. It now persists to Postgres and getClient restores it. The storage this uses was already written and unused, aimed at a telegram_sessions table nothing in the repository created; the table is created here too. Tests cover the delivery-type mapping, that an unknown type is never reported as "app", and that a resend without a next channel refuses. --- .../telegram-bridge/internal/api/handlers.go | 277 +++++++++++++++++- .../internal/api/pending_auth.go | 163 +++++++++++ .../internal/telegram/auth_code.go | 211 +++++++++++++ .../internal/telegram/auth_code_test.go | 113 +++++++ .../internal/telegram/auth_qr.go | 170 +++++++++++ .../internal/telegram/client.go | 46 +-- 6 files changed, 950 insertions(+), 30 deletions(-) create mode 100644 tools/telegram-bridge/internal/api/pending_auth.go create mode 100644 tools/telegram-bridge/internal/telegram/auth_code.go create mode 100644 tools/telegram-bridge/internal/telegram/auth_code_test.go create mode 100644 tools/telegram-bridge/internal/telegram/auth_qr.go diff --git a/tools/telegram-bridge/internal/api/handlers.go b/tools/telegram-bridge/internal/api/handlers.go index dd2b9cda06..67db82b9fa 100644 --- a/tools/telegram-bridge/internal/api/handlers.go +++ b/tools/telegram-bridge/internal/api/handlers.go @@ -53,6 +53,8 @@ func NewRouter(cfg *config.Config, db *sql.DB) *Router { wsHub: NewWSHub(), } + r.initAuthPersistence() + // Initialize Bot API client if token is configured log.Printf("[INIT] BotToken set: %v (len=%d)", cfg.BotToken != "", len(cfg.BotToken)) log.Printf("[INIT] BotWebhookURL: %s", cfg.BotWebhookURL) @@ -213,6 +215,9 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/v1/auth/phone", r.handleAuthPhone) r.mux.HandleFunc("/api/v1/auth/code", r.handleAuthCode) r.mux.HandleFunc("/api/v1/auth/2fa", r.handleAuth2FA) + r.mux.HandleFunc("/api/v1/auth/resend", r.handleAuthResend) + r.mux.HandleFunc("/api/v1/auth/qr", r.handleAuthQR) + r.mux.HandleFunc("/api/v1/auth/qr/status", r.handleAuthQRStatus) r.mux.HandleFunc("/api/v1/me", r.handleGetMe) r.mux.HandleFunc("/api/v1/dialogs", r.handleGetDialogs) r.mux.HandleFunc("/api/v1/history/", r.handleGetHistory) @@ -368,8 +373,16 @@ func (r *Router) handleConnect(w http.ResponseWriter, req *http.Request) { // Generate session ID sessionID := generateSessionID() - // Create session storage - use gotd's FileStorage directly for proper persistence - sessionStorage := &session.FileStorage{Path: r.cfg.SessionDir + "/" + sessionID + ".session"} + // Session storage. On a container filesystem FileStorage is not + // persistence -- it is a file that dies with the container, so a redeploy + // mid-login took the session with it. Use the database when we have one + // and keep the file only as the local-development fallback. + var sessionStorage session.Storage + if r.db != nil { + sessionStorage = telegram.NewPostgresSessionStorage(r.db, sessionID) + } else { + sessionStorage = &session.FileStorage{Path: r.cfg.SessionDir + "/" + sessionID + ".session"} + } // Create Telegram client client, err := telegram.NewClient(body.AppID, body.AppHash, sessionStorage) @@ -441,14 +454,14 @@ func (r *Router) handleAuthPhone(w http.ResponseWriter, req *http.Request) { log.Printf("Sending code to phone: %s", body.Phone) // Retry logic for DC migration - Telegram may require reconnection to different data center - var codeHash string + var sent telegram.SentCode var err error maxRetries := 2 for attempt := 1; attempt <= maxRetries; attempt++ { // Use longer timeout per attempt to allow DC migration to complete ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) - codeHash, err = client.SendCode(ctx, body.Phone) + sent, err = client.SendCode(ctx, body.Phone) cancel() if err == nil { @@ -474,15 +487,243 @@ func (r *Router) handleAuthPhone(w http.ResponseWriter, req *http.Request) { return } - log.Printf("Auth phone request success: phone=%s", body.Phone) + if sent.AlreadyAuthorized { + respondJSON(w, http.StatusOK, map[string]interface{}{ + "status": "authorized", + "message": "This session is already authorized; no code was sent.", + }) + return + } + + r.savePendingAuth(sessionID, client) + + log.Printf("Auth phone success: phone=%s delivery=%s timeout=%ds next=%q", + body.Phone, sent.Type, sent.TimeoutSeconds, sent.NextType) + + // The delivery channel is reported as Telegram stated it, never guessed. + // "app" in particular is not a synonym for "check your SMS": it means the + // code went to other logged-in sessions of this number, and if there are + // none, it went nowhere the user can reach. Saying so is the difference + // between a user who waits for an SMS that will never arrive and a user + // who scans a QR code. + resp := map[string]interface{}{ + "status": "code_sent", + "code_hash": sent.CodeHash, + "delivery": sent.Type, + "can_resend": sent.CanResend(), + "message": describeDelivery(sent), + } + if sent.Length > 0 { + resp["code_length"] = sent.Length + } + if sent.TimeoutSeconds > 0 { + resp["resend_after_seconds"] = sent.TimeoutSeconds + } + if sent.NextType != "" { + resp["next_delivery"] = sent.NextType + } + if sent.Detail != "" { + resp["delivery_detail"] = sent.Detail + } + respondJSON(w, http.StatusOK, resp) +} + +// describeDelivery states, in the user's own terms, where the code went. +func describeDelivery(s telegram.SentCode) string { + switch s.Type { + case "app": + return "The code was sent to Telegram itself, in the chat named \"Telegram\", " + + "on your other logged-in devices for this number. If this number is not " + + "logged in anywhere else, no code will arrive on any channel -- use QR login instead." + case "sms": + return "The code was sent by SMS." + case "call": + return "The code will be dictated by an automated phone call." + case "missed_call": + return "You will receive a missed call; the code is the last digits of the calling number." + case "flash_call": + return "You will receive a dropped call; the code is taken from the calling number." + case "email": + return "The code was sent to the login email " + s.Detail + "." + case "fragment": + return "The code was delivered through Fragment: " + s.Detail + case "firebase_sms": + return "The code was sent by SMS via Firebase." + case "setup_email_required": + return "This account must set up a login email before a code can be sent." + default: + return "Telegram used a delivery channel this bridge does not recognise (" + s.Detail + "). " + + "Rather than guess, we are telling you so: QR login does not depend on code delivery." + } +} + +// AuthResendRequest is the request for /auth/resend +type AuthResendRequest struct { + SessionID string `json:"session_id,omitempty"` +} + +// handleAuthResend advances the code to Telegram's next delivery channel. +// +// The old "Request a new code" button called /auth/phone again. That restarts +// auth.sendCode, which is free to choose the same channel it already chose, so +// for the user who cannot receive an app code it repeated the failure with a +// fresh timer. auth.resendCode moves to the next_type Telegram itself +// nominated, and when it nominates none we say so instead of pretending. +func (r *Router) handleAuthResend(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + var body AuthResendRequest + _ = json.NewDecoder(req.Body).Decode(&body) + + sessionID := req.Header.Get("X-Session-ID") + if sessionID == "" { + sessionID = body.SessionID + } + client := r.getClient(sessionID) + if client == nil { + respondError(w, http.StatusUnauthorized, "Invalid or missing session") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + sent, err := client.ResendCode(ctx) + if err != nil { + log.Printf("ResendCode error for session %s: %v", sessionID, err) + respondError(w, http.StatusBadRequest, err.Error()) + return + } + + r.savePendingAuth(sessionID, client) + + resp := map[string]interface{}{ + "status": "code_resent", + "code_hash": sent.CodeHash, + "delivery": sent.Type, + "can_resend": sent.CanResend(), + "message": describeDelivery(sent), + } + if sent.TimeoutSeconds > 0 { + resp["resend_after_seconds"] = sent.TimeoutSeconds + } + if sent.NextType != "" { + resp["next_delivery"] = sent.NextType + } + respondJSON(w, http.StatusOK, resp) +} + +// AuthQRRequest is the request for the QR endpoints. +type AuthQRRequest struct { + SessionID string `json:"session_id,omitempty"` +} + +// handleAuthQR issues a tg://login token for the user to scan. +func (r *Router) handleAuthQR(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + var body AuthQRRequest + _ = json.NewDecoder(req.Body).Decode(&body) + + sessionID := req.Header.Get("X-Session-ID") + if sessionID == "" { + sessionID = body.SessionID + } + client := r.getClient(sessionID) + if client == nil { + respondError(w, http.StatusUnauthorized, "Invalid or missing session") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + code, err := client.ExportQR(ctx) + if err != nil { + log.Printf("ExportQR error for session %s: %v", sessionID, err) + respondError(w, http.StatusInternalServerError, "Failed to export login token: "+err.Error()) + return + } + if code == nil { + respondJSON(w, http.StatusOK, map[string]interface{}{ + "status": "authorized", + "message": "This session is already authorized.", + }) + return + } + + r.savePendingAuth(sessionID, client) respondJSON(w, http.StatusOK, map[string]interface{}{ - "status": "code_sent", - "code_hash": codeHash, - "message": "Verification code sent to phone", + "status": "qr_issued", + "url": code.URL, + "expires_at": code.ExpiresAt, + "message": "Open Telegram on the phone that holds this number, go to " + + "Settings -> Devices -> Link Desktop Device, and scan this code. " + + "Poll /api/v1/auth/qr/status until it reports authorized.", }) } +// handleAuthQRStatus reports whether the token has been scanned yet. +func (r *Router) handleAuthQRStatus(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet && req.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + sessionID := req.Header.Get("X-Session-ID") + if sessionID == "" { + sessionID = req.URL.Query().Get("session_id") + } + client := r.getClient(sessionID) + if client == nil { + respondError(w, http.StatusUnauthorized, "Invalid or missing session") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + status, err := client.PollQR(ctx) + if err != nil { + log.Printf("PollQR error for session %s: %v", sessionID, err) + respondError(w, http.StatusInternalServerError, "Failed to poll login token: "+err.Error()) + return + } + + resp := map[string]interface{}{"state": string(status.State)} + switch status.State { + case telegram.QRPending: + resp["message"] = "Not scanned yet." + case telegram.QRExpired: + resp["message"] = "The previous token expired; a fresh one is attached. The login itself is not lost." + case telegram.QRNeedsPassword: + // Identical second factor to the code path, so the client can reuse + // the same screen it already has. + resp["message"] = "Scanned. This account has two-step verification; POST the cloud password to /api/v1/auth/2fa." + case telegram.QRAuthorized: + resp["message"] = "Authorized." + if err := r.clearPendingAuth(sessionID); err != nil { + log.Printf("clear pending auth for session %s: %v", sessionID, err) + } + } + if status.Token != nil { + resp["url"] = status.Token.URL + resp["expires_at"] = status.Token.ExpiresAt + r.savePendingAuth(sessionID, client) + } + if status.User != nil { + resp["user"] = status.User + } + respondJSON(w, http.StatusOK, resp) +} + // AuthCodeRequest is the request for /auth/code type AuthCodeRequest struct { SessionID string `json:"session_id,omitempty"` @@ -537,6 +778,11 @@ func (r *Router) handleAuthCode(w http.ResponseWriter, req *http.Request) { log.Printf("Auth code request: user=%s", user.Username) + // The login is complete; the in-flight record has nothing left to protect. + if err := r.clearPendingAuth(sessionID); err != nil { + log.Printf("clear pending auth for session %s: %v", sessionID, err) + } + // Explicitly save session to disk if err := client.SaveSession(); err != nil { log.Printf("⚠️ Failed to save session after SignIn: %v", err) @@ -595,6 +841,10 @@ func (r *Router) handleAuth2FA(w http.ResponseWriter, req *http.Request) { log.Printf("Auth 2FA request: user=%s", user.Username) + if err := r.clearPendingAuth(sessionID); err != nil { + log.Printf("clear pending auth for session %s: %v", sessionID, err) + } + // Explicitly save session to disk if err := client.SaveSession(); err != nil { log.Printf("⚠️ Failed to save session after 2FA: %v", err) @@ -898,8 +1148,15 @@ func (r *Router) handleWebSocket(w http.ResponseWriter, req *http.Request) { func (r *Router) getClient(sessionID string) *telegram.Client { r.mu.RLock() - defer r.mu.RUnlock() - return r.clients[sessionID] + client := r.clients[sessionID] + r.mu.RUnlock() + if client != nil { + return client + } + // Not in memory. After a restart that is true of every session, so before + // telling the caller their session is invalid, look for a persisted + // in-flight login. This is the whole point of persisting it. + return r.restoreClient(sessionID) } func (r *Router) forwardUpdates(sessionID string, client *telegram.Client) { diff --git a/tools/telegram-bridge/internal/api/pending_auth.go b/tools/telegram-bridge/internal/api/pending_auth.go new file mode 100644 index 0000000000..520eb59ee8 --- /dev/null +++ b/tools/telegram-bridge/internal/api/pending_auth.go @@ -0,0 +1,163 @@ +package api + +import ( + "context" + "database/sql" + "encoding/json" + "log" + "time" + + "github.com/vibee/telegram-bridge/internal/telegram" +) + +// An unfinished login used to live entirely in this process: the *telegram.Client +// in r.clients, and inside it the phone and code hash. A redeploy between "enter +// your phone" and "enter the code" -- which on Railway is any push to main -- +// dropped both. The user then typed a perfectly valid code into a bridge that no +// longer knew which login it belonged to, and got "Invalid or missing session" +// with no explanation, or worse, a generic failure that looked like Telegram's +// fault. +// +// What follows moves the in-flight part out of memory. The session blob itself +// already had a home in telegram_sessions; it simply was not being used, because +// handleConnect hard-coded gotd's FileStorage against an ephemeral container +// filesystem. + +const pendingAuthSchema = ` +CREATE TABLE IF NOT EXISTS telegram_pending_auth ( + session_id TEXT PRIMARY KEY, + app_id INTEGER NOT NULL, + app_hash TEXT NOT NULL, + flow JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TABLE IF NOT EXISTS telegram_sessions ( + phone TEXT PRIMARY KEY, + session_data BYTEA, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +);` + +// initAuthPersistence creates the tables the login flow needs. +// +// telegram_sessions is included because PostgresSessionStorage referenced it +// while nothing in the repository ever created it -- it was dead code aimed at +// a table that did not exist. +func (r *Router) initAuthPersistence() { + if r.db == nil { + log.Printf("[AUTH] No database configured: in-flight logins will NOT survive a restart") + return + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if _, err := r.db.ExecContext(ctx, pendingAuthSchema); err != nil { + log.Printf("[AUTH] Failed to create auth persistence tables: %v", err) + } +} + +// savePendingAuth records the unfinished login. Best effort by design: a +// database hiccup must not fail a login that is otherwise proceeding, so it is +// logged rather than returned. +func (r *Router) savePendingAuth(sessionID string, client *telegram.Client) { + if r.db == nil || sessionID == "" { + return + } + + appID, appHash := client.Credentials() + flow, err := json.Marshal(client.ExportPendingAuth()) + if err != nil { + log.Printf("[AUTH] marshal pending auth for %s: %v", sessionID, err) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err = r.db.ExecContext(ctx, ` + INSERT INTO telegram_pending_auth (session_id, app_id, app_hash, flow) + VALUES ($1, $2, $3, $4) + ON CONFLICT (session_id) DO UPDATE SET + app_id = EXCLUDED.app_id, + app_hash = EXCLUDED.app_hash, + flow = EXCLUDED.flow, + updated_at = NOW() + `, sessionID, appID, appHash, flow) + if err != nil { + log.Printf("[AUTH] save pending auth for %s: %v", sessionID, err) + } +} + +// clearPendingAuth drops the record once the login has completed. +func (r *Router) clearPendingAuth(sessionID string) error { + if r.db == nil || sessionID == "" { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := r.db.ExecContext(ctx, + "DELETE FROM telegram_pending_auth WHERE session_id = $1", sessionID) + return err +} + +// restoreClient rebuilds a client for a session this process has never seen, +// which after a restart is every session. +// +// It returns nil when there is nothing to restore -- an unknown session id is +// still an unknown session id, and this must not manufacture one. +func (r *Router) restoreClient(sessionID string) *telegram.Client { + if r.db == nil || sessionID == "" { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var appID int + var appHash string + var flowJSON []byte + err := r.db.QueryRowContext(ctx, ` + SELECT app_id, app_hash, flow FROM telegram_pending_auth WHERE session_id = $1 + `, sessionID).Scan(&appID, &appHash, &flowJSON) + if err == sql.ErrNoRows { + return nil + } + if err != nil { + log.Printf("[AUTH] restore pending auth for %s: %v", sessionID, err) + return nil + } + + var pending telegram.PendingAuth + if err := json.Unmarshal(flowJSON, &pending); err != nil { + log.Printf("[AUTH] unmarshal pending auth for %s: %v", sessionID, err) + return nil + } + + storage := telegram.NewPostgresSessionStorage(r.db, sessionID) + client, err := telegram.NewClient(appID, appHash, storage) + if err != nil { + log.Printf("[AUTH] rebuild client for %s: %v", sessionID, err) + return nil + } + if err := client.Connect(context.Background()); err != nil { + log.Printf("[AUTH] reconnect restored client for %s: %v", sessionID, err) + return nil + } + client.RestorePendingAuth(pending) + + r.mu.Lock() + // Another request may have restored the same session while this one was + // connecting; the first one to register wins and this copy is discarded. + if existing, ok := r.clients[sessionID]; ok { + r.mu.Unlock() + return existing + } + r.clients[sessionID] = client + r.mu.Unlock() + + go r.forwardUpdates(sessionID, client) + + log.Printf("[AUTH] Restored in-flight login for session %s after restart (phone=%s, delivery=%s)", + sessionID, pending.Phone, pending.CodeType) + return client +} diff --git a/tools/telegram-bridge/internal/telegram/auth_code.go b/tools/telegram-bridge/internal/telegram/auth_code.go new file mode 100644 index 0000000000..8204df3b00 --- /dev/null +++ b/tools/telegram-bridge/internal/telegram/auth_code.go @@ -0,0 +1,211 @@ +package telegram + +import ( + "context" + "fmt" + "time" + + "github.com/gotd/td/tg" +) + +// SentCode describes how Telegram actually delivered a login code. +// +// The UI used to guess between "app" and "SMS". It has no business guessing: +// auth.sendCode says which channel it used, how long the code is, how long the +// caller must wait before asking for another, and what the next channel would +// be. All four are reported here so the screen can state a fact instead of a +// hopeful default. +type SentCode struct { + CodeHash string `json:"code_hash"` + // Type is the channel the code went to: app, sms, call, missed_call, + // flash_call, email, fragment, firebase_sms, or setup_email_required. + Type string `json:"type"` + // Length is the number of digits, where the channel defines one. + Length int `json:"length,omitempty"` + // TimeoutSeconds is Telegram's own timeout before a resend is allowed. + // Zero means Telegram sent none, and the caller must not invent one. + TimeoutSeconds int `json:"timeout_seconds,omitempty"` + // NextType is the channel a resend would use, empty if Telegram did not + // offer one -- in which case there is nothing to resend to. + NextType string `json:"next_type,omitempty"` + // Detail carries channel-specific information the user needs: the + // Fragment URL, the flash-call pattern, the missed-call prefix, the + // email pattern. + Detail string `json:"detail,omitempty"` + // AlreadyAuthorized is set when Telegram short-circuited the flow. + AlreadyAuthorized bool `json:"already_authorized,omitempty"` +} + +// CanResend reports whether Telegram offered a next delivery channel. +func (s SentCode) CanResend() bool { return s.NextType != "" } + +// describeSentCodeType maps a tg.AuthSentCodeTypeClass onto the wire names and +// the human-visible detail. Every branch is named; an unknown type is reported +// as unknown rather than silently rendered as "app". +func describeSentCodeType(t tg.AuthSentCodeTypeClass) (kind string, length int, detail string) { + switch v := t.(type) { + case *tg.AuthSentCodeTypeApp: + return "app", v.Length, "sent to another logged-in Telegram session on this number" + case *tg.AuthSentCodeTypeSMS: + return "sms", v.Length, "" + case *tg.AuthSentCodeTypeCall: + return "call", v.Length, "dictated by an automated call" + case *tg.AuthSentCodeTypeFlashCall: + return "flash_call", 0, v.Pattern + case *tg.AuthSentCodeTypeMissedCall: + return "missed_call", v.Length, "last digits of the calling number, prefix " + v.Prefix + case *tg.AuthSentCodeTypeEmailCode: + return "email", v.Length, v.EmailPattern + case *tg.AuthSentCodeTypeSetUpEmailRequired: + return "setup_email_required", 0, "the account must set up a login email before a code can be sent" + case *tg.AuthSentCodeTypeFragmentSMS: + return "fragment", v.Length, v.URL + case *tg.AuthSentCodeTypeFirebaseSMS: + return "firebase_sms", v.Length, "" + default: + return "unknown", 0, fmt.Sprintf("%T", t) + } +} + +// describeNextType maps the optional next-delivery hint. +func describeNextType(t tg.AuthCodeTypeClass) string { + switch t.(type) { + case *tg.AuthCodeTypeSMS: + return "sms" + case *tg.AuthCodeTypeCall: + return "call" + case *tg.AuthCodeTypeFlashCall: + return "flash_call" + case *tg.AuthCodeTypeMissedCall: + return "missed_call" + case *tg.AuthCodeTypeFragmentSMS: + return "fragment" + default: + return "" + } +} + +// readSentCode turns the raw auth.sentCode into a SentCode and records the +// parts of it the resend path needs. +func (c *Client) readSentCode(sentCode tg.AuthSentCodeClass) SentCode { + switch v := sentCode.(type) { + case *tg.AuthSentCode: + kind, length, detail := describeSentCodeType(v.Type) + + next := "" + if nt, ok := v.GetNextType(); ok { + next = describeNextType(nt) + } + timeout, _ := v.GetTimeout() + + c.mu.Lock() + c.authFlow.codeHash = v.PhoneCodeHash + c.authFlow.codeType = kind + c.authFlow.nextType = next + c.authFlow.timeout = timeout + c.authFlow.sentAt = time.Now() + c.mu.Unlock() + + return SentCode{ + CodeHash: v.PhoneCodeHash, + Type: kind, + Length: length, + TimeoutSeconds: timeout, + NextType: next, + Detail: detail, + } + + case *tg.AuthSentCodeSuccess: + c.mu.Lock() + c.isAuthed = true + c.mu.Unlock() + return SentCode{AlreadyAuthorized: true} + } + + return SentCode{Type: "unknown"} +} + +// ResendCode asks Telegram to deliver the code over its next channel. +// +// This is not the same call as repeating SendCode. auth.sendCode starts the +// flow again and Telegram is free to pick the same channel it already used, so +// a "request a new code" button wired to it changes nothing for a user who has +// no other session to receive an app code. auth.resendCode advances to the +// next_type Telegram itself nominated, which is the only way the channel +// actually changes. +func (c *Client) ResendCode(ctx context.Context) (SentCode, error) { + c.mu.RLock() + phone := c.authFlow.phone + codeHash := c.authFlow.codeHash + next := c.authFlow.nextType + timeout := c.authFlow.timeout + sentAt := c.authFlow.sentAt + c.mu.RUnlock() + + if phone == "" || codeHash == "" { + return SentCode{}, fmt.Errorf("send code first") + } + if next == "" { + return SentCode{}, fmt.Errorf("telegram offered no next delivery channel for this number; a resend would change nothing -- use QR login instead") + } + if timeout > 0 { + if wait := time.Duration(timeout)*time.Second - time.Since(sentAt); wait > 0 { + return SentCode{}, fmt.Errorf("resend not permitted for another %d seconds", int(wait.Seconds())+1) + } + } + + sentCode, err := c.api.API().AuthResendCode(ctx, &tg.AuthResendCodeRequest{ + PhoneNumber: phone, + PhoneCodeHash: codeHash, + }) + if err != nil { + return SentCode{}, fmt.Errorf("resend code: %w", err) + } + + return c.readSentCode(sentCode), nil +} + +// PendingAuth is the part of an unfinished login that must outlive the +// process. Keeping it only in memory meant a redeploy between "enter phone" +// and "enter code" silently lost the flow and the user's code stopped working +// with no explanation. +type PendingAuth struct { + Phone string `json:"phone"` + CodeHash string `json:"code_hash"` + CodeType string `json:"code_type"` + NextType string `json:"next_type"` + Timeout int `json:"timeout"` + SentAt time.Time `json:"sent_at"` + QRToken string `json:"qr_token,omitempty"` + QRExpires time.Time `json:"qr_expires,omitempty"` +} + +// ExportPendingAuth snapshots the unfinished login for persistence. +func (c *Client) ExportPendingAuth() PendingAuth { + c.mu.RLock() + defer c.mu.RUnlock() + return PendingAuth{ + Phone: c.authFlow.phone, + CodeHash: c.authFlow.codeHash, + CodeType: c.authFlow.codeType, + NextType: c.authFlow.nextType, + Timeout: c.authFlow.timeout, + SentAt: c.authFlow.sentAt, + QRToken: c.authFlow.qrToken, + QRExpires: c.authFlow.qrExpires, + } +} + +// RestorePendingAuth reinstates an unfinished login after a restart. +func (c *Client) RestorePendingAuth(p PendingAuth) { + c.mu.Lock() + defer c.mu.Unlock() + c.authFlow.phone = p.Phone + c.authFlow.codeHash = p.CodeHash + c.authFlow.codeType = p.CodeType + c.authFlow.nextType = p.NextType + c.authFlow.timeout = p.Timeout + c.authFlow.sentAt = p.SentAt + c.authFlow.qrToken = p.QRToken + c.authFlow.qrExpires = p.QRExpires +} diff --git a/tools/telegram-bridge/internal/telegram/auth_code_test.go b/tools/telegram-bridge/internal/telegram/auth_code_test.go new file mode 100644 index 0000000000..efa4964141 --- /dev/null +++ b/tools/telegram-bridge/internal/telegram/auth_code_test.go @@ -0,0 +1,113 @@ +package telegram + +import ( + "testing" + + "github.com/gotd/td/tg" +) + +// The defect these guard: the bridge told every user "app/SMS" regardless of +// what Telegram said, so a user whose code went to a set of zero logged-in +// sessions was advised to wait for an SMS that could never arrive. +func TestDescribeSentCodeType(t *testing.T) { + cases := []struct { + name string + in tg.AuthSentCodeTypeClass + wantKind string + wantLen int + }{ + {"app", &tg.AuthSentCodeTypeApp{Length: 5}, "app", 5}, + {"sms", &tg.AuthSentCodeTypeSMS{Length: 5}, "sms", 5}, + {"call", &tg.AuthSentCodeTypeCall{Length: 6}, "call", 6}, + {"missed", &tg.AuthSentCodeTypeMissedCall{Prefix: "+79", Length: 4}, "missed_call", 4}, + {"email", &tg.AuthSentCodeTypeEmailCode{EmailPattern: "d*@e*.com", Length: 6}, "email", 6}, + {"fragment", &tg.AuthSentCodeTypeFragmentSMS{URL: "https://fragment.com/x", Length: 6}, "fragment", 6}, + {"firebase", &tg.AuthSentCodeTypeFirebaseSMS{Length: 6}, "firebase_sms", 6}, + {"setup_email", &tg.AuthSentCodeTypeSetUpEmailRequired{}, "setup_email_required", 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + kind, length, _ := describeSentCodeType(c.in) + if kind != c.wantKind { + t.Errorf("kind = %q, want %q", kind, c.wantKind) + } + if length != c.wantLen { + t.Errorf("length = %d, want %d", length, c.wantLen) + } + }) + } +} + +// An unrecognised type must say so rather than fall back to "app". Reporting +// an unknown channel as the one channel that silently delivers nowhere is the +// exact failure being fixed. +func TestDescribeSentCodeTypeUnknownIsNotApp(t *testing.T) { + kind, _, detail := describeSentCodeType(&tg.AuthSentCodeTypeSMSWord{}) + if kind != "unknown" { + t.Fatalf("kind = %q, want %q", kind, "unknown") + } + if detail == "" { + t.Error("unknown type reported no detail; the caller has nothing to show") + } +} + +func TestDescribeNextType(t *testing.T) { + if got := describeNextType(&tg.AuthCodeTypeSMS{}); got != "sms" { + t.Errorf("sms next type = %q", got) + } + if got := describeNextType(&tg.AuthCodeTypeCall{}); got != "call" { + t.Errorf("call next type = %q", got) + } +} + +// CanResend is what the UI uses to decide whether to offer the button at all. +// Offering it when Telegram nominated no next channel is how the old flow +// promised the user something it could not deliver. +func TestCanResend(t *testing.T) { + if (SentCode{NextType: "sms"}).CanResend() != true { + t.Error("a nominated next channel should be resendable") + } + if (SentCode{}).CanResend() != false { + t.Error("no next channel means no resend; the button must not be offered") + } +} + +// A resend with no next channel must fail loudly instead of repeating +// auth.sendCode and appearing to work. +func TestResendCodeRefusesWithoutNextChannel(t *testing.T) { + c := &Client{authFlow: &authFlow{}} + c.authFlow.phone = "+79990000000" + c.authFlow.codeHash = "hash" + // nextType deliberately empty. + + if _, err := c.ResendCode(t.Context()); err == nil { + t.Fatal("expected refusal when Telegram nominated no next channel") + } +} + +func TestResendCodeRequiresSendCodeFirst(t *testing.T) { + c := &Client{authFlow: &authFlow{}} + if _, err := c.ResendCode(t.Context()); err == nil { + t.Fatal("expected refusal when no code has been sent") + } +} + +func TestPendingAuthRoundTrip(t *testing.T) { + c := &Client{authFlow: &authFlow{}} + in := PendingAuth{ + Phone: "+79990000000", + CodeHash: "hash", + CodeType: "app", + NextType: "sms", + Timeout: 60, + QRToken: "tok", + } + c.RestorePendingAuth(in) + out := c.ExportPendingAuth() + + if out.Phone != in.Phone || out.CodeHash != in.CodeHash || + out.CodeType != in.CodeType || out.NextType != in.NextType || + out.Timeout != in.Timeout || out.QRToken != in.QRToken { + t.Errorf("round trip lost data: got %+v, want %+v", out, in) + } +} diff --git a/tools/telegram-bridge/internal/telegram/auth_qr.go b/tools/telegram-bridge/internal/telegram/auth_qr.go new file mode 100644 index 0000000000..03dd2a4e2f --- /dev/null +++ b/tools/telegram-bridge/internal/telegram/auth_qr.go @@ -0,0 +1,170 @@ +package telegram + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/gotd/td/telegram/auth/qrlogin" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" +) + +// QR login is the second route into an account, and for some users it is the +// only one. +// +// sentCodeTypeApp delivers the login code to other logged-in sessions of the +// same number. A user whose only session is the one they are trying to create +// has nowhere to receive it, and no amount of resending changes that: the code +// is not lost, it was delivered to a set of zero devices. auth.exportLoginToken +// does not depend on code delivery at all -- the already-logged-in phone scans +// a token and confirms. That is why this path exists. + +// QRCode is the token a client turns into a scannable image. +type QRCode struct { + // URL is the tg://login?token=... link. Render it as a QR code; the + // Telegram app's "Link Desktop Device" scanner reads it. + URL string `json:"url"` + // ExpiresAt is when Telegram stops accepting this token. Past it, call + // ExportQR again -- the login itself is not lost, only the token. + ExpiresAt time.Time `json:"expires_at"` +} + +// QRState is the outcome of one poll of the QR login. +type QRState string + +const ( + // QRPending means the token has not been scanned yet. + QRPending QRState = "pending" + // QRExpired means the token timed out; export a fresh one. + QRExpired QRState = "expired" + // QRNeedsPassword means the token was accepted and the account has 2FA. + // The cloud password must now be supplied via Check2FA -- exactly the + // same second factor as the code path. + QRNeedsPassword QRState = "needs_password" + // QRAuthorized means the login is complete. + QRAuthorized QRState = "authorized" +) + +// QRStatus is a poll result. +type QRStatus struct { + State QRState `json:"state"` + // Token is set when a fresh token was issued during this poll, either + // because the previous one expired or because Telegram rotated it. + Token *QRCode `json:"token,omitempty"` + User *User `json:"user,omitempty"` +} + +// qr builds the login helper, with DC migration wired to the client's own +// connection so a token issued on the wrong DC does not dead-end. +func (c *Client) qr() qrlogin.QR { + return qrlogin.NewQR(c.api.API(), c.appID, c.appHash, qrlogin.Options{ + Migrate: c.api.MigrateTo, + }) +} + +// ExportQR issues a login token and records it against the pending flow. +func (c *Client) ExportQR(ctx context.Context) (*QRCode, error) { + token, err := c.qr().Export(ctx) + if err != nil { + return nil, fmt.Errorf("export login token: %w", err) + } + if token.Empty() { + // AuthLoginTokenSuccess: a previous token was already accepted. + c.mu.Lock() + c.isAuthed = true + c.mu.Unlock() + return nil, nil + } + + code := &QRCode{URL: token.URL(), ExpiresAt: token.Expires()} + + c.mu.Lock() + c.authFlow.qrToken = token.String() + c.authFlow.qrExpires = token.Expires() + c.mu.Unlock() + + return code, nil +} + +// PollQR asks Telegram whether the token has been scanned. +// +// One call, one honest answer: still waiting, expired and here is a new one, +// scanned but the account wants its cloud password, or done. The caller polls +// this rather than holding a socket open, so a redeploy between the scan and +// the confirmation does not lose the flow -- the token lives in the persisted +// pending-auth record, not in a goroutine. +func (c *Client) PollQR(ctx context.Context) (*QRStatus, error) { + auth, err := c.qr().Import(ctx) + if err == nil { + user, uerr := c.acceptAuthorization(auth) + if uerr != nil { + return nil, uerr + } + return &QRStatus{State: QRAuthorized, User: user}, nil + } + + // The account has 2FA. The token was accepted; only the second factor + // is outstanding, and it is the same Check2FA the code path uses. + if tgerr.Is(err, "SESSION_PASSWORD_NEEDED") { + return &QRStatus{State: QRNeedsPassword}, nil + } + + // Import re-exports the token to learn its state. An unscanned token + // comes back as auth.loginToken, which Import cannot use and reports as + // an unexpected type -- that is "not scanned yet", not a failure. + if isUnscannedToken(err) { + c.mu.RLock() + expires := c.authFlow.qrExpires + c.mu.RUnlock() + if !expires.IsZero() && time.Now().After(expires) { + fresh, ferr := c.ExportQR(ctx) + if ferr != nil { + return nil, ferr + } + return &QRStatus{State: QRExpired, Token: fresh}, nil + } + return &QRStatus{State: QRPending}, nil + } + + if tgerr.Is(err, "AUTH_TOKEN_EXPIRED") { + fresh, ferr := c.ExportQR(ctx) + if ferr != nil { + return nil, ferr + } + return &QRStatus{State: QRExpired, Token: fresh}, nil + } + + return nil, fmt.Errorf("poll qr login: %w", err) +} + +// isUnscannedToken reports whether the error is Import's complaint about being +// handed a still-pending auth.loginToken. +func isUnscannedToken(err error) bool { + return err != nil && strings.Contains(err.Error(), "unexpected type *tg.AuthLoginToken") +} + +// acceptAuthorization records a completed login and returns the user. +func (c *Client) acceptAuthorization(auth *tg.AuthAuthorization) (*User, error) { + user, ok := auth.User.(*tg.User) + if !ok { + return nil, fmt.Errorf("unexpected user type %T", auth.User) + } + + c.mu.Lock() + c.isAuthed = true + c.mu.Unlock() + + if err := c.SaveSession(); err != nil { + return nil, fmt.Errorf("save session after qr login: %w", err) + } + + return &User{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Username: user.Username, + Phone: user.Phone, + }, nil +} diff --git a/tools/telegram-bridge/internal/telegram/client.go b/tools/telegram-bridge/internal/telegram/client.go index 8dd140dcfa..03d89d86f6 100644 --- a/tools/telegram-bridge/internal/telegram/client.go +++ b/tools/telegram-bridge/internal/telegram/client.go @@ -215,7 +215,18 @@ func (c *Client) Connect(ctx context.Context) error { } // SendCode sends authentication code to phone -func (c *Client) SendCode(ctx context.Context, phone string) (string, error) { +// Credentials returns the API credentials this client was built with, so a +// restored session can be rebuilt with the same ones. +func (c *Client) Credentials() (int, string) { + return c.appID, c.appHash +} + +// SendCode starts the login flow and reports how Telegram delivered the code. +// +// The whole auth.sentCode response is kept, not just the hash: the delivery +// channel, the code length, Telegram's own resend timeout and the channel a +// resend would use. Discarding those is what forced the UI to guess. +func (c *Client) SendCode(ctx context.Context, phone string) (SentCode, error) { c.mu.Lock() c.authFlow.phone = phone c.mu.Unlock() @@ -228,27 +239,10 @@ func (c *Client) SendCode(ctx context.Context, phone string) (string, error) { Settings: tg.CodeSettings{}, }) if err != nil { - return "", fmt.Errorf("send code: %w", err) - } - - // Extract phone code hash based on type - var codeHash string - switch v := sentCode.(type) { - case *tg.AuthSentCode: - codeHash = v.PhoneCodeHash - case *tg.AuthSentCodeSuccess: - // Already authorized - c.mu.Lock() - c.isAuthed = true - c.mu.Unlock() - return "", nil + return SentCode{}, fmt.Errorf("send code: %w", err) } - c.mu.Lock() - c.authFlow.codeHash = codeHash - c.mu.Unlock() - - return codeHash, nil + return c.readSentCode(sentCode), nil } // SignIn verifies code and signs in @@ -1189,6 +1183,18 @@ func (c *Client) extractChatID(peer tg.PeerClass) int64 { type authFlow struct { phone string codeHash string + // codeType, nextType and timeout are what Telegram actually said about + // the delivery. They used to be discarded, which is why the UI guessed + // "app/SMS" and why the resend timer was invented locally instead of + // being read from the response. + codeType string + nextType string + timeout int + sentAt time.Time + // qrToken is the exported login token for the QR path, base64url as it + // appears in the tg://login?token=... URL. + qrToken string + qrExpires time.Time } // computeSRP computes SRP parameters for 2FA using gotd/td crypto/srp package