Quay lại blog
7 tháng 10, 2026Sergei Solod16 phút đọc

Cách xây dựng SSO liên domain trên các root domain độc lập

Một kiến trúc thực tế cho xác thực thống nhất trên các root domain độc lập, sử dụng phiên chỉ dành cho host, một issuer xác thực đã biết và token handoff dùng một lần có thời hạn ngắn mà không buộc mọi khách truy cập phải đi qua chuyển hướng xác thực.

SSO liên domainXác thựcBảo mật phiênBảo mật webKiến trúc web

Tôi cần nhiều website độc lập hoạt động như một hệ thống tài khoản duy nhất mà không làm suy yếu cơ chế cô lập thông thường của trình duyệt. Người dùng có thể đăng nhập trên một domain, sau đó mở trực tiếp một domain khác, nhấp Log In và tiếp tục ở đó mà không phải nhập lại thông tin xác thực.

Ràng buộc quan trọng là đây là các root domain độc lập. Cookie phiên do một root domain tạo ra không thể đơn giản được đặt phạm vi sang một root domain không liên quan. Thiết kế trở nên dễ lý giải hơn nhiều khi tôi tách hai khái niệm thường bị trộn lẫn: danh tính toàn cục và phiên trình duyệt cục bộ.

Mô hình cuối cùng khá đơn giản: backend nhận diện một danh tính tài khoản dùng chung, mỗi domain sở hữu phiên riêng gắn với host của nó, và một handoff dùng một lần có thời hạn ngắn cho phép origin đáng tin cậy đã xác thực người dùng cấp quyền tạo một phiên mới trên một origin đáng tin cậy khác.

Danh tính toàn cục không cần cookie dùng chung

Tài khoản có thể mang tính toàn cục ngay cả khi các phiên trình duyệt vẫn là cục bộ.

Mỗi site giữ một cookie phiên chỉ dành cho host. Cookie do một root domain phát hành không thể được mở rộng sang một root domain không liên quan thông qua thuộc tính Domain. Thuộc tính đó chỉ có thể mở rộng phạm vi cookie trong hệ phân cấp domain mà host phát hành được phép sử dụng.

Với một phiên cần tiếp tục bị ràng buộc vào một host, cookie có tiền tố __Host- là lựa chọn phù hợp. Về mặt khái niệm:

Set-Cookie: __Host-<session-cookie>=<opaque-token>; Path=/; Secure; HttpOnly; SameSite=Lax

Điểm quan trọng nằm ở các ranh giới:

  • Site A không thể đọc cookie phiên chỉ dành cho host của Site B.
  • Site B không thể đọc cookie phiên chỉ dành cho host của Site A.
  • Cả hai phiên vẫn có thể ánh xạ tới cùng một danh tính ở backend.

Vì vậy, SSO liên domain không nên cố di chuyển cookie từ site này sang site khác. Nó nên chuyển một bằng chứng tạm thời cho thấy một nguồn đáng tin cậy đã xác thực người dùng.

Mẫu handoff dùng một lần

Nguồn đã xác thực cung cấp một thao tác handoff nhỏ. Trình duyệt chỉ gọi thao tác này khi thực sự cần chuyển tiếp xác thực giữa các site.

Backend tạo một ticket ngẫu nhiên bằng phương pháp mật mã, trả giá trị thô cho trình duyệt đúng một lần, rồi chỉ lưu hash cùng lượng thông tin tối thiểu cần thiết để xác thực việc chuyển giao:

{
  tokenHash,
  userId,
  sourceOrigin,
  targetSite,
  returnPath,
  expiresAt
}

Ticket nên có thời hạn ngắn và chỉ hợp lệ cho một target. Yêu cầu phát hành ở phía nguồn phải được xác thực sẵn và được bảo vệ khỏi CSRF. Target được yêu cầu và đích quay lại phải được kiểm tra trước khi bất kỳ thứ gì được phát hành.

Sau đó, trình duyệt thực hiện một POST cấp cao nhất tới target. Target sử dụng ticket, xác định danh tính người dùng dùng chung, tạo phiên cục bộ của riêng mình và chuyển hướng trình duyệt tới path dự kiến.

Về mặt khái niệm:

record = consumeOnce({
  tokenHash: hash(ticket),
  targetSite: currentSite,
  sourceOrigin: requestOrigin,
  notExpired: true
})

if (!record) deny()

user = loadSharedUser(record.userId)
createLocalSession(user)
redirect(record.returnPath)

Ticket không phải là phiên của target. Nó là quyền dùng một lần để tạo phiên đó trên target.

Consume nguyên tử khép lại cửa sổ replay

Một cách triển khai yếu hơn sẽ tải ticket, kiểm tra nó, tạo phiên rồi mới xóa ticket. Cách này tạo ra một khoảng thời gian trong đó hai request có thể cùng nhìn thấy một record vẫn còn hiệu lực.

Thiết kế an toàn hơn là kết hợp xác thực và vô hiệu hóa trong một thao tác consume nguyên tử. Nếu không có record nào khớp, quá trình xác thực thất bại. Nếu một record được trả về, ticket đó đã bị loại khỏi tập có thể sử dụng trước khi phiên được tạo.

Cách này cũng cho phép target ràng buộc nhiều điều kiện vào cùng một thao tác: target dự kiến, origin nguồn dự kiến, hash của token và thời điểm hết hạn.

Kiểm tra origin nên fail closed

Việc ràng buộc origin bổ sung một thuộc tính hữu ích: ticket được phát hành thông qua một nguồn đáng tin cậy không nên có thể bị consume từ một origin không liên quan.

Kiểm tra origin nên là một phần của chính điều kiện consume. Điều đó có nghĩa là request từ origin sai sẽ không consume ticket hợp lệ, trong khi nguồn hợp lệ vẫn có thể hoàn tất handoff sau đó.

Có một lưu ý quan trọng: header Origin không được đảm bảo luôn chứa một chuỗi origin thông thường trong mọi ngữ cảnh trình duyệt. Một số request có thể đến mà không có giá trị dùng được, và các ngữ cảnh opaque có thể tạo ra Origin: null.

Quy tắc của tôi là coi Origin bị thiếu hoặc opaque là kiểm tra origin thất bại, trừ khi ứng dụng có một cơ chế xác thực thay thế được thiết kế rõ ràng. Giá trị nguyên văn null không nên trở thành một origin đáng tin cậy chỉ vì mục đích tương thích.

Đây là lựa chọn fail-closed. Nếu một luồng hợp lệ cần hỗ trợ ngữ cảnh không có origin dùng được, ngoại lệ đó phải được thiết kế tường minh thay vì âm thầm làm yếu đi phép kiểm tra chung.

Không đưa thông tin xác thực handoff vào URL

Tôi ưu tiên gửi ticket tạm thời bằng một form POST cấp cao nhất thay vì đặt nó vào query string.

Một phiên bản tổng quát ở phía client là:

const form = document.createElement('form');
form.method = 'POST';
form.action = targetOrigin + '/auth/consume';

const input = document.createElement('input');
input.type = 'hidden';
input.name = 'ticket';
input.value = ticket;

form.append(input);
document.body.append(form);
form.submit();

Target nhận ticket từ body POST như dự kiến và từ chối các cách tương đương nhằm cung cấp ticket qua URL.

Điều này không khiến thông tin xác thực trở nên vô hại, nhưng giúp nó không xuất hiện trong các URL điều hướng thông thường và giảm nguy cơ vô tình lộ qua lịch sử URL, liên kết được sao chép, referrer và hệ thống logging theo URL.

Liên kết liên site đã xác thực có thể tự nâng cấp

Nếu site hiện tại đã biết người dùng được xác thực, một liên kết thông thường tới một site đáng tin cậy khác có thể tận dụng cơ hội đó để thực hiện handoff.

Liên kết vẫn nên chứa một href thực. SSO là phần tăng cường cho điều hướng thông thường, không phải thứ thay thế nó.

Một luồng client rút gọn trông như sau:

if (session.status !== 'authenticated') {
  return; // ordinary browser navigation
}

if (!isTrustedTarget(destination)) {
  return;
}

event.preventDefault();

const ticket = await requestHandoff({
  targetSite,
  returnPath: destinationPath
});

postTicketToTarget(ticket);

Bản production cũng nên ngăn việc gửi trùng lặp, kiểm tra target dựa trên registry đáng tin cậy và quay lại điều hướng thông thường nếu không thể tạo handoff.

Hành vi mặc định của trình duyệt nên được giữ nguyên. Nhấp chuột giữa, nhấp kèm phím modifier, tải xuống và các liên kết dành cho một ngữ cảnh duyệt khác không nên âm thầm bị chuyển thành request xác thực.

Nút Log In trên site thứ hai cần một issuer đã biết

Trường hợp khó hơn bắt đầu khi người dùng mở trực tiếp Site B. Site B không có phiên cục bộ, vì vậy nó đúng khi coi khách truy cập là ẩn danh dù trình duyệt vẫn còn một phiên hợp lệ trên Site A.

Site B không thể kiểm tra cookie chỉ dành cho host của Site A. Nó cũng không thể chuyển hướng tới một Site C tùy ý rồi kỳ vọng site đó phát hiện các phiên thuộc phần còn lại của hệ thống. Mỗi root domain không liên quan chỉ có thể quan sát các cookie thuộc về chính nó.

Điều đó có nghĩa là luồng Log In ẩn danh cần một issuer đã biết: hoặc một origin xác thực chuẩn, hoặc một site đáng tin cậy được chọn cụ thể và được kỳ vọng giữ phiên đăng nhập có thể tái sử dụng.

Với trường hợp đơn giản Site A → Site B, trình tự là:

  1. Người dùng đăng nhập trên Site A.
  2. Sau đó, người dùng mở trực tiếp Site B.
  3. Site B không có phiên nên hiển thị Log In.
  4. Người dùng nhấp Log In.
  5. Trình duyệt thực hiện điều hướng cấp cao nhất tới Site A, là issuer được chọn.
  6. Site A có thể đọc cookie phiên chỉ dành cho host hiện có của chính nó.
  7. Vì phiên đó vẫn hợp lệ, Site A ngay lập tức phát hành một handoff dùng một lần cho Site B.
  8. Trình duyệt POST ticket tới Site B.
  9. Site B tạo phiên cục bộ của riêng nó và đưa người dùng trở lại trang đã yêu cầu.

Từ góc nhìn người dùng, đây chỉ là một lần chuyển hướng nhanh tới Site A rồi quay lại. Không cần mật khẩu vì Site A đã có phiên mà nó được phép quan sát.

Giới hạn này cũng quan trọng không kém: nếu người dùng chỉ đăng nhập trên Site C, việc chuyển Site B tới Site A sẽ không giúp ích trừ khi Site A thực sự là bộ trung gian xác thực trung tâm có hiểu biết độc lập về trạng thái đăng nhập. Cơ sở dữ liệu người dùng dùng chung không khiến các phiên trình duyệt trở thành dùng chung.

Vì sao issuer chuẩn giúp đơn giản hóa hệ thống

Khi số lượng site độc lập tăng lên, issuer chuẩn loại bỏ nhu cầu dò tìm phiên trên các domain tùy ý.

Nếu không có issuer chuẩn, Site B đang ẩn danh bằng cách nào đó sẽ phải quyết định site nào khác hiện có thể giữ một cookie hữu ích. Site A không thể kiểm tra cookie của Site C, Site C không thể kiểm tra cookie của Site A, và Site B cũng không thể kiểm tra cookie của bên nào.

Issuer chuẩn cung cấp cho mọi lần đăng nhập ẩn danh một đường đi có thể dự đoán:

Site B
  → issuer xác thực
  → có phiên của issuer?
      có    → tạo handoff dùng một lần
      không → hiển thị đăng nhập thông thường
  → Site B consume handoff
  → Site B tạo phiên cục bộ

Issuer không cần phục vụ phần còn lại của ứng dụng. Trách nhiệm liên quan của nó là sở hữu phiên xác thực có thể tái sử dụng và phát hành các handoff có phạm vi chặt chẽ tới những site đáng tin cậy.

Với hệ thống nhỏ hơn, một site hiện có có thể đảm nhiệm vai trò đó. Với kiến trúc rộng hơn, một origin xác thực chuyên dụng có thể giúp mô hình tin cậy dễ hiểu hơn.

Đừng chuyển mọi khách truy cập qua issuer

Luồng issuer không nên tự động chạy cho mọi lượt xem trang ẩn danh.

Chuyển mọi khách truy cập sang domain khác chỉ để kiểm tra xem có phiên hiện hữu hay không sẽ tạo ra điều hướng không cần thiết, tăng độ trễ, tăng số kiểu lỗi, làm analytics nhiễu hơn và khiến hành vi caching phức tạp hơn.

Cách này cũng không phù hợp với các trang công khai nhạy cảm về SEO. Crawler hoặc khách truy cập bình thường yêu cầu một URL công khai nên nhận trực tiếp trang công khai đó thay vì bị buộc đi vòng qua quy trình xác thực trước.

Quy tắc tôi dùng là:

Không dò issuer khi tải trang. Chỉ bắt đầu xác thực liên domain sau một thao tác Log In rõ ràng hoặc một lần điều hướng liên site đã xác thực và có chủ đích.

Cách này giữ lưu lượng công khai đơn giản nhưng vẫn giúp xác thực nhanh với người dùng đã có phiên hợp lệ trên issuer được chọn.

Kiểm tra đường dẫn quay lại cần kỹ hơn một phép kiểm tra tiền tố

Handoff thường cần nhớ trình duyệt phải đến đâu sau đó. Giá trị này có thể trở thành open redirect nếu bị coi là một URL tùy ý do client kiểm soát.

Khi có thể, thiết kế an toàn nhất là hoàn toàn không chấp nhận một URL đích đầy đủ. Một định danh ngắn hoặc ánh xạ phía server cho các đích đã biết sẽ giảm lượng thao tác parse và kiểm tra URL cần thiết.

Nếu phải chấp nhận path tương đối, hãy kiểm tra cẩn thận và giới hạn nó trong target site.

Một ví dụ được cố ý rút gọn—không phải quy trình kiểm tra redirect hoàn chỉnh—là:

function isSafeReturnPath(value) {
  if (!value.startsWith('/')) return false;
  if (value.startsWith('//')) return false;
  if (value.includes('\\\\')) return false;

  const base = 'https://example.invalid';
  const parsed = new URL(value, base);

  return parsed.origin === base;
}

Triển khai thực tế cũng cần tính đến hành vi giải mã, đầu vào sai định dạng, ký tự điều khiển, chuẩn hóa, quy tắc định tuyến của ứng dụng và mọi biến đổi do proxy hoặc framework thực hiện trước khi redirect được tạo.

Bất biến bảo mật đơn giản hơn chi tiết của bộ phân tích cú pháp: handoff có thể chọn một vị trí được phép trong ứng dụng target, nhưng không bao giờ được chọn một đích bên ngoài tùy ý.

Các trường hợp lỗi quan trọng hơn luồng thành công

Đăng nhập thành công chỉ chứng minh rằng đường đi cơ bản hoạt động. Các phép thử hữu ích hơn là những phép thử tác động vào ranh giới tin cậy.

Tôi đã kiểm tra hành vi cho các trường hợp như:

  • một nguồn chưa xác thực cố phát hành handoff;
  • thiếu bảo vệ CSRF;
  • origin bị thiếu, opaque hoặc không như dự kiến;
  • target không đáng tin cậy;
  • đích quay lại không an toàn;
  • ticket được đưa tới sai target;
  • một lần consume từ origin sai;
  • replay ticket;
  • ticket hết hạn;
  • người dùng không còn tồn tại;
  • ticket được cung cấp qua URL thay vì body POST như dự kiến.

Hai bất biến đặc biệt hữu ích để kiểm tra trực tiếp: yêu cầu bị từ chối không được vô tình hủy một ticket vẫn hợp lệ cho luồng chính đáng, và một lần consume thành công phải khiến ticket đó không thể sử dụng lại ngay lập tức.

Giao thức mở rộng quy mô mà không cần tích hợp theo từng cặp

Thiết kế handoff về cơ bản không thay đổi khi bổ sung thêm site.

Mỗi site tham gia cần cùng một hợp đồng nhỏ:

  • một định danh logic ổn định;
  • một origin công khai đáng tin cậy;
  • quyền truy cập vào danh tính tài khoản dùng chung;
  • một thao tác phát hành handoff cho nguồn đã xác thực;
  • một thao tác consume handoff;
  • một registry các site đáng tin cậy;
  • một phiên cục bộ gắn với host;
  • một chính sách đích quay lại an toàn;
  • và một issuer đã biết cho các luồng Log In ẩn danh.

Lựa chọn quan trọng khi mở rộng là tránh logic xác thực tùy biến theo từng cặp site. Một site đã xác thực có thể phát hành cùng loại handoff cho một site đáng tin cậy khác. Một site ẩn danh có thể gửi trình duyệt tới issuer đã biết.

Nhờ đó, hệ thống dựa trên một giao thức duy nhất thay vì một ma trận ngày càng lớn của các trường hợp đặc biệt.

Khả năng mang theo trạng thái đăng nhập tách biệt với việc lan truyền đăng xuất

Các phiên độc lập gắn với host tạo ra một phân biệt hữu ích khác: làm cho trạng thái đăng nhập có thể được dùng xuyên site không tự động định nghĩa đăng xuất toàn cục.

Nếu người dùng đăng xuất khỏi Site A, phiên cục bộ của một site khác vẫn có thể còn hiệu lực trừ khi backend chủ động thu hồi nó. Đó là chính sách quản lý phiên, không phải lỗi của handoff SSO.

Hệ thống có thể chọn đăng xuất cục bộ, đăng xuất toàn cục hoặc quản lý phiên tường minh. Giữ quyết định đó tách biệt khỏi handoff giúp cả hai cơ chế dễ lý giải hơn.

Mô hình tôi sử dụng

Kiến trúc trở nên đơn giản hơn khi tôi ngừng mô tả vấn đề là “chia sẻ cookie đăng nhập giữa các domain”.

Có một danh tính toàn cục, mỗi domain sở hữu phiên trình duyệt riêng của mình, và một handoff dùng một lần có thời hạn ngắn cho phép origin đáng tin cậy đã xác thực cấp quyền tạo thêm một phiên cục bộ.

Khi truy cập trực tiếp vào một site đang ở trạng thái ẩn danh, còn một quy tắc quan trọng: trình duyệt phải được gửi tới một issuer thực sự có thể quan sát phiên hiện có của người dùng. Nếu người dùng trước đó đã đăng nhập trên issuer đó, chuyến đi rồi quay lại có thể gần như không nhận thấy. Nếu issuer không có phiên hợp lệ, nó phải quay về quy trình xác thực thông thường thay vì giả vờ rằng phiên của một domain không liên quan khác có thể được nhìn thấy.

Mô hình này giữ nguyên ranh giới domain của trình duyệt, tránh các chuyển hướng xác thực không cần thiết đối với khách truy cập công khai, đồng thời vẫn cung cấp trải nghiệm mong muốn: đăng nhập trên issuer, sau đó mở một site đáng tin cậy khác, nhấp Log In và quay lại với một phiên cục bộ mới mà không phải nhập lại mật khẩu.