Thiết kế flow SSO cho hệ app desktop / mobile / TV qua một API server đóng vai trò Token Broker — và vì sao mô hình này an toàn hơn việc để từng app tự chạy OAuth2.


Nếu bạn từng làm SSO cho app native (Flutter desktop, Android, TV...), gần như chắc chắn bạn đã đi qua con đường quen thuộc: app tự mở browser, chạy OAuth2 + PKCE, rồi hứng authorization code bằng loopback 127.0.0.1 hoặc custom URL scheme. Con đường đó chạy được — nhưng mỗi platform một kiểu workaround, mỗi platform một client registration, và app thì cầm thẳng token của IDP trong tay.

Bài này mô tả kiến trúc tôi dùng cho hệ app LIMS: Token Broker, hay còn gọi là BFF (Backend-for-Frontend) cho native apps. Thay vì app tự điều hướng OAuth2, API server đảm nhận toàn bộ việc nói chuyện với OIDC server. App không bao giờ chạm vào authorization code, không loopback, không WebSocket.

Kiến trúc gồm ba thành phần:

  • IDP: OIDC server (Django) — nơi user thực sự đăng nhập
  • Broker: API server (Kotlin/Jakarta EE) — confidential client, giữ toàn bộ OAuth2/PKCE
  • App: chỉ biết gọi hai RPC method và mở browser

Flow SSO qua Token Broker: app, API server, OIDC server

1. Vì sao broker hợp lý hơn app tự làm

OAuth2 chia client thành hai loại, và toàn bộ lý do tồn tại của pattern này nằm ở khác biệt giữa chúng:

Public client (kiến trúc cũ) Confidential client (kiến trúc này)
Ai nói chuyện với IDP App native trên từng platform API server, duy nhất
Cơ sở trust Không giữ nổi secret → phải bù bằng PKCE phía app, loopback 127.0.0.1, custom scheme — một rừng workaround khác nhau trên mỗi platform Giữ được client secret → IDP trust thẳng; PKCE trở thành chuyện server-to-server
Registration tại IDP Mỗi platform một client + redirect URI riêng phải khai báo, bảo trì Một client registration duy nhất

Bản chất: API server là nơi dễ dàng được trust với OIDC server. Dồn quan hệ trust về đúng một confidential client thì mọi phức tạp platform-specific biến mất khỏi tầng OAuth — chỉ còn lại một bài toán duy nhất: "giao grant credential về app", giải bằng hai mode ở mục 5.

Hệ quả mà mô hình broker mang lại

  • App không bao giờ cầm token của IDP. ID token / access token của IDP dừng tại API server; app chỉ cầm api_token / refresh_token do chính hệ thống phát hành. Vòng đời token, revocation, audit nằm trọn phía mình — đổi IDP, thêm IDP thứ hai, siết policy đều không cần release app.
  • Điểm kiểm soát tập trung: bind token với device_id, kill session từ server, đếm/giới hạn thiết bị đăng nhập — gần như bất khả thi khi app tự nói chuyện với IDP.
  • Một state machine, một endpoint claim phủ mọi platform.

Cái giá phải trả

  • API server trở thành high-value target: cầm client secret, giữ token material, thủng là thủng tất cả. Vì vậy các nguyên tắc trong bài (hash secret trong Redis, one-shot claim, TTL tuyệt đối, database là source of truth) không phải trang trí — chúng là điều kiện để broker an toàn hơn thay vì chỉ tập trung rủi ro hơn.
  • Pattern này phù hợp với doanh nghiệp hoặc app muốn làm bài bản — vì phải đầu tư và vận hành API server. App indie không có backend thì public client + PKCE phía app (kiểu AppAuth) vẫn là chuẩn hợp lệ; đã có sẵn API server thì brokered flow gần như luôn là lựa chọn đúng hơn.

2. Nguyên lý cốt lõi: hai yếu tố, hai kênh

Đây là bất biến của toàn bộ thiết kế, đúng với mọi platform. Claim token luôn cần đủ hai yếu tố, tách kênh hoàn toàn:

Yếu tố Ai giữ Đi qua kênh nào
claim_secret (256-bit) Chỉ app tạo phiên, giữ trong memory HTTPS response của startSsoSession — không bao giờ vào URL browser hay intent
Grant credential (user_code hoặc grant_code) Chỉ được giao ở phía browser, sau khi login xong Tùy mode — xem mục 5

Kẻ tấn công muốn chiếm token phải có cả hai — nhưng hai yếu tố đi hai kênh độc lập nên không có điểm nào lộ đồng thời:

  • Phishing link (dụ nạn nhân login hộ) → attacker có claim_secret của mình, nhưng grant credential chỉ xuất hiện trên thiết bị nạn nhân.
  • Cướp kênh browser/intent (malicious app trùng scheme, đọc history) → có grant credential nhưng không có claim_secret.

Đây là lý do không được gộp hai giá trị này làm một, và không được để claim_secret xuất hiện trong bất kỳ URL nào.

3. State machine của phiên SSO

PENDING ──(callback: exchange code + verify ID token OK)──> AUTHENTICATED ──(claim OK)──> XÓA
   │                                                              │
   └── hết TTL 5 phút / sai claim_secret ──> XÓA                  └── sai claim_secret,
                                                                      sai mã quá 5 lần,
                                                                      hết TTL ──> XÓA

Vài quyết định đáng chú ý:

  • Một chiều, không quay lui. Không có trạng thái CLAIMED — claim thành công là phiên bị xóa ngay (one-shot), request thứ hai nhận SESSION_NOT_FOUND.
  • Phiên lưu Redis với TTL tuyệt đối 5 phút. Mỗi lần ghi đè phải tính lại TTL còn lại từ expires_at — vì SETEX reset TTL, không tính lại sẽ vô tình gia hạn phiên mỗi lần user gõ sai mã.
  • Redis TTL là cơ chế dọn rác duy nhất. Không cron, không cleanup job.
  • Không lưu secret dạng raw — chỉ lưu SHA-256 hash của claim_secret và grant credential.

4. Flow chung (giống nhau cho mọi mode)

Bước 1 — App khởi tạo phiên: auth.startSsoSession

App gọi trước khi mở browser. Server:

  1. Sinh session_id (128-bit, coi như bán công khai) và claim_secret (256-bit, trả về app đúng một lần).
  2. Sinh pkce_verifier + nonce — server giữ, vì server là bên exchange code.
  3. Lưu phiên PENDING vào Redis kèm device_id và mode.
  4. Dựng sẵn authorize_url (state = session_id, PKCE S256, nonce) — app không tự ghép URL, tránh lệch config giữa các platform.

Response: session_id, claim_secret, authorize_url, expires_at.

Bước 2 — App mở browser với authorize_url

Hành vi app sau đó khác nhau theo mode — xem mục 5.

Bước 3 — Browser: login tại IDP → redirect về API server callback

Callback (một servlet thường, không phải RPC method) thực hiện tuần tự:

  1. Tìm phiên PENDING theo state — fail thì render trang lỗi "phiên hết hạn, mở lại app".
  2. Exchange authorization code với IDP server-to-server (confidential client + pkce_verifier từ phiên). Browser refresh không lặp lại được bước này — IDP từ chối code reuse.
  3. Verify ID token: chữ ký RS256 qua JWKS + đối chiếu nonce.
  4. Chuyển phiên sang AUTHENTICATED, sinh grant credential.
  5. Rẽ nhánh theo mode — xem mục 5.

Bước 4 — App claim: auth.ssoExchange(session_id, claim_secret, code)

Thứ tự verify có chủ đích, không đảo:

  1. Phiên tồn tại? → không: SESSION_NOT_FOUND (hết hạn / đã claim / đã hủy).
  2. Đã AUTHENTICATED? → chưa: SESSION_NOT_READY.
  3. claim_secret khớp hash? → sai là hủy phiên ngay, không retry — secret 256-bit không thể "gõ nhầm", sai nghĩa là kẻ lạ đang dò.
  4. Grant credential còn hạn? (sub-TTL 90 giây kể từ lúc login xong) → CODE_EXPIRED.
  5. Grant credential khớp hash? → sai: tăng counter, cho retry vì user thật có thể gõ nhầm (chỉ áp dụng code mode); chạm 5 lần → hủy phiên (chặn brute-force không gian 10⁶ của mã 6 số).
  6. Xóa phiên trước (one-shot), phát hành token sau — token chỉ sinh ra khi chắc chắn có người nhận.

Response: api_token + refresh_token — two-token model chuẩn của hệ thống, token bind với device_id của phiên.

5. Rẽ nhánh: hai mode giao grant credential

5.1. Vì sao phải rẽ nhánh

  • Desktop: app và browser cạnh nhau, Alt-Tab dễ — gõ mã 6 số là chấp nhận được, và không cần đăng ký URL scheme ở tầng OS.
  • Android: chuyển qua lại giữa browser và app rất bất tiện; ngược lại Android có cơ chế App Links/intent chuẩn để browser trả control về app.

Nên grant credential được giao theo hai cách:

CODE mode (desktop, Android TV, CLI, fallback) REDIRECT mode (Android, iOS sau này)
Grant credential user_code — mã 6 số grant_code — token entropy cao (không ai gõ nên không cần ngắn)
Kênh giao Trang callback hiển thị mã, user đọc và gõ tay vào app Callback trả HTTP redirect về app link https://.../sso/return?session_id=...&grant_code=... — OS mở app, app đọc từ intent
Tín hiệu hoàn tất Chính hành động user gõ mã Intent đến app
Retry khi sai Có, tối đa 5 lần (gõ nhầm là bình thường) Không cần — máy đọc máy, sai là bất thường
Consent chống phishing User chủ động đọc và gõ mã = consent tường minh; trang callback hiển thị tên app + thời điểm tạo phiên Grant code chỉ đến được thiết bị nơi login diễn ra = structurally blocked

5.2. Mode được quyết định ở đâu

  • App khai mode (hoặc server suy ra từ platform của device_id đã đăng ký) tại bước 1, ghi cứng vào phiên.
  • Enforce cứng tại callback: phiên code thì chỉ render trang mã, phiên redirect thì chỉ redirect — không cho lai, không cho đổi mode giữa chừng, tránh mọi downgrade path.
  • Điểm thú vị về trust: mode do client khai là chấp nhận được, không cần xác minh. Platform flag là UX hint, không phải security boundary — vì dù attacker khai mode gì cho phiên của hắn, grant credential vẫn chỉ xuất hiện trên thiết bị của nạn nhân (màn hình hoặc intent), không bao giờ về tay attacker. Cả hai nhánh giữ nguyên invariant hai-yếu-tố ở mục 2.

5.3. Phân tích an ninh từng nhánh

Phishing link (attacker tạo phiên, dụ nạn nhân login hộ):

  • Code mode: mã hiện trên browser nạn nhân. Attacker phải quay lại xin nạn nhân đọc mã — thêm một vòng social engineering, và "gửi tôi mã trên màn hình" là red flag kinh điển (cùng nguyên lý number matching của Microsoft Authenticator). Chiều của mã quan trọng: sinh phía browser, nhập phía app — ngược lại là vô dụng.
  • Redirect mode: grant_code redirect về app trên máy nạn nhân. Nếu app của nạn nhân lỡ gọi claim với phiên không phải của mình → sai claim_secret → phiên bị hủy ngay: attack tự hủy. Attacker không bao giờ thấy grant code. Nhánh này chặn phishing về mặt cấu trúc, mạnh hơn cả code mode.

Malicious app trên máy user cướp intent (redirect mode):

  • Cướp được grant_code nhưng không có claim_secret (chưa bao giờ đi qua browser/intent) → claim fail. Đây là chỗ thiết kế hai-yếu-tố trả lãi.
  • Dù vậy vẫn nên dùng Android App Links (https + domain verify qua assetlinks.json) thay cho custom scheme thuần — chặn cướp intent ngay từ tầng OS.

grant_code lọt browser history (redirect mode):

  • Chấp nhận được: one-shot, sub-TTL 90 giây, và vô dụng khi thiếu claim_secret. Đây là thêm một lý do grant_code phải khác claim_secret.

Brute-force mã 6 số (code mode):

  • Không gian 10⁶, tối đa 5 lần thử → xác suất 0.0005%; mã chỉ sinh sau khi login xong và sống 90 giây → cửa sổ đoán rất hẹp. Kẻ ngoài còn phải có claim_secret trước đã.

5.4. Nghĩa vụ phía app theo từng nhánh

Code mode (desktop / TV):

  1. Gọi startSsoSession → giữ claim_secret trong memory (không persist).
  2. Mở browser với authorize_url, hiển thị ngay ô nhập 6 số + countdown theo expires_at.
  3. User gõ mã → gọi ssoExchange. Xử lý đủ các mã lỗi: SESSION_NOT_READY ("hoàn tất trên trình duyệt trước"), INVALID_CODE (hiện số lần còn lại), CODE_EXPIRED / SESSION_NOT_FOUND ("bắt đầu lại").

Redirect mode (Android):

  1. Gọi startSsoSession với mode redirect → giữ claim_secret trong memory.
  2. Mở browser (Custom Tabs) với authorize_url, hiển thị màn hình chờ.
  3. Nhận App Link intent → bắt buộc verify session_id trong intent khớp với phiên pending của chính mình trước khi gọi claim. Không khớp → bỏ qua intent. Bước này vừa chống intent lạ, vừa tránh việc app vô tình claim hộ phiên phishing và làm hủy phiên của chính mình.
  4. Gọi ssoExchange(session_id, claim_secret, grant_code) — cùng endpoint, grant_code nằm đúng chỗ user_code.

Fallback (redirect mode không bắt được link): trang callback hiện nút "Mở ứng dụng", phương án cuối là hiển thị mã 6 số kiểu desktop. Fallback an toàn vì chỉ chuyển từ mode mạnh về mode vẫn-an-toàn — chiều ngược lại (code → redirect) không tồn tại.

6. Mã lỗi

Mã Ý nghĩa App xử lý
SESSION_NOT_FOUND Hết TTL / đã claim / đã hủy "Phiên hết hạn, đăng nhập lại"
SESSION_NOT_READY Chưa login xong đã claim "Hoàn tất trên trình duyệt trước"
SESSION_INVALIDATED Sai claim_secret, hoặc sai mã quá 5 lần Bắt đầu lại từ đầu
INVALID_CODE Sai mã, còn lượt thử Hiện số lần còn lại
CODE_EXPIRED Mã quá 90 giây (phiên có thể còn sống) Bắt đầu lại từ đầu

7. Các hằng số

Hằng số Giá trị Lý do
SESSION_TTL_SECONDS 300 Thu hẹp cửa sổ phishing, đủ cho user login
CODE_TTL_SECONDS 90 Sub-TTL của grant credential, tính từ lúc login xong
MAX_CODE_ATTEMPTS 5 Cân bằng gõ nhầm thật vs brute-force 10⁶

Kết

Điều tôi thích nhất ở thiết kế này không phải là từng cơ chế riêng lẻ, mà là cách chúng khóa vào nhau: hai yếu tố tách kênh làm nền, state machine one-shot bảo đảm không có "lần thứ hai", và hai mode giao credential chỉ là hai cách hiện thực cùng một invariant. Khi security property đến từ cấu trúc thay vì từ việc "nhớ check đủ thứ", code phía app trở nên đơn giản đến bất ngờ — hai RPC call và một cái browser.

Nếu hệ thống của bạn đã có API server, hãy cân nhắc để nó làm broker thay vì tiếp tục vá loopback redirect trên từng platform. Còn nếu chưa có — đó chính là cái giá của pattern này, và public client + PKCE vẫn là bạn đồng hành hợp lệ.