02Arquitetura5 min

Já migrei de provedor de auth quatro vezes

Quatro provedores gerenciados em um ano e meio, e o mesmo destino em todos: middleware próprio. O que quebra em cada transição e o que eu olho agora antes de escrever uma linha de auth.

Por Salva Castells

Imagem de capa: Shixart1985, CC BY 2.0.
Neste post

No boilerplate que uso para começar projetos, o commit que integra o primeiro provedor de auth e o que o substitui pelo segundo estão separados por dois dias. Dois dias. O primeiro colocava middleware para rotas protegidas e uma tela de login; o segundo arrancava tudo e punha outro SDK com suas próprias páginas de sign-in e sign-up. Desde então esse mesmo template passou por mais dois provedores e terminou, quase um ano depois, com um middleware escrito à mão.

Não é azar. É um padrão, e já o repeti em pelo menos quatro repositórios.

Sempre começo com um provedor gerenciado, e sempre pelo mesmo motivo

No dia um de um projeto eu não quero escrever hash de senhas, verificação de e-mail, nem o fluxo de OAuth com o Google. Instalo um SDK, coloco duas variáveis de ambiente, e numa tarde há login. É a decisão correta naquele dia: o projeto ainda não sabe se vai existir daqui a um mês.

O problema é que esse SDK não fica quieto na borda da aplicação. Se enfia no layout, no contexto de usuário, em cada server action que precisa saber quem está chamando, e no esquema de banco de dados porque o user_id da tabela dele é o que acaba sendo chave estrangeira em todas as suas.

As quatro migrações e o que disparou cada uma

  • Provedor um para provedor dois, em dois dias. O primeiro funcionava, mas o modelo de preços e o acoplamento com o dashboard dele não encaixavam. Nenhuma linha de auth sobreviveu à troca.
  • Provedor dois, seis meses de convivência. Aqui teve de tudo: notas no README sobre seu comportamento estranho com Suspense, refactors do middleware para ler os domínios protegidos de um arquivo de config, e uma mudança de useProfile para useUser para parar de manter minha própria camada em cima da deles.
  • Provedor dois para provedor três. Um PR inteiro chamado "migrate from" mais o nome do anterior. Trinta arquivos tocados. Apagam-se stack.ts e a rota catch-all app/handler/[...stack]/page.tsx, e aparecem auth.ts no cliente e app/api/auth/[...path]/route.ts no servidor.
  • Provedor três para middleware próprio. O commit diz literalmente "replace neonAuth with custom auth middleware and update session handling". Do SDK gerenciado fico com o cliente; o resto eu controlo.

O que sempre quebra: as URLs

Na segunda migração há dois commits no mesmo dia, um atrás do outro. O primeiro deixa o arquivo de configuração assim:

export const stackServerApp = new StackServerApp({
  tokenStore: "nextjs-cookie",
  urls: {
    signIn: "/ass",
    signUp: "/asdds",
  },
});

Isso é o que acontece quando você está testando na mão para onde cada coisa redireciona e esquece a digitação de teste ali. O segundo commit devolve os dois para /auth. É ridículo e é exatamente a falha que você vai engolir em toda migração de auth: o provedor não sabe onde mora sua tela de login, então há uma URL declarada na config dele que precisa bater com uma rota real do router, e não há tipos que verifiquem isso. Redireciona para um 404 e o sintoma não é "URL errada": é "o login não funciona".

A outra metade do que quebra é a forma da sessão. Na passagem para o middleware próprio, getSession deixou de devolver o objeto do SDK e passou a devolver algo explícito:

type SessionData = NonNullable<Awaited<ReturnType<typeof auth.getSession>>["data"]>;
export type Session = SessionData | { session: null; user: null };

Esse | { session: null; user: null } é a concessão que é preciso fazer. O SDK anterior devolvia um nullable pelado e metade do código fazia session?.user. Ao normalizar, todos os lugares que assumiam que a sessão podia ser undefined pararam de compilar. Essa é a parte cara da migração, não o login.

O que ganho e o que perco escrevendo eu mesmo

O que ganho é concreto: controle sobre o objeto de sessão, sobre onde o cookie é guardado e sobre o que acontece exatamente quando não há sessão. E uma coisa que eu não tinha valorizado até precisar: poder colocar regras de autorização no mesmo lugar.

Numa ferramenta de BI para uma empresa grande, a autorização acabou sendo uma lista de e-mails no próprio middleware:

const authorizedEmails = [ /* ... */ ];
const hasFullAccess = userEmail && authorizedEmails.includes(userEmail);

Com dois redirecionamentos distintos: o não autenticado vai para o login, o autenticado sem permissão vai para a home. Isso, com um provedor gerenciado, se faz brigando com as claims ou o painel dele. Ali escrevi em dez linhas. Também deixei um console.log("User email:", userEmail) que teve que sair num commit posterior, e naquele app a auth foi reescrita três vezes em um mês.

O que perco é tudo o que o provedor fazia e você não via: rotação de tokens, invalidação de sessões, o e-mail de recuperação, o rate limit do login. Nada disso é impossível, mas passa a ser código meu, sem testes que eu mesmo não tenha escrito.

Meu critério agora

Não é "escreva sua própria auth". É mais chato: assuma desde o dia um que você vai trocar de provedor. O que faço hoje antes de instalar qualquer coisa:

  • Uma única função getSession() no meu código. Nenhum componente importa o SDK. O tipo de retorno eu defino, não infiro do provedor. É a diferença entre migrar trinta arquivos e migrar um.
  • O user_id do meu banco de dados é meu. O do provedor vai numa coluna à parte. Senão, a migração deixa de ser uma troca de biblioteca e vira uma migração de dados.
  • As URLs de login numa constante, não na config do provedor. Que o /ass não tenha onde se esconder.
  • Autorização nunca dentro do provedor. Quem pode entrar é do provedor; o que pode fazer é do meu código.

Faz pouco tempo comecei um projeto novo e voltei a começar com um provedor gerenciado. No primeiro dia não deu para entrar: a URL base de auth apontava para um valor de preenchimento do boilerplate. O ciclo começa de novo, e desta vez sei exatamente onde vai doer.