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 会绑到这些值 — 但每个都必须在您客户端的 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

示例客户端

相关文档