feat: sync Telegram Login OIDC provider

This commit is contained in:
A 2026-07-21 15:46:24 +08:00
parent 30774f8c39
commit ebead9e98c
63 changed files with 11374 additions and 37 deletions

View file

@ -2,6 +2,7 @@ package domain
import (
"errors"
"net"
"net/url"
"strings"
"unicode/utf8"
@ -51,7 +52,12 @@ const (
// MarkupButtonCallback 是 keyboardButtonCallback(点击触发 getBotCallbackAnswer)。
MarkupButtonCallback MarkupButtonType = "callback"
// MarkupButtonURL 是 keyboardButtonUrl(点击打开链接)。
MarkupButtonURL MarkupButtonType = "url"
MarkupButtonURL MarkupButtonType = "url"
// MarkupButtonLoginURL is Bot API login_url / inputKeyboardButtonUrlAuth.
// The target bot is resolved and the linked origin is verified before the
// message is persisted; ButtonID is the stable flattened keyboard index
// returned to clients as keyboardButtonUrlAuth.button_id.
MarkupButtonLoginURL MarkupButtonType = "login_url"
MarkupButtonRequestPhone MarkupButtonType = "request_phone"
MarkupButtonRequestLocation MarkupButtonType = "request_location"
MarkupButtonRequestPoll MarkupButtonType = "request_poll"
@ -122,6 +128,13 @@ type MarkupButton struct {
Data []byte `json:"data,omitempty"`
// URL 仅 url 使用。
URL string `json:"url,omitempty"`
// Login URL-only fields. LoginBotUserID=0 means the sending bot until the
// RPC/Bot API edge resolves it. LoginBotUsername is input-only and must be
// cleared before persistence.
ForwardText string `json:"forward_text,omitempty"`
LoginBotUserID int64 `json:"login_bot_user_id,omitempty"`
LoginBotUsername string `json:"login_bot_username,omitempty"`
RequestWriteAccess bool `json:"request_write_access,omitempty"`
// RequiresPassword 仅 callback 使用(keyboardButtonCallback.requires_password,
// 2FA SRP 校验 P3 stub)。
RequiresPassword bool `json:"requires_password,omitempty"`
@ -343,6 +356,14 @@ func validateMarkupButton(b MarkupButton, replyKeyboard bool) error {
if err := validateButtonURL(b.URL); err != nil {
return err
}
case MarkupButtonLoginURL:
if err := validateLoginButtonURL(b.URL); err != nil {
return err
}
if b.ButtonID < 0 || b.LoginBotUserID < 0 || utf8.RuneCountInString(b.ForwardText) > MaxReplyKeyboardButtonTextLen ||
utf8.RuneCountInString(b.LoginBotUsername) > 64 {
return ErrButtonInvalid
}
case MarkupButtonWebView:
if err := validateButtonURL(b.URL); err != nil {
return err
@ -374,6 +395,33 @@ func validateButtonURL(raw string) error {
return nil
}
// validateLoginButtonURL performs only the protocol-shape validation shared by
// Bot API and MTProto input buttons. The Telegram Login service remains the
// authority for the deployment policy: it rejects loopback HTTP unless the
// explicit development switch is enabled and the exact origin is registered.
func validateLoginButtonURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" || len(raw) > MaxBotMenuButtonURLLen {
return ErrButtonURLInvalid
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" || u.User != nil {
return ErrButtonURLInvalid
}
if u.Scheme == "https" {
return nil
}
if u.Scheme != "http" {
return ErrButtonURLInvalid
}
host := strings.ToLower(u.Hostname())
ip := net.ParseIP(host)
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
return ErrButtonURLInvalid
}
return nil
}
// BotCallbackAnswer 是 bot 对一次 callback query 的应答(setBotCallbackAnswer →
// 解挂等待中的 getBotCallbackAnswer)。
type BotCallbackAnswer struct {

View file

@ -26,6 +26,10 @@ func TestValidateReplyMarkup(t *testing.T) {
{"url http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "http://example.com"}}}}, ErrButtonURLInvalid},
{"url javascript bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "javascript:alert(1)"}}}}, ErrButtonURLInvalid},
{"url empty bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: ""}}}}, ErrButtonURLInvalid},
{"login url loopback http ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://127.0.0.1:8080/login"}}}}, nil},
{"login url localhost http ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://localhost:8080/login"}}}}, nil},
{"login url public http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://example.com/login"}}}}, ErrButtonURLInvalid},
{"login url credentials bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "https://user@example.com/login"}}}}, ErrButtonURLInvalid},
{"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "rainbow", Text: "x"}}}}, ErrButtonTypeInvalid},
{"reply keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Resize: true, Persistent: true, Placeholder: "Choose"}, nil},
{"reply keyboard semantic style ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Delete", Style: MarkupButtonStyleDanger, IconCustomEmojiID: 123}}}}, nil},

View file

@ -0,0 +1,506 @@
package domain
import (
"errors"
"slices"
"strings"
"time"
"unicode/utf8"
)
var (
ErrTelegramLoginClientInvalid = errors.New("telegram login client invalid")
ErrTelegramLoginClientDisabled = errors.New("telegram login client disabled")
ErrTelegramLoginURLInvalid = errors.New("telegram login url invalid")
ErrTelegramLoginRequestInvalid = errors.New("telegram login request invalid")
ErrTelegramLoginRequestExpired = errors.New("telegram login request expired")
ErrTelegramLoginRequestConflict = errors.New("telegram login request conflict")
ErrTelegramLoginMatchCodeInvalid = errors.New("telegram login match code invalid")
ErrTelegramLoginScopeInvalid = errors.New("telegram login scope invalid")
ErrTelegramLoginCodeInvalid = errors.New("telegram login code invalid")
ErrTelegramLoginCodeConsumed = errors.New("telegram login code consumed")
ErrTelegramLoginWebAuthHashInvalid = errors.New("telegram login web authorization hash invalid")
ErrTelegramLoginRedirectNotAllowed = errors.New("telegram login redirect not allowed")
ErrTelegramLoginOriginNotAllowed = errors.New("telegram login origin not allowed")
ErrTelegramLoginSecretInvalid = errors.New("telegram login client secret invalid")
ErrTelegramLoginPKCEInvalid = errors.New("telegram login pkce invalid")
ErrTelegramLoginAuthorizationsTooMany = errors.New("telegram login authorizations too many")
)
const MaxTelegramLoginWebAuthorizations = 1000
type TelegramLoginSigningAlgorithm string
const (
TelegramLoginSigningRS256 TelegramLoginSigningAlgorithm = "RS256"
TelegramLoginSigningES256 TelegramLoginSigningAlgorithm = "ES256"
TelegramLoginSigningEdDSA TelegramLoginSigningAlgorithm = "EdDSA"
TelegramLoginSigningES256K TelegramLoginSigningAlgorithm = "ES256K"
)
func (a TelegramLoginSigningAlgorithm) Valid() bool {
switch a {
case TelegramLoginSigningRS256, TelegramLoginSigningES256, TelegramLoginSigningEdDSA, TelegramLoginSigningES256K:
return true
default:
return false
}
}
type TelegramLoginScope string
const (
TelegramLoginScopeOpenID TelegramLoginScope = "openid"
TelegramLoginScopeProfile TelegramLoginScope = "profile"
TelegramLoginScopePhone TelegramLoginScope = "phone"
TelegramLoginScopeBotAccess TelegramLoginScope = "telegram:bot_access"
)
func (s TelegramLoginScope) Valid() bool {
switch s {
case TelegramLoginScopeOpenID, TelegramLoginScopeProfile, TelegramLoginScopePhone, TelegramLoginScopeBotAccess:
return true
default:
return false
}
}
type TelegramLoginClient struct {
BotUserID int64
ClientID string
SecretHash []byte
SecretVersion int64
SigningAlgorithm TelegramLoginSigningAlgorithm
Enabled bool
CreatedAt time.Time
UpdatedAt time.Time
}
func (c TelegramLoginClient) Clone() TelegramLoginClient {
out := c
out.SecretHash = append([]byte(nil), c.SecretHash...)
return out
}
func (c TelegramLoginClient) Validate() error {
if c.BotUserID <= 0 || c.ClientID == "" || len(c.SecretHash) != 32 || c.SecretVersion <= 0 || !c.SigningAlgorithm.Valid() {
return ErrTelegramLoginClientInvalid
}
return nil
}
type TelegramLoginAllowedURLKind string
const (
TelegramLoginAllowedWebOrigin TelegramLoginAllowedURLKind = "web_origin"
TelegramLoginAllowedRedirectURI TelegramLoginAllowedURLKind = "redirect_uri"
)
type TelegramLoginAllowedURL struct {
ID int64
BotUserID int64
Kind TelegramLoginAllowedURLKind
NormalizedURL string
CreatedAt time.Time
}
type TelegramLoginNativePlatform string
const (
TelegramLoginNativeIOS TelegramLoginNativePlatform = "ios"
TelegramLoginNativeAndroid TelegramLoginNativePlatform = "android"
)
type TelegramLoginNativeApp struct {
ID int64
BotUserID int64
Platform TelegramLoginNativePlatform
ApplicationID string
// VerificationID is the 10-character Apple Team ID on iOS and the
// normalized 64-hex SHA-256 signing-certificate fingerprint on Android.
VerificationID string
CallbackURI string
VerifiedDisplayName string
Enabled bool
CreatedAt time.Time
UpdatedAt time.Time
}
const MaxTelegramLoginNativeApps = 20
func (p TelegramLoginNativePlatform) Valid() bool {
return p == TelegramLoginNativeIOS || p == TelegramLoginNativeAndroid
}
func (a TelegramLoginNativeApp) Validate() error {
if a.BotUserID <= 0 || !a.Platform.Valid() || a.ApplicationID == "" || len(a.ApplicationID) > 255 ||
a.VerificationID == "" || a.CallbackURI == "" || len(a.CallbackURI) > 4096 ||
a.VerifiedDisplayName == "" || len(a.VerifiedDisplayName) > 128 ||
a.CreatedAt.IsZero() || a.UpdatedAt.IsZero() {
return ErrTelegramLoginClientInvalid
}
return nil
}
type TelegramLoginRequestSource string
const (
TelegramLoginRequestWeb TelegramLoginRequestSource = "web"
TelegramLoginRequestJavaScript TelegramLoginRequestSource = "javascript"
TelegramLoginRequestNative TelegramLoginRequestSource = "native"
TelegramLoginRequestMiniApp TelegramLoginRequestSource = "mini_app"
TelegramLoginRequestMessageButton TelegramLoginRequestSource = "message_button"
)
type TelegramLoginRequestState string
const (
TelegramLoginRequestPending TelegramLoginRequestState = "pending"
TelegramLoginRequestApproved TelegramLoginRequestState = "approved"
TelegramLoginRequestDeclined TelegramLoginRequestState = "declined"
TelegramLoginRequestExpired TelegramLoginRequestState = "expired"
)
func (s TelegramLoginRequestState) Terminal() bool {
return s == TelegramLoginRequestApproved || s == TelegramLoginRequestDeclined || s == TelegramLoginRequestExpired
}
func CanTransitionTelegramLoginRequest(from, to TelegramLoginRequestState) bool {
if from != TelegramLoginRequestPending {
return false
}
return to == TelegramLoginRequestApproved || to == TelegramLoginRequestDeclined || to == TelegramLoginRequestExpired
}
type TelegramLoginRequest struct {
ID int64
RequestTokenHash []byte
BrowserTokenHash []byte
BotUserID int64
ClientID string
SigningAlgorithm TelegramLoginSigningAlgorithm
Source TelegramLoginRequestSource
ResponseType string
RedirectURI string
Origin string
Domain string
Scopes []TelegramLoginScope
State string
Nonce string
CodeChallenge string
CodeChallengeMethod string
Browser string
Platform string
IP string
Region string
InAppOrigin string
IsApp bool
VerifiedAppName string
MatchCodes []string
MatchCode string
MatchCodesFirst bool
UserIDHint int64
PeerType PeerType
PeerID int64
MessageID int
ButtonID int
Status TelegramLoginRequestState
AuthorizedUserID int64
ProfileName string
GivenName string
FamilyName string
PreferredUsername string
Picture string
PhoneNumber string
WriteAllowed bool
PhoneShared bool
CreatedAt time.Time
ExpiresAt time.Time
ApprovedAt time.Time
DeclinedAt time.Time
}
func (r TelegramLoginRequest) Clone() TelegramLoginRequest {
out := r
out.RequestTokenHash = append([]byte(nil), r.RequestTokenHash...)
out.BrowserTokenHash = append([]byte(nil), r.BrowserTokenHash...)
out.Scopes = append([]TelegramLoginScope(nil), r.Scopes...)
out.MatchCodes = append([]string(nil), r.MatchCodes...)
return out
}
func (r TelegramLoginRequest) Requests(scope TelegramLoginScope) bool {
return slices.Contains(r.Scopes, scope)
}
func (r TelegramLoginRequest) Validate() error {
if len(r.RequestTokenHash) != 32 || len(r.BrowserTokenHash) != 32 || r.BotUserID <= 0 || r.ClientID == "" || r.ClientID != strings.TrimSpace(r.ClientID) || r.RedirectURI == "" || r.Domain == "" {
return ErrTelegramLoginRequestInvalid
}
if !r.SigningAlgorithm.Valid() || !r.Source.Valid() || (r.ResponseType != "code" && r.ResponseType != "post_message" && r.ResponseType != "legacy_url") ||
r.Status != TelegramLoginRequestPending || r.CreatedAt.IsZero() || !r.ExpiresAt.After(r.CreatedAt) {
return ErrTelegramLoginRequestInvalid
}
switch r.Source {
case TelegramLoginRequestWeb:
if r.ResponseType != "code" {
return ErrTelegramLoginRequestInvalid
}
case TelegramLoginRequestJavaScript:
if r.ResponseType != "post_message" {
return ErrTelegramLoginRequestInvalid
}
case TelegramLoginRequestNative:
if r.ResponseType != "code" || !r.IsApp || r.VerifiedAppName == "" || r.Origin != "" {
return ErrTelegramLoginRequestInvalid
}
case TelegramLoginRequestMiniApp:
if r.ResponseType != "post_message" {
return ErrTelegramLoginRequestInvalid
}
case TelegramLoginRequestMessageButton:
if r.ResponseType != "legacy_url" {
return ErrTelegramLoginRequestInvalid
}
default:
return ErrTelegramLoginRequestInvalid
}
if r.Source != TelegramLoginRequestNative && (r.IsApp || r.VerifiedAppName != "" || r.Origin == "") {
return ErrTelegramLoginRequestInvalid
}
if r.AuthorizedUserID != 0 || r.ProfileName != "" || r.GivenName != "" || r.FamilyName != "" ||
r.PreferredUsername != "" || r.Picture != "" || r.PhoneNumber != "" || r.WriteAllowed || r.PhoneShared ||
!r.ApprovedAt.IsZero() || !r.DeclinedAt.IsZero() {
return ErrTelegramLoginRequestInvalid
}
if len(r.RedirectURI) > 4096 || len(r.Origin) > 4096 || len(r.Domain) > 255 || len(r.InAppOrigin) > 4096 ||
len(r.State) > 2048 || len(r.Nonce) > 1024 || len(r.Browser) == 0 || len(r.Browser) > 255 ||
len(r.Platform) == 0 || len(r.Platform) > 255 || len(r.IP) == 0 || len(r.IP) > 128 ||
len(r.Region) == 0 || len(r.Region) > 255 || len(r.VerifiedAppName) > 128 || r.UserIDHint < 0 ||
r.PeerID < 0 || r.MessageID < 0 || r.ButtonID < 0 || len(r.MatchCodes) > 8 {
return ErrTelegramLoginRequestInvalid
}
if r.ResponseType == "legacy_url" {
if r.Source != TelegramLoginRequestMessageButton || r.PeerID <= 0 || r.MessageID <= 0 ||
(r.PeerType != PeerTypeUser && r.PeerType != PeerTypeChannel) || r.CodeChallenge != "" || r.CodeChallengeMethod != "" ||
len(r.MatchCodes) != 0 || r.MatchCode != "" || r.MatchCodesFirst {
return ErrTelegramLoginRequestInvalid
}
if !slices.Contains(r.Scopes, TelegramLoginScopeOpenID) || !slices.Contains(r.Scopes, TelegramLoginScopeProfile) {
return ErrTelegramLoginScopeInvalid
}
seen := make(map[TelegramLoginScope]struct{}, len(r.Scopes))
for _, scope := range r.Scopes {
if !scope.Valid() || scope == TelegramLoginScopePhone {
return ErrTelegramLoginScopeInvalid
}
if _, duplicate := seen[scope]; duplicate {
return ErrTelegramLoginScopeInvalid
}
seen[scope] = struct{}{}
}
} else if r.ResponseType == "code" {
if err := ValidateTelegramLoginScopes(r.Scopes, r.SigningAlgorithm); err != nil {
return err
}
if r.CodeChallengeMethod != "S256" || r.CodeChallenge == "" {
return ErrTelegramLoginPKCEInvalid
}
} else {
if err := ValidateTelegramLoginScopes(r.Scopes, r.SigningAlgorithm); err != nil {
return err
}
// Telegram's official JavaScript SDK returns an ID token directly and
// therefore sends no authorization-code PKCE parameters. Accept a PKCE
// pair for generic callers, but never a partial pair.
if r.CodeChallenge == "" && r.CodeChallengeMethod == "" {
// Official post_message/Mini App shape.
} else if r.CodeChallengeMethod != "S256" || r.CodeChallenge == "" {
return ErrTelegramLoginPKCEInvalid
}
}
if r.Source == TelegramLoginRequestMiniApp {
if r.ResponseType != "post_message" || r.InAppOrigin == "" || r.Origin != r.InAppOrigin {
return ErrTelegramLoginRequestInvalid
}
} else if r.InAppOrigin != "" {
return ErrTelegramLoginRequestInvalid
}
if r.MatchCodesFirst && len(r.MatchCodes) == 0 {
return ErrTelegramLoginRequestInvalid
}
if len(r.MatchCodes) > 0 && (r.MatchCode == "" || !slices.Contains(r.MatchCodes, r.MatchCode)) {
return ErrTelegramLoginRequestInvalid
}
return nil
}
// TelegramLoginMessageButtonAuthorization is the domain-only input for the
// legacy login_url consent path. BotToken is used transiently to produce the
// official HMAC response and is never persisted in the login aggregate.
type TelegramLoginMessageButtonAuthorization struct {
UserID int64
BotUserID int64
BotToken string
URL string
RequestWriteAccess bool
WriteAllowed bool
Peer Peer
MessageID int
ButtonID int
Browser string
Platform string
IP string
Region string
Identity TelegramLoginIdentitySnapshot
}
type TelegramLoginMessageButtonResult struct {
URL string
Request TelegramLoginRequest
WebAuthorization TelegramLoginWebAuthorization
}
func (s TelegramLoginRequestSource) Valid() bool {
switch s {
case TelegramLoginRequestWeb, TelegramLoginRequestJavaScript, TelegramLoginRequestNative,
TelegramLoginRequestMiniApp, TelegramLoginRequestMessageButton:
return true
default:
return false
}
}
// TelegramLoginIdentitySnapshot is the immutable identity presented on the
// approval screen and later signed into the ID token. It is written together
// with the pending->approved transition so a profile/phone mutation between
// approval and code exchange cannot change what the relying party receives.
type TelegramLoginIdentitySnapshot struct {
UserID int64
Name string
GivenName string
FamilyName string
PreferredUsername string
Picture string
PhoneNumber string
}
func (s TelegramLoginIdentitySnapshot) Sanitized(includeProfile, includePhone bool) (TelegramLoginIdentitySnapshot, error) {
if s.UserID <= 0 {
return TelegramLoginIdentitySnapshot{}, ErrTelegramLoginRequestInvalid
}
out := TelegramLoginIdentitySnapshot{UserID: s.UserID}
if includeProfile {
out.Name = strings.TrimSpace(s.Name)
out.GivenName = strings.TrimSpace(s.GivenName)
out.FamilyName = strings.TrimSpace(s.FamilyName)
out.PreferredUsername = strings.TrimSpace(s.PreferredUsername)
out.Picture = strings.TrimSpace(s.Picture)
if out.Name == "" || out.GivenName == "" {
return TelegramLoginIdentitySnapshot{}, ErrTelegramLoginRequestInvalid
}
}
if includePhone {
out.PhoneNumber = NormalizePhone(s.PhoneNumber)
if !ValidPhone(out.PhoneNumber) {
return TelegramLoginIdentitySnapshot{}, ErrPhoneNumberInvalid
}
}
if !boundedUTF8(out.Name, 255) || !boundedUTF8(out.GivenName, 255) || !boundedUTF8(out.FamilyName, 255) ||
!boundedUTF8(out.PreferredUsername, 64) || !boundedUTF8(out.Picture, 4096) || len(out.PhoneNumber) > 32 {
return TelegramLoginIdentitySnapshot{}, ErrTelegramLoginRequestInvalid
}
return out, nil
}
func boundedUTF8(value string, maxBytes int) bool {
return utf8.ValidString(value) && len(value) <= maxBytes
}
func ValidateTelegramLoginScopes(scopes []TelegramLoginScope, alg TelegramLoginSigningAlgorithm) error {
if !alg.Valid() || len(scopes) == 0 || !slices.Contains(scopes, TelegramLoginScopeOpenID) {
return ErrTelegramLoginScopeInvalid
}
seen := make(map[TelegramLoginScope]struct{}, len(scopes))
for _, scope := range scopes {
if !scope.Valid() {
return ErrTelegramLoginScopeInvalid
}
if _, duplicate := seen[scope]; duplicate {
return ErrTelegramLoginScopeInvalid
}
seen[scope] = struct{}{}
}
if alg == TelegramLoginSigningEdDSA || alg == TelegramLoginSigningES256K {
if len(scopes) != 1 || scopes[0] != TelegramLoginScopeOpenID {
return ErrTelegramLoginScopeInvalid
}
}
return nil
}
type TelegramLoginApproval struct {
RequestID int64
Identity TelegramLoginIdentitySnapshot
WriteAllowed bool
PhoneShared bool
MatchCode string
ApprovedAt time.Time
}
type TelegramLoginAuthorizationCode struct {
ID int64
RequestID int64
CodeHash []byte
SealedCode []byte
SealNonce []byte
SealKeyID string
IssuedAt time.Time
ExpiresAt time.Time
ConsumedAt time.Time
}
// TelegramLoginCodeExchange carries the values already normalized/hashed by
// the application service. The durable store compares them again while the
// code/request/client rows are locked, closing redirect, PKCE and secret-
// rotation TOCTOU gaps between HTTP validation and one-time consumption.
type TelegramLoginCodeExchange struct {
CodeHash []byte
ClientID string
ClientSecretVersion int64
RedirectURI string
CodeChallenge string
Now time.Time
}
func (c TelegramLoginAuthorizationCode) Clone() TelegramLoginAuthorizationCode {
out := c
out.CodeHash = append([]byte(nil), c.CodeHash...)
out.SealedCode = append([]byte(nil), c.SealedCode...)
out.SealNonce = append([]byte(nil), c.SealNonce...)
return out
}
type TelegramLoginWebAuthorization struct {
Hash int64
RequestID int64
UserID int64
BotUserID int64
Domain string
Browser string
Platform string
IP string
Region string
Scopes []TelegramLoginScope
PhoneShared bool
BotAccessGranted bool
CreatedAt time.Time
LastActiveAt time.Time
RevokedAt time.Time
}
func (a TelegramLoginWebAuthorization) Clone() TelegramLoginWebAuthorization {
out := a
out.Scopes = append([]TelegramLoginScope(nil), a.Scopes...)
return out
}

View file

@ -0,0 +1,112 @@
package domain
import (
"strings"
"testing"
"time"
)
func TestValidateTelegramLoginScopes(t *testing.T) {
tests := []struct {
name string
scopes []TelegramLoginScope
alg TelegramLoginSigningAlgorithm
valid bool
}{
{name: "rs profile phone", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopeProfile, TelegramLoginScopePhone}, alg: TelegramLoginSigningRS256, valid: true},
{name: "missing openid", scopes: []TelegramLoginScope{TelegramLoginScopeProfile}, alg: TelegramLoginSigningRS256},
{name: "duplicate", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopeOpenID}, alg: TelegramLoginSigningRS256},
{name: "unknown", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, "admin"}, alg: TelegramLoginSigningRS256},
{name: "eddsa openid", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID}, alg: TelegramLoginSigningEdDSA, valid: true},
{name: "eddsa profile forbidden", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopeProfile}, alg: TelegramLoginSigningEdDSA},
{name: "es256k phone forbidden", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopePhone}, alg: TelegramLoginSigningES256K},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := ValidateTelegramLoginScopes(test.scopes, test.alg)
if (err == nil) != test.valid {
t.Fatalf("ValidateTelegramLoginScopes() error = %v, valid = %v", err, test.valid)
}
})
}
}
func TestTelegramLoginRequestTransitions(t *testing.T) {
for _, terminal := range []TelegramLoginRequestState{
TelegramLoginRequestApproved,
TelegramLoginRequestDeclined,
TelegramLoginRequestExpired,
} {
if !CanTransitionTelegramLoginRequest(TelegramLoginRequestPending, terminal) {
t.Fatalf("pending -> %s must be valid", terminal)
}
if CanTransitionTelegramLoginRequest(terminal, TelegramLoginRequestPending) {
t.Fatalf("%s -> pending must be forbidden", terminal)
}
}
if CanTransitionTelegramLoginRequest(TelegramLoginRequestApproved, TelegramLoginRequestDeclined) {
t.Fatal("approved -> declined must be forbidden")
}
}
func TestTelegramLoginRequestSourceShapeMatrix(t *testing.T) {
now := time.Unix(1_780_000_000, 0).UTC()
base := TelegramLoginRequest{
RequestTokenHash: make([]byte, 32), BrowserTokenHash: make([]byte, 32),
BotUserID: 9001, ClientID: "9001", SigningAlgorithm: TelegramLoginSigningRS256,
Source: TelegramLoginRequestWeb, ResponseType: "code", RedirectURI: "https://rp.example/callback",
Origin: "https://rp.example", Domain: "rp.example", Scopes: []TelegramLoginScope{TelegramLoginScopeOpenID},
CodeChallenge: strings.Repeat("A", 43), CodeChallengeMethod: "S256",
Browser: "Firefox", Platform: "Windows", IP: "192.0.2.1", Region: "Test",
Status: TelegramLoginRequestPending, CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute),
}
if err := base.Validate(); err != nil {
t.Fatalf("valid web request: %v", err)
}
invalid := []struct {
name string
mutate func(*TelegramLoginRequest)
}{
{name: "web post message", mutate: func(r *TelegramLoginRequest) {
r.ResponseType = "post_message"
r.CodeChallenge = ""
r.CodeChallengeMethod = ""
}},
{name: "javascript code", mutate: func(r *TelegramLoginRequest) { r.Source = TelegramLoginRequestJavaScript }},
{name: "message button code", mutate: func(r *TelegramLoginRequest) { r.Source = TelegramLoginRequestMessageButton }},
{name: "web app flag", mutate: func(r *TelegramLoginRequest) { r.IsApp = true; r.VerifiedAppName = "Forged" }},
{name: "web missing origin", mutate: func(r *TelegramLoginRequest) { r.Origin = "" }},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
request := base.Clone()
tc.mutate(&request)
if err := request.Validate(); err == nil {
t.Fatal("forbidden source shape was accepted")
}
})
}
native := base.Clone()
native.Source, native.Origin, native.Domain = TelegramLoginRequestNative, "", "dev.bedolaga.demo"
native.IsApp, native.VerifiedAppName = true, "Bedolaga"
if err := native.Validate(); err != nil {
t.Fatalf("valid native request: %v", err)
}
native.IsApp = false
if err := native.Validate(); err == nil {
t.Fatal("native request without verified app state was accepted")
}
mini := base.Clone()
mini.Source, mini.ResponseType = TelegramLoginRequestMiniApp, "post_message"
mini.CodeChallenge, mini.CodeChallengeMethod = "", ""
mini.RedirectURI, mini.InAppOrigin = "https://rp.example/", mini.Origin
if err := mini.Validate(); err != nil {
t.Fatalf("valid Mini App request: %v", err)
}
mini.InAppOrigin = "https://other.example"
if err := mini.Validate(); err == nil {
t.Fatal("Mini App origin mismatch was accepted")
}
}