第三方 OAuth 接入接入准备
快速开始
十五分钟内跑通「跳转 → 同意 → 回调 → 换令牌 → 读用户信息」。
本页用一个最小可用的机密客户端(web_server)走完整个流程。所有示例都是标准 OAuth 2.0,你换成自己惯用的客户端库后参数含义不变。
0. 准备
| 需要 | 从哪里来 |
|---|---|
client_id / client_secret | 见 申请客户端 |
redirect_uri | 已登记的回调地址,例如 https://your-app.example.com/callback |
issuer | 例如 https://id.example.com/v1/oauth |
authorization_endpoint | 从元数据读取,例如 https://id.example.com/authorize |
本次申请的最小权限集:openid profile email。需要长期访问再加 offline_access。
1. 生成 PKCE 与 state
import { randomBytes, createHash } from 'node:crypto'
const verifier = randomBytes(32).toString('base64url')
const challenge = createHash('sha256').update(verifier, 'ascii').digest('base64url')
const state = randomBytes(16).toString('base64url')verifier 与 state 必须和当前浏览器会话绑定(例如放进 HttpOnly Cookie 或服务端会话),回调时要取回来比对。
2. 让浏览器跳转到同意页
const url = new URL('https://id.example.com/authorize')
url.searchParams.set('client_id', CLIENT_ID)
url.searchParams.set('redirect_uri', REDIRECT_URI)
url.searchParams.set('response_type', 'code')
url.searchParams.set('scope', 'openid profile email')
url.searchParams.set('state', state)
url.searchParams.set('code_challenge', challenge)
url.searchParams.set('code_challenge_method', 'S256')
return Response.redirect(url, 302)不要自己拼 /v1/oauth/authorize
服务器上的 /v1/oauth/authorize 是同意页提交决策用的内部 JSON 接口,只接受 POST。
浏览器跳转的目标必须是元数据里的 authorization_endpoint(同意页地址)。
3. 用户在同意页确认
同意页会展示应用名称与逐条权限。用户点击「同意」后,浏览器被 302 回你的 redirect_uri:
https://your-app.example.com/callback?code=...&state=...&iss=https%3A%2F%2Fid.example.com%2Fv1%2Foauth回调处理的三件事,顺序不能换:
- 比对
state,不一致直接终止(防 CSRF)。 - 比对
iss与你信任的 issuer,不一致直接终止(RFC 9207,防 mix-up)。 - 取出
code,立刻去换令牌(授权码是一次性的,且有分钟级寿命)。
用户拒绝授权时回调形如:
https://your-app.example.com/callback?error=access_denied&state=...&iss=...4. 换令牌
curl -X POST 'https://id.example.com/v1/oauth/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d 'grant_type=authorization_code' \
-d "code=$CODE" \
-d "redirect_uri=$REDIRECT_URI" \
-d "code_verifier=$VERIFIER"成功响应:
{
"token_type": "Bearer",
"expires_in": 3600,
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"refresh_token": "eyJhbGciOiJSUzI1NiIs...",
"scope": "openid profile email offline_access",
"id_token": "eyJhbGciOiJSUzI1NiIs..."
}要点:
redirect_uri必须与授权请求里的值完全一致,否则换令牌失败。code_verifier对应第 1 步的challenge;plain方式不被接受。- 响应带
Cache-Control: no-store,中间任何一层都不要缓存它。
5. 读用户信息
curl 'https://id.example.com/v1/auth/userinfo' \
-H "Authorization: Bearer $ACCESS_TOKEN"{
"sub": "0192f3a4-...",
"preferred_username": "muoceon_user",
"name": "显示名",
"email": "[email protected]",
"email_verified": true
}sub 是稳定用户标识,请用它做主键关联账号,不要用邮箱——邮箱可以变更,sub 不会。
检查点
| 现象 | 常见原因 |
|---|---|
| 同意页报「客户端不存在」 | client_id 未登记或拼错 |
| 同意页报回调地址非法 | 未登记该地址,或 redirect_uri 与登记值差一个末尾斜杠 |
换令牌返回 invalid_grant | 授权码已用过/已过期,或 code_verifier 与 challenge 不匹配 |
换令牌返回 invalid_client | 机密客户端没带密钥,或用了错误的认证方式 |
| UserInfo 返回受众不匹配 | 用了 oauth 受众的令牌,或 scope 里没有 openid/profile/email |
拿到的 scope 比申请的少 | 服务端按登记白名单裁剪,被裁掉的 scope 不报错,见 scope 与受众 |