← All posts

हमारा लॉगिन ठीक 10 सेकंड के लिए अटक गया। यह हमारे अपने सुरक्षा हेडर की करतूत थी।

Authagonal·July 23, 2026
oidcssocspfrontenddebuggingwar-story

लगभग एक हफ़्ते तक, हमारा पोर्टल खोलने वाले लौटते हुए उपयोगकर्ता लॉगिन पेज दिखने से पहले "Loading…" शब्द को दस सेकंड तक स्क्रीन पर टिका हुआ देखते रहे। कभी-कभी नहीं। लगभग नहीं। हर बार पूरे दस सेकंड, और उसके बाद सब कुछ बिल्कुल ठीक काम करता। पहली बार आने वाले इसे कभी नहीं देखते थे। सिर्फ़ वे लोग जो पहले साइन इन कर चुके थे।

ऐसा बग जो कोई भी अनिश्चित समय लेता है, एक परफ़ॉर्मेंस समस्या है। पर ऐसा बग जो ठीक दस सेकंड लेता है, एक इक़बालिया बयान है। किसी स्वस्थ वेब रिक्वेस्ट में कुछ भी अपने आप को दस की साफ़ घात तक गोल नहीं कर लेता। वह संख्या किसी असली काम का जोड़ नहीं है; वह किसी टाइमआउट की छत है, और टाइमआउट का मतलब है कि कहीं कुछ धैर्य के साथ ऐसी चीज़ का इंतज़ार कर रहा है जो कभी आने ही वाली नहीं।

यह उसी चीज़ की कहानी है जिसका वह इंतज़ार कर रहा था, और यह कि जिस चीज़ का उसने इंतज़ार किया वह एक ऐसे सुरक्षा हेडर से क्यों रुक गई जिस पर हमें गर्व था।

पोर्टल माउंट होने पर क्या करता है

हमारा पोर्टल एक सिंगल-पेज ऐप है। जब यह लोड होता है, आपको कुछ भी दिखाने से पहले, यह एक सवाल का जवाब देने की कोशिश करता है: क्या आप पहले से साइन इन हैं? OIDC के साथ ऐसा करने का शिष्ट तरीक़ा एक साइलेंट जाँच है। ऐप आइडेंटिटी प्रोवाइडर से पूछता है "अगर इस ब्राउज़र के पास पहले से कोई सेशन है, तो उपयोगकर्ता को परेशान किए बिना मुझे एक ताज़ा टोकन दे दो।" हमारी क्लाइंट लाइब्रेरी, oidc-client-ts, इसे signinSilent() के रूप में उजागर करती है, और हम इसे माउंट पर एक renewSession() हेल्पर से कॉल करते थे।

वह साइलेंट जाँच दो तरीक़ों से चल सकती है। अगर ऐप के पास एक रिफ़्रेश टोकन है, तो लाइब्रेरी एक चुपचाप बैक-चैनल एक्सचेंज करती है, कोई UI शामिल नहीं होता, और आप अंदर आ जाते हैं। अगर उसके पास रिफ़्रेश टोकन नहीं है, तो लाइब्रेरी पुराने तंत्र पर लौट जाती है: यह prompt=none के साथ आइडेंटिटी प्रोवाइडर के authorize एंडपॉइंट की ओर इशारा करता हुआ एक छिपा हुआ iframe खोलती है, और उस iframe के कोई नतीजा वापस पोस्ट करने का इंतज़ार करती है। iframe का पूरा मक़सद यही है कि वह अदृश्य रहे। आपको इसे कभी नहीं देखना चाहिए, और किसी स्वस्थ सेटअप में आप इसे कभी नहीं देखते, क्योंकि यह मिलीसेकंड में हल हो जाता है।

हमारा तो कभी हल हुआ ही नहीं।

वह दीवार जो हमने ख़ुद बनाई

iframe auth होस्ट को लोड करता है। और वह auth होस्ट, हमारे उन सभी होस्ट की तरह जिन्हें गंभीरता से लिया जाना चाहिए, दो हेडर भेजता है जिनका पूरा काम यही कहना है कि "तुम मुझे किसी फ़्रेम में नहीं रख सकते":

  • X-Frame-Options: DENY
  • Content-Security-Policy: frame-ancestors 'none'

ये क्लिकजैकिंग बचाव हैं, और ये सही हैं। जो हमलावर आपके लॉगिन पेज को iframe में डाल सकता है, वह उसे किसी छलावे के नीचे तैरा सकता है, उपयोगकर्ता को धोखे से किसी हानिरहित दिखने वाली चीज़ में असली क्रेडेंशियल टाइप करवा सकता है, और उन्हें बटोर सकता है। frame-ancestors 'none' वह आधुनिक निर्देश है कि कोई भी ऑरिजिन, यहाँ तक कि हमारा अपना भी नहीं, इस पेज को एम्बेड कर सकता। हमने इसे जान-बूझकर चालू किया था। यह ठीक वैसी ही चीज़ है जिसे कोई सुरक्षा समीक्षा ढूँढती है और सराहती है।

तो जब oidc-client-ts ने उस होस्ट के विरुद्ध अपना छिपा हुआ iframe खोला, तो ब्राउज़र ने बिल्कुल वही किया जो हमने उसे करने को कहा था: उसने पेज को किसी फ़्रेम में रेंडर करने से इनकार कर दिया। और यहीं वह निर्दयी हिस्सा है। एक इनकार किया हुआ फ़्रेम कोई अपवाद नहीं फेंकता। लाइब्रेरी के पकड़ने के लिए कोई एरर इवेंट नहीं होता, कोई रिजेक्ट हुआ promise नहीं, कोई कंसोल लाइन नहीं। iframe बस वहीं, ख़ाली, अनिश्चित काल तक पड़ा रहता है। लाइब्रेरी की नज़र में अभी तक कुछ हुआ ही नहीं, इसलिए वह वही एकमात्र काम करती है जो वह कर सकती है। वह अपने टाइमआउट का इंतज़ार करती है। वह टाइमआउट, डिफ़ॉल्ट silentRequestTimeout, दस सेकंड का है।

दस सेकंड तक एक छिपा हुआ iframe एक कोरी दीवार को घूरता रहता है, फिर लाइब्रेरी हार मान लेती है, promise आख़िरकार रिजेक्ट हो जाता है, ऐप कंधे उचकाकर आपको असली लॉगिन पेज पर भेज देता है, और सब कुछ काम करता है। वह अटकाव कभी विफलता था ही नहीं। वह एक सफलता थी जिसने एक अभिशप्त iframe से होकर घुमावदार रास्ता लिया।

दो सही चीज़ें, एक ख़राब सीवन

इसे सचमुच देख पाना इसलिए मुश्किल था क्योंकि कुछ भी टूटा हुआ नहीं था। सुरक्षा हेडर सही थे। silent-renew फ़ॉलबैक सही था, एक वैध और व्यापक रूप से इस्तेमाल होने वाला OIDC पैटर्न। हर कंपोनेंट ठीक वैसा ही बर्ताव कर रहा था जैसा उसे डिज़ाइन किया गया था और ठीक वैसा ही जैसा कोई भी समीक्षक चाहेगा। दस सेकंड का वह अटकाव उनमें से किसी एक के भीतर नहीं रहता था। वह उनके बीच की जगह में रहता था, उस धारणा में जो हर एक ने दूसरे के बारे में बना रखी थी। SSO लाइब्रेरी ने मान लिया कि वह आइडेंटिटी प्रोवाइडर को फ़्रेम में डाल सकती है। आइडेंटिटी प्रोवाइडर ने मान लिया कि किसी को भी कभी उसे फ़्रेम में डालने की इजाज़त नहीं होनी चाहिए। दोनों धारणाओं का बचाव किया जा सकता था। वे बस एक-दूसरे के साथ असंगत थीं, और कोई एक भी फ़ाइल उस विरोधाभास को अपने भीतर नहीं समेटे थी।

आख़िर उसने iframe की ओर हाथ बढ़ाया ही क्यों

फिर भी एक सवाल बचा रह गया। तेज़ रास्ता, यानी रिफ़्रेश-टोकन एक्सचेंज, iframe को पूरी तरह छोड़ देता। तो लौटते हुए उपयोगकर्ता धीमे रास्ते पर क्यों पहुँच रहे थे? क्योंकि उनके पास रखने के लिए कोई रिफ़्रेश टोकन ही नहीं था। और उनके पास रिफ़्रेश टोकन इसलिए नहीं था क्योंकि हमारे अपने पोर्टल OAuth क्लाइंट AllowOfflineAccess के बिना प्रोविज़न किए गए थे, यानी वह फ़्लैग जो किसी क्लाइंट को रिफ़्रेश टोकन जारी करने की अनुमति देता है। कोई ऑफ़लाइन एक्सेस नहीं, कोई रिफ़्रेश टोकन नहीं, कोई तेज़ रास्ता नहीं, और हर लौटता हुआ उपयोगकर्ता उसी iframe में धकेल दिया जाता था जो कभी लोड ही नहीं हो सकता था।

यही असली दोष था, और यह एक डेटा समस्या थी जो हर टेनेंट में फैली हुई थी, न कि कोई एक-लाइन का कोड बदलाव जिसे हम एक बार में शिप कर सकते। इसलिए इसका इलाज एक रिकंसाइल सर्विस है जो स्टार्टअप पर हर टेनेंट के पोर्टल क्लाइंट पर AllowOfflineAccess को दोबारा लागू करती है, और अगले डिप्लॉय पर पूरे बेड़े को बिना किसी के हाथ से किसी टेनेंट को छुए ठीक कर देती है। रिफ़्रेश टोकन फिर से बहने लगे, और तेज़ रास्ता अपने आप ज़िंदा हो गया।

समाधान, और सबक़

रिकंसाइल सर्विस ने कारण को ठीक कर दिया। पर एक लॉगिन को दस सेकंड तक अटकना नहीं चाहिए, भले ही वह आख़िरकार धीमे रास्ते पर ही क्यों न पहुँचे, इसलिए हमने उस सीवन को भी मज़बूत किया। renewSession() अब पहले संग्रहीत उपयोगकर्ता की जाँच करता है: अगर हाथ में कोई रिफ़्रेश टोकन नहीं है, तो वह तुरंत रास्ता छोटा कर देता है और तुरंत कुछ भी नहीं लौटाता, उस iframe को छोड़ देता है जिसे वह पहले से अभिशप्त जानता है, और उपयोगकर्ता को सीधे इंटरैक्टिव लॉगिन पर भेज देता है। रिफ़्रेश-टोकन वाला तेज़ रास्ता अछूता है। और किसी भी बैकग्राउंड रिन्यूअल के लिए बचाव के तौर पर जो अब भी एक iframe खोलता है, हमने टाइमआउट को दस सेकंड से घटाकर पाँच कर दिया, ताकि सबसे बुरी स्थिति भी आधी ही बुरी रहे।

असली सबक़ जो हमने अपने पास रखा, वह बग की एक पूरी श्रेणी के बारे में है, न कि इस एक उदाहरण के बारे में। अटकाव एक बग है, भले ही वह कोई एरर, कोई अपवाद, किसी लॉग में कोई लाल लाइन न छोड़े। वह अपने पीछे इकलौता सबूत जो छोड़ता है, वह है बीता हुआ समय। और जब वह समय एक साफ़ गोल संख्या हो, तो अनुकूलित करने के लिए धीमे काम की तलाश में मत निकलिए। किसी टाइमआउट की तलाश में निकलिए, और फिर उसके दूसरे छोर पर वह चीज़ ढूँढिए जो चुपचाप, हमेशा के लिए, कभी जवाब देने ही वाली नहीं। हमारी वह चीज़ एक iframe थी, जो एक ऐसे दरवाज़े पर शिष्टता से दस्तक दे रहा था जिसे हमने जान-बूझकर बंद कर रखा था।

अगर आप चाहते हैं कि आपका लॉगिन फ़्लो पहले से ही जानता हो कि एक कसकर बंद किया गया auth होस्ट और एक silent-renew iframe आपस में नहीं मिलते, तो यह वही सीवन है जिससे हम पहले ही टकरा चुके हैं ताकि आपको कभी न टकराना पड़े। Authagonal SSO प्लंबिंग और सुरक्षा हेडर को एक ही सिस्टम के रूप में शिप करता है जिसे एक साथ परखा गया था, न कि दो सही हिस्सों के रूप में जिनके असंगत होने का पता आपको हर पेज लोड पर दस सेकंड की क़ीमत पर चलता है।