doc/api/a2a-oauth-rs.md
让 n9e 的 a2a / mcp agent 端点接受外置企业 IdP(Keycloak / Entra ID / Okta / Auth0 等)签发的 OAuth access token, 作为「按用户」的凭证。各类 Agent 编排平台代表某个具体用户调用 n9e 时,携带该用户的 access token, n9e 验 token 后把调用落到对应的本地用户——审计按人留痕、权限与该用户一致,不再共享一个机器人身份。
该档与现有 X-User-Token(PAT)、自签 session JWT 并列,满足任一即放行;默认关闭,开启后不影响原有两条鉴权路径。
[HTTP.RSAuth].Provider,复用对应的 SSO 登录配置,不必再配第二个授权服务器):
oidc(默认):复用 OIDC 登录配置指向的 IdP,借其 issuer 与 JWKS 在本地验签 JWT access token。oauth2:复用 OAuth2 登录配置指向的 IdP,校验 opaque access token(校验方式见 2.4 的 RSVerifyMethod)。Authorization: Bearer <token>。n9e 自签 session JWT 不带 iss,且本身是 JWT:
oidc provider:只有携带 iss 的 JWT 才走 RS 校验;oauth2 provider:外部 token 是 opaque,故非 JWT 的 Bearer token 才走 RS 校验。
两种情况下自签 session JWT 都仍走原路径,回归不破。oidc provider,任一不过即 401):
aud 必须包含配置的 Audience(绑定本服务,防止 IdP 发给其它应用的 token 被重放);exp。
(oauth2 provider 的校验内容与是否校验 aud 取决于 RSVerifyMethod,见 2.4。)Attributes.Username,默认 sub,可改 preferred_username),映射到本地用户。oidc:Belong=oidc、角色用 OIDC DefaultRoles、团队用 OIDC DefaultTeams;oauth2:Belong=oauth2、角色用 OAuth2 DefaultRoles(OAuth2 配置无默认团队)。已存在的用户不会重复创建。对接 IdP 需要改两处:① n9e 配置文件里的 [HTTP.RSAuth] 开关;② OIDC 登录配置(指定受信 IdP)。
etc/config.toml:[HTTP.RSAuth][HTTP.RSAuth]
# 总开关。true 时 a2a/mcp 等走 tokenAuth 的端点开始接受外置 IdP 的 OAuth access token
Enable = true
# 本服务的资源标识;access token 的 aud 必须包含它。Enable=true 时必填,留空则 RS 校验不生效
Audience = "n9e-a2a-rs"
# 受信 IdP 协议:oidc(默认,JWT 经 JWKS 本地验签)或 oauth2(opaque token,校验方式见 2.4)
Provider = "oidc"
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| Enable | bool | false | RS 校验总开关。关闭时整条分支跳过,行为与现状完全一致 |
| Audience | string | "" | 本服务资源标识。空值时 RS 不生效。注意:仅 oidc 与 oauth2+introspect 真正校验 aud;oauth2+userinfo(oauth2 默认)不校验 aud(见 2.4) |
| Provider | string | "oidc" | 受信 IdP 协议。oidc=复用 OIDC 登录、本地 JWKS 验签 JWT;oauth2=复用 OAuth2 登录、校验 opaque token(见 2.4) |
配置结构定义见 pkg/httpx/httpx.go 的 RSAuth。改 config.toml 后需重启 center 生效。
Provider=oidc):指定受信 IdPRS 复用 OIDC 配置里的 IdP,所以必须先把 OIDC 配好且 Enable = true(否则 RS 拿不到 provider/JWKS,不生效)。
OIDC 配置存在数据库 sso_config 表,通过 Web UI(系统设置 → 单点登录 → OIDC) 或接口 PUT /api/n9e/sso-config 维护,不在 config.toml 里。
与 RS 相关的关键字段:
Enable = true
# IdP 的 issuer 根地址;n9e 据此拉 <SsoAddr>/.well-known/openid-configuration 与 JWKS
SsoAddr = 'https://idp.example.com/realms/yourrealm'
ClientId = '<oidc-client-id>'
ClientSecret = '<oidc-client-secret>'
DefaultRoles = ['Standard'] # JIT 建用户时赋的默认角色
DefaultTeams = [2] # JIT 建用户时加入的默认团队 id(可空)
[Attributes]
# RS 从 access token 取哪个 claim 当用户名;Keycloak 常用 preferred_username
Username = 'preferred_username'
Nickname = 'name'
Email = 'email'
说明:RS 校验 audience 用的是
[HTTP.RSAuth].Audience,不是 OIDC 的ClientId——两者通常不同。 OIDC 的ClientId/Secret仅用于交互式登录流程;RS 只需要SsoAddr(拿 issuer/JWKS)、Attributes.Username、DefaultRoles、DefaultTeams。
aud多数 IdP 默认不会把你的资源标识写进 access token 的 aud,需要显式配置一个 audience:
Included Custom Audience 填 n9e-a2a-rs,勾选 Add to access token。audience=n9e-a2a-rs(在 API 中注册该 Identifier)。aud 为该值,Audience 填成对应值。另外确保:
Provider=oidc 时)IdP 签发的须是 JWT access token;若 IdP 只发 opaque token,请改用 Provider=oauth2(见 2.4)。NO_PROXY/no_proxy,否则拉 JWKS/discovery 会失败。Provider=oauth2):校验 opaque tokenIdP 只签发 opaque(非 JWT)access token 时用此 provider。RS 复用 OAuth2 登录配置(系统设置 → 单点登录 → OAuth2,存 sso_config 表),所以须先把 OAuth2 配好且 Enable = true。与 RS 相关的字段:
Enable = true
SsoAddr = 'https://sso.example.com/oauth2/authorize' # 作为 RFC 9728 的 authorization_servers 广告出去
UserInfoAddr = 'https://api.example.com/api/v1/user/info'
ClientId = '<client-id>'
ClientSecret = '<client-secret>' # introspect 模式向内省端点做 Basic Auth 用
DefaultRoles = ['Standard'] # JIT 建用户的默认角色(OAuth2 无默认团队)
# 校验方式:留空(默认)/userinfo,或 introspect
RSVerifyMethod = ''
IntrospectAddr = '' # RSVerifyMethod=introspect 时必填(RFC 7662 内省端点)
IntrospectCacheSeconds = 60 # 正向结果按 token 哈希缓存秒数(introspect 再以 token exp 封顶),0 不缓存
[Attributes]
Username = 'sub'
RSVerifyMethod 两种校验:
| 取值 | 校验方式 | 是否校验 aud | 适用 |
|---|---|---|---|
''(默认)/ userinfo | 拿 token 调 UserInfoAddr,成功即视为 token 有效 | 否 —— UserInfo 响应不含 aud,同一 IdP 下任意有效 token 都被接受 | 对接最省事(多数 OAuth2 server 都有 UserInfo);安全要求不高时用 |
introspect | RFC 7662 内省(IntrospectAddr,带 ClientId/Secret Basic Auth),校验 active 与 aud | 是 —— aud 须含 Audience,否则 401 | 有安全要求时用 |
⚠️ 安全提示:
userinfo是 oauth2 的默认模式,它不校验 audience——即使你配了[HTTP.RSAuth].Audience,该值在此模式下仅用于 RFC 9728 元数据广告,不参与放行判定。若同一 IdP 还给别的应用发 token,这些 token 也会被 n9e 接受。需要 audience 绑定时务必把RSVerifyMethod切到introspect。 启动日志会对 userinfo 模式打印对应 warning。
aud 含 n9e-a2a-rs;确认用户名落在 preferred_username。[HTTP.RSAuth] Enable = true、Audience = "n9e-a2a-rs",重启 center。Enable=true,SsoAddr 指向 Keycloak realm,填 ClientId/Secret,Attributes.Username = preferred_username,按需设 DefaultRoles / DefaultTeams。保存(约 9s 内热加载生效)。[A2A] done ... user=<该用户> / [MCP] done ... user=<该用户>;若该用户原本不存在,用户管理页会新出现该用户(默认角色/团队)。# 1) 从 IdP 取该用户的 access token(Keycloak 密码模式示例)
TOKEN=$(curl -s --noproxy '*' \
-d 'grant_type=password' -d 'client_id=<client>' -d 'client_secret=<secret>' \
-d 'username=carol' -d 'password=<pwd>' -d 'scope=openid' \
'https://idp.example.com/realms/yourrealm/protocol/openid-connect/token' | jq -r .access_token)
# 2) 调 MCP(合法 token → 200,返回 result.tools)
curl -s --noproxy '*' -X POST http://127.0.0.1:17000/mcp \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# 3) 反例:aud 不符 / 过期 / 改坏签名 / iss 不符 → 一律 401 unauthorized
Enable=false 时 RS 分支整体跳过,OAuth token 不再被接受,其余鉴权与现状完全一致。X-User-Token、自签 JWT 行为不变;三档满足任一即放行。/a2a /mcp 端点被受理。这两个 group 在 tokenAuth 之前装了 agentOAuthScope 标记,tokenAuth 仅在带标记时才走 RS/内建 AS 分支;其余走 tokenAuth 的接口(/api/n9e/* 管理 API)看不到标记,该 token 会落到 session-JWT 校验并以 401 结束。这样一个为 agent 签发的 token 不能被拿去调用其它接口(最小权限)。X-User-Token(PAT) 与浏览器 session JWT 不受此限制,行为不变。/.well-known/oauth-protected-resource、在 AgentCard 增加 oidc 档,并在 /a2a /mcp 的 401 响应带上 WWW-Authenticate: Bearer resource_metadata="…" 头——三处发现入口齐备,MCP 客户端可零配置自动发现受信 IdP。为减少调用方手工配置,RS 启用时(rsAuthEnabled)会主动暴露三处「发现」信息,让支持 OAuth 的客户端自动找到受信 IdP:
AgentCard 增加 oidc 档(A2A 客户端用,仅 Provider=oidc 时):GET /.well-known/agent-card.json 的 securitySchemes 在原有 x-user-token 之外增加一档 oidc(type=openIdConnect,openIdConnectUrl 指向 IdP 的 …/.well-known/openid-configuration),并加入 security 数组——两档满足任一即可,A2A 客户端据此自动选择走 OAuth。Provider=oauth2 时纯 OAuth2 IdP 无 OIDC discovery 文档,AgentCard 不增加该档(只保留 x-user-token)。
RFC 9728 资源元数据端点(OAuth/MCP 客户端用):GET /.well-known/oauth-protected-resource(公开、无需鉴权)返回:
{
"resource": "n9e-a2a-rs",
"authorization_servers": ["https://idp.example.com/realms/yourrealm"],
"bearer_methods_supported": ["header"]
}
resource = [HTTP.RSAuth].Audience(建议配成 https URL 以完全契合 RFC 9728),authorization_servers = 受信 provider 的 SsoAddr(oidc 取 OIDC、oauth2 取 OAuth2)。RS 未启用时该端点返回 404,不广告任何东西。该端点另注册了带路径后缀的别名 /.well-known/oauth-protected-resource/a2a、/.well-known/oauth-protected-resource/mcp(RFC 9728 的 well-known-URI 插入式路径,部分 MCP 客户端按连接的端点推导),内容与根路径一致。
401 WWW-Authenticate 发现头(②):RS 启用时,/a2a /mcp 端点的 401 响应带上 WWW-Authenticate: Bearer resource_metadata="<base>/.well-known/oauth-protected-resource"(已带 Bearer 但校验失败时追加 error="invalid_token")。这正是 MCP 客户端(ChatGPT/Claude connector)标准自动发现的入口:无 token 调用 → 收到 401 + 指针 → 拉资源元数据 → 找到受信 IdP → 走 OAuth,无需手动配置 IdP 与 audience。base 优先取 [HTTP.A2A].BaseURL,否则按请求的 Host + X-Forwarded-Proto 推导(与 AgentCard 同源)。
该头仅挂在 /a2a /mcp 上(由 rsAuthChallenge 中间件实现),共享 tokenAuth 的其它 API(如浏览器 session JWT 登录流)的 401 不带该头,保持原状。RS 未启用时同样不带。
说明:AgentCard 的
oidc档与资源元数据端点一样每次请求实时计算——运行时启用 RS/OIDC 或更换 IdP 后,下一次拉取 AgentCard 即生效,无需重启 center。
| 现象 | 可能原因 |
|---|---|
| OAuth token 一律 401 | RSAuth.Enable=false / Audience 空 / 对应 provider 未启用(OIDC 或 OAuth2 Enable=false)/ oauth2+introspect 缺 IntrospectAddr / oauth2+userinfo 缺 UserInfoAddr;启动日志会打印对应 warning |
| 合法 token 仍 401 | (oidc)aud 不含 Audience、iss 与 SsoAddr issuer 不一致、token 过期、拉不到 JWKS;(oauth2 introspect)active=false、aud 不含 Audience、内省端点不通或 Basic Auth 失败;(oauth2 userinfo)UserInfo 返回非 200 |
| 建了用户但用户名不对 | 对应登录配置 Attributes.Username 取错 claim(如该用 preferred_username 却配了 sub) |
| 未自动建用户/没进团队 | DefaultRoles / DefaultTeams 未配(OAuth2 无默认团队) |
开启 debug 日志可看到校验失败原因:[RS] verify access token failed: <err>。
pkg/httpx/httpx.go — RSAuth 配置(Enable / Audience / Provider)pkg/oidcx/oidc.go — VerifyAccessToken(oidc provider:复用 provider 的 JWKS 验签 + issuer/audience/过期,映射 claim)pkg/oauth2x/oauth2x.go — VerifyAccessToken(oauth2 provider:introspect/userinfo 两种校验 + 按 token 哈希缓存)center/router/router_rsauth.go — rsAuthProvider / rsAuthEnabled / shouldVerifyAsRS(按 provider 区分 token)/ authByIdPAccessToken(JIT 建用户)/ oidcDiscoveryURL / rsAuthServerAddr / oauthProtectedResource(RFC 9728 元数据)/ rsAuthChallenge(401 WWW-Authenticate 发现头中间件)/ wwwAuthenticateChallenge / protectedResourceMetadataURLcenter/router/router_mw.go — tokenAuth() 中的 RS 分支;agentOAuthScope(把 OAuth 受理限定在 agent 面,分支前置门控)center/router/router_a2a.go — 注册 /.well-known/oauth-protected-resource(含 /a2a /mcp 路径别名),把 rsAuthChallenge 挂进 a2a/mcp 中间件链,并把 OIDC 发现 URL 传入 AgentCardaiagent/a2a/agent_card.go — AgentCard 的 oidc securityScheme 档