Authorization Code Flow + PKCE(授權碼流程)
Authorization Code Flow(RFC 6749)搭配 PKCE(Proof Key for Code Exchange,RFC 7636)是網頁應用、單頁應用(SPA)、行動應用的首選 OAuth 2.0 流程。
何時使用此流程
在下列情況使用 Authorization Code + PKCE:
- 您在打造 伺服器端網頁應用(機密客戶端 — 有後端可安全保管
client_secret)
- 您在打造 單頁應用(公開客戶端 — 沒有 secret)
- 您在打造 行動或桌面應用(公開客戶端)
- 您希望使用者在授權前看到 同意授權畫面
客戶端類型
| 類型 | 憑證 | 典型範例 | 必須使用 PKCE? |
|---|---|---|---|
confidential |
client_id + client_secret |
Rails / Django / Node 後端 | 建議 |
public |
僅 client_id(無 secret) |
React SPA、iOS/Android、Electron | 是 — 一律要 |
PKCE(S256)永遠是安全的選擇,也是 Signet 唯一接受的
code_challenge_method。機密客戶端也應該加上,做為縱深防禦。
Client ID Metadata Documents(CIMD)— 免註冊的 MCP 客戶端
當管理者設定 CIMD_ENABLED=true(預設關閉)時,MCP 客戶端可以直接以自行託管的 HTTPS URL 作為 client_id(例如 https://app.example.com/client.json)。Signet 會在授權當下抓取該 URL 上的 JSON 中繼資料文件並自動完成註冊 — 不需要管理員預先建立客戶端,也不需要呼叫動態註冊端點。文件內的 client_id 必須與其存放的 URL 完全一致、必須列出你的 redirect_uris,且 token_endpoint_auth_method 必須是 none(CIMD 客戶端一律是公開客戶端,因此必須使用 S256 PKCE)。這類客戶端執行的授權碼流程與下文描述完全相同;其 RFC 8707 resource 值會對照全伺服器的 CIMD_ALLOWED_RESOURCES 允許清單檢查,同意頁面也會顯示客戶端網域及未驗證客戶端提示。此機制遵循 MCP 2026-07-28 授權規範;動態客戶端註冊(RFC 7591)仍可作為舊式備援使用。完整的文件託管、MCP 伺服器實作、Signet 設定與 CIDR/SSRF 安全性說明,請參閱 CIMD 完整部署教學。
運作方式
步驟 1:產生 PKCE 參數
產生一串密碼學隨機的 code_verifier(43–128 字元),並推導出 code_challenge:
Go
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
)
buf := make([]byte, 32)
_, _ = rand.Read(buf)
codeVerifier := base64.RawURLEncoding.EncodeToString(buf)
h := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.RawURLEncoding.EncodeToString(h[:])
Python
import hashlib, base64, secrets
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
JavaScript(Node / 瀏覽器 via crypto.subtle)
// Node 16+:
const codeVerifier = crypto.randomBytes(32).toString("base64url");
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier)
.digest("base64url");
把 code_verifier 存起來,步驟 4 會用到:
- 機密客戶端:放在伺服器端 session。
- SPA:最好放在記憶體變數裡。真的需要跨重新整理時才退一步用
sessionStorage— 請注意任何瀏覽器儲存都會暴露在 XSS 面前,真正的解法是採用 Backend-For-Frontend(BFF)模式。
步驟 2:導向授權端點
GET /oauth/authorize
?client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.example/callback
&response_type=code
&scope=openid profile email offline_access
&state=RANDOM_STATE
&nonce=RANDOM_NONCE
&code_challenge=CODE_CHALLENGE
&code_challenge_method=S256
| 參數 | 必填 | 備註 |
|---|---|---|
client_id |
是 | 管理員提供 |
redirect_uri |
是 | 與註冊 URI 完全字串比對。選擇性啟用的例外(RFC 8252 §7.3):管理員設定 LOOPBACK_REDIRECT_ANY_PORT_ENABLED=true 後,註冊為不含連接埠的純 http loopback URI — http://localhost/callback、http://127.0.0.1/callback、http://[::1]/callback — 可匹配任意連接埠。主機名稱、編碼後路徑與原始查詢字串仍需完全一致(localhost 不會匹配 127.0.0.1) |
response_type |
是 | 必須是 code(Signet 僅支援此類型) |
scope |
建議 | 空白分隔;包含 openid 以取得 ID token |
state |
是(CSRF) | 隨機值 — 回呼時驗證 |
nonce |
OIDC | 當 scope 含 openid 時必填;會寫進 id_token 以防重送 |
code_challenge |
PKCE | 依上面方式推導 |
code_challenge_method |
PKCE | 必須是 S256(plain 會被拒) |
resource |
選填 | RFC 8707 Resource Indicator — 絕對 http(s) URI、無 fragment、≤ 1024 字元;可重複(最多 10 個)。帶入後,簽發的 access token aud 會綁到這些值 — 但每個值都必須在您客戶端的 允許資源清單 上(預設全部拒絕)。格式不正確 或 未在允許清單 → invalid_target |
state 與 nonce:各自獨立、隨機、夠長(≥ 16 bytes base64url)。把
state與code_verifier以state當作鍵存入使用者 session,回呼時才能查得到。
使用者會被要求登入(若尚未登入),接著看到列出 scope 的授權同意畫面。若帶了 resource,同意畫面會另外列出 token 適用的 audience 目標。
當伺服器記住同意(CONSENT_REMEMBER=true,預設值)時,是以每個 resource 組合分別記住:對同一個應用程式核准不同的 resource 組合會建立獨立的授權紀錄,不會覆蓋先前的紀錄,且每筆授權都可在帳戶 → 已授權應用程式中個別檢視與撤銷。只要請求的 resource 組合與任何已儲存的授權都不相符 — 包括在核准過綁定 resource 的授權後送出不帶 resource 的請求(反之亦然)— 同意畫面就會再次出現,使用者核准的 audience 絕不會被悄悄變更。(舊版每個應用程式只保留一筆授權;核准新的 resource 組合會覆蓋舊的。)
步驟 3:處理回呼
https://yourapp.example/callback?code=AUTH_CODE&state=RANDOM_STATE&iss=https://your-signet
使用 code 之前 先驗證 state 是否與您送出的相符。不相符就中止。
每次回呼也會帶上 iss 參數(RFC 9207),用來標識簽發伺服器 — 其值永遠等於探索文件中的 issuer 欄位。驗證 state 後,每個用戶端都必須確認 iss 存在,並以簡單字串完全比對,驗證它等於該次授權請求所記錄的 issuer;不得進行 URL 正規化。缺少或不相符時,必須在處理授權回應或使用 code 兌換 token 前拒絕。即使用戶端只設定一個授權伺服器也同樣適用,因為 Signet 已宣告 authorization_response_iss_parameter_supported: true。
若使用者拒絕授權,Signet 會改用 OAuth 錯誤格式回呼(錯誤回呼同樣帶有 iss 參數):
https://yourapp.example/callback?error=access_denied&error_description=...&state=RANDOM_STATE&iss=https://your-signet
完整錯誤清單見 錯誤處理。
步驟 4:用 code 交換權杖
/oauth/token 端點 只 接受 application/x-www-form-urlencoded,不吃 JSON。
公開客戶端(只有 PKCE):
curl -X POST https://your-signet/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=AUTH_CODE" \
-d "redirect_uri=https://yourapp.example/callback" \
-d "client_id=YOUR_CLIENT_ID" \
-d "code_verifier=CODE_VERIFIER"
機密客戶端(HTTP Basic — 建議):
curl -X POST https://your-signet/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=AUTH_CODE" \
-d "redirect_uri=https://yourapp.example/callback" \
-d "code_verifier=CODE_VERIFIER"
也可以放在表單 body(client_id=...&client_secret=...)。依 RFC 6749 §2.3.1,HTTP Basic 是首選。
縮小
resource:若在/oauth/authorize帶過resource=...,這裡可再傳一次,但必須是當時送出的集合的 子集(RFC 8707 §2.2)。擴張會回400 invalid_target。省略resource則拿到綁定完整授權集的 token。
回應:
{
"access_token": "eyJhbG...",
"refresh_token": "def502...",
"id_token": "eyJhbG...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile email offline_access"
}
id_token 僅在要求 scope 含 openid 時出現。claim 與驗證方式詳見 OpenID Connect。
這裡送出的
redirect_uri必須與步驟 2 的一模一樣 — Signet 會完全比對。
步驟 5:刷新 access token
接近過期時,用 refresh token 換一組新的。詳見 Token 與撤銷,特別留意 輪轉模式重用偵測的陷阱,舊 refresh token 被重用兩次會讓整個 token family 被撤銷。
步驟 6:登出
登出時,請撤銷 refresh token(不要只清本機 session)— 見 Token 與撤銷。只刪 cookie 會讓被偷走的 token 一直有效到過期為止。
管理使用者授權
使用者可在 Signet 介面 Account → Authorized Apps 檢視與撤銷每個應用的授權。要預期使用者回來時手上的 token 已過期或被撤銷 — 請妥善處理 invalid_grant 並重跑流程。
安全檢查清單
| 要求 | 說明 |
|---|---|
一律驗 state |
防止回呼 CSRF |
一律驗 nonce |
OIDC 用 — 比對 id_token 內的 nonce claim 與您送出的值 |
| 一律用 PKCE(S256) | 即便是機密客戶端,也做縱深防禦 |
| 全程 HTTPS | token 與 code 絕對不能以明文通過網路 |
| redirect URI 完全符合 | Signet 完全字串比對 — 小心尾斜線。選擇性啟用的 loopback 例外:設定 LOOPBACK_REDIRECT_ANY_PORT_ENABLED=true 並註冊 http://localhost/callback(不含連接埠),即可在執行期使用任意連接埠(RFC 8252) |
| 登出時撤銷 | 用 refresh token 呼叫 /oauth/revoke |
| 短效 access token | 尊重 expires_in;提前刷新(例如過期前 30 秒) |
驗證 id_token |
簽章、iss、aud=client_id、exp、nonce — 見 OpenID Connect |
驗證 access token aud |
在 resource server 端要求 aud 等於該服務的識別字(RFC 8707)— 見 JWT 驗證 |
| SPA 權杖儲存 | 優先使用 BFF。否則:access token 只放記憶體;refresh token 放 HttpOnly; Secure; SameSite=Lax cookie。絕不用 localStorage。 |
| 原生 / CLI 儲存 | OS keychain / Credential Manager / Secret Service — 見 Device Flow |
範例客戶端
- github.com/go-signet/oauth-cli — Go 實作的 Auth Code + PKCE
- github.com/go-signet/cli — 混合 CLI(SSH 環境走 Device Flow,本機走 Auth Code)