地址
| 项目 | 地址 |
|---|---|
| issuer | https://sb.sb/oidc |
| 发现文档 | https://sb.sb/oidc/.well-known/openid-configuration(根路径 /.well-known/openid-configuration 返回相同内容) |
| authorize | https://sb.sb/oidc/authorize |
| token | https://sb.sb/oidc/token |
| userinfo | https://sb.sb/oidc/userinfo |
| JWKS | https://sb.sb/oidc/keys |
| revoke(RFC 7009) | https://sb.sb/oidc/revoke |
| introspect(RFC 7662) | https://sb.sb/oidc/introspect |
| 登出(RP-Initiated) | https://sb.sb/oidc/logout |
端点地址以发现文档为准。配置 issuer 后,应由 OIDC 库读取发现文档,避免硬编码端点地址。
issuer 一旦发布就不会变。
协议支持范围
- 仅支持授权码模式(
response_type=code),必须使用 PKCE,且只接受S256。plain会被拒绝,
没有code_challenge的公开客户端也会被拒绝。不支持 implicit、hybrid、密码模式、client_credentials、
设备码、动态注册。上述功能不在实现计划内。接入所用的 OIDC 库须支持 PKCE。 - 客户端认证:
client_secret_basic、client_secret_post,或公开客户端(none,必须 PKCE)。
不支持private_key_jwt。 - refresh token 仅在请求包含
offline_access时签发。每次刷新均签发新的 refresh token,
旧 token 立即失效;再次使用旧 token 将触发重放检测,并吊销该链上的全部 token。
refresh token 的空闲有效期为 7 天,绝对有效期为 30 天。 - 授权码 60 秒内一次性使用;重放会吊销该 code 换取的全部 token。
- 签名算法 ES256。JWKS 中会同时包含即将启用、正在使用和正在退役的公钥,应按
kid选择公钥,
并至少每天刷新一次 JWKS 缓存。
scope 与 claims
| scope | 返回的信息或授予的权限 |
|---|---|
openid | sub、iss、aud、exp、iat、auth_time、nonce、amr、acr、sid、azp |
profile | preferred_username、name、picture、profile、updated_at、locale、zoneinfo(仅在会员设置时区后返回) |
email | email、email_verified |
groups | groups(需要管理员授予) |
roles | roles(需要管理员授予) |
forum | level、level_name、growth,以及 userinfo 中的 points(已审核应用由管理员授予) |
points:spend | 调用扣费接口的资格,仅官方应用(见下文「等级、烧饼与扣费接口」) |
offline_access | refresh token |
论坛不提供 phone、address 数据,对应 scope 会被丢弃。请求中未经授予的 scope 也会被静默丢弃,
不返回错误。实际授予的权限以 token 响应中的 scope 字段为准。
claims 说明
| claim | 值 | 变化情况 |
|---|---|---|
sub | 用户的稳定标识(见下) | 不变,唯一可用作用户主键的 claim |
preferred_username / name | 论坛用户名 | 可变,用户可以改名;仅用于显示 |
picture | 头像原图地址(上传的文件本身、Gravatar 或内置头像),不是站内的缩略图 | 会变 |
profile | 论坛个人主页地址 | 会变(用户名是路径的一部分) |
updated_at | 资料最后修改时间 | — |
locale | zh-CN / zh-TW,会员在个性设置中选择的论坛语言;未设置时使用站点语言 zh-CN | 会变 |
zoneinfo | 会员在个性设置中选择的 IANA 时区,如 Asia/Tokyo;未设置时不返回 | 会变 |
email | 邮箱。用户未设置邮箱时不返回该 claim,不以空字符串表示 | 可变,用户可以更换邮箱 |
email_verified | 邮箱是否经过论坛验证,值与实际验证状态一致 | 会变 |
amr | 登录方式:["pwd"] 密码;["pwd","otp"] 密码 + 两步验证;["hwk","user"] Passkey;["ext"] 第三方登录;["ext","otp"] 第三方登录 + 两步验证;两步验证用 Passkey 代替验证码时以 "hwk","user" 结尾 | 每次登录不同 |
ext_provider | amr 含 ext 时的第三方登录提供方:google / github / x / telegram | — |
acr | urn:forum:loa:1 单因素;urn:forum:loa:2 含两步验证或 Passkey | 每次登录不同 |
sid | 本次论坛登录的会话 id,用于 back-channel 登出 | 每次登录不同 |
groups | 用户所在的组,稳定 slug(见下) | 用户组配置变更时变化 |
groups_truncated | ID token 中 groups 超过 20 个时为 true,完整列表通过 userinfo 获取 | — |
roles | 能力角色:super / admin / moderator 的子集 | 用户组配置变更时变化 |
level / level_name | 会员等级的序号和名称,依据论坛当前的等级阶梯 | 随成长值变化 |
growth | 成长值 | 会变 |
points | 可用烧饼。仅在 userinfo 和 introspection 中返回,不包含在 ID token 和 access token 中 | 实时变化,见下文 |
sub 是唯一稳定的用户标识。用户改名或更换邮箱后,sub 保持不变;注销后重新注册将生成新账号和新 sub。
使用 preferred_username 或 email 作为主键可能导致账号关联丢失或错误合并。
sub 是什么
sub 的形式取决于应用的信任等级(见下一节):
- 官方应用(
first_party):sub是论坛用户 id 的十进制字符串(public类型)。 - 第三方应用(
verified/unverified):sub采用 pairwise 类型。同一用户在同一应用中的sub保持不变,在不同应用中的值不同,无法据此跨应用关联用户。该值由回调地址所在的主域名(eTLD+1)派生,
所以一个应用的所有回调地址必须在同一个主域名下。
groups 与 roles
论坛用户组没有 slug,且显示名可修改,因此 claim 使用以下稳定标识:
| slug | 含义 |
|---|---|
group:<id> | 用户所在的论坛用户组,按数字 id |
admin | 组有后台权限(或超级管理员) |
moderator | 组有管理权限 |
super | 超级管理员 |
verified | 账号具有「认证」标记 |
roles 仅包含三个能力角色:super、admin、moderator。
管理员为每个客户端配置 visible_groups 白名单,claim 仅返回白名单与用户实际所属组的交集。
白名单为空时,不返回任何组信息。申请 groups scope 后若返回空数组,应向管理员确认白名单配置。
等级、烧饼与扣费接口
应用如需根据会员等级或成长值确定服务范围,应申请 forum scope。ID token 包含 level、level_name、growth,
可直接用于判断服务条件,无需额外请求。烧饼余额实时变化,因此 points 不包含在 token 中,
需通过 userinfo 或下述读取接口实时获取。
烧饼账本由论坛统一管理,接入方不应独立维护会员余额。显示余额和扣费均须调用论坛接口;扣费接口实时校验余额,
并返回扣费后的余额。接入方缓存以论坛数据为准。任何应用均不能修改成长值。
以下三个接口均使用会员授权的 access token(Authorization: Bearer …),请求体和响应体采用 JSON 格式:
| 方法 | 地址 | 需要的 scope | 说明 |
|---|---|---|---|
GET | /oidc/api/points | forum | 实时的 level / level_name / growth / points |
POST | /oidc/api/points/charge | points:spend | 扣费,见下 |
POST | /oidc/api/points/refund | points:spend | 按幂等键原路退回一笔扣费 |
points:spend 仅授予官方应用(first_party)。管理员须在后台配置单笔扣费上限,
以及对同一会员的每日累计扣费上限(按 UTC 计日)。上限为 0 时,扣费接口返回 spend_not_configured。
授权页单独列出「允许该应用扣除你的烧饼」,会员可随时在「已授权应用」中撤销授权。
扣费请求:
{ "amount": 30, "reason": "开通高级功能", "key": "order-20260907-001", "reference": "订单号,可选" }
key是幂等键(1–64 位,字母数字和_ . : -),按应用隔离。使用相同key重复请求时,
返回相同的余额,charged为false,不重复扣费。请求超时后可使用原key重试。reason必填(≤100 字),会原样记录在会员的烧饼明细中,并以应用名称作为前缀。- 成功返回
200:{ "charged": true, "amount": 30, "key": "…", "points": 70, "growth": 40, "level": 3, "level_name": "…" }。 - 拒绝时的
error:charge_limit(超单笔上限,附max_per_charge)、daily_limit(超每日上限,附max_per_day
和spent_today,退款会释放额度)、insufficient_points(余额不足,409,附当前points)、insufficient_scope(403)、invalid_token(401)、rate_limited(429)。
退款请求为 { "key": "order-20260907-001" },按指定幂等键全额退回对应扣费。成功时返回 refunded 和退款后的 points;
使用相同 key 重复请求不会重复退款;key 不存在时返回 404 unknown_charge。
每笔扣费和退款均记录在会员的烧饼明细中。管理员可在应用详情页查看该应用的全部扣费记录,会员可在
「已授权应用」中查看各应用的累计扣费金额。
信任等级
| 等级 | 创建与审核 | sub | access token | 可申请的 scope | 可授权用户 |
|---|---|---|---|---|---|
first_party 官方 | 管理员 | public | JWT 或 opaque | 全部 | 所有会员 |
verified 已审核 | 会员创建,管理员审核通过 | pairwise | opaque | openid profile email + 管理员单独授予的 groups / roles | 所有会员 |
unverified 未审核 | 会员自助创建 | pairwise | opaque | 只有 openid profile(email 须通过审核后申请) | 只有开发者本人和最多 10 个测试用户 |
未审核应用的授权页显示黄色提示条,注明应用尚未通过站方审核。
access token:JWT 还是 opaque
- 官方应用可配置为 JWT access token,包含
sub、aud、scope、sid、groups(受白名单约束)等,
资源服务器可使用 JWKS 验证签名,无需每次调用 userinfo。 - 第三方应用的 access token 均为 opaque 字符串,以支持即时吊销;已签发的 JWT 无法在本地验签时即时撤回。
获取 opaque token 后,应调用/oidc/userinfo,或使用本应用的 client 凭证调用/oidc/introspect(仅能验证签发给本应用的 token)。应用被封禁后,签发给该应用的 token 立即失效:
userinfo 返回 403,introspect 不再返回active: true,token 端点拒绝该 client 的请求。
登出
登出涉及论坛、接入系统和用户浏览器的登录状态。接入方需实现以下两种登出流程。
RP-Initiated Logout(用户在接入系统登出)
将用户重定向至:
GET https://sb.sb/oidc/logout
?id_token_hint=<上次拿到的 id_token>
&post_logout_redirect_uri=<登出后回到哪>
&state=<随便什么,原样带回>
post_logout_redirect_uri必须与后台登记的完全一致,否则返回 400,不执行登出操作。- 论坛结束该用户在当前应用的登录状态(吊销该应用持有的 token,并将该应用移出用户的 SSO 会话),然后以 302 重定向至指定地址,并携带
state。
官方应用和第三方应用一样,没有确认页。 - 此操作不会结束用户在论坛的登录状态,与退出使用 Google 登录的网站不会退出 Google 相同。用户下次选择「用论坛账号登录」时,
已授权应用可直接完成登录。如需结束论坛会话,用户须在论坛登出,接入方随后会收到 Back-Channel Logout 通知。
Back-Channel Logout(同步论坛登出状态)
在后台为应用配置 backchannel_logout_uri(必须 https;开发时允许 http://localhost)。
用户退出、退出所有设备、修改密码、被封禁、注销账号或会话过期时,
论坛均会向该地址发送 POST 表单请求:
POST <backchannel_logout_uri>
Content-Type: application/x-www-form-urlencoded
logout_token=<JWT>
logout_token 是 ES256 签名的 JWT,头部 typ: logout+jwt,claims:
{
"iss": "https://sb.sb/oidc",
"sub": "<和 id_token 里一样的 sub>",
"aud": "<你的 client_id>",
"iat": 1757000000,
"exp": 1757000120,
"jti": "<随机>",
"sid": "<和 id_token 里一样的 sid>",
"events": { "http://schemas.openid.net/event/backchannel-logout": {} }
}
根据规范,logout_token 不包含 nonce;接收方须拒绝包含 nonce 的请求。使用 JWKS 验证签名,并核对 iss、aud、events,
然后按 sid(优先)或 sub 结束对应的本地会话,返回 200。论坛请求超时为 5 秒,失败后分别于 1 秒、5 秒、30 秒重试一次;
非 200 响应均视为失败。该请求由服务器直接发送,处理过程不得依赖浏览器 cookie。
限流
| 端点 | 官方 | 已审核 | 未审核 |
|---|---|---|---|
/oidc/token | 不限 | 300 / 分钟 | 30 / 分钟 |
/oidc/userinfo | 不限 | 600 / 分钟 | 60 / 分钟 |
/oidc/introspect | 不限 | 600 / 分钟 | 60 / 分钟 |
/oidc/authorize | 每个 IP 60 / 分钟 | 同左 | 同左 |
超过限额时返回 429 + {"error":"slow_down"}。每个用户每小时最多完成 20 次授权。
错误
错误响应采用 RFC 6749 §5.2 / OIDC Core 3.1.2.6 定义的 error 码,不包含内部实现细节:
| 错误或响应 | 含义 |
|---|---|
invalid_request(authorize,重定向至应用的 redirect_uri) | 未提供 PKCE、方法不是 S256 或参数缺失 |
| 返回 400 页面,不重定向 | redirect_uri 未登记、client_id 不存在或已封禁;为防止开放重定向,论坛不执行跳转 |
login_required | prompt=none 但用户未登录 |
consent_required | prompt=none 但用户尚未授权该应用 |
access_denied | 用户取消授权,或未审核应用被非测试用户访问 |
unauthorized_client | 应用被禁用 / 封禁 |
invalid_grant(token) | code 无效、过期或被重放;refresh token 已失效或被重放 |
invalid_client(token / introspect) | secret 错误,或应用已被封禁 |
slow_down(429) | 限流 |
Go 接入示例(zitadel/oidc)
package main
import (
"context"
"log"
"net/http"
"github.com/zitadel/oidc/v3/pkg/client/rp"
httphelper "github.com/zitadel/oidc/v3/pkg/http"
"github.com/zitadel/oidc/v3/pkg/oidc"
)
func main() {
ctx := context.Background()
issuer := "https://sb.sb/oidc"
clientID, clientSecret := "…", "…" // 后台创建应用时给的;公开客户端 secret 留空
redirectURI := "https://app.example.com/auth/callback"
// PKCE 通过 cookie 保存 verifier;密钥自己生成、妥善保管。
cookies := httphelper.NewCookieHandler([]byte("32-byte-hash-key-……………………"), []byte("16-byte-enc-key…"))
relying, err := rp.NewRelyingPartyOIDC(ctx, issuer, clientID, clientSecret, redirectURI,
[]string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
rp.WithPKCE(cookies), rp.WithCookieHandler(cookies))
if err != nil {
log.Fatal(err)
}
state := func() string { return "随机字符串,每次不同" }
http.Handle("/auth/login", rp.AuthURLHandler(state, relying))
http.Handle("/auth/callback", rp.CodeExchangeHandler(
rp.UserinfoCallback(func(w http.ResponseWriter, r *http.Request,
tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, relying rp.RelyingParty, info *oidc.UserInfo) {
// tokens.IDTokenClaims.Subject 是唯一稳定的主键;
// info.PreferredUsername / info.Email 只拿来显示,下次登录时刷新。
log.Printf("sub=%s name=%s email=%s verified=%v",
info.Subject, info.PreferredUsername, info.Email, info.EmailVerified)
// 保存 tokens.IDToken:登出时作为 id_token_hint;保存 tokens.IDTokenClaims.SessionID:
// back-channel 登出时按它找会话。
}), relying))
log.Fatal(http.ListenAndServe(":8080", nil))
}
登出时:
u, err := rp.EndSession(ctx, relying, savedIDToken, "https://app.example.com/bye", state, "", nil)
// 302 到 u;论坛处理完会回到 post_logout_redirect_uri 并带上 state
通用 OIDC 配置(其它语言 / 现成软件)
支持标准 OIDC 和 PKCE 的软件可参考以下配置:
issuer / discovery URL : https://sb.sb/oidc
client_id : <后台给的>
client_secret : <后台给的;公开客户端不填>
scopes : openid profile email (要 refresh token 加 offline_access)
response_type : code
code_challenge_method : S256 (必须)
token_endpoint_auth : client_secret_basic 或 client_secret_post
username claim : preferred_username (只用于显示)
unique id claim : sub (用户主键)
email claim : email (核对 email_verified)
groups claim : groups (需要管理员授予 scope 并配置白名单)
end_session_endpoint : https://sb.sb/oidc/logout
常见软件:oauth2-proxy 用 --provider=oidc --oidc-issuer-url=https://sb.sb/oidc --code-challenge-method=S256;
Grafana 的 [auth.generic_oauth] 中 use_pkce = true,auth_url/token_url/api_url 使用发现文档中的值,login_attribute_path = preferred_username,role_attribute_path 可以用 contains(roles[*], 'admin') && 'Admin' || 'Viewer';
Nextcloud 的 user_oidc 配置 discovery URL。
第三方应用注册与审核
- 论坛账号须满足以下条件:邮箱已验证、注册满 30 天、未被禁言禁访、等级达到站点要求(默认 3 级;管理员和版主不受等级限制)、
应用数未超配额(默认 5 个)。 - 在 个人设置 → 开发者应用(
/settings/developer/)创建应用。勾选同意开发者条款(/pages/developer-terms/)。client_secret只显示一次。 - 新建应用处于 未审核 状态,仅限开发者本人和最多 10 个测试用户登录,可申请的 scope 限于
openid profile。在此阶段完成接入调试。 - 调试完成后 提交审核,说明应用用途、接入论坛账号的原因及所需权限。管理员审核通过后,应用状态变为 已审核,
所有会员都可以登录,并可申请email;groups/roles须在申请理由中说明,由管理员单独授予。 - 审核通过后,修改应用名称、图标或回调地址会使应用恢复待审核状态;已有用户的授权和 token 不受影响。
- 应用页的数据看板显示授权用户数、token 签发量、userinfo 调用量、最近的协议错误。看板仅提供统计数据,不提供用户列表。
- 用户可以在 个人设置 → 已授权的应用 中随时撤销应用授权;撤销后,该用户的 token 立即失效。
已配置 back-channel 地址的应用会收到通知。用户也可举报应用,举报数量达到阈值后,应用将被自动封禁。
面向用户的授权说明见 /pages/oauth-apps/。