</>Học Dev
Bài học

Tuần 2 - Ngày 9: OAuth2 & OpenID Connect

Tuần 2 – Ngày 9

Mục tiêu học tập

  • Phân biệt authentication (OIDC) và authorization (OAuth2)
  • Hiểu 4 grant types của OAuth2 và chọn đúng cho từng use case
  • Nắm rõ Authorization Code + PKCE flow — mặc định cho web/mobile/SPA năm 2024
  • Phân biệt Access Token vs ID Token, scope vs audience
  • Tích hợp với providers phổ biến: Google, Auth0, Clerk, AWS Cognito

1. OAuth2 là gì, không là gì?

OAuth2 là framework cho authorization: cho phép một ứng dụng (Client) truy cập resource thay mặt user, không cần biết password của user.

"OAuth2 không phải là protocol cho authentication"
                              ↑
              (nhưng nhiều người dùng nhầm như vậy)

OAuth2 trả lời: "Client này có được phép gọi API X thay mặt user Y không?" Không trả lời: "User này là ai?" → đó là việc của OIDC.

Ví dụ thực tế

Spotify muốn import playlist từ tài khoản YouTube của bạn:

❌ Không OAuth2:
   Spotify hỏi: "Cho tôi mật khẩu YouTube của bạn"
   → Bạn cho password đầy đủ → Spotify có thể đọc Gmail, xóa video...

✅ Có OAuth2:
   Spotify redirect bạn tới Google → bạn login với Google →
   Google hỏi: "Cho phép Spotify ĐỌC playlist YouTube?" →
   Bạn approve → Spotify nhận access token CHỈ cho scope youtube.readonly
   → Không biết password, không đọc Gmail được

4 vai trò trong OAuth2

ResourceAuthorizationOwnerServer(AS)(user)e.g.accounts.google.comClientResourceServer(Spotify)(RS)YouTubeAPI
Vai tròLà ai (ví dụ)Trách nhiệm
Resource OwnerUser cuốiSở hữu dữ liệu, approve/deny truy cập
ClientApp của developerMuốn truy cập resource của user
Authorization ServerGoogle, Auth0, CognitoAuthenticate user, phát access token
Resource ServerAPI có dataVerify token, trả data theo scope

2. OAuth2 Grant Types

Grant typeUse caseStatus 2024
Authorization Code + PKCEWeb app, SPA, mobileDEFAULT
Client CredentialsMachine-to-machine (no user)OK
Refresh TokenRefresh access tokenOK (kèm grant khác)
Authorization Code (no PKCE)Server-side web confidentialVẫn dùng, nên thêm PKCE
Implicit(cũ) SPADEPRECATED (OAuth 2.1)
Resource Owner Password(cũ) trusted first-partyDEPRECATED
Device CodeTV, CLI không có browserOK

Client types

Confidential client: có thể giữ secret an toàn
  → Backend server (Node.js, Django, Rails)
  → Lưu client_secret trong env var

Public client: KHÔNG thể giữ secret
  → SPA (code đọc được từ browser)
  → Mobile app (decompile được)
  → CLI tool
  → BẮT BUỘC dùng PKCE

3. Authorization Code + PKCE Flow

PKCE là gì?

PKCE (Proof Key for Code Exchange, RFC 7636) chống authorization code interception attack: malicious app trên cùng device chặn authorization code trong redirect URL.

Trước PKCE chỉ confidential client dùng được Authorization Code vì cần client_secret. PKCE thay thế client_secret bằng one-time proof → public client dùng được.

Cách PKCE hoạt động

1. Client tự sinh:
   code_verifier  = random 43-128 ký tự (cryptographically random)
   code_challenge = BASE64URL( SHA256(code_verifier) )

2. Gửi code_challenge LÊN cùng authorization request
3. Khi exchange code lấy token, gửi code_verifier LÊN
4. AS verify: SHA256(code_verifier) == stored code_challenge?
   - Nếu attacker chặn code, không có code_verifier → không exchange được

Flow đầy đủ (web SPA + PKCE)

Actors:UserBrowser/SPAAuthServerAPI(Google/Auth0)Step1Userclicks"Login"UserBrowser/SPAStep2Browser/SPAgeneratesPKCE+statecode_verifier=random43-128chars(CSPRNG)code_challenge=BASE64URL(SHA256(code_verifier))state=random(CSRFtoken)Step3BrowserAuthServer:GET/authorize?client_id=spa&redirect_uri=https://app/cb&response_type=code&scope=openidprofileemail&state=xyz&code_challenge=abc...&code_challenge_method=S256Step4AuthServerUser:login+consentscreenStep5UserapprovesAuthServerStep6AuthServerBrowser:302redirectLocation:https://app/cb?code=AUTH_CODE&state=xyzStep7Browser/SPAverifiesstate==xyz(CSRFcheck)Step8Browser/SPAAuthServer:POST/tokengrant_type=authorization_codecode=AUTH_CODEclient_id=sparedirect_uri=https://app/cbcode_verifier=ORIGINAL_VERIFIERStep9AuthSerververifies:SHA256(verifier)==storedchallenge?OnsuccessBrowser:{access_token,id_token,refresh_token,expires_in:3600}Step10Browser/SPAAPI:GET/api/meAuthorization:BearerACCESS_TOKENStep11APIverifiestokensignature,iss,aud,exp,scopeOnsuccessBrowser:{userdata}

Quan trọng: state parameter chống CSRF

// Client sinh state random trước khi redirect
const state = crypto.randomUUID();
sessionStorage.setItem("oauth_state", state);

// Trong callback, verify
const returnedState = new URL(location).searchParams.get("state");
if (returnedState !== sessionStorage.getItem("oauth_state")) {
  throw new Error("State mismatch — possible CSRF");
}

4. Client Credentials Grant (M2M)

Khi không có user — service A gọi service B:

ServiceAuthServerServiceABPOST/tokengrant_type=client_credentialsclient_id=svc-aclient_secret=...scope=orders.read{access_token:"..."}GET/api/ordersAuthorization:Bearer...

Đặc điểm:

  • Không có user, không có ID token
  • Client phải là confidential (giữ được secret)
  • Scope hạn chế theo M2M

5. OpenID Connect — Identity Layer

OAuth2 trả lời "ai cho phép truy cập gì". OIDC mở rộng để trả lời "user này là ai".

Điểm khác biệt then chốt

OAuth2alone:ASphát:access_token(opaquehocJWT)ClientgiRSOIDC=OAuth2+IDToken:ASphát:access_token+IDToken(JWT)Clientbiếtuserlàai"Iauthenticateuser_42,email=alice@x.com"

Access Token vs ID Token

Access TokenID Token
Mục đíchAuthorize API callsAuthenticate user vào Client
AudienceResource Server (API)Client app
FormatOpaque hoặc JWTLuôn là JWT
Client có decode khôngKhông bắt buộc, đọc claims user
Gửi đến đâuAPI qua Authorization: BearerChỉ dùng trong Client
Claimsscope, exp, subsub, email, name, picture, exp

Nhớ: Đừng gửi ID Token qua Bearer header đến API. Đừng dùng ID Token để authorize. ID Token chỉ để Client biết user là ai.

Standard OIDC scopes

openid          ← BẮT BUỘC để kích hoạt OIDC, returns ID token
profile         ← name, family_name, given_name, picture, locale
email           ← email, email_verified
address         ← address claim
phone           ← phone_number, phone_number_verified
offline_access  ← refresh_token

Discovery endpoint

OIDC providers công bố metadata tại:

https://accounts.google.com/.well-known/openid-configuration

{
  "issuer": "https://accounts.google.com",
  "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
  "token_endpoint": "https://oauth2.googleapis.com/token",
  "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
  "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
  "response_types_supported": ["code", "token", ...],
  "id_token_signing_alg_values_supported": ["RS256"]
}

Library OIDC tự fetch endpoint này → developer không hardcode URL.


6. Scope vs Audience

Scope — quyền hạn

scope="openidprofileemailorders.readorders.write"permissionchoAPI"orders"

User thấy consent screen liệt kê scope → approve từng cái. Resource Server check scope trong access token để cho phép operation.

Audience — token gửi cho ai

{
  "iss": "https://auth.example.com",
  "sub": "user_42",
  "aud": "https://api.orders.example.com",   ← chỉ API này nên accept
  "scope": "orders.read"
}

Resource Server PHẢI verify aud claim:

// ❌ SAI: chỉ verify signature
jwt.verify(token, publicKey);

// ✅ ĐÚNG: verify audience
jwt.verify(token, publicKey, {
  audience: "https://api.orders.example.com",
  issuer: "https://auth.example.com",
});

Không verify aud → token cho API A có thể bị replay sang API B → confused deputy attack.


7. Providers phổ biến

ProviderLoạiĐặc điểm
GoogleOIDC, freePhổ biến cho social login
GitHubOAuth2 (không phải OIDC chuẩn)Cho developer tool, có userinfo riêng
Auth0OIDC, SaaSCustomizable, đắt khi scale lớn
ClerkOIDC + UI componentsDX tốt cho Next.js, free tier 10k MAU
AWS CognitoOIDCTích hợp sâu AWS, UX kém hơn
KeycloakOIDC, self-hostedOpen source, control hoàn toàn
OktaOIDC enterpriseSSO cho doanh nghiệp
Microsoft Entra IDOIDCAzure AD, B2B/B2C

Khi nào tự build vs dùng provider?

DÙNG PROVIDER khi:
- Team < 50 dev, không có security engineer chuyên trách
- Cần social login (Google/Facebook/Apple)
- Time-to-market quan trọng
- Compliance (SOC2, HIPAA) — provider đã certified

TỰ BUILD khi:
- Có yêu cầu compliance đặc biệt (gov, classified)
- Volume cực lớn (cost provider > engineer)
- Có security team
- Cần fully customize flow

8. Code ví dụ

Node.js với openid-client

// npm install openid-client
import * as client from "openid-client";

// 1. Discovery
const config = await client.discovery(
  new URL("https://accounts.google.com"),
  process.env.CLIENT_ID,
  process.env.CLIENT_SECRET   // bỏ nếu public client
);

// 2. Build authorization URL
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const state = client.randomState();

const authUrl = client.buildAuthorizationUrl(config, {
  redirect_uri: "https://app.example.com/callback",
  scope: "openid profile email",
  code_challenge: codeChallenge,
  code_challenge_method: "S256",
  state,
});

// Lưu codeVerifier + state vào session, redirect user

// 3. Trong callback handler
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
  pkceCodeVerifier: codeVerifier,
  expectedState: state,
});

// tokens.access_token, tokens.id_token, tokens.refresh_token
const claims = tokens.claims();
console.log("User:", claims.sub, claims.email);

SPA với PKCE (browser flow)

// 1. Generate PKCE pair
async function sha256(str) {
  const buf = new TextEncoder().encode(str);
  const hash = await crypto.subtle.digest("SHA-256", buf);
  return btoa(String.fromCharCode(...new Uint8Array(hash)))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

const verifier = crypto.randomUUID() + crypto.randomUUID();
const challenge = await sha256(verifier);
const state = crypto.randomUUID();

sessionStorage.setItem("pkce_verifier", verifier);
sessionStorage.setItem("oauth_state", state);

// 2. Redirect
const params = new URLSearchParams({
  client_id: "spa-client",
  redirect_uri: "https://app/callback",
  response_type: "code",
  scope: "openid profile email",
  code_challenge: challenge,
  code_challenge_method: "S256",
  state,
});
location.href = `https://auth.example.com/authorize?${params}`;

// 3. Callback handler
const url = new URL(location);
const code = url.searchParams.get("code");
const returnedState = url.searchParams.get("state");
if (returnedState !== sessionStorage.getItem("oauth_state")) {
  throw new Error("State mismatch");
}

const res = await fetch("https://auth.example.com/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    client_id: "spa-client",
    code,
    redirect_uri: "https://app/callback",
    code_verifier: sessionStorage.getItem("pkce_verifier"),
  }),
});
const tokens = await res.json();

9. Câu hỏi ôn tập

  1. OAuth2 và OIDC khác nhau như thế nào? Nếu chỉ cần "social login" để biết user là ai, dùng cái nào?

    Xem đáp án

    OAuth2 là framework cho authorization — Client xin phép user truy cập resource. Nó không định nghĩa user identity, không có chuẩn để biết "user là ai". OIDC xây trên OAuth2, thêm ID Token (JWT) chứa claims user (sub, email, name) → biết user là ai. Để social login, luôn dùng OIDC (scope openid + lấy ID token). Dùng OAuth2 thuần để gọi API của bên thứ 3 thay mặt user (Spotify đọc YouTube playlist).

  2. Tại sao SPA năm 2024 phải dùng PKCE thay vì Implicit grant?

    Xem đáp án

    Implicit grant trả access token thẳng trong URL fragment → token leak qua browser history, referer header, log server proxy. Không có protection cho code interception trên cùng device. Authorization Code + PKCE trả về code ngắn hạn (one-time use), exchange thành token qua POST riêng. PKCE thay thế client_secret bằng one-time code_verifier → public client (SPA, mobile) vẫn an toàn. OAuth 2.1 chính thức deprecate Implicit.

  3. ID Token có thể dùng để gọi API không? Tại sao?

    Xem đáp án

    Không. ID Token có aud = client app, không phải API. Nó chỉ chứng minh "user X đã authenticate vào client Y" cho client biết. Gửi ID token đến API là sai mẫu vì: (1) API không nên trust một token có aud không phải mình, (2) ID Token thường có thông tin nhạy cảm (email, name) không nên gửi mọi API call, (3) ID Token thường có lifetime dài hơn access token. Luôn dùng access token cho API calls.

  4. Resource Server cần verify những claim nào của access token JWT?

    Xem đáp án

    Tối thiểu: (1) Signature — verify bằng public key từ JWKS endpoint của AS, (2) iss — issuer phải là AS được trust, (3) aud — audience phải là RS này (chống confused deputy: token cho API A bị dùng cho API B), (4) exp — chưa hết hạn, (5) scope — đủ permission cho operation. Tùy chọn: nbf (not before), jti (check denylist nếu cần revoke). Skip bất kỳ cái nào trong 5 cái đầu là lỗ hổng.

  5. Khi nào dùng Client Credentials grant?

    Xem đáp án

    Khi không có user — machine-to-machine. Ví dụ: service A backend (gọi bằng cron) gọi API service B; data pipeline ETL gọi public API; webhook backend của vendor X push data đến API của bạn. Client phải là confidential (giữ được client_secret). Không có ID token, không có refresh token. Token thường có scope hạn chế cho M2M (vd: analytics.read). KHÔNG dùng cho user-facing app vì không capture được consent của user.


Bài tập thực hành

# 1. Đăng ký Google OAuth client
# - Truy cập https://console.cloud.google.com/apis/credentials
# - Tạo OAuth client ID, type = Web application
# - Authorized redirect URIs: http://localhost:3000/callback

# 2. Setup demo với openid-client
mkdir oidc-lab && cd oidc-lab
npm init -y
npm install openid-client express

# 3. Tạo server demo (xem code ở section 8)

# 4. Khi callback, in ra:
# - access_token (gửi tới API)
# - id_token (decode tại jwt.io, xem claims sub, email, name)
# - refresh_token (nếu xin offline_access)

# 5. Verify ID token signature
# - Fetch jwks_uri của Google
# - Verify ID token với jose library
# - Verify aud == client_id, iss == "https://accounts.google.com"

# 6. Thử Auth0 free tier:
# - Đăng ký auth0.com
# - Tạo Application type = SPA (sẽ enforce PKCE)
# - Dùng @auth0/auth0-spa-js trong frontend

# 7. Quan sát discovery endpoint:
curl https://accounts.google.com/.well-known/openid-configuration | jq
curl https://YOUR_DOMAIN.auth0.com/.well-known/openid-configuration | jq

Tài liệu tham khảo chính thức


Bài tiếp theo →