← All posts

Trang đăng nhập của chúng tôi treo đúng 10 giây. Chính header bảo mật của chúng tôi gây ra.

Authagonal·July 23, 2026
oidcssocspfrontenddebuggingwar-story

Trong khoảng một tuần, những người dùng quay lại mở portal của chúng tôi nhìn chữ "Đang tải…" nằm trên màn hình suốt mười giây trước khi trang đăng nhập xuất hiện. Không phải thỉnh thoảng. Không phải xấp xỉ. Mười giây, lần nào cũng vậy, rồi sau đó mọi thứ chạy hoàn hảo. Người truy cập lần đầu không bao giờ thấy điều này. Chỉ những ai từng đăng nhập trước đó.

Một lỗi mất một khoảng thời gian ngẫu nhiên là vấn đề hiệu năng. Một lỗi mất đúng mười giây là một lời thú tội. Không có gì trong một request web khỏe mạnh lại tự làm tròn thành một lũy thừa của mười gọn gàng. Con số đó không phải là tổng của một công việc thực sự nào; nó là trần của một timeout, và một timeout nghĩa là có thứ gì đó, ở đâu đó, đang kiên nhẫn chờ đợi một điều sẽ không bao giờ đến.

Đây là câu chuyện về thứ mà nó đang chờ, và vì sao thứ nó chờ lại bị chặn bởi một header bảo mật mà chúng tôi rất tự hào.

Portal làm gì lúc mount

Portal của chúng tôi là một ứng dụng single-page. Khi nó tải, trước khi hiển thị bất cứ thứ gì cho bạn, nó cố trả lời một câu hỏi: bạn đã đăng nhập rồi phải không? Cách lịch sự để làm điều đó với OIDC là một lượt kiểm tra âm thầm. Ứng dụng hỏi identity provider "nếu trình duyệt này đã có sẵn một phiên, hãy đưa cho tôi một token mới mà không làm phiền người dùng." Thư viện client của chúng tôi, oidc-client-ts, phơi bày điều này dưới dạng signinSilent(), và chúng tôi gọi nó lúc mount từ một hàm trợ giúp renewSession().

Có hai cách để lượt kiểm tra âm thầm đó chạy. Nếu ứng dụng đang giữ một refresh token, thư viện thực hiện một cuộc trao đổi back-channel lặng lẽ, không dính đến UI, và bạn đã vào được. Nếu nó không giữ một refresh token, thư viện lùi về cơ chế cũ hơn: nó mở một iframe ẩn trỏ tới authorize endpoint của identity provider với prompt=none, và chờ iframe đó gửi kết quả về. Toàn bộ ý nghĩa của iframe là nó vô hình. Bạn không bao giờ được cho là sẽ thấy nó, và trên một hệ thống khỏe mạnh bạn chẳng bao giờ thấy, vì nó phân giải trong vài mili-giây.

Cái của chúng tôi thì không bao giờ phân giải nổi.

Bức tường chính chúng tôi dựng lên

Iframe tải auth host. Và auth host, giống như mọi host mà chúng tôi vận hành và có bất kỳ lý do gì để được xem trọng, gửi kèm hai header với nhiệm vụ duy nhất là nói "anh không được đặt tôi vào trong một frame":

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

Đây là các biện pháp phòng thủ clickjacking, và chúng đúng đắn. Một kẻ tấn công có thể iframe trang đăng nhập của bạn thì có thể thả nó bên dưới một lớp mồi nhử, lừa người dùng gõ thông tin đăng nhập thật vào thứ trông có vẻ vô hại, rồi thu thập chúng. frame-ancestors 'none' là chỉ thị hiện đại nói rằng không origin nào, kể cả của chính chúng tôi, được phép nhúng trang này. Chúng tôi bật nó lên một cách có chủ đích. Nó chính xác là loại thứ mà một cuộc rà soát bảo mật tìm kiếm và tưởng thưởng.

Vậy nên khi oidc-client-ts mở iframe ẩn của nó nhắm vào host đó, trình duyệt làm đúng chính xác điều chúng tôi đã bảo nó: nó từ chối render trang trong một frame. Và đây là phần tàn nhẫn. Một frame bị từ chối không ném ra lỗi. Không có sự kiện lỗi nào để thư viện bắt lấy, không có promise bị reject, không có dòng nào trên console. Iframe cứ nằm đó, trống rỗng, vô thời hạn. Dưới góc nhìn của thư viện thì chưa có gì xảy ra cả, nên nó làm điều duy nhất nó có thể. Nó chờ hết timeout. Cái timeout đó, giá trị mặc định silentRequestTimeout, là mười giây.

Mười giây một iframe ẩn trân trân nhìn vào một bức tường trắng, rồi thư viện bỏ cuộc, promise cuối cùng cũng reject, ứng dụng nhún vai và chuyển hướng bạn tới trang đăng nhập thật, và mọi thứ chạy được. Cú treo chưa bao giờ là một thất bại. Nó là một thành công đi đường vòng ngắm cảnh qua một iframe đã được định sẵn số phận.

Hai thứ đúng đắn, một đường nối tồi

Điều khiến chuyện này thực sự khó nhận ra là chẳng có gì hỏng cả. Các header bảo mật đều đúng. Cơ chế dự phòng silent-renew cũng đúng, một mẫu hình OIDC hợp lệ và được dùng rộng rãi. Mọi thành phần đều hành xử đúng như thiết kế và đúng như bất kỳ người rà soát nào mong muốn. Cú treo mười giây không nằm bên trong bất kỳ thành phần nào. Nó nằm ở khoảng không gian giữa chúng, trong giả định mà mỗi bên đặt ra về bên kia. Thư viện SSO giả định nó có thể đặt identity provider vào frame. Identity provider giả định không ai được phép đặt nó vào frame cả. Cả hai giả định đều có cơ sở. Chúng chỉ đơn giản là không tương thích, và không một file đơn lẻ nào chứa đựng mâu thuẫn đó.

Vì sao ngay từ đầu nó lại phải cầu viện đến iframe

Điều đó vẫn để lại một câu hỏi. Đường nhanh, cuộc trao đổi refresh-token, lẽ ra đã bỏ qua iframe hoàn toàn. Vì sao những người dùng quay lại lại rơi vào đường chậm? Vì họ không có refresh token nào để giữ. Và họ không có refresh token bởi vì chính các OAuth client của portal chúng tôi đã được cấp phát mà thiếu AllowOfflineAccess, cái cờ cho phép một client được phát một refresh token. Không offline access, không refresh token, không đường nhanh, và mọi người dùng quay lại đều bị đẩy vào iframe không bao giờ tải nổi.

Đó mới là khiếm khuyết thật sự, và nó là một vấn đề dữ liệu trải khắp mọi tenant, không phải một thay đổi code một dòng mà chúng tôi có thể ship một lần là xong. Nên bản sửa là một reconcile service áp dụng lại AllowOfflineAccess cho các portal client của mọi tenant lúc khởi động, chỉnh sửa toàn bộ đội máy trong lần deploy kế tiếp mà không ai phải đụng tay vào từng tenant. Refresh token bắt đầu chảy trở lại, và đường nhanh tự nó sống lại.

Bản sửa, và bài học

Reconcile service đã sửa nguyên nhân. Nhưng một lượt đăng nhập không nên khựng lại mười giây ngay cả khi nó rốt cuộc rơi vào đường chậm, nên chúng tôi cũng gia cố đường nối. renewSession() giờ đây soi người dùng đã lưu trước tiên: nếu không có refresh token trong tay, nó cắt mạch và trả về không gì cả ngay lập tức, bỏ qua cái iframe mà nó đã biết là vô vọng, và đưa người dùng thẳng tới lượt đăng nhập tương tác. Đường nhanh refresh-token vẫn nguyên vẹn. Và như một tấm lưới đỡ cho bất kỳ lượt gia hạn chạy nền nào vẫn còn mở iframe, chúng tôi cắt timeout từ mười giây xuống năm, để trường hợp tệ nhất chỉ còn tệ một nửa.

Bài học mà chúng tôi thật sự giữ lại là về một loại lỗi, chứ không phải riêng trường hợp này. Một cú treo là một lỗi, dẫu nó chẳng phát ra lỗi nào, chẳng ngoại lệ nào, chẳng dòng đỏ nào trong log. Bằng chứng duy nhất nó để lại là thời gian đã trôi qua. Và khi thời gian đó là một con số tròn trịa gọn gàng, đừng đi săn tìm công việc chậm chạp nào đó để tối ưu. Hãy đi săn một timeout, rồi tìm cho ra cái thứ ở đầu bên kia của nó đang lặng lẽ, vĩnh viễn, không bao giờ trả lời. Cái của chúng tôi là một iframe, gõ cửa lịch sự lên một cánh cửa mà chúng tôi đã cố ý cài then đóng chặt.

Nếu bạn muốn luồng đăng nhập của mình ngay từ đầu đã biết rằng một auth host bị khóa chặt và một iframe silent-renew không đi cùng nhau được, thì đó là một đường nối mà chúng tôi đã va phải rồi để bạn không bao giờ phải va. Authagonal cung cấp phần đường ống SSO và các header bảo mật như một hệ thống duy nhất đã được kiểm thử cùng nhau, chứ không phải hai nửa đúng đắn mà bạn phát hiện ra là không tương thích với cái giá mười giây mỗi lần tải trang.