ADR 0084 — 社交登录(GitHub/GitLab),修订 ADR 0031/0032 的 backlog
背景
ADR 0031(first-party 认证)和 ADR 0032(移除 Keycloak)明确把社交登录放进有意识的 backlog:“Keycloak 提供过它们,但没人使用;现在重新实现就是在没有需求的情况下付出成本。”CLAUDE.md 把这一决定变成了永久禁令——“不要实现(……)社交登录”——docs/explanation/backlog.md 自 FASE 13c 起一直把这一项保留为开放的待办。
产品负责人明确要求了这条战线,并清楚其中涉及的安全后果,因此该禁令针对这条战线被撤销——而不是追溯地针对 MFA、OIDC provider 或联合认证,它们仍然不在范围内(见“后果”)。
产品已经拥有该机制的两块,用于两个不同目的:
- First-party 认证(ADR 0031/0032):
EmitirSessaoUseCase在身份已解析之后打包 access(Ed25519,短期)+ refresh(不透明,带家族的轮换)这一对——今天由LoginUseCase和RegisterUseCase使用。 - GitHub/GitLab 的 OAuth(第 2 阶段,ADR 0059):
GitOauthClient(buildAuthorizeUrl/exchangeCode)、GitOauthClientRegistry、由GIT_OAUTH_STATE_SECRET签名的signOauthState/verifyOauthState——但用于把 git 凭据连接到一个已经认证的用户的项目(StartGitOauthUseCase需要projectId和userId)。
这条战线的工作是把两者连接起来,而不发明第三种会话格式,也不混淆 state 的两种用途。
决策
1. 会话签发机制被复用,没有例外
SocialLoginCallbackUseCase 最后调用的是与 LoginUseCase/RefreshUseCase 使用的同一个 EmitirSessaoUseCase。对于通过 GitHub/GitLab 进入的人,不存在第二种令牌、cookie 或 claim 格式——通过密码登录的会话与通过 OAuth 登录的会话在签发后无法区分。
2. OAuth 客户端被复用;state 的用途不复用
GitOauthClient 增加了两个新方法——
buildLoginAuthorizeUrl/fetchIdentity——由 git 连接流程的同一个
GithubOauthClient/GitlabOauthClient 实现。
exchangeCode 原样复用。
不复用的是 state 的签名。domain/auth/social-oauth-state.ts
是一个专属模块,其 payload 在结构上不同
({purpose: 'social_login', provider, nonce, expiresAt},没有 projectId
也没有 userId——社交登录没有“在哪里”,只有身份),以及一个在任何其他字段之前检查的用途判别值
(RN-273)。git 连接流程的 state,即使由同一把密钥签名,在这里也不被接受——测试套件证明了重要的那个方向(git-connect state → 社交登录验证器):在登录回调中接受那个 state 意味着以别人的 userId 登录,纯粹的权限提升。
HMAC 密钥仍然是 GIT_OAUTH_STATE_SECRET
(resolveOauthStateSecret())——没有新的环境变量。复用密钥是安全的,因为区分两种用途的是 payload 的结构不兼容,而不是秘密本身。
3. 登录专属的最小作用域
buildLoginAuthorizeUrl 请求 read:user user:email(GitHub)和 read_user
(GitLab)——绝不请求连接流程的 repo/api。登录账户不应授予对任何仓库的访问
(RN-277)。
4. 新表:social_identities
迁移 0047。users 中的专用列被拒绝,原因 keycloak_sub 已经教过:每个遗留 provider 一列无法扩展到两个同时存在的 provider(一个用户可以同时通过 GitHub 和 GitLab 登录)。(provider, provider_user_id) 唯一;provider_user_id 是 provider 的数字 id,绝不是登录名/电子邮件——它们可能换主人
(RN-276)。user_id 为 NOT NULL:关联在解析身份的同一步中诞生,没有“没有主人的身份”这一中间状态。
5. 回调的三个决定,按顺序
SocialLoginCallbackUseCase 按以下顺序决定
(RN-272):
- 已知身份(
(provider, providerUserId)在social_identities中)→ 直接登录。 - 新身份,电子邮件匹配现有账户,且 provider 将该电子邮件标记为已验证 → 关联并登录
(RN-274)。关联就是账户合并——provider 的验证起到了密码注册中点击验证链接的作用,因此作为副作用,一个通过密码注册且从未验证的账户在关联后
emailVerifiedAt会被填上(RN-279):provider 刚刚通过一条独立路径证明了那次点击所要证明的事情。 - 新身份,电子邮件匹配但未验证 → 拒绝
(
403)。在 OAuth provider 中键入(未验证)的电子邮件不是拥有权的证明——在这里接受就会打开账户接管:在 Brabo 拥有alguem@empresa.com的人并没有要求让一个仅键入了该地址的他人 GitHub 继承这个账户。 - 新身份,没有对应账户 → 配置一个新用户,没有密码——复用 Keycloak 迁移已经留下的同一个“待定”状态(
users在auth_credentials中没有行, RN-278)。这里电子邮件不需要已验证(RN-275):没有可接管的现有账户,只有一个将要诞生的账户,要求验证只会让常见情况变贵而保护不了任何东西。LoginUseCase和ResetPasswordUseCase已经知道如何处理这种状态——社交账户免费获得“忘记密码”,无需第二种机制。
6. 回调从不在 URL 或正文中暴露令牌
GET /auth/oauth/:provider/callback 写入会话 cookie
(definirCookiesDeSessao,与密码登录相同的函数)并重定向到 WEB_ORIGIN/。access token 不经由 URL 传输:web 的启动
(restaurarSessao(),在每次页面加载时调用,apps/web/src/main.tsx)已经会把刚写入的 refresh 换成 access token——客户端除了两个按钮和错误路由别名之外零新代码
(RN-282)。失败跳转到
WEB_ORIGIN/login?oauth_error=1,不细说原因——与 git 连接回调同一模式(RN-283)。
7. 复用同一个 OAuth 应用——没有新的环境变量
GITHUB_OAUTH_CLIENT_ID/_SECRET 和 GITLAB_OAUTH_CLIENT_ID/_SECRET
仍然是已为 git 连接注册的应用的那些。按流程变化的是 redirect_uri(/auth/oauth/<provider>/callback 而不是
/git/oauth/<provider>/callback)和请求的 scope——两者都在请求时决定,而不是在配置时
(RN-281)。操作员的动作仍然必要:第二个回调 URL 需要在每个 provider 的 OAuth 应用中注册(记录在 .env.example 中)——正是这一要求,而不是新的环境变量,使分支以 breaking/ 诞生成为合理。
后果
- 两条新的公开路由(
GET /auth/oauth/:provider/start、GET /auth/oauth/:provider/callback),在docs/security-surface.md中说明理由,并由route-surface.spec.ts覆盖——公开暴露面从十二条变为十四条路由。 social_identities是一张新表,没有软删除也没有历史:解除身份关联(撤销通过 GitHub/GitLab 的访问而保留账户)在这条战线中没有 UI 也没有路由——backlog。- 只有 GitHub 和 GitLab。 没有通用 OIDC provider,没有 SAML 联合——ADR 0031/0032 的 backlog 对 MFA、通用 OIDC 联合和作为 OIDC 提供方的 api 仍然有效。本 ADR 修订的只是该列表中的“社交登录”一项,并且只针对已注册了
GitOauthClient的两个 provider。 emailVerified依赖 provider 说真话。 GitHub 把电子邮件和验证分在两次调用中(/user和/user/emails);GitLab 把验证嵌在账户的confirmed_at中。两者按产品决定被视为等价——在 Brabo 一侧,除了信任 provider 的响应之外,两者都无法审计。- 仅社交的账户从不经过密码登录的锁定桶(RN-030/031):没有可以拦截的密码尝试。拦截它的是 provider 一侧的 OAuth 握手本身。
CLAUDE.md需要删除“O que NÃO fazer”一节中的“不要实现(……)社交登录”这句话——本 ADR 不编辑它(留给本波次收尾的 PR,与其他战线一起)。