跳至主要內容
Skip to content

OAuth 2.0 完整圖解:授權碼、PKCE 與 OIDC

OAuth 2.0 是現代身份驗證的基石。「使用 Google 登入」、「使用 GitHub 登入」背後都是 OAuth。本篇將全面解析這個重要協定。


一、 OAuth 2.0 基礎

1.1 為什麼需要 OAuth?

傳統方式的問題:

OAuth 的解決方案:

1.2 四個角色

角色說明範例
Resource Owner資源擁有者用戶
Client客戶端應用你的 App
Authorization Server授權伺服器Google 授權服務
Resource Server資源伺服器Google API

1.3 四種授權流程

流程適用場景
Authorization CodeWeb 應用(推薦)
Authorization Code + PKCESPA、行動 App(推薦)
Implicit已棄用
Client Credentials伺服器對伺服器

二、 授權碼流程(Authorization Code)

2.1 完整流程

2.2 步驟詳解

Step 1: 導向授權端點

javascript
// 構建授權 URL
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");

authUrl.searchParams.set("client_id", CLIENT_ID);
authUrl.searchParams.set("redirect_uri", "https://myapp.com/callback");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", "email profile");
authUrl.searchParams.set("state", generateRandomState()); // 防 CSRF

res.redirect(authUrl.toString());

Step 2: 用戶授權

用戶在授權伺服器(如 Google)登入並同意授權。

Step 3: 接收授權碼

javascript
// /callback 路由
app.get("/callback", async (req, res) => {
  const { code, state } = req.query;

  // 驗證 state 防止 CSRF
  if (state !== req.session.oauthState) {
    return res.status(400).send("State mismatch");
  }

  // 用 code 換取 token
  // ...
});

Step 4: 換取 Token

javascript
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    code,
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
    redirect_uri: "https://myapp.com/callback",
    grant_type: "authorization_code",
  }),
});

const { access_token, refresh_token, id_token } = await tokenResponse.json();

Step 5: 使用 Token

javascript
const userResponse = await fetch(
  "https://www.googleapis.com/oauth2/v2/userinfo",
  {
    headers: { Authorization: `Bearer ${access_token}` },
  }
);

const user = await userResponse.json();
// { id: '123', email: 'user@gmail.com', name: 'John Doe' }

三、 PKCE 安全增強

3.1 為什麼需要 PKCE?

授權碼可能被攔截:

PKCE(Proof Key for Code Exchange)解決這個問題:

3.2 實作

javascript
const crypto = require("crypto");

// 生成 code_verifier(43-128 字元)
function generateCodeVerifier() {
  return crypto.randomBytes(32).toString("base64url");
}

// 計算 code_challenge
function generateCodeChallenge(verifier) {
  return crypto.createHash("sha256").update(verifier).digest("base64url");
}

// 開始授權流程
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);

// 儲存 verifier(session 或 localStorage)
req.session.codeVerifier = codeVerifier;

// 構建授權 URL
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
authUrl.searchParams.set("client_id", CLIENT_ID);
authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", "email profile");
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");

// 換取 token 時帶上 verifier
const tokenResponse = await fetch(tokenEndpoint, {
  method: "POST",
  body: new URLSearchParams({
    code,
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    grant_type: "authorization_code",
    code_verifier: req.session.codeVerifier,
  }),
});

四、 OpenID Connect (OIDC)

4.1 OAuth vs OIDC

OAuth 2.0OIDC
授權(Authorization)身份驗證(Authentication)
「App 可以存取你的資料」「用戶是誰」
返回 access_token額外返回 id_token

4.2 ID Token

ID Token 是一個 JWT,包含用戶身份資訊:

javascript
// 解碼 id_token
{
  "iss": "https://accounts.google.com",      // 發行者
  "sub": "110169484474386276334",            // 用戶唯一 ID
  "aud": "your-client-id",                   // 接收者(你的 App)
  "exp": 1640000000,                         // 過期時間
  "iat": 1639996400,                         // 發行時間
  "email": "user@gmail.com",
  "email_verified": true,
  "name": "John Doe",
  "picture": "https://..."
}

4.3 使用 OIDC

javascript
// 授權時加上 openid scope
const authUrl = new URL(authEndpoint);
authUrl.searchParams.set("scope", "openid email profile");

// 回應會包含 id_token
const { access_token, id_token } = await tokenResponse.json();

// 驗證 id_token
const jwt = require("jsonwebtoken");
const jwksClient = require("jwks-rsa");

const client = jwksClient({
  jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
});

function getKey(header, callback) {
  client.getSigningKey(header.kid, (err, key) => {
    callback(null, key.getPublicKey());
  });
}

jwt.verify(
  id_token,
  getKey,
  {
    issuer: "https://accounts.google.com",
    audience: CLIENT_ID,
  },
  (err, decoded) => {
    if (err) {
      console.error("Invalid ID token");
    } else {
      console.log("User:", decoded);
    }
  }
);

五、 Refresh Token

5.1 流程

5.2 實作

javascript
async function refreshAccessToken(refreshToken) {
  const response = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      refresh_token: refreshToken,
      grant_type: "refresh_token",
    }),
  });

  if (!response.ok) {
    throw new Error("Refresh failed");
  }

  return response.json();
}

六、 Client Credentials 流程

6.1 用途

伺服器對伺服器,沒有用戶參與:

6.2 實作

javascript
const response = await fetch("https://oauth2.example.com/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "client_credentials",
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
    scope: "api.read",
  }),
});

const { access_token } = await response.json();

七、 完整實作範例

7.1 Google OAuth + Express

javascript
const express = require("express");
const session = require("express-session");
const crypto = require("crypto");

const app = express();

const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
const REDIRECT_URI = "http://localhost:3000/auth/google/callback";

app.use(session({ secret: "secret", resave: false, saveUninitialized: false }));

// 開始 OAuth 流程
app.get("/auth/google", (req, res) => {
  const state = crypto.randomBytes(16).toString("hex");
  req.session.oauthState = state;

  const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
  url.searchParams.set("client_id", GOOGLE_CLIENT_ID);
  url.searchParams.set("redirect_uri", REDIRECT_URI);
  url.searchParams.set("response_type", "code");
  url.searchParams.set("scope", "openid email profile");
  url.searchParams.set("state", state);

  res.redirect(url.toString());
});

// 處理回調
app.get("/auth/google/callback", async (req, res) => {
  const { code, state } = req.query;

  // 驗證 state
  if (state !== req.session.oauthState) {
    return res.status(400).send("Invalid state");
  }
  delete req.session.oauthState;

  // 換取 token
  const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      code,
      client_id: GOOGLE_CLIENT_ID,
      client_secret: GOOGLE_CLIENT_SECRET,
      redirect_uri: REDIRECT_URI,
      grant_type: "authorization_code",
    }),
  });

  const tokens = await tokenRes.json();

  // 取得用戶資訊
  const userRes = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
    headers: { Authorization: `Bearer ${tokens.access_token}` },
  });

  const googleUser = await userRes.json();

  // 建立或更新用戶
  let user = await User.findOne({ googleId: googleUser.id });
  if (!user) {
    user = await User.create({
      googleId: googleUser.id,
      email: googleUser.email,
      name: googleUser.name,
      avatar: googleUser.picture,
    });
  }

  // 建立 session
  req.session.userId = user.id;

  res.redirect("/dashboard");
});

app.listen(3000);

總結

概念說明
OAuth 2.0授權框架
Authorization Code最安全的流程
PKCESPA/行動端必備
OIDC身份驗證 + ID Token
Refresh Token長期存取
Client Credentials伺服器對伺服器

> **最佳實踐**:

  • Web App: Authorization Code + PKCE
  • SPA: Authorization Code + PKCE(無 Client Secret)
  • 行動 App: Authorization Code + PKCE
  • 伺服器: Client Credentials

進階挑戰

  1. 實作一個支援多個 OAuth Provider(Google、GitHub、Facebook)的登入系統。
  2. 研究 OAuth 2.1 的變化,與 OAuth 2.0 有何不同?
  3. 實作一個 OAuth Server,讓其他應用可以用你的服務登入。

延伸閱讀與資源