大約一週以來,回訪使用者開啟我們的入口網站後,會看到「Loading…」在畫面上停留十秒,之後才出現登入頁面。這不是偶發,也不是大約十秒,而是每次都剛好十秒,之後一切就運作正常。新訪客從未遇到此問題,只有曾登入過的使用者才會發生。
一個隨機耗時的錯誤屬於效能問題;但一個剛好耗時 十秒 的錯誤則是一種自白。在健康的網路請求中,沒有任何東西會把自己四捨五入成乾淨的十次方數值。那個數字並非真正工作量的總和,而是逾時的上限;而逾時代表某處正在耐心等待一個永遠不會到達的東西。
這就是它在等待什麼,以及為什麼它等待的東西被我們引以為傲的安全標頭擋住了。
入口網站在 mount 時做了什麼
我們的入口網站是單頁應用程式。當它載入時,在顯示任何內容之前,它會試圖回答一個問題:你是否已經登入?使用 OIDC 禮貌地進行此檢查的方法是 無聲 檢查。應用程式詢問身分提供者:「如果此瀏覽器已有工作階段,請給我一個新的 token,而不打擾使用者。」我們的用戶端函式庫 oidc-client-ts 將此暴露為 signinSilent(),我們在 mount 時從 renewSession() 輔助函式呼叫它。
無聲檢查有兩種執行方式。如果應用程式持有 refresh token,函式庫會進行安靜的後通道交換,不涉及 UI,你就已登入。如果它 沒有 持有 refresh token,函式庫會退回到較舊的機制:它開啟一個隱藏的 iframe,指向身分提供者的 authorize 端點並帶有 prompt=none,然後等待該 iframe 回傳結果。iframe 的重點是它不可見。你永遠不應該看到它,而在健康的設定中,你也永遠不會看到它,因為它會在毫秒內解析。
我們的 iframe 從未解析。
我們自己築起的牆
iframe 載入驗證主機。而驗證主機,就像我們運行的每個需要認真對待的主機一樣,會傳送兩個標頭,其唯一工作就是說「你不得將我放入框架中」:
X-Frame-Options: DENYContent-Security-Policy: frame-ancestors 'none'
這些是點擊劫持防禦措施,而且是正確的。能夠將你的登入頁面放入 iframe 的攻擊者,可以將它浮動在誘餌下方,誘使使用者在看起來無害的東西中輸入真實憑證,並竊取它們。frame-ancestors 'none' 是現代指令,任何來源(甚至我們自己)都不得嵌入此頁面。我們是故意開啟它的。這正是安全審查會尋找並獎勵的那種東西。
因此,當 oidc-client-ts 針對該主機開啟其隱藏的 iframe 時,瀏覽器確實做了我們告訴它的事:拒絕在框架中渲染頁面。而殘酷的地方在於。被拒絕的框架不會拋出錯誤。函式庫沒有錯誤事件可捕捉,沒有被拒絕的 promise,沒有主控台行。iframe 只是坐在那裡,空白地,無限期地。從函式庫的角度來看,什麼都還沒發生,所以它做了唯一能做的事。它等待其逾時。那個逾時,預設的 silentRequestTimeout,是十秒。
隱藏的 iframe 盯著空白的牆面十秒,然後函式庫放棄,promise 終於被拒絕,應用程式聳聳肩,將你重新導向到真正的登入頁面,一切都運作了。卡住從來不是失敗。它是成功,只是走了一條穿過注定失敗的 iframe 的風景路線。
兩件正確的事,一個糟糕的接縫
讓這件事真正難以發現的是,沒有任何東西壞掉。安全標頭是正確的。無聲續訂的備援是正確的,這是一個合法且廣泛使用的 OIDC 模式。每個元件都完全按照設計運作,也完全按照任何審查者想要的方式運作。十秒的卡住並不存在於它們中的任何一個。它存在於它們 之間 的空間,在每個元件對另一個元件的假設中。SSO 函式庫假設它可以將身分提供者放入框架。身分提供者假設任何人都不應該被允許將它放入框架。這兩個假設都是合理的。它們只是不相容,而且沒有任何單一檔案包含這個矛盾。
為什麼它根本會去使用 iframe
這仍然留下一個問題。快速路徑,refresh token 交換,會完全跳過 iframe。為什麼回訪使用者會走到慢速路徑?因為他們沒有 refresh token 可持有。而他們沒有 refresh token,是因為我們自己的入口網站 OAuth 用戶端在佈建時沒有 AllowOfflineAccess 旗標,該旗標授權用戶端被發放 refresh token。沒有離線存取,沒有 refresh token,沒有快速路徑,每個回訪使用者都被轉到永遠無法載入的 iframe。
這才是真正的缺陷,而且是一個散布在每個租用戶的資料問題,而不是我們可以一次發佈的一行程式碼變更。因此,修復是一個調解服務,它在啟動時將 AllowOfflineAccess 重新套用於每個租用戶的入口網站用戶端,在下一次部署時修正整個機群,而無需任何人手動觸碰租用戶。Refresh token 開始再次流動,快速路徑也自動恢復了。
修復,以及教訓
調解服務修復了原因。但即使登入最終走到慢速路徑,登入也不應該卡住十秒,所以我們也強化了接縫。renewSession() 現在會先檢查儲存的使用者:如果手頭沒有 refresh token,它會立即短路並傳回空值,跳過它已經知道注定失敗的 iframe,並直接將使用者傳送到互動式登入。refresh token 快速路徑不受影響。而作為任何仍會開啟 iframe 的背景續訂的後備,我們將逾時從十秒縮短為五秒,因此最壞情況只有一半糟糕。
我們真正保留的教訓是關於一類錯誤,而不是這個單一實例。卡住是一個錯誤,即使它不發出錯誤、不發出例外、不在日誌中畫紅線。它留下的唯一證據是經過的時間。而當那個時間是一個乾淨的整數時,不要去尋找要最佳化的慢速工作。去尋找一個逾時,然後找到在另一端永遠不會回答的東西。我們的案例是一個 iframe,禮貌地敲著我們故意鎖上的門。
如果你希望你的登入流程已經知道,受保護的驗證主機與無聲續訂 iframe 不能混用,那是一個我們已經遇到的接縫,所以你永遠不必遇到。Authagonal 將 SSO 管道與安全標頭作為一個經過共同測試的系統一起提供,而不是兩個正確的半部,讓你在每次頁面載入十秒後才發現它們不相容。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.