Bỏ qua để vào nội dung chính

Dựng remote MCP server trên Next.js: OAuth và 5 cái bẫy

Bởi Isabella Chen
10 thg 9, 20264 phút đọc

Bốn endpoint, stateless transport, 401 phải kèm WWW-Authenticate, và một đòn chiếm token mà PKCE không chặn được. Ghi chú từ một triển khai thật.

MCP server chạy stdio trên laptop thì dễ: một process cục bộ, một file config, một token bạn dán vào. Đưa nó lên HTTPS để Claude hay ChatGPT đăng nhập bằng browser là chuyện khác — và đó đúng là chỗ phần lớn tutorial dừng lại. Một bài viết trên dev.to mô tả toàn bộ đường đi trên Next.js 16 App Router, chỉ dùng SDK chính thức, kèm các bẫy hiếm khi được nói ra.

Bốn bề mặt HTTP, và client tự đi hết

Theo tác giả, server cần bốn endpoint: POST /api/mcp là chính nó; /.well-known/oauth-protected-resource/api/mcp nói "ai cấp quyền cho tôi"; /.well-known/oauth-authorization-server nói "endpoint OAuth của tôi ở đâu"; và /authorize, /token, /register. Một client chưa từng thấy server của bạn sẽ đi qua cả bốn theo thứ tự, không cần ai bảo. Chuỗi discovery đó là lý do người dùng dán một URL vào Claude rồi thấy trang đăng nhập, thay vì bị hỏi token.

Ba bẫy hạ tầng

  • Stateless không phải bản cắt giảm. Đặt sessionIdGenerator: undefined để server không giữ session: trên Vercel hay bất kỳ host serverless, các request liên tiếp rơi vào instance khác nhau, nên session không có chỗ để tồn tại. Tác giả gọi đây là lựa chọn duy nhất chạy được.
  • CORS thiếu Expose-Headers là lỗi im lặng. Client trong browser gọi cross-origin, browser bắn preflight OPTIONS trước; preflight fail thì request thật không bao giờ xảy ra, log của bạn trống, còn client báo một câu vô nghĩa. Thiếu Access-Control-Expose-Headers thì browser che các header giao thức MCP dù server đã gửi.
  • 401 phải chỉ đường. Một call chưa xác thực không được trả 401 trơn: nó phải kèm WWW-Authenticate với URL metadata theo RFC 9728. Đúng một header đó là thứ khởi động toàn bộ luồng đăng nhập bằng browser; trả 401 trơn thì client kết luận server của bạn hỏng, chứ không phải đang khoá.

Bẫy bảo mật: PKCE không cứu bạn

Đây là phần đáng đọc nhất. Mô hình quen thuộc cho rằng PKCE làm cho redirect_uri chưa đăng ký trở nên an toàn, vì code bị đánh cắp thì vô dụng nếu không có verifier. Tác giả chỉ ra mô hình đó vỡ khi kẻ tấn công chính là người khởi tạo luồng: chúng tạo link /authorize với redirect_uricode_challenge của chúng, gửi cho nạn nhân đang đăng nhập, rồi đổi code bằng verifier chúng tự chọn. Một cú click là mất access token của người khác. PKCE làm đúng việc của nó và không bảo vệ được ai.

Cách chặn mà tác giả dùng: GET /authorize render trang đồng ý, code chỉ được phát bởi một POST từ trang đó, và POST bị từ chối nếu Origin khác origin của bạn. Hai tính chất làm việc này: một link được dựng sẵn không thể thay nạn nhân bấm đồng ý, và Origin không thể bị script trong trang giả mạo. Thêm một chi tiết dễ mất buổi chiều: redirect sau khi đồng ý phải là 303, không phải 307 — 307 giữ nguyên method nên browser sẽ POST code vào callback chỉ nhận GET, và client báo "Bad Request" như thể lỗi của nó.

Token và quyền

Tác giả để token là một row trong database và chỉ lưu hash, không lưu token. Một query trả về cả tính hợp lệ lẫn danh tính, role và plan của người gọi; kết nối MCP hành xử như chính user đó, nên row-level security có sẵn tự áp cho traffic MCP mà không cần mô hình quyền song song. Một bài học khác từ chính tác giả: quyết định về capability là quyết định theo URL, không phải theo runtime — app directory đăng ký OAuth client theo base URL rồi đóng băng nó, nên phải chốt endpoint public được làm gì trước khi đem đi liệt kê.

Nếu bạn định làm tuần này

Kiểm ba thứ trước tiên: 401 của bạn có WWW-Authenticate chưa, response có Expose-Headers chưa, và /authorize có trang đồng ý với POST same-origin chưa. Ba thứ đó chặn hai lỗi im lặng và một lỗ hổng chiếm token.

Không spam, hủy đăng ký bất kỳ lúc nào.

Bài viết liên quan