ADR 0084 — Login social (GitHub/GitLab), revisando o backlog dos ADRs 0031/0032
Contexto
O ADR 0031 (auth first-party) e o ADR 0032 (corte do Keycloak) colocaram
login social explicitamente no backlog consciente: "o Keycloak os
oferecia e não eram usados; reimplementá-los agora seria pagar o custo sem a
demanda." O CLAUDE.md transformou essa decisão em proibição permanente —
"Não implementar (…) login social" — e o docs/explanation/backlog.md
manteve o item como pendência aberta desde a FASE 13c.
O dono do produto pediu explicitamente esta frente, ciente das consequências de segurança envolvidas, e por isso a proibição foi revogada PARA ESTA FRENTE — não retroativamente para MFA, OIDC provider ou federação, que continuam fora de escopo (ver Consequências).
O produto já tem DOIS pedaços do mecanismo, para dois propósitos diferentes:
- Auth first-party (ADR 0031/0032):
EmitirSessaoUseCaseempacota o par access (Ed25519, curto) + refresh (opaco, rotação com família) depois que a identidade já foi resolvida — usado porLoginUseCaseeRegisterUseCasehoje. - OAuth de GitHub/GitLab (Fase 2, ADR 0059):
GitOauthClient(buildAuthorizeUrl/exchangeCode),GitOauthClientRegistry,signOauthState/verifyOauthStateassinado porGIT_OAUTH_STATE_SECRET— mas para conectar uma credencial de git a um projeto de um usuário JÁ AUTENTICADO (StartGitOauthUseCaseexigeprojectIdeuserId).
O trabalho desta frente é ligar os dois SEM inventar um terceiro formato de
sessão nem misturar os dois propósitos de state.
Decisão
1. O mecanismo de emissão de sessão é reusado, sem exceção
SocialLoginCallbackUseCase termina chamando o MESMO EmitirSessaoUseCase
que LoginUseCase/RefreshUseCase usam. Não existe um segundo formato de
token, cookie ou claim para quem entra por GitHub/GitLab — a sessão de quem
loga por senha e a de quem loga por OAuth são indistinguíveis depois de
emitidas.
2. O cliente OAuth é reusado; o propósito do state, NÃO
GitOauthClient ganhou dois métodos novos —
buildLoginAuthorizeUrl/fetchIdentity — implementados pelos MESMOS
GithubOauthClient/GitlabOauthClient do fluxo de conexão de git.
exchangeCode é reusado tal como está.
O que NÃO é reusado é a assinatura do state. domain/auth/social-oauth-state.ts
é um módulo PRÓPRIO, com payload estruturalmente diferente
({purpose: 'social_login', provider, nonce, expiresAt}, sem projectId
nem userId — não existe "onde" para login social, só identidade) e um
discriminante de PROPÓSITO checado ANTES de qualquer outro campo
(RN-273). Um state do fluxo de CONEXÃO de
git, mesmo assinado pela MESMA chave, não é aceito aqui — e a suíte prova a
direção que importava (git-connect state → verificador de login social):
aceitar aquele state no callback de LOGIN teria significado logar como
userId de outra pessoa, escalação de privilégio pura.
A chave HMAC continua sendo GIT_OAUTH_STATE_SECRET
(resolveOauthStateSecret()) — nenhuma variável de ambiente nova. Reusar
a chave é seguro porque a incompatibilidade estrutural do payload, e não o
segredo, é o que separa os dois propósitos.
3. Escopo mínimo, próprio do login
buildLoginAuthorizeUrl pede read:user user:email (GitHub) e read_user
(GitLab) — nunca o repo/api do fluxo de conexão. Entrar na conta não
deveria conceder acesso a repositório nenhum
(RN-277).
4. Tabela nova: social_identities
Migração 0047. Coluna dedicada em users foi rejeitada pelo motivo que
keycloak_sub já ensina: uma coluna por provider legado não escala para
DOIS providers simultâneos (um usuário pode logar por GitHub e GitLab ao
mesmo tempo). (provider, provider_user_id) é único; provider_user_id é o
id NUMÉRICO do provider, nunca o login/e-mail — que podem mudar de dono
(RN-276). user_id é NOT NULL: o vínculo
nasce no MESMO passo que resolve a identidade, sem um estado intermediário
"identidade sem dono".
5. As três decisões do callback, em ordem
SocialLoginCallbackUseCase decide, nesta ordem
(RN-272):
- Identidade já conhecida (
(provider, providerUserId)emsocial_identities) → login direto. - Identidade nova, e-mail bate com conta existente E o provider marca o
e-mail como VERIFICADO → vincula e loga
(RN-274). Vincular é fusão de contas — a
verificação do PROVIDER faz o papel que o clique no link de verificação
faz no registro por senha, e por isso, como efeito colateral, uma conta
registrada por senha e nunca verificada fica com
emailVerifiedAtpreenchido depois de vincular (RN-279): o provider acabou de provar, por um caminho independente, exatamente o que aquele clique provaria. - Identidade nova, e-mail bate mas NÃO verificado → recusa
(
403). Um e-mail digitado (não verificado) num provider OAuth não é prova de posse — aceitar aqui seria abrir account takeover: quem temalguem@empresa.comna Brabo não pediu para um GitHub alheio, com aquele endereço só digitado, herdar a conta. - Identidade nova, sem conta correspondente → provisiona usuário NOVO,
sem senha — reusando o MESMO estado "pendente" que a migração do
Keycloak já deixa (
userssem linha emauth_credentials, RN-278). Aqui o e-mail NÃO precisa estar verificado (RN-275): não há conta existente para tomar, só uma para nascer, e exigir verificação encareceria o caso comum sem proteger nada.LoginUseCaseeResetPasswordUseCasejá sabem tratar esse estado — a conta social ganha "esqueci minha senha" de graça, sem um segundo mecanismo.
6. O callback nunca expõe token na URL nem no corpo
GET /auth/oauth/:provider/callback grava os cookies de sessão
(definirCookiesDeSessao, a MESMA função do login por senha) e redireciona
para WEB_ORIGIN/. O access token não viaja na URL: o boot da web
(restaurarSessao(), chamado em TODA carga de página, apps/web/src/main.tsx)
já troca o refresh recém-gravado por um access token — zero código novo do
lado do cliente além dos dois botões e o alias de rota de erro
(RN-282). Falha vai para
WEB_ORIGIN/login?oauth_error=1, sem detalhar o motivo — mesmo padrão do
callback de conexão de git (RN-283).
7. Reuso do MESMO app OAuth — sem variável de ambiente nova
GITHUB_OAUTH_CLIENT_ID/_SECRET e GITLAB_OAUTH_CLIENT_ID/_SECRET
continuam sendo os do app já cadastrado para conexão de git. O que muda por
fluxo é o redirect_uri (/auth/oauth/<provider>/callback em vez de
/git/oauth/<provider>/callback) e o scope pedido — ambos decididos em
tempo de requisição, não de configuração
(RN-281). Ação do operador continua
necessária: o segundo callback URL precisa ser cadastrado no app OAuth de
cada provider (documentado em .env.example) — é essa exigência, não uma
env var nova, que justifica o branch nascer breaking/.
Consequências
- Duas rotas públicas novas (
GET /auth/oauth/:provider/start,GET /auth/oauth/:provider/callback), justificadas emdocs/security-surface.mde cobertas porroute-surface.spec.ts— a superfície pública passa de doze para catorze rotas. social_identitiesé tabela NOVA, sem soft delete e sem histórico: desvincular uma identidade (revogar acesso por GitHub/GitLab mantendo a conta) não tem UI nem rota nesta frente — backlog.- Só GitHub e GitLab. Nenhum provider OIDC genérico, nenhuma federação
SAML — o backlog dos ADRs 0031/0032 continua valendo para MFA,
federação OIDC genérica e a api como provedor OIDC. O que este ADR
revisa é SÓ o item "login social" daquela lista, e só para os dois
providers que já têm
GitOauthClientregistrado. emailVerifieddepende do provider dizer a verdade. O GitHub separa e-mail de verificação em duas chamadas (/usere/user/emails); o GitLab embute a verificação emconfirmed_atda CONTA. Os dois são tratados como equivalentes por decisão de produto — nenhum dos dois é auditável pelo lado da Brabo além de confiar na resposta do provider.- Conta social-only nunca passa pelo balde de lockout do login por senha (RN-030/031): não há tentativa de senha para conter. O que a contém é o próprio handshake OAuth, do lado do provider.
CLAUDE.mdprecisa perder a frase "Não implementar (…) login social" da seção "O que NÃO fazer" — não editado por este ADR (fica para a PR de fechamento da onda, junto das outras frentes).