// Package loginwidget validates Telegram Login Widget authorization data, the // web (non-Mini-App) sign-in flow used to attach a Telegram identity to an existing // account during linking (Stage 11). Like initdata it lives in the connector // because the secret is derived from the bot token, held only here // (ARCHITECTURE.md ยง12); the gateway calls the connector's ValidateLoginWidget RPC. // // The Login Widget algorithm differs from Mini App initData: the secret key is // SHA-256(bot_token) (not HMAC(bot_token, "WebAppData")), the data-check string is // the sorted key=value lines of the top-level fields (id, first_name, username, // auth_date, ...), and there is no nested user JSON or language_code. package loginwidget import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "net/url" "sort" "strconv" "strings" "time" ) // ErrInvalidLoginWidget is returned when the data fails HMAC validation, is // missing the hash or id, is malformed, or is older than the freshness window. var ErrInvalidLoginWidget = errors.New("loginwidget: invalid telegram login widget data") // defaultMaxAge bounds how old a validated payload may be. const defaultMaxAge = 24 * time.Hour // User is the identity extracted from validated Login Widget data. ExternalID is // the Telegram user id used as the identities external_id. type User struct { ExternalID string Username string FirstName string } // Validator validates Login Widget data and returns the authenticated user. It is // an interface so the connector can be tested with a fixture. type Validator interface { Validate(data string) (User, error) } // HMACValidator validates Login Widget data against a bot token. type HMACValidator struct { botToken string maxAge time.Duration now func() time.Time } // NewHMACValidator constructs a validator for botToken. func NewHMACValidator(botToken string) *HMACValidator { return &HMACValidator{botToken: botToken, maxAge: defaultMaxAge, now: time.Now} } // Validate parses and verifies the widget data (a URL-encoded key=value query // string carrying the widget fields plus hash) and returns the authenticated user. func (v *HMACValidator) Validate(data string) (User, error) { values, err := url.ParseQuery(data) if err != nil { return User{}, ErrInvalidLoginWidget } hash := values.Get("hash") if hash == "" { return User{}, ErrInvalidLoginWidget } values.Del("hash") if !v.checkSignature(values, hash) { return User{}, ErrInvalidLoginWidget } if err := v.checkFreshness(values.Get("auth_date")); err != nil { return User{}, err } id := values.Get("id") if id == "" { return User{}, ErrInvalidLoginWidget } return User{ExternalID: id, Username: values.Get("username"), FirstName: values.Get("first_name")}, nil } // checkSignature recomputes the HMAC over the sorted data-check string under the // SHA-256(bot_token) secret and compares it with hash in constant time. func (v *HMACValidator) checkSignature(values url.Values, hash string) bool { keys := make([]string, 0, len(values)) for k := range values { keys = append(keys, k) } sort.Strings(keys) lines := make([]string, 0, len(keys)) for _, k := range keys { lines = append(lines, k+"="+values.Get(k)) } dataCheck := strings.Join(lines, "\n") secret := sha256.Sum256([]byte(v.botToken)) mac := hmac.New(sha256.New, secret[:]) mac.Write([]byte(dataCheck)) want := mac.Sum(nil) got, err := hex.DecodeString(hash) if err != nil { return false } return hmac.Equal(want, got) } // checkFreshness rejects an auth_date older than the validator's window. func (v *HMACValidator) checkFreshness(authDate string) error { if authDate == "" { return ErrInvalidLoginWidget } secs, err := strconv.ParseInt(authDate, 10, 64) if err != nil { return ErrInvalidLoginWidget } if v.now().Sub(time.Unix(secs, 0)) > v.maxAge { return ErrInvalidLoginWidget } return nil }