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 会绑到这些值 — 但每个都必须在您客户端的 allowed-resources 白名单 内(默认全部拒绝)。格式不正确 或 不在白名单内 → 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)