feat: sync Telegram Login OIDC provider
This commit is contained in:
parent
30774f8c39
commit
ebead9e98c
63 changed files with 11374 additions and 37 deletions
|
|
@ -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 并设置 joingroups(join=true)或 privacy(join=false)。
|
||||
func (s *Service) applyToggle(ctx context.Context, botID int64, text string, join bool) (botReply, error) {
|
||||
var on bool
|
||||
|
|
|
|||
100
internal/app/bots/botfather_login_test.go
Normal file
100
internal/app/bots/botfather_login_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
131
internal/app/telegramlogin/crypto.go
Normal file
131
internal/app/telegramlogin/crypto.go
Normal 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
|
||||
}
|
||||
406
internal/app/telegramlogin/jose.go
Normal file
406
internal/app/telegramlogin/jose.go
Normal 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)
|
||||
}
|
||||
5
internal/app/telegramlogin/jose_es256k_disabled.go
Normal file
5
internal/app/telegramlogin/jose_es256k_disabled.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//go:build !jwx_es256k
|
||||
|
||||
package telegramlogin
|
||||
|
||||
const telegramLoginES256KEnabled = false
|
||||
17
internal/app/telegramlogin/jose_es256k_disabled_test.go
Normal file
17
internal/app/telegramlogin/jose_es256k_disabled_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
5
internal/app/telegramlogin/jose_es256k_enabled.go
Normal file
5
internal/app/telegramlogin/jose_es256k_enabled.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//go:build jwx_es256k
|
||||
|
||||
package telegramlogin
|
||||
|
||||
const telegramLoginES256KEnabled = true
|
||||
57
internal/app/telegramlogin/jose_es256k_enabled_test.go
Normal file
57
internal/app/telegramlogin/jose_es256k_enabled_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
188
internal/app/telegramlogin/jose_test.go
Normal file
188
internal/app/telegramlogin/jose_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
186
internal/app/telegramlogin/keyfiles.go
Normal file
186
internal/app/telegramlogin/keyfiles.go
Normal 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))
|
||||
}
|
||||
86
internal/app/telegramlogin/keyfiles_test.go
Normal file
86
internal/app/telegramlogin/keyfiles_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
80
internal/app/telegramlogin/native.go
Normal file
80
internal/app/telegramlogin/native.go
Normal 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
|
||||
}
|
||||
1374
internal/app/telegramlogin/service.go
Normal file
1374
internal/app/telegramlogin/service.go
Normal file
File diff suppressed because it is too large
Load diff
404
internal/app/telegramlogin/service_test.go
Normal file
404
internal/app/telegramlogin/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
141
internal/app/telegramlogin/url.go
Normal file
141
internal/app/telegramlogin/url.go
Normal 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
|
||||
}
|
||||
115
internal/app/telegramlogin/url_test.go
Normal file
115
internal/app/telegramlogin/url_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue