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