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

@ -380,14 +380,10 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
ID)
})
registerRPC[*tg.AccountGetWebAuthorizationsRequest](d, tlprofile.SemanticMethodAccountGetWebAuthorizations, func(ctx context.Context, layerRequest *tg.AccountGetWebAuthorizationsRequest) (any, error) {
return tdesktop.WebAuthorizations(), nil
return r.onAccountGetWebAuthorizations(ctx)
})
registerRPC[*tg.AccountResetWebAuthorizationRequest](d, tlprofile.SemanticMethodAccountResetWebAuthorization, func(ctx context.Context, layerRequest *tg.AccountResetWebAuthorizationRequest) (any, error) {
hash := layerRequest.
Hash
_ = hash
return true, nil
return r.onAccountResetWebAuthorization(ctx, layerRequest.Hash)
})
registerRPC[*tg.AccountResetWebAuthorizationsRequest](d, tlprofile.SemanticMethodAccountResetWebAuthorizations, func(ctx context.Context, layerRequest *tg.AccountResetWebAuthorizationsRequest) (
@ -395,7 +391,7 @@ func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
// 无内置浏览器例外、不强制外部浏览器。Android 启动时会拉取,缺它会反复 500
// NOT_IMPLEMENTED。空结构 Hash=0客户端按默认内置浏览器、无例外渲染。
any, error) {
return true, nil
return r.onAccountResetWebAuthorizations(ctx)
})
registerRPC[*tg.AccountGetWebBrowserSettingsRequest](d, tlprofile.SemanticMethodAccountGetWebBrowserSettings, func(ctx context.Context, layerRequest *tg.AccountGetWebBrowserSettingsRequest) (any, error) {
hash := layerRequest.

View file

@ -834,6 +834,14 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64,
return domain.Message{}, errors.New("MESSAGE_TOO_LONG")
}
peer := domain.Peer{Type: domain.PeerTypeUser, ID: chatID}
if setReplyMarkup {
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
return domain.Message{}, replyMarkupErr(err)
}
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
return domain.Message{}, err
}
}
res, err := r.deps.Messages.EditMessage(ctx, botID, domain.EditMessageRequest{
OwnerUserID: botID,
Peer: peer,
@ -934,6 +942,11 @@ func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, i
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
return false, replyMarkupErr(err)
}
if setReplyMarkup {
if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil {
return false, replyMarkupErr(err)
}
}
req := &tg.MessagesEditInlineBotMessageRequest{
ID: tgInputBotInlineMessageID(inlineMessageID),
NoWebpage: disableWebPagePreview,
@ -959,6 +972,11 @@ func (r *Router) BotAPIEditInlineRichMessage(ctx context.Context, botID int64, i
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
return false, replyMarkupErr(err)
}
if setReplyMarkup {
if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil {
return false, replyMarkupErr(err)
}
}
wire, err := tgInputRichMessageFromBotAPI(input)
if err != nil {
return false, err

View file

@ -300,6 +300,9 @@ func (r *Router) domainInlineResultsFromTG(ctx context.Context, botID int64, req
if err != nil {
return domain.BotInlineResults{}, err
}
if err := r.prepareTelegramLoginMarkup(ctx, botID, item.ReplyMarkup); err != nil {
return domain.BotInlineResults{}, replyMarkupErr(err)
}
if _, ok := seen[item.ID]; ok {
return domain.BotInlineResults{}, resultIDDuplicateErr()
}

View file

@ -3,6 +3,7 @@ package rpc
import (
"context"
"errors"
"strings"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
@ -14,6 +15,9 @@ import (
// a chat input field and are not supported in broadcast channels. Inline keyboards remain
// valid in both megagroups and broadcasts.
func (r *Router) validateReplyMarkupForPeer(ctx context.Context, userID int64, peer domain.Peer, markup *domain.MessageReplyMarkup) error {
if err := r.prepareTelegramLoginMarkup(ctx, userID, markup); err != nil {
return replyMarkupErr(err)
}
if markup == nil || !markup.IsReplyKeyboardFamily() || peer.Type != domain.PeerTypeChannel {
return nil
}
@ -30,6 +34,92 @@ func (r *Router) validateReplyMarkupForPeer(ctx context.Context, userID int64, p
return nil
}
// prepareTelegramLoginMarkup resolves every login_url target and validates its
// linked web origin before persistence. It mutates only the freshly parsed
// request DTO and assigns a deterministic flattened button id, which is later
// re-read by messages.requestUrlAuth.
func (r *Router) prepareTelegramLoginMarkup(ctx context.Context, senderBotID int64, markup *domain.MessageReplyMarkup) error {
if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline {
return nil
}
hasLoginButton := false
for rowIndex := range markup.Inline {
for buttonIndex := range markup.Inline[rowIndex] {
if markup.Inline[rowIndex][buttonIndex].Type == domain.MarkupButtonLoginURL {
hasLoginButton = true
break
}
}
if hasLoginButton {
break
}
}
if !hasLoginButton {
return nil
}
if r == nil || r.deps.TelegramLogin == nil || r.deps.Users == nil || senderBotID <= 0 {
return domain.ErrButtonTypeInvalid
}
sender, found, err := r.deps.Users.ByID(ctx, senderBotID, senderBotID)
if err != nil {
return err
}
if !found || !sender.Bot || sender.Deleted {
return domain.ErrButtonTypeInvalid
}
flatID := 0
for rowIndex := range markup.Inline {
for buttonIndex := range markup.Inline[rowIndex] {
button := &markup.Inline[rowIndex][buttonIndex]
if button.Type != domain.MarkupButtonLoginURL {
flatID++
continue
}
botID := button.LoginBotUserID
if button.LoginBotUsername != "" {
resolver, ok := r.deps.Users.(UserIdentityService)
if !ok {
return domain.ErrButtonInvalid
}
bot, found, err := resolver.ResolveUsername(ctx, senderBotID, strings.TrimPrefix(button.LoginBotUsername, "@"))
if err != nil {
return err
}
if !found || !bot.Bot || bot.Deleted {
return domain.ErrButtonInvalid
}
botID = bot.ID
}
if botID == 0 {
botID = senderBotID
}
bot, found, err := r.deps.Users.ByID(ctx, senderBotID, botID)
if err != nil {
return err
}
if !found || !bot.Bot || bot.Deleted {
return domain.ErrButtonInvalid
}
normalized, _, err := r.deps.TelegramLogin.ValidateMessageButton(ctx, botID, button.URL)
if err != nil {
if errors.Is(err, domain.ErrTelegramLoginURLInvalid) || errors.Is(err, domain.ErrTelegramLoginOriginNotAllowed) {
return domain.ErrButtonURLInvalid
}
if errors.Is(err, domain.ErrTelegramLoginClientDisabled) {
return domain.ErrButtonInvalid
}
return err
}
button.URL = normalized
button.LoginBotUserID = botID
button.LoginBotUsername = ""
button.ButtonID = flatID
flatID++
}
}
return domain.ValidateReplyMarkup(markup)
}
// P3 reply_markup 错误码(对齐官方)。
func buttonDataInvalidErr() error { return tgerr.New(400, "BUTTON_DATA_INVALID") }
func buttonInvalidErr() error { return tgerr.New(400, "BUTTON_INVALID") }
@ -175,21 +265,23 @@ func domainReplyKeyboardButton(button tg.KeyboardButtonClass) (domain.MarkupButt
func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarkup, error) {
out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))}
buttonID := 0
for _, row := range inline.Rows {
domainRow := make([]domain.MarkupButton, 0, len(row.Buttons))
for _, btn := range row.Buttons {
db, err := domainMarkupButton(btn)
db, err := domainMarkupButton(btn, buttonID)
if err != nil {
return nil, err
}
domainRow = append(domainRow, db)
buttonID++
}
out.Inline = append(out.Inline, domainRow)
}
return out, nil
}
func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error) {
func domainMarkupButton(btn tg.KeyboardButtonClass, buttonID int) (domain.MarkupButton, error) {
style, icon, err := domainMarkupButtonStyle(btn)
if err != nil {
return domain.MarkupButton{}, err
@ -209,6 +301,26 @@ func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error)
Type: domain.MarkupButtonURL, Text: b.Text, URL: b.URL,
Style: style, IconCustomEmojiID: icon,
}, nil
case *tg.InputKeyboardButtonURLAuth:
botUserID := int64(0)
switch bot := b.Bot.(type) {
case nil, *tg.InputUserEmpty, *tg.InputUserSelf:
case *tg.InputUser:
botUserID = bot.UserID
default:
return domain.MarkupButton{}, domain.ErrButtonInvalid
}
return domain.MarkupButton{
Type: domain.MarkupButtonLoginURL, Text: b.Text, URL: b.URL,
ForwardText: b.FwdText, ButtonID: buttonID, LoginBotUserID: botUserID,
RequestWriteAccess: b.RequestWriteAccess, Style: style, IconCustomEmojiID: icon,
}, nil
case *tg.KeyboardButtonURLAuth:
return domain.MarkupButton{
Type: domain.MarkupButtonLoginURL, Text: b.Text, URL: b.URL,
ForwardText: b.FwdText, ButtonID: b.ButtonID,
Style: style, IconCustomEmojiID: icon,
}, nil
case *tg.KeyboardButtonWebView:
return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: b.Text, URL: b.URL, Style: style, IconCustomEmojiID: icon}, nil
case *tg.KeyboardButtonSwitchInline:
@ -322,6 +434,15 @@ func tgMarkupButton(btn domain.MarkupButton) tg.KeyboardButtonClass {
out.SetStyle(style)
}
return out
case domain.MarkupButtonLoginURL:
out := &tg.KeyboardButtonURLAuth{Text: btn.Text, URL: btn.URL, ButtonID: btn.ButtonID}
if btn.ForwardText != "" {
out.SetFwdText(btn.ForwardText)
}
if style, ok := tgMarkupButtonStyle(btn); ok {
out.SetStyle(style)
}
return out
case domain.MarkupButtonWebView:
out := &tg.KeyboardButtonWebView{Text: btn.Text, URL: btn.URL}
if style, ok := tgMarkupButtonStyle(btn); ok {

View file

@ -71,6 +71,26 @@ func TestInlineButtonStyleTLDomainRoundTrip(t *testing.T) {
}
}
func TestLoginURLButtonTLDomainProjection(t *testing.T) {
button := &tg.InputKeyboardButtonURLAuth{
Text: "Log in", URL: "https://example.com/login", Bot: &tg.InputUser{UserID: 9001, AccessHash: 77},
}
button.SetRequestWriteAccess(true)
button.SetFwdText("Open login")
markup, err := domainReplyMarkupForSender(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{button}}}}, true)
if err != nil {
t.Fatal(err)
}
got := markup.Inline[0][0]
if got.Type != domain.MarkupButtonLoginURL || got.LoginBotUserID != 9001 || !got.RequestWriteAccess || got.ForwardText != "Open login" || got.ButtonID != 0 {
t.Fatalf("domain login_url = %#v", got)
}
wire, ok := tgReplyMarkup(markup).(*tg.ReplyInlineMarkup).Rows[0].Buttons[0].(*tg.KeyboardButtonURLAuth)
if !ok || wire.Text != "Log in" || wire.URL != "https://example.com/login" || wire.ButtonID != 0 || wire.FwdText != "Open login" {
t.Fatalf("wire login_url = %#v", wire)
}
}
func TestReplyKeyboardHideAndForceReplyTLDomainRoundTrip(t *testing.T) {
hide, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardHide{Selective: true}, true)
if err != nil {

View file

@ -241,6 +241,24 @@ type UsersService interface {
ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error)
}
// TelegramLoginService is the domain-only boundary shared by the MTProto RPC
// edge and the public OIDC provider. PostgreSQL remains authoritative for all
// consent transitions; the RPC layer only projects domain state to TL.
type TelegramLoginService interface {
ValidateMessageButton(ctx context.Context, botUserID int64, rawURL string) (normalizedURL, domainName string, err error)
AuthorizeMessageButton(ctx context.Context, params domain.TelegramLoginMessageButtonAuthorization) (domain.TelegramLoginMessageButtonResult, error)
RequestByDeepLink(ctx context.Context, deepLink string) (domain.TelegramLoginRequest, error)
RequestByDeepLinkForOrigin(ctx context.Context, deepLink, inAppOrigin string) (domain.TelegramLoginRequest, error)
CheckMatchCode(ctx context.Context, deepLink, selected string) (bool, error)
Approve(ctx context.Context, deepLink string, identity domain.TelegramLoginIdentitySnapshot, writeAllowed, phoneShared bool, matchCode string) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error)
FinalizeRedirectByDeepLink(ctx context.Context, deepLink string) (string, error)
FinalizeInAppRedirectByDeepLink(ctx context.Context, deepLink string) (string, error)
Decline(ctx context.Context, deepLink string, userID int64) (domain.TelegramLoginRequest, error)
ListWebAuthorizations(ctx context.Context, userID int64) ([]domain.TelegramLoginWebAuthorization, error)
RevokeWebAuthorization(ctx context.Context, userID, hash int64) error
RevokeAllWebAuthorizations(ctx context.Context, userID int64) (int64, error)
}
// BatchViewerUsersResolver 是 UsersService 的可选能力:跨多个 viewer 一次性投影同一组 user
// fan-out 模板化,把 per-recipient 的 ByIDs(=ForViewer) 折叠成 O(owner) 查询)。结果按 viewer
// 与 ByIDs(viewer, ids) 字节等价personal photo overlay 除外,见 users.ByIDsForViewers
@ -873,6 +891,7 @@ type Deps struct {
EphemeralPush store.EphemeralPushBroker
EphemeralReports store.EphemeralReportStore
Users UsersService
TelegramLogin TelegramLoginService
Updates UpdatesService
BootstrapUpdates store.BootstrapUpdateJobStore
BotAPIUpdates store.BotAPIUpdateStore

View file

@ -35,6 +35,9 @@ func (r *Router) onMessagesSavePreparedInlineMessage(ctx context.Context, req *t
if err != nil {
return nil, err
}
if err := r.prepareTelegramLoginMarkup(ctx, botID, result.ReplyMarkup); err != nil {
return nil, replyMarkupErr(err)
}
peerTypes, err := preparedInlinePeerTypesFromTG(req.PeerTypes)
if err != nil {
return nil, err
@ -159,6 +162,11 @@ func (r *Router) editPrivateInlineBotMessage(ctx context.Context, botID int64, t
setReplyMarkup = true
}
}
if setReplyMarkup {
if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil {
return false, replyMarkupErr(err)
}
}
_, err = r.deps.Messages.EditMessage(ctx, target.OwnerUserID, domain.EditMessageRequest{
OwnerUserID: target.OwnerUserID,
Peer: target.Peer,
@ -246,6 +254,11 @@ func (r *Router) editChannelInlineBotMessage(ctx context.Context, botID int64, t
setReplyMarkup = true
}
}
if setReplyMarkup {
if err := r.prepareTelegramLoginMarkup(ctx, botID, replyMarkup); err != nil {
return false, replyMarkupErr(err)
}
}
res, err := r.deps.Channels.EditInlineBotMessage(ctx, botID, domain.EditChannelMessageRequest{
UserID: target.SenderUserID,
ChannelID: target.ChannelID,

View file

@ -110,6 +110,11 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
setReplyMarkup = true
}
}
if setReplyMarkup {
if err := r.validateReplyMarkupForPeer(ctx, userID, peer, replyMarkup); err != nil {
return nil, err
}
}
if peer.Type == domain.PeerTypeChannel {
if r.deps.Channels == nil {
return nil, peerIDInvalidErr()
@ -125,6 +130,8 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit
Message: message,
Entities: domainMessageEntitiesForViewer(userID, entities),
MentionUserIDs: mentionUserIDs,
SetReplyMarkup: setReplyMarkup,
ReplyMarkup: replyMarkup,
SetRichMessage: replaceRichMessage,
RichMessage: richMessage,
EditDate: int(r.clock.Now().Unix()),

View file

@ -11,6 +11,18 @@ import (
// registerMessages 注册 messages.* RPC handler。
func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
registerRPC[*tg.MessagesRequestURLAuthRequest](d, tlprofile.SemanticMethodMessagesRequestURLAuth, func(ctx context.Context, req *tg.MessagesRequestURLAuthRequest) (any, error) {
return r.onMessagesRequestURLAuth(ctx, req)
})
registerRPC[*tg.MessagesAcceptURLAuthRequest](d, tlprofile.SemanticMethodMessagesAcceptURLAuth, func(ctx context.Context, req *tg.MessagesAcceptURLAuthRequest) (any, error) {
return r.onMessagesAcceptURLAuth(ctx, req)
})
registerRPC[*tg.MessagesDeclineURLAuthRequest](d, tlprofile.SemanticMethodMessagesDeclineURLAuth, func(ctx context.Context, req *tg.MessagesDeclineURLAuthRequest) (any, error) {
return r.onMessagesDeclineURLAuth(ctx, req.URL)
})
registerRPC[*tg.MessagesCheckURLAuthMatchCodeRequest](d, tlprofile.SemanticMethodMessagesCheckURLAuthMatchCode, func(ctx context.Context, req *tg.MessagesCheckURLAuthMatchCodeRequest) (any, error) {
return r.onMessagesCheckURLAuthMatchCode(ctx, req.URL, req.MatchCode)
})
registerRPC[*tg.MessagesReceivedMessagesRequest](d, tlprofile.SemanticMethodMessagesReceivedMessages, func(ctx context.Context, layerRequest *tg.MessagesReceivedMessagesRequest) (any, error) {
return r.onMessagesReceivedMessages(ctx, layerRequest.
MaxID)

View file

@ -314,6 +314,9 @@ func (r *Router) onMessagesSendWebViewResultMessage(ctx context.Context, req *tg
if err != nil {
return nil, err
}
if err := r.prepareTelegramLoginMarkup(ctx, botID, result.ReplyMarkup); err != nil {
return nil, replyMarkupErr(err)
}
if err := r.sendWebViewDomainResultMessage(ctx, botID, req.BotQueryID, result); err != nil {
return nil, err
}
@ -336,6 +339,9 @@ func (r *Router) AnswerWebAppQueryFromBotAPI(ctx context.Context, botID int64, b
} else if !found {
return "", userBotRequiredErr()
}
if err := r.prepareTelegramLoginMarkup(ctx, botID, result.ReplyMarkup); err != nil {
return "", replyMarkupErr(err)
}
if err := r.sendWebViewDomainResultMessage(ctx, botID, botQueryID, result); err != nil {
return "", err
}
@ -362,6 +368,9 @@ func (r *Router) SavePreparedInlineMessageFromBotAPI(ctx context.Context, botID,
} else if !found {
return "", 0, userIDInvalidErr()
}
if err := r.prepareTelegramLoginMarkup(ctx, botID, result.ReplyMarkup); err != nil {
return "", 0, replyMarkupErr(err)
}
id, expireDate := r.inlines.savePreparedInlineContext(ctx, r.clock.Now(), botID, userID, result, peerTypes)
return id, expireDate, nil
}

View file

@ -0,0 +1,434 @@
package rpc
import (
"context"
"errors"
"math"
"net/url"
"strconv"
"strings"
"time"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"telesrv/internal/domain"
)
func telegramLoginOAuthInvalidErr() error { return tgerr.New(500, "OAUTH_REQUEST_INVALID") }
func telegramLoginURLExpiredErr() error { return tgerr.New(400, "URL_EXPIRED") }
func telegramLoginURLInvalidErr() error { return tgerr.New(400, "URL_INVALID") }
func telegramLoginHashInvalidErr() error { return tgerr.New(400, "HASH_INVALID") }
func telegramLoginRPCError(err error) error {
switch {
case errors.Is(err, domain.ErrTelegramLoginRequestExpired):
return telegramLoginURLExpiredErr()
case errors.Is(err, domain.ErrTelegramLoginURLInvalid):
return telegramLoginURLInvalidErr()
case errors.Is(err, domain.ErrTelegramLoginMatchCodeInvalid),
errors.Is(err, domain.ErrTelegramLoginRequestInvalid),
errors.Is(err, domain.ErrTelegramLoginRequestConflict),
errors.Is(err, domain.ErrTelegramLoginClientDisabled),
errors.Is(err, domain.ErrTelegramLoginOriginNotAllowed),
errors.Is(err, domain.ErrTelegramLoginRedirectNotAllowed),
errors.Is(err, domain.ErrTelegramLoginScopeInvalid),
errors.Is(err, domain.ErrTelegramLoginAuthorizationsTooMany):
return telegramLoginOAuthInvalidErr()
default:
return internalErr()
}
}
func (r *Router) requireTelegramLoginUser(ctx context.Context) (int64, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil || userID <= 0 || r.deps.TelegramLogin == nil || r.deps.Users == nil {
return 0, internalErr()
}
self, err := r.deps.Users.Self(ctx, userID)
if err != nil || self.Bot || self.Deleted {
return 0, telegramLoginOAuthInvalidErr()
}
return userID, nil
}
func (r *Router) telegramLoginRequestResult(ctx context.Context, viewerUserID int64, request domain.TelegramLoginRequest, deepLink string) (tg.URLAuthResultClass, error) {
switch request.Status {
case domain.TelegramLoginRequestApproved:
if request.AuthorizedUserID != viewerUserID {
return nil, telegramLoginOAuthInvalidErr()
}
return r.telegramLoginAcceptedResult(ctx, request, deepLink)
case domain.TelegramLoginRequestPending:
// Continue below.
case domain.TelegramLoginRequestDeclined, domain.TelegramLoginRequestExpired:
return nil, telegramLoginURLExpiredErr()
default:
return nil, telegramLoginOAuthInvalidErr()
}
bot, found, err := r.deps.Users.ByID(ctx, viewerUserID, request.BotUserID)
if err != nil {
return nil, internalErr()
}
if !found || !bot.Bot || bot.Deleted {
return nil, telegramLoginOAuthInvalidErr()
}
botTL := r.withBotProfileFlags(ctx, r.tgUser(bot))
out := &tg.URLAuthResultRequest{
RequestWriteAccess: request.Requests(domain.TelegramLoginScopeBotAccess),
RequestPhoneNumber: request.Requests(domain.TelegramLoginScopePhone),
MatchCodesFirst: request.MatchCodesFirst,
IsApp: request.IsApp,
Bot: botTL,
Domain: request.Domain,
}
// OAuth requests carry the complete device tuple. Keep the four fields on
// their shared flag together so old exact-layer codecs never see a partial
// conditional shape.
if request.Browser != "" && request.Platform != "" && request.IP != "" && request.Region != "" {
out.SetBrowser(request.Browser)
out.SetPlatform(request.Platform)
out.SetIP(request.IP)
out.SetRegion(request.Region)
}
if len(request.MatchCodes) > 0 {
out.SetMatchCodes(append([]string(nil), request.MatchCodes...))
}
if request.UserIDHint > 0 {
out.SetUserIDHint(request.UserIDHint)
}
if request.IsApp && request.VerifiedAppName != "" {
out.SetVerifiedAppName(request.VerifiedAppName)
}
return out, nil
}
func (r *Router) telegramLoginAcceptedResult(ctx context.Context, request domain.TelegramLoginRequest, deepLink string) (tg.URLAuthResultClass, error) {
accepted := &tg.URLAuthResultAccepted{}
switch {
case request.Source == domain.TelegramLoginRequestNative && request.IsApp:
redirectURL, err := r.deps.TelegramLogin.FinalizeRedirectByDeepLink(ctx, deepLink)
if err != nil {
return nil, telegramLoginRPCError(err)
}
accepted.SetURL(redirectURL)
case request.Source == domain.TelegramLoginRequestMiniApp:
resultURL, err := r.deps.TelegramLogin.FinalizeInAppRedirectByDeepLink(ctx, deepLink)
if err != nil {
return nil, telegramLoginRPCError(err)
}
accepted.SetURL(resultURL)
}
return accepted, nil
}
func (r *Router) onMessagesRequestURLAuth(ctx context.Context, req *tg.MessagesRequestURLAuthRequest) (tg.URLAuthResultClass, error) {
userID, err := r.requireTelegramLoginUser(ctx)
if err != nil {
return nil, err
}
_, hasPeer := req.GetPeer()
urlValue, hasURL := req.GetURL()
_, hasOrigin := req.GetInAppOrigin()
if hasPeer == hasURL || (!hasPeer && strings.TrimSpace(urlValue) == "") || hasOrigin && !hasURL {
return nil, telegramLoginOAuthInvalidErr()
}
if hasPeer {
button, peer, err := r.telegramLoginButtonFromMessage(ctx, userID, req.Peer, req.MsgID, req.ButtonID)
if err != nil {
return nil, err
}
u, err := url.Parse(button.URL)
if err != nil || u.Hostname() == "" {
return nil, telegramLoginURLInvalidErr()
}
request := domain.TelegramLoginRequest{
BotUserID: button.LoginBotUserID, Source: domain.TelegramLoginRequestMessageButton,
ResponseType: "legacy_url", RedirectURI: button.URL, Domain: u.Hostname(),
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile},
PeerType: peer.Type, PeerID: peer.ID, MessageID: req.MsgID, ButtonID: req.ButtonID,
Status: domain.TelegramLoginRequestPending,
}
if button.RequestWriteAccess {
request.Scopes = append(request.Scopes, domain.TelegramLoginScopeBotAccess)
}
return r.telegramLoginRequestResult(ctx, userID, request, "")
}
if hasOrigin && req.InAppOrigin == "" {
return nil, telegramLoginURLInvalidErr()
}
request, err := r.deps.TelegramLogin.RequestByDeepLinkForOrigin(ctx, urlValue, req.InAppOrigin)
if err != nil {
return nil, telegramLoginRPCError(err)
}
return r.telegramLoginRequestResult(ctx, userID, request, urlValue)
}
func (r *Router) onMessagesAcceptURLAuth(ctx context.Context, req *tg.MessagesAcceptURLAuthRequest) (tg.URLAuthResultClass, error) {
userID, err := r.requireTelegramLoginUser(ctx)
if err != nil {
return nil, err
}
_, hasPeer := req.GetPeer()
deepLink, hasURL := req.GetURL()
matchCode, hasMatchCode := req.GetMatchCode()
if hasPeer == hasURL || (!hasPeer && strings.TrimSpace(deepLink) == "") || (hasMatchCode && matchCode == "") {
return nil, telegramLoginOAuthInvalidErr()
}
if hasPeer {
if hasMatchCode || req.SharePhoneNumber {
return nil, telegramLoginOAuthInvalidErr()
}
button, peer, err := r.telegramLoginButtonFromMessage(ctx, userID, req.Peer, req.MsgID, req.ButtonID)
if err != nil {
return nil, err
}
if r.deps.Bots == nil {
return nil, internalErr()
}
profile, found, err := r.deps.Bots.BotInfo(ctx, button.LoginBotUserID)
if err != nil {
return nil, internalErr()
}
if !found || profile.TokenSecret == "" {
return nil, telegramLoginOAuthInvalidErr()
}
self, err := r.deps.Users.Self(ctx, userID)
if err != nil {
return nil, internalErr()
}
identity := r.telegramLoginIdentity(self)
result, err := r.deps.TelegramLogin.AuthorizeMessageButton(ctx, domain.TelegramLoginMessageButtonAuthorization{
UserID: userID, BotUserID: button.LoginBotUserID,
BotToken: domain.FormatBotToken(button.LoginBotUserID, profile.TokenSecret), URL: button.URL,
RequestWriteAccess: button.RequestWriteAccess, WriteAllowed: req.WriteAllowed,
Peer: peer, MessageID: req.MsgID, ButtonID: req.ButtonID,
Browser: "Telegram", Platform: "Telegram Client", IP: "Unknown IP", Region: "Unknown region",
Identity: identity,
})
if err != nil {
return nil, telegramLoginRPCError(err)
}
accepted := &tg.URLAuthResultAccepted{}
accepted.SetURL(result.URL)
return accepted, nil
}
request, err := r.deps.TelegramLogin.RequestByDeepLink(ctx, deepLink)
if err != nil {
return nil, telegramLoginRPCError(err)
}
if request.Status == domain.TelegramLoginRequestApproved {
if request.AuthorizedUserID == userID {
return r.telegramLoginAcceptedResult(ctx, request, deepLink)
}
return nil, telegramLoginOAuthInvalidErr()
}
if request.Status != domain.TelegramLoginRequestPending {
return nil, telegramLoginURLExpiredErr()
}
self, err := r.deps.Users.Self(ctx, userID)
if err != nil {
return nil, internalErr()
}
identity := r.telegramLoginIdentity(self)
approved, _, err := r.deps.TelegramLogin.Approve(ctx, deepLink, identity, req.WriteAllowed, req.SharePhoneNumber, matchCode)
if err != nil {
return nil, telegramLoginRPCError(err)
}
if approved.AuthorizedUserID != userID {
return nil, telegramLoginOAuthInvalidErr()
}
return r.telegramLoginAcceptedResult(ctx, approved, deepLink)
}
func (r *Router) telegramLoginIdentity(self domain.User) domain.TelegramLoginIdentitySnapshot {
identity := domain.TelegramLoginIdentitySnapshot{
UserID: self.ID, Name: strings.TrimSpace(strings.TrimSpace(self.FirstName) + " " + strings.TrimSpace(self.LastName)),
GivenName: self.FirstName, FamilyName: self.LastName,
PreferredUsername: self.Username, PhoneNumber: self.Phone,
}
if strings.TrimSpace(r.cfg.PublicBaseURL) != "" && self.Username != "" && self.PhotoID > 0 {
identity.Picture = strings.TrimSuffix(r.cfg.PublicBaseURL, "/") + "/_public/avatar/" + url.PathEscape(self.Username) + "/" + strconv.FormatInt(self.PhotoID, 10)
}
return identity
}
func (r *Router) telegramLoginButtonFromMessage(ctx context.Context, userID int64, inputPeer tg.InputPeerClass, messageID, buttonID int) (domain.MarkupButton, domain.Peer, error) {
if messageID <= 0 || messageID > domain.MaxMessageBoxID || buttonID < 0 {
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inputPeer)
if err != nil {
return domain.MarkupButton{}, domain.Peer{}, err
}
var markup *domain.MessageReplyMarkup
switch peer.Type {
case domain.PeerTypeUser:
message, found, err := r.lookupOwnerMessage(ctx, userID, messageID)
if err != nil {
return domain.MarkupButton{}, domain.Peer{}, internalErr()
}
if !found || message.Peer != peer {
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
}
markup = message.ReplyMarkup
case domain.PeerTypeChannel:
if r.deps.Channels == nil {
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
}
history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, []int{messageID})
if err != nil || len(history.Messages) != 1 || history.Messages[0].ID != messageID {
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
}
markup = history.Messages[0].ReplyMarkup
default:
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
}
if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline {
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
}
for _, row := range markup.Inline {
for _, button := range row {
if button.Type == domain.MarkupButtonLoginURL && button.ButtonID == buttonID && button.LoginBotUserID > 0 {
return button, peer, nil
}
}
}
return domain.MarkupButton{}, domain.Peer{}, telegramLoginOAuthInvalidErr()
}
func (r *Router) onMessagesDeclineURLAuth(ctx context.Context, deepLink string) (bool, error) {
userID, err := r.requireTelegramLoginUser(ctx)
if err != nil {
return false, err
}
if strings.TrimSpace(deepLink) == "" {
return false, telegramLoginURLInvalidErr()
}
request, err := r.deps.TelegramLogin.RequestByDeepLink(ctx, deepLink)
if err != nil {
return false, telegramLoginRPCError(err)
}
if request.Status == domain.TelegramLoginRequestDeclined {
return true, nil
}
if request.Status != domain.TelegramLoginRequestPending {
return false, telegramLoginOAuthInvalidErr()
}
if _, err := r.deps.TelegramLogin.Decline(ctx, deepLink, userID); err != nil {
return false, telegramLoginRPCError(err)
}
return true, nil
}
func (r *Router) onMessagesCheckURLAuthMatchCode(ctx context.Context, deepLink, matchCode string) (bool, error) {
if _, err := r.requireTelegramLoginUser(ctx); err != nil {
return false, err
}
if strings.TrimSpace(deepLink) == "" || matchCode == "" {
return false, telegramLoginURLInvalidErr()
}
ok, err := r.deps.TelegramLogin.CheckMatchCode(ctx, deepLink, matchCode)
if err != nil {
return false, telegramLoginRPCError(err)
}
return ok, nil
}
func (r *Router) onAccountGetWebAuthorizations(ctx context.Context) (*tg.AccountWebAuthorizations, error) {
if r.deps.TelegramLogin == nil {
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
return &tg.AccountWebAuthorizations{Authorizations: []tg.WebAuthorization{}, Users: []tg.UserClass{}}, nil
}
userID, err := r.requireTelegramLoginUser(ctx)
if err != nil {
return nil, err
}
authorizations, err := r.deps.TelegramLogin.ListWebAuthorizations(ctx, userID)
if err != nil {
return nil, internalErr()
}
result := &tg.AccountWebAuthorizations{
Authorizations: make([]tg.WebAuthorization, 0, len(authorizations)),
Users: []tg.UserClass{},
}
botIDs := make([]int64, 0, len(authorizations))
seenBots := make(map[int64]struct{}, len(authorizations))
for _, authorization := range authorizations {
result.Authorizations = append(result.Authorizations, tg.WebAuthorization{
Hash: authorization.Hash, BotID: authorization.BotUserID, Domain: authorization.Domain,
Browser: authorization.Browser, Platform: authorization.Platform,
DateCreated: telegramLoginUnixInt(authorization.CreatedAt), DateActive: telegramLoginUnixInt(authorization.LastActiveAt),
IP: authorization.IP, Region: authorization.Region,
})
if _, duplicate := seenBots[authorization.BotUserID]; !duplicate {
seenBots[authorization.BotUserID] = struct{}{}
botIDs = append(botIDs, authorization.BotUserID)
}
}
if len(botIDs) > 0 {
bots, err := r.deps.Users.ByIDs(ctx, userID, botIDs)
if err != nil {
return nil, internalErr()
}
for _, bot := range bots {
if bot.Bot && !bot.Deleted {
result.Users = append(result.Users, r.withBotProfileFlags(ctx, r.tgUser(bot)))
}
}
}
return result, nil
}
func (r *Router) onAccountResetWebAuthorization(ctx context.Context, hash int64) (bool, error) {
if r.deps.TelegramLogin == nil {
if _, _, err := r.currentUserID(ctx); err != nil {
return false, internalErr()
}
return true, nil
}
userID, err := r.requireTelegramLoginUser(ctx)
if err != nil {
return false, err
}
if hash == 0 {
return false, telegramLoginHashInvalidErr()
}
if err := r.deps.TelegramLogin.RevokeWebAuthorization(ctx, userID, hash); err != nil {
if errors.Is(err, domain.ErrTelegramLoginWebAuthHashInvalid) {
return false, telegramLoginHashInvalidErr()
}
return false, internalErr()
}
return true, nil
}
func (r *Router) onAccountResetWebAuthorizations(ctx context.Context) (bool, error) {
if r.deps.TelegramLogin == nil {
if _, _, err := r.currentUserID(ctx); err != nil {
return false, internalErr()
}
return true, nil
}
userID, err := r.requireTelegramLoginUser(ctx)
if err != nil {
return false, err
}
if _, err := r.deps.TelegramLogin.RevokeAllWebAuthorizations(ctx, userID); err != nil {
return false, internalErr()
}
return true, nil
}
func telegramLoginUnixInt(value time.Time) int {
unix := value.Unix()
if unix < 0 {
return 0
}
if unix > math.MaxInt32 {
return math.MaxInt32
}
return int(unix)
}

View file

@ -0,0 +1,380 @@
package rpc
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
"go.uber.org/zap/zaptest"
telegramloginapp "telesrv/internal/app/telegramlogin"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type telegramLoginBotPermissionAdapter struct{ bots BotsService }
func (a telegramLoginBotPermissionAdapter) AllowBotSendMessage(ctx context.Context, botUserID, userID int64, fromRequest bool) (bool, error) {
return a.bots.AllowSendMessage(ctx, userID, botUserID, fromRequest)
}
type telegramLoginRPCFixture struct {
ctx context.Context
service *telegramloginapp.Service
router *Router
user domain.User
intruder domain.User
bot domain.User
client telegramloginapp.ClientCredentials
redirect string
}
func newTelegramLoginRPCFixture(t *testing.T) *telegramLoginRPCFixture {
t.Helper()
ctx := context.Background()
users := memory.NewUserStore()
user, err := users.Create(ctx, domain.User{Phone: "+15551001", FirstName: "Alice", LastName: "Example", Username: "alice", AccessHash: 11})
if err != nil {
t.Fatal(err)
}
intruder, err := users.Create(ctx, domain.User{Phone: "+15551002", FirstName: "Mallory", Username: "mallory", AccessHash: 13})
if err != nil {
t.Fatal(err)
}
bot, err := users.Create(ctx, domain.User{FirstName: "Login Bot", Username: "login_rpc_bot", AccessHash: 12, Bot: true, BotInfoVersion: 1})
if err != nil {
t.Fatal(err)
}
sealKey := make([]byte, 32)
sealKey[0] = 3
sealer, err := telegramloginapp.NewCodeSealer("test", map[string][]byte{"test": sealKey})
if err != nil {
t.Fatal(err)
}
pepper := make([]byte, 32)
pepper[0] = 4
service, err := telegramloginapp.NewService(memory.NewTelegramLoginStore(nil), sealer, telegramloginapp.Config{
Issuer: "https://oauth.test", AppScheme: "telesrv", ClientSecretPepper: pepper,
Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() },
})
if err != nil {
t.Fatal(err)
}
client, err := service.CreateClient(ctx, bot.ID, domain.TelegramLoginSigningRS256)
if err != nil {
t.Fatal(err)
}
redirect := "https://rp.test/callback"
if _, err := service.AddAllowedURL(ctx, bot.ID, domain.TelegramLoginAllowedRedirectURI, redirect); err != nil {
t.Fatal(err)
}
if _, err := service.AddAllowedURL(ctx, bot.ID, domain.TelegramLoginAllowedWebOrigin, "https://rp.test"); err != nil {
t.Fatal(err)
}
router := New(Config{}, Deps{Users: appusers.NewService(users), TelegramLogin: service}, zaptest.NewLogger(t), clock.System)
return &telegramLoginRPCFixture{ctx: WithUserID(ctx, user.ID), service: service, router: router, user: user, intruder: intruder, bot: bot, client: client, redirect: redirect}
}
func (f *telegramLoginRPCFixture) authorization(t *testing.T, match bool) telegramloginapp.CreatedAuthorization {
t.Helper()
challenge, err := telegramloginapp.PKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
if err != nil {
t.Fatal(err)
}
created, err := f.service.CreateAuthorization(f.ctx, telegramloginapp.CreateAuthorizationParams{
ClientID: f.client.Client.ClientID, RedirectURI: f.redirect, ResponseType: "code",
Scope: "openid profile telegram:bot_access", CodeChallenge: challenge, CodeChallengeMethod: "S256",
IncludeMatchCodes: match, MatchCodesFirst: match,
})
if err != nil {
t.Fatal(err)
}
return created
}
func TestTelegramLoginRPCsAcrossExactLayerProfiles(t *testing.T) {
for profile := tlprofile.Profile225; profile <= tlprofile.Profile228; profile++ {
t.Run(fmt.Sprintf("layer_%d", profile), func(t *testing.T) {
f := newTelegramLoginRPCFixture(t)
approve := f.authorization(t, true)
// TDesktop normalizes the configured telesrv:// launcher to the
// official internal tg://oauth form before invoking MTProto.
canonicalURL := strings.Replace(approve.DeepLink, "telesrv://", "tg://", 1)
request := &tg.MessagesRequestURLAuthRequest{}
request.SetURL(canonicalURL)
result, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, request)
if method != "messages.requestUrlAuth" {
t.Fatalf("method = %q", method)
}
prompt, ok := dispatchCanonicalValue(result).(*tg.URLAuthResultRequest)
if !ok || prompt.Bot.GetID() != f.bot.ID || !prompt.RequestWriteAccess || len(prompt.MatchCodes) != 5 {
t.Fatalf("request result = %#v", dispatchCanonicalValue(result))
}
checked, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.MessagesCheckURLAuthMatchCodeRequest{
URL: canonicalURL, MatchCode: approve.Request.MatchCode,
})
if method != "messages.checkUrlAuthMatchCode" || dispatchCanonicalValue(checked) != true {
t.Fatalf("check result = %#v method=%q", dispatchCanonicalValue(checked), method)
}
accept := &tg.MessagesAcceptURLAuthRequest{WriteAllowed: true}
accept.SetURL(canonicalURL)
accept.SetMatchCode(approve.Request.MatchCode)
accepted, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, accept)
if method != "messages.acceptUrlAuth" {
t.Fatalf("accept method = %q", method)
}
if _, ok := dispatchCanonicalValue(accepted).(*tg.URLAuthResultAccepted); !ok {
t.Fatalf("accept result = %#v", dispatchCanonicalValue(accepted))
}
decline := f.authorization(t, false)
declined, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.MessagesDeclineURLAuthRequest{URL: decline.DeepLink})
if method != "messages.declineUrlAuth" || dispatchCanonicalValue(declined) != true {
t.Fatalf("decline result = %#v method=%q", dispatchCanonicalValue(declined), method)
}
listed, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.AccountGetWebAuthorizationsRequest{})
web, ok := dispatchCanonicalValue(listed).(*tg.AccountWebAuthorizations)
if method != "account.getWebAuthorizations" || !ok || len(web.Authorizations) != 1 || web.Authorizations[0].BotID != f.bot.ID {
t.Fatalf("getWebAuthorizations = %#v method=%q", dispatchCanonicalValue(listed), method)
}
reset, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.AccountResetWebAuthorizationRequest{Hash: web.Authorizations[0].Hash})
if method != "account.resetWebAuthorization" || dispatchCanonicalValue(reset) != true {
t.Fatalf("resetWebAuthorization = %#v method=%q", dispatchCanonicalValue(reset), method)
}
const nativeCallback = "bedolaga://telegram-login"
if _, err := f.service.AddNativeApp(f.ctx, f.bot.ID, domain.TelegramLoginNativeAndroid,
"dev.bedolaga.demo", strings.Repeat("A", 64), nativeCallback, "Bedolaga Android Demo"); err != nil {
t.Fatal(err)
}
challenge, err := telegramloginapp.PKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
if err != nil {
t.Fatal(err)
}
native, err := f.service.CreateAuthorization(f.ctx, telegramloginapp.CreateAuthorizationParams{
ClientID: f.client.Client.ClientID, RedirectURI: nativeCallback, ResponseType: "code",
Scope: "profile", CodeChallenge: challenge, CodeChallengeMethod: "S256",
NativePlatform: domain.TelegramLoginNativeAndroid, IncludeMatchCodes: true, MatchCodesFirst: true,
})
if err != nil {
t.Fatal(err)
}
nativeRequest := &tg.MessagesRequestURLAuthRequest{}
nativeRequest.SetURL(native.DeepLink)
nativeResult, _ := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, nativeRequest)
nativePrompt, ok := dispatchCanonicalValue(nativeResult).(*tg.URLAuthResultRequest)
if !ok || !nativePrompt.IsApp || nativePrompt.VerifiedAppName != "Bedolaga Android Demo" || len(nativePrompt.MatchCodes) != 5 {
t.Fatalf("native request result = %#v", dispatchCanonicalValue(nativeResult))
}
nativeAccept := &tg.MessagesAcceptURLAuthRequest{}
nativeAccept.SetURL(native.DeepLink)
nativeAccept.SetMatchCode(native.Request.MatchCode)
nativeAcceptedResult, _ := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, nativeAccept)
nativeAccepted, ok := dispatchCanonicalValue(nativeAcceptedResult).(*tg.URLAuthResultAccepted)
if !ok || !strings.HasPrefix(nativeAccepted.URL, nativeCallback+"?code=") {
t.Fatalf("native accepted result = %#v", dispatchCanonicalValue(nativeAcceptedResult))
}
nativeRetryResult, _ := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, nativeRequest)
nativeRetry, ok := dispatchCanonicalValue(nativeRetryResult).(*tg.URLAuthResultAccepted)
if !ok || nativeRetry.URL != nativeAccepted.URL {
t.Fatalf("native retry result = %#v, want URL %q", dispatchCanonicalValue(nativeRetryResult), nativeAccepted.URL)
}
const miniAppOrigin = "https://rp.test"
miniApp, err := f.service.CreateAuthorization(f.ctx, telegramloginapp.CreateAuthorizationParams{
ClientID: f.client.Client.ClientID, RedirectURI: miniAppOrigin + "/", ResponseType: "post_message",
Scope: "openid profile", Origin: miniAppOrigin, InAppOrigin: miniAppOrigin,
Source: domain.TelegramLoginRequestMiniApp, IncludeMatchCodes: true, MatchCodesFirst: true,
})
if err != nil {
t.Fatal(err)
}
miniAppRequest := &tg.MessagesRequestURLAuthRequest{}
miniAppRequest.SetURL(miniApp.DeepLink)
miniAppRequest.SetInAppOrigin(miniAppOrigin)
miniAppResult, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, miniAppRequest)
miniAppPrompt, ok := dispatchCanonicalValue(miniAppResult).(*tg.URLAuthResultRequest)
if method != "messages.requestUrlAuth" || !ok || len(miniAppPrompt.MatchCodes) != 5 {
t.Fatalf("mini-app request result = %#v method=%q", dispatchCanonicalValue(miniAppResult), method)
}
miniAppAccept := &tg.MessagesAcceptURLAuthRequest{}
miniAppAccept.SetURL(miniApp.DeepLink)
miniAppAccept.SetMatchCode(miniApp.Request.MatchCode)
miniAppAcceptedResult, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, miniAppAccept)
miniAppAccepted, ok := dispatchCanonicalValue(miniAppAcceptedResult).(*tg.URLAuthResultAccepted)
if method != "messages.acceptUrlAuth" || !ok || !strings.HasPrefix(miniAppAccepted.URL, "https://oauth.test/inapp?token=") {
t.Fatalf("mini-app accepted result = %#v method=%q", dispatchCanonicalValue(miniAppAcceptedResult), method)
}
miniAppRetryResult, _ := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, miniAppRequest)
miniAppRetry, ok := dispatchCanonicalValue(miniAppRetryResult).(*tg.URLAuthResultAccepted)
if !ok || miniAppRetry.URL != miniAppAccepted.URL {
t.Fatalf("mini-app retry result = %#v, want URL %q", dispatchCanonicalValue(miniAppRetryResult), miniAppAccepted.URL)
}
resetAll, method := dispatchExactLayerRPCTest(t, f.router, f.ctx, profile, &tg.AccountResetWebAuthorizationsRequest{})
if method != "account.resetWebAuthorizations" || dispatchCanonicalValue(resetAll) != true {
t.Fatalf("resetWebAuthorizations = %#v method=%q", dispatchCanonicalValue(resetAll), method)
}
})
}
}
func TestTelegramLoginApprovedDeepLinkRejectsAnotherUser(t *testing.T) {
f := newTelegramLoginRPCFixture(t)
created := f.authorization(t, false)
accept := &tg.MessagesAcceptURLAuthRequest{}
accept.SetURL(created.DeepLink)
if _, err := f.router.onMessagesAcceptURLAuth(f.ctx, accept); err != nil {
t.Fatal(err)
}
request := &tg.MessagesRequestURLAuthRequest{}
request.SetURL(created.DeepLink)
if _, err := f.router.onMessagesRequestURLAuth(WithUserID(context.Background(), f.intruder.ID), request); err == nil {
t.Fatal("another user observed an approved deep link as accepted")
}
}
func TestTelegramLoginMessageButtonRereadSignsAndGrantsWriteAccess(t *testing.T) {
f := newBotAPIReceiveFixture(t, false)
sealKey := make([]byte, 32)
sealKey[0] = 7
sealer, err := telegramloginapp.NewCodeSealer("test", map[string][]byte{"test": sealKey})
if err != nil {
t.Fatal(err)
}
pepper := make([]byte, 32)
pepper[0] = 8
loginStore := memory.NewTelegramLoginStore(telegramLoginBotPermissionAdapter{bots: f.router.deps.Bots})
login, err := telegramloginapp.NewService(loginStore, sealer, telegramloginapp.Config{
Issuer: "https://oauth.test", AppScheme: "telesrv", ClientSecretPepper: pepper,
Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() },
})
if err != nil {
t.Fatal(err)
}
if _, err := login.CreateClient(f.ctx, f.bot.ID, domain.TelegramLoginSigningRS256); err != nil {
t.Fatal(err)
}
if _, err := login.AddAllowedURL(f.ctx, f.bot.ID, domain.TelegramLoginAllowedWebOrigin, "https://rp.test"); err != nil {
t.Fatal(err)
}
f.router.deps.TelegramLogin = login
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
Type: domain.MarkupButtonLoginURL, Text: "Log in", URL: "https://rp.test/login?next=%2Fhome", RequestWriteAccess: true,
}}}}
if _, err := f.router.BotAPISendMessage(f.ctx, f.bot.ID, f.owner.ID, "Authorize", nil, markup, false, false, 0); err != nil {
t.Fatalf("BotAPISendMessage: %v", err)
}
history, err := f.messages.GetHistory(f.ctx, f.owner.ID, domain.MessageFilter{
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.bot.ID}, Limit: 10,
})
if err != nil || len(history.Messages) == 0 {
t.Fatalf("GetHistory: messages=%d err=%v", len(history.Messages), err)
}
message := history.Messages[0]
if message.ReplyMarkup == nil || message.ReplyMarkup.Inline[0][0].LoginBotUserID != f.bot.ID {
t.Fatalf("persisted login button = %#v", message.ReplyMarkup)
}
peer := &tg.InputPeerUser{UserID: f.bot.ID, AccessHash: f.bot.AccessHash}
request := &tg.MessagesRequestURLAuthRequest{}
request.SetPeer(peer)
request.SetMsgID(message.ID)
request.SetButtonID(0)
requested, err := f.router.onMessagesRequestURLAuth(WithUserID(f.ctx, f.owner.ID), request)
if err != nil {
t.Fatalf("requestUrlAuth: %v", err)
}
prompt, ok := requested.(*tg.URLAuthResultRequest)
if !ok || !prompt.RequestWriteAccess || prompt.Domain != "rp.test" {
t.Fatalf("requestUrlAuth result = %#v", requested)
}
accept := &tg.MessagesAcceptURLAuthRequest{}
accept.SetWriteAllowed(true)
accept.SetPeer(peer)
accept.SetMsgID(message.ID)
accept.SetButtonID(0)
accepted, err := f.router.onMessagesAcceptURLAuth(WithUserID(f.ctx, f.owner.ID), accept)
if err != nil {
t.Fatalf("acceptUrlAuth: %v", err)
}
final, ok := accepted.(*tg.URLAuthResultAccepted)
if !ok || final.URL == "" {
t.Fatalf("acceptUrlAuth result = %#v", accepted)
}
verifyLegacyTelegramLoginURL(t, final.URL, domain.FormatBotToken(f.bot.ID, "secret"), f.owner.ID)
if allowed, err := f.router.deps.Bots.CanSendMessage(f.ctx, f.owner.ID, f.bot.ID); err != nil || !allowed {
t.Fatalf("bot write permission = %v,%v", allowed, err)
}
web, err := login.ListWebAuthorizations(f.ctx, f.owner.ID)
if err != nil || len(web) != 1 || !web[0].BotAccessGranted || web[0].Domain != "rp.test" {
t.Fatalf("web authorizations = %#v err=%v", web, err)
}
// The server must re-read durable message state. A forged button id never
// falls back to URL data supplied by the client.
forged := &tg.MessagesAcceptURLAuthRequest{}
forged.SetPeer(peer)
forged.SetMsgID(message.ID)
forged.SetButtonID(99)
if _, err := f.router.onMessagesAcceptURLAuth(WithUserID(f.ctx, f.owner.ID), forged); err == nil {
t.Fatal("forged button id was accepted")
}
}
func TestTelegramLoginMarkupRequiresBotSender(t *testing.T) {
f := newTelegramLoginRPCFixture(t)
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
Type: domain.MarkupButtonLoginURL, Text: "Log in", URL: "https://rp.test/login",
}}}}
if err := f.router.prepareTelegramLoginMarkup(WithUserID(f.ctx, f.user.ID), f.user.ID, markup); !errors.Is(err, domain.ErrButtonTypeInvalid) {
t.Fatalf("ordinary user login_url error = %v, want ErrButtonTypeInvalid", err)
}
}
func verifyLegacyTelegramLoginURL(t *testing.T, raw, botToken string, wantUserID int64) {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
query := u.Query()
provided := query.Get("hash")
query.Del("hash")
if query.Get("id") != strconv.FormatInt(wantUserID, 10) || query.Get("auth_date") == "" || query.Get("next") != "/home" {
t.Fatalf("legacy login query = %#v", query)
}
keys := make([]string, 0, len(query))
for key := range query {
if key != "next" { // Existing application query fields are not signed.
keys = append(keys, key)
}
}
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, key := range keys {
lines = append(lines, key+"="+query.Get(key))
}
secret := sha256.Sum256([]byte(botToken))
mac := hmac.New(sha256.New, secret[:])
_, _ = mac.Write([]byte(strings.Join(lines, "\n")))
if !hmac.Equal([]byte(strings.ToLower(provided)), []byte(hex.EncodeToString(mac.Sum(nil)))) {
t.Fatalf("legacy login hash = %q, want %s", provided, hex.EncodeToString(mac.Sum(nil)))
}
}