SSO cho native app không cần loopback server hay deep link
Bối cảnh
Ứng dụng desktop/mobile đăng nhập qua OIDC server thường dùng một trong hai cách nhận authorization code:
- Loopback server: app mở một HTTP server tạm trên
127.0.0.1, đặtredirect_urivề đó. Chạy được trên desktop, nhưng Windows bật firewall prompt ngay lần đầu — với người dùng doanh nghiệp đây là điểm gây nghi ngại thật sự. Ngoài ra còn port conflict và các môi trường chặn loopback. Mobile thì không dùng được cách này. - Deep link: custom scheme (
myapp://callback) thì app nào cũng đăng ký trùng scheme để cướp code được; App Links / Universal Links an toàn hơn nhưng phải host file verify, và nổi tiếng hay hỏng lặt vặt giữa các phiên bản Android/iOS — đặc biệt trên các ROM tùy biến phổ biến ở thị trường Việt Nam.
Cả hai cách đều buộc client tham gia vào việc nhận code — thứ mà client thực ra không cần. Bài này mô tả flow thay thế: callback về thẳng API server, client chỉ chờ kết quả. Một code path duy nhất cho cả Windows, macOS, Android, iOS — không loopback, không deep link.
Thiết kế
Ý tưởng cốt lõi: tách phiên SSO thành một session ở server, client giữ hai giá trị do server sinh ra:
session_id— 128-bit random, dùng để định danh phiên: subscribe kênh WebSocket chờ tín hiệu, và là khóa lưu trong Redis.poll_secret— 128-bit random, là credential duy nhất để đổi lấy token. Chỉ xuất hiện đúng một lần trong response khởi tạo, không bao giờ đi qua browser hay WebSocket.
Toàn bộ PKCE (verifier/challenge) sinh và giữ ở API server — client không chạm vào. API server lúc này là confidential client đúng nghĩa với OIDC server.
Flow đầy đủ:
Diễn giải theo từng chặng:
- Khởi tạo — client gọi
auth.startSsoSessionkèmapp_id(định danh cài đặt, gán một lần khi cài app). Server sinhsession_id,poll_secret,state, cặp PKCE, lưu vào Redis với TTL 5 phút, trả vềauthorization_url. - Chờ tín hiệu — client subscribe WebSocket bằng
session_id(gửi trong message đầu sau khi connect, không đặt trong URL path để tránh lọt vào access log), rồi mở system browser vớiauthorization_url. - Đăng nhập — user hoàn tất ở OIDC server, browser bị redirect về
/sso/callbackcủa API server — không quay về client. - Exchange — API server validate
state, đổi code lấy token với OIDC server (server-to-server, kèm PKCE verifier), tạo cặpapiToken/refreshToken, ghi vào session, đánh dấucompleted, và publish tín hiệu lên Redis pub/sub để socket server đẩysso_completedxuống đúng connection. - Nhận token — client nhận tín hiệu, gọi
auth.pollSsoSessionkèmsession_id + poll_secret + app_id. Server so khớp (secret so bằng hash, constant-time), trả token và xóa session ngay lập tức — mỗi phiên chỉ đổi được token đúng một lần.
Vì sao endpoint WebSocket không cần auth vẫn an toàn
Kênh WS ở bước 2 là kết nối pre-auth — client lúc này chưa có token. Điều giữ nó an toàn là phân tầng thông tin:
| Giá trị | Ai biết | Lộ thì mất gì |
|---|---|---|
session_id |
client, server, (có thể lọt log) | Nghe được đúng một chữ sso_completed — vô giá trị |
poll_secret |
chỉ client và server (dạng hash) | Cần thêm app_id mới poll được |
app_id |
client, server | Cần thêm poll_secret mới poll được |
Lộ bất kỳ một mảnh đều chưa đủ lấy token. Kể cả kịch bản xấu nhất — attacker có cả bộ và poll trước — thì vì session là single-use, client thật sẽ nhận lỗi ngay, user thấy login fail: không tồn tại kịch bản mất phiên trong im lặng. Server còn log được sự kiện "session đã consumed mà vẫn có người poll" làm tín hiệu cảnh báo.
Cùng nguyên lý với magic link hay reset-password link: bảo mật dựa trên tính không-đoán-được của định danh, cộng TTL ngắn và single-use.
Mobile: WebSocket không phải kênh duy nhất
Khi user đang đăng nhập trong browser, OS có thể suspend app và cắt luôn socket. Vì vậy client có ba lưới, xếp theo độ tin cậy:
- Fetch khi app resume (
AppLifecycleState.resumed) — đường chính trên mobile: user đăng nhập xong kiểu gì cũng quay lại app, một call là đủ. - WebSocket signal — cắt latency về gần 0, hưởng lợi chủ yếu trên desktop nơi app vẫn chạy nền.
- Poll chậm — lưới cuối, chỉ phụ thuộc HTTPS.
Cả ba đều hội tụ về cùng một lệnh gọi auth.pollSsoSession, nên client logic vẫn là một state machine duy nhất cho mọi platform.
Prior art
Mô hình này là tổ hợp của những mảnh đã có tên tuổi, không phải phát minh mới — và đó chính là lý do nên tin nó:
- Cấu trúc "xin session → auth ở kênh khác → poll lấy token" là xương sống của OAuth 2.0 Device Authorization Grant (RFC 8628) — thứ chạy trên Smart TV và CLI (
gh auth login). Khác biệt: browser mở trên cùng máy thay vì máy khác, nên UX không cần user gõ mã. - Callback về backend là pattern Backend-for-Frontend (BFF), hướng đi đang được chuẩn hóa trong draft OAuth 2.0 for Browser-Based Apps của IETF.
- Push tín hiệu qua kênh riêng để cắt poll latency cùng họ với CIBA (Client Initiated Backchannel Authentication).
- Tách định danh khỏi credential (
session_id/poll_secret) là nguyên tắc capability-based kinh điển.
Kết
Đổi lấy một chút state ở server (vài key Redis TTL 5 phút), client bỏ được toàn bộ phần mong manh nhất của OAuth trên native app: loopback server, firewall prompt, deep link config. Với codebase Flutter đa nền tảng, đây là khác biệt giữa bốn code path phụ thuộc platform và một code path duy nhất.