Xác thực CLI, theo cách đúng đắn
(abgeo.dev)- Nhiều CLI mặc định dùng localhost OAuth redirect vì có thể hoàn tất nhanh trong trình duyệt cục bộ trên laptop, nhưng trong các môi trường phát triển như SSH, container, WSL, giả định đó bị phá vỡ và luồng đăng nhập bị mắc kẹt
- Cách làm hiện tại là CLI mở một máy chủ HTTP tạm thời trên
127.0.0.1, gửi trình duyệt tới URL xác thực, rồi nhà cung cấp xác thực trả authorization code về callback cục bộ - RFC 8628 Device Authorization Grant được chuẩn hóa năm 2019 tách riêng CLI yêu cầu token và thiết bị trình duyệt nơi người dùng xác thực, loại bỏ sự phụ thuộc vào bind cổng hay trình duyệt cục bộ
- Device flow nhận
device_code,user_code,verification_uri,interval, rồi định kỳ polling/tokenvà xử lý các trạng thái chuẩn nhưauthorization_pending,slow_down,access_denied,expired_token - Với CLI mới, nên đặt device flow làm mặc định, khám phá endpoint bằng
.well-known/openid-configuration, và lưu refresh token trong OS keychain thay vì file JSON trong~/.config
Những gì localhost redirect đang mặc định giả định
- Kiểu đăng nhập CLI phổ biến hoạt động dựa trên giả định rằng máy chủ HTTP cục bộ và trình duyệt hệ thống nằm trên cùng một máy
- CLI bind một máy chủ HTTP vào một cổng cụ thể trên
127.0.0.1 - Mở trình duyệt hệ thống tới OAuth authorization endpoint và kèm
redirect_uri=http://127.0.0.1:<port>/callback - Khi người dùng đăng nhập, nhà cung cấp xác thực sẽ
302redirect authorization code tới loopback URL - Máy chủ HTTP nhỏ của CLI đọc code và đổi nó lấy token tại token endpoint
- Hầu hết đều dùng thêm PKCE, rồi sau đó hiện trang “Bạn có thể đóng tab này”
- CLI bind một máy chủ HTTP vào một cổng cụ thể trên
gcloud auth login,wrangler login,vercel logintrước đây và nhiều CLI của các nhà cung cấp khác dùng cách này- Wrangler dùng cổng
8976 - gcloud dùng
8085 - Claude Code lấy cổng tạm mỗi lần chạy
- Wrangler dùng cổng
- RFC 8252 khuyến nghị mẫu này cho native app khi có trình duyệt, nhưng không đề cập cách xử lý khi host không có trình duyệt
Vì sao người dùng thường không nhận ra bước localhost
- localhost callback diễn ra rất nhanh nên đa số người dùng không nhìn thấy
- URL mà CLI in ra thường rất dài, và redirect URI nằm trong query string của nó
- Người dùng đăng nhập và cấp quyền trên tên miền thật của nhà cung cấp xác thực
- Nhà cung cấp xác thực đưa trình duyệt tới localhost callback để CLI đọc code, rồi lại chuyển tiếp sang một trang “signed in” chỉn chu
- Nhìn bề ngoài giống như “đăng nhập trên website thì CLI được xác thực”, nhưng thực tế chính là sự đồng tồn tại của máy chủ HTTP cục bộ và trình duyệt đang nâng đỡ toàn bộ luồng này
Điểm gãy trong SSH, container, WSL
- Toàn bộ luồng phụ thuộc vào giả định rằng máy chạy CLI và máy chạy trình duyệt là cùng một máy
- Trong phiên SSH, host từ xa không có trình duyệt, và
xdg-opencó thể thất bại hoặc mở một trình duyệt từ xa không nhìn thấy được trong môi trường X forwarding- Có thể tunnel callback port về laptop, nhưng redirect URI đã đăng ký với nhà cung cấp xác thực phải cho phép cổng đi qua tunnel đó
- Container không có trình duyệt, và nhiều image cũng không có
xdg-openhayopen- Có thể expose callback port bằng
-p, nhưng phải biết CLI sẽ chọn cổng nào - Với Cloudflare CLI, đã có hàng loạt issue từ người dùng bị chặn bởi vấn đề này
- Có thể expose callback port bằng
- Trong WSL, trình duyệt mở trên Windows còn loopback server chạy trên Linux
- Port forwarding của WSL2 thường hoạt động, nhưng không phải lúc nào cũng vậy
- Trên shared box, tiến trình khác trên cùng máy có thể dò listening port qua
/proc/net/tcphoặc tranh bind trước vào cổng đã biết- PKCE bảo vệ code exchange, nhưng không bảo vệ authenticated session của chính redirect đó
Fallback đã tự phơi bày vấn đề thiết kế
- Những CLI lấy loopback flow làm mặc định thường cũng cung cấp fallback cho lúc nó hỏng
- gcloud có
--no-launch-browser - Wrangler thì treo lại, và workaround được chấp nhận là tự
curllocalhost URL ở terminal thứ hai claudecủa Anthropic in ra “Paste code here if prompted” rồi chờ- Các fallback này thực chất là device flow thủ công, tồn tại vì luồng mặc định không chạy được trong môi trường CLI thực tế
RFC 8628 Device Authorization Grant
- RFC 8628 là OAuth 2.0 Device Authorization Grant cho “input-constrained devices”, được chuẩn hóa năm 2019
- TV, console, CLI đều nằm trong đối tượng này
- Cốt lõi là tách thiết bị yêu cầu token khỏi thiết bị trình duyệt nơi người dùng xác thực
- CLI gửi POST tới
device_authorization_endpointcủa nhà cung cấp xác thực- Ví dụ request gửi
client_id=my-cli&scope=openid+offline_access
- Ví dụ request gửi
- Nhà cung cấp xác thực trả về JSON gồm các giá trị sau
device_codeuser_codeverification_uriverification_uri_completeexpires_ininterval
- CLI in ra URL và mã ngắn, và nếu có thể thì hiển thị cả QR cho
verification_uri_complete - Người dùng mở URL trên thiết bị mình muốn, đăng nhập, xem scope được yêu cầu và tên client, kiểm tra xem có khớp với mã ngắn hiển thị trên CLI hay không rồi phê duyệt
Polling và xử lý trạng thái chuẩn
- CLI polling token endpoint mỗi
intervalgiây - Grant type dùng
urn:ietf:params:oauth:grant-type:device_code - RFC 8628 section 3.5 định nghĩa các trạng thái sau
authorization_pending: đang chờ người dùng phê duyệtslow_down: nhà cung cấp xác thực yêu cầu giảm tần suất polling, và đặc tả quy định phải tăng interval ít nhất 5 giâyaccess_denied: người dùng đã từ chốiexpired_token: chờ quá lâu nên token đã hết hạn
- Trong device flow, CLI không bind cổng và cũng không giả định host thực thi có trình duyệt
- Cùng một cách đăng nhập có thể chạy trên laptop, container, hoặc CI job đang chờ con người phê duyệt
Chi phí polling và khám phá endpoint
- Interval polling mặc định là 5 giây
- Phần lớn quá trình xác thực xong trong vòng 1 phút, nên một lần đăng nhập điển hình chỉ polling
/tokenkhoảng 10 lần rồi dừng - Server có thể tăng interval bằng
slow_down, và client được viết đúng phải tuân theo - So với cách giữ kết nối WebSocket hay SSE tới một endpoint có trạng thái cho mỗi phiên đăng nhập đang chờ, polling stateless vào
/tokenđơn giản và rẻ hơn - Nếu nhà cung cấp xác thực hỗ trợ OpenID Connect Discovery, CLI có thể lấy
device_authorization_endpointvàtoken_endpointtừ.well-known/openid-configurationđể tránh hardcode URL
Rủi ro phishing của device flow
- Trong device flow có kiểu tấn công mà kẻ tấn công gọi
device_authorization_endpointcủa nhà cung cấp xác thực thật để lấyuser_codevàdevice_code, rồi lừa nạn nhân nhập mã - Nạn nhân có thể đăng nhập trên URL thật với mã thật và phê duyệt màn hình consent thật
- Kẻ tấn công polling
/tokenbằngdevice_codedo chính hắn tạo ra rồi nhận access token - Một nhóm đe dọa từ Nga đã thực hiện chiến dịch này nhắm vào tenant M365 từ sau tháng 8/2024
- Microsoft Threat Intelligence theo dõi chiến dịch này dưới tên Storm-2372
- Volexity quy attribution cho APT29/Midnight Blizzard
- Các tenant chính phủ, quốc phòng và NGO trên nhiều châu lục đã bị ảnh hưởng
Phòng thủ phishing là trách nhiệm của nhà cung cấp xác thực
- Biện pháp phòng thủ phishing nên được thực hiện ở phía nhà cung cấp xác thực, không phải CLI
- Các biện pháp giảm thiểu cần có gồm
- thời gian hết hạn ngắn cho
user_code - hiển thị nổi bật tên client và vị trí yêu cầu trên trang xác minh
- rate limiting với các lần thử nhập mã
- không lộ
verification_uri_complete, để nạn nhân phải tự nhập mã thay vì chỉ bấm link - với tenant giá trị cao, dùng conditional access policy để chặn device code flow nếu không đến từ network hay thiết bị đã biết
- thời gian hết hạn ngắn cho
- Vai trò của CLI là tuân thủ đặc tả và không tạo shortcut
- Device flow chuyển local attack surface thành social attack surface, nhưng hợp lý hơn vì nó cung cấp một luồng hoạt động trong nhiều môi trường hơn và tận dụng được các biện pháp giảm thiểu từ nhà cung cấp xác thực
Luồng cốt lõi trong ví dụ triển khai bằng Go
- Toàn bộ triển khai trong Go chỉ khoảng 30 dòng nếu dùng
net/http - Luồng triển khai như sau
- Gọi
http.PostFormtớiDeviceAuthorizationEndpointvớiclient_idvàscope - Decode JSON phản hồi để lấy
DeviceCode,UserCode,VerificationURIComplete,Interval - In
VerificationURICompletevàUserCodecho người dùng - Lặp POST tới
TokenEndpointvớidevice_code,client_id, và device grant type - Nếu là
authorization_pendingthì tiếp tục chờ - Nếu là
slow_downthì tăng interval thêm 5 giây - Nếu không có error thì trả về
access_tokenvàrefresh_token - Các error khác được xử lý là thất bại
- Gọi
- Bật capability “OAuth 2.0 Device Authorization Grant” trong Keycloak realm, hoặc dùng nhà cung cấp được OpenID chứng nhận có hỗ trợ grant này, là có thể đăng nhập bằng device flow
Cách nên lấy làm mặc định cho CLI mới
- Mặc định nên là device flow
- Nên khám phá endpoint từ
.well-known/openid-configurationthay vì hardcode URL - Bắt buộc phải tuân thủ
intervalvàslow_down - Refresh token nên được lưu trong OS keychain, không phải file JSON dưới
~/.config - Nếu muốn cung cấp loopback path cho trải nghiệm đăng nhập nhanh trên laptop, hãy đặt nó sau cờ
--webvà không biến nó thành mặc định
Những CLI đã chuyển sang và các công cụ còn lại
- Đã có các CLI lấy device flow làm mặc định
gh auth logindùng device flow ngay từ đầu và được xem là reference implementation gọn gàng nhất trong mã nguồn mởaws sso loginchạy device flow end-to-end với IAM Identity Centervercel loginđã chuyển sang RFC 8628 vào tháng 9/2025, thay thế login qua email và cờ--oobtrước đó- Stripe CLI không dùng đúng RFC 8628, nhưng dùng pairing-code flow với UX được làm tốt
- Vẫn còn các công cụ lấy loopback flow làm mặc định và gắn thêm fallback paste-the-code
- Google
gcloud - Cloudflare
wrangler - Anthropic
claude
- Google
- Nếu một CLI cứ mỗi lần rời khỏi laptop lại cần fallback paste-the-code thủ công, thì tốt hơn nên cung cấp chính fallback đó như luồng mặc định
1 bình luận
Ý kiến trên Lobste.rs
Cách diễn đạt hơi sơ sài nhưng khá thú vị. Nếu mã thiết bị/liên kết được thay đổi mỗi 1 phút thì có lẽ cũng sẽ giảm việc bị lạm dụng cho phishing
Sau khi đã được dùng một lần thì có thể ngừng xoay vòng và gắn phiên đó với IP hoặc trình duyệt
Với những nhà cung cấp như Microsoft, nơi người dùng phải tự nhập mã, trang đích thậm chí có thể hiện hướng dẫn rồi sao chép mã vào clipboard để khiến họ dễ sập bẫy phishing hơn
Bài viết hay, và tôi đồng ý rằng mọi người nên chuyển sang RFC 8628
Vì phải trải qua quy trình CLI OAuth trên máy phát triển từ xa quá thường xuyên, tôi đã làm một công cụ cá nhân chặn
xdg-openvà tự động chuyển tiếp cổng để che đi trải nghiệm người dùng tệ hại: https://github.com/phinze/bankshotKhá thú vị. Gần đây tôi vừa triển khai cách xác thực “cũ” là RFC 8252, nhưng lại không biết về cách “mới” là RFC 8628
Có lẽ là vì trường hợp sử dụng chính của tôi là xác thực máy chủ Google nên bị hổng kiến thức. Trong tài liệu mà tôi tưởng là luồng RFC 8628, có ghi rõ như sau
Việc Google giới hạn phạm vi là chỗ OIDC thò đầu ra một cách khá rắc rối. Lý tưởng nhất là Google nên trả về ID token thay vì nhồi mọi thứ vào access token, nhưng đó là vấn đề trong cách Google cấu hình OAuth chứ không phải đặc tính của riêng 8628
Sự phức tạp vô tận của OAuth đến từ đây. Chuẩn này định nghĩa khá tốt khuôn khổ cách tạo và truyền tải sơ đồ cấp quyền, nhưng cố ý không nói gì về việc nó nên là gì. Phải mất tới việc OIDC ra đời và thêm nhiều năm nữa mới có được một bộ endpoint HTTP chung mà “đa số” nhà cung cấp cùng đồng ý
Một mẹo khác là chuyển tiếp lệnh gọi
xdg-opentrên máy chủ về laptop. Tôi đã làm một công cụ nhỏ để làm việc đó cho hạ tầng cá nhân: https://github.com/zimbatm/subportal/Không thể kết hợp hai cách tiếp cận này sao? Tức là chuyển hướng đến URL
localhostvà yêu cầu trả vềhello, rồi nếu client không nhận đượchellothì CLI sẽ in ra URLĐồng thời, nếu máy chủ không nhận được phản hồi cho
hellomà nó gửi thì trình duyệt sẽ hiện mã và hiển thị thông điệp kiểu “hãy xác nhận bạn đang cố đăng nhập”. Cũng có thể làm dễ hơn theo kiểu Google, tức là hiện một con số để chọn trên điện thoạiĐiểm hay là ngay cả trong trường hợp 2, mọi người vẫn dễ bấm vào liên kết nhưng lại ít chia sẻ OTP/mã hơn tương đối, và kẻ tấn công sẽ phải tiếp tục can thiệp bằng social engineering trong suốt quá trình tấn công
Khi nó hoạt động tốt trên máy cục bộ thì không cần tương tác, nên mặc định tôi muốn nó là luồng dựa trên trình duyệt