feat: sync HTTP callback OIDC setup

This commit is contained in:
A 2026-07-21 18:18:33 +08:00
parent bf72c246b6
commit f53579416e
26 changed files with 557 additions and 133 deletions

View file

@ -22,7 +22,7 @@ func newBotFatherTelegramLoginService(t *testing.T) *telegramloginapp.Service {
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,
Issuer: "http://192.0.2.25:2404", AppScheme: "telesrv", AllowHTTP: true,
ClientSecretPepper: pepper, Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() },
})
if err != nil {
@ -52,13 +52,13 @@ func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) {
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!") {
if reply := sendToBotFather(t, svc, messages, owner, "add origin http://rp.example.test: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!") {
if reply := sendToBotFather(t, svc, messages, owner, "add redirect http://192.0.2.26:3000/auth/callback"); !strings.Contains(reply, "Success!") {
t.Fatalf("add redirect reply = %q", reply)
}
@ -83,7 +83,7 @@ func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) {
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"} {
for _, want := range []string{"Signing algorithm: ES256", "web_origin http://rp.example.test:3000", "redirect_uri http://192.0.2.26: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)
}

View file

@ -293,9 +293,10 @@ func (r *SigningKeyRing) sign(algorithm domain.TelegramLoginSigningAlgorithm, to
}
type IDTokenIssuerConfig struct {
Issuer string
TTL time.Duration
Now func() time.Time
Issuer string
TTL time.Duration
Now func() time.Time
AllowHTTP bool
}
type IDTokenIssuer struct {
@ -337,7 +338,7 @@ func NewIDTokenIssuer(keys *SigningKeyRing, cfg IDTokenIssuerConfig) (*IDTokenIs
if keys == nil {
return nil, errors.New("telegram login signing key ring is required")
}
issuer, err := NormalizeWebOrigin(cfg.Issuer, true)
issuer, err := NormalizeWebOrigin(cfg.Issuer, cfg.AllowHTTP)
if err != nil {
return nil, fmt.Errorf("telegram login ID token issuer: %w", err)
}

View file

@ -167,6 +167,21 @@ func TestIDTokenIssuerScopeProjectionAndVerification(t *testing.T) {
}
}
func TestIDTokenIssuerAcceptsHTTPIPOnlyWhenEnabled(t *testing.T) {
now := time.Date(2026, 7, 21, 10, 0, 0, 0, time.UTC)
ring := telegramLoginTestSigningKeys(t, &now)
if _, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{Issuer: "http://192.0.2.25:2401"}); err == nil {
t.Fatal("HTTP issuer was accepted while AllowHTTP was false")
}
issuer, err := NewIDTokenIssuer(ring, IDTokenIssuerConfig{Issuer: "http://192.0.2.25:2401", AllowHTTP: true})
if err != nil {
t.Fatal(err)
}
if issuer.Issuer() != "http://192.0.2.25:2401" {
t.Fatalf("issuer=%q", issuer.Issuer())
}
}
func TestSigningKeyRingRejectsWrongCurveAndDuplicateActiveKey(t *testing.T) {
p384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {

View file

@ -45,7 +45,7 @@ func normalizeNativeVerificationID(platform domain.TelegramLoginNativePlatform,
// 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) {
func NormalizeNativeCallbackURI(raw string, allowHTTP bool) (string, error) {
if raw == "" || len(raw) > maxTelegramLoginURLLength || raw != strings.TrimSpace(raw) || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
return "", domain.ErrTelegramLoginURLInvalid
}
@ -54,7 +54,7 @@ func NormalizeNativeCallbackURI(raw string, allowLoopbackHTTP bool) (string, err
return "", domain.ErrTelegramLoginURLInvalid
}
if strings.EqualFold(u.Scheme, "http") || strings.EqualFold(u.Scheme, "https") {
normalized, _, err := NormalizeRedirectURI(raw, allowLoopbackHTTP)
normalized, _, err := NormalizeRedirectURI(raw, allowHTTP)
return normalized, err
}
scheme := strings.ToLower(u.Scheme)

View file

@ -36,7 +36,7 @@ var telegramLoginMatchCodePool = []string{
type Config struct {
Issuer string
AppScheme string
AllowLoopbackHTTP bool
AllowHTTP bool
ClientSecretPepper []byte
SupportedSigningAlgorithms []domain.TelegramLoginSigningAlgorithm
RequestTTL time.Duration
@ -49,7 +49,7 @@ type Service struct {
sealer *CodeSealer
issuer string
appScheme string
allowLoopbackHTTP bool
allowHTTP bool
clientSecretPepper []byte
signingAlgorithms []domain.TelegramLoginSigningAlgorithm
signingAlgorithmSet map[domain.TelegramLoginSigningAlgorithm]struct{}
@ -62,7 +62,7 @@ func NewService(loginStore store.TelegramLoginStore, sealer *CodeSealer, cfg Con
if loginStore == nil || sealer == nil || len(cfg.ClientSecretPepper) < 32 {
return nil, fmt.Errorf("telegram login dependencies are incomplete")
}
issuer, err := NormalizeWebOrigin(cfg.Issuer, cfg.AllowLoopbackHTTP)
issuer, err := NormalizeWebOrigin(cfg.Issuer, cfg.AllowHTTP)
if err != nil {
return nil, fmt.Errorf("telegram login issuer: %w", err)
}
@ -93,7 +93,7 @@ func NewService(loginStore store.TelegramLoginStore, sealer *CodeSealer, cfg Con
}
return &Service{
store: loginStore, sealer: sealer, issuer: issuer, appScheme: strings.ToLower(cfg.AppScheme),
allowLoopbackHTTP: cfg.AllowLoopbackHTTP,
allowHTTP: cfg.AllowHTTP,
clientSecretPepper: append([]byte(nil), cfg.ClientSecretPepper...),
signingAlgorithms: append([]domain.TelegramLoginSigningAlgorithm(nil), cfg.SupportedSigningAlgorithms...),
signingAlgorithmSet: signingAlgorithmSet,
@ -248,9 +248,9 @@ func (s *Service) AddAllowedURL(ctx context.Context, botUserID int64, kind domai
var err error
switch kind {
case domain.TelegramLoginAllowedWebOrigin:
normalized, err = NormalizeWebOrigin(raw, s.allowLoopbackHTTP)
normalized, err = NormalizeWebOrigin(raw, s.allowHTTP)
case domain.TelegramLoginAllowedRedirectURI:
normalized, _, err = NormalizeRedirectURI(raw, s.allowLoopbackHTTP)
normalized, _, err = NormalizeRedirectURI(raw, s.allowHTTP)
default:
err = domain.ErrTelegramLoginURLInvalid
}
@ -267,9 +267,9 @@ func (s *Service) DeleteAllowedURL(ctx context.Context, botUserID int64, kind do
var err error
switch kind {
case domain.TelegramLoginAllowedWebOrigin:
normalized, err = NormalizeWebOrigin(raw, s.allowLoopbackHTTP)
normalized, err = NormalizeWebOrigin(raw, s.allowHTTP)
case domain.TelegramLoginAllowedRedirectURI:
normalized, _, err = NormalizeRedirectURI(raw, s.allowLoopbackHTTP)
normalized, _, err = NormalizeRedirectURI(raw, s.allowHTTP)
default:
err = domain.ErrTelegramLoginURLInvalid
}
@ -311,7 +311,7 @@ func (s *Service) AddNativeApp(ctx context.Context, botUserID int64, platform do
if err != nil {
return domain.TelegramLoginNativeApp{}, err
}
callbackURI, err = NormalizeNativeCallbackURI(callbackURI, s.allowLoopbackHTTP)
callbackURI, err = NormalizeNativeCallbackURI(callbackURI, s.allowHTTP)
if err != nil {
return domain.TelegramLoginNativeApp{}, err
}
@ -335,7 +335,7 @@ func (s *Service) DeleteNativeApp(ctx context.Context, botUserID, appID int64) (
}
func (s *Service) matchNativeApp(ctx context.Context, botUserID int64, platform domain.TelegramLoginNativePlatform, rawCallbackURI string) (domain.TelegramLoginNativeApp, string, bool, error) {
callbackURI, err := NormalizeNativeCallbackURI(rawCallbackURI, s.allowLoopbackHTTP)
callbackURI, err := NormalizeNativeCallbackURI(rawCallbackURI, s.allowHTTP)
if err != nil {
return domain.TelegramLoginNativeApp{}, "", false, nil
}
@ -362,7 +362,7 @@ func (s *Service) ValidateMessageButton(ctx context.Context, botUserID int64, ra
if !found || !client.Enabled {
return "", "", domain.ErrTelegramLoginClientDisabled
}
normalizedURL, domainName, err = NormalizeRedirectURI(rawURL, s.allowLoopbackHTTP)
normalizedURL, domainName, err = NormalizeRedirectURI(rawURL, s.allowHTTP)
if err != nil {
return "", "", err
}
@ -370,7 +370,7 @@ func (s *Service) ValidateMessageButton(ctx context.Context, botUserID int64, ra
if err != nil {
return "", "", domain.ErrTelegramLoginURLInvalid
}
origin, err := NormalizeWebOrigin(u.Scheme+"://"+u.Host, s.allowLoopbackHTTP)
origin, err := NormalizeWebOrigin(u.Scheme+"://"+u.Host, s.allowHTTP)
if err != nil {
return "", "", err
}
@ -415,7 +415,7 @@ func (s *Service) AuthorizeMessageButton(ctx context.Context, params domain.Tele
if err != nil {
return domain.TelegramLoginMessageButtonResult{}, domain.ErrTelegramLoginURLInvalid
}
origin, err := NormalizeWebOrigin(u.Scheme+"://"+u.Host, s.allowLoopbackHTTP)
origin, err := NormalizeWebOrigin(u.Scheme+"://"+u.Host, s.allowHTTP)
if err != nil {
return domain.TelegramLoginMessageButtonResult{}, err
}
@ -552,7 +552,7 @@ func (s *Service) CreateAuthorization(ctx context.Context, params CreateAuthoriz
}
var allowed, isApp bool
var nativeApp domain.TelegramLoginNativeApp
redirectURI, domainName, redirectErr := NormalizeRedirectURI(params.RedirectURI, s.allowLoopbackHTTP)
redirectURI, domainName, redirectErr := NormalizeRedirectURI(params.RedirectURI, s.allowHTTP)
if params.ResponseType == "code" && redirectErr == nil && !params.NativePlatform.Valid() {
allowed, err = s.store.IsTelegramLoginURLAllowed(ctx, client.BotUserID, domain.TelegramLoginAllowedRedirectURI, redirectURI)
if err != nil {
@ -603,14 +603,14 @@ func (s *Service) CreateAuthorization(ctx context.Context, params CreateAuthoriz
u, _ := url.Parse(redirectURI)
origin = u.Scheme + "://" + u.Host
}
origin, err = NormalizeWebOrigin(origin, s.allowLoopbackHTTP)
origin, err = NormalizeWebOrigin(origin, s.allowHTTP)
if err != nil {
return CreatedAuthorization{}, err
}
}
if params.ResponseType == "post_message" {
redirectURL, _ := url.Parse(redirectURI)
redirectOrigin, redirectOriginErr := NormalizeWebOrigin(redirectURL.Scheme+"://"+redirectURL.Host, s.allowLoopbackHTTP)
redirectOrigin, redirectOriginErr := NormalizeWebOrigin(redirectURL.Scheme+"://"+redirectURL.Host, s.allowHTTP)
if redirectOriginErr != nil || redirectOrigin != origin {
return CreatedAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
}
@ -627,7 +627,7 @@ func (s *Service) CreateAuthorization(ctx context.Context, params CreateAuthoriz
if isApp {
return CreatedAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
}
inAppOrigin, err = NormalizeWebOrigin(params.InAppOrigin, s.allowLoopbackHTTP)
inAppOrigin, err = NormalizeWebOrigin(params.InAppOrigin, s.allowHTTP)
if err != nil {
return CreatedAuthorization{}, err
}
@ -693,7 +693,7 @@ func (s *Service) ResolveAuthorizationErrorTarget(ctx context.Context, clientID,
redirectURI, safe, err := s.safeCodeRedirect(ctx, client.BotUserID, rawRedirectURI)
return AuthorizationErrorTarget{ResponseType: responseType, RedirectURI: redirectURI}, safe, err
case "post_message":
redirectURI, _, err := NormalizeRedirectURI(rawRedirectURI, s.allowLoopbackHTTP)
redirectURI, _, err := NormalizeRedirectURI(rawRedirectURI, s.allowHTTP)
if err != nil {
return AuthorizationErrorTarget{}, false, nil
}
@ -702,12 +702,12 @@ func (s *Service) ResolveAuthorizationErrorTarget(ctx context.Context, clientID,
redirect, _ := url.Parse(redirectURI)
origin = redirect.Scheme + "://" + redirect.Host
}
origin, err = NormalizeWebOrigin(origin, s.allowLoopbackHTTP)
origin, err = NormalizeWebOrigin(origin, s.allowHTTP)
if err != nil {
return AuthorizationErrorTarget{}, false, nil
}
redirect, _ := url.Parse(redirectURI)
redirectOrigin, err := NormalizeWebOrigin(redirect.Scheme+"://"+redirect.Host, s.allowLoopbackHTTP)
redirectOrigin, err := NormalizeWebOrigin(redirect.Scheme+"://"+redirect.Host, s.allowHTTP)
if err != nil || redirectOrigin != origin {
return AuthorizationErrorTarget{}, false, nil
}
@ -725,7 +725,7 @@ func (s *Service) ResolveAuthorizationErrorTarget(ctx context.Context, clientID,
}
func (s *Service) safeCodeRedirect(ctx context.Context, botUserID int64, raw string) (string, bool, error) {
if redirectURI, _, err := NormalizeRedirectURI(raw, s.allowLoopbackHTTP); err == nil {
if redirectURI, _, err := NormalizeRedirectURI(raw, s.allowHTTP); err == nil {
allowed, err := s.store.IsTelegramLoginURLAllowed(ctx, botUserID, domain.TelegramLoginAllowedRedirectURI, redirectURI)
if err != nil || allowed {
return redirectURI, allowed, err
@ -856,7 +856,7 @@ func (s *Service) RequestByDeepLinkForOrigin(ctx context.Context, rawURL, rawOri
if rawOrigin == "" || request.InAppOrigin == "" {
return domain.TelegramLoginRequest{}, domain.ErrTelegramLoginOriginNotAllowed
}
origin, err := NormalizeWebOrigin(rawOrigin, s.allowLoopbackHTTP)
origin, err := NormalizeWebOrigin(rawOrigin, s.allowHTTP)
if err != nil {
return domain.TelegramLoginRequest{}, err
}
@ -1067,7 +1067,7 @@ func (s *Service) ExchangeInAppTokenAndIssue(ctx context.Context, token, rawOrig
if issuer == nil || len(token) < 16 || len(token) > 1024 || strings.IndexFunc(token, func(r rune) bool { return r <= 0x20 || r == 0x7f }) >= 0 {
return IssuedAuthorization{}, domain.ErrTelegramLoginCodeInvalid
}
origin, err := NormalizeWebOrigin(rawOrigin, s.allowLoopbackHTTP)
origin, err := NormalizeWebOrigin(rawOrigin, s.allowHTTP)
if err != nil {
return IssuedAuthorization{}, domain.ErrTelegramLoginOriginNotAllowed
}
@ -1294,7 +1294,7 @@ func (s *Service) exchangeAuthorizationCode(ctx context.Context, params Exchange
return ExchangedAuthorization{}, "", domain.ErrTelegramLoginCodeInvalid
}
} else {
redirectURI, _, err = NormalizeRedirectURI(params.RedirectURI, s.allowLoopbackHTTP)
redirectURI, _, err = NormalizeRedirectURI(params.RedirectURI, s.allowHTTP)
if err != nil {
return ExchangedAuthorization{}, "", err
}

View file

@ -94,7 +94,7 @@ func newTelegramLoginTestServiceWithAlgorithms(t *testing.T, now *time.Time, alg
pepper[0] = 9
service, err := NewService(loginStore, sealer, Config{
Issuer: "https://oauth.telesrv.test", AppScheme: "telesrv",
AllowLoopbackHTTP: true, ClientSecretPepper: pepper,
AllowHTTP: true, ClientSecretPepper: pepper,
SupportedSigningAlgorithms: algorithms,
Now: func() time.Time { return *now },
})

View file

@ -14,8 +14,8 @@ import (
const maxTelegramLoginURLLength = 4096
func NormalizeRedirectURI(raw string, allowLoopbackHTTP bool) (normalized, domainName string, err error) {
u, err := parseWebURL(raw, allowLoopbackHTTP)
func NormalizeRedirectURI(raw string, allowHTTP bool) (normalized, domainName string, err error) {
u, err := parseWebURL(raw, allowHTTP)
if err != nil {
return "", "", err
}
@ -34,8 +34,8 @@ func NormalizeRedirectURI(raw string, allowLoopbackHTTP bool) (normalized, domai
return u.String(), u.Hostname(), nil
}
func NormalizeWebOrigin(raw string, allowLoopbackHTTP bool) (string, error) {
u, err := parseWebURL(raw, allowLoopbackHTTP)
func NormalizeWebOrigin(raw string, allowHTTP bool) (string, error) {
u, err := parseWebURL(raw, allowHTTP)
if err != nil {
return "", err
}
@ -46,7 +46,7 @@ func NormalizeWebOrigin(raw string, allowLoopbackHTTP bool) (string, error) {
return u.String(), nil
}
func parseWebURL(raw string, allowLoopbackHTTP bool) (*url.URL, error) {
func parseWebURL(raw string, allowHTTP bool) (*url.URL, error) {
if raw == "" || len(raw) > maxTelegramLoginURLLength || raw != strings.TrimSpace(raw) || strings.IndexFunc(raw, unicode.IsControl) >= 0 {
return nil, domain.ErrTelegramLoginURLInvalid
}
@ -78,7 +78,7 @@ func parseWebURL(raw string, allowLoopbackHTTP bool) (*url.URL, error) {
port = ""
}
case "http":
if !allowLoopbackHTTP || !isLoopbackHost(host) {
if !allowHTTP {
return nil, domain.ErrTelegramLoginURLInvalid
}
if port == "80" {
@ -99,14 +99,6 @@ func parseWebURL(raw string, allowLoopbackHTTP bool) (*url.URL, error) {
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 == "" {

View file

@ -18,8 +18,9 @@ func TestNormalizeRedirectURIIsExactAndRejectsOpenRedirectShapes(t *testing.T) {
}{
{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: "http hostname enabled", raw: "http://example.com:8080/callback", allowHTTP: true, want: "http://example.com:8080/callback", valid: true},
{name: "http ipv4 enabled", raw: "http://192.0.2.25:3000/callback", allowHTTP: true, want: "http://192.0.2.25:3000/callback", valid: true},
{name: "http disabled", 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"},
@ -64,19 +65,19 @@ func TestNormalizeWebOriginRejectsPathAndQuery(t *testing.T) {
}
}
func TestNormalizeLoopbackIPv6PreservesURLBrackets(t *testing.T) {
origin, err := NormalizeWebOrigin("http://[0:0:0:0:0:0:0:1]:80/", true)
func TestNormalizeHTTPIPv6PreservesURLBrackets(t *testing.T) {
origin, err := NormalizeWebOrigin("http://[2001:db8::25]:80/", true)
if err != nil {
t.Fatal(err)
}
if origin != "http://[0:0:0:0:0:0:0:1]" {
if origin != "http://[2001:db8::25]" {
t.Fatalf("origin=%q", origin)
}
redirect, domainName, err := NormalizeRedirectURI("http://[::1]/callback", true)
redirect, domainName, err := NormalizeRedirectURI("http://[2001:db8::26]:3000/callback", true)
if err != nil {
t.Fatal(err)
}
if redirect != "http://[::1]/callback" || domainName != "::1" {
if redirect != "http://[2001:db8::26]:3000/callback" || domainName != "2001:db8::26" {
t.Fatalf("redirect=%q domain=%q", redirect, domainName)
}
}