OAuth 2.0, OIDC và PKCE: đăng nhập qua identity provider mà không để lộ code hay token Trung cấp
Thời gian đọc ước tính: 12 phút
Cần đọc trước
Vấn đề
Phần tiêu đề “Vấn đề”Một shop có SPA ở shop.example, một mobile app và orders API viết bằng NestJS ở api.shop.example. Người dùng đăng nhập qua identity provider (IdP) ở id.shop.example, nên shop không tự lưu mật khẩu.
Bản đầu đi đường tắt. SPA gửi thẳng mật khẩu tới token endpoint của IdP và cất access token trong localStorage. Mobile app đăng nhập trong một WebView nhúng. API nhận mọi token do IdP ký.
Mỗi client nên dùng luồng nào, token nằm ở đâu, và API phải kiểm gì trước khi tin một token?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Tình huống | Chọn | Điều kiện |
|---|---|---|
| SPA có backend riêng | BFF + Authorization Code + PKCE; browser chỉ giữ session cookie | BFF là confidential client; quy tắc cookie xem Session hay JWT |
| SPA hoàn toàn không có backend | Public client chạy trong browser, Authorization Code + PKCE | Kém an toàn nhất theo RFC 10017; access token ngắn, refresh token rotation |
| Mobile app | Authorization Code + PKCE trong system browser | Redirect https đã claim; refresh token trong Keychain/Keystore |
| “User này là ai?” | OpenID Connect: scope openid, ID token |
Client đọc ID token; API không bao giờ nhận nó |
| Implicit grant hoặc password grant | Không dùng | RFC 9700: implicit SHOULD NOT, password grant MUST NOT |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”OAuth 2.0 lo authorization: client lấy access token để gọi API thay mặt người dùng. OpenID Connect (OIDC) thêm authentication lên trên: một ID token cho client biết ai vừa đăng nhập.
| ID token (OIDC) | Access token (OAuth) | |
|---|---|---|
Audience (aud) |
client_id của client |
API, ví dụ https://api.shop.example/orders |
| Ai đọc | Client, một lần, lúc login | API, ở mỗi request |
| Trả lời | “User là ai, đăng nhập lúc nào?” | “Request này có được làm thay sub với scope này không?” |
| Định dạng | Luôn là JWT có chữ ký | JWT (RFC 9068, typ: at+jwt) hoặc opaque, tuỳ IdP |
Bốn vai trò OAuth trong tình huống này: người dùng (resource owner), SPA hoặc app (client), id.shop.example (authorization server) và orders API (resource server).
Confidential client giữ được secret (một backend). Public client thì không: mọi thứ nằm trong bundle SPA hay file app đều có thể bị trích ra (RFC 6749 §2.1).
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Ở mỗi bước ai giữ bí mật nào, để code hay token bị lấy cắp cũng vô dụng?
BFF sinh ngẫu nhiên state, nonce và code_verifier 43 ký tự, lưu vào session tạm trước login, rồi băm verifier.
| Bước | Thay đổi | Chặn được |
|---|---|---|
| 0–1 | BFF lưu state, nonce, verifier; browser chỉ mang challenge |
Không bí mật nào nằm ở browser |
| 2 | Mật khẩu gõ ở IdP; code gắn với challenge | App không thấy mật khẩu |
| 3 | BFF so state (và iss) với session |
Login CSRF, IdP mix-up |
| 4 | IdP kiểm S256(code_verifier) == code_challenge |
Code bị lấy cắp thành vô dụng |
| 5 | BFF kiểm ID token, cấp cookie __Host- |
ID token bị replay (nonce), token lọt vào JS |
| 6–7 | API kiểm access token | Token giả hoặc sai đích |
| 8 | Refresh token được rotate | Refresh token bị dùng lại |
PKCE: chứng minh bên đổi code chính là bên bắt đầu luồng
Phần tiêu đề “PKCE: chứng minh bên đổi code chính là bên bắt đầu luồng”const verifier = randomBytes(32).toString('base64url'); // 43 ký tự, 256 bitconst challenge = createHash('sha256').update(verifier).digest('base64url'); // method S256Verifier dài 43–128 ký tự unreserved (RFC 7636). Chỉ challenge đi qua browser; verifier đi thẳng tới token endpoint. Không khớp thì IdP trả invalid_grant.
Luôn gửi code_challenge_method=S256. Bỏ trống thì mặc định là plain: challenge bằng verifier, chặn được một cái là có cả hai.
RFC 9700 §2.1.1: public client MUST dùng PKCE, confidential client được khuyến nghị (RECOMMENDED), và authorization server MUST hỗ trợ.
state, nonce và iss
Phần tiêu đề “state, nonce và iss”state: giá trị ngẫu nhiên dùng một lần, gắn với session của browser (RFC 6749 §10.12). Thiếu nó, kẻ tấn công nhét code của chính họ vào và bạn bị đăng nhập vào tài khoản của họ (login CSRF).nonce: được IdP chép vào ID token (OIDC Core). Tuỳ chọn trong code flow; nó gắn ID token với đúng lần login này.isstrong callback (RFC 9207): chặn mix-up khi một client dùng nhiều IdP.
RFC 9700 chỉ cho client dựa vào PKCE để chống CSRF sau khi đã xác nhận IdP hỗ trợ PKCE. Giữ thêm state gần như không tốn gì.
Redirect URI: khớp chuỗi tuyệt đối
Phần tiêu đề “Redirect URI: khớp chuỗi tuyệt đối”RFC 9700 §2.1: IdP MUST so redirect URI bằng so khớp chuỗi tuyệt đối, ngoại lệ duy nhất là port của loopback URI cho native app. Wildcard hay khớp tiền tố cho phép kẻ tấn công lái code tới https://shop.example/callback/../open-redirect.
Vì sao implicit và password grant bị loại
Phần tiêu đề “Vì sao implicit và password grant bị loại”| Grant | Vấn đề | Trạng thái |
|---|---|---|
Implicit (response_type=token) |
Access token trả về trong URL: lộ qua history, referrer, có thể bị inject; không PKCE, không sender binding | RFC 9700: SHOULD NOT dùng |
| Resource owner password (ROPC) | Client thấy mật khẩu; không có MFA hay consent ở IdP; tập cho người dùng gõ mật khẩu vào bất kỳ app nào | RFC 9700: MUST NOT dùng |
OAuth 2.1 bỏ cả hai. Nó vẫn là Internet-Draft (draft -16, tháng 9/2026), chưa phải RFC, nhưng các quy tắc đã có sẵn trong RFC 9700.
Token nằm ở đâu
Phần tiêu đề “Token nằm ở đâu”| Client | Mô hình | Token lộ ra ở đâu |
|---|---|---|
| SPA có backend | BFF: backend là OAuth client, proxy các lời gọi API | Không có trong browser; cookie cần chống CSRF |
| SPA, backend chỉ đi lấy token | Token-mediating backend | Access token nằm trong JS |
| SPA không backend | Public client chạy trong browser | Access và refresh token, XSS nào cũng đọc được |
| Mobile app | Public client, system browser (RFC 8252) | Refresh token trong secure storage của OS |
RFC 10017 xếp các mô hình này theo mức an toàn giảm dần và khuyến nghị mạnh BFF cho ứng dụng doanh nghiệp và dữ liệu cá nhân.
RFC 8252 §8.12: native app MUST NOT dùng WebView nhúng để authorize; app chủ có thể đọc mật khẩu và cookie.
API kiểm token
Phần tiêu đề “API kiểm token”API NestJS nhận một bearer token và phải tự quyết, không gọi IdP, có tin nó hay không.
Đang tải diagram…
// orders-api/src/auth/access-token.guard.ts — jose v6import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL(process.env.IDP_JWKS_URI!)); // jwks_uri lấy từ discovery
@Injectable()export class AccessTokenGuard implements CanActivate { async canActivate(ctx: ExecutionContext): Promise<boolean> { const req = ctx.switchToHttp().getRequest(); const [scheme, token] = (req.headers.authorization ?? '').split(' '); if (scheme !== 'Bearer' || !token) throw new UnauthorizedException(); try { const { payload } = await jwtVerify(token, JWKS, { issuer: 'https://id.shop.example', audience: 'https://api.shop.example/orders', algorithms: ['ES256'], typ: 'at+jwt', clockTolerance: 30, }); req.user = { sub: payload.sub, scope: payload.scope }; return true; } catch { throw new UnauthorizedException(); } }}aud(RFC 9068 §4, RFC 9700 §2.3): API từ chối token không cấp cho nó. Chính điều này chặn ID token, hay token của API khác, dùng được ở đây.typ: at+jwtchỉ dùng được khi IdP cấp token theo RFC 9068. Token opaque thì phải introspect.- Xoay key:
createRemoteJWKSetfetch lại khi gặpkidlạ, tối đa một lần mỗicooldownDuration(mặc định 30 giây); cache mặc định sống 10 phút. - Kiểm token chỉ trả lời “ai”; kiểm BOLA trên đơn hàng vẫn là việc của bạn.
Vì sao từng claim quan trọng (alg: none, key confusion): Session hay JWT.
Refresh token
Phần tiêu đề “Refresh token”RFC 9700 §2.2.2: refresh token của public client MUST được sender-constrain hoặc rotate. Rotation và phát hiện reuse xem ở Session hay JWT. Với BFF, refresh token không bao giờ rời server.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”- Authorization code: dùng một lần, thời gian sống khuyến nghị ≤ 10 phút (RFC 6749 §4.1.2); code bị dùng lại thì nên thu hồi các token đã cấp từ nó.
- Request OIDC MUST có scope
openid; thiếu nó thì kết quả chỉ là OAuth thường. - Redirect URI cho native app: ưu tiên
httpsđã claim; app desktop dùng loopback IP với port bất kỳ (RFC 8252 §7).
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Mô phỏng chạy được trong bộ nhớ (IdP, token endpoint, orders API), chỉ dùng node:crypto:
npx --prefix examples ts-node examples/src/oauth2-oidc-pkce/pkce-flow-demo.ts1. S256(RFC 7636 App. B verifier) = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM matches spec: true3. Token endpoint stolen code, attacker has no verifier FAIL invalid_grant (code_verifier mismatch) replay the first (used) code FAIL invalid_grant (code already used) redirect_uri + "/../evil" FAIL redirect_uri not registered (exact match)4. Orders API access_token ACCEPT sub=user-42 scope=orders:read id_token sent as Bearer REJECT typ JWT is not an access token5. Key rotation (new kid 2026-11) 10 s after last JWKS fetch REJECT unknown kid 2026-11 61 s after: unknown kid -> refetch ACCEPT sub=user-42 scope=orders:readMục 5 là lý do IdP phải công bố key mới trước khi ký bằng nó: verifier còn trong cooldown sẽ từ chối kid chưa từng thấy.
Trade-offs
Phần tiêu đề “Trade-offs”Authorization Code + PKCE + BFF được gì
- Mật khẩu chỉ gõ ở IdP; MFA và consent nằm ở đó.
- Code bị lấy cắp vô dụng nếu không có verifier.
- Không có token trong browser; XSS không mang token đi được.
Phải trả gì
- BFF là server có state nằm trên đường request, kèm session và chống CSRF.
- Nhiều redirect và round trip hơn một form mật khẩu.
- Phải vận hành xoay key và cache JWKS.
Checklist production
Phần tiêu đề “Checklist production”- Tắt implicit và password grant cho mọi client ở IdP.
- Đăng ký redirect URI khớp tuyệt đối cho từng client;
client_idriêng cho SPA/BFF và mobile. - Bắt buộc PKCE với
S256; từ chốiplain. - Sinh
statevànoncemới cho mỗi lần login, gắn với session trước login, dùng một lần. - SPA: để token trong BFF; cookie
__Host-,Secure,HttpOnly,SameSite. - Mobile: system browser, redirect
httpsđã claim, refresh token trong Keychain/Keystore. - API: kiểm
alg,kidqua JWKS,iss,aud,exp,typ; từ chối ID token. - Bật refresh token rotation có phát hiện reuse; access token sống vài phút.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Lỗi phía API gây hại nhất là chỉ kiểm chữ ký:
Nhận mọi token do IdP ký
Hay gặp
await jwtVerify(token, JWKS); // không issuer, audience, typNên viết
await jwtVerify(token, JWKS, { issuer, audience: ORDERS_API, algorithms: ['ES256'], typ: 'at+jwt' });Vì sao
IdP thường ký ID token và token cho các API khác bằng cùng bộ key. Không kiểm aud thì token nào trong số đó cũng mở được orders API.
Bẫy thứ hai nằm ở client, không phải API:
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
| PKCE thay được client secret | BFF không xác thực client thành public client | Confidential client dùng cả hai |
Có PKCE thì bỏ state được |
Mất chống CSRF nếu IdP bỏ qua PKCE | Giữ state |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Kẻ tấn công chặn được code trên một custom URL scheme. Bước nào thất bại với họ, và dùng
plainthì câu trả lời đổi ra sao? - Vì sao API vẫn từ chối ID token dù nó được ký bằng đúng key của IdP?
- IdP thêm key và ký bằng nó ngay trong cùng giây. Vì sao vài instance API có thể tạm từ chối token hợp lệ, và thứ tự xoay key nào tránh được?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- RFC 6749: vai trò, loại client, thời gian sống của code, CSRF.
- RFC 7636: định dạng verifier PKCE và
S256. - RFC 9700: danh sách MUST/SHOULD bảo mật OAuth hiện hành.
- OpenID Connect Core 1.0: cách kiểm ID token.
- RFC 10017: BFF và các kiến trúc browser khác.
- RFC 8252: native app, system browser, redirect URI.
- RFC 9068: resource server phải kiểm gì với JWT access token.
- OAuth 2.1 draft: trạng thái hiện tại.
- jose: tuỳ chọn
jwtVerify, cache JWKS.