地址

项目地址
issuerhttps://sb.sb/oidc
发现文档https://sb.sb/oidc/.well-known/openid-configuration(根路径 /.well-known/openid-configuration 返回相同内容)
authorizehttps://sb.sb/oidc/authorize
tokenhttps://sb.sb/oidc/token
userinfohttps://sb.sb/oidc/userinfo
JWKShttps://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,且只接受 S256plain 会被拒绝,
    没有 code_challenge 的公开客户端也会被拒绝。不支持 implicit、hybrid、密码模式、client_credentials、
    设备码、动态注册。上述功能不在实现计划内。接入所用的 OIDC 库须支持 PKCE。
  • 客户端认证:client_secret_basicclient_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返回的信息或授予的权限
openidsubissaudexpiatauth_timenonceamracrsidazp
profilepreferred_usernamenamepictureprofileupdated_atlocalezoneinfo(仅在会员设置时区后返回)
emailemailemail_verified
groupsgroups(需要管理员授予)
rolesroles(需要管理员授予)
forumlevellevel_namegrowth,以及 userinfo 中的 points(已审核应用由管理员授予)
points:spend调用扣费接口的资格,仅官方应用(见下文「等级、烧饼与扣费接口」)
offline_accessrefresh token

论坛不提供 phoneaddress 数据,对应 scope 会被丢弃。请求中未经授予的 scope 也会被静默丢弃,
不返回错误。实际授予的权限以 token 响应中的 scope 字段为准。

claims 说明

claim变化情况
sub用户的稳定标识(见下)不变,唯一可用作用户主键的 claim
preferred_username / name论坛用户名可变,用户可以改名;仅用于显示
picture头像原图地址(上传的文件本身、Gravatar 或内置头像),不是站内的缩略图会变
profile论坛个人主页地址会变(用户名是路径的一部分)
updated_at资料最后修改时间
localezh-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_provideramrext 时的第三方登录提供方:google / github / x / telegram
acrurn:forum:loa:1 单因素;urn:forum:loa:2 含两步验证或 Passkey每次登录不同
sid本次论坛登录的会话 id,用于 back-channel 登出每次登录不同
groups用户所在的组,稳定 slug(见下)用户组配置变更时变化
groups_truncatedID token 中 groups 超过 20 个时为 true,完整列表通过 userinfo 获取
roles能力角色:super / admin / moderator 的子集用户组配置变更时变化
level / level_name会员等级的序号和名称,依据论坛当前的等级阶梯随成长值变化
growth成长值会变
points可用烧饼。仅在 userinfo 和 introspection 中返回,不包含在 ID token 和 access token 中实时变化,见下文

sub 是唯一稳定的用户标识。用户改名或更换邮箱后,sub 保持不变;注销后重新注册将生成新账号和新 sub
使用 preferred_usernameemail 作为主键可能导致账号关联丢失或错误合并。

sub 是什么

sub 的形式取决于应用的信任等级(见下一节):

  • 官方应用first_party):sub 是论坛用户 id 的十进制字符串(public 类型)。
  • 第三方应用verified / unverified):sub 采用 pairwise 类型。同一用户在同一应用中的
    sub 保持不变,在不同应用中的值不同,无法据此跨应用关联用户。该值由回调地址所在的主域名(eTLD+1)派生,
    所以一个应用的所有回调地址必须在同一个主域名下。

groupsroles

论坛用户组没有 slug,且显示名可修改,因此 claim 使用以下稳定标识:

slug含义
group:<id>用户所在的论坛用户组,按数字 id
admin组有后台权限(或超级管理员)
moderator组有管理权限
super超级管理员
verified账号具有「认证」标记

roles 仅包含三个能力角色:superadminmoderator

管理员为每个客户端配置 visible_groups 白名单,claim 仅返回白名单与用户实际所属组的交集。
白名单为空时,不返回任何组信息。申请 groups scope 后若返回空数组,应向管理员确认白名单配置。

等级、烧饼与扣费接口

应用如需根据会员等级或成长值确定服务范围,应申请 forum scope。ID token 包含 levellevel_namegrowth
可直接用于判断服务条件,无需额外请求。烧饼余额实时变化,因此 points 不包含在 token 中,
需通过 userinfo 或下述读取接口实时获取。

烧饼账本由论坛统一管理,接入方不应独立维护会员余额。显示余额和扣费均须调用论坛接口;扣费接口实时校验余额,
并返回扣费后的余额。接入方缓存以论坛数据为准。任何应用均不能修改成长值。

以下三个接口均使用会员授权的 access token(Authorization: Bearer …),请求体和响应体采用 JSON 格式:

方法地址需要的 scope说明
GET/oidc/api/pointsforum实时的 level / level_name / growth / points
POST/oidc/api/points/chargepoints:spend扣费,见下
POST/oidc/api/points/refundpoints:spend按幂等键原路退回一笔扣费

points:spend 仅授予官方应用(first_party)。管理员须在后台配置单笔扣费上限,
以及对同一会员的每日累计扣费上限(按 UTC 计日)。上限为 0 时,扣费接口返回 spend_not_configured
授权页单独列出「允许该应用扣除你的烧饼」,会员可随时在「已授权应用」中撤销授权。

扣费请求:

{ "amount": 30, "reason": "开通高级功能", "key": "order-20260907-001", "reference": "订单号,可选" }
  • key 是幂等键(1–64 位,字母数字和 _ . : -),按应用隔离。使用相同 key 重复请求时,
    返回相同的余额,chargedfalse,不重复扣费。请求超时后可使用原 key 重试。
  • reason 必填(≤100 字),会原样记录在会员的烧饼明细中,并以应用名称作为前缀。
  • 成功返回 200{ "charged": true, "amount": 30, "key": "…", "points": 70, "growth": 40, "level": 3, "level_name": "…" }
  • 拒绝时的 errorcharge_limit(超单笔上限,附 max_per_charge)、daily_limit(超每日上限,附 max_per_day
    spent_today,退款会释放额度)、insufficient_points(余额不足,409,附当前 points)、
    insufficient_scope403)、invalid_token401)、rate_limited429)。

退款请求为 { "key": "order-20260907-001" },按指定幂等键全额退回对应扣费。成功时返回 refunded 和退款后的 points
使用相同 key 重复请求不会重复退款;key 不存在时返回 404 unknown_charge

每笔扣费和退款均记录在会员的烧饼明细中。管理员可在应用详情页查看该应用的全部扣费记录,会员可在
「已授权应用」中查看各应用的累计扣费金额。

信任等级

等级创建与审核subaccess token可申请的 scope可授权用户
first_party 官方管理员publicJWT 或 opaque全部所有会员
verified 已审核会员创建,管理员审核通过pairwiseopaqueopenid profile email + 管理员单独授予的 groups / roles所有会员
unverified 未审核会员自助创建pairwiseopaque只有 openid profileemail 须通过审核后申请)只有开发者本人和最多 10 个测试用户

未审核应用的授权页显示黄色提示条,注明应用尚未通过站方审核。

access token:JWT 还是 opaque

  • 官方应用可配置为 JWT access token,包含 subaudscopesidgroups(受白名单约束)等,
    资源服务器可使用 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 验证签名,并核对 issaudevents
然后按 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_requiredprompt=none 但用户未登录
consent_requiredprompt=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 = trueauth_url/token_url/api_url 使用发现文档中的值,
login_attribute_path = preferred_usernamerole_attribute_path 可以用 contains(roles[*], 'admin') && 'Admin' || 'Viewer'
Nextcloud 的 user_oidc 配置 discovery URL。

第三方应用注册与审核

  1. 论坛账号须满足以下条件:邮箱已验证、注册满 30 天、未被禁言禁访、等级达到站点要求(默认 3 级;管理员和版主不受等级限制)、
    应用数未超配额(默认 5 个)。
  2. 个人设置 → 开发者应用/settings/developer/)创建应用。勾选同意开发者条款(/pages/developer-terms/)。
    client_secret 只显示一次。
  3. 新建应用处于 未审核 状态,仅限开发者本人和最多 10 个测试用户登录,可申请的 scope 限于 openid profile。在此阶段完成接入调试。
  4. 调试完成后 提交审核,说明应用用途、接入论坛账号的原因及所需权限。管理员审核通过后,应用状态变为 已审核
    所有会员都可以登录,并可申请 emailgroups / roles 须在申请理由中说明,由管理员单独授予。
  5. 审核通过后,修改应用名称、图标或回调地址会使应用恢复待审核状态;已有用户的授权和 token 不受影响。
  6. 应用页的数据看板显示授权用户数、token 签发量、userinfo 调用量、最近的协议错误。看板仅提供统计数据,不提供用户列表。
  7. 用户可以在 个人设置 → 已授权的应用 中随时撤销应用授权;撤销后,该用户的 token 立即失效。
    已配置 back-channel 地址的应用会收到通知。用户也可举报应用,举报数量达到阈值后,应用将被自动封禁。

面向用户的授权说明见 /pages/oauth-apps/