统一 IdP 抽象(OIDC + OAuth2)
把 Inbound OIDC 的
tenant_oidc_providers扩展成「外部身份提供方」统一抽象,让租户在 UI 上自助配置任意登录源——既支持标准 OIDC IdP(Keycloak/Azure/Google),也支持 OAuth2 提供方(Gitee/GitHub/微信/飞书等非标准 OIDC),并按租户停用。 定稿 2026-07-10。前置:Inbound OIDC(docs/TENANT_PHASE5_INBOUND_OIDC.md)、Gitee 硬编码(commit1a058c7)。
0. 为什么
当前 auth 有两套登录入口:
- 硬编码 handler:GitHub、Gitee(每家一个 handler + 全局 client_id/secret,所有租户共享,租户无法停用/自定义)。
- 配置化 IdP:Inbound OIDC(
tenant_oidc_providers,每租户配企业 IdP,可停用)——但只支持标准 OIDC(依赖 discovery + id_token + 标准 userinfo),接不上 Gitee/微信等 OAuth2 平台。
目标是统一成一套配置:租户管理员在 UI 配一个 IdP(选类型 OIDC/OAuth2),填对应参数,就能接入任意登录源,并能停用。这是 IDaaS 的核心能力(Auth0/Casbin 的 social connections 都这么设计)。
1. 数据模型扩展(tenant_oidc_providers 加字段)
ALTER TABLE tenant_oidc_providers ADD COLUMN provider_type VARCHAR(10) DEFAULT 'oidc'; -- 'oidc' | 'oauth2'
-- OAuth2 手填 endpoints(OIDC 用 discovery,这几列留空)
ALTER TABLE tenant_oidc_providers ADD COLUMN authorize_url TEXT;
ALTER TABLE tenant_oidc_providers ADD COLUMN token_url TEXT;
ALTER TABLE tenant_oidc_providers ADD COLUMN userinfo_url TEXT;
-- OAuth2 userinfo 字段映射(OIDC 用标准 sub/email,这几列留空走默认)
ALTER TABLE tenant_oidc_providers ADD COLUMN field_id VARCHAR(50) DEFAULT 'id';
ALTER TABLE tenant_oidc_providers ADD COLUMN field_username VARCHAR(50) DEFAULT 'login';
ALTER TABLE tenant_oidc_providers ADD COLUMN field_email VARCHAR(50) DEFAULT 'email';
ALTER TABLE tenant_oidc_providers ADD COLUMN field_name VARCHAR(50) DEFAULT 'name';
ALTER TABLE tenant_oidc_providers ADD COLUMN field_avatar VARCHAR(50) DEFAULT 'avatar_url';
-- OAuth2 token 传递与编码
ALTER TABLE tenant_oidc_providers ADD COLUMN userinfo_token_in VARCHAR(10) DEFAULT 'query'; -- 'query'(?access_token=) | 'header'(Authorization: Bearer)
ALTER TABLE tenant_oidc_providers ADD COLUMN token_content_type VARCHAR(10) DEFAULT 'form'; -- 'form'(x-www-form-urlencoded) | 'json'
现有 IdP 配置迁移:provider_type='oidc'(默认),新字段空走 OIDC discovery,零破坏。
2. RP 逻辑泛化(src/sso.rs)
start(GET /api/auth/sso/:provider_id/start)
按 provider_type 分支:
- oidc:
discover(issuer)拿 authorization_endpoint + 生成 PKCE(code_challenge)+ state 存 Redis → 302。(现有逻辑) - oauth2:用
authorize_url(手填)+ state 存 Redis(不生成 PKCE,多数 OAuth2 如 Gitee/GitHub 不要求;若某些平台要,可后续加use_pkce开关)→ 302。
callback(GET /api/auth/sso/callback?code=&state=)
- 换 token:
- oidc:
POST discovery.token_endpoint+code_verifier(PKCE)→{access_token, id_token}。 - oauth2:
POST token_url,body 按token_content_type(form/json),含grant_type=authorization_code, code, client_id, redirect_uri, client_secret(无 code_verifier)→{access_token}。
- oidc:
- userinfo:
- oidc:
GET discovery.userinfo_endpoint+Authorization: Bearer→ 标准{sub, email, name}。 - oauth2:
GET userinfo_url,token 按userinfo_token_in(query?access_token=或 header)→ 按field_*映射取{id, username, email, name, avatar}。
- oidc:
- 用户映射(统一):身份 =
(provider_id, provider_kind),登录/绑定走user_idp_bindings表(多绑模型,2026-09);users.oauth_provider = "idp:{id}"/oauth_id = mapped_id两列仅为双轨投影(写时同步)。
公开端点(GET /api/auth/sso/providers?tenant_id=)
返 [{id, name, provider_type}](加 provider_type 供登录页按类型显示图标/文案;不返敏感)。
3. 字段映射示例(覆盖主流 OAuth2 平台)
| 平台 | authorize_url | token_url | userinfo_url | field_id | field_username | userinfo_token_in | token_content_type |
|---|---|---|---|---|---|---|---|
| Gitee | https://gitee.com/oauth/authorize | https://gitee.com/oauth/token | https://gitee.com/api/v5/user | id | login | query | form |
| GitHub | https://github.com/login/oauth/authorize | https://github.com/login/oauth/access_token | https://api.github.com/user | id | login | header | json |
| 通用 OAuth2 | 各自填 | 各自填 | 各自填 | 配置 | 配置 | 配置 | 配置 |
scope 按平台官方词表填(预置:GitHub read:user user:email、Gitee user_info——Gitee 合法 scope 不含 emails,塞了会 invalid_scope 登录断)。私有邮箱补全不靠 scope,靠 emails_url(邮箱补全端点):userinfo 未返回 email 时回退拉取该端点,只取已验证条目(GitHub verified=true / Gitee state=confirmed,primary 优先);预置 GitHub .../user/emails、Gitee /api/v5/emails。
租户管理员在 UI 填这些 → 接入对应平台。
4. UI(admin-ui/src/sso_providers.vue)
- 表单加「类型」选择:OIDC / OAuth2。
- OIDC 显示:issuer(discovery 用)。
- OAuth2 显示:authorize_url / token_url / userinfo_url + 字段映射(field_id/username/email/name/avatar)+ userinfo_token_in(query/header)+ token_content_type(form/json)+ scope + emails_url(邮箱补全端点,可空——userinfo 无 email 时回退拉取,只取已验证条目)。
- 通用:name / client_id / client_secret / is_active(停用开关)。
- 预置模板(可选 UX 加分):点「Gitee 模板」自动填 Gitee 的 URL/字段映射,租户只补 client_id/secret。
5. 硬编码 GitHub/Gitee 已退役(2026-07)
- 硬编码
github_oauth_*/gitee_oauth_*handler 与.env的GITHUB_*/GITEE_*配置已移除。 - 全部统一到配置化 IdP:社交登录(GitHub/Gitee)和企业 SSO(OIDC)都走
tenant_oidc_providers表 +/api/auth/sso/*路径。 - 功能平移:db.rs 启动迁移在 default 租户下自动创建 GitHub OAuth2 IdP(读
.env的GITHUB_CLIENT_ID/SECRET,幂等判重);Gitee 早已预置(id=1)。 - 历史
oauth_provider值:硬编码'github'/'gitee'(遗留账号)vs 配置'idp:{id}'(新登录)。两套不冲突,老账号原值不变。2026-09 多绑模型迁移(schema v7)已把两套存量回填进user_idp_bindings:能映射到 provider 记录的按(provider_id, 'oauth2')归一,映射不到的落 legacy 负哨兵;users两列此后由写路径同步维护(投影)。
6. 工作量(1 人估)
| 子项 | 工作量 |
|---|---|
| 表扩展 + 迁移 + 模型字段 | ~1 天 |
| RP 逻辑泛化(start/callback 按 type 分支 + 字段映射 + token 传递) | ~3 天 |
| 公开端点 + UI(sso_providers.vue type 选择 + 条件字段 + 模板) | ~2 天 |
| 测试(oidc 路径不回归 + oauth2 用 Gitee 实测) | ~1 天 |
| 合计 | ~1 周 |
7. 微信小程序登录(2026-09 上线,wechat.rs;接入方请看 WECHAT_MINIPROGRAM 契约文档)
微信是自有协议(非标准 OAuth2/OIDC,无 redirect 授权流),不走上文的 provider 通用流程,独立成模块:
- 配置:org_idp_providers 一行
provider_type='wechat'——client_id=小程序 AppID、client_secret=AppSecret(AES 加密落库);不进网页登录按钮(公开列表端点过滤 wechat 类型)。 - 身份键 = unionid 优先:
subject = unionid(拿不到则 openid),bindings 按(provider_kind='wechat', subject)匹配——「微信里的这个人」跨小程序/公众号是同一账号(provider_id 仅记录来源小程序;schema v9 部分唯一索引保证一人一行)。 - 三端点(公开):
POST /api/auth/wechat/login {code, appid}——code2session 换身份;已绑定直接签 JWT;未绑定不建号,返{status:"unbound", wx_token}(Redis 一次性 5min 票据)。POST /api/auth/wechat/register {wx_token}——快速开通:建无邮箱无密码的微信专属账号(后续可转正绑邮箱)。POST /api/auth/wechat/bind {wx_token, username, password, mfa_code?}——绑老号:凭证验证(login_guard 双维度限流 + TOTP 老号必须带码)→link_identity→ 签老号 token。凭证失败不烧票据可重试。无密码老号(Gitee 建号)明确报错引导(设置密码或改用确认码)。- 确认码绑定(免输密码,2026-09):小程序
POST /api/auth/wechat/bind-code {wx_token}换 6 位码(5 分钟,错 5 次作废)→ 用户在已登录的网页端个人中心「关联小程序」输码POST /api/auth/wechat/confirm-bind {code}(老号登录态即控制权证明,无密码老号同样适用)→ 小程序POST /api/auth/wechat/poll-bind {code}轮询取老号 token 对(票据一次性)。
- 首登不建号的理由:微信无邮箱 → 双侧已验证邮箱合并天然失效,即建号必产生「老用户扫码进空号」;二选一把选择权给用户,不产生双号。
- 安全红线:session_key 拿到即弃(不存不下发);code 一次性;AppSecret 只在服务端。
- 边界:身份归 auth(直调 jscode2session,与 GitHub/Gitee IdP 同构);wechat-publish-service 等下游应用保持自身用户体系不动,可渐进切换。本地 mock:
WECHAT_API_BASE覆盖 API 域名。
7b. QQ 互联(2026-09-05 上线,provider_type=‘qq’)
redirect 流的「微信式特例」——协议形态介于标准 OAuth2 与自有协议之间,callback 走专属分支:
- 配置:管理台「组织 IdP 配置」类型选 QQ 互联——client_id=APP ID、client_secret=APP Key(AES 加密落库);四端点内置免填(
QQ_API_BASE可覆盖供 mock)。QQ 后台的回调地址填https://auth.ai-as.cc/api/auth/sso/callback。 - 协议特例三处(callback 专属分支):token 端点 GET、响应是 form 文本(
access_token=...&expires_in=...);身份键 openid 由独立 me 端点返回(JSONP 剥壳,申请了 unionid 机制的应用带 unionid);userinfo 需附oauth_consumer_key(APPID)+openid、无 email(JIT 建号无邮箱,用户后续自助绑邮箱)。 - 身份家族:
kind='qq',subject = unionid(有则用)或 openid;登录/属主查询按(kind, subject)(对称微信家族)——同一开发者账号下多 QQ 应用 unionid 同号(schema v11 部分唯一索引)。首个应用未申请 unionid 机制时 subject=openid,将来申请后新登录走 unionid(老绑定不动,用户重绑即迁移)。 - 登录页:QQ 按钮出现在网页登录页/绑定卡(与 Gitee 同列,redirect 流)。
8. 边界(本阶段不做)
- ❌ 硬编码 GitHub/Gitee 迁移到 IdP 配置(保留兼容,迁移影响现有用户 oauth_provider 值)。
- ❌
微信/飞书自有协议适配(微信小程序已于 2026-09 落地,见 §7;飞书仍不做)。 - ❌ SAML(仍等大客户要再做)。
- ❌ 租户级硬编码停用(硬编码是平台预置全局,租户不能停;租户只管自己配的 IdP)。