Bài viết giới thiệu tổng quan về hệ thống OIDC/SSO server mà tôi tự xây dựng — một Identity Provider phục vụ đăng nhập tập trung cho toàn bộ hệ sinh thái ứng dụng nội bộ và đối tác.

Khi một tổ chức có nhiều hơn hai ứng dụng, câu chuyện đăng nhập bắt đầu trở nên phiền phức: mỗi app một tài khoản, mỗi app một màn hình login, người dùng quên mật khẩu liên tục, còn đội vận hành thì phải quản lý user ở nhiều nơi. Tệ hơn, khi một nhân viên nghỉ việc, việc thu hồi quyền truy cập trên từng hệ thống rời rạc là cơn ác mộng về bảo mật.

Lời giải kinh điển cho bài toán này là Single Sign-On (SSO): người dùng đăng nhập một lần tại một nơi duy nhất, và mọi ứng dụng trong hệ sinh thái đều tin tưởng danh tính đó. Bài viết này giới thiệu tổng quan về OIDC server mà tôi đã xây dựng để đóng vai trò trung tâm danh tính (Identity Provider) như vậy.

OIDC server là gì trong bức tranh tổng thể?

Hệ thống được xây dựng trên hai chuẩn mở đã được kiểm chứng rộng rãi:

  • OAuth 2.0 — framework ủy quyền (authorization): cho phép ứng dụng truy cập tài nguyên thay mặt người dùng mà không cần biết mật khẩu của họ.
  • OpenID Connect (OIDC) — lớp định danh (authentication) xây trên OAuth 2.0: chuẩn hóa cách ứng dụng xác minh người dùng là ai thông qua ID Token.

OIDC Server

Trong mô hình này, OIDC server là nguồn chân lý duy nhất về danh tính. Các ứng dụng — web, mobile, hay dịch vụ của đối tác — đều là client: chúng không tự quản lý mật khẩu, không tự xác thực người dùng, mà chuyển hướng người dùng về server trung tâm để đăng nhập, rồi nhận về token đã được ký số.

┌──────────┐      redirect       ┌─────────────────┐
│  App A   │ ──────────────────► │                 │
├──────────┤                     │   OIDC Server   │
│  App B   │ ──────────────────► │  (đăng nhập +   │
├──────────┤                     │   cấp token)    │
│ Mobile   │ ──────────────────► │                 │
└──────────┘   ◄── ID/Access ──  └─────────────────┘
                    Token

Các luồng xác thực được hỗ trợ

Trái tim của hệ thống là Authorization Code Flow — luồng được khuyến nghị cho hầu hết mọi loại client hiện nay, với hai biến thể tùy theo bản chất của ứng dụng:

Web application (confidential client): client có backend riêng, giữ được bí mật, nên xác thực với server bằng client_id + client_secret khi đổi authorization code lấy token.

Mobile application (public client): app cài trên thiết bị người dùng không thể giữ secret an toàn. Thay vào đó, hệ thống bắt buộc dùng PKCE (Proof Key for Code Exchange) với phương thức S256 — mỗi phiên đăng nhập sinh một cặp code_verifier/code_challenge ngẫu nhiên, đảm bảo authorization code bị chặn giữa đường cũng không thể đổi được token.

Điểm đáng nói là việc phân loại này tự động theo loại client: khi đăng ký một client mới, hệ thống tự áp cấu hình bảo mật tương ứng — web bắt buộc có secret, mobile bắt buộc PKCE. Người tích hợp không có cơ hội cấu hình sai theo hướng yếu hơn.

Token: được ký số và có thể kiểm chứng

Sau khi đăng nhập thành công, client nhận về bộ token theo chuẩn:

  • ID Token (JWT): chứa thông tin định danh người dùng, được ký bằng RSA (RS256). Client xác minh chữ ký bằng public key công bố qua endpoint JWKS — hoàn toàn không cần gọi về server cho mỗi request.
  • Access Token: dùng để gọi API tài nguyên.
  • Refresh Token: dùng để lấy access token mới khi hết hạn, với cơ chế rotation — mỗi lần dùng, refresh token cũ bị vô hiệu và cấp token mới, hạn chế thiệt hại nếu token bị lộ.

Hệ thống cũng công bố đầy đủ discovery endpoints (/.well-known/openid-configuration, JWKS), nên bất kỳ thư viện OIDC chuẩn nào cũng có thể tự động cấu hình để tích hợp mà không cần hard-code endpoint.

Các lớp phòng thủ

Một Identity Provider là mục tiêu tấn công hấp dẫn nhất trong toàn hệ thống, nên bảo mật không dừng ở "chạy đúng luồng":

  • Authorization code chỉ dùng một lần — chống replay attack; code đã đổi token mà bị dùng lại sẽ kích hoạt thu hồi.
  • So sánh PKCE theo timing-safe — chống timing attack khi xác minh code_verifier.
  • Nonce validation — chống replay ID Token.
  • Scope validation — client chỉ được cấp đúng phạm vi đã đăng ký.
  • Token revocation & introspection — có endpoint chuẩn để thu hồi token và để resource server kiểm tra trạng thái token khi cần.
  • Ràng buộc cấu hình tối giản: mỗi client chỉ có một callback URI duy nhất. Đây là lựa chọn có chủ đích — càng ít điểm cấu hình, càng ít bề mặt tấn công và càng ít cơ hội cấu hình nhầm.

Đăng xuất — bài toán khó hơn đăng nhập

SSO làm đăng nhập dễ đi, nhưng lại làm đăng xuất khó lên: người dùng logout ở một app, các app còn lại thì sao? Hệ thống giải quyết bằng cả hai cơ chế chuẩn của OIDC:

  • Front-channel logout: khi người dùng logout, trình duyệt tải các iframe ẩn trỏ đến từng client để xóa session phía đó.
  • Back-channel logout: server gửi thông báo server-to-server đến từng client — đáng tin cậy hơn vì không phụ thuộc trình duyệt. Mỗi phiên đăng nhập gắn với một Session ID (sid) để client biết chính xác phiên nào cần hủy.

Ngoài ra còn có tính năng "Đăng xuất khỏi tất cả thiết bị" — thực hiện qua cơ chế session versioning: tăng version phiên của user là mọi token/phiên cũ trên mọi thiết bị lập tức mất hiệu lực.

Không chỉ là đăng nhập: trung tâm dữ liệu tổ chức

Ngoài vai trò xác thực, server còn là nơi sở hữu dữ liệu tổ chức dùng chung: hồ sơ người dùng, quyền hạn, cây công ty — phòng ban — chức vụ. Nguyên tắc phân định rất đơn giản:

Dữ liệu mà nhiều hơn một ứng dụng cần → sống ở server trung tâm. Dữ liệu nghiệp vụ riêng của từng app → sống tại app đó.

Các client đồng bộ dữ liệu này về theo mô hình webhook-trigger + pull: khi dữ liệu thay đổi, server chỉ bắn tín hiệu (webhook không mang payload), client nhận tín hiệu thì chủ động gọi API kéo phần thay đổi về theo con trỏ last_sync_date — và con trỏ này luôn do server trả về, client không tự sinh, để tránh lệch dữ liệu. Kèm theo đó là lịch đồng bộ định kỳ đóng vai trò lưới an toàn nếu webhook thất lạc.

Kết

Xây dựng một OIDC server không chỉ là chuyện implement vài endpoint theo spec — nó là việc thiết kế một trung tâm niềm tin cho cả hệ sinh thái: từ luồng xác thực, vòng đời token, đăng xuất đồng bộ, cho đến ranh giới sở hữu dữ liệu giữa các ứng dụng. Hai nguyên tắc xuyên suốt mà tôi giữ trong toàn bộ quá trình là bám sát spec (những RFC như OAuth 2.0, OIDC Core, token revocation... tồn tại vì đã có người trả giá cho các lỗ hổng trước đó) và đơn giản hóa như một biện pháp bảo mật — mỗi ràng buộc bớt đi một lựa chọn cấu hình là bớt đi một cách để làm sai.

Nếu bạn đang đứng trước bài toán đăng nhập tập trung cho nhiều ứng dụng nội bộ, hy vọng bức tranh tổng quan này giúp bạn hình dung được các mảnh ghép cần có — và cả những góc khuất (như logout hay đồng bộ dữ liệu) thường bị bỏ quên khi mới bắt đầu.