owpengram-server/internal/rpc/telegram_login_rpc_test.go

380 lines
17 KiB
Go

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: "http://192.0.2.25:2401", AppScheme: "telesrv", AllowHTTP: true, 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, "http://rp.test:3000"); 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: "http://rp.test:3000/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)))
}
}