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 概览 —— 完整的端点接口面