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 完整部署教學。

運作方式

sequenceDiagram participant App participant Signet participant Browser App->>App: (1) 產生 code_verifier + code_challenge(PKCE) App->>Browser: (2) 導向 /oauth/authorize<br/>?code_challenge=...&client_id=...&state=...&nonce=... Browser->>Signet: GET /oauth/authorize Signet->>Browser: 顯示登入頁(若尚未登入) Browser->>Signet: 送出帳密 Signet->>Browser: 顯示授權同意畫面(列出 scope) Browser->>Signet: 使用者同意 Signet->>Browser: (3) 導回 redirect_uri?code=AUTH_CODE&state=...&iss=... Browser->>App: 回呼並帶入 AUTH_CODE App->>Signet: (4) POST /oauth/token(code + code_verifier) Signet-->>App: (5) access_token + refresh_token [+ id_token]

步驟 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

範例客戶端

相關文件