Skip to content

Zoom-in: OAuth 2.0

Karify98·
Cover Image for Zoom-in: OAuth 2.0

"Đăng nhập bằng Google" — 3 từ, một click. Bên dưới đó là một cơ chế mà password không bao giờ rời khỏi Google, app không bao giờ biết mật khẩu của người dùng, và người dùng có thể thu hồi quyền truy cập bất cứ lúc nào mà không cần đổi password.

Phóng to dần vào đó.


Layer 1 — Bài toán delegation

Cách cũ: muốn app A đọc email của người dùng trên Google → người dùng phải đưa username/password cho app A.

graph LR
    U["👤 User"] -->|"username + password"| A["📱 App A"]
    A -->|"login as user"| G["🔑 Google"]
    style U fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
    style A fill:#1a3a2a,stroke:#22c55e,color:#86efac
    style G fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d

Vấn đề: App A có toàn quyền với tài khoản Google — không giới hạn scope, không có cách thu hồi riêng, và nếu App A bị lộ dữ liệu thì password Google của người dùng cũng bị lộ.

OAuth 2.0 giải quyết bằng một khái niệm khác: thay vì đưa chìa khóa nhà, cấp một token có giới hạn thời gian và scope.

Vấn đề còn lại: token phải đến tay app một cách an toàn — mà không qua browser URL (dễ bị leak vào logs).

Layer 2 — Authorization Code flow

OAuth 2.0 tách quá trình thành hai bước: lấy code → đổi code lấy token.

sequenceDiagram
    participant U as 👤 User
    participant A as 📱 App (Client)
    participant G as 🔑 Google Auth Server
    participant R as 📦 Resource Server (Gmail API)

    U->>A: "Đăng nhập bằng Google"
    A->>G: Redirect → /authorize?client_id=...&scope=email&redirect_uri=...&state=xyz
    G-->>U: Hiện consent screen
    U->>G: Đồng ý cấp quyền
    G-->>A: Redirect về redirect_uri?code=AUTH_CODE&state=xyz
    A->>G: POST /token (code=AUTH_CODE + client_secret)
    G-->>A: access_token + refresh_token
    A->>R: GET /gmail/messages (Authorization: Bearer access_token)
    R-->>A: Dữ liệu email

Authorization code chỉ sống vài giây và chỉ dùng được một lần — nó đi qua browser URL, nhưng không phải token thật. Token thật (access_token) được đổi qua server-to-server với client_secret, không qua browser.

Tham số state là một chuỗi ngẫu nhiên do app tạo ra — dùng để chống CSRF: nếu response trả về state khác với state đã gửi đi, flow bị hủy.

Vấn đề còn lại: access_token có thời hạn ngắn. Người dùng không thể đăng nhập lại mỗi giờ. Cần một cơ chế làm mới mà không hỏi người dùng.

Layer 3 — Access token vs Refresh token

Hai loại token, hai mục đích khác nhau.

graph LR
    A["📱 App"] -->|"access_token (15 phút)"| R["📦 Resource Server"]
    A -->|"refresh_token (30 ngày)"| G["🔑 Auth Server"]
    G -->|"access_token mới"| A
    style A fill:#1e3a5f,stroke:#3b82f6,color:#93c5fd
    style R fill:#1a3a2a,stroke:#22c55e,color:#86efac
    style G fill:#3b2a1a,stroke:#f59e0b,color:#fcd34d
Access token Refresh token
Gửi đến Resource server (mọi request) Auth server (khi access token hết hạn)
Thời hạn 15 phút - 1 giờ Ngày đến tháng
Nếu bị lộ Nguy hiểm trong thời gian ngắn Nguy hiểm hơn, nhưng có thể thu hồi

Tách hai loại token giúp giảm rủi ro: access token ngắn hạn nên ngay cả khi bị lộ, window tấn công nhỏ. Refresh token chỉ đi đến auth server — ít exposure hơn.

Vấn đề còn lại: flow trên yêu cầu client_secret — secret này chỉ an toàn ở server-side app. Mobile app và SPA không có nơi giữ secret an toàn.

Layer 4 — PKCE: bảo vệ public client

Mobile app và browser SPA là public client — không có server, không có nơi giữ client_secret. PKCE (Proof Key for Code Exchange) thay thế client_secret bằng một cặp giá trị tạm thời.

sequenceDiagram
    participant A as 📱 Mobile App
    participant G as 🔑 Auth Server

    Note over A: Tạo code_verifier (ngẫu nhiên, 43-128 ký tự)
    Note over A: code_challenge = SHA256(code_verifier)

    A->>G: /authorize?...&code_challenge=...&code_challenge_method=S256
    G-->>A: code=AUTH_CODE

    A->>G: /token (code=AUTH_CODE + code_verifier)
    Note over G: SHA256(code_verifier) == code_challenge? ✓
    G-->>A: access_token + refresh_token

code_verifier chỉ tồn tại trong memory của app, không bao giờ rời khỏi app cho đến bước đổi token. Ngay cả khi AUTH_CODE bị chặn, kẻ tấn công không có code_verifier để đổi lấy token.


Full picture

sequenceDiagram
    participant U as 👤 User
    participant A as 📱 App
    participant G as 🔑 Auth Server
    participant R as 📦 Resource Server

    Note over A: Tạo state + PKCE (nếu public client)
    A->>G: /authorize (scope, redirect_uri, state, code_challenge)
    G-->>U: Consent screen
    U->>G: Chấp nhận
    G-->>A: code + state

    Note over A: Xác nhận state
    A->>G: /token (code + client_secret hoặc code_verifier)
    G-->>A: access_token (15ph) + refresh_token (30 ngày)

    loop Mỗi request
        A->>R: API call (Bearer access_token)
        R-->>A: Dữ liệu
    end

    Note over A: access_token hết hạn
    A->>G: /token (refresh_token)
    G-->>A: access_token mới

Takeaway

OAuth 2.0 tách biệt ba thứ: ai là người dùng, ai được cấp quyền, và quyền cụ thể là gì. Password không rời khỏi auth server. Token có scope giới hạn và thời hạn ngắn. Refresh token cho phép duy trì session mà không hỏi lại người dùng.

Khi implement OAuth, hai điểm hay bị bỏ qua: validate state để chống CSRF, và dùng PKCE cho mọi public client — kể cả khi server hỗ trợ client_secret, vì PKCE thêm một lớp bảo vệ không có chi phí.


Bài viết được hỗ trợ bởi Amy 🌸 - AI Assistant. Nội dung đã được kiểm duyệt bởi tác giả.

Related Posts