OAuth 2.0 的令牌内省端点(RFC 7662)允许资源服务器通过一个受保护请求验证令牌是否仍然有效,响应格式固定为 application/json。Rodauth OAuth 并没有在路由层随意拼写这段 JSON,而是把它封装在 Rodauth::OAuth::AccessTokenIntrospection::Response::Json 类中。这个类承担两个核心任务:一是根据令牌状态计算 active、scope、exp 等字段;二是把这些字段转换为符合规范且可扩展的 JSON 字符串。下面从它的定位、默认序列化流程、自定义扩展和测试方式几个角度展开。

一、内省端点的响应契约与 Json 类定位
RFC 7662 规定内省端点返回 application/json,至少包含 active 字段。active 必须是布尔值,表示令牌当前是否有效。当 active 为 true 时,可选字段包括 scope、client_id、username、token_type、exp、iat、nbf、sub、aud、iss、jti 等。Rodauth OAuth 为了兼容不同授权模式和令牌类型,把响应生成拆成多个类。Rodauth::OAuth::AccessTokenIntrospection::Response::Json 专门表达 JSON 格式的输出,与之相对的可能是响应基类或处理错误响应的类。这个类不关心传输层,只负责内存中的 Ruby 对象到 JSON 字符串的转换。
为什么需要单独一个 Json 类?如果直接在 Rodauth 路由块里写哈希然后调用 JSON.generate,很容易漏掉空值过滤,也难在多个 grant 之间复用。独立类带来两个直接好处:一是在测试中可以单独实例化并断言 JSON 结构;二是后续如果增加内省错误码或自定义 claim,只改一个类。Rodauth 的模块化设计通常用 response_class 之类的配置项选择实现,因此理解 Json 类的构造参数是理解整条内省链路的入口。
二、初始化参数与默认序列化逻辑
一般情况下,Response::Json 的 initialize 会接收内省结果对象或一组属性。常见示例可以抽象为:
class Json < Response
def initialize(token, **options)
super
@token = token
@options = options
end
def active?
!@token.revoked? && !@token.expired?
end
end
上面的代码不是完整 gem 源码,而是展示类的基本结构。Rodauth OAuth 会把 token 对象传入,内省响应类通过询问 token 状态计算 active,而不是在路由里提前算好。这样能避免一个经典错误:路由层先用 token.revoked? 判断一次,响应层又根据缓存结果判断一次,导致行为不一致。
to_json 的实现通常不是直接手拼字符串。更稳妥的做法是先构建一个哈希,再调用 to_json。伪代码如下:
def to_json(*args)
{
active: active?,
scope: token.scope,
client_id: token.client_id,
username: token.subject,
token_type: token.token_type,
exp: token.expires_at.to_i
}.reject { |_key, value| value.nil? }.to_json(*args)
end
这里有一个容易忽略的细节:exp 必须是整数时间戳。如果用的是 ActiveSupport::TimeWithZone 或 Time 对象,直接 to_json 可能输出带时区偏移的字符串。内省规范要求 exp、iat、nbf 都是 NumericDate,即自 Unix 纪元以来的秒数。因此序列化层必须负责类型转换,而不是把这个责任推给业务层。JSON 的布尔字段也不能用字符串表示,否则资源服务器按严格模式解析时可能直接拒绝。
三、扩展 Json 类:自定义字段与过滤规则
实际项目中,资源服务器可能要求除了 RFC 标准字段外,还要返回租户 ID、权限标识或 key 标识。直接改 gem 源码不可取,升级时容易冲突。可以利用 Ruby 的开放类或继承。推荐做法是在项目里定义子类:
class IntrospectionJsonResponse < Rodauth::OAuth::AccessTokenIntrospection::Response::Json
def to_hash
super.merge(
"tenant_id" => @token.tenant_id,
"scopes" => Array(@token.scope).join(" ")
)
end
def to_json(*args)
to_hash.to_json(*args)
end
end
然后在 Rodauth 配置中把该类注入。不同版本配置项可能不同,常见思路是通过 plugin 或 response_class 设置。如果项目中已经使用 rodauth-oauth 的 DSL,可以在启用 oauth_introspection 后加上:
plugin :rodauth do enable :oauth, :oauth_authorization_code_grant, :oauth_introspection oauth_response_class Rodauth::OAuth::AccessTokenIntrospection::Response::Json end
覆盖 to_hash 比覆盖 to_json 更安全,因为 to_hash 返回普通 Ruby 哈希,json 库会自动处理转义。不要在 to_json 里手动拼接字符串,例如用 "{\"active\":#{active?}}" 这种写法,遇到 scope 中包含引号或反斜杠会破坏 JSON。序列化层只应负责组织数据结构,编码交给标准库。
四、Content-Type 与测试断言
内省响应必须带 Content-Type: application/json;charset=UTF-8。Rodauth 的 response.finish 或 Rack 层一般会使用响应对象的 content_type。如果自定义 Json 类没有设置,测试中要特别检查。示例 RSpec:
it "returns RFC 7662 compatible introspection response" do
response = IntrospectionJsonResponse.new(token)
body = JSON.parse(response.to_json)
expect(body).to include("active" => true)
expect(body["exp"]).to be_a(Integer)
expect(response.content_type).to eq("application/json")
end
另一个常见问题是响应已经序列化成 JSON 字符串后,又被 Rodauth 的 view 层包了一层。比如在路由里写 r.halt [200, headers, [response.to_json]] 正常,但如果同时启用了某些 render 插件,可能对字符串再做一次 JSON 编码,导致返回体里出现转义的双引号。调试时可以用 Rack::MockRequest 打完整响应,而不是只看对象方法输出。
当令牌无效时,RFC 7662 允许响应只有 active: false,其他字段可以省略。默认 Json 类通常会拒绝 nil 值,这符合规范;但如果业务要求明确返回 "scope": null,就需要在 to_hash 中保留 nil 或使用固定键加默认值。这属于策略差异,最好在测试中固化,避免未来升级 gem 时默认行为改变导致业务方误判。
五、性能考虑与常见误区
内省端点可能被资源服务器高频调用,每次请求都做 JSON 序列化,性能取决于字段数量和嵌套结构。标准字段都较浅,压力通常不大。更需要注意的是不要在内省响应中嵌入完整的用户对象或权限树。序列化前应只查询必要字段。Rodauth 的 token 对象如果带 ORM 关联,访问未预加载的关联可能触发 N+1 查询。
另一个误区是缓存内省响应。如果令牌尚未过期,某些开发者会缓存 JSON 字符串以减轻数据库压力。但 OAuth 内省结果和令牌状态强相关,令牌可能在缓存期间被吊销。比较安全的做法是缓存令牌元数据,但 active 必须实时计算。Response::Json 类的设计允许 active? 作为方法独立求值,正好适配这种缓存策略。把序列化结果整体缓存则要非常谨慎。
Rodauth::OAuth::AccessTokenIntrospection::Response::Json 把内省结果的 JSON 序列化集中在一个可替换的类中,理解它的 to_hash、to_json 和初始化参数后,可以安全地扩展字段、规范时间戳、保持 Content-Type 正确。实际项目中优先使用子类和配置注入,而不是修改 gem 源码;测试要覆盖 active 布尔类型、exp 整数、空值字段和 Content-Type,这样内省端点在多服务间才能稳定工作。
Rodauth::OAuthAccessTokenIntrospectionJSON序列化修改时间:2026-09-19 20:47:40