From 6f2662b58c8475aff184a6bf635687743fb7e155 Mon Sep 17 00:00:00 2001 From: A Date: Tue, 21 Jul 2026 15:46:52 +0800 Subject: [PATCH] fix: sync Telegram Login mobile popup delivery --- internal/telegramloginhttp/handler.go | 35 +++++++++++++++- internal/telegramloginhttp/handler_test.go | 46 ++++++++++++++++++++-- internal/telegramloginhttp/sdk.go | 9 +++-- 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/internal/telegramloginhttp/handler.go b/internal/telegramloginhttp/handler.go index 9130a5e8..b977b76d 100644 --- a/internal/telegramloginhttp/handler.go +++ b/internal/telegramloginhttp/handler.go @@ -166,9 +166,16 @@ var authorizationPage = template.Must(template.New("telegram-login").Parse(`Log in with {{.AppName}}

Log in with {{.AppName}}

Open the {{.AppName}} app and approve this request. Keep this page open.

Open {{.AppName}}{{if .MatchCode}}

When prompted, select this emoji in {{.AppName}}:

{{end}}

Waiting for approval…

This request expires at {{.ExpiresAt}}.

-`)) +`)) func (h *Handler) authorize(w http.ResponseWriter, r *http.Request) { + // Authorization popups must retain their cross-origin opener long enough to + // hand the registered RP a short-lived browser token. The default + // same-origin-allow-popups policy isolates a document that was itself opened + // cross-origin, so it breaks the exact-origin postMessage flow before the + // external Telegram app is launched. Other provider endpoints keep the + // stricter default policy. + w.Header().Set("Cross-Origin-Opener-Policy", "unsafe-none") clientIP := h.requestIP(r) if !h.allow(w, r, "authorize", clientIP, 30, time.Minute) { return @@ -449,6 +456,9 @@ func (h *Handler) authorizationStatus(w http.ResponseWriter, r *http.Request) { writeOAuthError(w, http.StatusBadRequest, "invalid_request", "invalid browser request") return } + if !h.authorizeStatusOrigin(w, r, request) { + return + } switch request.Status { case domain.TelegramLoginRequestPending: writeJSON(w, http.StatusOK, map[string]string{"status": "pending"}) @@ -491,6 +501,29 @@ func (h *Handler) authorizationStatus(w http.ResponseWriter, r *http.Request) { } } +// authorizeStatusOrigin keeps the original same-origin popup poll working and +// permits the registered relying-party origin to take over polling when a +// mobile browser drops window.opener during an external app round-trip. The +// short-lived browser token remains a bearer credential, but browser-readable +// responses are exposed only to the exact origin persisted on the request. +func (h *Handler) authorizeStatusOrigin(w http.ResponseWriter, r *http.Request, request domain.TelegramLoginRequest) bool { + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" { + return true + } + issuerOrigin, err := loginapp.NormalizeWebOrigin(h.tokens.Issuer(), h.allowLoopbackHTTP) + if err == nil && origin == issuerOrigin { + return true + } + if request.ResponseType == "post_message" && origin == request.Origin { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Add("Vary", "Origin") + return true + } + writeOAuthError(w, http.StatusForbidden, "access_denied", "browser origin is not authorized") + return false +} + func (h *Handler) token(w http.ResponseWriter, r *http.Request) { if !h.allow(w, r, "token-ip", h.requestIP(r), 60, time.Minute) { return diff --git a/internal/telegramloginhttp/handler_test.go b/internal/telegramloginhttp/handler_test.go index c3dd05bd..2563de45 100644 --- a/internal/telegramloginhttp/handler_test.go +++ b/internal/telegramloginhttp/handler_test.go @@ -494,16 +494,51 @@ func TestJavaScriptPostMessageFlowReturnsStableDirectIDToken(t *testing.T) { 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 { @@ -520,13 +555,13 @@ func TestJavaScriptPostMessageFlowReturnsStableDirectIDToken(t *testing.T) { } poll := func() map[string]string { t.Helper() - statusRecorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodPost, "/auth/status", strings.NewReader(url.Values{"browser_token": {browserToken}}.Encode())) - request.Header.Set("Content-Type", "application/x-www-form-urlencoded") - f.handler.ServeHTTP(statusRecorder, request) + 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) @@ -660,6 +695,9 @@ func TestTelegramLoginJavaScriptIsCacheableAndConditional(t *testing.T) { 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") { diff --git a/internal/telegramloginhttp/sdk.go b/internal/telegramloginhttp/sdk.go index cadd2c00..d95e18ec 100644 --- a/internal/telegramloginhttp/sdk.go +++ b/internal/telegramloginhttp/sdk.go @@ -30,7 +30,8 @@ function normalize(options){ return {client_id:String(options.client_id),scope:scopes.join(' '),nonce:String(options.nonce||'').slice(0,1024),lang:String(options.lang||'').slice(0,16)}; } function randomURL(bytes){var data=new Uint8Array(bytes);crypto.getRandomValues(data);var raw='';data.forEach(function(value){raw+=String.fromCharCode(value);});return btoa(raw).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');} -function finish(flow,result){if(active!==flow){return;}active=null;if(flow.timer){clearInterval(flow.timer);}if(flow.listener){global.removeEventListener('message',flow.listener);}callback(flow.callback,result);} +function finish(flow,result){if(active!==flow){return;}active=null;if(flow.timer){clearInterval(flow.timer);}if(flow.pollTimer){clearTimeout(flow.pollTimer);}if(flow.listener){global.removeEventListener('message',flow.listener);}callback(flow.callback,result);} +async function pollFromParent(flow,token){if(active!==flow){return;}try{var body=new URLSearchParams({browser_token:token});var response=await fetch(provider+'/auth/status',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:body,credentials:'omit',cache:'no-store'});var data=await response.json();if(!response.ok){throw new Error(data.error||'request_failed');}if(data.status==='pending'){flow.pollTimer=setTimeout(function(){pollFromParent(flow,token);},1000);return;}finish(flow,data.id_token?build({result:data.id_token}):{error:data.error||data.status});}catch(error){finish(flow,{error:error.message||'login_failed'});}} function sendEvent(type,data){if(global.TelegramWebviewProxy&&typeof global.TelegramWebviewProxy.postEvent==='function'){global.TelegramWebviewProxy.postEvent(type,JSON.stringify(data||{}));}} async function receiveEvent(type,data){ if(type==='oauth_supported'){inApp=true;return;} @@ -44,7 +45,7 @@ async function receiveEvent(type,data){ function begin(options,cb,isNormalized){ var normalized;try{normalized=isNormalized?options:normalize(options);}catch(error){callback(cb,{error:error.message});return null;} if(active){callback(cb,{error:'login_in_progress'});return null;} - var flow={popup:null,callback:cb,listener:null,timer:null};active=flow; + var flow={popup:null,callback:cb,listener:null,timer:null,pollTimer:null,browserToken:''};active=flow; if(inApp){ if(inAppPending){finish(flow,{error:'login_in_progress'});return null;}inAppPending=true; var inAppParams=new URLSearchParams({scope:normalized.scope,origin:global.location.origin,client_id:normalized.client_id,response_type:'id_token'}); @@ -53,8 +54,8 @@ function begin(options,cb,isNormalized){ } var popup=global.open('about:blank','telegram-login-'+randomURL(8),'popup,width=550,height=650,resizable=yes,scrollbars=yes'); if(!popup){finish(flow,{error:'popup_blocked'});return null;}flow.popup=popup; - flow.listener=function(event){if(event.origin!==provider||event.source!==popup){return;}var data=event.data;try{if(typeof data==='string'){data=JSON.parse(data);}}catch(_){return;}if(!data||data.event!=='auth_result'){return;}finish(flow,build(data));}; - global.addEventListener('message',flow.listener);flow.timer=setInterval(function(){if(popup.closed){finish(flow,{error:'popup_closed'});}},500); + flow.listener=function(event){if(event.origin!==provider||event.source!==popup){return;}var data=event.data;try{if(typeof data==='string'){data=JSON.parse(data);}}catch(_){return;}if(!data){return;}if(data.event==='auth_pending'){var token=String(data.browser_token||'');if(!/^[A-Za-z0-9_-]{43}$/.test(token)){finish(flow,{error:'invalid_browser_token'});return;}if(flow.browserToken&&flow.browserToken!==token){finish(flow,{error:'invalid_browser_token'});return;}if(!flow.browserToken){flow.browserToken=token;pollFromParent(flow,token);}return;}if(data.event!=='auth_result'){return;}finish(flow,build(data));}; + global.addEventListener('message',flow.listener);flow.timer=setInterval(function(){if(popup.closed&&!flow.browserToken){finish(flow,{error:'popup_closed'});}},500); try{var params=new URLSearchParams({client_id:normalized.client_id,redirect_uri:global.location.origin+global.location.pathname,response_type:'post_message',scope:normalized.scope});if(normalized.nonce){params.set('nonce',normalized.nonce);}if(normalized.lang){params.set('lang',normalized.lang);}popup.location.replace(provider+'/auth?'+params.toString());} catch(error){try{popup.close();}catch(_){}finish(flow,{error:error.message||'login_failed'});} return popup;