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

@ -12,6 +12,7 @@ import (
"go.uber.org/zap"
telegramloginapp "telesrv/internal/app/telegramlogin"
"telesrv/internal/branding"
"telesrv/internal/domain"
)
@ -33,6 +34,9 @@ const (
botFatherCmdSetInlineFB = "setinlinefeedback"
botFatherCmdSetJoinGroups = "setjoingroups"
botFatherCmdSetPrivacy = "setprivacy"
botFatherCmdSetLogin = "setlogin"
botFatherCmdLoginInfo = "logininfo"
botFatherCmdResetLogin = "resetloginsecret"
botFatherStepName = "name"
botFatherStepUsername = "username"
@ -60,6 +64,9 @@ You can control me by sending these commands:
/setinlinefeedback - change inline feedback settings
/setjoingroups - toggle whether a bot can join groups
/setprivacy - toggle a bot's group privacy mode
/setlogin - configure Telegram Login allowed URLs and signing
/logininfo - show a bot's Telegram Login configuration
/resetloginsecret - rotate a bot's OIDC Client Secret
/cancel - cancel the current operation
/help - show this message`
@ -175,6 +182,7 @@ var botFatherGlobalCommands = map[string]bool{
botFatherCmdSetName: true, botFatherCmdSetDescription: true, botFatherCmdSetAbout: true,
botFatherCmdSetCommands: true, botFatherCmdSetInline: true, botFatherCmdSetInlineGeo: true,
botFatherCmdSetInlineFB: true, botFatherCmdSetJoinGroups: true, botFatherCmdSetPrivacy: true,
botFatherCmdSetLogin: true, botFatherCmdLoginInfo: true, botFatherCmdResetLogin: true,
}
func (s *Service) handleBotFather(ctx context.Context, userID int64, text string) botReply {
@ -231,6 +239,9 @@ var pickerPrompts = map[string]string{
botFatherCmdSetInlineGeo: "Choose a bot to change inline location requests for. Send the bot's username:",
botFatherCmdSetJoinGroups: "Choose a bot to configure group joining for. Send the bot's username:",
botFatherCmdSetPrivacy: "Choose a bot to configure group privacy for. Send the bot's username:",
botFatherCmdSetLogin: "Choose a bot to configure Telegram Login for. Send the bot's username:",
botFatherCmdLoginInfo: "Choose a bot whose Telegram Login configuration you want to inspect:",
botFatherCmdResetLogin: "Choose a bot whose OIDC Client Secret you want to rotate:",
}
// startBotPicker 列出 owner 的 bot 并进入 choose step所有需先选 bot 的命令共用)。
@ -322,7 +333,8 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
case botFatherCmdToken, botFatherCmdRevoke,
botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy:
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy,
botFatherCmdSetLogin, botFatherCmdLoginInfo, botFatherCmdResetLogin:
return s.startBotPicker(ctx, userID, cmd)
case botFatherCmdSetInlineFB:
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
@ -351,6 +363,8 @@ func valuePrompt(cmd, username string) string {
return fmt.Sprintf("Send 'enable' to allow @%s to be added to groups, or 'disable' to prevent it.", username)
case botFatherCmdSetPrivacy:
return fmt.Sprintf("Send 'enable' to turn ON group privacy for @%s (it will only receive commands and replies), or 'disable' to let it receive all group messages.", username)
case botFatherCmdSetLogin:
return telegramLoginConfigurationPrompt(username)
default:
return "Send the new value, or /cancel."
}
@ -445,6 +459,61 @@ func (s *Service) handleChooseBot(ctx context.Context, state domain.BotChatState
}
head := fmt.Sprintf("Token for @%s has been revoked. The old token will stop working immediately. New token:\n", chosen.Username)
return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
case botFatherCmdLoginInfo:
defer s.clearState(ctx, state.UserID)
if s.telegramLogin == nil {
return botReply{Text: "Telegram Login is not enabled on this server."}
}
configuration, found, err := s.telegramLogin.ClientConfiguration(ctx, chosen.ID)
if err != nil {
s.log.Error("botfather: get telegram login configuration", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
return internalReply()
}
if !found {
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Use /setlogin to create it.", chosen.Username)}
}
return botReply{Text: formatTelegramLoginConfiguration(chosen.Username, configuration)}
case botFatherCmdResetLogin:
defer s.clearState(ctx, state.UserID)
if s.telegramLogin == nil {
return botReply{Text: "Telegram Login is not enabled on this server."}
}
credentials, err := s.telegramLogin.RotateClientSecret(ctx, chosen.ID)
if errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Use /setlogin first.", chosen.Username)}
}
if err != nil {
s.log.Error("botfather: rotate telegram login secret", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
return internalReply()
}
head := fmt.Sprintf("The previous OIDC Client Secret for @%s is now invalid. Save this new secret; it will only be shown once:\n", chosen.Username)
return tokenReply(head, credentials.Secret, "\n\nClient ID: "+credentials.Client.ClientID)
case botFatherCmdSetLogin:
if s.telegramLogin == nil {
s.clearState(ctx, state.UserID)
return botReply{Text: "Telegram Login is not enabled on this server."}
}
credentials, created, err := s.telegramLogin.EnsureClient(ctx, chosen.ID)
if err != nil {
s.log.Error("botfather: ensure telegram login client", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
return internalReply()
}
state.Step = botFatherStepValue
if state.Draft == nil {
state.Draft = map[string]string{}
}
state.Draft[botFatherDraftBotID] = strconv.FormatInt(chosen.ID, 10)
state.Draft[botFatherDraftBotUsername] = chosen.Username
if err := s.bots.UpsertBotChatState(ctx, state); err != nil {
s.log.Error("botfather: save telegram login state", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
prompt := telegramLoginConfigurationPrompt(chosen.Username)
if !created {
return botReply{Text: fmt.Sprintf("Telegram Login client %s is ready for @%s.\n\n%s", credentials.Client.ClientID, chosen.Username, prompt)}
}
head := fmt.Sprintf("Telegram Login is now enabled for @%s.\nClient ID: %s\nSave this Client Secret; it will only be shown once:\n", chosen.Username, credentials.Client.ClientID)
return tokenReply(head, credentials.Secret, "\n\n"+prompt)
case botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy:
@ -507,6 +576,8 @@ func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState,
reply, err = s.applyToggle(ctx, botID, text, true)
case botFatherCmdSetPrivacy:
reply, err = s.applyToggle(ctx, botID, text, false)
case botFatherCmdSetLogin:
reply, err = s.applyTelegramLoginConfiguration(ctx, botID, username, text)
default:
s.clearState(ctx, state.UserID)
return internalReply()
@ -583,6 +654,156 @@ func (s *Service) applySetInlineGeo(ctx context.Context, botID int64, text strin
return botReply{Text: fmt.Sprintf("Success! Inline location requests are now %s.", state)}, nil
}
func telegramLoginConfigurationPrompt(username string) string {
return fmt.Sprintf(`Send one configuration command for @%s:
add origin https://example.com
add redirect https://example.com/auth/callback
add ios com.example.app ABCDE12345 exampleapp://tglogin Example iOS App
add android com.example.app AA:BB:...:FF exampleapp://telegram-login Example Android App
remove origin https://example.com
remove redirect https://example.com/auth/callback
remove app 12
algorithm RS256|ES256|EdDSA|ES256K
enable
disable
Origins authorize the JS SDK and legacy login_url buttons. Redirects are exact OIDC callbacks. Run /logininfo to inspect the result or /cancel to stop.`, username)
}
func formatTelegramLoginConfiguration(username string, configuration telegramloginapp.ClientConfiguration) string {
status := "disabled"
if configuration.Client.Enabled {
status = "enabled"
}
var out strings.Builder
fmt.Fprintf(&out, "Telegram Login for @%s\nClient ID: %s\nStatus: %s\nSigning algorithm: %s\nSecret version: %d",
username, configuration.Client.ClientID, status, configuration.Client.SigningAlgorithm, configuration.Client.SecretVersion)
if len(configuration.AllowedURLs) == 0 {
out.WriteString("\nAllowed URLs: none")
} else {
out.WriteString("\nAllowed URLs:")
for _, allowed := range configuration.AllowedURLs {
fmt.Fprintf(&out, "\n- %s %s", allowed.Kind, allowed.NormalizedURL)
}
}
if len(configuration.NativeApps) > 0 {
out.WriteString("\nNative apps:")
for _, app := range configuration.NativeApps {
fmt.Fprintf(&out, "\n- #%d %s %s [%s] -> %s (%s)", app.ID, app.Platform, app.ApplicationID, app.VerificationID, app.CallbackURI, app.VerifiedDisplayName)
}
}
return out.String()
}
func telegramLoginAllowedURLKind(raw string) (domain.TelegramLoginAllowedURLKind, bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "origin":
return domain.TelegramLoginAllowedWebOrigin, true
case "redirect":
return domain.TelegramLoginAllowedRedirectURI, true
default:
return "", false
}
}
func telegramLoginSigningAlgorithm(raw string) (domain.TelegramLoginSigningAlgorithm, bool) {
switch strings.ToUpper(strings.TrimSpace(raw)) {
case "RS256":
return domain.TelegramLoginSigningRS256, true
case "ES256":
return domain.TelegramLoginSigningES256, true
case "EDDSA":
return domain.TelegramLoginSigningEdDSA, true
case "ES256K":
return domain.TelegramLoginSigningES256K, true
default:
return "", false
}
}
func (s *Service) applyTelegramLoginConfiguration(ctx context.Context, botID int64, username, text string) (botReply, error) {
if s.telegramLogin == nil {
return botReply{Text: "Telegram Login is not enabled on this server."}, domain.ErrTelegramLoginClientDisabled
}
fields := strings.Fields(strings.TrimSpace(text))
if len(fields) == 1 {
switch strings.ToLower(fields[0]) {
case "enable":
if err := s.telegramLogin.SetClientEnabled(ctx, botID, true); err != nil {
return botReply{}, err
}
return botReply{Text: fmt.Sprintf("Telegram Login is enabled for @%s. Use /setlogin for another change or /logininfo to review it.", username)}, nil
case "disable":
if err := s.telegramLogin.SetClientEnabled(ctx, botID, false); err != nil {
return botReply{}, err
}
return botReply{Text: fmt.Sprintf("Telegram Login is disabled for @%s. Pending requests can no longer be approved or exchanged.", username)}, nil
}
}
if len(fields) == 2 && strings.EqualFold(fields[0], "algorithm") {
algorithm, ok := telegramLoginSigningAlgorithm(fields[1])
if !ok {
return botReply{Text: "Unknown signing algorithm. Use RS256, ES256, EdDSA or ES256K, or /cancel."}, domain.ErrTelegramLoginClientInvalid
}
if _, err := s.telegramLogin.SetClientSigningAlgorithm(ctx, botID, algorithm); err != nil {
if errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
return botReply{Text: fmt.Sprintf("%s is not available on this server because no active signing key is configured for it. Choose another algorithm or ask the operator to rotate the key ring.", algorithm)}, err
}
return botReply{}, err
}
return botReply{Text: fmt.Sprintf("Success! New ID tokens for @%s will use %s. EdDSA and ES256K accept only the openid scope.", username, algorithm)}, nil
}
if len(fields) == 3 && (strings.EqualFold(fields[0], "add") || strings.EqualFold(fields[0], "remove")) &&
(strings.EqualFold(fields[1], "origin") || strings.EqualFold(fields[1], "redirect")) {
kind, ok := telegramLoginAllowedURLKind(fields[1])
if !ok {
return botReply{Text: "URL kind must be origin or redirect. Try again or /cancel."}, domain.ErrTelegramLoginURLInvalid
}
if strings.EqualFold(fields[0], "add") {
allowed, err := s.telegramLogin.AddAllowedURL(ctx, botID, kind, fields[2])
if err != nil {
return botReply{Text: "That URL is not allowed. Use an exact HTTPS URL without credentials, fragments or reserved OAuth query fields."}, err
}
return botReply{Text: fmt.Sprintf("Success! Added %s for @%s:\n%s", allowed.Kind, username, allowed.NormalizedURL)}, nil
}
deleted, err := s.telegramLogin.DeleteAllowedURL(ctx, botID, kind, fields[2])
if err != nil {
return botReply{Text: "That URL is invalid. Try again or /cancel."}, err
}
if !deleted {
return botReply{Text: "That exact URL was not registered. Check /logininfo and try again."}, domain.ErrTelegramLoginURLInvalid
}
return botReply{Text: fmt.Sprintf("Success! Removed %s from @%s.", kind, username)}, nil
}
if len(fields) >= 6 && strings.EqualFold(fields[0], "add") && (strings.EqualFold(fields[1], "ios") || strings.EqualFold(fields[1], "android")) {
platform := domain.TelegramLoginNativeIOS
if strings.EqualFold(fields[1], "android") {
platform = domain.TelegramLoginNativeAndroid
}
app, err := s.telegramLogin.AddNativeApp(ctx, botID, platform, fields[2], fields[3], fields[4], strings.Join(fields[5:], " "))
if err != nil {
return botReply{Text: "Invalid native app registration. iOS needs Bundle ID + 10-character Team ID; Android needs package name + SHA-256 signing fingerprint. Use an exact HTTPS callback or a custom scheme://host callback."}, err
}
return botReply{Text: fmt.Sprintf("Success! Registered native app #%d for @%s:\n%s %s -> %s", app.ID, username, app.Platform, app.ApplicationID, app.CallbackURI)}, nil
}
if len(fields) == 3 && strings.EqualFold(fields[0], "remove") && strings.EqualFold(fields[1], "app") {
appID, err := strconv.ParseInt(fields[2], 10, 64)
if err != nil || appID <= 0 {
return botReply{Text: "Native app ID must be the positive number shown by /logininfo."}, domain.ErrTelegramLoginClientInvalid
}
deleted, err := s.telegramLogin.DeleteNativeApp(ctx, botID, appID)
if err != nil {
return botReply{}, err
}
if !deleted {
return botReply{Text: "That native app was not registered for this bot. Check /logininfo."}, domain.ErrTelegramLoginClientInvalid
}
return botReply{Text: fmt.Sprintf("Success! Removed native app #%d from @%s.", appID, username)}, nil
}
return botReply{Text: telegramLoginConfigurationPrompt(username)}, domain.ErrTelegramLoginRequestInvalid
}
// applyToggle 解析 enable/disable 并设置 joingroupsjoin=true或 privacyjoin=false
func (s *Service) applyToggle(ctx context.Context, botID int64, text string, join bool) (botReply, error) {
var on bool

View file

@ -0,0 +1,100 @@
package bots
import (
"context"
"strconv"
"strings"
"testing"
"time"
telegramloginapp "telesrv/internal/app/telegramlogin"
"telesrv/internal/store/memory"
)
func newBotFatherTelegramLoginService(t *testing.T) *telegramloginapp.Service {
t.Helper()
sealKey := make([]byte, 32)
sealKey[0] = 1
sealer, err := telegramloginapp.NewCodeSealer("test", map[string][]byte{"test": sealKey})
if err != nil {
t.Fatal(err)
}
pepper := make([]byte, 32)
pepper[0] = 2
service, err := telegramloginapp.NewService(memory.NewTelegramLoginStore(nil), sealer, telegramloginapp.Config{
Issuer: "http://localhost:2404", AppScheme: "telesrv", AllowLoopbackHTTP: true,
ClientSecretPepper: pepper, Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() },
})
if err != nil {
t.Fatal(err)
}
return service
}
func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) {
svc, users, _, messages := newTestService(t)
svc.telegramLogin = newBotFatherTelegramLoginService(t)
owner := newOwner(t, users, "+1090")
bot, _, err := svc.CreateBot(context.Background(), owner.ID, "Login Demo", "login_demo_bot")
if err != nil {
t.Fatal(err)
}
if reply := sendToBotFather(t, svc, messages, owner, "/setlogin"); !strings.Contains(reply, "Choose a bot") {
t.Fatalf("/setlogin reply = %q", reply)
}
created := sendToBotFather(t, svc, messages, owner, "@login_demo_bot")
if !strings.Contains(created, "Client ID: "+strconv.FormatInt(bot.ID, 10)) || !strings.Contains(created, "only be shown once") {
t.Fatalf("create login reply = %q", created)
}
secretMarker := "only be shown once:\n"
secret := strings.SplitN(strings.SplitN(created, secretMarker, 2)[1], "\n", 2)[0]
if len(secret) < 32 {
t.Fatalf("client secret is unexpectedly short: %q", secret)
}
if reply := sendToBotFather(t, svc, messages, owner, "add origin http://localhost:3000"); !strings.Contains(reply, "Success!") {
t.Fatalf("add origin reply = %q", reply)
}
sendToBotFather(t, svc, messages, owner, "/setlogin")
sendToBotFather(t, svc, messages, owner, "login_demo_bot")
if reply := sendToBotFather(t, svc, messages, owner, "add redirect http://localhost:3000/auth/callback"); !strings.Contains(reply, "Success!") {
t.Fatalf("add redirect reply = %q", reply)
}
sendToBotFather(t, svc, messages, owner, "/setlogin")
sendToBotFather(t, svc, messages, owner, "login_demo_bot")
if reply := sendToBotFather(t, svc, messages, owner, "algorithm ES256"); !strings.Contains(reply, "ES256") {
t.Fatalf("algorithm reply = %q", reply)
}
sendToBotFather(t, svc, messages, owner, "/setlogin")
sendToBotFather(t, svc, messages, owner, "login_demo_bot")
if reply := sendToBotFather(t, svc, messages, owner, "add ios dev.bedolaga.demo ABCDE12345 bedolaga://telegram-login Bedolaga iOS Demo"); !strings.Contains(reply, "Registered native app #") {
t.Fatalf("add iOS app reply = %q", reply)
}
sendToBotFather(t, svc, messages, owner, "/setlogin")
sendToBotFather(t, svc, messages, owner, "login_demo_bot")
fingerprint := strings.Repeat("A", 64)
if reply := sendToBotFather(t, svc, messages, owner, "add android dev.bedolaga.demo "+fingerprint+" bedolaga://android-login Bedolaga Android Demo"); !strings.Contains(reply, "Registered native app #") {
t.Fatalf("add Android app reply = %q", reply)
}
sendToBotFather(t, svc, messages, owner, "/logininfo")
info := sendToBotFather(t, svc, messages, owner, "login_demo_bot")
for _, want := range []string{"Signing algorithm: ES256", "web_origin http://localhost:3000", "redirect_uri http://localhost:3000/auth/callback", "dev.bedolaga.demo", "Bedolaga iOS Demo", "Bedolaga Android Demo"} {
if !strings.Contains(info, want) {
t.Fatalf("login info = %q, missing %q", info, want)
}
}
if strings.Contains(info, secret) {
t.Fatal("/logininfo leaked the one-time client secret")
}
sendToBotFather(t, svc, messages, owner, "/resetloginsecret")
rotated := sendToBotFather(t, svc, messages, owner, "login_demo_bot")
if !strings.Contains(rotated, "previous OIDC Client Secret") || strings.Contains(rotated, secret) {
t.Fatalf("rotate reply = %q", rotated)
}
}

View file

@ -16,6 +16,7 @@ import (
"go.uber.org/zap"
telegramloginapp "telesrv/internal/app/telegramlogin"
"telesrv/internal/domain"
"telesrv/internal/links"
"telesrv/internal/store"
@ -82,6 +83,7 @@ type Service struct {
stickers stickerSetCreator
installer userStickerSetInstaller
aiChat aiChatGenerator
telegramLogin *telegramloginapp.Service
hooks RouterHooks
textDrafts TextDraftPusher
userCache store.UserCache
@ -175,6 +177,16 @@ func WithAIChatGenerator(g aiChatGenerator) Option {
}
}
// WithTelegramLogin injects the OIDC application service used by BotFather.
// BotFather never writes the login tables directly.
func WithTelegramLogin(login *telegramloginapp.Service) Option {
return func(s *Service) {
if login != nil {
s.telegramLogin = login
}
}
}
// WithAIChatStreamThrottle 调整 @ChatBot 流式草稿推送的最小时间间隔(测试用)。
func WithAIChatStreamThrottle(d time.Duration) Option {
return func(s *Service) {

View file

@ -0,0 +1,131 @@
package telegramlogin
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"telesrv/internal/domain"
)
const opaqueTokenBytes = 32
func GenerateOpaqueToken() (string, error) {
raw := make([]byte, opaqueTokenBytes)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate opaque token: %w", err)
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func HashOpaqueToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}
func HashClientSecret(pepper []byte, secret string) ([]byte, error) {
if len(pepper) < 32 || secret == "" {
return nil, domain.ErrTelegramLoginSecretInvalid
}
mac := hmac.New(sha256.New, pepper)
_, _ = mac.Write([]byte(secret))
return mac.Sum(nil), nil
}
func VerifyClientSecret(pepper []byte, secret string, expected []byte) bool {
actual, err := HashClientSecret(pepper, secret)
if err != nil || len(expected) != sha256.Size {
return false
}
return subtle.ConstantTimeCompare(actual, expected) == 1
}
func PKCEChallenge(verifier string) (string, error) {
if len(verifier) < 43 || len(verifier) > 128 {
return "", domain.ErrTelegramLoginPKCEInvalid
}
for i := 0; i < len(verifier); i++ {
c := verifier[i]
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '_' || c == '~') {
return "", domain.ErrTelegramLoginPKCEInvalid
}
}
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:]), nil
}
func ValidatePKCEChallenge(challenge, method string) error {
if method != "S256" || len(challenge) < 43 || len(challenge) > 128 {
return domain.ErrTelegramLoginPKCEInvalid
}
for i := 0; i < len(challenge); i++ {
c := challenge[i]
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
return domain.ErrTelegramLoginPKCEInvalid
}
}
return nil
}
type CodeSealer struct {
activeKeyID string
keys map[string]cipher.AEAD
}
func NewCodeSealer(activeKeyID string, rawKeys map[string][]byte) (*CodeSealer, error) {
if activeKeyID == "" || len(rawKeys) == 0 {
return nil, errors.New("telegram login code seal key ring is empty")
}
keys := make(map[string]cipher.AEAD, len(rawKeys))
for keyID, raw := range rawKeys {
if keyID == "" || len(raw) != 32 {
return nil, fmt.Errorf("invalid telegram login code seal key %q", keyID)
}
block, err := aes.NewCipher(raw)
if err != nil {
return nil, fmt.Errorf("create telegram login code seal key %q: %w", keyID, err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create telegram login code sealer %q: %w", keyID, err)
}
keys[keyID] = aead
}
if _, ok := keys[activeKeyID]; !ok {
return nil, fmt.Errorf("active telegram login code seal key %q not found", activeKeyID)
}
return &CodeSealer{activeKeyID: activeKeyID, keys: keys}, nil
}
func (s *CodeSealer) Seal(plaintext string, aad []byte) (sealed, nonce []byte, keyID string, err error) {
if s == nil || plaintext == "" {
return nil, nil, "", domain.ErrTelegramLoginCodeInvalid
}
aead := s.keys[s.activeKeyID]
nonce = make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, nil, "", fmt.Errorf("generate telegram login code nonce: %w", err)
}
return aead.Seal(nil, nonce, []byte(plaintext), aad), nonce, s.activeKeyID, nil
}
func (s *CodeSealer) Open(sealed, nonce []byte, keyID string, aad []byte) (string, error) {
if s == nil {
return "", domain.ErrTelegramLoginCodeInvalid
}
aead, ok := s.keys[keyID]
if !ok || len(nonce) != aead.NonceSize() {
return "", domain.ErrTelegramLoginCodeInvalid
}
plaintext, err := aead.Open(nil, nonce, sealed, aad)
if err != nil || len(plaintext) == 0 {
return "", domain.ErrTelegramLoginCodeInvalid
}
return string(plaintext), nil
}

View file

@ -0,0 +1,406 @@
package telegramlogin
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"telesrv/internal/domain"
)
const defaultIDTokenTTL = time.Hour
type SigningKeyMaterial struct {
Algorithm domain.TelegramLoginSigningAlgorithm
KeyID string
PrivateKey any
Active bool
PublishUntil time.Time
}
type signingKey struct {
algorithm domain.TelegramLoginSigningAlgorithm
jwaAlgorithm jwa.SignatureAlgorithm
keyID string
private jwk.Key
public jwk.Key
active bool
publishUntil time.Time
}
// SigningKeyRing owns no mutable crypto state. Rotation is performed by
// constructing a new ring containing the new active key and old public keys
// with a PublishUntil at least as long as the maximum ID-token lifetime.
type SigningKeyRing struct {
keys []signingKey
active map[domain.TelegramLoginSigningAlgorithm]signingKey
now func() time.Time
}
func NewSigningKeyRing(materials []SigningKeyMaterial, now func() time.Time) (*SigningKeyRing, error) {
if len(materials) == 0 {
return nil, errors.New("telegram login signing key ring is empty")
}
if now == nil {
now = time.Now
}
ring := &SigningKeyRing{
keys: make([]signingKey, 0, len(materials)),
active: make(map[domain.TelegramLoginSigningAlgorithm]signingKey),
now: now,
}
seenKeyIDs := make(map[string]struct{}, len(materials))
for _, material := range materials {
key, err := importSigningKey(material)
if err != nil {
return nil, err
}
if _, duplicate := seenKeyIDs[key.keyID]; duplicate {
return nil, fmt.Errorf("duplicate telegram login signing kid %q", key.keyID)
}
seenKeyIDs[key.keyID] = struct{}{}
if key.active {
if _, duplicate := ring.active[key.algorithm]; duplicate {
return nil, fmt.Errorf("multiple active telegram login signing keys for %s", key.algorithm)
}
ring.active[key.algorithm] = key
}
ring.keys = append(ring.keys, key)
}
if len(ring.active) == 0 {
return nil, errors.New("telegram login signing key ring has no active key")
}
return ring, nil
}
func importSigningKey(material SigningKeyMaterial) (signingKey, error) {
if !material.Algorithm.Valid() || material.PrivateKey == nil {
return signingKey{}, fmt.Errorf("invalid telegram login signing key material")
}
if material.Algorithm == domain.TelegramLoginSigningES256K && !telegramLoginES256KEnabled {
return signingKey{}, errors.New("telegram login ES256K requires a build with -tags jwx_es256k")
}
if err := validateRawSigningKey(material.Algorithm, material.PrivateKey); err != nil {
return signingKey{}, err
}
privateKey, err := jwk.Import(material.PrivateKey)
if err != nil {
return signingKey{}, fmt.Errorf("import telegram login %s private key: %w", material.Algorithm, err)
}
if err := privateKey.Validate(); err != nil {
return signingKey{}, fmt.Errorf("validate telegram login %s private JWK: %w", material.Algorithm, err)
}
publicKey, err := privateKey.PublicKey()
if err != nil {
return signingKey{}, fmt.Errorf("derive telegram login %s public JWK: %w", material.Algorithm, err)
}
thumbprint, err := publicKey.Thumbprint(crypto.SHA256)
if err != nil {
return signingKey{}, fmt.Errorf("thumbprint telegram login %s public JWK: %w", material.Algorithm, err)
}
keyID := strings.TrimSpace(material.KeyID)
if keyID == "" {
keyID = base64.RawURLEncoding.EncodeToString(thumbprint)
}
if len(keyID) > 128 || strings.IndexFunc(keyID, func(r rune) bool { return r <= 0x20 || r == 0x7f }) >= 0 {
return signingKey{}, fmt.Errorf("invalid telegram login signing kid")
}
jwaAlgorithm, err := telegramLoginJWA(material.Algorithm)
if err != nil {
return signingKey{}, err
}
for _, key := range []jwk.Key{privateKey, publicKey} {
if err := key.Set(jwk.KeyIDKey, keyID); err != nil {
return signingKey{}, fmt.Errorf("set telegram login signing kid: %w", err)
}
if err := key.Set(jwk.AlgorithmKey, jwaAlgorithm); err != nil {
return signingKey{}, fmt.Errorf("set telegram login signing algorithm: %w", err)
}
if err := key.Set(jwk.KeyUsageKey, "sig"); err != nil {
return signingKey{}, fmt.Errorf("set telegram login signing use: %w", err)
}
}
return signingKey{
algorithm: material.Algorithm, jwaAlgorithm: jwaAlgorithm, keyID: keyID,
private: privateKey, public: publicKey, active: material.Active,
publishUntil: material.PublishUntil.UTC(),
}, nil
}
func validateRawSigningKey(algorithm domain.TelegramLoginSigningAlgorithm, raw any) error {
switch algorithm {
case domain.TelegramLoginSigningRS256:
key, ok := rsaPrivateKey(raw)
if !ok || key.N == nil || key.N.BitLen() < 2048 || key.E < 3 {
return errors.New("telegram login RS256 requires an RSA private key of at least 2048 bits")
}
if err := key.Validate(); err != nil {
return fmt.Errorf("validate telegram login RSA private key: %w", err)
}
case domain.TelegramLoginSigningES256:
key, ok := ecdsaPrivateKey(raw)
if !ok || key.Curve != elliptic.P256() || key.D == nil || key.X == nil || key.Y == nil {
return errors.New("telegram login ES256 requires a P-256 ECDSA private key")
}
case domain.TelegramLoginSigningEdDSA:
key, ok := raw.(ed25519.PrivateKey)
if !ok || len(key) != ed25519.PrivateKeySize {
return errors.New("telegram login EdDSA requires an Ed25519 private key")
}
case domain.TelegramLoginSigningES256K:
key, ok := ecdsaPrivateKey(raw)
if !ok || key.Curve == nil || key.Curve.Params() == nil ||
!strings.EqualFold(key.Curve.Params().Name, "secp256k1") || key.D == nil || key.X == nil || key.Y == nil {
return errors.New("telegram login ES256K requires a secp256k1 ECDSA private key")
}
default:
return domain.ErrTelegramLoginClientInvalid
}
return nil
}
func rsaPrivateKey(raw any) (*rsa.PrivateKey, bool) {
switch key := raw.(type) {
case *rsa.PrivateKey:
return key, key != nil
case rsa.PrivateKey:
return &key, true
default:
return nil, false
}
}
func ecdsaPrivateKey(raw any) (*ecdsa.PrivateKey, bool) {
switch key := raw.(type) {
case *ecdsa.PrivateKey:
return key, key != nil
case ecdsa.PrivateKey:
return &key, true
default:
return nil, false
}
}
func telegramLoginJWA(algorithm domain.TelegramLoginSigningAlgorithm) (jwa.SignatureAlgorithm, error) {
switch algorithm {
case domain.TelegramLoginSigningRS256:
return jwa.RS256(), nil
case domain.TelegramLoginSigningES256:
return jwa.ES256(), nil
case domain.TelegramLoginSigningEdDSA:
return jwa.EdDSA(), nil
case domain.TelegramLoginSigningES256K:
if telegramLoginES256KEnabled {
return jwa.ES256K(), nil
}
return jwa.EmptySignatureAlgorithm(), errors.New("telegram login ES256K is disabled in this build")
default:
return jwa.EmptySignatureAlgorithm(), domain.ErrTelegramLoginClientInvalid
}
}
func (r *SigningKeyRing) SupportedAlgorithms() []string {
if r == nil {
return nil
}
ordered := make([]string, 0, len(r.active))
for _, algorithm := range []domain.TelegramLoginSigningAlgorithm{
domain.TelegramLoginSigningRS256,
domain.TelegramLoginSigningES256,
domain.TelegramLoginSigningEdDSA,
domain.TelegramLoginSigningES256K,
} {
if _, ok := r.active[algorithm]; ok {
ordered = append(ordered, string(algorithm))
}
}
return ordered
}
// ActiveAlgorithms returns the algorithms that can sign new tokens on this
// instance. Callers use it to prevent durable client configuration from
// selecting an algorithm without an active private key.
func (r *SigningKeyRing) ActiveAlgorithms() []domain.TelegramLoginSigningAlgorithm {
if r == nil {
return nil
}
ordered := make([]domain.TelegramLoginSigningAlgorithm, 0, len(r.active))
for _, algorithm := range []domain.TelegramLoginSigningAlgorithm{
domain.TelegramLoginSigningRS256,
domain.TelegramLoginSigningES256,
domain.TelegramLoginSigningEdDSA,
domain.TelegramLoginSigningES256K,
} {
if _, ok := r.active[algorithm]; ok {
ordered = append(ordered, algorithm)
}
}
return ordered
}
func (r *SigningKeyRing) JWKS() ([]byte, string, error) {
if r == nil {
return nil, "", errors.New("telegram login signing key ring is nil")
}
now := r.now().UTC()
set := jwk.NewSet()
for _, key := range r.keys {
if !key.active && (key.publishUntil.IsZero() || !now.Before(key.publishUntil)) {
continue
}
clone, err := key.public.Clone()
if err != nil {
return nil, "", fmt.Errorf("clone telegram login public JWK: %w", err)
}
if err := set.AddKey(clone); err != nil {
return nil, "", fmt.Errorf("add telegram login public JWK: %w", err)
}
}
body, err := json.Marshal(set)
if err != nil {
return nil, "", fmt.Errorf("marshal telegram login JWKS: %w", err)
}
sum := sha256.Sum256(body)
return body, `"` + base64.RawURLEncoding.EncodeToString(sum[:]) + `"`, nil
}
func (r *SigningKeyRing) sign(algorithm domain.TelegramLoginSigningAlgorithm, token jwt.Token) (string, error) {
if r == nil || token == nil {
return "", errors.New("telegram login ID token signer is unavailable")
}
key, ok := r.active[algorithm]
if !ok {
return "", fmt.Errorf("no active telegram login signing key for %s", algorithm)
}
signed, err := jwt.Sign(token, jwt.WithKey(key.jwaAlgorithm, key.private))
if err != nil {
return "", fmt.Errorf("sign telegram login ID token with %s: %w", algorithm, err)
}
return string(signed), nil
}
type IDTokenIssuerConfig struct {
Issuer string
TTL time.Duration
Now func() time.Time
}
type IDTokenIssuer struct {
issuer string
ttl time.Duration
now func() time.Time
keys *SigningKeyRing
}
func (i *IDTokenIssuer) Issuer() string {
if i == nil {
return ""
}
return i.issuer
}
func (i *IDTokenIssuer) TTL() time.Duration {
if i == nil {
return 0
}
return i.ttl
}
func (i *IDTokenIssuer) SupportedAlgorithms() []string {
if i == nil {
return nil
}
return i.keys.SupportedAlgorithms()
}
func (i *IDTokenIssuer) JWKS() ([]byte, string, error) {
if i == nil {
return nil, "", errors.New("telegram login ID token issuer is nil")
}
return i.keys.JWKS()
}
func NewIDTokenIssuer(keys *SigningKeyRing, cfg IDTokenIssuerConfig) (*IDTokenIssuer, error) {
if keys == nil {
return nil, errors.New("telegram login signing key ring is required")
}
issuer, err := NormalizeWebOrigin(cfg.Issuer, true)
if err != nil {
return nil, fmt.Errorf("telegram login ID token issuer: %w", err)
}
if cfg.TTL == 0 {
cfg.TTL = defaultIDTokenTTL
}
if cfg.TTL < time.Minute || cfg.TTL > 24*time.Hour {
return nil, errors.New("telegram login ID token TTL is outside the bounded range")
}
if cfg.Now == nil {
cfg.Now = time.Now
}
return &IDTokenIssuer{issuer: issuer, ttl: cfg.TTL, now: cfg.Now, keys: keys}, nil
}
func (i *IDTokenIssuer) Issue(request domain.TelegramLoginRequest) (string, error) {
if i == nil || request.Status != domain.TelegramLoginRequestApproved || request.AuthorizedUserID <= 0 ||
request.ClientID == "" || request.ApprovedAt.IsZero() {
return "", domain.ErrTelegramLoginRequestInvalid
}
if err := domain.ValidateTelegramLoginScopes(request.Scopes, request.SigningAlgorithm); err != nil {
return "", err
}
identity := domain.TelegramLoginIdentitySnapshot{
UserID: request.AuthorizedUserID, Name: request.ProfileName, GivenName: request.GivenName,
FamilyName: request.FamilyName, PreferredUsername: request.PreferredUsername,
Picture: request.Picture, PhoneNumber: request.PhoneNumber,
}
identity, err := identity.Sanitized(request.Requests(domain.TelegramLoginScopeProfile), request.PhoneShared)
if err != nil {
return "", err
}
now := i.now().UTC()
builder := jwt.NewBuilder().
Issuer(i.issuer).
Audience([]string{request.ClientID}).
Subject(fmt.Sprintf("%d", identity.UserID)).
IssuedAt(now).
Expiration(now.Add(i.ttl))
if request.Nonce != "" {
builder.Claim("nonce", request.Nonce)
}
if request.Requests(domain.TelegramLoginScopeProfile) {
builder.Claim("id", identity.UserID).
Claim("name", identity.Name).
Claim("given_name", identity.GivenName)
if identity.FamilyName != "" {
builder.Claim("family_name", identity.FamilyName)
}
if identity.PreferredUsername != "" {
builder.Claim("preferred_username", identity.PreferredUsername)
}
if identity.Picture != "" {
builder.Claim("picture", identity.Picture)
}
}
if request.Requests(domain.TelegramLoginScopePhone) && request.PhoneShared {
builder.Claim("phone_number", identity.PhoneNumber).
Claim("phone_number_verified", true)
}
token, err := builder.Build()
if err != nil {
return "", fmt.Errorf("build telegram login ID token: %w", err)
}
return i.keys.sign(request.SigningAlgorithm, token)
}

View file

@ -0,0 +1,5 @@
//go:build !jwx_es256k
package telegramlogin
const telegramLoginES256KEnabled = false

View file

@ -0,0 +1,17 @@
//go:build !jwx_es256k
package telegramlogin
import (
"testing"
"telesrv/internal/domain"
)
func TestES256KFailsClosedWithoutBuildTag(t *testing.T) {
if _, err := NewSigningKeyRing([]SigningKeyMaterial{{
Algorithm: domain.TelegramLoginSigningES256K, PrivateKey: struct{}{}, Active: true,
}}, nil); err == nil {
t.Fatal("ES256K configuration unexpectedly accepted without jwx_es256k")
}
}

View file

@ -0,0 +1,5 @@
//go:build jwx_es256k
package telegramlogin
const telegramLoginES256KEnabled = true

View file

@ -0,0 +1,57 @@
//go:build jwx_es256k
package telegramlogin
import (
"testing"
"time"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"telesrv/internal/domain"
)
func TestES256KIDTokenRoundTripWithBuildTag(t *testing.T) {
raw, err := secp256k1.GeneratePrivateKey()
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
ring, err := NewSigningKeyRing([]SigningKeyMaterial{{
Algorithm: domain.TelegramLoginSigningES256K,
KeyID: "secp256k1-active",
PrivateKey: raw.ToECDSA(),
Active: true,
}}, func() time.Time { return now })
if err != nil {
t.Fatal(err)
}
issuer, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{
Issuer: "https://oauth.telesrv.test", Now: func() time.Time { return now },
})
if err != nil {
t.Fatal(err)
}
signed, err := issuer.Issue(domain.TelegramLoginRequest{
ClientID: "9001", SigningAlgorithm: domain.TelegramLoginSigningES256K,
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID},
Status: domain.TelegramLoginRequestApproved, AuthorizedUserID: 42,
ApprovedAt: now.Add(-time.Minute),
})
if err != nil {
t.Fatal(err)
}
body, _, err := ring.JWKS()
if err != nil {
t.Fatal(err)
}
set, err := jwk.Parse(body)
if err != nil {
t.Fatal(err)
}
if _, err := jwt.Parse([]byte(signed), jwt.WithKeySet(set), jwt.WithValidate(false)); err != nil {
t.Fatalf("verify ES256K token: %v", err)
}
}

View file

@ -0,0 +1,188 @@
package telegramlogin
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"testing"
"time"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"telesrv/internal/domain"
)
func telegramLoginTestSigningKeys(t *testing.T, now *time.Time) *SigningKeyRing {
t.Helper()
oldRSA, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
activeRSA, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
es256, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
_, ed25519Key, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
ring, err := NewSigningKeyRing([]SigningKeyMaterial{
{Algorithm: domain.TelegramLoginSigningRS256, KeyID: "rsa-old", PrivateKey: oldRSA, PublishUntil: now.Add(2 * time.Hour)},
{Algorithm: domain.TelegramLoginSigningRS256, KeyID: "rsa-active", PrivateKey: activeRSA, Active: true},
{Algorithm: domain.TelegramLoginSigningES256, KeyID: "p256-active", PrivateKey: es256, Active: true},
{Algorithm: domain.TelegramLoginSigningEdDSA, KeyID: "ed25519-active", PrivateKey: ed25519Key, Active: true},
}, func() time.Time { return *now })
if err != nil {
t.Fatal(err)
}
return ring
}
func TestSigningKeyRingRotationAndAlgorithms(t *testing.T) {
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
ring := telegramLoginTestSigningKeys(t, &now)
if got := ring.SupportedAlgorithms(); len(got) != 3 || got[0] != "RS256" || got[1] != "ES256" || got[2] != "EdDSA" {
t.Fatalf("SupportedAlgorithms = %#v", got)
}
body, etag, err := ring.JWKS()
if err != nil {
t.Fatal(err)
}
set, err := jwk.Parse(body)
if err != nil {
t.Fatalf("parse JWKS: %v", err)
}
if set.Len() != 4 || etag == "" {
t.Fatalf("JWKS len=%d etag=%q body=%s", set.Len(), etag, body)
}
var decoded map[string]any
if err := json.Unmarshal(body, &decoded); err != nil {
t.Fatal(err)
}
for i := 0; i < set.Len(); i++ {
key, _ := set.Key(i)
if key.Has("d") || key.Has("p") || key.Has("q") {
t.Fatalf("JWKS leaked private key material: %s", body)
}
}
now = now.Add(3 * time.Hour)
body, _, err = ring.JWKS()
if err != nil {
t.Fatal(err)
}
set, err = jwk.Parse(body)
if err != nil || set.Len() != 3 {
t.Fatalf("JWKS after retirement len=%d err=%v body=%s", set.Len(), err, body)
}
}
func TestIDTokenIssuerScopeProjectionAndVerification(t *testing.T) {
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
ring := telegramLoginTestSigningKeys(t, &now)
issuer, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{
Issuer: "https://oauth.telesrv.test", Now: func() time.Time { return now },
})
if err != nil {
t.Fatal(err)
}
profileRequest := domain.TelegramLoginRequest{
ClientID: "9001", SigningAlgorithm: domain.TelegramLoginSigningRS256,
Scopes: []domain.TelegramLoginScope{
domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile, domain.TelegramLoginScopePhone,
},
Nonce: "request-nonce", Status: domain.TelegramLoginRequestApproved, AuthorizedUserID: 42,
ProfileName: "Alice Example", GivenName: "Alice", FamilyName: "Example",
PreferredUsername: "alice", Picture: "https://oauth.telesrv.test/userpic/42",
PhoneNumber: "15551234567", PhoneShared: true, ApprovedAt: now.Add(-time.Minute),
}
signed, err := issuer.Issue(profileRequest)
if err != nil {
t.Fatal(err)
}
jwksBody, _, err := ring.JWKS()
if err != nil {
t.Fatal(err)
}
set, err := jwk.Parse(jwksBody)
if err != nil {
t.Fatal(err)
}
token, err := jwt.Parse([]byte(signed), jwt.WithKeySet(set), jwt.WithValidate(false))
if err != nil {
t.Fatalf("verify signed token: %v", err)
}
issuerValue, _ := token.Issuer()
subject, _ := token.Subject()
audience, _ := token.Audience()
if issuerValue != "https://oauth.telesrv.test" || subject != "42" || len(audience) != 1 || audience[0] != "9001" {
t.Fatalf("standard claims iss=%q sub=%q aud=%#v", issuerValue, subject, audience)
}
var id float64
var name, phone, nonce string
var verified bool
if err := token.Get("id", &id); err != nil || id != 42 {
t.Fatalf("id claim=%v err=%v", id, err)
}
if err := token.Get("name", &name); err != nil || name != "Alice Example" {
t.Fatalf("name claim=%q err=%v", name, err)
}
if err := token.Get("phone_number", &phone); err != nil || phone != "15551234567" {
t.Fatalf("phone claim=%q err=%v", phone, err)
}
if err := token.Get("phone_number_verified", &verified); err != nil || !verified {
t.Fatalf("phone verified=%v err=%v", verified, err)
}
if err := token.Get("nonce", &nonce); err != nil || nonce != "request-nonce" {
t.Fatalf("nonce=%q err=%v", nonce, err)
}
openidOnly := profileRequest
openidOnly.SigningAlgorithm = domain.TelegramLoginSigningEdDSA
openidOnly.Scopes = []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID}
openidOnly.ProfileName = ""
openidOnly.GivenName = ""
openidOnly.FamilyName = ""
openidOnly.PreferredUsername = ""
openidOnly.Picture = ""
openidOnly.PhoneNumber = ""
openidOnly.PhoneShared = false
signed, err = issuer.Issue(openidOnly)
if err != nil {
t.Fatal(err)
}
token, err = jwt.Parse([]byte(signed), jwt.WithKeySet(set), jwt.WithValidate(false))
if err != nil {
t.Fatalf("verify EdDSA token: %v", err)
}
if token.Has("id") || token.Has("name") || token.Has("phone_number") {
t.Fatalf("openid-only token leaked optional claims: %#v", token.Keys())
}
}
func TestSigningKeyRingRejectsWrongCurveAndDuplicateActiveKey(t *testing.T) {
p384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
t.Fatal(err)
}
if _, err := NewSigningKeyRing([]SigningKeyMaterial{{
Algorithm: domain.TelegramLoginSigningES256, PrivateKey: p384, Active: true,
}}, nil); err == nil {
t.Fatal("P-384 key unexpectedly accepted for ES256")
}
key1, _ := rsa.GenerateKey(rand.Reader, 2048)
key2, _ := rsa.GenerateKey(rand.Reader, 2048)
if _, err := NewSigningKeyRing([]SigningKeyMaterial{
{Algorithm: domain.TelegramLoginSigningRS256, PrivateKey: key1, Active: true},
{Algorithm: domain.TelegramLoginSigningRS256, PrivateKey: key2, Active: true},
}, nil); err == nil {
t.Fatal("two active RS256 keys unexpectedly accepted")
}
}

View file

@ -0,0 +1,186 @@
package telegramlogin
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/lestrrat-go/jwx/v3/jwk"
"telesrv/internal/domain"
)
const (
maxTelegramLoginManifestBytes = 1 << 20
maxTelegramLoginKeyBytes = 256 << 10
)
type signingKeyManifest struct {
Version int `json:"version"`
Keys []signingKeyManifestEntry `json:"keys"`
}
type signingKeyManifestEntry struct {
Algorithm domain.TelegramLoginSigningAlgorithm `json:"algorithm"`
KeyID string `json:"kid,omitempty"`
PrivateKeyFile string `json:"private_key_file"`
Active bool `json:"active"`
PublishUntil string `json:"publish_until,omitempty"`
}
// LoadSigningKeyRing reads a versioned manifest and private PEM/JWK files.
// Relative key paths are resolved against the manifest directory. The caller
// should atomically replace files and rebuild/swap the ring when rotating.
func LoadSigningKeyRing(path string, now func() time.Time) (*SigningKeyRing, error) {
var manifest signingKeyManifest
if err := readStrictJSONFile(path, maxTelegramLoginManifestBytes, &manifest); err != nil {
return nil, fmt.Errorf("load telegram login signing manifest: %w", err)
}
if manifest.Version != 1 || len(manifest.Keys) == 0 || len(manifest.Keys) > 32 {
return nil, errors.New("telegram login signing manifest has invalid version or key count")
}
baseDir := filepath.Dir(path)
materials := make([]SigningKeyMaterial, 0, len(manifest.Keys))
for index, entry := range manifest.Keys {
keyPath := strings.TrimSpace(entry.PrivateKeyFile)
if !entry.Algorithm.Valid() || keyPath == "" {
return nil, fmt.Errorf("telegram login signing manifest key %d is invalid", index)
}
if !filepath.IsAbs(keyPath) {
keyPath = filepath.Join(baseDir, keyPath)
}
data, err := readBoundedFile(keyPath, maxTelegramLoginKeyBytes)
if err != nil {
return nil, fmt.Errorf("read telegram login signing key %d: %w", index, err)
}
var parsed jwk.Key
if len(bytes.TrimSpace(data)) > 0 && bytes.TrimSpace(data)[0] == '{' {
parsed, err = jwk.ParseKey(data)
} else {
parsed, err = jwk.ParseKey(data, jwk.WithPEM(true))
}
if err != nil {
return nil, fmt.Errorf("parse telegram login signing key %d: %w", index, err)
}
var raw any
if err := jwk.Export(parsed, &raw); err != nil {
return nil, fmt.Errorf("export telegram login signing key %d: %w", index, err)
}
var publishUntil time.Time
if entry.PublishUntil != "" {
publishUntil, err = time.Parse(time.RFC3339, entry.PublishUntil)
if err != nil {
return nil, fmt.Errorf("parse telegram login signing key %d publish_until: %w", index, err)
}
}
if entry.Active && !publishUntil.IsZero() {
return nil, fmt.Errorf("active telegram login signing key %d must not set publish_until", index)
}
if !entry.Active && publishUntil.IsZero() {
return nil, fmt.Errorf("retiring telegram login signing key %d requires publish_until", index)
}
materials = append(materials, SigningKeyMaterial{
Algorithm: entry.Algorithm, KeyID: entry.KeyID, PrivateKey: raw,
Active: entry.Active, PublishUntil: publishUntil,
})
}
return NewSigningKeyRing(materials, now)
}
type codeKeyManifest struct {
Version int `json:"version"`
Active string `json:"active"`
Keys map[string]string `json:"keys"`
}
func LoadCodeSealer(path string) (*CodeSealer, error) {
var manifest codeKeyManifest
if err := readStrictJSONFile(path, maxTelegramLoginManifestBytes, &manifest); err != nil {
return nil, fmt.Errorf("load telegram login code-key manifest: %w", err)
}
if manifest.Version != 1 || manifest.Active == "" || len(manifest.Keys) == 0 || len(manifest.Keys) > 16 {
return nil, errors.New("telegram login code-key manifest has invalid version or key count")
}
keys := make(map[string][]byte, len(manifest.Keys))
for keyID, encoded := range manifest.Keys {
if strings.TrimSpace(keyID) == "" || keyID != strings.TrimSpace(keyID) || len(keyID) > 128 {
return nil, errors.New("telegram login code-key manifest has invalid key id")
}
raw, err := decodeBase64Key(encoded)
if err != nil || len(raw) != 32 {
return nil, fmt.Errorf("telegram login code-key %q must be 32 base64-encoded bytes", keyID)
}
keys[keyID] = raw
}
return NewCodeSealer(manifest.Active, keys)
}
func LoadClientSecretPepper(path string) ([]byte, error) {
data, err := readBoundedFile(path, 4096)
if err != nil {
return nil, fmt.Errorf("read telegram login client-secret pepper: %w", err)
}
raw, err := decodeBase64Key(strings.TrimSpace(string(data)))
if err != nil || len(raw) != 32 {
return nil, errors.New("telegram login client-secret pepper must be 32 base64-encoded bytes")
}
return raw, nil
}
func decodeBase64Key(value string) ([]byte, error) {
value = strings.TrimSpace(value)
for _, encoding := range []*base64.Encoding{
base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding, base64.StdEncoding,
} {
if raw, err := encoding.DecodeString(value); err == nil {
return raw, nil
}
}
return nil, errors.New("invalid base64")
}
func readStrictJSONFile(path string, maxBytes int64, target any) error {
data, err := readBoundedFile(path, maxBytes)
if err != nil {
return err
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("multiple JSON values")
}
return err
}
return nil
}
func readBoundedFile(path string, maxBytes int64) ([]byte, error) {
if strings.TrimSpace(path) == "" || maxBytes <= 0 {
return nil, errors.New("invalid file path or size bound")
}
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() { _ = file.Close() }()
info, err := file.Stat()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Size() > maxBytes {
return nil, errors.New("file is not regular or exceeds size bound")
}
return io.ReadAll(io.LimitReader(file, maxBytes+1))
}

View file

@ -0,0 +1,86 @@
package telegramlogin
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"os"
"path/filepath"
"testing"
"time"
)
func TestLoadSigningKeyRingAndSymmetricKeyFiles(t *testing.T) {
dir := t.TempDir()
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(rsaKey)})
if err := os.WriteFile(filepath.Join(dir, "rsa.pem"), pemBytes, 0o600); err != nil {
t.Fatal(err)
}
manifest := map[string]any{
"version": 1,
"keys": []map[string]any{{
"algorithm": "RS256", "kid": "rsa-test", "private_key_file": "rsa.pem", "active": true,
}},
}
manifestBytes, _ := json.Marshal(manifest)
manifestPath := filepath.Join(dir, "signing.json")
if err := os.WriteFile(manifestPath, manifestBytes, 0o600); err != nil {
t.Fatal(err)
}
ring, err := LoadSigningKeyRing(manifestPath, nil)
if err != nil {
t.Fatal(err)
}
if got := ring.SupportedAlgorithms(); len(got) != 1 || got[0] != "RS256" {
t.Fatalf("algorithms=%#v", got)
}
codeKey := make([]byte, 32)
pepper := make([]byte, 32)
_, _ = rand.Read(codeKey)
_, _ = rand.Read(pepper)
codeManifest, _ := json.Marshal(map[string]any{
"version": 1, "active": "2026-07", "keys": map[string]string{"2026-07": base64.RawURLEncoding.EncodeToString(codeKey)},
})
codePath := filepath.Join(dir, "code-keys.json")
pepperPath := filepath.Join(dir, "pepper")
if err := os.WriteFile(codePath, codeManifest, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(pepperPath, []byte(base64.RawURLEncoding.EncodeToString(pepper)), 0o600); err != nil {
t.Fatal(err)
}
sealer, err := LoadCodeSealer(codePath)
if err != nil {
t.Fatal(err)
}
sealed, nonce, kid, err := sealer.Seal("code", []byte("aad"))
if err != nil {
t.Fatal(err)
}
if opened, err := sealer.Open(sealed, nonce, kid, []byte("aad")); err != nil || opened != "code" {
t.Fatalf("open=%q err=%v", opened, err)
}
loadedPepper, err := LoadClientSecretPepper(pepperPath)
if err != nil || string(loadedPepper) != string(pepper) {
t.Fatalf("pepper len=%d err=%v", len(loadedPepper), err)
}
}
func TestLoadSigningKeyRingRejectsUnknownManifestFieldAndUnboundedRetiringKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.json")
if err := os.WriteFile(path, []byte(`{"version":1,"keys":[],"unknown":true}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadSigningKeyRing(path, func() time.Time { return time.Now() }); err == nil {
t.Fatal("unknown manifest field unexpectedly accepted")
}
}

View file

@ -0,0 +1,80 @@
package telegramlogin
import (
"net/url"
"regexp"
"strings"
"unicode"
"telesrv/internal/domain"
)
var nativeApplicationIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{2,254}$`)
func normalizeNativeApplicationID(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if !nativeApplicationIDPattern.MatchString(raw) || !strings.Contains(raw, ".") || strings.Contains(raw, "..") {
return "", domain.ErrTelegramLoginClientInvalid
}
return raw, nil
}
func normalizeNativeVerificationID(platform domain.TelegramLoginNativePlatform, raw string) (string, error) {
raw = strings.ToUpper(strings.TrimSpace(raw))
switch platform {
case domain.TelegramLoginNativeIOS:
if len(raw) != 10 || strings.IndexFunc(raw, func(r rune) bool {
return !((r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9'))
}) >= 0 {
return "", domain.ErrTelegramLoginClientInvalid
}
case domain.TelegramLoginNativeAndroid:
raw = strings.ReplaceAll(raw, ":", "")
if len(raw) != 64 || strings.IndexFunc(raw, func(r rune) bool {
return !((r >= 'A' && r <= 'F') || (r >= '0' && r <= '9'))
}) >= 0 {
return "", domain.ErrTelegramLoginClientInvalid
}
default:
return "", domain.ErrTelegramLoginClientInvalid
}
return raw, nil
}
// NormalizeNativeCallbackURI accepts the exact HTTPS universal/app link or a
// non-web custom scheme registered for a native application. Query and
// fragment components are forbidden because OAuth response fields are
// appended by the provider and must not collide with application input.
func NormalizeNativeCallbackURI(raw string, allowLoopbackHTTP bool) (string, error) {
if raw == "" || len(raw) > maxTelegramLoginURLLength || raw != strings.TrimSpace(raw) || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
return "", domain.ErrTelegramLoginURLInvalid
}
u, err := url.Parse(raw)
if err != nil || !u.IsAbs() || u.Opaque != "" || u.User != nil || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" {
return "", domain.ErrTelegramLoginURLInvalid
}
if strings.EqualFold(u.Scheme, "http") || strings.EqualFold(u.Scheme, "https") {
normalized, _, err := NormalizeRedirectURI(raw, allowLoopbackHTTP)
return normalized, err
}
scheme := strings.ToLower(u.Scheme)
if !validAppScheme(scheme) || scheme == "tg" || scheme == "javascript" || scheme == "data" || scheme == "file" || u.Port() != "" {
return "", domain.ErrTelegramLoginURLInvalid
}
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
if host == "" || strings.IndexFunc(host, func(r rune) bool {
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '.')
}) >= 0 {
return "", domain.ErrTelegramLoginURLInvalid
}
u.Scheme, u.Host = scheme, host
return u.String(), nil
}
func normalizeNativeDisplayName(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" || len(raw) > 128 || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
return "", domain.ErrTelegramLoginClientInvalid
}
return raw, nil
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,404 @@
package telegramlogin
import (
"context"
"errors"
"net/url"
"sync"
"sync/atomic"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestServiceClientCreationAndSecretRotationAreSingleWinner(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_780_000_000, 0).UTC()
service, loginStore := newTelegramLoginTestService(t, &now)
const contenders = 24
start := make(chan struct{})
var wg sync.WaitGroup
var created atomic.Int32
var conflicts atomic.Int32
for i := 0; i < contenders; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, err := service.CreateClient(ctx, 9010, domain.TelegramLoginSigningRS256)
switch {
case err == nil:
created.Add(1)
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
conflicts.Add(1)
default:
t.Errorf("CreateClient: %v", err)
}
}()
}
close(start)
wg.Wait()
if created.Load() != 1 || conflicts.Load() != contenders-1 {
t.Fatalf("create winners=%d conflicts=%d", created.Load(), conflicts.Load())
}
client, found, err := loginStore.GetTelegramLoginClientByBot(ctx, 9010)
if err != nil || !found {
t.Fatalf("GetTelegramLoginClientByBot: found=%v err=%v", found, err)
}
start = make(chan struct{})
created.Store(0)
conflicts.Store(0)
for i := 0; i < contenders; i++ {
wg.Add(1)
go func(seed byte) {
defer wg.Done()
<-start
hash := make([]byte, 32)
hash[0] = seed
_, err := loginStore.RotateTelegramLoginClientSecret(ctx, client.BotUserID, client.SecretVersion, hash, now.Add(time.Second))
switch {
case err == nil:
created.Add(1)
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
conflicts.Add(1)
default:
t.Errorf("RotateTelegramLoginClientSecret: %v", err)
}
}(byte(i + 1))
}
close(start)
wg.Wait()
if created.Load() != 1 || conflicts.Load() != contenders-1 {
t.Fatalf("rotate winners=%d conflicts=%d", created.Load(), conflicts.Load())
}
}
func newTelegramLoginTestService(t *testing.T, now *time.Time) (*Service, *memory.TelegramLoginStore) {
return newTelegramLoginTestServiceWithAlgorithms(t, now, nil)
}
func newTelegramLoginTestServiceWithAlgorithms(t *testing.T, now *time.Time, algorithms []domain.TelegramLoginSigningAlgorithm) (*Service, *memory.TelegramLoginStore) {
t.Helper()
key := make([]byte, 32)
key[0] = 7
sealer, err := NewCodeSealer("test", map[string][]byte{"test": key})
if err != nil {
t.Fatal(err)
}
loginStore := memory.NewTelegramLoginStore(nil)
pepper := make([]byte, 32)
pepper[0] = 9
service, err := NewService(loginStore, sealer, Config{
Issuer: "https://oauth.telesrv.test", AppScheme: "telesrv",
AllowLoopbackHTTP: true, ClientSecretPepper: pepper,
SupportedSigningAlgorithms: algorithms,
Now: func() time.Time { return *now },
})
if err != nil {
t.Fatal(err)
}
return service, loginStore
}
func TestServiceAcceptsOfficialClientCanonicalOAuthDeepLinks(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_780_000_000, 0).UTC()
service, _ := newTelegramLoginTestService(t, &now)
credentials, err := service.CreateClient(ctx, 9030, domain.TelegramLoginSigningRS256)
if err != nil {
t.Fatal(err)
}
const redirectURI = "https://rp.example/callback"
if _, err := service.AddAllowedURL(ctx, 9030, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil {
t.Fatal(err)
}
challenge, err := PKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
if err != nil {
t.Fatal(err)
}
created, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
ClientID: credentials.Client.ClientID, RedirectURI: redirectURI, ResponseType: "code",
Scope: "openid", CodeChallenge: challenge, CodeChallengeMethod: "S256",
})
if err != nil {
t.Fatal(err)
}
parsed, err := url.Parse(created.DeepLink)
if err != nil {
t.Fatal(err)
}
token := parsed.Query().Get("token")
valid := []string{
created.DeepLink,
"tg://oauth?token=" + url.QueryEscape(token),
"tg://resolve?domain=oauth&startapp=" + url.QueryEscape(token),
"https://t.me/oauth?startapp=" + url.QueryEscape(token),
}
for _, deepLink := range valid {
request, err := service.RequestByDeepLink(ctx, deepLink)
if err != nil || request.ID != created.Request.ID {
t.Fatalf("RequestByDeepLink(%q) request=%#v err=%v", deepLink, request, err)
}
}
invalid := []string{
"telegram://oauth?token=" + url.QueryEscape(token),
"tg://oauth/path?token=" + url.QueryEscape(token),
"tg://oauth?token=" + url.QueryEscape(token) + "&token=other",
"tg://resolve?domain=oauth&domain=other&startapp=" + url.QueryEscape(token),
"tg://resolve?domain=oauth&startapp=" + url.QueryEscape(token) + "&startapp=other",
"tg://oauth?token=" + url.QueryEscape(token) + "#fragment",
}
for _, deepLink := range invalid {
if _, err := service.RequestByDeepLink(ctx, deepLink); !errors.Is(err, domain.ErrTelegramLoginURLInvalid) {
t.Fatalf("RequestByDeepLink(%q) error=%v, want URL invalid", deepLink, err)
}
}
}
func TestServiceRejectsSigningAlgorithmsWithoutActiveKeys(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_780_000_000, 0).UTC()
service, loginStore := newTelegramLoginTestServiceWithAlgorithms(t, &now, []domain.TelegramLoginSigningAlgorithm{
domain.TelegramLoginSigningES256,
})
if _, err := service.CreateClient(ctx, 9020, domain.TelegramLoginSigningRS256); !errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
t.Fatalf("CreateClient unsupported algorithm error=%v", err)
}
credentials, created, err := service.EnsureClient(ctx, 9020)
if err != nil || !created || credentials.Client.SigningAlgorithm != domain.TelegramLoginSigningES256 {
t.Fatalf("EnsureClient credentials=%#v created=%v err=%v", credentials, created, err)
}
if _, err := service.SetClientSigningAlgorithm(ctx, 9020, domain.TelegramLoginSigningEdDSA); !errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
t.Fatalf("SetClientSigningAlgorithm unsupported error=%v", err)
}
// Simulate configuration drift from a previous deployment. Authorization
// must fail before a request is persisted instead of failing after consent.
if _, err := loginStore.SetTelegramLoginClientSigningAlgorithm(ctx, 9020, domain.TelegramLoginSigningRS256, now.Add(time.Second)); err != nil {
t.Fatal(err)
}
if err := service.SetClientEnabled(ctx, 9020, false); err != nil {
t.Fatal(err)
}
if err := service.SetClientEnabled(ctx, 9020, true); !errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
t.Fatalf("SetClientEnabled unavailable algorithm error=%v", err)
}
if _, err := service.AddAllowedURL(ctx, 9020, domain.TelegramLoginAllowedWebOrigin, "https://rp.example"); err != nil {
t.Fatal(err)
}
if _, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
ClientID: credentials.Client.ClientID, RedirectURI: "https://rp.example/", ResponseType: "post_message",
Scope: "openid profile",
}); !errors.Is(err, domain.ErrTelegramLoginClientDisabled) {
t.Fatalf("CreateAuthorization unavailable algorithm error=%v", err)
}
}
func TestServiceAuthorizationCodeFlowAndRevocation(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_780_000_000, 0).UTC()
service, _ := newTelegramLoginTestService(t, &now)
credentials, err := service.CreateClient(ctx, 9001, domain.TelegramLoginSigningRS256)
if err != nil {
t.Fatalf("CreateClient: %v", err)
}
const redirectURI = "https://rp.example/callback"
if _, err := service.AddAllowedURL(ctx, 9001, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil {
t.Fatalf("AddAllowedURL redirect: %v", err)
}
if _, err := service.AddAllowedURL(ctx, 9001, domain.TelegramLoginAllowedWebOrigin, "https://rp.example"); err != nil {
t.Fatalf("AddAllowedURL origin: %v", err)
}
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge, _ := PKCEChallenge(verifier)
created, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
ClientID: credentials.Client.ClientID, RedirectURI: redirectURI,
ResponseType: "code", Scope: "openid profile phone telegram:bot_access",
State: "opaque-state", Nonce: "nonce", CodeChallenge: challenge, CodeChallengeMethod: "S256",
Browser: "Firefox", Platform: "Windows", IP: "192.0.2.10", Region: "Test Region",
IncludeMatchCodes: true, MatchCodesFirst: true,
})
if err != nil {
t.Fatalf("CreateAuthorization: %v", err)
}
if created.DeepLink == "" || created.Request.ID == 0 || len(created.Request.MatchCodes) != 5 {
t.Fatalf("created authorization = %#v", created)
}
if _, err := service.CheckMatchCode(ctx, created.DeepLink, created.Request.MatchCodes[0]); err == nil && created.Request.MatchCodes[0] != created.Request.MatchCode {
t.Fatal("wrong match code unexpectedly accepted")
}
if ok, err := service.CheckMatchCode(ctx, created.DeepLink, created.Request.MatchCode); err != nil || !ok {
t.Fatalf("CheckMatchCode correct = %v,%v", ok, err)
}
now = now.Add(time.Second)
identity := domain.TelegramLoginIdentitySnapshot{
UserID: 42, Name: "Alice Example", GivenName: "Alice", FamilyName: "Example",
PreferredUsername: "alice", Picture: "https://oauth.telesrv.test/userpic/42",
}
approved, web, err := service.Approve(ctx, created.DeepLink, identity, true, false, created.Request.MatchCode)
if err != nil {
t.Fatalf("Approve: %v", err)
}
if approved.Status != domain.TelegramLoginRequestApproved || web.PhoneShared || !web.BotAccessGranted {
t.Fatalf("approved=%#v web=%#v", approved, web)
}
if approved.ProfileName != "Alice Example" || approved.PhoneNumber != "" {
t.Fatalf("identity snapshot = %#v", approved)
}
now = now.Add(time.Second)
finalized, err := service.FinalizeByBrowserToken(ctx, created.BrowserToken)
if err != nil {
t.Fatalf("FinalizeByBrowserToken: %v", err)
}
redirect, err := url.Parse(finalized.RedirectURL)
if err != nil || redirect.Query().Get("code") != finalized.Code || redirect.Query().Get("state") != "opaque-state" {
t.Fatalf("final redirect = %q,%v", finalized.RedirectURL, err)
}
if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
Code: finalized.Code, ClientID: credentials.Client.ClientID, ClientSecret: credentials.Secret,
RedirectURI: redirectURI, CodeVerifier: verifier + "x",
}); !errors.Is(err, domain.ErrTelegramLoginCodeInvalid) {
t.Fatalf("exchange wrong verifier error = %v, want code invalid", err)
}
now = now.Add(time.Second)
exchanged, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
Code: finalized.Code, ClientID: credentials.Client.ClientID, ClientSecret: credentials.Secret,
RedirectURI: redirectURI, CodeVerifier: verifier,
})
if err != nil {
t.Fatalf("ExchangeAuthorizationCode: %v", err)
}
if exchanged.Request.AuthorizedUserID != 42 || exchanged.WebAuthorization.Hash != web.Hash {
t.Fatalf("exchanged = %#v", exchanged)
}
if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
Code: finalized.Code, ClientID: credentials.Client.ClientID, ClientSecret: credentials.Secret,
RedirectURI: redirectURI, CodeVerifier: verifier,
}); !errors.Is(err, domain.ErrTelegramLoginCodeConsumed) {
t.Fatalf("replay exchange error = %v, want consumed", err)
}
if err := service.RevokeWebAuthorization(ctx, 42, web.Hash); err != nil {
t.Fatalf("RevokeWebAuthorization: %v", err)
}
if list, err := service.ListWebAuthorizations(ctx, 42); err != nil || len(list) != 0 {
t.Fatalf("ListWebAuthorizations after revoke = %#v,%v", list, err)
}
if err := service.RevokeWebAuthorization(ctx, 42, web.Hash); !errors.Is(err, domain.ErrTelegramLoginWebAuthHashInvalid) {
t.Fatalf("second revoke error = %v, want hash invalid", err)
}
}
func TestFinalizationRetryRechecksLiveAuthorization(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_780_000_000, 0).UTC()
service, _ := newTelegramLoginTestService(t, &now)
credentials, err := service.CreateClient(ctx, 9010, domain.TelegramLoginSigningRS256)
if err != nil {
t.Fatal(err)
}
const redirectURI = "https://retry.example/callback"
const origin = "https://retry.example"
if _, err := service.AddAllowedURL(ctx, 9010, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil {
t.Fatal(err)
}
if _, err := service.AddAllowedURL(ctx, 9010, domain.TelegramLoginAllowedWebOrigin, origin); err != nil {
t.Fatal(err)
}
challenge, err := PKCEChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
if err != nil {
t.Fatal(err)
}
codeRequest, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
ClientID: credentials.Client.ClientID, RedirectURI: redirectURI, ResponseType: "code",
Scope: "openid", CodeChallenge: challenge, CodeChallengeMethod: "S256",
})
if err != nil {
t.Fatal(err)
}
_, codeWeb, err := service.Approve(ctx, codeRequest.DeepLink, domain.TelegramLoginIdentitySnapshot{UserID: 51}, false, false, "")
if err != nil {
t.Fatal(err)
}
if _, err := service.FinalizeByBrowserToken(ctx, codeRequest.BrowserToken); err != nil {
t.Fatal(err)
}
if err := service.RevokeWebAuthorization(ctx, 51, codeWeb.Hash); err != nil {
t.Fatal(err)
}
if _, err := service.FinalizeByBrowserToken(ctx, codeRequest.BrowserToken); !errors.Is(err, domain.ErrTelegramLoginRequestConflict) {
t.Fatalf("authorization-code retry after revoke error = %v, want conflict", err)
}
miniRequest, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
ClientID: credentials.Client.ClientID, RedirectURI: origin + "/", ResponseType: "post_message", Scope: "openid",
Origin: origin, InAppOrigin: origin, Source: domain.TelegramLoginRequestMiniApp,
})
if err != nil {
t.Fatal(err)
}
_, miniWeb, err := service.Approve(ctx, miniRequest.DeepLink, domain.TelegramLoginIdentitySnapshot{UserID: 52}, false, false, "")
if err != nil {
t.Fatal(err)
}
if _, err := service.FinalizeInAppRedirectByDeepLink(ctx, miniRequest.DeepLink); err != nil {
t.Fatal(err)
}
if err := service.RevokeWebAuthorization(ctx, 52, miniWeb.Hash); err != nil {
t.Fatal(err)
}
if _, err := service.FinalizeInAppRedirectByDeepLink(ctx, miniRequest.DeepLink); !errors.Is(err, domain.ErrTelegramLoginRequestConflict) {
t.Fatalf("Mini App token retry after revoke error = %v, want conflict", err)
}
}
func TestServiceSecretRotationClosesExchangeTOCTOU(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_780_000_000, 0).UTC()
service, _ := newTelegramLoginTestService(t, &now)
oldCredentials, err := service.CreateClient(ctx, 9002, domain.TelegramLoginSigningRS256)
if err != nil {
t.Fatal(err)
}
const redirectURI = "https://rotate.example/callback"
if _, err := service.AddAllowedURL(ctx, 9002, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil {
t.Fatal(err)
}
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge, _ := PKCEChallenge(verifier)
created, err := service.CreateAuthorization(ctx, CreateAuthorizationParams{
ClientID: oldCredentials.Client.ClientID, RedirectURI: redirectURI, ResponseType: "code",
Scope: "openid", CodeChallenge: challenge, CodeChallengeMethod: "S256",
})
if err != nil {
t.Fatal(err)
}
now = now.Add(time.Second)
if _, _, err := service.Approve(ctx, created.DeepLink, domain.TelegramLoginIdentitySnapshot{UserID: 43}, false, false, ""); err != nil {
t.Fatal(err)
}
now = now.Add(time.Second)
finalized, err := service.FinalizeByBrowserToken(ctx, created.BrowserToken)
if err != nil {
t.Fatal(err)
}
newCredentials, err := service.RotateClientSecret(ctx, 9002)
if err != nil {
t.Fatal(err)
}
if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
Code: finalized.Code, ClientID: oldCredentials.Client.ClientID, ClientSecret: oldCredentials.Secret,
RedirectURI: redirectURI, CodeVerifier: verifier,
}); !errors.Is(err, domain.ErrTelegramLoginSecretInvalid) {
t.Fatalf("old secret exchange error = %v", err)
}
if _, err := service.ExchangeAuthorizationCode(ctx, ExchangeAuthorizationCodeParams{
Code: finalized.Code, ClientID: newCredentials.Client.ClientID, ClientSecret: newCredentials.Secret,
RedirectURI: redirectURI, CodeVerifier: verifier,
}); err != nil {
t.Fatalf("new secret exchange: %v", err)
}
}

View file

@ -0,0 +1,141 @@
package telegramlogin
import (
"net"
"net/url"
"strconv"
"strings"
"unicode"
"golang.org/x/net/idna"
"telesrv/internal/domain"
)
const maxTelegramLoginURLLength = 4096
func NormalizeRedirectURI(raw string, allowLoopbackHTTP bool) (normalized, domainName string, err error) {
u, err := parseWebURL(raw, allowLoopbackHTTP)
if err != nil {
return "", "", err
}
if u.Fragment != "" {
return "", "", domain.ErrTelegramLoginURLInvalid
}
query := u.Query()
for _, reserved := range []string{"code", "state", "error", "error_description"} {
if _, exists := query[reserved]; exists {
return "", "", domain.ErrTelegramLoginURLInvalid
}
}
if u.Path == "" {
u.Path = "/"
}
return u.String(), u.Hostname(), nil
}
func NormalizeWebOrigin(raw string, allowLoopbackHTTP bool) (string, error) {
u, err := parseWebURL(raw, allowLoopbackHTTP)
if err != nil {
return "", err
}
if (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" {
return "", domain.ErrTelegramLoginURLInvalid
}
u.Path = ""
return u.String(), nil
}
func parseWebURL(raw string, allowLoopbackHTTP bool) (*url.URL, error) {
if raw == "" || len(raw) > maxTelegramLoginURLLength || raw != strings.TrimSpace(raw) || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
return nil, domain.ErrTelegramLoginURLInvalid
}
u, err := url.Parse(raw)
if err != nil || !u.IsAbs() || u.Opaque != "" || u.User != nil || u.Host == "" {
return nil, domain.ErrTelegramLoginURLInvalid
}
u.Scheme = strings.ToLower(u.Scheme)
host := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")
if host == "" {
return nil, domain.ErrTelegramLoginURLInvalid
}
if ip := net.ParseIP(host); ip == nil {
host, err = idna.Lookup.ToASCII(host)
if err != nil || host == "" {
return nil, domain.ErrTelegramLoginURLInvalid
}
}
port := u.Port()
if port != "" {
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return nil, domain.ErrTelegramLoginURLInvalid
}
}
switch u.Scheme {
case "https":
if port == "443" {
port = ""
}
case "http":
if !allowLoopbackHTTP || !isLoopbackHost(host) {
return nil, domain.ErrTelegramLoginURLInvalid
}
if port == "80" {
port = ""
}
default:
return nil, domain.ErrTelegramLoginURLInvalid
}
if port == "" {
if ip := net.ParseIP(host); ip != nil && strings.Contains(host, ":") {
u.Host = "[" + host + "]"
} else {
u.Host = host
}
} else {
u.Host = net.JoinHostPort(host, port)
}
return u, nil
}
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func AppendAuthorizationResult(redirectURI, code, state string) (string, error) {
u, err := url.Parse(redirectURI)
if err != nil || !u.IsAbs() || code == "" {
return "", domain.ErrTelegramLoginURLInvalid
}
q := u.Query()
q.Set("code", code)
if state != "" {
q.Set("state", state)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
func AppendAuthorizationError(redirectURI, errorCode, state string) (string, error) {
switch errorCode {
case "access_denied", "temporarily_unavailable", "server_error", "invalid_request", "invalid_scope", "unsupported_response_type":
default:
return "", domain.ErrTelegramLoginRequestInvalid
}
u, err := url.Parse(redirectURI)
if err != nil || !u.IsAbs() {
return "", domain.ErrTelegramLoginURLInvalid
}
q := u.Query()
q.Set("error", errorCode)
if state != "" {
q.Set("state", state)
}
u.RawQuery = q.Encode()
return u.String(), nil
}

View file

@ -0,0 +1,115 @@
package telegramlogin
import (
"errors"
"net/url"
"testing"
"telesrv/internal/domain"
)
func TestNormalizeRedirectURIIsExactAndRejectsOpenRedirectShapes(t *testing.T) {
tests := []struct {
name string
raw string
allowHTTP bool
want string
valid bool
}{
{name: "https canonical", raw: "https://EXAMPLE.com:443/callback?tenant=one", want: "https://example.com/callback?tenant=one", valid: true},
{name: "idna", raw: "https://例子.测试/callback", want: "https://xn--fsqu00a.xn--0zwm56d/callback", valid: true},
{name: "loopback dev", raw: "http://127.0.0.1:8080/callback", allowHTTP: true, want: "http://127.0.0.1:8080/callback", valid: true},
{name: "http production", raw: "http://example.com/callback"},
{name: "userinfo", raw: "https://user@example.com/callback"},
{name: "fragment", raw: "https://example.com/callback#token"},
{name: "reserved code", raw: "https://example.com/callback?code=attacker"},
{name: "leading whitespace", raw: " https://example.com/callback"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, _, err := NormalizeRedirectURI(test.raw, test.allowHTTP)
if test.valid {
if err != nil || got != test.want {
t.Fatalf("NormalizeRedirectURI() = %q,%v, want %q,nil", got, err, test.want)
}
} else if !errors.Is(err, domain.ErrTelegramLoginURLInvalid) {
t.Fatalf("NormalizeRedirectURI() error = %v, want URL invalid", err)
}
})
}
}
func TestAppendAuthorizationErrorPreservesState(t *testing.T) {
got, err := AppendAuthorizationError("https://example.com/callback?tenant=one", "access_denied", "opaque")
if err != nil {
t.Fatal(err)
}
u, _ := url.Parse(got)
if u.Query().Get("tenant") != "one" || u.Query().Get("error") != "access_denied" || u.Query().Get("state") != "opaque" {
t.Fatalf("error redirect = %q", got)
}
if _, err := AppendAuthorizationError("https://example.com/callback", "invalid_client", ""); err == nil {
t.Fatal("unsafe authorization error unexpectedly accepted")
}
}
func TestNormalizeWebOriginRejectsPathAndQuery(t *testing.T) {
if got, err := NormalizeWebOrigin("https://Example.com/", false); err != nil || got != "https://example.com" {
t.Fatalf("NormalizeWebOrigin = %q,%v", got, err)
}
for _, raw := range []string{"https://example.com/path", "https://example.com/?x=1", "https://example.com/#x"} {
if _, err := NormalizeWebOrigin(raw, false); !errors.Is(err, domain.ErrTelegramLoginURLInvalid) {
t.Fatalf("NormalizeWebOrigin(%q) error = %v, want URL invalid", raw, err)
}
}
}
func TestNormalizeLoopbackIPv6PreservesURLBrackets(t *testing.T) {
origin, err := NormalizeWebOrigin("http://[0:0:0:0:0:0:0:1]:80/", true)
if err != nil {
t.Fatal(err)
}
if origin != "http://[0:0:0:0:0:0:0:1]" {
t.Fatalf("origin=%q", origin)
}
redirect, domainName, err := NormalizeRedirectURI("http://[::1]/callback", true)
if err != nil {
t.Fatal(err)
}
if redirect != "http://[::1]/callback" || domainName != "::1" {
t.Fatalf("redirect=%q domain=%q", redirect, domainName)
}
}
func TestPKCERFC7636Vector(t *testing.T) {
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
const want = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
got, err := PKCEChallenge(verifier)
if err != nil || got != want {
t.Fatalf("PKCEChallenge = %q,%v, want %q,nil", got, err, want)
}
}
func TestCodeSealerUsesAADAndRetiringKeys(t *testing.T) {
oldKey := make([]byte, 32)
newKey := make([]byte, 32)
oldKey[0], newKey[0] = 1, 2
old, err := NewCodeSealer("old", map[string][]byte{"old": oldKey})
if err != nil {
t.Fatal(err)
}
sealed, nonce, keyID, err := old.Seal("authorization-code", []byte("request-1"))
if err != nil {
t.Fatal(err)
}
rotated, err := NewCodeSealer("new", map[string][]byte{"old": oldKey, "new": newKey})
if err != nil {
t.Fatal(err)
}
if got, err := rotated.Open(sealed, nonce, keyID, []byte("request-1")); err != nil || got != "authorization-code" {
t.Fatalf("Open after rotation = %q,%v", got, err)
}
if _, err := rotated.Open(sealed, nonce, keyID, []byte("request-2")); !errors.Is(err, domain.ErrTelegramLoginCodeInvalid) {
t.Fatalf("Open with wrong AAD error = %v", err)
}
}

View file

@ -347,6 +347,9 @@ func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, e
if button.CopyTextSet {
constructors++
}
if button.LoginURLSet {
constructors++
}
if constructors != 1 {
return domain.MarkupButton{}, errors.New("BUTTON_INVALID")
}
@ -357,6 +360,13 @@ func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, e
if button.URLSet {
return domain.MarkupButton{Type: domain.MarkupButtonURL, Text: button.Text, URL: button.URL, Style: style, IconCustomEmojiID: icon}, nil
}
if button.LoginURLSet {
return domain.MarkupButton{
Type: domain.MarkupButtonLoginURL, Text: button.Text, URL: button.LoginURL,
ForwardText: button.LoginForwardText, LoginBotUsername: button.LoginBotUsername,
RequestWriteAccess: button.LoginRequestWriteAccess, Style: style, IconCustomEmojiID: icon,
}, nil
}
if button.CallbackDataSet {
if button.CallbackData == "" || len([]byte(button.CallbackData)) > domain.MaxCallbackDataLen {
return domain.MarkupButton{}, errors.New("BUTTON_DATA_INVALID")
@ -689,23 +699,28 @@ type apiForceReply struct {
}
type apiInlineKeyboardButton struct {
Text string
URL string
URLSet bool
CallbackData string
CallbackDataSet bool
Style string
IconCustomEmojiID string
IconCustomEmojiIDSet bool
Unsupported bool
WebAppURL string
WebAppSet bool
SwitchInlineQuery string
SwitchInlineSet bool
SwitchInlineSamePeer bool
SwitchInlinePeerTypes []string
CopyText string
CopyTextSet bool
Text string
URL string
URLSet bool
CallbackData string
CallbackDataSet bool
Style string
IconCustomEmojiID string
IconCustomEmojiIDSet bool
Unsupported bool
WebAppURL string
WebAppSet bool
SwitchInlineQuery string
SwitchInlineSet bool
SwitchInlineSamePeer bool
SwitchInlinePeerTypes []string
CopyText string
CopyTextSet bool
LoginURL string
LoginForwardText string
LoginBotUsername string
LoginRequestWriteAccess bool
LoginURLSet bool
}
func (b *apiInlineKeyboardButton) UnmarshalJSON(data []byte) error {
@ -739,6 +754,20 @@ func (b *apiInlineKeyboardButton) UnmarshalJSON(data []byte) error {
}
b.WebAppURL = app.URL
}
if raw, ok := fields["login_url"]; ok {
b.LoginURLSet = true
var login struct {
URL string `json:"url"`
ForwardText string `json:"forward_text"`
BotUsername string `json:"bot_username"`
RequestWriteAccess bool `json:"request_write_access"`
}
if json.Unmarshal(raw, &login) != nil {
return errors.New("invalid login url")
}
b.LoginURL, b.LoginForwardText = login.URL, login.ForwardText
b.LoginBotUsername, b.LoginRequestWriteAccess = login.BotUsername, login.RequestWriteAccess
}
switchActions := 0
if raw, ok := fields["switch_inline_query"]; ok {
switchActions++
@ -807,7 +836,7 @@ func (b *apiInlineKeyboardButton) UnmarshalJSON(data []byte) error {
}
for key := range fields {
switch key {
case "text", "url", "callback_data", "web_app", "switch_inline_query", "switch_inline_query_current_chat", "switch_inline_query_chosen_chat", "copy_text", "style", "icon_custom_emoji_id":
case "text", "url", "callback_data", "web_app", "login_url", "switch_inline_query", "switch_inline_query_current_chat", "switch_inline_query_chosen_chat", "copy_text", "style", "icon_custom_emoji_id":
default:
b.Unsupported = true
}

View file

@ -509,6 +509,18 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any {
switch button.Type {
case domain.MarkupButtonURL:
item["url"] = button.URL
case domain.MarkupButtonLoginURL:
login := map[string]any{"url": button.URL}
if button.ForwardText != "" {
login["forward_text"] = button.ForwardText
}
if button.LoginBotUsername != "" {
login["bot_username"] = button.LoginBotUsername
}
if button.RequestWriteAccess {
login["request_write_access"] = true
}
item["login_url"] = login
case domain.MarkupButtonCallback:
item["callback_data"] = string(button.Data)
case domain.MarkupButtonWebView:

View file

@ -886,6 +886,19 @@ func TestReplyMarkupFromAPIReplyKeyboardVariants(t *testing.T) {
if err != nil || webApp == nil || webApp.Inline[0][0].Type != domain.MarkupButtonWebView {
t.Fatalf("web_app inline button = %#v err=%v", webApp, err)
}
login, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"Log in","login_url":{"url":"https://example.com/login","forward_text":"Open","bot_username":"auth_bot","request_write_access":true}}]]}`))
if err != nil || login == nil {
t.Fatalf("login_url inline button = %#v err=%v", login, err)
}
loginButton := login.Inline[0][0]
if loginButton.Type != domain.MarkupButtonLoginURL || loginButton.URL != "https://example.com/login" || loginButton.ForwardText != "Open" ||
loginButton.LoginBotUsername != "auth_bot" || !loginButton.RequestWriteAccess {
t.Fatalf("login_url button = %#v", loginButton)
}
projectedLogin := apiReplyMarkup(login)["inline_keyboard"].([][]map[string]any)[0][0]["login_url"].(map[string]any)
if projectedLogin["url"] != "https://example.com/login" || projectedLogin["bot_username"] != "auth_bot" || projectedLogin["request_write_access"] != true {
t.Fatalf("projected login_url = %#v", projectedLogin)
}
}
func TestReplyMarkupFromAPIPreservesSemanticButtonStyles(t *testing.T) {

View file

@ -4,6 +4,8 @@ package config
import (
"bufio"
"fmt"
"net"
"net/netip"
"net/url"
"os"
"strconv"
@ -88,6 +90,22 @@ type Config struct {
// PublicLinkWebAddr 是公开链接落地页监听地址;为空关闭。
// 生产应只监听 loopback并由 nginx 将 /<username>、/addstickers/、/addemoji/ 与 /addlist/ 反代到该地址。
PublicLinkWebAddr string
// TelegramLoginEnabled mounts the self-hosted Telegram Login/OIDC provider
// on PublicLinkWebAddr. Secrets are file-backed so they are not exposed in
// process listings or accidentally copied into tracked .env templates.
TelegramLoginEnabled bool
TelegramLoginIssuer string
TelegramLoginAllowLoopbackHTTP bool
TelegramLoginSigningKeysFile string
TelegramLoginCodeKeysFile string
TelegramLoginSecretPepperFile string
TelegramLoginRequestTTL time.Duration
TelegramLoginCodeTTL time.Duration
TelegramLoginIDTokenTTL time.Duration
TelegramLoginTrustedProxyCIDRs []string
TelegramLoginRetention time.Duration
TelegramLoginSweepInterval time.Duration
TelegramLoginSweepBatch int
// Admin UI 独立进程配置项保留在统一配置中cmd/telesrv-admin 也按同名 env 读取。
AdminUIAddr string
AdminUIPassword string
@ -471,6 +489,19 @@ func Load() (Config, error) {
PublicWebBaseURL: publicWebBaseURL,
PublicAppName: publicAppName,
PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""),
TelegramLoginEnabled: envBoolOr("TELESRV_TELEGRAM_LOGIN_ENABLE", false),
TelegramLoginIssuer: strings.TrimSuffix(envOr("TELESRV_TELEGRAM_LOGIN_ISSUER", publicBaseURL), "/"),
TelegramLoginAllowLoopbackHTTP: envBoolOr("TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_HTTP", false),
TelegramLoginSigningKeysFile: envOr("TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE", "data/telegram-login/signing-keys.json"),
TelegramLoginCodeKeysFile: envOr("TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE", "data/telegram-login/code-keys.json"),
TelegramLoginSecretPepperFile: envOr("TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE", "data/telegram-login/client-secret-pepper"),
TelegramLoginRequestTTL: envDurationOr("TELESRV_TELEGRAM_LOGIN_REQUEST_TTL", 5*time.Minute),
TelegramLoginCodeTTL: envDurationOr("TELESRV_TELEGRAM_LOGIN_CODE_TTL", 2*time.Minute),
TelegramLoginIDTokenTTL: envDurationOr("TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL", time.Hour),
TelegramLoginTrustedProxyCIDRs: envListOr("TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS", nil),
TelegramLoginRetention: envDurationOr("TELESRV_TELEGRAM_LOGIN_RETENTION", 7*24*time.Hour),
TelegramLoginSweepInterval: envDurationOr("TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL", 5*time.Minute),
TelegramLoginSweepBatch: envIntOr("TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH", 500),
AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"),
AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""),
AdminUIToken: envOr("TELESRV_ADMIN_UI_TOKEN", ""),
@ -629,9 +660,56 @@ func Load() (Config, error) {
if err := validateStarGiftConfig(cfg); err != nil {
return Config{}, err
}
if err := validateTelegramLoginConfig(cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
func validateTelegramLoginConfig(cfg Config) error {
if !cfg.TelegramLoginEnabled {
return nil
}
if strings.TrimSpace(cfg.PublicLinkWebAddr) == "" {
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ENABLE requires TELESRV_PUBLIC_LINK_WEB_ADDR")
}
issuer, err := url.Parse(strings.TrimSpace(cfg.TelegramLoginIssuer))
if err != nil || issuer.User != nil || issuer.Host == "" || issuer.RawQuery != "" || issuer.Fragment != "" ||
(issuer.Path != "" && issuer.Path != "/") {
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER must be an absolute origin URL")
}
switch issuer.Scheme {
case "https":
case "http":
host := issuer.Hostname()
ip := net.ParseIP(host)
if !cfg.TelegramLoginAllowLoopbackHTTP || (host != "localhost" && (ip == nil || !ip.IsLoopback())) {
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER http is allowed only for explicit loopback development")
}
default:
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_ISSUER must use https")
}
if strings.TrimSpace(cfg.TelegramLoginSigningKeysFile) == "" || strings.TrimSpace(cfg.TelegramLoginCodeKeysFile) == "" || strings.TrimSpace(cfg.TelegramLoginSecretPepperFile) == "" {
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_* key and pepper files are required")
}
if cfg.TelegramLoginRequestTTL < time.Minute || cfg.TelegramLoginRequestTTL > 15*time.Minute ||
cfg.TelegramLoginCodeTTL < 30*time.Second || cfg.TelegramLoginCodeTTL > 10*time.Minute ||
cfg.TelegramLoginIDTokenTTL < time.Minute || cfg.TelegramLoginIDTokenTTL > 24*time.Hour {
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN TTL values are outside their bounded ranges")
}
if cfg.TelegramLoginRetention < time.Hour || cfg.TelegramLoginRetention > 90*24*time.Hour ||
cfg.TelegramLoginSweepInterval < 10*time.Second || cfg.TelegramLoginSweepInterval > time.Hour ||
cfg.TelegramLoginSweepBatch <= 0 || cfg.TelegramLoginSweepBatch > 1000 {
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN retention must be 1h..90d, sweep interval 10s..1h, and sweep batch 1..1000")
}
for _, raw := range cfg.TelegramLoginTrustedProxyCIDRs {
if _, err := netip.ParsePrefix(strings.TrimSpace(raw)); err != nil {
return fmt.Errorf("TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS contains invalid CIDR %q: %w", raw, err)
}
}
return nil
}
func validateStarGiftConfig(cfg Config) error {
if cfg.StarGiftSweepInterval <= 0 || cfg.StarGiftSweepBatch <= 0 || cfg.StarGiftSweepBatch > 10000 {
return fmt.Errorf("TELESRV_STARGIFT_SWEEP_INTERVAL must be positive and TELESRV_STARGIFT_SWEEP_BATCH must be 1..10000")

View file

@ -35,13 +35,13 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) {
func TestLoadUsesExplicitAdvertiseIP(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_ADVERTISE_IP", "192.0.2.10")
t.Setenv("TELESRV_ADVERTISE_IP", "10.172.61.102")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AdvertiseIP != "192.0.2.10" {
if cfg.AdvertiseIP != "10.172.61.102" {
t.Fatalf("AdvertiseIP = %q, want explicit env", cfg.AdvertiseIP)
}
}
@ -431,6 +431,83 @@ func TestLoadNormalizesLocalPublicBaseURL(t *testing.T) {
}
}
func TestLoadTelegramLoginConfig(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_PUBLIC_LINK_WEB_ADDR", "127.0.0.1:2401")
t.Setenv("TELESRV_TELEGRAM_LOGIN_ENABLE", "true")
t.Setenv("TELESRV_TELEGRAM_LOGIN_ISSUER", "http://127.0.0.1:2401/")
t.Setenv("TELESRV_TELEGRAM_LOGIN_ALLOW_LOOPBACK_HTTP", "true")
t.Setenv("TELESRV_TELEGRAM_LOGIN_SIGNING_KEYS_FILE", "secrets/signing.json")
t.Setenv("TELESRV_TELEGRAM_LOGIN_CODE_KEYS_FILE", "secrets/codes.json")
t.Setenv("TELESRV_TELEGRAM_LOGIN_SECRET_PEPPER_FILE", "secrets/pepper")
t.Setenv("TELESRV_TELEGRAM_LOGIN_REQUEST_TTL", "7m")
t.Setenv("TELESRV_TELEGRAM_LOGIN_CODE_TTL", "90s")
t.Setenv("TELESRV_TELEGRAM_LOGIN_ID_TOKEN_TTL", "45m")
t.Setenv("TELESRV_TELEGRAM_LOGIN_TRUSTED_PROXY_CIDRS", "127.0.0.0/8,10.0.0.0/8")
t.Setenv("TELESRV_TELEGRAM_LOGIN_RETENTION", "48h")
t.Setenv("TELESRV_TELEGRAM_LOGIN_SWEEP_INTERVAL", "30s")
t.Setenv("TELESRV_TELEGRAM_LOGIN_SWEEP_BATCH", "73")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.TelegramLoginEnabled || cfg.TelegramLoginIssuer != "http://127.0.0.1:2401" || !cfg.TelegramLoginAllowLoopbackHTTP {
t.Fatalf("telegram login endpoint config = enabled:%v issuer:%q loopback:%v", cfg.TelegramLoginEnabled, cfg.TelegramLoginIssuer, cfg.TelegramLoginAllowLoopbackHTTP)
}
if cfg.TelegramLoginSigningKeysFile != "secrets/signing.json" || cfg.TelegramLoginCodeKeysFile != "secrets/codes.json" || cfg.TelegramLoginSecretPepperFile != "secrets/pepper" {
t.Fatalf("telegram login secret files = %q / %q / %q", cfg.TelegramLoginSigningKeysFile, cfg.TelegramLoginCodeKeysFile, cfg.TelegramLoginSecretPepperFile)
}
if cfg.TelegramLoginRequestTTL != 7*time.Minute || cfg.TelegramLoginCodeTTL != 90*time.Second || cfg.TelegramLoginIDTokenTTL != 45*time.Minute ||
cfg.TelegramLoginRetention != 48*time.Hour || cfg.TelegramLoginSweepInterval != 30*time.Second || cfg.TelegramLoginSweepBatch != 73 {
t.Fatalf("telegram login durations/batch = %v / %v / %v / %v / %v / %d", cfg.TelegramLoginRequestTTL, cfg.TelegramLoginCodeTTL,
cfg.TelegramLoginIDTokenTTL, cfg.TelegramLoginRetention, cfg.TelegramLoginSweepInterval, cfg.TelegramLoginSweepBatch)
}
if len(cfg.TelegramLoginTrustedProxyCIDRs) != 2 || cfg.TelegramLoginTrustedProxyCIDRs[1] != "10.0.0.0/8" {
t.Fatalf("trusted proxy CIDRs = %#v", cfg.TelegramLoginTrustedProxyCIDRs)
}
}
func TestValidateTelegramLoginConfigRejectsUnsafeOrUnboundedSettings(t *testing.T) {
valid := Config{
TelegramLoginEnabled: true, PublicLinkWebAddr: "127.0.0.1:2401", TelegramLoginIssuer: "https://login.example.test",
TelegramLoginSigningKeysFile: "signing.json", TelegramLoginCodeKeysFile: "codes.json", TelegramLoginSecretPepperFile: "pepper",
TelegramLoginRequestTTL: 5 * time.Minute, TelegramLoginCodeTTL: 2 * time.Minute, TelegramLoginIDTokenTTL: time.Hour,
TelegramLoginRetention: 7 * 24 * time.Hour, TelegramLoginSweepInterval: 5 * time.Minute, TelegramLoginSweepBatch: 500,
}
if err := validateTelegramLoginConfig(valid); err != nil {
t.Fatalf("valid config: %v", err)
}
tests := []struct {
name string
mutate func(*Config)
}{
{name: "missing listener", mutate: func(c *Config) { c.PublicLinkWebAddr = "" }},
{name: "issuer path", mutate: func(c *Config) { c.TelegramLoginIssuer = "https://login.example.test/oauth" }},
{name: "public http", mutate: func(c *Config) {
c.TelegramLoginIssuer = "http://login.example.test"
c.TelegramLoginAllowLoopbackHTTP = true
}},
{name: "loopback http disabled", mutate: func(c *Config) { c.TelegramLoginIssuer = "http://127.0.0.1:2401" }},
{name: "missing key file", mutate: func(c *Config) { c.TelegramLoginSigningKeysFile = "" }},
{name: "request ttl too long", mutate: func(c *Config) { c.TelegramLoginRequestTTL = 16 * time.Minute }},
{name: "code ttl too short", mutate: func(c *Config) { c.TelegramLoginCodeTTL = 29 * time.Second }},
{name: "id token ttl too long", mutate: func(c *Config) { c.TelegramLoginIDTokenTTL = 25 * time.Hour }},
{name: "retention too short", mutate: func(c *Config) { c.TelegramLoginRetention = 59 * time.Minute }},
{name: "sweep unbounded", mutate: func(c *Config) { c.TelegramLoginSweepBatch = 1001 }},
{name: "invalid proxy CIDR", mutate: func(c *Config) { c.TelegramLoginTrustedProxyCIDRs = []string{"10.0.0.0/33"} }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := valid
tc.mutate(&cfg)
if err := validateTelegramLoginConfig(cfg); err == nil {
t.Fatal("unsafe Telegram Login config was accepted")
}
})
}
}
func TestLoadRejectsInvalidPublicBaseURL(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv("TELESRV_PUBLIC_BASE_URL", "https://links.example.test/root?tenant=one")

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")
}
}

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)))
}
}

View file

@ -75,6 +75,9 @@ func botFatherSeedProfile() domain.BotProfile {
{Command: "mybots", Description: "list your bots"},
{Command: "token", Description: "show a bot's token"},
{Command: "revoke", Description: "revoke a bot's token"},
{Command: "setlogin", Description: "configure Telegram Login"},
{Command: "logininfo", Description: "show Telegram Login configuration"},
{Command: "resetloginsecret", Description: "rotate an OIDC Client Secret"},
{Command: "cancel", Description: "cancel the current operation"},
{Command: "help", Description: "show help"},
},

View file

@ -0,0 +1,721 @@
package memory
import (
"context"
"sort"
"strconv"
"sync"
"time"
"telesrv/internal/domain"
)
type telegramLoginBotPermissionWriter interface {
AllowBotSendMessage(ctx context.Context, botUserID, userID int64, fromRequest bool) (bool, error)
}
// TelegramLoginStore is the deterministic in-memory implementation used by
// application and RPC tests. A single mutex makes the same aggregate changes
// atomic; production uses PostgreSQL row locks and one transaction.
type TelegramLoginStore struct {
mu sync.RWMutex
permissions telegramLoginBotPermissionWriter
nextURLID int64
nextAppID int64
nextRequestID int64
nextCodeID int64
clientsByID map[string]domain.TelegramLoginClient
clientByBot map[int64]string
allowedURLs map[string]domain.TelegramLoginAllowedURL
nativeApps map[int64]domain.TelegramLoginNativeApp
requests map[int64]domain.TelegramLoginRequest
requestToken map[string]int64
browserToken map[string]int64
codes map[int64]domain.TelegramLoginAuthorizationCode
codeByHash map[string]int64
codeByRequest map[int64]int64
webAuths map[int64]domain.TelegramLoginWebAuthorization
}
func NewTelegramLoginStore(permissions telegramLoginBotPermissionWriter) *TelegramLoginStore {
return &TelegramLoginStore{
permissions: permissions,
clientsByID: make(map[string]domain.TelegramLoginClient),
clientByBot: make(map[int64]string),
allowedURLs: make(map[string]domain.TelegramLoginAllowedURL),
nativeApps: make(map[int64]domain.TelegramLoginNativeApp),
requests: make(map[int64]domain.TelegramLoginRequest),
requestToken: make(map[string]int64),
browserToken: make(map[string]int64),
codes: make(map[int64]domain.TelegramLoginAuthorizationCode),
codeByHash: make(map[string]int64),
codeByRequest: make(map[int64]int64),
webAuths: make(map[int64]domain.TelegramLoginWebAuthorization),
}
}
func (s *TelegramLoginStore) CreateTelegramLoginClient(_ context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error) {
if err := client.Validate(); err != nil {
return domain.TelegramLoginClient{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.clientByBot[client.BotUserID]; exists {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginRequestConflict
}
if _, exists := s.clientsByID[client.ClientID]; exists {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginRequestConflict
}
s.clientsByID[client.ClientID] = client.Clone()
s.clientByBot[client.BotUserID] = client.ClientID
return client.Clone(), nil
}
func (s *TelegramLoginStore) UpsertTelegramLoginClient(_ context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error) {
if err := client.Validate(); err != nil {
return domain.TelegramLoginClient{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
if existingID, exists := s.clientByBot[client.BotUserID]; exists && existingID != client.ClientID {
delete(s.clientsByID, existingID)
}
if existing, exists := s.clientsByID[client.ClientID]; exists && existing.BotUserID != client.BotUserID {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
}
s.clientsByID[client.ClientID] = client.Clone()
s.clientByBot[client.BotUserID] = client.ClientID
return client.Clone(), nil
}
func (s *TelegramLoginStore) GetTelegramLoginClient(_ context.Context, clientID string) (domain.TelegramLoginClient, bool, error) {
s.mu.RLock()
client, ok := s.clientsByID[clientID]
s.mu.RUnlock()
return client.Clone(), ok, nil
}
func (s *TelegramLoginStore) GetTelegramLoginClientByBot(_ context.Context, botUserID int64) (domain.TelegramLoginClient, bool, error) {
s.mu.RLock()
clientID, ok := s.clientByBot[botUserID]
client := s.clientsByID[clientID]
s.mu.RUnlock()
return client.Clone(), ok, nil
}
func (s *TelegramLoginStore) RotateTelegramLoginClientSecret(_ context.Context, botUserID, expectedVersion int64, secretHash []byte, now time.Time) (domain.TelegramLoginClient, error) {
if botUserID <= 0 || expectedVersion <= 0 || len(secretHash) != 32 {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
clientID, ok := s.clientByBot[botUserID]
if !ok {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
}
client := s.clientsByID[clientID]
if client.SecretVersion != expectedVersion {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginRequestConflict
}
client.SecretVersion++
client.SecretHash = append([]byte(nil), secretHash...)
client.UpdatedAt = now
s.clientsByID[clientID] = client
return client.Clone(), nil
}
func (s *TelegramLoginStore) SetTelegramLoginClientSigningAlgorithm(_ context.Context, botUserID int64, algorithm domain.TelegramLoginSigningAlgorithm, now time.Time) (domain.TelegramLoginClient, error) {
if botUserID <= 0 || !algorithm.Valid() {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
clientID, ok := s.clientByBot[botUserID]
if !ok {
return domain.TelegramLoginClient{}, domain.ErrTelegramLoginClientInvalid
}
client := s.clientsByID[clientID]
client.SigningAlgorithm = algorithm
client.UpdatedAt = now
s.clientsByID[clientID] = client
return client.Clone(), nil
}
func (s *TelegramLoginStore) SetTelegramLoginClientEnabled(_ context.Context, botUserID int64, enabled bool, now time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
clientID, ok := s.clientByBot[botUserID]
if !ok {
return domain.ErrTelegramLoginClientInvalid
}
client := s.clientsByID[clientID]
client.Enabled = enabled
client.UpdatedAt = now
s.clientsByID[clientID] = client
return nil
}
func telegramLoginAllowedURLKey(botUserID int64, kind domain.TelegramLoginAllowedURLKind, value string) string {
return strconv.FormatInt(botUserID, 10) + "\x00" + string(kind) + "\x00" + value
}
func (s *TelegramLoginStore) AddTelegramLoginAllowedURL(_ context.Context, allowed domain.TelegramLoginAllowedURL) (domain.TelegramLoginAllowedURL, error) {
if allowed.BotUserID <= 0 || allowed.NormalizedURL == "" || (allowed.Kind != domain.TelegramLoginAllowedWebOrigin && allowed.Kind != domain.TelegramLoginAllowedRedirectURI) {
return domain.TelegramLoginAllowedURL{}, domain.ErrTelegramLoginURLInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.clientByBot[allowed.BotUserID]; !ok {
return domain.TelegramLoginAllowedURL{}, domain.ErrTelegramLoginClientInvalid
}
key := telegramLoginAllowedURLKey(allowed.BotUserID, allowed.Kind, allowed.NormalizedURL)
if existing, ok := s.allowedURLs[key]; ok {
return existing, nil
}
s.nextURLID++
allowed.ID = s.nextURLID
s.allowedURLs[key] = allowed
return allowed, nil
}
func (s *TelegramLoginStore) DeleteTelegramLoginAllowedURL(_ context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
key := telegramLoginAllowedURLKey(botUserID, kind, normalizedURL)
if _, ok := s.allowedURLs[key]; !ok {
return false, nil
}
delete(s.allowedURLs, key)
return true, nil
}
func (s *TelegramLoginStore) ListTelegramLoginAllowedURLs(_ context.Context, botUserID int64) ([]domain.TelegramLoginAllowedURL, error) {
s.mu.RLock()
out := make([]domain.TelegramLoginAllowedURL, 0)
for _, allowed := range s.allowedURLs {
if allowed.BotUserID == botUserID {
out = append(out, allowed)
}
}
s.mu.RUnlock()
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}
func (s *TelegramLoginStore) IsTelegramLoginURLAllowed(_ context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error) {
s.mu.RLock()
_, ok := s.allowedURLs[telegramLoginAllowedURLKey(botUserID, kind, normalizedURL)]
s.mu.RUnlock()
return ok, nil
}
func (s *TelegramLoginStore) UpsertTelegramLoginNativeApp(_ context.Context, app domain.TelegramLoginNativeApp) (domain.TelegramLoginNativeApp, error) {
if err := app.Validate(); err != nil {
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.clientByBot[app.BotUserID]; !ok {
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid
}
if app.ID == 0 {
for id, existing := range s.nativeApps {
if existing.BotUserID == app.BotUserID && existing.Platform == app.Platform && existing.ApplicationID == app.ApplicationID && existing.VerificationID == app.VerificationID {
app.ID, app.CreatedAt = id, existing.CreatedAt
s.nativeApps[id] = app
return app, nil
}
if existing.BotUserID == app.BotUserID && existing.CallbackURI == app.CallbackURI {
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginRequestConflict
}
}
count := 0
for _, existing := range s.nativeApps {
if existing.BotUserID == app.BotUserID {
count++
}
}
if count >= domain.MaxTelegramLoginNativeApps {
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginRequestInvalid
}
s.nextAppID++
app.ID = s.nextAppID
} else if existing, ok := s.nativeApps[app.ID]; ok && existing.BotUserID != app.BotUserID {
return domain.TelegramLoginNativeApp{}, domain.ErrTelegramLoginClientInvalid
}
s.nativeApps[app.ID] = app
return app, nil
}
func (s *TelegramLoginStore) DeleteTelegramLoginNativeApp(_ context.Context, botUserID, appID int64) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
app, ok := s.nativeApps[appID]
if !ok || app.BotUserID != botUserID {
return false, nil
}
delete(s.nativeApps, appID)
return true, nil
}
func (s *TelegramLoginStore) ListTelegramLoginNativeApps(_ context.Context, botUserID int64) ([]domain.TelegramLoginNativeApp, error) {
s.mu.RLock()
out := make([]domain.TelegramLoginNativeApp, 0)
for _, app := range s.nativeApps {
if app.BotUserID == botUserID {
out = append(out, app)
}
}
s.mu.RUnlock()
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
if len(out) > domain.MaxTelegramLoginNativeApps {
out = out[:domain.MaxTelegramLoginNativeApps]
}
return out, nil
}
func (s *TelegramLoginStore) CreateTelegramLoginRequest(_ context.Context, request domain.TelegramLoginRequest) (domain.TelegramLoginRequest, error) {
if err := request.Validate(); err != nil {
return domain.TelegramLoginRequest{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
client, ok := s.clientsByID[request.ClientID]
if !ok || client.BotUserID != request.BotUserID || !client.Enabled || client.SigningAlgorithm != request.SigningAlgorithm {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginClientDisabled
}
if _, exists := s.requestToken[string(request.RequestTokenHash)]; exists {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestConflict
}
if _, exists := s.browserToken[string(request.BrowserTokenHash)]; exists {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestConflict
}
s.nextRequestID++
request.ID = s.nextRequestID
s.requests[request.ID] = request.Clone()
s.requestToken[string(request.RequestTokenHash)] = request.ID
s.browserToken[string(request.BrowserTokenHash)] = request.ID
return request.Clone(), nil
}
func (s *TelegramLoginStore) GetTelegramLoginRequest(_ context.Context, requestID int64) (domain.TelegramLoginRequest, bool, error) {
s.mu.RLock()
request, ok := s.requests[requestID]
s.mu.RUnlock()
return request.Clone(), ok, nil
}
func (s *TelegramLoginStore) GetTelegramLoginRequestByTokenHash(_ context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error) {
s.mu.RLock()
id, ok := s.requestToken[string(tokenHash)]
request := s.requests[id]
s.mu.RUnlock()
return request.Clone(), ok, nil
}
func (s *TelegramLoginStore) GetTelegramLoginRequestByBrowserTokenHash(_ context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error) {
s.mu.RLock()
id, ok := s.browserToken[string(tokenHash)]
request := s.requests[id]
s.mu.RUnlock()
return request.Clone(), ok, nil
}
func grantedTelegramLoginScopes(request domain.TelegramLoginRequest, approval domain.TelegramLoginApproval) ([]domain.TelegramLoginScope, error) {
if approval.WriteAllowed && !request.Requests(domain.TelegramLoginScopeBotAccess) {
return nil, domain.ErrTelegramLoginScopeInvalid
}
if approval.PhoneShared && !request.Requests(domain.TelegramLoginScopePhone) {
return nil, domain.ErrTelegramLoginScopeInvalid
}
out := make([]domain.TelegramLoginScope, 0, len(request.Scopes))
for _, scope := range request.Scopes {
if scope == domain.TelegramLoginScopePhone && !approval.PhoneShared {
continue
}
if scope == domain.TelegramLoginScopeBotAccess && !approval.WriteAllowed {
continue
}
out = append(out, scope)
}
return out, nil
}
func (s *TelegramLoginStore) ApproveTelegramLoginRequest(ctx context.Context, approval domain.TelegramLoginApproval, webAuthorizationHash int64) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) {
if approval.RequestID <= 0 || approval.Identity.UserID <= 0 || webAuthorizationHash == 0 || approval.ApprovedAt.IsZero() {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
request, ok := s.requests[approval.RequestID]
if !ok {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid
}
if request.Status != domain.TelegramLoginRequestPending {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestConflict
}
if !approval.ApprovedAt.Before(request.ExpiresAt) {
request.Status = domain.TelegramLoginRequestExpired
s.requests[request.ID] = request
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestExpired
}
client, clientExists := s.clientsByID[request.ClientID]
if !clientExists || !client.Enabled || client.BotUserID != request.BotUserID || client.SigningAlgorithm != request.SigningAlgorithm {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginClientDisabled
}
if request.ResponseType == "code" {
_, webAllowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedRedirectURI, request.RedirectURI)]
if !webAllowed && !(request.Source == domain.TelegramLoginRequestNative && s.nativeCallbackAllowedLocked(request.BotUserID, request.RedirectURI)) {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRedirectNotAllowed
}
} else if request.ResponseType == "post_message" || request.ResponseType == "legacy_url" {
if _, ok := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, request.Origin)]; !ok {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
}
} else {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestInvalid
}
if request.InAppOrigin != "" {
if _, ok := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, request.InAppOrigin)]; !ok {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
}
}
if len(request.MatchCodes) > 0 && approval.MatchCode != request.MatchCode {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginMatchCodeInvalid
}
scopes, err := grantedTelegramLoginScopes(request, approval)
if err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err
}
if _, exists := s.webAuths[webAuthorizationHash]; exists {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginRequestConflict
}
identity, err := approval.Identity.Sanitized(request.Requests(domain.TelegramLoginScopeProfile), approval.PhoneShared)
if err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err
}
activeAuthorizations := 0
for _, authorization := range s.webAuths {
if authorization.UserID == identity.UserID && authorization.RevokedAt.IsZero() {
activeAuthorizations++
}
}
if activeAuthorizations >= domain.MaxTelegramLoginWebAuthorizations {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginAuthorizationsTooMany
}
if approval.WriteAllowed && s.permissions != nil {
if _, err := s.permissions.AllowBotSendMessage(ctx, request.BotUserID, identity.UserID, true); err != nil {
return domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, err
}
}
request.Status = domain.TelegramLoginRequestApproved
request.AuthorizedUserID = identity.UserID
request.ProfileName = identity.Name
request.GivenName = identity.GivenName
request.FamilyName = identity.FamilyName
request.PreferredUsername = identity.PreferredUsername
request.Picture = identity.Picture
request.PhoneNumber = identity.PhoneNumber
request.WriteAllowed = approval.WriteAllowed
request.PhoneShared = approval.PhoneShared
request.ApprovedAt = approval.ApprovedAt
s.requests[request.ID] = request.Clone()
web := domain.TelegramLoginWebAuthorization{
Hash: webAuthorizationHash,
RequestID: request.ID,
UserID: identity.UserID,
BotUserID: request.BotUserID,
Domain: request.Domain,
Browser: request.Browser,
Platform: request.Platform,
IP: request.IP,
Region: request.Region,
Scopes: scopes,
PhoneShared: approval.PhoneShared,
BotAccessGranted: approval.WriteAllowed,
CreatedAt: approval.ApprovedAt,
LastActiveAt: approval.ApprovedAt,
}
s.webAuths[web.Hash] = web.Clone()
return request.Clone(), web.Clone(), nil
}
func (s *TelegramLoginStore) DeclineTelegramLoginRequest(_ context.Context, requestID, userID int64, now time.Time) (domain.TelegramLoginRequest, error) {
if requestID <= 0 || userID <= 0 || now.IsZero() {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
request, ok := s.requests[requestID]
if !ok {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestInvalid
}
if request.Status != domain.TelegramLoginRequestPending {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestConflict
}
if !now.Before(request.ExpiresAt) {
request.Status = domain.TelegramLoginRequestExpired
s.requests[request.ID] = request
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginRequestExpired
}
request.Status = domain.TelegramLoginRequestDeclined
request.DeclinedAt = now
s.requests[request.ID] = request.Clone()
return request.Clone(), nil
}
func (s *TelegramLoginStore) PutTelegramLoginAuthorizationCode(_ context.Context, code domain.TelegramLoginAuthorizationCode) (domain.TelegramLoginAuthorizationCode, error) {
if code.RequestID <= 0 || len(code.CodeHash) != 32 || len(code.SealedCode) < 32 || len(code.SealNonce) < 12 || code.SealKeyID == "" || code.IssuedAt.IsZero() || !code.ExpiresAt.After(code.IssuedAt) {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginCodeInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
request, ok := s.requests[code.RequestID]
if !ok || request.Status != domain.TelegramLoginRequestApproved {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
}
client, clientExists := s.clientsByID[request.ClientID]
if !clientExists || !client.Enabled || client.BotUserID != request.BotUserID || client.SigningAlgorithm != request.SigningAlgorithm {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginClientDisabled
}
switch request.ResponseType {
case "code":
_, allowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedRedirectURI, request.RedirectURI)]
if !allowed && !(request.Source == domain.TelegramLoginRequestNative && s.nativeCallbackAllowedLocked(request.BotUserID, request.RedirectURI)) {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRedirectNotAllowed
}
case "post_message":
if _, allowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, request.Origin)]; !allowed {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginOriginNotAllowed
}
default:
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
}
web, active := s.webAuthByRequestLocked(request.ID)
if !active || !web.RevokedAt.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
}
if id, exists := s.codeByRequest[code.RequestID]; exists {
return s.codes[id].Clone(), nil
}
if _, exists := s.codeByHash[string(code.CodeHash)]; exists {
return domain.TelegramLoginAuthorizationCode{}, domain.ErrTelegramLoginRequestConflict
}
s.nextCodeID++
code.ID = s.nextCodeID
s.codes[code.ID] = code.Clone()
s.codeByHash[string(code.CodeHash)] = code.ID
s.codeByRequest[code.RequestID] = code.ID
return code.Clone(), nil
}
func (s *TelegramLoginStore) GetTelegramLoginAuthorizationCodeByRequest(_ context.Context, requestID int64) (domain.TelegramLoginAuthorizationCode, bool, error) {
s.mu.RLock()
id, ok := s.codeByRequest[requestID]
code := s.codes[id]
s.mu.RUnlock()
return code.Clone(), ok, nil
}
func (s *TelegramLoginStore) GetTelegramLoginAuthorizationCodeByHash(_ context.Context, codeHash []byte) (domain.TelegramLoginAuthorizationCode, bool, error) {
s.mu.RLock()
id, ok := s.codeByHash[string(codeHash)]
code := s.codes[id]
s.mu.RUnlock()
return code.Clone(), ok, nil
}
func (s *TelegramLoginStore) ConsumeTelegramLoginAuthorizationCode(_ context.Context, exchange domain.TelegramLoginCodeExchange) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) {
if len(exchange.CodeHash) != 32 || exchange.ClientID == "" || exchange.ClientSecretVersion <= 0 || exchange.RedirectURI == "" || exchange.CodeChallenge == "" || exchange.Now.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
id, ok := s.codeByHash[string(exchange.CodeHash)]
if !ok {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
code := s.codes[id]
if !code.ConsumedAt.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeConsumed
}
if !exchange.Now.Before(code.ExpiresAt) {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
request := s.requests[code.RequestID]
client, clientExists := s.clientsByID[exchange.ClientID]
if !clientExists || !client.Enabled || client.SecretVersion != exchange.ClientSecretVersion || request.ResponseType != "code" || request.ClientID != exchange.ClientID || request.RedirectURI != exchange.RedirectURI || request.CodeChallenge != exchange.CodeChallenge {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
_, webAllowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedRedirectURI, request.RedirectURI)]
if !webAllowed && !(request.Source == domain.TelegramLoginRequestNative && s.nativeCallbackAllowedLocked(request.BotUserID, request.RedirectURI)) {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
web, exists := s.webAuthByRequestLocked(code.RequestID)
if request.Status != domain.TelegramLoginRequestApproved || !exists || !web.RevokedAt.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
code.ConsumedAt = exchange.Now
web.LastActiveAt = exchange.Now
s.codes[id] = code.Clone()
s.webAuths[web.Hash] = web.Clone()
return code.Clone(), request.Clone(), web.Clone(), nil
}
func (s *TelegramLoginStore) ConsumeTelegramLoginDirectToken(_ context.Context, tokenHash []byte, origin string, now time.Time) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error) {
if len(tokenHash) != 32 || origin == "" || now.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
id, ok := s.codeByHash[string(tokenHash)]
if !ok {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
code := s.codes[id]
if !code.ConsumedAt.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeConsumed
}
if !now.Before(code.ExpiresAt) {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
request := s.requests[code.RequestID]
client, clientExists := s.clientsByID[request.ClientID]
if !clientExists || !client.Enabled || client.BotUserID != request.BotUserID ||
request.Status != domain.TelegramLoginRequestApproved || request.Source != domain.TelegramLoginRequestMiniApp ||
request.ResponseType != "post_message" || request.Origin != origin || request.InAppOrigin != origin {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
if _, allowed := s.allowedURLs[telegramLoginAllowedURLKey(request.BotUserID, domain.TelegramLoginAllowedWebOrigin, origin)]; !allowed {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
web, exists := s.webAuthByRequestLocked(code.RequestID)
if !exists || !web.RevokedAt.IsZero() {
return domain.TelegramLoginAuthorizationCode{}, domain.TelegramLoginRequest{}, domain.TelegramLoginWebAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
code.ConsumedAt = now
web.LastActiveAt = now
s.codes[id] = code.Clone()
s.webAuths[web.Hash] = web.Clone()
return code.Clone(), request.Clone(), web.Clone(), nil
}
func (s *TelegramLoginStore) webAuthByRequestLocked(requestID int64) (domain.TelegramLoginWebAuthorization, bool) {
for _, web := range s.webAuths {
if web.RequestID == requestID {
return web, true
}
}
return domain.TelegramLoginWebAuthorization{}, false
}
func (s *TelegramLoginStore) nativeCallbackAllowedLocked(botUserID int64, callbackURI string) bool {
for _, app := range s.nativeApps {
if app.BotUserID == botUserID && app.Enabled && app.CallbackURI == callbackURI {
return true
}
}
return false
}
func (s *TelegramLoginStore) ListTelegramLoginWebAuthorizations(_ context.Context, userID int64) ([]domain.TelegramLoginWebAuthorization, error) {
s.mu.RLock()
out := make([]domain.TelegramLoginWebAuthorization, 0, min(len(s.webAuths), domain.MaxTelegramLoginWebAuthorizations))
for _, web := range s.webAuths {
if web.UserID == userID && web.RevokedAt.IsZero() {
out = append(out, web.Clone())
}
}
s.mu.RUnlock()
sort.Slice(out, func(i, j int) bool {
if out[i].LastActiveAt.Equal(out[j].LastActiveAt) {
return out[i].Hash > out[j].Hash
}
return out[i].LastActiveAt.After(out[j].LastActiveAt)
})
if len(out) > domain.MaxTelegramLoginWebAuthorizations {
out = out[:domain.MaxTelegramLoginWebAuthorizations]
}
return out, nil
}
func (s *TelegramLoginStore) RevokeTelegramLoginWebAuthorization(_ context.Context, userID, hash int64, now time.Time) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
web, ok := s.webAuths[hash]
if !ok || web.UserID != userID || !web.RevokedAt.IsZero() {
return false, nil
}
web.RevokedAt = now
s.webAuths[hash] = web
return true, nil
}
func (s *TelegramLoginStore) RevokeAllTelegramLoginWebAuthorizations(_ context.Context, userID int64, now time.Time) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
var count int64
for hash, web := range s.webAuths {
if web.UserID == userID && web.RevokedAt.IsZero() {
web.RevokedAt = now
s.webAuths[hash] = web
count++
}
}
return count, nil
}
func (s *TelegramLoginStore) DeleteExpiredTelegramLoginArtifacts(_ context.Context, before time.Time, limit int) (int64, error) {
if limit <= 0 || limit > 1000 {
return 0, domain.ErrTelegramLoginRequestInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
var deleted int64
for id, code := range s.codes {
if deleted >= int64(limit) {
break
}
if code.ExpiresAt.Before(before) || (!code.ConsumedAt.IsZero() && code.ConsumedAt.Before(before)) {
delete(s.codes, id)
delete(s.codeByHash, string(code.CodeHash))
delete(s.codeByRequest, code.RequestID)
deleted++
}
}
for id, request := range s.requests {
if deleted >= int64(limit) {
break
}
deleteRequest := (request.Status == domain.TelegramLoginRequestPending || request.Status == domain.TelegramLoginRequestDeclined || request.Status == domain.TelegramLoginRequestExpired) && request.ExpiresAt.Before(before)
var revokedWebHash int64
if request.Status == domain.TelegramLoginRequestApproved && !request.ApprovedAt.IsZero() && request.ApprovedAt.Before(before) {
// Approved requests remain the immutable claim snapshot behind an active
// web authorization. They may only be collected after the grant itself
// was revoked and every exchange code has left the retention window.
for hash, web := range s.webAuths {
if web.RequestID == id && !web.RevokedAt.IsZero() && web.RevokedAt.Before(before) {
deleteRequest = true
revokedWebHash = hash
break
}
}
if _, hasCode := s.codeByRequest[id]; hasCode {
deleteRequest = false
}
}
if !deleteRequest {
continue
}
delete(s.requests, id)
delete(s.requestToken, string(request.RequestTokenHash))
delete(s.browserToken, string(request.BrowserTokenHash))
if revokedWebHash != 0 {
delete(s.webAuths, revokedWebHash)
}
deleted++
}
return deleted, nil
}

View file

@ -0,0 +1,320 @@
package memory
import (
"context"
"crypto/sha256"
"errors"
"sync"
"testing"
"time"
"telesrv/internal/domain"
)
type telegramLoginPermissionRecorder struct {
mu sync.Mutex
grants map[[2]int64]int
}
func (r *telegramLoginPermissionRecorder) AllowBotSendMessage(_ context.Context, botUserID, userID int64, _ bool) (bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.grants == nil {
r.grants = make(map[[2]int64]int)
}
key := [2]int64{botUserID, userID}
created := r.grants[key] == 0
r.grants[key]++
return created, nil
}
func telegramLoginTestHash(value string) []byte {
sum := sha256.Sum256([]byte(value))
return sum[:]
}
func seedTelegramLoginRequest(t *testing.T, s *TelegramLoginStore, now time.Time) domain.TelegramLoginRequest {
t.Helper()
ctx := context.Background()
client := domain.TelegramLoginClient{
BotUserID: 9001,
ClientID: "9001",
SecretHash: telegramLoginTestHash("client-secret"),
SecretVersion: 1,
SigningAlgorithm: domain.TelegramLoginSigningRS256,
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
}
if _, err := s.UpsertTelegramLoginClient(ctx, client); err != nil {
t.Fatalf("UpsertTelegramLoginClient: %v", err)
}
if _, err := s.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{
BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedRedirectURI,
NormalizedURL: "https://rp.example/callback", CreatedAt: now,
}); err != nil {
t.Fatalf("AddTelegramLoginAllowedURL: %v", err)
}
request := domain.TelegramLoginRequest{
RequestTokenHash: telegramLoginTestHash("request-token"),
BrowserTokenHash: telegramLoginTestHash("browser-token"),
BotUserID: client.BotUserID,
ClientID: client.ClientID,
SigningAlgorithm: client.SigningAlgorithm,
Source: domain.TelegramLoginRequestWeb,
ResponseType: "code",
RedirectURI: "https://rp.example/callback",
Origin: "https://rp.example",
Domain: "rp.example",
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile, domain.TelegramLoginScopePhone, domain.TelegramLoginScopeBotAccess},
State: "state",
Nonce: "nonce",
CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
CodeChallengeMethod: "S256",
Browser: "Firefox",
Platform: "Windows",
IP: "192.0.2.10",
Region: "Test Region",
MatchCodes: []string{"🟢", "🔵", "🟠"},
MatchCode: "🔵",
MatchCodesFirst: true,
Status: domain.TelegramLoginRequestPending,
CreatedAt: now,
ExpiresAt: now.Add(5 * time.Minute),
}
created, err := s.CreateTelegramLoginRequest(ctx, request)
if err != nil {
t.Fatalf("CreateTelegramLoginRequest: %v", err)
}
return created
}
func approveTelegramLoginRequest(t *testing.T, s *TelegramLoginStore, request domain.TelegramLoginRequest, now time.Time) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization) {
t.Helper()
approved, web, err := s.ApproveTelegramLoginRequest(context.Background(), domain.TelegramLoginApproval{
RequestID: request.ID,
Identity: domain.TelegramLoginIdentitySnapshot{
UserID: 42, Name: "Alice Example", GivenName: "Alice", FamilyName: "Example",
PreferredUsername: "alice", Picture: "https://oauth.example/userpic/42",
},
WriteAllowed: true,
PhoneShared: false,
MatchCode: request.MatchCode,
ApprovedAt: now,
}, 7000+request.ID)
if err != nil {
t.Fatalf("ApproveTelegramLoginRequest: %v", err)
}
return approved, web
}
func TestTelegramLoginApproveIsAtomicAndShrinksConsent(t *testing.T) {
now := time.Unix(1_780_000_000, 0)
permissions := &telegramLoginPermissionRecorder{}
s := NewTelegramLoginStore(permissions)
request := seedTelegramLoginRequest(t, s, now)
approved, web := approveTelegramLoginRequest(t, s, request, now.Add(time.Second))
if approved.Status != domain.TelegramLoginRequestApproved || approved.AuthorizedUserID != 42 {
t.Fatalf("approved request = %#v", approved)
}
if web.PhoneShared || web.BotAccessGranted != true {
t.Fatalf("web consent = %#v", web)
}
if len(web.Scopes) != 3 || web.Scopes[0] != domain.TelegramLoginScopeOpenID || web.Scopes[1] != domain.TelegramLoginScopeProfile || web.Scopes[2] != domain.TelegramLoginScopeBotAccess {
t.Fatalf("granted scopes = %#v", web.Scopes)
}
permissions.mu.Lock()
grants := permissions.grants[[2]int64{9001, 42}]
permissions.mu.Unlock()
if grants != 1 {
t.Fatalf("bot permission grants = %d, want 1", grants)
}
}
func TestTelegramLoginAcceptDeclineRaceHasOneTerminalState(t *testing.T) {
now := time.Unix(1_780_000_000, 0)
s := NewTelegramLoginStore(nil)
request := seedTelegramLoginRequest(t, s, now)
start := make(chan struct{})
errs := make(chan error, 2)
go func() {
<-start
_, _, err := s.ApproveTelegramLoginRequest(context.Background(), domain.TelegramLoginApproval{
RequestID: request.ID,
Identity: domain.TelegramLoginIdentitySnapshot{UserID: 42, Name: "Alice", GivenName: "Alice"},
MatchCode: request.MatchCode, ApprovedAt: now.Add(time.Second),
}, 7001)
errs <- err
}()
go func() {
<-start
_, err := s.DeclineTelegramLoginRequest(context.Background(), request.ID, 42, now.Add(time.Second))
errs <- err
}()
close(start)
var success, conflict int
for range 2 {
err := <-errs
switch {
case err == nil:
success++
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
conflict++
default:
t.Fatalf("unexpected race error: %v", err)
}
}
if success != 1 || conflict != 1 {
t.Fatalf("success=%d conflict=%d, want 1/1", success, conflict)
}
}
func TestTelegramLoginAuthorizationCodeSingleConsumeAndRevocation(t *testing.T) {
now := time.Unix(1_780_000_000, 0)
s := NewTelegramLoginStore(nil)
request := seedTelegramLoginRequest(t, s, now)
approveTelegramLoginRequest(t, s, request, now.Add(time.Second))
code := domain.TelegramLoginAuthorizationCode{
RequestID: request.ID,
CodeHash: telegramLoginTestHash("authorization-code"),
SealedCode: append(make([]byte, 32), 1),
SealNonce: make([]byte, 12),
SealKeyID: "test-key",
IssuedAt: now.Add(2 * time.Second),
ExpiresAt: now.Add(time.Minute),
}
if _, err := s.PutTelegramLoginAuthorizationCode(context.Background(), code); err != nil {
t.Fatalf("PutTelegramLoginAuthorizationCode: %v", err)
}
start := make(chan struct{})
errs := make(chan error, 8)
for range 8 {
go func() {
<-start
_, _, _, err := s.ConsumeTelegramLoginAuthorizationCode(context.Background(), domain.TelegramLoginCodeExchange{
CodeHash: code.CodeHash, ClientID: request.ClientID, ClientSecretVersion: 1,
RedirectURI: request.RedirectURI, CodeChallenge: request.CodeChallenge, Now: now.Add(3 * time.Second),
})
errs <- err
}()
}
close(start)
var success, consumed int
for range 8 {
err := <-errs
switch {
case err == nil:
success++
case errors.Is(err, domain.ErrTelegramLoginCodeConsumed):
consumed++
default:
t.Fatalf("unexpected consume error: %v", err)
}
}
if success != 1 || consumed != 7 {
t.Fatalf("success=%d consumed=%d, want 1/7", success, consumed)
}
request2 := request.Clone()
request2.ID = 0
request2.RequestTokenHash = telegramLoginTestHash("request-token-2")
request2.BrowserTokenHash = telegramLoginTestHash("browser-token-2")
request2, err := s.CreateTelegramLoginRequest(context.Background(), request2)
if err != nil {
t.Fatalf("Create second request: %v", err)
}
_, web2 := approveTelegramLoginRequest(t, s, request2, now.Add(4*time.Second))
code2 := code.Clone()
code2.ID = 0
code2.RequestID = request2.ID
code2.CodeHash = telegramLoginTestHash("authorization-code-2")
if _, err := s.PutTelegramLoginAuthorizationCode(context.Background(), code2); err != nil {
t.Fatalf("Put second code: %v", err)
}
if revoked, err := s.RevokeTelegramLoginWebAuthorization(context.Background(), web2.UserID, web2.Hash, now.Add(5*time.Second)); err != nil || !revoked {
t.Fatalf("RevokeTelegramLoginWebAuthorization = %v,%v", revoked, err)
}
if _, _, _, err := s.ConsumeTelegramLoginAuthorizationCode(context.Background(), domain.TelegramLoginCodeExchange{
CodeHash: code2.CodeHash, ClientID: request2.ClientID, ClientSecretVersion: 1,
RedirectURI: request2.RedirectURI, CodeChallenge: request2.CodeChallenge, Now: now.Add(6 * time.Second),
}); !errors.Is(err, domain.ErrTelegramLoginCodeInvalid) {
t.Fatalf("consume after revoke error = %v, want code invalid", err)
}
}
func TestTelegramLoginRetentionPreservesActiveAndReferencedApprovals(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_780_000_000, 0)
before := now.Add(24 * time.Hour)
s := NewTelegramLoginStore(nil)
active := seedTelegramLoginRequest(t, s, now)
_, activeWeb := approveTelegramLoginRequest(t, s, active, now.Add(time.Second))
revoked := active.Clone()
revoked.ID = 0
revoked.RequestTokenHash = telegramLoginTestHash("retention-revoked-request")
revoked.BrowserTokenHash = telegramLoginTestHash("retention-revoked-browser")
revoked.Status = domain.TelegramLoginRequestPending
revoked.AuthorizedUserID = 0
revoked.ProfileName, revoked.GivenName, revoked.FamilyName = "", "", ""
revoked.PreferredUsername, revoked.Picture, revoked.PhoneNumber = "", "", ""
revoked.WriteAllowed, revoked.PhoneShared = false, false
revoked.ApprovedAt = time.Time{}
revoked, err := s.CreateTelegramLoginRequest(ctx, revoked)
if err != nil {
t.Fatalf("create revoked request: %v", err)
}
_, revokedWeb := approveTelegramLoginRequest(t, s, revoked, now.Add(2*time.Second))
if ok, err := s.RevokeTelegramLoginWebAuthorization(ctx, revokedWeb.UserID, revokedWeb.Hash, now.Add(3*time.Second)); err != nil || !ok {
t.Fatalf("revoke old authorization = %v,%v", ok, err)
}
referenced := revoked.Clone()
referenced.ID = 0
referenced.RequestTokenHash = telegramLoginTestHash("retention-referenced-request")
referenced.BrowserTokenHash = telegramLoginTestHash("retention-referenced-browser")
referenced.Status = domain.TelegramLoginRequestPending
referenced.AuthorizedUserID = 0
referenced.ProfileName, referenced.GivenName, referenced.FamilyName = "", "", ""
referenced.PreferredUsername, referenced.Picture, referenced.PhoneNumber = "", "", ""
referenced.WriteAllowed, referenced.PhoneShared = false, false
referenced.ApprovedAt = time.Time{}
referenced, err = s.CreateTelegramLoginRequest(ctx, referenced)
if err != nil {
t.Fatalf("create referenced request: %v", err)
}
_, referencedWeb := approveTelegramLoginRequest(t, s, referenced, now.Add(4*time.Second))
if _, err := s.PutTelegramLoginAuthorizationCode(ctx, domain.TelegramLoginAuthorizationCode{
RequestID: referenced.ID, CodeHash: telegramLoginTestHash("retention-live-code"),
SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "test-key",
IssuedAt: before.Add(time.Hour), ExpiresAt: before.Add(2 * time.Hour),
}); err != nil {
t.Fatalf("put retained code: %v", err)
}
if ok, err := s.RevokeTelegramLoginWebAuthorization(ctx, referencedWeb.UserID, referencedWeb.Hash, now.Add(5*time.Second)); err != nil || !ok {
t.Fatalf("revoke referenced authorization = %v,%v", ok, err)
}
deleted, err := s.DeleteExpiredTelegramLoginArtifacts(ctx, before, 100)
if err != nil {
t.Fatalf("delete expired artifacts: %v", err)
}
if deleted != 1 {
t.Fatalf("deleted = %d, want revoked request only", deleted)
}
if _, found, _ := s.GetTelegramLoginRequest(ctx, active.ID); !found {
t.Fatal("active authorization request was deleted")
}
if _, found, _ := s.GetTelegramLoginRequest(ctx, referenced.ID); !found {
t.Fatal("request with retained code was deleted")
}
if _, found, _ := s.GetTelegramLoginRequest(ctx, revoked.ID); found {
t.Fatal("old revoked authorization request was retained")
}
listed, err := s.ListTelegramLoginWebAuthorizations(ctx, activeWeb.UserID)
if err != nil || len(listed) != 1 || listed[0].Hash != activeWeb.Hash {
t.Fatalf("active authorizations after retention = %#v, %v", listed, err)
}
}

View file

@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
if err != nil {
t.Fatalf("migrate star gift lifecycle schema: %v", err)
}
if status.Dirty || status.Empty || status.Version != 124 {
t.Fatalf("migration status = %+v, want clean version 124", status)
if status.Dirty || status.Empty || status.Version != 125 {
t.Fatalf("migration status = %+v, want clean version 125", status)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,434 @@
package postgres
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
func telegramLoginPGHash(value string) []byte {
sum := sha256.Sum256([]byte(value))
return sum[:]
}
func TestTelegramLoginStorePostgresAtomicStateAndCodeConsumption(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Microsecond)
suffix := now.UnixNano() % 1_000_000_000
users := NewUserStore(pool)
bots := NewBotStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: suffix + 101,
Phone: fmt.Sprintf("1777%09d", suffix),
FirstName: "OIDC Owner",
})
if err != nil {
t.Fatalf("create oidc owner: %v", err)
}
bot, _, err := bots.CreateBotAccount(ctx, domain.User{
AccessHash: suffix + 102,
FirstName: "OIDC Test Bot",
Username: fmt.Sprintf("oidc_%09d_bot", suffix),
}, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "bot-secret"})
if err != nil {
t.Fatalf("create oidc bot: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id IN ($1,$2)", owner.ID, bot.ID)
})
store := NewTelegramLoginStore(pool)
client, err := store.UpsertTelegramLoginClient(ctx, domain.TelegramLoginClient{
BotUserID: bot.ID,
ClientID: fmt.Sprintf("%d", bot.ID),
SecretHash: telegramLoginPGHash("client-secret"),
SecretVersion: 1,
SigningAlgorithm: domain.TelegramLoginSigningRS256,
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatalf("upsert oidc client: %v", err)
}
redirectURI := fmt.Sprintf("https://rp-%d.example/callback", suffix)
origin := fmt.Sprintf("https://rp-%d.example", suffix)
if _, err := store.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{
BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedRedirectURI,
NormalizedURL: redirectURI, CreatedAt: now,
}); err != nil {
t.Fatalf("add redirect: %v", err)
}
if _, err := store.AddTelegramLoginAllowedURL(ctx, domain.TelegramLoginAllowedURL{
BotUserID: client.BotUserID, Kind: domain.TelegramLoginAllowedWebOrigin,
NormalizedURL: origin, CreatedAt: now,
}); err != nil {
t.Fatalf("add web origin: %v", err)
}
newRequest := func(label string) domain.TelegramLoginRequest {
request, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
RequestTokenHash: telegramLoginPGHash("request-" + label),
BrowserTokenHash: telegramLoginPGHash("browser-" + label),
BotUserID: bot.ID,
ClientID: client.ClientID,
SigningAlgorithm: client.SigningAlgorithm,
Source: domain.TelegramLoginRequestWeb,
ResponseType: "code",
RedirectURI: redirectURI,
Origin: origin,
Domain: fmt.Sprintf("rp-%d.example", suffix),
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile, domain.TelegramLoginScopeBotAccess},
State: "state",
Nonce: "nonce",
CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
CodeChallengeMethod: "S256",
Browser: "Firefox",
Platform: "Windows",
IP: "192.0.2.10",
Region: "Test Region",
MatchCodes: []string{"🟢", "🔵", "🟠"},
MatchCode: "🔵",
MatchCodesFirst: true,
Status: domain.TelegramLoginRequestPending,
CreatedAt: now,
ExpiresAt: now.Add(5 * time.Minute),
})
if err != nil {
t.Fatalf("create request %s: %v", label, err)
}
return request
}
request := newRequest(fmt.Sprintf("race-%d", suffix))
start := make(chan struct{})
errs := make(chan error, 2)
go func() {
<-start
_, _, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
RequestID: request.ID,
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
WriteAllowed: true,
MatchCode: request.MatchCode, ApprovedAt: now.Add(time.Second),
}, suffix+10_000)
errs <- err
}()
go func() {
<-start
_, err := store.DeclineTelegramLoginRequest(ctx, request.ID, owner.ID, now.Add(time.Second))
errs <- err
}()
close(start)
var success, conflict int
for range 2 {
err := <-errs
switch {
case err == nil:
success++
case errors.Is(err, domain.ErrTelegramLoginRequestConflict):
conflict++
default:
t.Fatalf("accept/decline race error: %v", err)
}
}
if success != 1 || conflict != 1 {
t.Fatalf("accept/decline success=%d conflict=%d, want 1/1", success, conflict)
}
codeRequest := newRequest(fmt.Sprintf("code-%d", suffix))
_, web, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
RequestID: codeRequest.ID,
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
WriteAllowed: true,
MatchCode: codeRequest.MatchCode, ApprovedAt: now.Add(2 * time.Second),
}, suffix+20_000)
if err != nil {
t.Fatalf("approve code request: %v", err)
}
canSend, err := bots.CanBotSendMessage(ctx, bot.ID, owner.ID)
if err != nil || !canSend {
t.Fatalf("bot access after atomic approval = %v,%v", canSend, err)
}
code := domain.TelegramLoginAuthorizationCode{
RequestID: codeRequest.ID,
CodeHash: telegramLoginPGHash(fmt.Sprintf("code-%d", suffix)),
SealedCode: append(make([]byte, 32), 1),
SealNonce: make([]byte, 12),
SealKeyID: "integration-key",
IssuedAt: now.Add(3 * time.Second),
ExpiresAt: now.Add(time.Minute),
}
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, code); err != nil {
t.Fatalf("put code: %v", err)
}
exchange := domain.TelegramLoginCodeExchange{
CodeHash: code.CodeHash, ClientID: client.ClientID, ClientSecretVersion: client.SecretVersion,
RedirectURI: codeRequest.RedirectURI, CodeChallenge: codeRequest.CodeChallenge, Now: now.Add(4 * time.Second),
}
start = make(chan struct{})
errs = make(chan error, 8)
var wg sync.WaitGroup
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, _, _, err := store.ConsumeTelegramLoginAuthorizationCode(ctx, exchange)
errs <- err
}()
}
close(start)
wg.Wait()
close(errs)
success, conflict = 0, 0
for err := range errs {
switch {
case err == nil:
success++
case errors.Is(err, domain.ErrTelegramLoginCodeConsumed):
conflict++
default:
t.Fatalf("code consume race error: %v", err)
}
}
if success != 1 || conflict != 7 {
t.Fatalf("code consume success=%d consumed=%d, want 1/7", success, conflict)
}
miniRequest, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
RequestTokenHash: telegramLoginPGHash(fmt.Sprintf("mini-request-%d", suffix)),
BrowserTokenHash: telegramLoginPGHash(fmt.Sprintf("mini-browser-%d", suffix)),
BotUserID: bot.ID, ClientID: client.ClientID, SigningAlgorithm: client.SigningAlgorithm,
Source: domain.TelegramLoginRequestMiniApp, ResponseType: "post_message",
RedirectURI: origin + "/", Origin: origin, InAppOrigin: origin, Domain: fmt.Sprintf("rp-%d.example", suffix),
Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile},
Browser: "Telegram Mini App", Platform: "Telegram Mini App", IP: "192.0.2.11", Region: "Test Region",
MatchCodes: []string{"🟢", "🔵", "🟠"}, MatchCode: "🔵", MatchCodesFirst: true,
Status: domain.TelegramLoginRequestPending, CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute),
})
if err != nil {
t.Fatalf("create mini-app request: %v", err)
}
if _, _, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
RequestID: miniRequest.ID,
Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: owner.FirstName, GivenName: owner.FirstName},
MatchCode: miniRequest.MatchCode, ApprovedAt: now.Add(5 * time.Second),
}, suffix+25_000); err != nil {
t.Fatalf("approve mini-app request: %v", err)
}
directToken := domain.TelegramLoginAuthorizationCode{
RequestID: miniRequest.ID, CodeHash: telegramLoginPGHash(fmt.Sprintf("mini-token-%d", suffix)),
SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "integration-key",
IssuedAt: now.Add(6 * time.Second), ExpiresAt: now.Add(time.Minute),
}
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, directToken); err != nil {
t.Fatalf("put mini-app token: %v", err)
}
start = make(chan struct{})
errs = make(chan error, 8)
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, _, _, err := store.ConsumeTelegramLoginDirectToken(ctx, directToken.CodeHash, origin, now.Add(7*time.Second))
errs <- err
}()
}
close(start)
wg.Wait()
close(errs)
success, conflict = 0, 0
for err := range errs {
switch {
case err == nil:
success++
case errors.Is(err, domain.ErrTelegramLoginCodeConsumed):
conflict++
default:
t.Fatalf("mini-app token consume race error: %v", err)
}
}
if success != 1 || conflict != 7 {
t.Fatalf("mini-app token consume success=%d consumed=%d, want 1/7", success, conflict)
}
if revoked, err := store.RevokeTelegramLoginWebAuthorization(ctx, owner.ID, web.Hash, now.Add(5*time.Second)); err != nil || !revoked {
t.Fatalf("revoke web authorization = %v,%v", revoked, err)
}
if listed, err := store.ListTelegramLoginWebAuthorizations(ctx, owner.ID); err != nil {
t.Fatalf("list web authorizations: %v", err)
} else {
for _, got := range listed {
if got.Hash == web.Hash {
t.Fatalf("revoked web authorization still listed: %#v", got)
}
}
}
assertTelegramLoginConfigDeleteTakesClientLock(t, pool, client.BotUserID, func() (bool, error) {
return store.DeleteTelegramLoginAllowedURL(ctx, client.BotUserID, domain.TelegramLoginAllowedRedirectURI, redirectURI)
})
}
func TestTelegramLoginStorePostgresNativeCallbackAndRetention(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Microsecond)
suffix := now.UnixNano() % 1_000_000_000
users := NewUserStore(pool)
bots := NewBotStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: suffix + 301, Phone: fmt.Sprintf("1666%09d", suffix), FirstName: "Native Owner",
})
if err != nil {
t.Fatal(err)
}
bot, _, err := bots.CreateBotAccount(ctx, domain.User{
AccessHash: suffix + 302, FirstName: "Native Login Bot", Username: fmt.Sprintf("native_%09d_bot", suffix),
}, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "native-bot-secret"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id IN ($1,$2)", owner.ID, bot.ID) })
store := NewTelegramLoginStore(pool)
client, err := store.CreateTelegramLoginClient(ctx, domain.TelegramLoginClient{
BotUserID: bot.ID, ClientID: fmt.Sprintf("%d", bot.ID), SecretHash: telegramLoginPGHash("native-secret"),
SecretVersion: 1, SigningAlgorithm: domain.TelegramLoginSigningRS256, Enabled: true,
CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
const callbackURI = "bedolaga://telegram-login"
nativeApp, err := store.UpsertTelegramLoginNativeApp(ctx, domain.TelegramLoginNativeApp{
BotUserID: bot.ID, Platform: domain.TelegramLoginNativeAndroid, ApplicationID: "dev.bedolaga.demo",
VerificationID: strings.Repeat("A", 64), CallbackURI: callbackURI, VerifiedDisplayName: "Bedolaga Demo",
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
createRequest := func(label string) domain.TelegramLoginRequest {
t.Helper()
request, err := store.CreateTelegramLoginRequest(ctx, domain.TelegramLoginRequest{
RequestTokenHash: telegramLoginPGHash("native-request-" + label), BrowserTokenHash: telegramLoginPGHash("native-browser-" + label),
BotUserID: bot.ID, ClientID: client.ClientID, SigningAlgorithm: client.SigningAlgorithm,
Source: domain.TelegramLoginRequestNative, ResponseType: "code", RedirectURI: callbackURI,
Domain: "dev.bedolaga.demo", Scopes: []domain.TelegramLoginScope{domain.TelegramLoginScopeOpenID, domain.TelegramLoginScopeProfile},
CodeChallenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", CodeChallengeMethod: "S256",
Browser: "TelegramLogin/Android", Platform: "Android", IP: "192.0.2.20", Region: "Test Region",
IsApp: true, VerifiedAppName: "Bedolaga Demo", MatchCodes: []string{}, Status: domain.TelegramLoginRequestPending,
CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute),
})
if err != nil {
t.Fatalf("create native request: %v", err)
}
return request
}
approve := func(request domain.TelegramLoginRequest, hash int64) domain.TelegramLoginWebAuthorization {
t.Helper()
_, web, err := store.ApproveTelegramLoginRequest(ctx, domain.TelegramLoginApproval{
RequestID: request.ID, Identity: domain.TelegramLoginIdentitySnapshot{UserID: owner.ID, Name: "Native Owner", GivenName: "Native"},
ApprovedAt: now.Add(time.Second),
}, hash)
if err != nil {
t.Fatalf("approve native request: %v", err)
}
return web
}
revokedRequest := createRequest(fmt.Sprintf("revoked-%d", suffix))
revokedWeb := approve(revokedRequest, suffix+30_000)
code := domain.TelegramLoginAuthorizationCode{
RequestID: revokedRequest.ID, CodeHash: telegramLoginPGHash(fmt.Sprintf("native-code-%d", suffix)),
SealedCode: append(make([]byte, 32), 1), SealNonce: make([]byte, 12), SealKeyID: "integration-key",
IssuedAt: now.Add(2 * time.Second), ExpiresAt: now.Add(time.Minute),
}
if _, err := store.PutTelegramLoginAuthorizationCode(ctx, code); err != nil {
t.Fatal(err)
}
if _, _, _, err := store.ConsumeTelegramLoginAuthorizationCode(ctx, domain.TelegramLoginCodeExchange{
CodeHash: code.CodeHash, ClientID: client.ClientID, ClientSecretVersion: client.SecretVersion,
RedirectURI: callbackURI, CodeChallenge: revokedRequest.CodeChallenge, Now: now.Add(3 * time.Second),
}); err != nil {
t.Fatalf("consume native code: %v", err)
}
if ok, err := store.RevokeTelegramLoginWebAuthorization(ctx, owner.ID, revokedWeb.Hash, now.Add(4*time.Second)); err != nil || !ok {
t.Fatalf("revoke native authorization = %v,%v", ok, err)
}
activeRequest := createRequest(fmt.Sprintf("active-%d", suffix))
activeWeb := approve(activeRequest, suffix+40_000)
deleted, err := store.DeleteExpiredTelegramLoginArtifacts(ctx, now.Add(2*time.Hour), 100)
if err != nil {
t.Fatal(err)
}
if deleted < 2 {
t.Fatalf("retention deleted=%d, want at least code and revoked request", deleted)
}
if _, found, _ := store.GetTelegramLoginRequest(ctx, revokedRequest.ID); found {
t.Fatal("revoked native request survived retention")
}
if _, found, _ := store.GetTelegramLoginRequest(ctx, activeRequest.ID); !found {
t.Fatal("active native request was deleted")
}
listed, err := store.ListTelegramLoginWebAuthorizations(ctx, owner.ID)
if err != nil || len(listed) != 1 || listed[0].Hash != activeWeb.Hash {
t.Fatalf("active authorization list=%#v err=%v", listed, err)
}
assertTelegramLoginConfigDeleteTakesClientLock(t, pool, client.BotUserID, func() (bool, error) {
return store.DeleteTelegramLoginNativeApp(ctx, client.BotUserID, nativeApp.ID)
})
}
func assertTelegramLoginConfigDeleteTakesClientLock(t *testing.T, pool *pgxpool.Pool, botUserID int64, remove func() (bool, error)) {
t.Helper()
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(ctx) }()
var lockedID int64
if err := tx.QueryRow(ctx, `SELECT bot_user_id FROM bot_login_clients WHERE bot_user_id = $1 FOR UPDATE`, botUserID).Scan(&lockedID); err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
deleted, err := remove()
if err == nil && !deleted {
err = errors.New("configuration row was not deleted")
}
result <- err
}()
select {
case err := <-result:
t.Fatalf("configuration delete bypassed client serialization lock: %v", err)
case <-time.After(150 * time.Millisecond):
}
if err := tx.Commit(ctx); err != nil {
t.Fatal(err)
}
select {
case err := <-result:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("configuration delete remained blocked after client lock committed")
}
}

View file

@ -0,0 +1,49 @@
package store
import (
"context"
"time"
"telesrv/internal/domain"
)
// TelegramLoginStore is the single durable boundary shared by the HTTP OIDC
// adapter, MTProto URL-authorization RPCs and account Web-authorization RPCs.
// Implementations must use compare-and-set transitions and must not treat an
// in-memory cache as the source of truth.
type TelegramLoginStore interface {
CreateTelegramLoginClient(ctx context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error)
UpsertTelegramLoginClient(ctx context.Context, client domain.TelegramLoginClient) (domain.TelegramLoginClient, error)
GetTelegramLoginClient(ctx context.Context, clientID string) (domain.TelegramLoginClient, bool, error)
GetTelegramLoginClientByBot(ctx context.Context, botUserID int64) (domain.TelegramLoginClient, bool, error)
RotateTelegramLoginClientSecret(ctx context.Context, botUserID, expectedVersion int64, secretHash []byte, now time.Time) (domain.TelegramLoginClient, error)
SetTelegramLoginClientSigningAlgorithm(ctx context.Context, botUserID int64, algorithm domain.TelegramLoginSigningAlgorithm, now time.Time) (domain.TelegramLoginClient, error)
SetTelegramLoginClientEnabled(ctx context.Context, botUserID int64, enabled bool, now time.Time) error
AddTelegramLoginAllowedURL(ctx context.Context, allowed domain.TelegramLoginAllowedURL) (domain.TelegramLoginAllowedURL, error)
DeleteTelegramLoginAllowedURL(ctx context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error)
ListTelegramLoginAllowedURLs(ctx context.Context, botUserID int64) ([]domain.TelegramLoginAllowedURL, error)
IsTelegramLoginURLAllowed(ctx context.Context, botUserID int64, kind domain.TelegramLoginAllowedURLKind, normalizedURL string) (bool, error)
UpsertTelegramLoginNativeApp(ctx context.Context, app domain.TelegramLoginNativeApp) (domain.TelegramLoginNativeApp, error)
DeleteTelegramLoginNativeApp(ctx context.Context, botUserID, appID int64) (bool, error)
ListTelegramLoginNativeApps(ctx context.Context, botUserID int64) ([]domain.TelegramLoginNativeApp, error)
CreateTelegramLoginRequest(ctx context.Context, request domain.TelegramLoginRequest) (domain.TelegramLoginRequest, error)
GetTelegramLoginRequest(ctx context.Context, requestID int64) (domain.TelegramLoginRequest, bool, error)
GetTelegramLoginRequestByTokenHash(ctx context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error)
GetTelegramLoginRequestByBrowserTokenHash(ctx context.Context, tokenHash []byte) (domain.TelegramLoginRequest, bool, error)
ApproveTelegramLoginRequest(ctx context.Context, approval domain.TelegramLoginApproval, webAuthorizationHash int64) (domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error)
DeclineTelegramLoginRequest(ctx context.Context, requestID, userID int64, now time.Time) (domain.TelegramLoginRequest, error)
PutTelegramLoginAuthorizationCode(ctx context.Context, code domain.TelegramLoginAuthorizationCode) (domain.TelegramLoginAuthorizationCode, error)
GetTelegramLoginAuthorizationCodeByRequest(ctx context.Context, requestID int64) (domain.TelegramLoginAuthorizationCode, bool, error)
GetTelegramLoginAuthorizationCodeByHash(ctx context.Context, codeHash []byte) (domain.TelegramLoginAuthorizationCode, bool, error)
ConsumeTelegramLoginAuthorizationCode(ctx context.Context, exchange domain.TelegramLoginCodeExchange) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error)
ConsumeTelegramLoginDirectToken(ctx context.Context, tokenHash []byte, origin string, now time.Time) (domain.TelegramLoginAuthorizationCode, domain.TelegramLoginRequest, domain.TelegramLoginWebAuthorization, error)
ListTelegramLoginWebAuthorizations(ctx context.Context, userID int64) ([]domain.TelegramLoginWebAuthorization, error)
RevokeTelegramLoginWebAuthorization(ctx context.Context, userID, hash int64, now time.Time) (bool, error)
RevokeAllTelegramLoginWebAuthorizations(ctx context.Context, userID int64, now time.Time) (int64, error)
DeleteExpiredTelegramLoginArtifacts(ctx context.Context, before time.Time, limit int) (int64, error)
}

View file

@ -0,0 +1,748 @@
// Package telegramloginhttp is the public HTTP adapter for Telegram Login and
// OpenID Connect. It contains protocol parsing/rendering only; durable state
// and authorization transitions remain in app/telegramlogin and its store.
package telegramloginhttp
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"mime"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
"unicode/utf8"
"go.uber.org/zap"
loginapp "telesrv/internal/app/telegramlogin"
"telesrv/internal/domain"
)
const (
maxAuthorizationQueryBytes = 16 << 10
maxTokenFormBytes = 16 << 10
maxStatusFormBytes = 4 << 10
)
type Config struct {
Service *loginapp.Service
Tokens *loginapp.IDTokenIssuer
Limiter RateLimiter
AppName string
Logger *zap.Logger
TrustedProxyCIDRs []string
AllowLoopbackHTTP bool
}
type Handler struct {
service *loginapp.Service
tokens *loginapp.IDTokenIssuer
appName string
logger *zap.Logger
limiter RateLimiter
trustedProxies []netip.Prefix
allowLoopbackHTTP bool
mux *http.ServeMux
}
type RateLimiter interface {
Allow(ctx context.Context, key string, limit int, window time.Duration) (allowed bool, retryAfterSeconds int, err error)
}
func NewHandler(cfg Config) (*Handler, error) {
if cfg.Service == nil || cfg.Tokens == nil || cfg.Tokens.Issuer() == "" {
return nil, errors.New("telegram login HTTP dependencies are incomplete")
}
if strings.TrimSpace(cfg.AppName) == "" {
cfg.AppName = "Telesrv"
}
if cfg.Logger == nil {
cfg.Logger = zap.NewNop()
}
trustedProxies := make([]netip.Prefix, 0, len(cfg.TrustedProxyCIDRs))
for _, raw := range cfg.TrustedProxyCIDRs {
prefix, err := netip.ParsePrefix(strings.TrimSpace(raw))
if err != nil {
return nil, fmt.Errorf("telegram login trusted proxy CIDR %q: %w", raw, err)
}
trustedProxies = append(trustedProxies, prefix.Masked())
}
h := &Handler{service: cfg.Service, tokens: cfg.Tokens, appName: strings.TrimSpace(cfg.AppName), logger: cfg.Logger, limiter: cfg.Limiter, trustedProxies: trustedProxies, allowLoopbackHTTP: cfg.AllowLoopbackHTTP}
mux := http.NewServeMux()
mux.HandleFunc("GET /.well-known/openid-configuration", h.discovery)
mux.HandleFunc("GET /.well-known/jwks.json", h.jwks)
mux.HandleFunc("GET /auth", h.authorize)
mux.HandleFunc("GET /crossapp", h.crossApp)
mux.HandleFunc("GET /inapp", h.inApp)
mux.HandleFunc("POST /auth/status", h.authorizationStatus)
mux.HandleFunc("POST /token", h.token)
mux.HandleFunc("GET /telegram-login.js", h.loginJavaScript)
mux.HandleFunc("GET /js/telegram-login.js", h.loginJavaScript)
h.mux = mux
return h, nil
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin-allow-popups")
h.mux.ServeHTTP(w, r)
}
type discoveryDocument struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSURI string `json:"jwks_uri"`
ScopesSupported []string `json:"scopes_supported"`
ResponseTypesSupported []string `json:"response_types_supported"`
ResponseModesSupported []string `json:"response_modes_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
SubjectTypesSupported []string `json:"subject_types_supported"`
IDTokenSigningAlgorithms []string `json:"id_token_signing_alg_values_supported"`
TokenEndpointAuthMethods []string `json:"token_endpoint_auth_methods_supported"`
ClaimsSupported []string `json:"claims_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
}
func (h *Handler) discovery(w http.ResponseWriter, _ *http.Request) {
issuer := h.tokens.Issuer()
writeJSON(w, http.StatusOK, discoveryDocument{
Issuer: issuer, AuthorizationEndpoint: issuer + "/auth", TokenEndpoint: issuer + "/token",
JWKSURI: issuer + "/.well-known/jwks.json",
ScopesSupported: []string{"openid", "profile", "phone", "telegram:bot_access"},
ResponseTypesSupported: []string{"code"}, ResponseModesSupported: []string{"query"},
GrantTypesSupported: []string{"authorization_code"}, SubjectTypesSupported: []string{"public"},
IDTokenSigningAlgorithms: h.tokens.SupportedAlgorithms(),
TokenEndpointAuthMethods: []string{"client_secret_basic", "client_secret_post", "none"},
ClaimsSupported: []string{
"iss", "aud", "sub", "iat", "exp", "nonce", "id", "name", "given_name",
"family_name", "preferred_username", "picture", "phone_number", "phone_number_verified",
},
CodeChallengeMethodsSupported: []string{"S256"},
})
}
func (h *Handler) jwks(w http.ResponseWriter, r *http.Request) {
body, etag, err := h.tokens.JWKS()
if err != nil {
h.logger.Error("telegram_login_jwks_failed", zap.Error(err))
writeOAuthError(w, http.StatusInternalServerError, "server_error", "key service unavailable")
return
}
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
w.Header().Set("ETag", etag)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
type authorizationPageData struct {
AppName string
DeepLink template.URL
MatchCode string
BrowserToken string
ExpiresAt string
CSPNonce string
ResponseType string
TargetOrigin string
}
var authorizationPage = template.Must(template.New("telegram-login").Parse(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Log in with {{.AppName}}</title><style>
:root{color-scheme:light dark}body{font:16px/1.45 system-ui,sans-serif;margin:0;background:#17212b;color:#fff}.card{max-width:460px;margin:10vh auto;padding:28px;border-radius:18px;background:#202b36;box-shadow:0 16px 48px #0006}h1{margin:.1em 0 .5em}.button{display:block;text-align:center;margin:24px 0;padding:13px 18px;border-radius:11px;background:#2aabee;color:#fff;text-decoration:none;font-weight:700}.match{text-align:center;margin:22px 0;padding:18px;border-radius:14px;background:#17212b}.match p{margin:0 0 8px}.match-code{font-size:44px;line-height:1.2}.muted{color:#a9b5c1;font-size:14px}.error{color:#ff8d8d}</style></head>
<body><main class="card"><h1>Log in with {{.AppName}}</h1><p>Open the {{.AppName}} app and approve this request. Keep this page open.</p><a class="button" href="{{.DeepLink}}">Open {{.AppName}}</a>{{if .MatchCode}}<section class="match" aria-labelledby="match-title"><p id="match-title">When prompted, select this emoji in {{.AppName}}:</p><div id="match-code" class="match-code" role="img" aria-label="Matching emoji">{{.MatchCode}}</div></section>{{end}}<p id="status" class="muted">Waiting for approval</p><p class="muted">This request expires at {{.ExpiresAt}}.</p></main>
<script nonce="{{.CSPNonce}}">const token={{.BrowserToken}},responseType={{.ResponseType}},targetOrigin={{.TargetOrigin}};const statusNode=document.getElementById('status');function deliver(data){if(!window.opener||!targetOrigin)throw new Error('missing_opener');const payload=data.id_token?{event:'auth_result',result:data.id_token}:{event:'auth_result',error:data.error||data.status};window.opener.postMessage(payload,targetOrigin);window.close()}async function poll(){try{const body=new URLSearchParams({browser_token:token});const response=await fetch('/auth/status',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body,cache:'no-store'});const data=await response.json();if(!response.ok){throw new Error(data.error||'request_failed')}if(data.status==='pending'){setTimeout(poll,1000);return}if(responseType==='post_message'){deliver(data);return}if(data.redirect_url){location.replace(data.redirect_url);return}throw new Error('invalid_response')}catch(error){statusNode.className='error';statusNode.textContent='Login status unavailable. Please restart the login flow.'}}poll();</script></body></html>`))
func (h *Handler) authorize(w http.ResponseWriter, r *http.Request) {
clientIP := h.requestIP(r)
if !h.allow(w, r, "authorize", clientIP, 30, time.Minute) {
return
}
if len(r.URL.RawQuery) > maxAuthorizationQueryBytes {
h.authorizationRequestError(w)
return
}
query := r.URL.Query()
nativePlatform, nativeMarkerOK := nativeSDKPlatform(query)
if !nativeMarkerOK {
h.authorizationRequestError(w)
return
}
values := make(map[string]string, 9)
for _, key := range []string{"client_id", "redirect_uri", "response_type", "scope", "state", "nonce", "code_challenge", "code_challenge_method", "origin"} {
value, ok := singleValue(query, key)
if !ok {
h.authorizationRequestError(w)
return
}
values[key] = value
}
platformLabel := "Web"
if nativePlatform == domain.TelegramLoginNativeIOS {
platformLabel = "iOS"
} else if nativePlatform == domain.TelegramLoginNativeAndroid {
platformLabel = "Android"
}
source := domain.TelegramLoginRequestWeb
if values["response_type"] == "post_message" {
source = domain.TelegramLoginRequestJavaScript
}
created, err := h.service.CreateAuthorization(r.Context(), loginapp.CreateAuthorizationParams{
ClientID: values["client_id"], RedirectURI: values["redirect_uri"], ResponseType: values["response_type"],
Scope: values["scope"], State: values["state"], Nonce: values["nonce"],
CodeChallenge: values["code_challenge"], CodeChallengeMethod: values["code_challenge_method"],
Origin: values["origin"],
Source: source, NativePlatform: nativePlatform,
Browser: boundedHeader(r.UserAgent(), "Unknown browser", 255),
Platform: platformLabel, IP: clientIP, Region: "Unknown region", IncludeMatchCodes: true, MatchCodesFirst: true,
})
if err != nil {
h.logger.Info("telegram_login_authorize_rejected", zap.String("error", errorClass(err)))
h.authorizationError(w, r, values, err)
return
}
cspNonce, err := loginapp.GenerateOpaqueToken()
if err != nil {
h.logger.Error("telegram_login_csp_nonce_failed", zap.Error(err))
h.authorizationRequestError(w)
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-"+cspNonce+"'; connect-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if err := authorizationPage.Execute(w, authorizationPageData{
AppName: h.appName, DeepLink: template.URL(created.DeepLink), MatchCode: created.Request.MatchCode, BrowserToken: created.BrowserToken,
ExpiresAt: created.Request.ExpiresAt.UTC().Format(time.RFC3339), CSPNonce: cspNonce,
ResponseType: created.Request.ResponseType, TargetOrigin: created.Request.Origin,
}); err != nil {
h.logger.Warn("telegram_login_authorize_render_failed", zap.Error(err))
}
}
func (h *Handler) crossApp(w http.ResponseWriter, r *http.Request) {
clientIP := h.requestIP(r)
if !h.allow(w, r, "crossapp", clientIP, 30, time.Minute) {
return
}
if len(r.URL.RawQuery) > maxAuthorizationQueryBytes {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid native login request")
return
}
query := r.URL.Query()
platform, ok := nativeSDKPlatform(query)
if !ok || !platform.Valid() {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "exactly one native SDK marker is required")
return
}
values := make(map[string]string, 8)
for _, key := range []string{"client_id", "redirect_uri", "response_type", "scope", "state", "nonce", "code_challenge", "code_challenge_method"} {
value, unique := singleValue(query, key)
if !unique {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "duplicate parameter")
return
}
values[key] = value
}
platformLabel := "iOS"
if platform == domain.TelegramLoginNativeAndroid {
platformLabel = "Android"
}
created, err := h.service.CreateAuthorization(r.Context(), loginapp.CreateAuthorizationParams{
ClientID: values["client_id"], RedirectURI: values["redirect_uri"], ResponseType: values["response_type"],
Scope: values["scope"], State: values["state"], Nonce: values["nonce"],
CodeChallenge: values["code_challenge"], CodeChallengeMethod: values["code_challenge_method"],
Source: domain.TelegramLoginRequestNative, NativePlatform: platform,
Browser: boundedHeader(r.UserAgent(), "Native SDK", 255), Platform: platformLabel,
IP: clientIP, Region: "Unknown region", IncludeMatchCodes: true, MatchCodesFirst: true,
})
if err != nil {
h.logger.Info("telegram_login_crossapp_rejected", zap.String("error", errorClass(err)))
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "native login request is not registered")
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
writeJSON(w, http.StatusOK, map[string]string{"url": created.DeepLink})
}
func (h *Handler) inApp(w http.ResponseWriter, r *http.Request) {
clientIP := h.requestIP(r)
if !h.allow(w, r, "inapp", clientIP, 60, time.Minute) {
return
}
if len(r.URL.RawQuery) > maxAuthorizationQueryBytes {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid in-app login request")
return
}
query := r.URL.Query()
if rawCode, exists := query["code"]; exists {
if len(query) != 1 || len(rawCode) != 1 || rawCode[0] == "" {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid in-app token")
return
}
issued, err := h.service.ExchangeInAppTokenAndIssue(r.Context(), rawCode[0], r.Header.Get("Origin"), h.tokens)
if err != nil {
h.logger.Info("telegram_login_inapp_exchange_rejected", zap.String("error", errorClass(err)))
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "in-app token is invalid or expired")
return
}
setInAppCORS(w, issued.Request.InAppOrigin)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
writeJSON(w, http.StatusOK, map[string]string{"result": issued.IDToken})
return
}
if len(query) != 4 {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid in-app login request")
return
}
values := make(map[string]string, 4)
for _, key := range []string{"client_id", "scope", "origin", "response_type"} {
value, unique := singleValue(query, key)
if !unique || value == "" {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid in-app login request")
return
}
values[key] = value
}
if values["response_type"] != "id_token" {
writeOAuthError(w, http.StatusBadRequest, "unsupported_response_type", "only id_token is supported")
return
}
origin, err := loginapp.NormalizeWebOrigin(values["origin"], h.allowLoopbackHTTP)
if err != nil || r.Header.Get("Origin") != origin {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "in-app origin is invalid")
return
}
setInAppCORS(w, origin)
created, err := h.service.CreateAuthorization(r.Context(), loginapp.CreateAuthorizationParams{
ClientID: values["client_id"], RedirectURI: origin + "/", ResponseType: "post_message",
Scope: values["scope"], Origin: origin, InAppOrigin: origin,
Source: domain.TelegramLoginRequestMiniApp,
Browser: boundedHeader(r.UserAgent(), "Telegram Mini App", 255), Platform: "Telegram Mini App",
IP: clientIP, Region: "Unknown region", IncludeMatchCodes: true, MatchCodesFirst: true,
})
if err != nil {
h.logger.Info("telegram_login_inapp_rejected", zap.String("error", errorClass(err)))
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "in-app login request is not registered")
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
writeJSON(w, http.StatusOK, map[string]string{"url": created.DeepLink})
}
func setInAppCORS(w http.ResponseWriter, origin string) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
}
func nativeSDKPlatform(values url.Values) (domain.TelegramLoginNativePlatform, bool) {
ios, iosUnique := singleValue(values, "ios_sdk")
android, androidUnique := singleValue(values, "android_sdk")
if !iosUnique || !androidUnique || (ios != "" && ios != "1") || (android != "" && android != "1") || (ios != "" && android != "") {
return "", false
}
if ios == "1" {
return domain.TelegramLoginNativeIOS, true
}
if android == "1" {
return domain.TelegramLoginNativeAndroid, true
}
return "", true
}
var authorizationErrorPage = template.Must(template.New("telegram-login-error").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Telegram Login</title></head><body><p>Login could not be started.</p><script nonce="{{.Nonce}}">if(window.opener){window.opener.postMessage({event:'auth_result',error:{{.Error}}},{{.Origin}});window.close();}</script></body></html>`))
func (h *Handler) authorizationError(w http.ResponseWriter, r *http.Request, values map[string]string, cause error) {
target, safe, err := h.service.ResolveAuthorizationErrorTarget(r.Context(), values["client_id"], values["response_type"], values["redirect_uri"], values["origin"])
if err != nil || !safe {
if err != nil {
h.logger.Error("telegram_login_authorize_error_target_failed", zap.String("error", errorClass(err)))
}
h.authorizationRequestError(w)
return
}
code := "invalid_request"
switch {
case errors.Is(cause, domain.ErrTelegramLoginScopeInvalid):
code = "invalid_scope"
case values["response_type"] != "code" && values["response_type"] != "post_message":
code = "unsupported_response_type"
default:
// Known validation failures use invalid_request. Unknown failures are
// reported as server_error without exposing their details.
if strings.HasPrefix(errorClass(cause), "internal_") {
code = "server_error"
}
}
state := values["state"]
if len(state) > 2048 {
state = ""
}
if target.ResponseType == "code" {
redirectURL, err := loginapp.AppendAuthorizationError(target.RedirectURI, code, state)
if err != nil {
h.authorizationRequestError(w)
return
}
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
nonce, err := loginapp.GenerateOpaqueToken()
if err != nil {
h.authorizationRequestError(w)
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'nonce-"+nonce+"'; frame-ancestors 'none'; base-uri 'none'")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = authorizationErrorPage.Execute(w, struct {
Nonce string
Error string
Origin string
}{Nonce: nonce, Error: code, Origin: target.Origin})
}
func (h *Handler) authorizationRequestError(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
http.Error(w, "Invalid Telegram Login request.", http.StatusBadRequest)
}
func (h *Handler) authorizationStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
if !h.allow(w, r, "status", h.requestIP(r), 300, time.Minute) {
return
}
form, ok := parseBoundedForm(w, r, maxStatusFormBytes)
if !ok {
return
}
browserToken, unique := singleValue(form, "browser_token")
if !unique || browserToken == "" {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid browser request")
return
}
request, err := h.service.RequestByBrowserToken(r.Context(), browserToken)
if err != nil {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid browser request")
return
}
switch request.Status {
case domain.TelegramLoginRequestPending:
writeJSON(w, http.StatusOK, map[string]string{"status": "pending"})
case domain.TelegramLoginRequestApproved:
if request.ResponseType == "post_message" {
finalized, err := h.service.FinalizeDirectByBrowserToken(r.Context(), browserToken, h.tokens)
if err != nil {
h.logger.Error("telegram_login_direct_finalize_failed", zap.Int64("request_id", request.ID), zap.Error(err))
writeOAuthError(w, http.StatusInternalServerError, "server_error", "authorization could not be finalized")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "approved", "id_token": finalized.IDToken})
return
}
finalized, err := h.service.FinalizeByBrowserToken(r.Context(), browserToken)
if err != nil {
h.logger.Error("telegram_login_finalize_failed", zap.Int64("request_id", request.ID), zap.Error(err))
writeOAuthError(w, http.StatusInternalServerError, "server_error", "authorization could not be finalized")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "approved", "redirect_url": finalized.RedirectURL})
case domain.TelegramLoginRequestDeclined, domain.TelegramLoginRequestExpired:
errorCode := "access_denied"
status := "declined"
if request.Status == domain.TelegramLoginRequestExpired {
errorCode, status = "temporarily_unavailable", "expired"
}
if request.ResponseType == "post_message" {
writeJSON(w, http.StatusOK, map[string]string{"status": status, "error": errorCode})
return
}
redirectURL, err := loginapp.AppendAuthorizationError(request.RedirectURI, errorCode, request.State)
if err != nil {
writeOAuthError(w, http.StatusInternalServerError, "server_error", "authorization could not be finalized")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": status, "redirect_url": redirectURL})
default:
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid browser request")
}
}
func (h *Handler) token(w http.ResponseWriter, r *http.Request) {
if !h.allow(w, r, "token-ip", h.requestIP(r), 60, time.Minute) {
return
}
form, ok := parseBoundedForm(w, r, maxTokenFormBytes)
if !ok {
return
}
authorizationHeaders := r.Header.Values("Authorization")
var clientID, clientSecret string
var publicNativeClient bool
switch len(authorizationHeaders) {
case 0:
var unique bool
clientID, unique = requiredSingleValue(form, "client_id", 64)
if !unique {
h.invalidClient(w)
return
}
clientSecret, unique = optionalSingleValue(form, "client_secret")
if !unique || len(clientSecret) > 1024 {
h.invalidClient(w)
return
}
publicNativeClient = clientSecret == ""
case 1:
var ok bool
clientID, clientSecret, ok = r.BasicAuth()
if !ok || clientID == "" || clientSecret == "" || len(clientID) > 64 || len(clientSecret) > 1024 {
h.invalidClient(w)
return
}
if _, supplied := form["client_secret"]; supplied {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "multiple client authentication methods are not allowed")
return
}
default:
h.invalidClient(w)
return
}
if !h.allow(w, r, "token-client", clientID, 60, time.Minute) {
return
}
formClientID, unique := optionalSingleValue(form, "client_id")
if !unique || (formClientID != "" && formClientID != clientID) {
h.invalidClient(w)
return
}
grantType, okGrant := requiredSingleValue(form, "grant_type", 64)
if !okGrant {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "grant_type is required")
return
}
if grantType != "authorization_code" {
writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type", "only authorization_code is supported")
return
}
code, okCode := requiredSingleValue(form, "code", 1024)
redirectURI, okRedirect := requiredSingleValue(form, "redirect_uri", 4096)
codeVerifier, okVerifier := requiredSingleValue(form, "code_verifier", 128)
if !okCode || !okRedirect || !okVerifier {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "code, redirect_uri and code_verifier are required")
return
}
// Generate every response artifact before consuming the one-time code. A
// transient entropy failure must leave the grant retryable.
accessToken, err := loginapp.GenerateOpaqueToken()
if err != nil {
h.logger.Error("telegram_login_access_token_failed", zap.Error(err))
writeOAuthError(w, http.StatusInternalServerError, "server_error", "token service unavailable")
return
}
issued, err := h.service.ExchangeAuthorizationCodeAndIssue(r.Context(), loginapp.ExchangeAuthorizationCodeParams{
Code: code, ClientID: clientID, ClientSecret: clientSecret, RedirectURI: redirectURI, CodeVerifier: codeVerifier,
PublicNativeClient: publicNativeClient,
}, h.tokens)
if err != nil {
switch {
case errors.Is(err, domain.ErrTelegramLoginSecretInvalid), errors.Is(err, domain.ErrTelegramLoginClientDisabled):
h.invalidClient(w)
case errors.Is(err, domain.ErrTelegramLoginCodeInvalid), errors.Is(err, domain.ErrTelegramLoginCodeConsumed),
errors.Is(err, domain.ErrTelegramLoginPKCEInvalid), errors.Is(err, domain.ErrTelegramLoginURLInvalid):
writeOAuthError(w, http.StatusBadRequest, "invalid_grant", "authorization code is invalid or expired")
default:
h.logger.Error("telegram_login_token_exchange_failed", zap.String("error", errorClass(err)))
writeOAuthError(w, http.StatusInternalServerError, "server_error", "token service unavailable")
}
return
}
_ = issued.WebAuthorization // durable authorization is intentionally not encoded into the opaque access token.
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
writeJSON(w, http.StatusOK, map[string]any{
"access_token": accessToken, "token_type": "Bearer", "expires_in": int64(h.tokens.TTL().Seconds()),
"id_token": issued.IDToken,
})
}
func (h *Handler) invalidClient(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="telegram-login-token", charset="UTF-8"`)
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "client authentication failed")
}
func parseBoundedForm(w http.ResponseWriter, r *http.Request, maxBytes int64) (url.Values, bool) {
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mediaType != "application/x-www-form-urlencoded" {
writeOAuthError(w, http.StatusUnsupportedMediaType, "invalid_request", "form content type is required")
return nil, false
}
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
body, err := io.ReadAll(r.Body)
if err != nil {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "request body is invalid")
return nil, false
}
form, err := url.ParseQuery(string(body))
if err != nil {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "request body is invalid")
return nil, false
}
return form, true
}
func singleValue(values url.Values, key string) (string, bool) {
items, exists := values[key]
if !exists {
return "", true
}
if len(items) != 1 {
return "", false
}
return items[0], true
}
func optionalSingleValue(values url.Values, key string) (string, bool) {
value, ok := singleValue(values, key)
return value, ok
}
func requiredSingleValue(values url.Values, key string, max int) (string, bool) {
value, ok := singleValue(values, key)
return value, ok && value != "" && len(value) <= max
}
func boundedHeader(value, fallback string, max int) string {
value = strings.TrimSpace(strings.ToValidUTF8(value, "<22>"))
if value == "" {
value = fallback
}
for len(value) > max {
_, size := utf8.DecodeLastRuneInString(value)
value = value[:len(value)-size]
}
return value
}
func (h *Handler) requestIP(r *http.Request) string {
remote, ok := parseRequestIP(r.RemoteAddr)
if !ok {
return "Unknown IP"
}
if !prefixContains(h.trustedProxies, remote) {
return remote.String()
}
forwarded := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
for i := len(forwarded) - 1; i >= 0; i-- {
candidate, ok := parseRequestIP(strings.TrimSpace(forwarded[i]))
if !ok {
continue
}
remote = candidate
if !prefixContains(h.trustedProxies, candidate) {
return candidate.String()
}
}
if candidate, ok := parseRequestIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); ok {
return candidate.String()
}
return remote.String()
}
func parseRequestIP(raw string) (netip.Addr, bool) {
if addrPort, err := netip.ParseAddrPort(raw); err == nil {
return addrPort.Addr().Unmap(), true
}
if host, _, err := net.SplitHostPort(raw); err == nil {
raw = host
}
addr, err := netip.ParseAddr(strings.Trim(raw, "[]"))
return addr.Unmap(), err == nil
}
func prefixContains(prefixes []netip.Prefix, addr netip.Addr) bool {
for _, prefix := range prefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
func (h *Handler) allow(w http.ResponseWriter, r *http.Request, bucket, subject string, limit int, window time.Duration) bool {
if h.limiter == nil {
return true
}
sum := sha256.Sum256([]byte(subject))
key := "telegram-login-http:" + bucket + ":" + base64.RawURLEncoding.EncodeToString(sum[:])
allowed, retryAfter, err := h.limiter.Allow(r.Context(), key, limit, window)
if err != nil {
h.logger.Error("telegram_login_rate_limit_failed", zap.String("bucket", bucket), zap.Error(err))
writeOAuthError(w, http.StatusServiceUnavailable, "temporarily_unavailable", "login service temporarily unavailable")
return false
}
if allowed {
return true
}
if retryAfter <= 0 {
retryAfter = 1
}
w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
writeOAuthError(w, http.StatusTooManyRequests, "temporarily_unavailable", "too many login requests")
return false
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeOAuthError(w http.ResponseWriter, status int, code, description string) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
writeJSON(w, status, map[string]string{"error": code, "error_description": description})
}
func errorClass(err error) string {
for _, candidate := range []struct {
target error
name string
}{
{domain.ErrTelegramLoginClientInvalid, "client_invalid"},
{domain.ErrTelegramLoginClientDisabled, "client_disabled"},
{domain.ErrTelegramLoginRedirectNotAllowed, "redirect_not_allowed"},
{domain.ErrTelegramLoginOriginNotAllowed, "origin_not_allowed"},
{domain.ErrTelegramLoginScopeInvalid, "scope_invalid"},
{domain.ErrTelegramLoginPKCEInvalid, "pkce_invalid"},
{domain.ErrTelegramLoginRequestInvalid, "request_invalid"},
} {
if errors.Is(err, candidate.target) {
return candidate.name
}
}
return fmt.Sprintf("internal_%T", err)
}

View file

@ -0,0 +1,675 @@
package telegramloginhttp
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"html"
"io"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"regexp"
"strings"
"sync"
"testing"
"time"
"unicode/utf8"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
loginapp "telesrv/internal/app/telegramlogin"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestRequestIPTrustsForwardingHeadersOnlyFromConfiguredProxies(t *testing.T) {
h := &Handler{trustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32"), netip.MustParsePrefix("10.0.0.0/8")}}
req := httptest.NewRequest(http.MethodGet, "https://oauth.test/auth", nil)
req.RemoteAddr = "127.0.0.1:44321"
req.Header.Set("X-Forwarded-For", "198.51.100.7, 10.0.0.4")
if got := h.requestIP(req); got != "198.51.100.7" {
t.Fatalf("trusted proxy client IP = %q", got)
}
req.RemoteAddr = "203.0.113.9:44321"
req.Header.Set("X-Forwarded-For", "198.51.100.8")
if got := h.requestIP(req); got != "203.0.113.9" {
t.Fatalf("untrusted spoofed client IP = %q", got)
}
}
func TestBoundedHeaderPreservesValidUTF8AtByteLimit(t *testing.T) {
got := boundedHeader(strings.Repeat("界", 100)+string([]byte{0xff}), "fallback", 255)
if !utf8.ValidString(got) || len(got) > 255 || got == "" {
t.Fatalf("bounded header len=%d valid=%v value=%q", len(got), utf8.ValidString(got), got)
}
}
type telegramLoginHTTPFixture struct {
handler *Handler
service *loginapp.Service
credentials loginapp.ClientCredentials
redirectURI string
verifier string
challenge string
now *time.Time
}
type telegramLoginHTTPDenyLimiter struct{}
func (telegramLoginHTTPDenyLimiter) Allow(context.Context, string, int, time.Duration) (bool, int, error) {
return false, 17, nil
}
func newTelegramLoginHTTPFixture(t *testing.T) telegramLoginHTTPFixture {
t.Helper()
now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
sealKey := make([]byte, 32)
sealKey[0] = 1
sealer, err := loginapp.NewCodeSealer("test", map[string][]byte{"test": sealKey})
if err != nil {
t.Fatal(err)
}
pepper := make([]byte, 32)
pepper[0] = 2
service, err := loginapp.NewService(memory.NewTelegramLoginStore(nil), sealer, loginapp.Config{
Issuer: "https://oauth.telesrv.test", AppScheme: "telesrv", ClientSecretPepper: pepper,
Now: func() time.Time { return now },
})
if err != nil {
t.Fatal(err)
}
credentials, err := service.CreateClient(context.Background(), 9001, domain.TelegramLoginSigningRS256)
if err != nil {
t.Fatal(err)
}
const redirectURI = "https://rp.example/callback"
if _, err := service.AddAllowedURL(context.Background(), 9001, domain.TelegramLoginAllowedRedirectURI, redirectURI); err != nil {
t.Fatal(err)
}
if _, err := service.AddAllowedURL(context.Background(), 9001, domain.TelegramLoginAllowedWebOrigin, "https://rp.example"); err != nil {
t.Fatal(err)
}
signingKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
ring, err := loginapp.NewSigningKeyRing([]loginapp.SigningKeyMaterial{{
Algorithm: domain.TelegramLoginSigningRS256, KeyID: "rsa-test", PrivateKey: signingKey, Active: true,
}}, func() time.Time { return now })
if err != nil {
t.Fatal(err)
}
tokens, err := loginapp.NewIDTokenIssuer(ring, loginapp.IDTokenIssuerConfig{
Issuer: "https://oauth.telesrv.test", Now: func() time.Time { return now },
})
if err != nil {
t.Fatal(err)
}
handler, err := NewHandler(Config{Service: service, Tokens: tokens, AppName: "Telesrv", AllowLoopbackHTTP: true})
if err != nil {
t.Fatal(err)
}
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge, err := loginapp.PKCEChallenge(verifier)
if err != nil {
t.Fatal(err)
}
return telegramLoginHTTPFixture{
handler: handler, service: service, credentials: credentials, redirectURI: redirectURI,
verifier: verifier, challenge: challenge, now: &now,
}
}
func (f telegramLoginHTTPFixture) authorize(t *testing.T) (browserToken, deepLink string) {
t.Helper()
query := url.Values{
"client_id": {f.credentials.Client.ClientID}, "redirect_uri": {f.redirectURI},
"response_type": {"code"}, "scope": {"openid profile phone telegram:bot_access"},
"state": {"state-value"}, "nonce": {"nonce-value"}, "code_challenge": {f.challenge},
"code_challenge_method": {"S256"},
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/auth?"+query.Encode(), nil)
request.RemoteAddr = "192.0.2.10:4242"
f.handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("authorize status=%d body=%s", recorder.Code, recorder.Body.String())
}
body := recorder.Body.String()
tokenMatch := regexp.MustCompile(`const token=("[^"]+")`).FindStringSubmatch(body)
if len(tokenMatch) != 2 || json.Unmarshal([]byte(tokenMatch[1]), &browserToken) != nil {
t.Fatalf("browser token not found in page: %s", body)
}
deepLinkMatch := regexp.MustCompile(`href="([^"]+)"`).FindStringSubmatch(body)
if len(deepLinkMatch) != 2 {
t.Fatalf("deep link not found in page: %s", body)
}
deepLink = html.UnescapeString(deepLinkMatch[1])
pending, err := f.service.RequestByDeepLink(context.Background(), deepLink)
if err != nil {
t.Fatalf("resolve authorization page deep link: %v", err)
}
if pending.MatchCode == "" || !strings.Contains(body, `id="match-code"`) || !strings.Contains(body, pending.MatchCode) {
t.Fatalf("matching emoji missing from authorization page: match=%q body=%s", pending.MatchCode, body)
}
if !strings.Contains(body, "poll();</script>") {
t.Fatalf("authorization status polling is not started: %s", body)
}
return browserToken, deepLink
}
func TestAuthorizationErrorsUseOnlyPreRegisteredTargets(t *testing.T) {
f := newTelegramLoginHTTPFixture(t)
base := url.Values{
"client_id": {f.credentials.Client.ClientID}, "redirect_uri": {f.redirectURI},
"response_type": {"code"}, "scope": {"openid unsupported"}, "state": {"safe-state"},
"code_challenge": {f.challenge}, "code_challenge_method": {"S256"},
}
recorder := httptest.NewRecorder()
f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth?"+base.Encode(), nil))
if recorder.Code != http.StatusFound {
t.Fatalf("valid redirect error status=%d body=%s", recorder.Code, recorder.Body.String())
}
location, err := url.Parse(recorder.Header().Get("Location"))
if err != nil || location.Scheme+"://"+location.Host+location.Path != f.redirectURI || location.Query().Get("error") != "invalid_scope" || location.Query().Get("state") != "safe-state" {
t.Fatalf("error redirect=%q err=%v", recorder.Header().Get("Location"), err)
}
forged := cloneURLValues(base)
forged.Set("redirect_uri", "https://attacker.example/callback")
recorder = httptest.NewRecorder()
f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth?"+forged.Encode(), nil))
if recorder.Code != http.StatusBadRequest || recorder.Header().Get("Location") != "" {
t.Fatalf("forged redirect status=%d location=%q", recorder.Code, recorder.Header().Get("Location"))
}
post := cloneURLValues(base)
post.Set("redirect_uri", "https://rp.example/")
post.Set("response_type", "post_message")
post.Set("origin", "https://rp.example")
recorder = httptest.NewRecorder()
f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth?"+post.Encode(), nil))
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `postMessage`) || !strings.Contains(recorder.Body.String(), `https://rp.example`) {
t.Fatalf("post_message error status=%d body=%s", recorder.Code, recorder.Body.String())
}
post.Set("redirect_uri", "https://attacker.example/")
recorder = httptest.NewRecorder()
f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth?"+post.Encode(), nil))
if recorder.Code != http.StatusBadRequest {
t.Fatalf("cross-origin post_message status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func cloneURLValues(in url.Values) url.Values {
out := make(url.Values, len(in))
for key, values := range in {
out[key] = append([]string(nil), values...)
}
return out
}
func (f telegramLoginHTTPFixture) approveAndFinalize(t *testing.T) (code string) {
t.Helper()
browserToken, deepLink := f.authorize(t)
*f.now = f.now.Add(time.Second)
pending, err := f.service.RequestByDeepLink(context.Background(), deepLink)
if err != nil {
t.Fatalf("RequestByDeepLink(%q): %v", deepLink, err)
}
_, _, err = f.service.Approve(context.Background(), deepLink, domain.TelegramLoginIdentitySnapshot{
UserID: 42, Name: "Alice Example", GivenName: "Alice", FamilyName: "Example",
PreferredUsername: "alice", Picture: "https://oauth.telesrv.test/userpic/42", PhoneNumber: "+1 555 123 4567",
}, true, true, pending.MatchCode)
if err != nil {
t.Fatal(err)
}
form := url.Values{"browser_token": {browserToken}}
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/auth/status", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
f.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("status status=%d body=%s", recorder.Code, recorder.Body.String())
}
var response struct {
Status string `json:"status"`
RedirectURL string `json:"redirect_url"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil || response.Status != "approved" {
t.Fatalf("status response=%+v err=%v body=%s", response, err, recorder.Body.String())
}
redirect, err := url.Parse(response.RedirectURL)
if err != nil || redirect.Query().Get("state") != "state-value" {
t.Fatalf("redirect=%q err=%v", response.RedirectURL, err)
}
return redirect.Query().Get("code")
}
func TestDiscoveryAndJWKS(t *testing.T) {
f := newTelegramLoginHTTPFixture(t)
for _, path := range []string{"/.well-known/openid-configuration", "/.well-known/jwks.json"} {
recorder := httptest.NewRecorder()
f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != http.StatusOK || recorder.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("GET %s status=%d headers=%v body=%s", path, recorder.Code, recorder.Header(), recorder.Body.String())
}
}
}
func TestAuthorizationCodeHTTPFlowAndReplay(t *testing.T) {
f := newTelegramLoginHTTPFixture(t)
code := f.approveAndFinalize(t)
form := url.Values{
"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {f.redirectURI},
"client_id": {f.credentials.Client.ClientID}, "code_verifier": {f.verifier},
}
exchange := func() *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(f.credentials.Client.ClientID, f.credentials.Secret)
f.handler.ServeHTTP(recorder, req)
return recorder
}
recorder := exchange()
if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("token status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
}
var response struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil || response.AccessToken == "" || response.IDToken == "" || response.TokenType != "Bearer" || response.ExpiresIn != 3600 {
t.Fatalf("token response=%+v err=%v body=%s", response, err, recorder.Body.String())
}
jwksRecorder := httptest.NewRecorder()
f.handler.ServeHTTP(jwksRecorder, httptest.NewRequest(http.MethodGet, "/.well-known/jwks.json", nil))
set, err := jwk.Parse(jwksRecorder.Body.Bytes())
if err != nil {
t.Fatal(err)
}
token, err := jwt.Parse([]byte(response.IDToken), jwt.WithKeySet(set), jwt.WithValidate(false))
if err != nil || !token.Has("phone_number") || !token.Has("preferred_username") {
t.Fatalf("verified ID token=%v err=%v", token, err)
}
replay := exchange()
if replay.Code != http.StatusBadRequest || !strings.Contains(replay.Body.String(), "invalid_grant") {
t.Fatalf("replay status=%d body=%s", replay.Code, replay.Body.String())
}
}
func TestNativeSDKCrossAppAndPublicPKCEExchange(t *testing.T) {
f := newTelegramLoginHTTPFixture(t)
const callbackURI = "bedolaga://telegram-login"
if _, err := f.service.AddNativeApp(context.Background(), 9001, domain.TelegramLoginNativeIOS,
"dev.bedolaga.demo", "ABCDE12345", callbackURI, "Bedolaga Demo"); err != nil {
t.Fatalf("AddNativeApp: %v", err)
}
query := url.Values{
"client_id": {f.credentials.Client.ClientID}, "redirect_uri": {callbackURI},
"response_type": {"code"}, "scope": {"profile"}, "ios_sdk": {"1"},
"code_challenge": {f.challenge}, "code_challenge_method": {"S256"},
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/crossapp?"+query.Encode(), nil)
request.Header.Set("User-Agent", "TelegramLogin/iOS")
f.handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("crossapp status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
}
var crossApp struct {
URL string `json:"url"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &crossApp); err != nil || crossApp.URL == "" {
t.Fatalf("crossapp response=%+v err=%v", crossApp, err)
}
pending, err := f.service.RequestByDeepLink(context.Background(), crossApp.URL)
if err != nil {
t.Fatal(err)
}
if pending.Source != domain.TelegramLoginRequestNative || !pending.IsApp || pending.VerifiedAppName != "Bedolaga Demo" ||
len(pending.Scopes) != 2 || pending.Scopes[0] != domain.TelegramLoginScopeOpenID {
t.Fatalf("native request=%#v", pending)
}
*f.now = f.now.Add(time.Second)
if _, _, err := f.service.Approve(context.Background(), crossApp.URL, domain.TelegramLoginIdentitySnapshot{
UserID: 42, Name: "Alice", GivenName: "Alice",
}, false, false, pending.MatchCode); err != nil {
t.Fatal(err)
}
redirectURL, err := f.service.FinalizeRedirectByDeepLink(context.Background(), crossApp.URL)
if err != nil {
t.Fatal(err)
}
callback, err := url.Parse(redirectURL)
if err != nil || callback.Scheme != "bedolaga" || callback.Host != "telegram-login" || callback.Query().Get("code") == "" {
t.Fatalf("native callback=%q err=%v", redirectURL, err)
}
form := url.Values{
"grant_type": {"authorization_code"}, "client_id": {f.credentials.Client.ClientID},
"code": {callback.Query().Get("code")}, "redirect_uri": {callbackURI}, "code_verifier": {f.verifier},
}
tokenRecorder := httptest.NewRecorder()
tokenRequest := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
tokenRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
f.handler.ServeHTTP(tokenRecorder, tokenRequest)
if tokenRecorder.Code != http.StatusOK || !strings.Contains(tokenRecorder.Body.String(), `"id_token"`) {
t.Fatalf("native token status=%d body=%s", tokenRecorder.Code, tokenRecorder.Body.String())
}
query.Set("android_sdk", "1")
recorder = httptest.NewRecorder()
f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/crossapp?"+query.Encode(), nil))
if recorder.Code != http.StatusBadRequest {
t.Fatalf("dual SDK marker status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestPublicTokenAuthenticationCannotExchangeWebCode(t *testing.T) {
f := newTelegramLoginHTTPFixture(t)
code := f.approveAndFinalize(t)
form := url.Values{
"grant_type": {"authorization_code"}, "client_id": {f.credentials.Client.ClientID},
"code": {code}, "redirect_uri": {f.redirectURI}, "code_verifier": {f.verifier},
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
f.handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized || !strings.Contains(recorder.Body.String(), "invalid_client") {
t.Fatalf("public web exchange status=%d body=%s", recorder.Code, recorder.Body.String())
}
// Authentication failure happens before the one-time consume, so the same
// code remains usable by the confidential web client.
recorder = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.SetBasicAuth(f.credentials.Client.ClientID, f.credentials.Secret)
f.handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("authenticated retry status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestConcurrentTokenExchangeHasOneSuccess(t *testing.T) {
f := newTelegramLoginHTTPFixture(t)
code := f.approveAndFinalize(t)
form := url.Values{
"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {f.redirectURI},
"client_id": {f.credentials.Client.ClientID}, "code_verifier": {f.verifier},
}.Encode()
const workers = 8
statuses := make(chan int, workers)
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(f.credentials.Client.ClientID, f.credentials.Secret)
f.handler.ServeHTTP(recorder, req)
statuses <- recorder.Code
}()
}
wg.Wait()
close(statuses)
success := 0
for status := range statuses {
if status == http.StatusOK {
success++
} else if status != http.StatusBadRequest {
t.Fatalf("unexpected concurrent exchange status %d", status)
}
}
if success != 1 {
t.Fatalf("successful exchanges=%d, want 1", success)
}
}
func TestTokenEndpointSupportsBodySecretAndRejectsMixedAuthentication(t *testing.T) {
f := newTelegramLoginHTTPFixture(t)
code := f.approveAndFinalize(t)
form := url.Values{
"grant_type": {"authorization_code"}, "client_id": {f.credentials.Client.ClientID},
"client_secret": {f.credentials.Secret}, "code": {code}, "redirect_uri": {f.redirectURI},
"code_verifier": {f.verifier},
}
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
f.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"id_token"`) {
t.Fatalf("client_secret_post status=%d body=%s", recorder.Code, recorder.Body.String())
}
secondCode := f.approveAndFinalize(t)
form.Set("code", secondCode)
recorder = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(f.credentials.Client.ClientID, f.credentials.Secret)
f.handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "invalid_request") {
t.Fatalf("mixed authentication status=%d body=%s", recorder.Code, recorder.Body.String())
}
recorder = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
f.handler.ServeHTTP(recorder, req)
_, _ = io.Copy(io.Discard, recorder.Result().Body)
if recorder.Code != http.StatusUnsupportedMediaType {
t.Fatalf("JSON token content status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestHTTPRateLimitFailsBeforeAuthorizationCreation(t *testing.T) {
f := newTelegramLoginHTTPFixture(t)
f.handler.limiter = telegramLoginHTTPDenyLimiter{}
recorder := httptest.NewRecorder()
f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth", nil))
if recorder.Code != http.StatusTooManyRequests || recorder.Header().Get("Retry-After") != "17" {
t.Fatalf("rate limit status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
}
}
func TestJavaScriptPostMessageFlowReturnsStableDirectIDToken(t *testing.T) {
f := newTelegramLoginHTTPFixture(t)
query := url.Values{
"client_id": {f.credentials.Client.ClientID}, "redirect_uri": {"https://rp.example/"},
"response_type": {"post_message"}, "scope": {"openid profile phone"},
"nonce": {"js-nonce"},
}
recorder := httptest.NewRecorder()
f.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth?"+query.Encode(), nil))
if recorder.Code != http.StatusOK {
t.Fatalf("JS authorize status=%d body=%s", recorder.Code, recorder.Body.String())
}
body := recorder.Body.String()
tokenMatch := regexp.MustCompile(`const token=("[^"]+")`).FindStringSubmatch(body)
deepLinkMatch := regexp.MustCompile(`href="([^"]+)"`).FindStringSubmatch(body)
if len(tokenMatch) != 2 || len(deepLinkMatch) != 2 {
t.Fatalf("JS authorize artifacts missing: %s", body)
}
var browserToken string
if err := json.Unmarshal([]byte(tokenMatch[1]), &browserToken); err != nil {
t.Fatal(err)
}
deepLink := html.UnescapeString(deepLinkMatch[1])
pending, err := f.service.RequestByDeepLink(context.Background(), deepLink)
if err != nil {
t.Fatal(err)
}
if pending.Source != domain.TelegramLoginRequestJavaScript || pending.Origin != "https://rp.example" || pending.CodeChallenge != "" {
t.Fatalf("official JavaScript request = %#v", pending)
}
*f.now = f.now.Add(time.Second)
if _, _, err := f.service.Approve(context.Background(), deepLink, domain.TelegramLoginIdentitySnapshot{
UserID: 42, Name: "Alice Example", GivenName: "Alice", PhoneNumber: "15551234567",
}, false, true, pending.MatchCode); err != nil {
t.Fatal(err)
}
poll := func() map[string]string {
t.Helper()
statusRecorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/auth/status", strings.NewReader(url.Values{"browser_token": {browserToken}}.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
f.handler.ServeHTTP(statusRecorder, request)
if statusRecorder.Code != http.StatusOK {
t.Fatalf("JS status=%d body=%s", statusRecorder.Code, statusRecorder.Body.String())
}
var result map[string]string
if err := json.Unmarshal(statusRecorder.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
return result
}
first, second := poll(), poll()
if first["status"] != "approved" || first["id_token"] == "" || second["id_token"] != first["id_token"] {
t.Fatalf("direct token first=%v second=%v", first, second)
}
web, err := f.service.ListWebAuthorizations(context.Background(), 42)
if err != nil || len(web) != 1 {
t.Fatalf("direct web authorization=%#v err=%v", web, err)
}
if err := f.service.RevokeWebAuthorization(context.Background(), 42, web[0].Hash); err != nil {
t.Fatal(err)
}
revokedRecorder := httptest.NewRecorder()
revokedRequest := httptest.NewRequest(http.MethodPost, "/auth/status", strings.NewReader(url.Values{"browser_token": {browserToken}}.Encode()))
revokedRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
f.handler.ServeHTTP(revokedRecorder, revokedRequest)
if revokedRecorder.Code != http.StatusInternalServerError || !strings.Contains(revokedRecorder.Body.String(), "server_error") {
t.Fatalf("revoked direct delivery status=%d body=%s", revokedRecorder.Code, revokedRecorder.Body.String())
}
exchangeForm := url.Values{
"grant_type": {"authorization_code"}, "code": {first["id_token"]}, "redirect_uri": {"https://rp.example/"},
"client_id": {f.credentials.Client.ClientID}, "code_verifier": {f.verifier},
}
exchangeRecorder := httptest.NewRecorder()
exchangeRequest := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(exchangeForm.Encode()))
exchangeRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
exchangeRequest.SetBasicAuth(f.credentials.Client.ClientID, f.credentials.Secret)
f.handler.ServeHTTP(exchangeRecorder, exchangeRequest)
if exchangeRecorder.Code != http.StatusBadRequest || !strings.Contains(exchangeRecorder.Body.String(), "invalid_grant") {
t.Fatalf("direct token exchange status=%d body=%s", exchangeRecorder.Code, exchangeRecorder.Body.String())
}
}
func TestMiniAppOfficialInAppFlowIsOriginBoundAndOneTime(t *testing.T) {
f := newTelegramLoginHTTPFixture(t)
const origin = "https://rp.example"
query := url.Values{
"client_id": {f.credentials.Client.ClientID}, "scope": {"openid profile phone telegram:bot_access"},
"origin": {origin}, "response_type": {"id_token"},
}
create := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/inapp?"+query.Encode(), nil)
request.Header.Set("Origin", origin)
f.handler.ServeHTTP(create, request)
if create.Code != http.StatusOK || create.Header().Get("Access-Control-Allow-Origin") != origin || create.Header().Get("Vary") != "Origin" {
t.Fatalf("in-app create status=%d headers=%v body=%s", create.Code, create.Header(), create.Body.String())
}
var created struct {
URL string `json:"url"`
}
if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil || created.URL == "" {
t.Fatalf("in-app create response=%+v err=%v", created, err)
}
pending, err := f.service.RequestByDeepLinkForOrigin(context.Background(), created.URL, origin)
if err != nil {
t.Fatal(err)
}
if pending.Source != domain.TelegramLoginRequestMiniApp || pending.InAppOrigin != origin || pending.ResponseType != "post_message" {
t.Fatalf("in-app request=%#v", pending)
}
*f.now = f.now.Add(time.Second)
if _, _, err := f.service.Approve(context.Background(), created.URL, domain.TelegramLoginIdentitySnapshot{
UserID: 42, Name: "Alice Example", GivenName: "Alice", PhoneNumber: "15551234567",
}, true, true, pending.MatchCode); err != nil {
t.Fatal(err)
}
resultURL, err := f.service.FinalizeInAppRedirectByDeepLink(context.Background(), created.URL)
if err != nil {
t.Fatal(err)
}
parsed, err := url.Parse(resultURL)
if err != nil || parsed.Scheme+"://"+parsed.Host != "https://oauth.telesrv.test" || parsed.Path != "/inapp" || parsed.Query().Get("token") == "" {
t.Fatalf("in-app result URL=%q err=%v", resultURL, err)
}
token := parsed.Query().Get("token")
wrongOrigin := httptest.NewRecorder()
wrongRequest := httptest.NewRequest(http.MethodGet, "/inapp?code="+url.QueryEscape(token), nil)
wrongRequest.Header.Set("Origin", "https://attacker.example")
f.handler.ServeHTTP(wrongOrigin, wrongRequest)
if wrongOrigin.Code != http.StatusBadRequest {
t.Fatalf("wrong-origin exchange status=%d body=%s", wrongOrigin.Code, wrongOrigin.Body.String())
}
const workers = 8
statuses := make(chan int, workers)
results := make(chan string, workers)
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/inapp?code="+url.QueryEscape(token), nil)
req.Header.Set("Origin", origin)
f.handler.ServeHTTP(recorder, req)
statuses <- recorder.Code
results <- recorder.Body.String()
}()
}
wg.Wait()
close(statuses)
close(results)
successes := 0
responses := make([]string, 0, workers)
for status := range statuses {
if status == http.StatusOK {
successes++
} else if status != http.StatusBadRequest {
t.Fatalf("unexpected in-app exchange status=%d", status)
}
}
for response := range results {
responses = append(responses, response)
}
if successes != 1 {
t.Fatalf("in-app exchange successes=%d, want 1; responses=%v", successes, responses)
}
}
func TestTelegramLoginJavaScriptIsCacheableAndConditional(t *testing.T) {
f := newTelegramLoginHTTPFixture(t)
first := httptest.NewRecorder()
f.handler.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/js/telegram-login.js", nil))
if first.Code != http.StatusOK || first.Header().Get("ETag") == "" ||
!strings.Contains(first.Body.String(), "Telegram.Login") ||
!strings.Contains(first.Body.String(), "auth_result") ||
!strings.Contains(first.Body.String(), "oauth_supported") ||
!strings.Contains(first.Body.String(), "/inapp?") ||
!strings.Contains(first.Body.String(), "data-client-id") {
t.Fatalf("SDK status=%d headers=%v body=%s", first.Code, first.Header(), first.Body.String())
}
second := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/js/telegram-login.js", nil)
request.Header.Set("If-None-Match", first.Header().Get("ETag"))
f.handler.ServeHTTP(second, request)
if second.Code != http.StatusNotModified {
t.Fatalf("conditional SDK status=%d", second.Code)
}
}

View file

@ -0,0 +1,90 @@
package telegramloginhttp
import (
"crypto/sha256"
"encoding/base64"
"net/http"
)
// telegramLoginJavaScript intentionally follows the public Telegram Login
// SDK contract (programmatic API, data-* auto-init, popup auth_result events,
// and the Mini App oauth_* bridge). It derives the provider from its own
// script origin so the same file works on a self-hosted issuer.
const telegramLoginJavaScript = `(function(global){
'use strict';
var current=document.currentScript;
if(!current){throw new Error('Telegram Login SDK must be loaded by a script element');}
var provider=new URL(current.src,document.baseURI).origin;
var saved=null,active=null,inApp=false,inAppPending=false;
function callback(cb,value){if(typeof cb==='function'){try{cb(value);}catch(error){setTimeout(function(){throw error;},0);}}}
function decode(token){try{var part=token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/');while(part.length%4){part+='=';}return JSON.parse(decodeURIComponent(Array.from(atob(part),function(c){return '%'+c.charCodeAt(0).toString(16).padStart(2,'0');}).join('')));}catch(_){return null;}}
function build(data){if(data&&data.error){return {error:String(data.error)};}var token=data&&data.result;if(typeof token!=='string'||!token){return {error:'missing id_token'};}var user=decode(token);return user?{id_token:token,user:user}:{error:'malformed id_token'};}
function normalize(options){
if(!options||!/^[0-9]{1,64}$/.test(String(options.client_id||''))){throw new Error('Telegram.Login client_id is required');}
var scopes=['openid'],input=options.scope;
if(input===undefined||input===null||input===''){scopes.push('profile');input=options.request_access||[];}
if(typeof input==='string'){input=input.trim()?input.trim().split(/\s+/):[];}
if(!Array.isArray(input)){throw new Error('Telegram.Login scope must be an array or string');}
var allowed={profile:'profile',phone:'phone',write:'telegram:bot_access','telegram:bot_access':'telegram:bot_access'};
input.forEach(function(value){var mapped=allowed[value];if(!mapped){throw new Error('Telegram.Login scope is invalid');}if(scopes.indexOf(mapped)<0){scopes.push(mapped);}});
return {client_id:String(options.client_id),scope:scopes.join(' '),nonce:String(options.nonce||'').slice(0,1024),lang:String(options.lang||'').slice(0,16)};
}
function randomURL(bytes){var data=new Uint8Array(bytes);crypto.getRandomValues(data);var raw='';data.forEach(function(value){raw+=String.fromCharCode(value);});return btoa(raw).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');}
function finish(flow,result){if(active!==flow){return;}active=null;if(flow.timer){clearInterval(flow.timer);}if(flow.listener){global.removeEventListener('message',flow.listener);}callback(flow.callback,result);}
function sendEvent(type,data){if(global.TelegramWebviewProxy&&typeof global.TelegramWebviewProxy.postEvent==='function'){global.TelegramWebviewProxy.postEvent(type,JSON.stringify(data||{}));}}
async function receiveEvent(type,data){
if(type==='oauth_supported'){inApp=true;return;}
if(type==='oauth_result_failed'){if(active){finish(active,{error:'access_denied'});}return;}
if(type!=='oauth_result_confirmed'||!active||!data||!data.result_url){return;}
try{var resultURL=new URL(data.result_url);if(resultURL.origin!==provider||resultURL.pathname!=='/inapp'){throw new Error('invalid in-app result URL');}var token=resultURL.searchParams.get('token');if(!token){throw new Error('missing in-app token');}
var response=await fetch(provider+'/inapp?code='+encodeURIComponent(token),{credentials:'omit',cache:'no-store'}),result=await response.json();
if(!response.ok){throw new Error(result.error||'in-app exchange failed');}finish(active,build(result));
}catch(error){finish(active,{error:error.message||'in_app_failed'});}
}
function begin(options,cb,isNormalized){
var normalized;try{normalized=isNormalized?options:normalize(options);}catch(error){callback(cb,{error:error.message});return null;}
if(active){callback(cb,{error:'login_in_progress'});return null;}
var flow={popup:null,callback:cb,listener:null,timer:null};active=flow;
if(inApp){
if(inAppPending){finish(flow,{error:'login_in_progress'});return null;}inAppPending=true;
var inAppParams=new URLSearchParams({scope:normalized.scope,origin:global.location.origin,client_id:normalized.client_id,response_type:'id_token'});
fetch(provider+'/inapp?'+inAppParams.toString(),{credentials:'omit',cache:'no-store'}).then(function(response){return response.json().then(function(body){if(!response.ok){throw new Error(body.error||'in-app request failed');}return body;});}).then(function(body){if(!body.url){throw new Error('missing OAuth deep link');}sendEvent('oauth_request',{url:body.url});}).catch(function(error){finish(flow,{error:error.message||'in_app_failed'});}).finally(function(){setTimeout(function(){inAppPending=false;},600);});
return null;
}
var popup=global.open('about:blank','telegram-login-'+randomURL(8),'popup,width=550,height=650,resizable=yes,scrollbars=yes');
if(!popup){finish(flow,{error:'popup_blocked'});return null;}flow.popup=popup;
flow.listener=function(event){if(event.origin!==provider||event.source!==popup){return;}var data=event.data;try{if(typeof data==='string'){data=JSON.parse(data);}}catch(_){return;}if(!data||data.event!=='auth_result'){return;}finish(flow,build(data));};
global.addEventListener('message',flow.listener);flow.timer=setInterval(function(){if(popup.closed){finish(flow,{error:'popup_closed'});}},500);
try{var params=new URLSearchParams({client_id:normalized.client_id,redirect_uri:global.location.origin+global.location.pathname,response_type:'post_message',scope:normalized.scope});if(normalized.nonce){params.set('nonce',normalized.nonce);}if(normalized.lang){params.set('lang',normalized.lang);}popup.location.replace(provider+'/auth?'+params.toString());}
catch(error){try{popup.close();}catch(_){}finish(flow,{error:error.message||'login_failed'});}
return popup;
}
var api={
init:function(options,cb){saved={options:normalize(options),callback:cb};return api;},
open:function(cb){if(!saved){callback(cb,{error:'not_initialized'});return null;}return begin(saved.options,cb||saved.callback,true);},
auth:function(options,cb){return begin(options,cb,false);},
close:function(){if(active&&active.popup){try{active.popup.close();}catch(_){}}if(active){finish(active,{error:'popup_closed'});}}
};
global.Telegram=global.Telegram||{};global.Telegram.Login=api;
global.Telegram.WebView=global.Telegram.WebView||{};global.Telegram.WebView.receiveEvent=receiveEvent;
global.Telegram.TelegramGameProxy=global.Telegram.TelegramGameProxy||{};global.Telegram.TelegramGameProxy.receiveEvent=receiveEvent;
if(global.TelegramWebviewProxy){sendEvent('oauth_request',{});}
document.addEventListener('click',function(event){var node=event.target;while(node&&node!==document){if(node.classList&&node.classList.contains('tg-auth-button')){api.open();return;}node=node.parentNode;}});
function resolveCallback(source){var match=/^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(\s*data\s*\)\s*;?$/.exec(source||'');if(!match){return null;}return function(data){var target=global,parts=match[1].split('.');for(var i=0;i<parts.length-1;i++){target=target&&target[parts[i]];}var fn=target&&target[parts[parts.length-1]];if(typeof fn==='function'){fn.call(target,data);}};}
function autoInit(){var client=current.getAttribute('data-client-id');if(!client){return;}var options={client_id:client},access=current.getAttribute('data-request-access'),lang=current.getAttribute('data-lang');if(access){options.request_access=access.trim().split(/\s+/);}if(lang){options.lang=lang;}api.init(options,resolveCallback(current.getAttribute('data-onauth')));}
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',autoInit);}else{autoInit();}
})(window);`
func (h *Handler) loginJavaScript(w http.ResponseWriter, r *http.Request) {
sum := sha256.Sum256([]byte(telegramLoginJavaScript))
etag := `"` + base64.RawURLEncoding.EncodeToString(sum[:]) + `"`
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=3600, must-revalidate")
w.Header().Set("ETag", etag)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(telegramLoginJavaScript))
}

View file

@ -33,6 +33,10 @@ type Config struct {
Photos ProfilePhotoResolver
UniqueGifts UniqueStarGiftResolver
GiftWithdrawals StarGiftWithdrawalResolver
// TelegramLogin is the optional OIDC/Login HTTP adapter. Public Web owns
// the listener so discovery/auth/token and public links share the exact
// externally registered origin behind one reverse proxy.
TelegramLogin http.Handler
}
type StickerSetResolver interface {
@ -166,6 +170,17 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) {
mux.HandleFunc("GET /nft/{slug}/{$}", h.uniqueGift)
mux.HandleFunc("GET /gift-withdrawal/{requestID}", h.starGiftWithdrawal)
mux.HandleFunc("POST /gift-withdrawal/{requestID}", h.completeStarGiftWithdrawal)
if cfg.TelegramLogin != nil {
mux.Handle("GET /.well-known/openid-configuration", cfg.TelegramLogin)
mux.Handle("GET /.well-known/jwks.json", cfg.TelegramLogin)
mux.Handle("GET /auth", cfg.TelegramLogin)
mux.Handle("GET /crossapp", cfg.TelegramLogin)
mux.Handle("GET /inapp", cfg.TelegramLogin)
mux.Handle("POST /auth/status", cfg.TelegramLogin)
mux.Handle("POST /token", cfg.TelegramLogin)
mux.Handle("GET /telegram-login.js", cfg.TelegramLogin)
mux.Handle("GET /js/telegram-login.js", cfg.TelegramLogin)
}
mux.HandleFunc("GET /{username}", h.usernameLink)
mux.HandleFunc("GET /{username}/{$}", h.usernameLink)
return publicSecurityHeaders(mux), nil