Tăng cường bảo mật OAuth flow cho native apps với Token Broker
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

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_tokendo 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_secretcủ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ậnSESSION_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ìSETEXreset 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_secretvà 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:
- 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). - Sinh
pkce_verifier+nonce— server giữ, vì server là bên exchange code. - Lưu phiên
PENDINGvào Redis kèmdevice_idvàmode. - 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ự:
- Tìm phiên
PENDINGtheostate— fail thì render trang lỗi "phiên hết hạn, mở lại app". - Exchange authorization code với IDP server-to-server (confidential client +
pkce_verifiertừ phiên). Browser refresh không lặp lại được bước này — IDP từ chối code reuse. - Verify ID token: chữ ký RS256 qua JWKS + đối chiếu
nonce. - Chuyển phiên sang
AUTHENTICATED, sinh grant credential. - 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:
- Phiên tồn tại? → không:
SESSION_NOT_FOUND(hết hạn / đã claim / đã hủy). - Đã
AUTHENTICATED? → chưa:SESSION_NOT_READY. claim_secretkhớ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ò.- Grant credential còn hạn? (sub-TTL 90 giây kể từ lúc login xong) →
CODE_EXPIRED. - 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ố).
- 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ủadevice_idđã đăng ký) tại bước 1, ghi cứng vào phiên. - Enforce cứng tại callback: phiên
codethì chỉ render trang mã, phiênredirectthì 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_coderedirect 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 → saiclaim_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_codenhư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ý dogrant_codephải khácclaim_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_secrettrước đã.
5.4. Nghĩa vụ phía app theo từng nhánh
Code mode (desktop / TV):
- Gọi
startSsoSession→ giữclaim_secrettrong memory (không persist). - Mở browser với
authorize_url, hiển thị ngay ô nhập 6 số + countdown theoexpires_at. - 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):
- Gọi
startSsoSessionvới mode redirect → giữclaim_secrettrong memory. - Mở browser (Custom Tabs) với
authorize_url, hiển thị màn hình chờ. - Nhận App Link intent → bắt buộc verify
session_idtrong 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. - Gọi
ssoExchange(session_id, claim_secret, grant_code)— cùng endpoint,grant_codenằ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ệ.