🔐 Security✍️ Khoa📅 20/04/2026☕ 12 phút đọc

OAuth 2.0 & OpenID Connect (OIDC)

OAuth 2.0 là framework cho authorization (không phải authentication!), cho phép third-party apps truy cập resources của user mà không cần password. OpenID Connect (OIDC) là layer bên trên OAuth 2.0 để handle authentication.

🔑 Ẩn dụ: OAuth là "valet key" — bạn cho người khác quyền mở cửa xe (access resource) nhưng không cho chìa khóa chính (password).


OAuth 2.0 vs OpenID Connect

OAuth 2.0 OpenID Connect (OIDC)
Purpose Authorization ("cho phép làm gì") Authentication ("bạn là ai")
Result Access token ID token + Access token
Use case "Allow app X to post to your timeline" "Login with Google"
Token format Opaque hoặc JWT JWT (always)
User info Không guarantee Yes (ID token chứa user profile)
Standard RFC 6749 Built on top of OAuth 2.0

Tóm tắt ngắn gọn:

  • OAuth 2.0 = "App A muốn truy cập data của bạn trên App B"
  • OIDC = "Bạn đăng nhập bằng tài khoản Google/Facebook"

OAuth 2.0 Core Concepts

Roles

┌────────────────────────────────────────────────────┐
│                                                    │
│  Resource Owner (User)                             │
│  └─ Người sở hữu data                              │
│                                                    │
└───────────┬────────────────────────────────────────┘
            │ ① Authorize
            ▼
┌─────────────────────────┐         ┌─────────────────────────┐
│  Client (App)           │         │  Authorization Server   │
│  └─ App muốn access     │◄────────┤  └─ Issue tokens        │
│     user's data         │ ③ Token │     (Google, GitHub)    │
└─────────┬───────────────┘         └─────────────────────────┘
          │ ④ Request with token
          ▼
┌─────────────────────────┐
│  Resource Server        │
│  └─ API chứa user data  │
│     (Google Drive API)  │
└─────────────────────────┘
  • Resource Owner: User (bạn)
  • Client: Application muốn access user's data (e.g., third-party app)
  • Authorization Server: Server issue tokens (Google, Facebook, GitHub)
  • Resource Server: API chứa protected resources (Gmail API, Facebook Graph API)

Tokens

  1. Access Token: Dùng để access protected resources

    • Thường là JWT hoặc opaque token
    • Short-lived (1 hour - 1 day)
    • Gửi kèm trong request: Authorization: Bearer <access_token>
  2. Refresh Token: Dùng để lấy access token mới khi hết hạn

    • Long-lived (7-90 days)
    • Chỉ gửi đến authorization server, không gửi đến resource server
  3. ID Token (OIDC only): Chứa thông tin về user authentication

    • Always JWT
    • Contains user profile (email, name, avatar)

OAuth 2.0 Flows (Grant Types)

OAuth 2.0 có nhiều "flows" (grant types) cho các use case khác nhau.

1. Authorization Code Flow (Phổ biến nhất)

Use case: Web apps, mobile apps (with PKCE)

Flow diagram:

┌─────────┐                                       ┌──────────────┐
│ User    │                                       │ Auth Server  │
└────┬────┘                                       │ (Google)     │
     │                                            └──────┬───────┘
     │ ① Click "Login with Google"                      │
     ▼                                                   │
┌─────────────────┐                                     │
│ Client App      │                                     │
│ (your website)  │                                     │
└────┬────────────┘                                     │
     │                                                   │
     │ ② Redirect to Google auth page                   │
     │   with client_id, redirect_uri, scope            │
     ├──────────────────────────────────────────────────►
     │                                                   │
     │                                            ③ User logs in
     │                                               & approves
     │                                                   │
     │ ④ Redirect back with authorization code          │
     │◄──────────────────────────────────────────────────┤
     │   Location: https://yourapp.com/callback?code=xyz │
     │                                                   │
     │ ⑤ Exchange code for tokens                       │
     │   POST /token                                     │
     │   code=xyz, client_id, client_secret              │
     ├──────────────────────────────────────────────────►
     │                                                   │
     │ ⑥ Return access_token, refresh_token              │
     │◄──────────────────────────────────────────────────┤
     │                                                   │
     │ ⑦ Use access_token to call API                   │
     │                                                   │
     ▼                                                   ▼
┌─────────────────┐
│ Resource Server │
│ (Gmail API)     │
└─────────────────┘

Implementation (Go):

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "os"

    "golang.org/x/oauth2"
    "golang.org/x/oauth2/google"
)

var (
    googleOAuthConfig = &oauth2.Config{
        ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
        ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
        RedirectURL:  "http://localhost:8080/callback",
        Scopes: []string{
            "https://www.googleapis.com/auth/userinfo.email",
            "https://www.googleapis.com/auth/userinfo.profile",
        },
        Endpoint: google.Endpoint,
    }
)

// Step 1: Redirect to Google auth page
func handleLogin(w http.ResponseWriter, r *http.Request) {
    // Generate random state for CSRF protection
    state := generateRandomState()
    
    // Store state in session (omitted for brevity)
    // session.Set("oauth_state", state)

    url := googleOAuthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
    http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}

// Step 2: Handle callback from Google
func handleCallback(w http.ResponseWriter, r *http.Request) {
    // Verify state to prevent CSRF
    state := r.URL.Query().Get("state")
    // expectedState := session.Get("oauth_state")
    // if state != expectedState {
    //     http.Error(w, "Invalid state", 400)
    //     return
    // }

    // Get authorization code
    code := r.URL.Query().Get("code")
    if code == "" {
        http.Error(w, "Missing code", 400)
        return
    }

    // Exchange code for token
    ctx := context.Background()
    token, err := googleOAuthConfig.Exchange(ctx, code)
    if err != nil {
        http.Error(w, "Failed to exchange token: "+err.Error(), 500)
        return
    }

    // token.AccessToken, token.RefreshToken available
    
    // Fetch user info
    client := googleOAuthConfig.Client(ctx, token)
    resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
    if err != nil {
        http.Error(w, "Failed to get user info", 500)
        return
    }
    defer resp.Body.Close()

    var userInfo struct {
        ID      string `json:"id"`
        Email   string `json:"email"`
        Name    string `json:"name"`
        Picture string `json:"picture"`
    }
    json.NewDecoder(resp.Body).Decode(&userInfo)

    // Create user session (omitted)
    // session.Set("user_id", userInfo.ID)

    fmt.Fprintf(w, "Welcome %s! Email: %s", userInfo.Name, userInfo.Email)
}

func main() {
    http.HandleFunc("/login", handleLogin)
    http.HandleFunc("/callback", handleCallback)
    http.ListenAndServe(":8080", nil)
}

Security notes:

  • ✅ Use state parameter để prevent CSRF attacks
  • ✅ Validate redirect_uri matches registered URI
  • ✅ Store tokens securely (encrypted in DB, not in cookies)
  • ✅ Use HTTPS for redirect URI

2. Authorization Code Flow + PKCE

PKCE (Proof Key for Code Exchange, RFC 7636) là extension của Authorization Code Flow để secure hơn cho mobile/SPA apps (không có backend để giữ client_secret).

Problem: Mobile apps không thể giữ client_secret an toàn → attacker có thể decompile app và lấy secret.

Solution: PKCE thêm dynamic secret cho mỗi request.

Flow:

Client tạo random string (code_verifier)
  ↓
Hash nó thành code_challenge = SHA256(code_verifier)
  ↓
Gửi code_challenge với authorization request
  ↓
Auth server lưu code_challenge
  ↓
Khi exchange code → gửi kèm code_verifier (plain text)
  ↓
Auth server verify: SHA256(code_verifier) == code_challenge
  ↓
Nếu match → issue token

Implementation:

import (
    "crypto/rand"
    "crypto/sha256"
    "encoding/base64"
)

// Step 1: Generate code verifier (random string)
func generateCodeVerifier() string {
    b := make([]byte, 32)
    rand.Read(b)
    return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(b)
}

// Step 2: Compute code challenge
func computeCodeChallenge(verifier string) string {
    hash := sha256.Sum256([]byte(verifier))
    return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])
}

// Step 3: Authorization URL with PKCE
func handleLoginWithPKCE(w http.ResponseWriter, r *http.Request) {
    verifier := generateCodeVerifier()
    challenge := computeCodeChallenge(verifier)

    // Store verifier in session
    // session.Set("code_verifier", verifier)

    url := googleOAuthConfig.AuthCodeURL(
        "state123",
        oauth2.SetAuthURLParam("code_challenge", challenge),
        oauth2.SetAuthURLParam("code_challenge_method", "S256"),
    )

    http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}

// Step 4: Exchange code with verifier
func handleCallbackWithPKCE(w http.ResponseWriter, r *http.Request) {
    code := r.URL.Query().Get("code")
    // verifier := session.Get("code_verifier")

    ctx := context.Background()
    token, err := googleOAuthConfig.Exchange(
        ctx,
        code,
        oauth2.SetAuthURLParam("code_verifier", verifier),
    )
    // ... rest of the flow
}

Khi nào dùng PKCE?

  • ✅ Mobile apps (iOS, Android)
  • ✅ Single-Page Apps (React, Vue, Angular)
  • ✅ Bất kỳ public client nào không thể giữ secret

3. Client Credentials Flow

Use case: Machine-to-machine (M2M), backend services, không có user interaction.

Flow:

┌─────────────┐                    ┌──────────────┐
│ Client App  │                    │ Auth Server  │
│ (Service A) │                    └──────┬───────┘
└──────┬──────┘                           │
       │                                  │
       │ ① POST /token                    │
       │    grant_type=client_credentials │
       │    client_id=xxx                 │
       │    client_secret=yyy             │
       ├─────────────────────────────────►│
       │                                  │
       │ ② Return access_token            │
       │◄─────────────────────────────────┤
       │                                  │
       │ ③ Call API with token            │
       ▼                                  │
┌─────────────┐                           │
│ Service B   │                           │
│ (API)       │                           │
└─────────────┘                           │

Implementation:

import "golang.org/x/oauth2/clientcredentials"

func getServiceToken() (*oauth2.Token, error) {
    config := &clientcredentials.Config{
        ClientID:     os.Getenv("CLIENT_ID"),
        ClientSecret: os.Getenv("CLIENT_SECRET"),
        TokenURL:     "https://auth.example.com/oauth/token",
        Scopes:       []string{"api.read", "api.write"},
    }

    ctx := context.Background()
    return config.Token(ctx)
}

func callProtectedAPI() {
    token, _ := getServiceToken()

    client := &http.Client{}
    req, _ := http.NewRequest("GET", "https://api.example.com/data", nil)
    req.Header.Set("Authorization", "Bearer "+token.AccessToken)

    resp, _ := client.Do(req)
    defer resp.Body.Close()
    // ... process response
}

Khi nào dùng?

  • ✅ Service-to-service communication
  • ✅ Cron jobs / background tasks
  • ✅ Không có user context

4. Resource Owner Password Credentials (DEPRECATED)

User gửi username/password trực tiếp cho client app → client gửi cho auth server.

⚠️ KHÔNG nên dùng trừ khi:

  • Bạn là first-party app (own both client and auth server)
  • Migrating legacy system
  • Không có cách nào khác

Tại sao deprecated?

  • Client app thấy user's password → risk cao
  • Không support MFA
  • Vi phạm principle of least privilege

5. Implicit Flow (DEPRECATED)

Token được return trực tiếp trong URL fragment (không có authorization code step).

⚠️ DEPRECATED — dùng Authorization Code + PKCE thay thế.

Tại sao deprecated?

  • Token exposed trong browser history
  • Không có refresh token
  • PKCE is better cho SPAs

OpenID Connect (OIDC)

OIDC = OAuth 2.0 + authentication layer.

Key differences:

  1. ID Token: JWT chứa user info

    {
      "iss": "https://accounts.google.com",
      "sub": "1234567890",
      "aud": "your-client-id",
      "exp": 1516239022,
      "iat": 1516235422,
      "email": "user@example.com",
      "email_verified": true,
      "name": "John Doe",
      "picture": "https://..."
    }
  2. UserInfo endpoint: /userinfo để lấy thêm profile data

  3. Standard scopes:

    • openid (required)
    • profile (name, picture, etc.)
    • email
    • address, phone

OIDC Flow (Authorization Code):

var oidcConfig = &oauth2.Config{
    ClientID:     os.Getenv("CLIENT_ID"),
    ClientSecret: os.Getenv("CLIENT_SECRET"),
    RedirectURL:  "http://localhost:8080/callback",
    Scopes:       []string{"openid", "profile", "email"},
    Endpoint: oauth2.Endpoint{
        AuthURL:  "https://accounts.google.com/o/oauth2/v2/auth",
        TokenURL: "https://oauth2.googleapis.com/token",
    },
}

func handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
    code := r.URL.Query().Get("code")

    ctx := context.Background()
    token, err := oidcConfig.Exchange(ctx, code)
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }

    // Extract ID token
    rawIDToken, ok := token.Extra("id_token").(string)
    if !ok {
        http.Error(w, "No id_token", 500)
        return
    }

    // Verify ID token (needs OIDC library)
    // idToken, err := verifier.Verify(ctx, rawIDToken)

    // Or decode JWT manually (but should verify signature!)
    var claims struct {
        Sub           string `json:"sub"`
        Email         string `json:"email"`
        EmailVerified bool   `json:"email_verified"`
        Name          string `json:"name"`
        Picture       string `json:"picture"`
    }
    // ... decode JWT ...

    fmt.Fprintf(w, "Logged in as: %s (%s)", claims.Name, claims.Email)
}

ID Token Validation (critical!):

import "github.com/coreos/go-oidc/v3/oidc"

func verifyIDToken(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) {
    provider, err := oidc.NewProvider(ctx, "https://accounts.google.com")
    if err != nil {
        return nil, err
    }

    verifier := provider.Verifier(&oidc.Config{
        ClientID: os.Getenv("CLIENT_ID"),
    })

    // Verify signature, expiry, audience, issuer
    idToken, err := verifier.Verify(ctx, rawIDToken)
    if err != nil {
        return nil, err
    }

    return idToken, nil
}

MUST verify:

  • ✅ Signature (using JWKS from provider)
  • exp (expiry)
  • aud (audience = your client_id)
  • iss (issuer = expected provider)

Scopes & Permissions

Scopes define what access the client is requesting.

Common scopes:

# OAuth 2.0
read:user        ← Read user profile
write:user       ← Update user profile
read:repo        ← Read repositories
admin:org        ← Manage organization

# OIDC (standard)
openid           ← Required for OIDC
profile          ← Name, picture, etc.
email            ← Email address
address          ← Physical address
phone            ← Phone number

Request scopes:

url := config.AuthCodeURL(
    state,
    oauth2.SetAuthURLParam("scope", "openid profile email read:user"),
)

Check scopes trong token:

func requireScope(requiredScope string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            token := extractToken(r)
            scopes := token.Scopes // from JWT claims

            if !contains(scopes, requiredScope) {
                http.Error(w, "Insufficient permissions", 403)
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

// Usage
http.Handle("/admin", requireScope("admin:org")(adminHandler))

Security Best Practices

1. Always use state parameter (CSRF protection)

state := generateRandomString(32)
// Store in session
session.Set("oauth_state", state)

url := config.AuthCodeURL(state)

Callback:

receivedState := r.URL.Query().Get("state")
expectedState := session.Get("oauth_state")

if receivedState != expectedState {
    http.Error(w, "Invalid state - CSRF detected", 400)
    return
}

2. Validate redirect_uri

Auth server MUST validate redirect_uri matches registered URIs.

// ✅ OK
redirect_uri = https://yourapp.com/callback

// 🔥 NOT OK
redirect_uri = https://evil.com/steal-code

3. Use PKCE for public clients

Mobile/SPA apps MUST use PKCE.

4. Store tokens securely

// 🔥 BAD: localStorage (XSS risk)
localStorage.setItem('access_token', token)

// ✅ GOOD: httpOnly cookie
http.SetCookie(w, &http.Cookie{
    Name:     "access_token",
    Value:    token,
    HttpOnly: true,
    Secure:   true,
    SameSite: http.SameSiteStrictMode,
})

// ✅ BETTER: Encrypted in database
db.StoreToken(userID, encrypt(token, encryptionKey))

5. Rotate refresh tokens

Mỗi lần refresh, issue refresh token mới và revoke token cũ.

6. Validate JWT signatures

// 🔥 NEVER do this
parts := strings.Split(token, ".")
payload, _ := base64.Decode(parts[1])
// Trust payload without verifying signature?!

// ✅ Always verify
verifiedToken, err := jwt.Parse(token, keyFunc)

7. Use short-lived access tokens

Access token: 15-60 minutes
Refresh token: 7-30 days

8. Implement token revocation

// Revoke refresh token on logout
func logout(w http.ResponseWriter, r *http.Request) {
    refreshToken := getRefreshTokenFromSession(r)
    db.RevokeToken(refreshToken)

    // Clear session
    session.Clear()
}

Common Mistakes

❌ Using implicit flow cho SPA

// ❌ OLD way
response_type=token

// .✅ MODERN way
response_type=code + PKCE

❌ Không verify ID token signature

// 🔥 DANGEROUS
parts := strings.Split(idToken, ".")
claims := base64.Decode(parts[1])
// Use claims without verification?!

❌ Lưu tokens trong localStorage

XSS attacks có thể steal tokens.

❌ Long-lived access tokens without refresh

// 🔥 BAD
expiresIn := 30 * 24 * time.Hour  // 30 days!

❌ Không handle token refresh

Client phải implement logic để refresh khi access token expires.


Real-world Example: "Login with Google"

Setup (Google Cloud Console):

  1. Create OAuth 2.0 Client ID
  2. Configure authorized redirect URIs:
    • http://localhost:8080/callback (dev)
    • https://yourapp.com/callback (prod)
  3. Get client_id and client_secret

Complete implementation:

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "os"

    "golang.org/x/oauth2"
    "golang.org/x/oauth2/google"
)

var googleOAuthConfig = &oauth2.Config{
    ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
    ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
    RedirectURL:  "http://localhost:8080/callback",
    Scopes: []string{
        "https://www.googleapis.com/auth/userinfo.email",
        "https://www.googleapis.com/auth/userinfo.profile",
    },
    Endpoint: google.Endpoint,
}

func main() {
    http.HandleFunc("/", handleHome)
    http.HandleFunc("/login", handleLogin)
    http.HandleFunc("/callback", handleCallback)
    http.ListenAndServe(":8080", nil)
}

func handleHome(w http.ResponseWriter, r *http.Request) {
    html := `<html><body><a href="/login">Login with Google</a></body></html>`
    fmt.Fprint(w, html)
}

func handleLogin(w http.ResponseWriter, r *http.Request) {
    url := googleOAuthConfig.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
    http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}

func handleCallback(w http.ResponseWriter, r *http.Request) {
    code := r.URL.Query().Get("code")

    token, err := googleOAuthConfig.Exchange(context.Background(), code)
    if err != nil {
        http.Error(w, "Failed to exchange token", http.StatusInternalServerError)
        return
    }

    client := googleOAuthConfig.Client(context.Background(), token)
    resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
    if err != nil {
        http.Error(w, "Failed to get user info", http.StatusInternalServerError)
        return
    }
    defer resp.Body.Close()

    var userInfo map[string]interface{}
    json.NewDecoder(resp.Body).Decode(&userInfo)

    fmt.Fprintf(w, "Welcome! User info: %+v", userInfo)
}

Run:

export GOOGLE_CLIENT_ID="your-client-id"
export GOOGLE_CLIENT_SECRET="your-client-secret"
go run main.go

Tóm tắt

Concept Summary
OAuth 2.0 Authorization framework (not authentication)
OIDC OAuth 2.0 + authentication layer
Authorization Code Standard flow cho web apps
PKCE Secure flow cho mobile/SPA
Client Credentials M2M, no user context
ID Token JWT với user info (OIDC)
Access Token To access protected resources
Refresh Token To get new access token
Scopes Define permissions

Golden rules:

  • Use Authorization Code + PKCE cho public clients
  • Always validate ID token signatures
  • Store tokens securely
  • Short-lived access tokens, long-lived refresh tokens
  • Implement token rotation and revocation

Bước tiếp theo

  • advanced-auth.md — MFA, SSO, Zero Trust
  • ../api-and-web-security.md — API keys, rate limiting
  • ../crypto-basics.md — How JWT signatures work