Là gì
OAuth 2.0 là giao thức uỷ quyền — nó cấp cho một ứng dụng quyền truy cập tài nguyên thay mặt người dùng. OpenID Connect (OIDC) là một lớp xác thực dựng trên OAuth để trả lời "người này là ai". Phần lớn lỗ hổng đến từ việc dùng OAuth (uỷ quyền) như thể nó là xác thực, và từ việc bỏ sót các phép kiểm mà giao thức đòi hỏi nhưng không cưỡng chế được.
Vì sao bạn quan tâm
OAuth khó không phải vì mật mã — mà vì nó là một giao thức nhiều bên, nhiều bước, và mỗi bước có một phép kiểm mà nếu quên thì không có gì báo lỗi. Luồng vẫn chạy, người dùng vẫn đăng nhập được, và lỗ hổng chỉ lộ ra khi có người tấn công.
Bốn lỗi phổ biến nhất, và cả bốn đều là bỏ sót:
- Không kiểm
state.statelà token chống CSRF của luồng OAuth. Thiếu nó, kẻ tấn công ghép mã uỷ quyền CỦA HỌ vào phiên nạn nhân — nạn nhân đăng nhập vào tài khoản của kẻ tấn công (login CSRF), rồi nhập dữ liệu vào đó. - Không dùng PKCE. Mã uỷ quyền bị chặn trên đường (redirect qua trình duyệt) có thể đổi lấy token. PKCE gắn mã với client đã khởi tạo, nên mã bị đánh cắp là mã vô dụng.
redirect_urikhớp lỏng. Nếu server chấp nhận redirect_uri khớp tiền tố hay khớp domain con, kẻ tấn công chuyển mã uỷ quyền tới server của họ.- Dùng
access_tokenđể xác thực. Access token nói "được phép làm gì", không nói "là ai". Dùng nó để đăng nhập là lỗ hổng "confused deputy" — một token cấp cho app khác được nhận làm danh tính.
Và một điều kiến trúc quan trọng: implicit flow đã chết. OAuth 2.1 và RFC 9700 bỏ nó hoàn toàn vì nó trả token trong URL fragment (rò qua lịch sử, referer, log). Nếu code của bạn còn response_type=token thì việc cần làm là chuyển sang authorization code + PKCE, không phải vá.
Cơ chế hoạt động
Luồng authorization code là bốn bước, và mỗi bước có một phép kiểm. Sơ đồ dưới đánh dấu chỗ mỗi phép kiểm sống.
sequenceDiagram autonumber actor U as Người dùng participant A as App của bạn participant I as Identity Provider A->>U: Chuyển tới I kèm state + code_challenge (PKCE) U->>I: Đăng nhập và đồng ý I->>A: Redirect về redirect_uri?code=...&state=... Note over A: KIỂM 1: state khớp giá trị đã lưu? (chống CSRF) A->>I: Đổi code lấy token, kèm code_verifier (PKCE) Note over I: KIỂM 2: code_verifier khớp code_challenge? I->>A: access_token + id_token Note over A: KIỂM 3: verify id_token (chữ ký, aud, iss, nonce)<br/>KIỂM 4: dùng id_token cho danh tính,<br/>KHÔNG dùng access_tokenĐiểm cốt lõi: code bị chặn trên đường (nó đi qua trình duyệt) là vô dụng NẾU có PKCE — vì đổi token cần code_verifier mà chỉ client khởi tạo mới có. Không có PKCE thì code bị chặn đổi được token ngay.
Bảng phép kiểm và hậu quả khi bỏ:
| Phép kiểm | Bỏ thì sao |
|---|---|
state khớp | Login CSRF — nạn nhân đăng nhập vào tài khoản kẻ tấn công |
| PKCE | Mã uỷ quyền bị chặn đổi được token |
redirect_uri khớp CHÍNH XÁC | Mã uỷ quyền chuyển tới server kẻ tấn công |
verify id_token (chữ ký, aud, iss) | Chấp nhận token giả hoặc token của app khác |
nonce | Replay một id_token cũ |
dùng id_token cho danh tính | Confused deputy — access token của app khác thành danh tính |
Hàng cuối đáng giải thích: một số app "đăng nhập bằng Google" nhận một access_token từ client rồi gọi Google /userinfo để lấy danh tính. Nhưng access token đó có thể do một app KHÁC lấy — và Google trả về danh tính đúng của nạn nhân, nên app tin nhầm kẻ tấn công là nạn nhân. Đây là "confused deputy", và bản vá là dùng id_token (có aud là chính app của bạn).
Mô tả sơ đồ: Sơ đồ tuần tự luồng authorization code với bốn phép kiểm được đánh dấu. App chuyển người dùng tới identity provider kèm state và code_challenge của PKCE. Người dùng đăng nhập và đồng ý, provider redirect về redirect_uri kèm code và state. Kiểm một: state khớp giá trị đã lưu để chống CSRF. App đổi code lấy token kèm code_verifier; kiểm hai ở provider: code_verifier khớp code_challenge. Provider trả access_token và id_token. Kiểm ba: verify id_token gồm chữ ký, aud, iss, nonce. Kiểm bốn: dùng id_token cho danh tính, không dùng access_token.
Ví dụ cụ thể
Ba đường tấn công, tương ứng ba phép kiểm bị bỏ ở khối 3.
# ① Login CSRF vì không kiểm state. Kẻ tấn công lấy code CỦA HỌ rồi ép nạn nhân dùng nó.# Kẻ tấn công bắt đầu luồng OAuth, dừng ở bước có code, rồi gửi link cho nạn nhân:GET https://app.example/oauth/callback?code=<code_CỦA_KẺ_TẤN_CÔNG> HTTP/1.1 # Nạn nhân bấm link → app đổi code → nạn nhân giờ đăng nhập vào tài khoản kẻ tấn công,# và mọi thứ họ nhập (thẻ tín dụng, tài liệu) vào tài khoản đó.# ② redirect_uri khớp lỏng. Server chấp nhận bất cứ gì bắt đầu bằng URL đã đăng ký.GET https://id.provider/authorize ?client_id=app &redirect_uri=https://app.example.evil.com/callback ← khớp tiền tố "https://app.example" &response_type=code # code được gửi tới app.example.evil.com — server của kẻ tấn công.# ③ Confused deputy. App nhận access_token từ client rồi tin /userinfo.POST /api/auth/google HTTP/1.1{"access_token":"<token kẻ tấn công lấy được cho MỘT APP KHÁC>"} # App gọi Google /userinfo với token đó → Google trả danh tính đúng của chủ token,# nhưng token đó có thể do một app lừa đảo lấy, và người dùng không hề định đăng nhập vào app.example.# Sau khi vá: state kiểm, PKCE bắt buộc, redirect_uri khớp CHÍNH XÁC, dùng id_token.# code bị chặn trên đường → vô dụng vì thiếu code_verifier.# access_token của app khác → id_token có aud sai → bị từ chối.[HttpGet("/oauth/callback")]public async Task<IActionResult> Callback([FromQuery] string code, [FromQuery] string state){ // ❌ 1 — không kiểm state. state là token chống CSRF của luồng. Thiếu nó, kẻ tấn // công ghép code CỦA HỌ vào phiên nạn nhân → nạn nhân đăng nhập vào tài khoản // kẻ tấn công (login CSRF). // // ❌ 2 — không PKCE. Một code bị chặn trên đường (nó đi qua trình duyệt) đổi // được token ngay, vì không có code_verifier để đòi. var http = new HttpClient(); var tokenResponse = await http.PostAsync("https://id.provider/token", new FormUrlEncodedContent( new Dictionary<string, string> { ["grant_type"] = "authorization_code", ["code"] = code, ["client_id"] = "app", ["client_secret"] = _secret, // ❌ 3 — redirect_uri không cần khớp chính xác vì server tự viết cũng // khớp lỏng. Covert Redirect ở khối 5 là chính xác lỗi này. })); var tokens = await tokenResponse.Content.ReadFromJsonAsync<TokenResponse>(); // ❌ 4 — dùng ACCESS_TOKEN để lấy danh tính. access_token nói "được phép làm gì", // không nói "là ai". Một token cấp cho APP KHÁC vẫn gọi được /userinfo và // Google trả về danh tính đúng của chủ token → confused deputy. var userinfo = await http.GetFromJsonAsync<UserInfo>( $"https://id.provider/userinfo?access_token={tokens.AccessToken}"); await SignInAsync(userinfo.Email); // đăng nhập bằng danh tính chưa verify đúng cách // ❌ 5 — và token trả về client để lưu localStorage. return Ok(new { accessToken = tokens.AccessToken, refreshToken = tokens.RefreshToken });}// ❌ Implicit flow: response_type=token. OAuth 2.1/RFC 9700 đã BỎ nó vì token trả// trong URL fragment — rò qua lịch sử trình duyệt, referer, và log.function login() { const params = new URLSearchParams({ client_id: "app", redirect_uri: "https://app.example/callback", response_type: "token", // ❌ implicit scope: "openid profile email", // ❌ không có state, không có code_challenge (PKCE) }); window.location.href = "https://id.provider/authorize?" + params;} function handleCallback() { // ❌ Token đọc từ URL fragment và lưu localStorage. XSS đọc được bằng một dòng. const token = new URLSearchParams(location.hash.slice(1)).get("access_token"); localStorage.setItem("access_token", token); // XSS đọc được — xem topic xss}Chuyện đã xảy ra
"Covert Redirect" (Wang Jing, 2014). Một họ lỗ hổng redirect_uri khớp lỏng trên nhiều nhà cung cấp OAuth, cho phép chuyển mã uỷ quyền hoặc token tới domain kẻ tấn công qua một redirect mở trên chính domain đã đăng ký. Đáng nhớ vì nó cho thấy khớp tiền tố hay khớp domain là không đủ — redirect_uri phải khớp CHÍNH XÁC.
Facebook "Login with Facebook" confused deputy (nhiều báo cáo 2012–2018). Mẫu lặp lại: một app nhận access_token từ client rồi gọi Graph API lấy danh tính. Token đó lấy được cho một app khác, nên kẻ tấn công đăng nhập vào tài khoản nạn nhân ở app thứ ba. Bản vá là kiểm aud của token — chính xác luận điểm hàng cuối bảng khối 3.
Và quyết định của chuẩn: OAuth 2.1 (draft) và RFC 9700 (BCP, 2025). Chúng bỏ implicit flow, bỏ password grant, và làm PKCE bắt buộc cho mọi authorization code flow — kể cả client bí mật. Đây không phải khuyến nghị "nên"; đây là chuẩn nói "các cách cũ không an toàn". Nếu code của bạn còn implicit flow hay thiếu PKCE, đó là nợ kỹ thuật đã được chuẩn đánh dấu.
Cách phòng chống
Authorization code + PKCE, và bốn phép kiểm đầy đủ
bắt buộcBản vá cốt lõi là dùng đúng luồng và không bỏ phép kiểm nào ở bảng khối 3.
- Authorization code flow, không bao giờ implicit. OAuth 2.1/RFC 9700 đã bỏ implicit — nó trả token trong URL fragment, rò qua lịch sử trình duyệt, referer và log.
- PKCE bắt buộc, kể cả cho client bí mật.
code_challenge = SHA256(code_verifier)gửi lúc khởi tạo,code_verifiergửi lúc đổi token. Một mã uỷ quyền bị chặn là vô dụng vì kẻ chặn không có verifier. statesinh ngẫu nhiên, lưu server-side, kiểm khi callback về. Đây là token chống CSRF của luồng. Với client, lưu trong cookieHttpOnlygắn với phiên khởi tạo (không chỉ so với chính giá trị trong URL).noncetrong id_token để chống replay.
Và không tự viết OAuth client. Đây là chỗ quan trọng: dùng một thư viện đã được kiểm — Microsoft.AspNetCore.Authentication.OpenIdConnect cho .NET — vốn đã cưỡng chế PKCE, state, nonce, và verify id_token đúng cách. Tự viết là tự nhận trách nhiệm cho cả bảy phép kiểm, và bỏ sót một cái là chuyện gần như chắc chắn.
// KHÔNG tự viết OAuth client. Microsoft.AspNetCore.Authentication.OpenIdConnect// cưỡng chế PKCE, state, nonce, và verify id_token đúng cách — bốn trong bảy phép// kiểm. Ba phép còn lại (redirect_uri chính xác, dùng id_token, chỗ lưu token) là// cấu hình bên dưới.builder.Services .AddAuthentication(o => { o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; o.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) // BFF pattern: sau khi đăng nhập, client chỉ giữ một cookie phiên HttpOnly. // Token KHÔNG BAO GIỜ chạm tới JavaScript, nên XSS không đọc được — cách an // toàn nhất, và là mặc định ở đây. .AddCookie(o => { o.Cookie.HttpOnly = true; o.Cookie.SecurePolicy = CookieSecurePolicy.Always; o.Cookie.SameSite = SameSiteMode.Lax; o.Cookie.Name = "__Host-session"; // xem topic csrf }) .AddOpenIdConnect(o => { o.Authority = "https://id.provider"; // JWKS, endpoint lấy từ metadata o.ClientId = "app"; o.ClientSecret = builder.Configuration["Oidc:ClientSecret"]; // authorization code flow, KHÔNG implicit (OAuth 2.1 đã bỏ implicit). o.ResponseType = "code"; // PKCE bắt buộc — kể cả với client bí mật (RFC 9700). Thư viện tự sinh // code_verifier/challenge và kiểm. o.UsePkce = true; // state và nonce do thư viện tự sinh, lưu trong một cookie tương quan // HttpOnly, và kiểm khi callback về. Không có gì cho ta bỏ sót ở đây. o.SaveTokens = true; // lưu ở SERVER (trong cookie đã mã hoá), không trả về client // DÙNG id_token cho danh tính. Thư viện verify chữ ký qua JWKS, kiểm aud = // ClientId, iss = Authority, nonce, exp — bảy phép kiểm của topic jwt. Đây // là bản vá cho confused deputy: một access_token của app khác không có // đường nào trở thành danh tính ở đây. o.GetClaimsFromUserInfoEndpoint = true; // làm giàu claim, KHÔNG dùng để xác thực o.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidAudience = "app", ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds(30), // không phải 5 phút mặc định }; // Scope tối thiểu (lớp 2): chỉ những gì app thật sự cần. o.Scope.Clear(); o.Scope.Add("openid"); o.Scope.Add("profile"); o.Scope.Add("email"); // redirect_uri phải khớp CHÍNH XÁC với giá trị đã đăng ký ở provider. // Đăng ký ở provider là danh sách hữu hạn URL đầy đủ — không khớp tiền tố, // không domain con, không regex (Covert Redirect ở khối 5). o.CallbackPath = "/signin-oidc"; });// SPA với authorization code + PKCE. Dùng thư viện đã kiểm (oidc-client-ts),// không tự viết — nhưng phần quan trọng dưới đây minh hoạ các phép kiểm. async function login() { // PKCE: sinh verifier ngẫu nhiên và challenge = SHA256(verifier). Web Crypto, // không Math.random — verifier là một bí mật. const verifier = base64url(crypto.getRandomValues(new Uint8Array(32))); const challenge = base64url(new Uint8Array( await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))); // state: token chống CSRF của luồng. Lưu cùng verifier để callback kiểm lại. const state = base64url(crypto.getRandomValues(new Uint8Array(16))); sessionStorage.setItem("oauth", JSON.stringify({ state, verifier })); const params = new URLSearchParams({ client_id: "app", redirect_uri: "https://app.example/callback", // khớp CHÍNH XÁC với đăng ký response_type: "code", // authorization code, không implicit scope: "openid profile email", // scope tối thiểu (lớp 2) state, code_challenge: challenge, code_challenge_method: "S256", }); location.href = "https://id.provider/authorize?" + params;} async function handleCallback() { const url = new URLSearchParams(location.search); const saved = JSON.parse(sessionStorage.getItem("oauth") ?? "{}"); // KIỂM state: so với giá trị ĐÃ LƯU, không với chính giá trị trong URL. if (!url.get("state") || url.get("state") !== saved.state) { throw new Error("state không khớp — có thể là login CSRF"); } // Đổi code lấy token Ở SERVER (BFF), không ở client. Server giữ client_secret, // đặt phiên vào cookie HttpOnly, và token KHÔNG BAO GIỜ trả về JavaScript — // nên XSS không có gì để đọc. const res = await fetch("/api/oauth/exchange", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: url.get("code"), verifier: saved.verifier }), }); sessionStorage.removeItem("oauth"); if (!res.ok) throw new Error("đổi token thất bại"); // Không lưu token nào ở client. Danh tính nằm trong cookie phiên HttpOnly.}`redirect_uri` khớp CHÍNH XÁC, và dùng `id_token` cho danh tính
bắt buộcHai phép kiểm còn lại, và cả hai là chỗ lỗ hổng thật sống.
redirect_uri khớp chuỗi chính xác với danh sách đã đăng ký — không khớp tiền tố, không khớp domain con, không regex. https://app.example/callback khớp đúng chuỗi đó, không khớp https://app.example.evil.com/callback (tiền tố) hay https://app.example/callback/../x (path traversal — xem topic path-traversal). Covert Redirect ở khối 5 là chính xác lỗi khớp lỏng này. Đăng ký ở phía provider phải là danh sách hữu hạn URL đầy đủ.
Dùng id_token cho danh tính, không dùng access_token. Access token trả lời "được phép làm gì", id_token trả lời "là ai" — và id_token có aud là chính app của bạn, nên một token cấp cho app khác bị từ chối. Đây là bản vá cho confused deputy (hàng cuối bảng khối 3, và sự cố Facebook ở khối 5).
Cụ thể: không nhận token từ client rồi gọi /userinfo để đăng nhập. Luồng đúng là app (server) đổi code lấy id_token, verify chữ ký qua JWKS của provider, kiểm aud = client_id của bạn, kiểm iss = provider của bạn (xem topic jwt — cùng bảy phép kiểm).
Token ở đúng chỗ: refresh token server-side, không token trong localStorage
Sau khi lấy token đúng cách, chỗ lưu chúng quyết định một XSS có đánh cắp được phiên không.
- Với web app truyền thống (BFF pattern): token ở SERVER, client chỉ có một cookie phiên
HttpOnly. Đây là cách an toàn nhất — token không bao giờ chạm tới JavaScript, nên XSS không đọc được. Đây cũng là cáchAddOpenIdConnectcủa ASP.NET Core làm theo mặc định. - Với SPA: nếu buộc phải giữ token ở client, access token trong bộ nhớ (biến JS, không
localStorage), refresh token trong cookieHttpOnly+SameSite.localStorageđọc được bằng một dòng JS — xem topic xss lớp 3. - Refresh token rotation với phát hiện tái sử dụng: xem topic jwt lớp 1b. Một refresh token đã dùng xuất hiện lần hai nghĩa là nó bị đánh cắp — thu hồi cả họ token của phiên.
Nguyên tắc: OAuth cho bạn token; token là credential; và mọi bài học về giữ credential ở topic jwt và xss áp dụng nguyên vẹn ở đây.
Scope tối thiểu và consent rõ ràng
Lớp này giới hạn thiệt hại khi một token bị lộ hoặc một consent bị lừa (clickjacking trang consent — xem topic clickjacking).
- Yêu cầu scope tối thiểu. Một app đọc profile không cần scope ghi. Scope thừa nghĩa là một token bị đánh cắp làm được nhiều hơn cần thiết — cùng nguyên tắc quyền tối thiểu như topic api-security.
- Trang consent chống clickjacking.
frame-ancestors none, và với hành động cấp quyền quan trọng thì yêu cầu tương tác không-một-cú-bấm (xem topic clickjacking lớp 1b). Một consent OAuth bị clickjack cấp cho app kẻ tấn công quyền truy cập dài hạn. - Kiểm và giới hạn ứng dụng bên thứ ba được phép. Nếu bạn là identity provider, danh sách app đã đăng ký là bề mặt tin cậy của bạn — cùng vấn đề inventory như API9 ở topic api-security.
Phát hiện: callback bất thường là tín hiệu độ nhiễu thấp
Luồng OAuth có hình dạng đều đặn, nên lệch khỏi hình dạng đó là tín hiệu sạch.
- Callback với
statekhông khớp hoặc thiếu. Client hợp lệ luôn gửi lại đúngstateđã lưu. Một callback thiếu state hoặc state lạ là một lần thử login CSRF — alert lần đầu, không theo tổng số. redirect_uribị từ chối. Nếu provider của bạn từ chối một redirect_uri không khớp, log nó: nó là một lần thử covert redirect.- id_token với
audhoặcisssai nhưng chữ ký hợp lệ. Cùng tín hiệu với topic jwt — nó nghĩa là có ai đang thử token của một app hay một provider khác. - Đổi code thất bại vì thiếu/sai code_verifier. Với PKCE bật, một lần đổi code không có verifier đúng là một mã bị chặn đang được thử dùng.
Đây là lớp 3 vì các phép kiểm ở lớp 1 đã chặn; nhưng log chúng biến "đã chặn" thành "biết ai đang thử".
Kiểm chứng đã vá
1. Kiểm PKCE và state được cưỡng chế — bắt đầu ngay từ URL authorize:
B=https://app.example# Khởi tạo luồng và kiểm redirect tới provider có CẢ state lẫn code_challenge.L=$(curl -sI "$B/oauth/login" | grep -i '^location:' | tr -d '\r')echo "$L" | grep -q 'code_challenge=' || { echo "THIẾU PKCE"; exit 1; }echo "$L" | grep -q 'state=' || { echo "THIẾU state"; exit 1; }echo "$L" | grep -q 'response_type=code' || { echo "không dùng authorization code flow"; exit 1; }echo "$L" | grep -q 'response_type=token' && { echo "còn dùng implicit flow — đã bị OAuth 2.1 bỏ"; exit 1; }2. Test callback với state SAI phải bị từ chối — đây là phép kiểm cho login CSRF:
# Callback với một state không khớp giá trị đã lưu trong phiên phải là 4xx, không đăng nhập.curl -s -o /dev/null -w '%{http_code}\n' "$B/oauth/callback?code=x&state=wrong-value" \ -b "oauth_session=<phiên có state khác>" # phải là 400, không phải 302 đăng nhập3. Test redirect_uri khớp CHÍNH XÁC (nếu bạn là provider) — Covert Redirect ở khối 5:
for uri in 'https://app.example.evil.com/callback' \ 'https://app.example/callback/../evil' \ 'https://app.example.evil.com' \ 'https://app.example@evil.com/callback'; do code=$(curl -s -o /dev/null -w '%{http_code}' \ "https://id.provider/authorize?client_id=app&response_type=code&redirect_uri=$uri") [ "$code" = "400" ] || echo "redirect_uri lỏng chấp nhận: $uri ($code)"done4. Kiểm app dùng id_token cho danh tính, không dùng access_token (confused deputy). Đây là phép kiểm cần đọc code, không tự động hoàn toàn được — tìm chỗ đăng nhập và khẳng định nó verify một id_token (kiểm aud), không phải nhận access_token rồi gọi /userinfo:
grep -rnE 'userinfo|access_token' --include='*.cs' src/ | grep -iE 'login|signin|authenticate' \ && echo "CẢNH BÁO: có thể đang dùng access_token/userinfo để xác thực — xem lại"5. Verify id_token đi qua đúng bảy phép kiểm của JWT — xem topic jwt khối 7. id_token LÀ một JWT, nên aud, iss, chữ ký, exp, và nonce đều phải được kiểm.
6. Kiểm token không nằm trong localStorage (lớp 1c):
grep -rnE 'localStorage\.(set|get)Item\([^)]*(token|access|refresh|id_token)' \ --include='*.ts' --include='*.tsx' src/ \ && { echo "token trong localStorage — XSS đọc được"; exit 1; }exit 0public class OAuthFlowTests : IClassFixture<ApiFixture>{ private readonly ApiFixture _fx; public OAuthFlowTests(ApiFixture fx) => _fx = fx; /// <summary> /// URL authorize phải mang CẢ state lẫn code_challenge, và response_type=code. /// Đây là phép kiểm sớm nhất — nó bắt được implicit flow và thiếu PKCE ngay ở /// bước đầu, trước khi có bất kỳ token nào. /// </summary> [Fact] public async Task Authorize_redirect_carries_state_and_pkce() { var res = await _fx.Client.GetAsync("/oauth/login"); var location = res.Headers.Location!.ToString(); Assert.Contains("response_type=code", location); Assert.DoesNotContain("response_type=token", location); // implicit đã bị bỏ Assert.Contains("code_challenge=", location); Assert.Contains("code_challenge_method=S256", location); Assert.Contains("state=", location); Assert.Contains("nonce=", location); } /// <summary> /// Login CSRF: callback với state không khớp giá trị đã lưu phải bị từ chối. /// So sánh phải với giá trị ĐÃ LƯU (cookie tương quan), không với chính giá trị /// trong URL — kẻ tấn công kiểm soát URL. /// </summary> [Fact] public async Task Callback_with_mismatched_state_is_rejected() { var (client, _) = await _fx.StartOAuthFlowAsync(); // lưu state=A trong cookie // Callback mang state=B (của kẻ tấn công) → không khớp A → từ chối. var res = await client.GetAsync("/signin-oidc?code=attacker_code&state=B"); Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); Assert.False(res.Headers.Contains("Set-Cookie")); // không có phiên nào được tạo } /// <summary> /// Confused deputy: một access_token cấp cho APP KHÁC không được nhận làm danh /// tính. Bản vá là dùng id_token có aud = client của ta, nên một token có aud /// khác bị từ chối ở tầng verify. /// </summary> [Fact] public async Task Access_token_for_another_app_is_not_accepted_as_identity() { // Token này hợp lệ, do đúng provider ký, nhưng aud là một app khác. var foreignIdToken = _fx.IssueIdToken(aud: "some-other-app", sub: "victim"); var res = await _fx.Client.PostAsJsonAsync("/oauth/callback-exchange", new { idToken = foreignIdToken }); Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode); } /// <summary> /// redirect_uri phải khớp CHÍNH XÁC (nếu ta là provider). Mỗi dòng là một biến /// thể Covert Redirect ở khối 5 — tiền tố, path traversal, userinfo trong host, /// domain con. /// </summary> [Theory] [InlineData("https://app.example.evil.com/callback")] // tiền tố [InlineData("https://app.example/callback/../evil")] // path traversal [InlineData("https://app.example@evil.com/callback")] // userinfo trong host [InlineData("https://evil.com/callback?x=app.example")] // chuỗi con public async Task Provider_rejects_non_exact_redirect_uri(string uri) { var res = await _fx.ProviderClient.GetAsync( $"/authorize?client_id=app&response_type=code&redirect_uri={Uri.EscapeDataString(uri)}"); Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); } /// <summary>Token không được trả về cho client để lưu localStorage (BFF pattern).</summary> [Fact] public async Task Tokens_are_not_returned_to_the_client() { var (client, _) = await _fx.CompleteOAuthFlowAsync(_fx.Alice); var body = await (await client.GetAsync("/api/me")).Content.ReadAsStringAsync(); Assert.DoesNotContain("access_token", body); Assert.DoesNotContain("refresh_token", body); // Danh tính đến từ cookie phiên HttpOnly, không từ token trong body. }}Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
| Dùng implicit flow "vì là SPA" | OAuth 2.1/RFC 9700 đã bỏ nó. Authorization code + PKCE là cách đúng cho SPA |
| Không dùng PKCE "vì client bí mật" | RFC 9700 làm PKCE bắt buộc cho MỌI authorization code flow, kể cả client bí mật |
Kiểm state bằng cách so với chính giá trị trong URL | Kẻ tấn công kiểm soát URL. state phải so với giá trị đã lưu ở server/cookie HttpOnly |
| redirect_uri khớp tiền tố hoặc domain | app.example.evil.com khớp tiền tố app.example. Covert Redirect chính là lỗi này. Khớp CHÍNH XÁC |
| Nhận access_token từ client rồi gọi /userinfo để đăng nhập | Confused deputy: token của app khác được nhận làm danh tính. Dùng id_token có aud |
| Không verify chữ ký id_token | id_token là JWT. Bỏ verify là chấp nhận token giả — xem topic jwt |
| Token trong localStorage | XSS đọc được bằng một dòng. Cookie HttpOnly, hoặc BFF giữ token ở server |
| Tự viết OAuth client | Bảy phép kiểm, và bỏ một cái không báo lỗi. Dùng thư viện đã kiểm |
Sai lầm về khái niệm, và nó là gốc của nhiều hàng trên: dùng OAuth (uỷ quyền) như xác thực. access_token nói "được phép làm gì", không nói "là ai". Đăng nhập bằng access_token là confused deputy. OIDC id_token mới là câu trả lời cho "là ai", và nó có aud để bạn biết token đó dành cho bạn.
Sai lầm về sở hữu phép kiểm: nghĩ thư viện lo hết. Thư viện cưỡng chế PKCE, state, nonce nếu bạn cấu hình đúng — nhưng redirect_uri khớp chính xác là ở phía provider, dùng id_token vs access_token là quyết định của code bạn, và chỗ lưu token là kiến trúc của bạn. Thư viện đóng bốn phép kiểm, ba phép còn lại là của bạn.
Bình luận
Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.
Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.
Đang tải bình luận…