owpengram-server/internal/telegramloginhttp/handler_test.go

713 lines
30 KiB
Go

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