fix: sync Telegram Login mobile popup delivery
This commit is contained in:
parent
ebead9e98c
commit
6f2662b58c
3 changed files with 81 additions and 9 deletions
|
|
@ -166,9 +166,16 @@ var authorizationPage = template.Must(template.New("telegram-login").Parse(`<!do
|
|||
<title>Log in with {{.AppName}}</title><style>
|
||||
:root{color-scheme:light dark}body{font:16px/1.45 system-ui,sans-serif;margin:0;background:#17212b;color:#fff}.card{max-width:460px;margin:10vh auto;padding:28px;border-radius:18px;background:#202b36;box-shadow:0 16px 48px #0006}h1{margin:.1em 0 .5em}.button{display:block;text-align:center;margin:24px 0;padding:13px 18px;border-radius:11px;background:#2aabee;color:#fff;text-decoration:none;font-weight:700}.match{text-align:center;margin:22px 0;padding:18px;border-radius:14px;background:#17212b}.match p{margin:0 0 8px}.match-code{font-size:44px;line-height:1.2}.muted{color:#a9b5c1;font-size:14px}.error{color:#ff8d8d}</style></head>
|
||||
<body><main class="card"><h1>Log in with {{.AppName}}</h1><p>Open the {{.AppName}} app and approve this request. Keep this page open.</p><a class="button" href="{{.DeepLink}}">Open {{.AppName}}</a>{{if .MatchCode}}<section class="match" aria-labelledby="match-title"><p id="match-title">When prompted, select this emoji in {{.AppName}}:</p><div id="match-code" class="match-code" role="img" aria-label="Matching emoji">{{.MatchCode}}</div></section>{{end}}<p id="status" class="muted">Waiting for approval…</p><p class="muted">This request expires at {{.ExpiresAt}}.</p></main>
|
||||
<script nonce="{{.CSPNonce}}">const token={{.BrowserToken}},responseType={{.ResponseType}},targetOrigin={{.TargetOrigin}};const statusNode=document.getElementById('status');function deliver(data){if(!window.opener||!targetOrigin)throw new Error('missing_opener');const payload=data.id_token?{event:'auth_result',result:data.id_token}:{event:'auth_result',error:data.error||data.status};window.opener.postMessage(payload,targetOrigin);window.close()}async function poll(){try{const body=new URLSearchParams({browser_token:token});const response=await fetch('/auth/status',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body,cache:'no-store'});const data=await response.json();if(!response.ok){throw new Error(data.error||'request_failed')}if(data.status==='pending'){setTimeout(poll,1000);return}if(responseType==='post_message'){deliver(data);return}if(data.redirect_url){location.replace(data.redirect_url);return}throw new Error('invalid_response')}catch(error){statusNode.className='error';statusNode.textContent='Login status unavailable. Please restart the login flow.'}}poll();</script></body></html>`))
|
||||
<script nonce="{{.CSPNonce}}">const token={{.BrowserToken}},responseType={{.ResponseType}},targetOrigin={{.TargetOrigin}};const statusNode=document.getElementById('status');function notifyPending(){if(responseType!=='post_message'||!window.opener||!targetOrigin)return;try{window.opener.postMessage({event:'auth_pending',browser_token:token},targetOrigin)}catch(_){}}function deliver(data){if(!window.opener||!targetOrigin){statusNode.className='muted';statusNode.textContent=data.id_token?'Login approved. Return to the original login tab.':'Login finished. Return to the original login tab.';return}const payload=data.id_token?{event:'auth_result',result:data.id_token}:{event:'auth_result',error:data.error||data.status};window.opener.postMessage(payload,targetOrigin);window.close()}async function poll(){try{const body=new URLSearchParams({browser_token:token});const response=await fetch('/auth/status',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body,cache:'no-store'});const data=await response.json();if(!response.ok){throw new Error(data.error||'request_failed')}if(data.status==='pending'){setTimeout(poll,1000);return}if(responseType==='post_message'){deliver(data);return}if(data.redirect_url){location.replace(data.redirect_url);return}throw new Error('invalid_response')}catch(error){statusNode.className='error';statusNode.textContent='Login status unavailable. Please restart the login flow.'}}notifyPending();poll();</script></body></html>`))
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue