Todos os posts

Conta Rockfeller: como construí o SSO que unificou o login da empresa

Um provedor OpenID Connect próprio sobre Better Auth, com papéis por escola, logout global, webhooks com outbox e autorização para agentes de IA. As decisões, os erros e o que eu faria de novo.

  • sso
  • oidc
  • oauth
  • better-auth
  • nestjs
  • segurança
  • arquitetura

Na Rockfeller, cada sistema novo nascia com a própria tela de login. No dia em que o sistema nasce, isso parece o caminho mais rápido. Nos meses seguintes vira manutenção, suporte e risco, multiplicados pelo número de sistemas.

A Conta Rockfeller é a minha resposta a isso: um login único, no estilo do Google Accounts, que hoje autentica oito sistemas internos. Vou contar as decisões que tomei no caminho, inclusive uma que tive de desfazer. Em SSO, escrever o fluxo de login é a parte fácil. O que me tomou tempo foi decidir onde cada responsabilidade mora.

Como era antes

Olhando um sistema de cada vez, estava tudo bem. Olhando todos juntos, cada um repetia a mesma pilha de autenticação, escrita do zero e com bugs próprios: tabela de usuários, hash de senha, "esqueci minha senha", e-mail de redefinição, convite com senha inicial e algum script para criar o primeiro admin.

Quem trabalha numa escola franqueada usa vários desses sistemas no mesmo dia. Eram várias senhas e vários lugares para trocar o nome ou a foto. Quando alguém saía de uma escola, o acesso precisava ser removido sistema por sistema, e bastava esquecer um para a pessoa continuar entrando.

A regra mais importante do negócio, algo como "o coordenador da escola X vê os alunos da escola X", também existia em várias versões, cada uma com nomes e casos de borda diferentes. Para completar, vários sistemas no mesmo domínio pai usavam cookies chamados session, e às vezes um lia o cookie do vizinho.

Os dados também envelheciam. Se o nome de alguém mudava ou uma conta era desativada, o sistema só ficava sabendo no próximo login da pessoa, quando ficava. E com os servidores MCP chegando, cada produto começou a emitir API keys pessoais para conectar agentes de IA. Essas chaves vazam, não têm segundo fator, não expiram e ignoram a permissão por escola.

Nada disso é exótico. É o custo de não ter uma identidade central, e ele cresce com cada sistema novo.

O que eu queria

Antes de escrever código, anotei o que "pronto" significava. Uma conta só, com seletor de contas como no Google. Cada sistema continuaria dono da própria sessão: o SSO diz quem é a pessoa e o sistema decide o que fazer com isso. A autorização por escola teria uma fonte de verdade só, e desativar alguém num lugar valeria para todos. Integrar do jeito errado deveria dar mais trabalho que integrar do jeito certo. E nada de protocolo inventado em casa.

Essa última exigência já escolhe o protocolo: OpenID Connect, sobre OAuth 2.0.

O atalho óbvio seria um cookie de sessão no domínio pai, lido por todos os sistemas. Funciona na primeira semana. Depois, qualquer sistema consegue ler (e vazar) a sessão de todos os outros, o logout vira um problema de sincronização e um XSS num subdomínio esquecido compromete a empresa inteira.

A Conta Rockfeller é um OpenID Provider. A sessão central fica num cookie host-only, que só o próprio SSO enxerga. Cada sistema é um cliente OIDC com o próprio cookie, e a ligação entre eles acontece pelo protocolo.

O fluxo é o Authorization Code com PKCE:

Fluxo Authorization Code com PKCE entre o navegador, o sistema e a Conta RockfellerFluxo Authorization Code com PKCE entre o navegador, o sistema e a Conta Rockfeller

Três regras valem para todos os clientes. O PKCE é obrigatório até para os confidenciais, que já têm um segredo: ele protege o code contra interceptação e não custa nada. A redirect URI precisa casar exatamente, sem curinga nem prefixo. E não existe registro dinâmico de clientes. Um admin cadastra cada sistema, com clientes separados para homologação e produção, e cada um recebe o próprio aud, então um token de homologação nunca passa em produção.

Por que Better Auth

Eu não queria escrever um servidor OAuth do zero, nem operar um Keycloak com um time pequeno. O Better Auth, com o plugin de OAuth provider, me deu o núcleo do protocolo (authorize, token, JWKS, discovery, consentimento e end-session) dentro de uma aplicação TypeScript que eu controlo. Múltiplas sessões, segundo fator, código por e-mail e passkeys vêm como plugins.

O que o Better Auth não decide por mim é justamente o que faz deste SSO o SSO da Rockfeller: a política de acesso, o modelo de papéis, os webhooks e a rotação de segredos. Foi aí que o tempo foi gasto.

Decisão 2: uma origem só

O OIDC exige um issuer único e estável, e a sessão central precisa morar num único host. O projeto, porém, tem duas peças: um portal em Next.js (login, consentimento, "Minha conta" e o painel admin) e uma API em NestJS, onde roda o Better Auth.

A saída foi servir as duas pela mesma origem:

Arquitetura da Conta Rockfeller, com o portal na frente da API, banco, cache e workersArquitetura da Conta Rockfeller, com o portal na frente da API, banco, cache e workers

O portal repassa as rotas de autenticação para a API, e a API não aceita tráfego que chegue por outro caminho. O detalhe que mais deu trabalho foi o proxy repassar os cabeçalhos de host direito. Quando ele erra, o discovery anuncia um issuer diferente do que o cliente espera, e nada funciona.

Decisão 3: identidade e autorização ficam separadas

A decisão que mais pesou no projeto foi definir o que o token não carrega.

Pessoas com vínculos

Não existe userType. Todo mundo é um User. Aluno, professor, secretaria, financeiro, gestor da unidade e franqueado são papéis dentro de vínculos com uma organização, que pode ser a franqueadora ou uma escola.

A mesma pessoa pode ser professora numa escola e coordenadora em outra. Cada vínculo guarda também a origem: veio do sistema de gestão escolar, de um convite ou de uma atribuição da franqueadora. Isso importa na revogação. Revogar um convite não pode apagar o vínculo que veio da sincronização, e vice-versa.

Avaliei o plugin de organizações do Better Auth e desisti dele. Ele guarda um papel por membro, não tem tipo nem hierarquia de organização e traz endpoints para criar organização, convidar e trocar a organização ativa, que eu teria de desligar um por um. Aqui ninguém cria escola à mão: elas chegam do sistema de gestão escolar, que é a fonte de verdade das unidades, por webhook e por uma reconciliação noturna.

Token magro, autorização no sistema

O access token é um JWT que leva só sub, aud e scope. Escola ativa e lista de permissões ficam de fora.

A Conta Rockfeller guarda os vínculos e o catálogo (papel → permissões, por sistema). O sistema, que conhece o recurso, decide se a pessoa pode mexer naquele aluno ou naquela turma. As permissões têm namespace por sistema, e cada sistema só enxerga o próprio.

Uma operação só passa se todas as condições abaixo forem verdadeiras:

Regra de autorização (ilustrativa)
// Negar é o padrão: cada linha é uma condição necessária.
const allowed =
	account.active &&
	app.accessGranted &&
	membership.validFor(schoolId) &&
	permissions.includes('students.read') &&
	(await resourceBelongsTo(studentId, schoolId)) &&
	authIsRecentEnough(permission, session.authTime);

A escola sempre vem explícita na operação. Uma "escola ativa" guardada na sessão é estado implícito, e estado implícito é a origem clássica de dado vazando de uma escola para outra.

O backend do sistema consulta as permissões com a credencial do próprio cliente (client_credentials) e guarda a resposta num cache curto. Algumas permissões são sensíveis, como estornar um pagamento ou atribuir papéis, e exigem um login recente. O sistema compara essa exigência com o auth_time do ID token e, se o login for antigo, manda a pessoa de volta ao SSO com max_age antes de executar.

O catálogo de papéis e permissões fica no banco e é editável pelo painel admin. Cada instância mantém uma cópia em memória e a descarta quando a versão no cache compartilhado muda, então uma alteração chega a todas em segundos.

Tokens e sessões

O quêDuraçãoPor quê
Access tokenCurtaValidado localmente pelo JWKS; como não dá para revogar, precisa vencer logo
ID tokenCurtaSó serve para montar a sessão local
Refresh tokenLonga, rotativoQualquer reuso é tratado como replay e derruba a família (RFC 9700)
Sessão centralLonga, renovada no usoQuem usa todo dia não precisa entrar de novo

As chaves de assinatura trocam de tempos em tempos. A nova aparece no JWKS antes de começar a assinar e a antiga continua publicada por uma carência, para que nenhum sistema com o JWKS em cache recuse um token válido.

A barreira fica na emissão do token

O OAuth provider não tem um gancho de "negar autorização" que enxergue o cliente. Por isso a política de acesso de cada sistema roda em dois pontos.

O primeiro é a emissão de qualquer token, seja por authorization_code ou por refresh_token. Essa é a barreira de verdade, e é por causa dela que uma conta desativada ou um vínculo revogado param de renovar tokens na hora. O segundo é um gancho antes do /authorize, que só melhora a experiência: mostra uma tela de "acesso negado" em vez de devolver a pessoa ao sistema com um erro cru.

Cada sistema é open (qualquer conta ativa e verificada entra) ou restricted (exige um papel que dê acesso a ele), e pode ainda aceitar só e-mails corporativos. O SSO nunca emite token para e-mail não verificado, o que permite aos sistemas ligar contas pelo e-mail com segurança.

Perfil no ID token

Por padrão, o provider só entrega nome e e-mail no /userinfo. Coloquei esses claims no próprio ID token, conforme os escopos concedidos. O sistema monta a sessão local sem uma ida a mais à rede, e o perfil chega assinado e amarrado ao nonce.

Logout global

Sair de um sistema chama o end-session, que encerra a sessão central. Depois, o SSO manda um back-channel logout (um JWT assinado, de servidor para servidor) a cada sistema em que a pessoa tinha sessão, e cada um derruba a sua.

Quando um admin desativa uma conta, o caminho é o mesmo, com um passo a mais: os refresh tokens de acesso offline também são revogados, coisa que o logout padrão pouparia.

O back-channel é uma tentativa só. Quando a sessão acaba por outro motivo (expirou ou foi encerrada em "Minha conta"), o sistema recebe um evento session.ended pela infraestrutura de webhooks, que é o assunto da próxima seção.

Webhooks com outbox

O logout resolve as sessões, mas os dados velhos continuam lá. Para eles, a Conta Rockfeller avisa os sistemas por webhooks: user.updated, user.disabled, user.deleted, membership.granted, membership.revoked, app.access_revoked e session.ended.

A parte difícil é garantir duas coisas ao mesmo tempo: nunca avisar sobre algo que não aconteceu e nunca deixar de avisar sobre algo que aconteceu. Gravar no banco e publicar na fila são duas operações, e se a API cair entre elas o aviso mente. A resposta clássica é o transactional outbox:

Outbox transacional, do commit no banco até a entrega assinada no sistema clienteOutbox transacional, do commit no banco até a entrega assinada no sistema cliente

O evento e uma entrega por destinatário são gravados na mesma transação da mudança, e nada vai para a fila antes do commit. Um worker reserva as entregas pendentes com FOR UPDATE SKIP LOCKED e as põe na fila com um jobId determinístico, então uma queda no meio do caminho não duplica nada.

A entrega é at-least-once, no formato Standard Webhooks, assinada com HMAC. O webhook-id é o id do evento e se repete em todas as tentativas, então o sistema deduplica por ele. Como não há garantia de ordem, o updatedAt do usuário funciona como versão. As retentativas usam backoff com jitter, e um endpoint que falha por tempo demais é desativado e os admins recebem um e-mail.

O detalhe de que mais gosto é o user.updated. Ele é calculado como a diferença em relação ao último retrato enviado àquele sistema e filtrado pelos escopos que o sistema recebeu. Um sistema sem o escopo de e-mail nunca fica sabendo de uma troca de e-mail, e se nada visível mudou, o evento não sai.

Como o SSO faz requisições para URLs cadastradas por outras equipes, ele é candidato natural a SSRF. A entrega de webhooks foi escrita com isso em mente, e os segredos de assinatura ficam cifrados no banco.

Segurança

Boa parte do que está lá é o esperado: rate limit global e por rota, captcha nas telas de autenticação, CSP com nonce por requisição, HSTS, frame-ancestors 'none' e conferência do cabeçalho Origin nas mutações autenticadas por cookie. Algumas escolhas merecem explicação.

Senhas que já apareceram em vazamentos são recusadas. A conferência usa o Pwned Passwords por k-anonymity, então a senha em si nunca sai do servidor. E-mails descartáveis também ficam de fora do cadastro.

Uma passkey com verificação do usuário já conta como dois fatores, porque junta a posse do dispositivo com a biometria ou o PIN. Para quem prefere, existe o TOTP. O segundo fator é obrigatório para admins, e o painel admin só abre com ele verificado na sessão atual.

Trocar o e-mail, mexer nas passkeys ou exportar os dados exige um login recente. Entrar por um dispositivo novo dispara um e-mail de aviso, que é barato de fazer e é dos alertas mais úteis para quem caiu num phishing.

A auditoria só aceita inserção. O usuário de banco da aplicação não tem permissão para alterar nem apagar esses registros, então nem um bug nem alguém que tome a aplicação consegue limpar rastros.

Trocar segredos sem derrubar ninguém deu mais trabalho do que eu esperava. No começo, trocar o segredo principal do Better Auth deslogava todo mundo e deixava ilegível tudo o que tinha sido cifrado com ele. Hoje os segredos têm versão: o mais novo assina e cifra, os antigos só abrem o que já existia, e o que estava na versão antiga é migrado aos poucos. Trocar segredo virou rotina de manutenção.

A LGPD entrou desde o primeiro dia. Excluir a conta anonimiza os dados depois de um prazo de arrependimento, e um sub nunca é reaproveitado. A pessoa pode exportar os próprios dados, e sessões, códigos, tokens e auditoria vencidos são apagados por uma rotina de retenção.

O SDK que eu disse que não faria

Uma das primeiras decisões que registrei foi "não haverá SDK próprio". Cada sistema usaria uma biblioteca OIDC genérica e seguiria um guia de integração. Parecia o mais honesto: padrão aberto, sem lock-in e sem pacote para manter.

Durou pouco. Na prática, cada sistema copiava da documentação uma configuração com uma dúzia de opções, mais a rota de back-channel, o token do próprio sistema e o cache de permissões. E é na cópia que os erros aparecem: escopo sem offline_access, um override de perfil esquecido, back-channel que não conferia o nonce. Um dos sistemas chegou a integrar primeiro com o provedor OAuth genérico e refez tudo com o plugin pouco depois. Ali ficou claro que o guia não bastava.

Desfiz a decisão e escrevi um plugin para o Better Auth. A integração ficou assim:

auth.ts (ilustrativo)
import { betterAuth } from 'better-auth';
import { sso } from './lib/sso';

export const auth = betterAuth({
	plugins: [
		sso({
			clientId: process.env.SSO_CLIENT_ID,
			clientSecret: process.env.SSO_CLIENT_SECRET,
		}),
	],
});

O plugin concentra as decisões que antes cada sistema tomava sozinho. Os endereços do SSO saem do issuer, sem discovery na subida, então o sistema sobe mesmo com o SSO fora do ar. O perfil vem do ID token, sem passar pelo /userinfo. O login local fica bloqueado por padrão, com uma saída explícita para quem precisa manter outro público com login próprio.

Ele também renova o token de cada conta uma vez por vez. Com refresh rotativo e detecção de reuso, duas abas renovando juntas derrubariam a família de tokens. As permissões ficam num cache curto, que é invalidado de uma vez quando chega um back-channel logout. E o signOut() devolve a URL de end-session sem redirecionar, para o sistema escolher entre sair só dele ou encerrar o SSO.

Para APIs há duas entradas separadas: um verificador de token que não depende do Better Auth e um módulo NestJS que protege todas as rotas por padrão.

Uma API NestJS protegida (ilustrativo)
@Controller('schools/:schoolId/students')
export class StudentsController {
	@Get()
	@RequirePermission('students.read')
	list(@Param('schoolId') schoolId: string) {
		// Só chega aqui com token válido, vínculo com a escola e a permissão.
		return this.students.listBySchool(schoolId);
	}
}

"Sem SDK" continua sendo uma boa posição de princípio. Como posição operacional, com vários times integrando com pressa, não se sustentou. O padrão segue aberto e o guia para integrar sem o plugin continua lá. O plugin só tira dos times as decisões que eles não deveriam estar tomando.

Agentes de IA

A especificação de autorização do MCP segue o OAuth 2.1: o servidor MCP é um recurso protegido e o login acontece num servidor de autorização, papel que a Conta Rockfeller já cumpria.

O problema é que agentes como Claude, ChatGPT ou Cursor não são clientes cadastrados por um admin. Para eles, o SSO aceita Client ID Metadata Documents: o agente se identifica por uma URL que publica seus metadados, e o SSO cria o cliente na primeira vez que a vê. O registro dinâmico continua desligado, e só são aceitos agentes de domínios aprovados.

Todo cliente de agente nasce com consentimento obrigatório (a tela deixa claro que é um agente de IA e qual domínio o identifica) e só com escopos de identidade. Escopos de gestão são recusados. O que o agente pode fazer em cada servidor MCP depende das permissões da pessoa naquela escola, que o servidor MCP consulta com a própria credencial.

As API keys pessoais saíram de cena. No lugar delas ficaram tokens curtos, que a pessoa revoga em "Minha conta" e que respeitam o segundo fator e o logout global.

A adoção

Hoje, oito sistemas internos autenticam pela Conta Rockfeller, e a migração seguiu o mesmo roteiro em todos.

No primeiro login, a conta local é ligada pelo e-mail verificado; daí em diante, pelo sub. Depois vem a parte mais satisfatória, que é apagar código: redefinição e troca de senha, convite com senha, criação do primeiro admin, DTOs de login e JWT feito à mão. Em mais de um sistema a migração removeu mais linhas do que adicionou, e onde havia senhas guardadas elas foram apagadas do banco. Cada sistema também ganhou um prefixo próprio no cookie, para nunca mais ler a sessão de um vizinho, e um endpoint que confere a assinatura dos webhooks e ignora eventos repetidos.

O gerador interno de projetos já oferece a integração como opção, e o design system tem o botão "Entrar com a Conta Rockfeller". Um sistema novo já nasce integrado, então o caminho certo passou a ser também o mais curto.

O que eu levo daqui

Manter o access token magro e deixar a autorização do recurso com o sistema me livrou da armadilha mais comum de SSO corporativo, que é o token gordo cheio de permissões que ficam velhas no minuto seguinte. Pôr a política de acesso na emissão de todo token, inclusive no refresh, protege mais do que qualquer tela de "acesso negado".

Registrar cada decisão num ADR curto, com contexto e consequências, ajudou mais do que eu imaginava. Quando o "sem SDK" caiu, estava escrito por que ele existia e por que deixou de fazer sentido, e ninguém precisou adivinhar.

Documentação sozinha não escala entre times com pressa. O que escalou foi um plugin com padrões seguros, um botão no design system e uma opção no gerador de projetos.

Por fim, a parte operacional também é produto. Trocar segredo sem derrubar ninguém, publicar chaves antes de usá-las e apagar dados vencidos não aparecem em demo nenhuma, mas são o que permite operar o SSO no dia a dia.

Se na sua empresa cada sistema novo ainda nasce com a própria tela de login, vale centralizar a identidade antes que o próximo apareça.


Todos os posts