// Package auth holds the gateway's credential validators. The only non-trivial // one is the Telegram Web App initData HMAC check; guest and email logins carry // no gateway-side secret and are validated by the backend. The validator is an // interface so handlers test against fixtures without a bot token. package auth import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "net/url" "sort" "strconv" "strings" "time" ) // ErrInvalidInitData is returned when initData fails HMAC validation, is missing // the hash, is malformed, or is older than the freshness window. var ErrInvalidInitData = errors.New("auth: invalid telegram init data") // defaultMaxAge bounds how old a validated initData payload may be. const defaultMaxAge = 24 * time.Hour // TelegramUser is the identity extracted from a validated initData payload. ID // is the platform user id used as the identity's external_id. type TelegramUser struct { ID string Username string FirstName string } // TelegramValidator validates Telegram Web App launch data and returns the // authenticated user. type TelegramValidator interface { Validate(initData string) (TelegramUser, error) } // HMACValidator validates initData against a bot token per Telegram's documented // algorithm: the data-check string is HMAC-SHA256'd under a secret derived from // the bot token, and the result is compared with the supplied hash. 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 initData, returning the authenticated user. func (v *HMACValidator) Validate(initData string) (TelegramUser, error) { values, err := url.ParseQuery(initData) if err != nil { return TelegramUser{}, ErrInvalidInitData } hash := values.Get("hash") if hash == "" { return TelegramUser{}, ErrInvalidInitData } values.Del("hash") if !v.checkSignature(values, hash) { return TelegramUser{}, ErrInvalidInitData } if err := v.checkFreshness(values.Get("auth_date")); err != nil { return TelegramUser{}, err } return parseUser(values.Get("user")) } // checkSignature recomputes the HMAC over the sorted data-check string 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 := hmacSHA256([]byte("WebAppData"), []byte(v.botToken)) want := hmacSHA256(secret, []byte(dataCheck)) 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 ErrInvalidInitData } secs, err := strconv.ParseInt(authDate, 10, 64) if err != nil { return ErrInvalidInitData } if v.now().Sub(time.Unix(secs, 0)) > v.maxAge { return ErrInvalidInitData } return nil } // parseUser extracts the user id and names from the user JSON field. func parseUser(userJSON string) (TelegramUser, error) { if userJSON == "" { return TelegramUser{}, ErrInvalidInitData } var u struct { ID int64 `json:"id"` Username string `json:"username"` FirstName string `json:"first_name"` } if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 { return TelegramUser{}, ErrInvalidInitData } return TelegramUser{ ID: strconv.FormatInt(u.ID, 10), Username: u.Username, FirstName: u.FirstName, }, nil } // hmacSHA256 returns HMAC-SHA256(message) under key. func hmacSHA256(key, message []byte) []byte { h := hmac.New(sha256.New, key) h.Write(message) return h.Sum(nil) }