获取第一个令牌
这是针对本地部署、手动完成的一次完整的授权码 + PKCE 往返流程。每个客户端库都实现了这个流程,亲手走过一遍之后,调试客户端库通常会容易得多。
示例假定 ISSUER=http://localhost:3000,以及一个 id 为 demo 的公开客户端,其注册的重定向 URI 为 http://localhost:8080/callback。请替换为你在注册第一个客户端中得到的值。
-
生成 verifier 及其 challenge。在这里,每个
response_type=code请求都强制要求 PKCE,且S256是唯一接受的方法。// save as pkce.ts, run with: bun pkce.tsconst verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');const challenge = new Bun.CryptoHasher('sha256').update(verifier).digest('base64url');console.log({ verifier, challenge });保存好 verifier;你将在第 4 步把它发送到令牌端点。
-
把浏览器引导到授权端点。用你的 challenge 构造 URL:
http://localhost:3000/auth?response_type=code&client_id=demo&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback&scope=openid&state=8f2c1d&nonce=b41e07&code_challenge=<challenge>&code_challenge_method=S256(全部写在一行,去掉缩进。)
state由你在回调时自行校验;nonce会被回显到 ID 令牌中,让你可以把令牌与本次请求关联起来。scope=openid使这成为一个 OpenID Connect 请求,并产生 ID 令牌。若要获得刷新令牌,还需要 scope 中包含offline_access,并且此 URL 中带上prompt=consent,并且客户端的授权类型中包含refresh_token。在控制台创建的客户端默认只有authorization_code。三者缺一,该 scope 就会被静默丢弃,不报任何错误:你得到的令牌响应中没有refresh_token。服务器还必须在其支持的 scope 中列出offline_access(默认如此);未列出期间,令牌端点会以unsupported_grant_type拒绝所有刷新令牌。 -
登录并同意授权。服务器会重定向到它自己的交互页面:先是该客户端所在项目指向的用户桶的登录表单,然后是列出 scope 的授权同意页面(新客户端默认开启授权同意)。两者都通过后,浏览器会被发送到
http://localhost:8080/callback?code=<code>&state=8f2c1d&iss=http%3A%2F%2Flocalhost%3A30008080 端口上无需有任何程序在监听;从浏览器地址栏中复制
code即可。检查state是否原样返回,以及iss是否是你的签发者。 -
在令牌端点用授权码换取令牌。授权码只能使用一次,有效期 60 秒,所以要尽快完成。如果超时,请从第 2 步重新开始:
终端窗口 curl -s http://localhost:3000/token \-H 'Content-Type: application/x-www-form-urlencoded' \-d grant_type=authorization_code \-d code=<code> \-d client_id=demo \-d redirect_uri=http://localhost:8080/callback \-d code_verifier=<verifier>redirect_uri必须与第 2 步中的逐字节一致——授权流程会将它与记录在授权码上的值比较,不一致即拒绝。对于机密客户端,去掉请求体中的client_id,改为进行客户端认证:client_secret_basic用-u demo:<secret>,client_secret_post用-d client_id=demo -d client_secret=<secret>。响应是标准的令牌响应:
{"access_token": "…","token_type": "Bearer","expires_in": 3600,"id_token": "…","scope": "openid"} -
解码 ID 令牌。它是一个已签名的 JWT;载荷是中间那一段,采用 base64url 编码:
终端窗口 echo '<id_token>' | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .检查
iss是否与你的ISSUER一致,aud是否为你的 client id,nonce是否与第 2 步一致。真实的客户端还要验证签名,所用密钥来自GET /jwks,按令牌的kid选取。载荷还说明了用户的登录方式:密码登录时amr为["pwd"],使用一次性验证码时为["pwd", "otp", "mfa"];因此要求第二因素时,应检查是否包含mfa,而不是检查该声明是否存在。 -
调用 UserInfo 端点,带上访问令牌:
终端窗口 curl -s http://localhost:3000/userinfo -H 'Authorization: Bearer <access_token>' | jq .返回什么,取决于授予的 scope 以及服务器上配置的声明(claims)。
在默认部署下,第 3 步会经过的两个页面:


默认开启,还是由开关控制
Section titled “默认开启,还是由开关控制”大多数端点都受某个功能开关控制,而开关关闭意味着该端点根本不提供服务。这种拒绝被刻意设计得与服务器不存在的路径无法区分。所以在这些端点上得到 404 是配置层面的答案,而不是缺陷:
- 始终可用,不受开关控制:
/health、/.well-known/openid-configuration、/.well-known/security.txt、/jwks、/auth和/token。本演练中除第 6 步之外的所有内容都属于这一组。 - 默认开启,可以关闭:
/userinfo(userinfo.enabled)和 RP 发起的登出(rpInitiatedLogout.enabled)。 - 默认关闭,需要手动开启:推送授权请求、令牌内省、令牌撤销、动态客户端注册、设备流程、CIBA 以及 MCP 控制平面。