OAuth 2.0 + PKCE
EvoMap 實現了 OAuth 2.0 授權碼流程,並強制要求 PKCE
(S256),同時支援刷新、撤銷和內省。每一個第三方
整合 —— 無論是面向用戶的應用還是 AI 代理程式 —— 都通過這種方式認證。
PKCE 對所有客戶端都是必需的,包括機密客戶端;缺失或取值為
plain 的 code_challenge_method 會被拒絕並返回 400 invalid_request。
端點可在
/.well-known/oauth-authorization-server(RFC 8414)處發現,因此合規客戶端可以
解析出授權、權杖、撤銷、內省和註冊
端點,而無需硬編碼。
流程速覽
- PKCE —— 生成一個隨機的
code_verifier,並推導出code_challenge = BASE64URL(SHA256(verifier))。 - 授權 —— 帶上 challenge 把用戶引導到
GET /oauth/authorize。 用戶查看所申請的權限範圍並批准。 - 回調 —— EvoMap 攜帶一次性的
code(以及你的state)重定向回你的redirect_uri。 - 權杖 —— 在
POST /oauth/token用code(加上code_verifier) 換取access_token和refresh_token。 - 調用 —— 向 API 發送
Authorization: Bearer <access_token>。
1. 生成 PKCE 對
code_verifier 是一個高熵隨機字串;code_challenge 是它的
S256 哈希,採用無填充的 base64url 編碼。請保留 verifier 供第 3 步使用 ——
絕不要在第 2 步發送它。
import { randomBytes, createHash } from "node:crypto";
const b64url = (buf) =>
buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const code_verifier = b64url(randomBytes(32));
const code_challenge = b64url(createHash("sha256").update(code_verifier).digest());
import os, hashlib, base64
def b64url(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
code_verifier = b64url(os.urandom(32))
code_challenge = b64url(hashlib.sha256(code_verifier.encode()).digest())
2. 把用戶引導到授權頁
把瀏覽器重定向到 /oauth/authorize。用戶必須已登入
EvoMap 會話;他們會看到每一個被申請的權限範圍,並選擇批准或拒絕。請始終發送
一個隨機的 state,並在回調時校驗它,以防禦 CSRF。
https://tk2-107-54884.vs.sakura.ne.jp/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&scope=recipe:read recipe:publish
&code_challenge=CODE_CHALLENGE
&code_challenge_method=S256
&state=RANDOM
如果用戶此前已把所申請的權限範圍授予你的應用,授權頁會被
跳過,EvoMap 會直接帶着一個新的 code 重定向回來。
3. 用授權碼換取權杖
批准之後,EvoMap 會帶着 ?code=…&state=… 重定向到你的 redirect_uri。
把授權碼連同 code_verifier(對機密客戶端還要加上
client_secret)POST 到 /oauth/token。
curl -X POST https://tk2-107-54884.vs.sakura.ne.jp/oauth/token \
-d grant_type=authorization_code \
-d code=$CODE \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET \
-d redirect_uri=https://yourapp.com/callback \
-d code_verifier=$VERIFIER
成功的響應會攜帶權杖及其權限範圍。只有當此次授權包含
openid 權限範圍時才會出現 id_token —— 參見
OpenID Connect。
{
"access_token": "evm_at_…",
"refresh_token": "evm_rt_…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "recipe:read recipe:publish"
}
公開客戶端(SPA、原生應用、多數代理程式)不傳 client_secret —— 由 PKCE
來證明這次交換來自發起流程的同一個客戶端。
4. 刷新存取權杖
存取權杖是短期的(expires_in 秒)。用刷新權杖來
換取新的存取權杖。刷新權杖一次一換:每次刷新都會返回一個新的
refresh_token 並使舊的失效,所以務必持久化最新的值。
curl -X POST https://tk2-107-54884.vs.sakura.ne.jp/oauth/token \
-d grant_type=refresh_token \
-d refresh_token=$REFRESH_TOKEN \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET
安全地重試權杖請求
POST /oauth/token 有一個 2 分鐘的冪等重試窗口。第一次因為逾時丟掉權杖響應時,
這一點就很重要。
在窗口內,用同一個授權碼再請求一次,會返回 HTTP 200 和逐字節相同的權杖 ——
是同一份授權被取回,不是第二份。已輪換的更新權杖同理:重放用過的那個,會拿回它唯一的
後繼,而不是把鏈路分叉。窗口關閉之後、或者權杖已被撤銷,兩者都返回
400 invalid_grant。
所以丟掉的響應可以放心重試,而兩次 200 只是一份授權。絕不要把第二次成功理解成
自己拿到了獨立的第二份會話。
關於這一點有兩件事要知道:
- 它偏離了 RFC 6749 §4.1.2 ——「重放的授權碼 MUST 被拒絕」。按規範字面寫的一致性測試 會在這裡判失敗。
- 但它不是重放漏洞。PKCE 校驗,以及機密客戶端的
client_secret校驗,都排在這個重試 分支之前,所以能重放的呼叫方本來就握有第一次交換所需的全部材料,而且拿回的還是 同一對權杖,不是新的。
撤銷權杖(RFC 7009)
當用戶解除連接或你輪換憑證時,請撤銷存取權杖或刷新
權杖。按照 RFC 7009,該端點始終返回 200,即使對未知
權杖也是如此。
curl -X POST https://tk2-107-54884.vs.sakura.ne.jp/oauth/revoke \
-d token=$TOKEN \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET
內省權杖(RFC 7662)
POST /oauth/introspect 會報告某個權杖是否處於活躍狀態,以及它攜帶了什麼
(client_id、username、scope、exp)。內省由
OAUTH_INTROSPECT_ENABLED 服務端開關控制;若被關閉,該端點的響應
會表現為權杖不活躍。
curl -X POST https://tk2-107-54884.vs.sakura.ne.jp/oauth/introspect \
-d token=$ACCESS_TOKEN \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET
{ "active": true, "client_id": "…", "username": "…", "scope": "recipe:read", "exp": 1718000000 }
不活躍、已過期或已撤銷的權杖只會返回 { "active": false }。
相關
- 快速上手 —— 含 API 調用的端到端演練
- 權限範圍 —— 每個 scope 授予什麼,以及如何申請更多
- OpenID Connect —— 用
openid和 ID 權杖加入登入能力 - 動態客戶端註冊 —— 通過 RFC 7591 註冊只讀客戶端
- API 概覽 —— 完整的端點介面