OAuth 与 OIDC
授权码 + PKCE 流程的完整参考:发现文档、授权请求、令牌换取与刷新、ID Token 校验、UserInfo、撤销、错误与限流。
本页按调用顺序说明每个端点。所有地址都以 Issuer https://tokentosea.com/api/oauth 为前缀。完整可运行的服务端示例见完整示例。
前提:已在 控制台 → 开发者生态 登记应用,拿到 Client ID(机密客户端另有客户端密钥),并登记了回调地址。
流程#
- 生成本次登录的随机值
为每次登录生成
state、nonce与 PKCEcode_verifier,保存在你的服务端会话中。 - 跳转到授权地址
浏览器打开
/authorize,平台把用户带到 TokenToSea 授权页。用户登录(需已绑定手机号)、确认权限并设置预算。 - 接收回调
平台跳回你的
redirect_uri,带上code、state与iss。核对state与iss。 - 换取令牌
服务端用
code+code_verifier调用/token,得到访问令牌、刷新令牌与 ID Token。 - 校验身份并调用接口
校验 ID Token 的签名与声明,用
iss+sub关联你的本地账号;用访问令牌调用委托接口,过期前用刷新令牌续期。
发现文档#
GET /.well-known/openid-configuration
{
"issuer": "https://tokentosea.com/api/oauth",
"authorization_endpoint": "https://tokentosea.com/api/oauth/authorize",
"token_endpoint": "https://tokentosea.com/api/oauth/token",
"userinfo_endpoint": "https://tokentosea.com/api/oauth/userinfo",
"jwks_uri": "https://tokentosea.com/api/oauth/jwks",
"revocation_endpoint": "https://tokentosea.com/api/oauth/revoke",
"response_types_supported": ["code"],
"response_modes_supported": ["query"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_post", "client_secret_basic"],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["openid", "profile", "email", "offline_access", "tools:read", "tools:invoke", "models:read", "models:invoke", "wallet:read", "grant:limits"],
"authorization_response_iss_parameter_supported": true,
"claims_supported": ["iss", "sub", "aud", "exp", "iat", "auth_time", "nonce", "name", "preferred_username", "email", "email_verified"]
}授权请求#
GET /authorize
在浏览器中打开,参数放在查询字符串里:
| 参数 | 必填 | 说明 |
|---|---|---|
response_type | 必填 | 固定为 code |
client_id | 必填 | 应用的 Client ID |
redirect_uri | 必填 | 必须与登记的某个回调地址完全一致 |
scope | 必填 | 空格分隔,必须是应用已登记 scope 的子集,不能重复 |
state | 必填 | 16–512 个字符的随机值,用于防 CSRF。回调时原样返回 |
code_challenge | 必填 | BASE64URL(SHA256(code_verifier)),43 个字符 |
code_challenge_method | 必填 | 固定为 S256 |
nonce | 可选(使用 openid 时建议) | 1–512 个字符,原样写入 ID Token,用于防重放 |
prompt | 可选 | 只接受 consent。每次授权都需要用户在授权页明确确认 |
code_verifier 是 43–128 个字符的随机串(A–Z a–z 0–9 - . _ ~)。
成功时返回 302,跳转到 TokenToSea 授权页(https://tokentosea.com/app/#/authorize?request=…)。授权请求在 10 分钟内有效。
参数错误时不会跳回你的回调地址,而是直接在浏览器中返回 400 JSON(避免把错误发往未经验证的地址):
| 响应 | 原因 |
|---|---|
{"error":"invalid_client"} | client_id 不存在,或应用已停用 |
{"error":"invalid_request", "error_description": "Unregistered redirect URI"} | 回调地址未登记 |
{"error":"invalid_request", "error_description": "Authorization code with S256 PKCE is required"} | response_type 不是 code,或缺少有效的 S256 PKCE 参数 |
{"error":"invalid_request", "error_description": "Requested authentication constraints are not supported"} | 使用了 max_age、acr_values、id_token_hint、claims 或非 query 的 response_mode |
{"error":"invalid_request", "error_description": "Only explicit consent is supported"} | prompt 不是 consent |
{"error":"invalid_scope"} | scope 不合法或超出应用登记范围 |
message 为 invalid_redirect_uri | redirect_uri 格式不合法 |
message 为 tool_invalid_state / tool_invalid_nonce / tool_invalid_client_id | state、nonce 或 client_id 缺失或长度不符 |
回调#
用户确认后,浏览器被重定向到:
https://your-app.example.com/callback?code=tts_code_…&state=<你的 state>&iss=https%3A%2F%2Ftokentosea.com%2Fapi%2Foauth用户拒绝时为 ?error=access_denied&state=…&iss=…。
收到回调后:
- 核对
state与会话中保存的值一致(使用常数时间比较),并在一次使用后作废; - 核对
iss等于https://tokentosea.com/api/oauth; - 有
error时停止; - 在 60 秒内用
code换取令牌。
换取令牌#
POST /token
请求体可以是 application/x-www-form-urlencoded 或 JSON。
| 参数 | 必填 | 说明 |
|---|---|---|
grant_type | 必填 | authorization_code |
code | 必填 | 回调中的 code |
redirect_uri | 必填 | 与授权请求中的值完全一致 |
code_verifier | 必填 | 生成 code_challenge 时使用的原始值 |
client_id | 必填 | Client ID(使用 Basic 认证时可省略) |
client_secret | 机密客户端 | 用 client_secret_post 时放在请求体中;也可改用 Authorization: Basic base64(client_id:client_secret)。两种方式不能同时使用。公共客户端不能发送密钥 |
{
"access_token": "tts_at_…",
"token_type": "Bearer",
"expires_in": 900,
"scope": "openid profile offline_access tools:read tools:invoke",
"refresh_token": "tts_rt_…",
"id_token": "eyJhbGciOiJSUzI1NiIs…"
}| 字段 | 说明 |
|---|---|
access_token | 访问令牌,用于 Authorization: Bearer |
expires_in | 剩余有效秒数,最多 900;授权即将到期时会更短 |
scope | 用户实际授予的 scope,可能少于你申请的 |
refresh_token | 仅在授予 offline_access 时返回 |
id_token | 仅在授予 openid 时返回 |
响应带有 Cache-Control: no-store。请把令牌保存在服务端,不要写入日志或浏览器存储。
curl https://tokentosea.com/api/oauth/token \
-d grant_type=authorization_code \
-d code="$CODE" \
-d redirect_uri=https://your-app.example.com/callback \
-d code_verifier="$CODE_VERIFIER" \
-d client_id="$CLIENT_ID" \
-d client_secret="$CLIENT_SECRET"import base64
import hashlib
import secrets
import requests
ISSUER = "https://tokentosea.com/api/oauth"
# 发起授权前
code_verifier = secrets.token_urlsafe(48) # 64 个字符
code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=").decode()
# 收到回调后;client_secret 仅机密客户端提供,公共客户端传 None
def exchange(code, client_id, client_secret, redirect_uri):
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
"client_id": client_id,
}
if client_secret: # 仅机密客户端
data["client_secret"] = client_secret
response = requests.post(ISSUER + "/token", data=data, timeout=30)
response.raise_for_status()
return response.json()import { createHash, randomBytes } from 'node:crypto';
const ISSUER = 'https://tokentosea.com/api/oauth';
// 发起授权前
const codeVerifier = randomBytes(32).toString('base64url'); // 43 个字符
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
// 收到回调后
export async function exchange(code, { clientId, clientSecret, redirectUri }) {
const body = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, code_verifier: codeVerifier, client_id: clientId });
if (clientSecret) body.set('client_secret', clientSecret); // 仅机密客户端
const response = await fetch(`${ISSUER}/token`, { method: 'POST', body });
if (!response.ok) throw new Error(`Token exchange failed (${response.status})`);
return response.json();
}刷新令牌#
| 参数 | 必填 | 说明 |
|---|---|---|
grant_type | 必填 | refresh_token |
refresh_token | 必填 | 最近一次拿到的刷新令牌 |
client_id / client_secret | 同上 | 客户端认证方式与换取令牌时相同 |
响应格式同上,包含新的刷新令牌。旧刷新令牌立即失效;再次使用旧令牌会撤销整个授权,用户需要重新授权。因此:
- 同一个授权的刷新请求要串行执行;
- 收到响应后原子地替换访问令牌和刷新令牌;
- 刷新成功会把授权有效期顺延到 30 天后;
- 授予了
openid时,刷新响应也包含新的 ID Token,其auth_time仍是用户最初登录的时间,且不含nonce。
curl https://tokentosea.com/api/oauth/token \
-d grant_type=refresh_token \
-d refresh_token="$REFRESH_TOKEN" \
-d client_id="$CLIENT_ID" \
-d client_secret="$CLIENT_SECRET"令牌端点错误#
| 状态码 | 响应 | 原因 |
|---|---|---|
401 | {"error":"invalid_client"} | Client ID 不存在、应用已停用、密钥错误、公共客户端发送了密钥,或同时使用了 Basic 与请求体密钥 |
400 | {"error":"invalid_grant"} | 授权码无效、过期或已使用;redirect_uri 或 code_verifier 不匹配;刷新令牌无效、过期或已被使用;授权已撤销或过期;用户账号不可用 |
400 | {"error":"unsupported_grant_type"} | grant_type 不是 authorization_code 或 refresh_token |
授权码或刷新令牌被重复使用时,平台会撤销整个授权以防令牌泄露。
校验 ID Token#
ID Token 是 RS256 签名的 JWT。必须全部校验:
| 检查项 | 要求 |
|---|---|
| 签名 | 头部 alg 为 RS256,用 JWKS 中 kid 相同、use 为 sig 的公钥验证 |
iss | 等于 https://tokentosea.com/api/oauth |
aud | 等于你的 Client ID |
exp | 晚于当前时间(签发后 15 分钟内有效) |
iat | 不晚于当前时间(允许少量时钟偏差) |
nonce | 等于授权请求中发送的值 |
sub | 字符串,用户在 TokenToSea 的稳定标识 |
载荷示例:
{
"iss": "https://tokentosea.com/api/oauth",
"sub": "cm1…",
"aud": "你的 Client ID",
"exp": 1792484100,
"iat": 1792483200,
"auth_time": 1792483150,
"nonce": "…",
"at_hash": "…",
"name": "张三",
"preferred_username": "zhangsan"
}用 iss + sub 关联本地账号,不要用 email 作为账号的唯一依据。name、preferred_username 仅在授予 profile 时出现;email、email_verified 仅在授予 email 且账号有邮箱时出现。
JWKS#
GET /jwks
返回 { "keys": [{ "kty": "RSA", "n": "…", "e": "AQAB", "kid": "…", "use": "sig", "alg": "RS256" }] },响应可缓存 300 秒。遇到未知的 kid 时重新获取一次。
# pip install "PyJWT[crypto]"
import hmac
import jwt
ISSUER = "https://tokentosea.com/api/oauth"
jwks_client = jwt.PyJWKClient(ISSUER + "/jwks")
def verify_id_token(id_token: str, client_id: str, expected_nonce: str) -> dict:
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
claims = jwt.decode(id_token, signing_key.key, algorithms=["RS256"], audience=client_id, issuer=ISSUER)
if not hmac.compare_digest(claims.get("nonce", ""), expected_nonce):
raise ValueError("nonce mismatch")
return claimsimport { createPublicKey, timingSafeEqual, verify } from 'node:crypto';
const ISSUER = 'https://tokentosea.com/api/oauth';
const equal = (a, b) => typeof a === 'string' && typeof b === 'string' && a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
export async function verifyIdToken(token, clientId, nonce) {
const [head, body, signature] = token.split('.');
const header = JSON.parse(Buffer.from(head, 'base64url'));
if (header.alg !== 'RS256') throw new Error('Unsupported algorithm');
const { keys } = await (await fetch(`${ISSUER}/jwks`)).json(); // 生产环境请缓存
const jwk = keys.find((k) => k.kid === header.kid && k.use === 'sig' && k.alg === 'RS256');
if (!jwk || !verify('RSA-SHA256', Buffer.from(`${head}.${body}`), createPublicKey({ key: jwk, format: 'jwk' }), Buffer.from(signature, 'base64url'))) {
throw new Error('Invalid signature');
}
const claims = JSON.parse(Buffer.from(body, 'base64url'));
const now = Date.now() / 1000;
if (claims.iss !== ISSUER || claims.aud !== clientId || !(claims.exp > now) || !(claims.iat <= now + 30) || !equal(claims.nonce, nonce)) {
throw new Error('Invalid claims');
}
return claims;
}用户信息#
GET /userinfo
需要 Authorization: Bearer <访问令牌>,且授权包含 openid。
{ "sub": "cm1…", "name": "张三", "preferred_username": "zhangsan", "email": "user@example.com", "email_verified": true }令牌无效返回 401 {"error":"invalid_token"};缺少 openid 返回 401 {"error":"insufficient_scope","scope":"openid"}。
撤销#
POST /revoke
| 参数 | 说明 |
|---|---|
token | 访问令牌或刷新令牌 |
client_id / client_secret | 客户端认证,同令牌端点 |
撤销会终止该令牌所属的整个授权(包括其他已签发的令牌)。无论令牌是否存在都返回 200,客户端认证失败时返回 401 {"error":"invalid_client"}。用户退出你的应用、或要求解除绑定时调用它。
限流与跨域#
/authorize、/token、/revoke与授权页使用的请求查询接口,按来源 IP 合计每分钟最多 120 次,超出返回429 {"error":"temporarily_unavailable","error_description":"Too many authorization requests"}。/token、/revoke、/userinfo、/jwks、发现文档与/api/v1/connect/*返回Access-Control-Allow-Origin: *,允许的请求头为Authorization、Content-Type、Idempotency-Key。公共客户端(例如单页应用)可以在浏览器中完成 PKCE 流程;机密客户端的密钥只能放在服务端。
安全清单#
- 每次登录使用新的
state、nonce、code_verifier,只用一次; - 回调地址使用 HTTPS 并精确登记;
- 客户端密钥、刷新令牌只保存在服务端,加密存储,不写日志;
- 按ID Token 校验的全部条件验证后再建立会话;
- 刷新令牌串行使用、原子替换;
- 不再需要时调用
/revoke。