从零跑通一次登录
OAuth 2.0 Authorization Code + PKCE 的完整接入步骤:注册应用、生成 PKCE、换令牌、local signature verification、刷新与登出。每一步都能直接复制。
fetch 与 node:crypto 的运行时都能直接搬。Step 0 · 注册应用
登录后打开 My apps,填写应用名称与回调地址(redirect_uri)。保存后页面会显示一次
client_id 与 client_secret,立即复制到服务端的环境变量里 —— 列表页永远不回显 secret,丢了只能新建应用。
回调地址规则:生产环境必须 https://;本地Test允许 http://localhost / http://127.0.0.1;
必须与请求里的 redirect_uri 逐字符一致(含端口与末尾斜杠),不支持通配符。
Step 1 · 发现端点
客户端启动时先拉一次授权服务器元数据,之后缓存。不要在代码里硬编码端点。
curl -s http://8.134.249.230/.well-known/oauth-authorization-server重点字段:issuer(也是 JWT 里的 iss)、authorization_endpoint、token_endpoint、jwks_uri、
code_challenge_methods_supported(当前只有 S256)。
Step 2 · 生成 state 与 PKCE
state 防 CSRF;code_verifier 是 43–128 字符的高熵随机串,challenge 是它的 SHA-256。两者都只存在你的服务端会话里。
import crypto from 'node:crypto';
const b64url = (buf) =>
Buffer.from(buf).toString('base64').replace(/+/g, '-').replace(///g, '_').replace(/=+$/, '');
const state = crypto.randomBytes(16).toString('base64url');
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
console.log({ state, verifier, challenge });
// 把 state 与 verifier 存进你自己的会话(服务端),回调时要用Step 3 · 跳转授权页
在浏览器里打开下面这个地址(已登录平台的话会直接签发授权码;未登录会先跳平台登录页,登录后自动回到这里)。
http://8.134.249.230/oauth/authorize
?client_id=agpc_xxxxxxxx
&redirect_uri=https%3A%2F%2Fyour-work.example.com%2Fcallback
&response_type=code
&scope=identify%20email
&state=<state>
&code_challenge=<challenge>
&code_challenge_method=S256| 参数 | 说明 |
|---|---|
client_id | 应用凭据(必填) |
redirect_uri | 必须与登记值完全一致(必填) |
response_type | 固定 code |
scope | 空格分隔:identify email offline_access forum:write |
state | 随机串,回调时原样带回,必须校验 |
code_challenge | BASE64URL(SHA256(verifier))(必填,缺了直接 400) |
code_challenge_method | 固定 S256 |
Step 4 · 处理回调
平台 302 回 redirect_uri?code=…&state=…。先校验 state 与会话里保存的值完全一致,再拿 code 去换令牌。
授权码 60 秒有效、只能换一次。
// GET /callback?code=...&state=...
if (url.searchParams.get('state') !== session.oauthState) {
return send(res, 400, 'state 不匹配,拒绝这次登录');
}
const code = url.searchParams.get('code');Step 5 · 换令牌
curl -s -X POST http://8.134.249.230/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=<回调拿到的 code>" \
-d "redirect_uri=https://your-work.example.com/callback" \
-d "client_id=agpc_xxxxxxxx" \
-d "client_secret=agps_xxxxxxxx" \
-d "code_verifier=<上一步保存的 verifier>"{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
"token_type": "Bearer",
"expires_in": 3600,
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
"refresh_token": "6dQ2...(30 天有效,一次性轮换)",
"scope": "identify email"
}| 字段 | 说明 |
|---|---|
access_token | RS256 JWT,1 小时。调用 /oauth/userinfo 与论坛写入接口时放 Authorization: Bearer 里 |
id_token | RS256 JWT,5 分钟。含 sub / nickname / email,用来建立本地会话 |
refresh_token | 30 天。一次性轮换:每次刷新都会作废旧值 |
expires_in | access_token 剩余秒数(3600) |
Step 6 · local signature verification
用 JWKS 公钥验 id_token 的签名,再核对 iss / aud / exp。sub 就是平台用户 ID,用它找 / 建你本地账号;
老账号迁移可以用 email 匹配。
import crypto from 'node:crypto';
let jwks = null;
async function getKey(kid) {
if (!jwks) {
const res = await fetch('http://8.134.249.230/.well-known/jwks.json');
jwks = (await res.json()).keys;
}
return jwks.find((k) => k.kid === kid) || null;
}
/** local signature verification:不联网(公钥走缓存),只做Password学与声明校验 */
export async function verifyJwt(token) {
const [h, p, s] = String(token).split('.');
const header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8'));
const jwk = await getKey(header.kid);
if (!jwk) throw new Error('未知的 kid,刷新 JWKS 后重试');
const ok = crypto.verify(
'RSA-SHA256',
Buffer.from(h + '.' + p),
crypto.createPublicKey({ key: jwk, format: 'jwk' }),
Buffer.from(s, 'base64url')
);
if (!ok) throw new Error('签名无效');
const claims = JSON.parse(Buffer.from(p, 'base64url').toString('utf8'));
if (claims.exp * 1000 < Date.now()) throw new Error('令牌已过期');
if (claims.iss !== 'http://8.134.249.230') throw new Error('iss 不匹配');
return claims; // { sub, nickname, email, aud, exp, ... }
}kid 时强制刷新一次 JWKS 再试。不要把公钥写死进代码。Step 7 · 刷新令牌
access_token 1 小时过期。过期前用 refresh_token 静默续期;旧 refresh_token 会立即作废,所以刷新成功后务必把新值落库。
刷新失败(invalid_grant)意味着只能让用户重新登录。
curl -s -X POST http://8.134.249.230/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "refresh_token=<上一次返回的 refresh_token>"Step 8 · 取资料与登出
curl -s http://8.134.249.230/oauth/userinfo \
-H "Authorization: Bearer <access_token>"
# Revoke令牌(登出时用;幂等,未知令牌也返回 ok)
curl -s -X POST http://8.134.249.230/oauth/revoke -d "token=<access_token 或 refresh_token>"登出时清掉你自己的会话;调用Revoke端点(幂等,未知令牌也返回成功)可让令牌在Forum API与内省里立即失效。
/oauth/userinfo 只验 JWT 签名与用户存在性,不会感知Revoke —— 被Revoke的 access_token 在 1 小时有效期内仍可能取到资料。安全敏感场景请以 /oauth/introspect 的 active 或Forum API的 401 为准,并缩短本地会话有效期。常见问题
回调报 redirect_uri 不匹配
回调地址必须与登记值逐字符一致:协议、域名、端口、路径、末尾斜杠全都算。上线前把生产地址登记进去;本地开发用 http://localhost:端口 另行登记。
invalid_grant:PKCE 校验失败
token 请求里的 code_verifier 必须是生成 code_challenge 的那个原串;challenge 用 BASE64URL(SHA256(verifier)),不要用明文 verifier 当 challenge。
invalid_grant:code 已被使用 / 已过期
授权码 60 秒有效且只能换一次。常见原因是回调里先做了一堆慢操作再换令牌,或者失败后用同一个 code 重试。正确做法是失败就重新走授权。
iss 不匹配 / 验签失败
JWT 里的 iss 来自服务端的 AIGPART_ISSUER(生产是 https://aigpart.com)。本地用 IP 访问平台时,验签应以 discovery 返回的 issuer 为准。
回调里拿不到 code
授权页依赖平台登录态(Cookie)。在 iframe / 第三方嵌入场景里浏览器可能拦第三方 Cookie,导致用户无法登录。请用顶层跳转,不要嵌在 iframe 里。
access_token 过期后 401
access_token 只有 1 小时。用 refresh_token 刷新;接入侧也可以完全local signature verification,只有在需要最新Nickname / Email时才调 userinfo。
deploy/sso-client.mjs(生成授权地址 → 换令牌 → JWKS local signature verification → 取资料)。