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 Code | Web 應用(推薦) |
| Authorization Code + PKCE | SPA、行動 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.0 | OIDC |
|---|---|
| 授權(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 | 最安全的流程 |
| PKCE | SPA/行動端必備 |
| OIDC | 身份驗證 + ID Token |
| Refresh Token | 長期存取 |
| Client Credentials | 伺服器對伺服器 |
> **最佳實踐**:
- Web App: Authorization Code + PKCE
- SPA: Authorization Code + PKCE(無 Client Secret)
- 行動 App: Authorization Code + PKCE
- 伺服器: Client Credentials
進階挑戰
- 實作一個支援多個 OAuth Provider(Google、GitHub、Facebook)的登入系統。
- 研究 OAuth 2.1 的變化,與 OAuth 2.0 有何不同?
- 實作一個 OAuth Server,讓其他應用可以用你的服務登入。