Voltar ao Blog
SaaS & Side Projects23 de novembro de 202411 min de leitura

Social Login + Auth Passwordless: Lições Aprendidas

DR

Doug Ribeiro

11 min de leitura

Autenticação é a parte de todo aplicativo que desenvolvedores subestimam, constroem rápido demais e depois passam meses corrigindo. Eu sabia disso de antemão. Disse a mim mesmo que faria certo da primeira vez. Ainda assim levei uns tombos, não catastróficos, mas de formas que quero documentar para que outros desenvolvedores cometam um pouco menos dos mesmos erros.

Meu projeto pessoal, um app social que construo localmente com Angular e Flask, suporta quatro métodos de autenticação: login social com Google, login social com Facebook, login social com Bluesky (que é notavelmente diferente dos outros) e magic link passwordless por email, além da opção de conectar múltiplos métodos à mesma conta. A arquitetura que trata tudo isso passa por Firebase Auth no lado do cliente e uma camada de auth Flask customizada no backend, com o Firebase enviando os emails de magic link. Este é um projeto pessoal, não um produto. Ele roda na minha máquina, nunca foi publicado, e nada do que vem a seguir foi dimensionado para o tráfego de ninguém além do meu.

Este post percorre como construí cada peça, os erros que cometi e as considerações de segurança que eu gostaria que fossem mais destacadas em cada tutorial que li.

Dois Caminhos de Arquitetura

Para um aplicativo que precisa de Google, Facebook, magic link por email e talvez uma identidade federada como Bluesky, você tem duas arquiteturas realistas. Cada uma é uma escolha legítima. A que você escolhe depende de você valorizar tempo-até-login-funcionando ou evitar dependência de vendor.

Firebase Auth (o que construí)Horas até Google + magic link f…SDKs dos provedores já tratados…Account linking é configurávelDepende da disponibilidade do F…Free tier cobre uma build local…Self-Hosted (Keycloak / Authentik)Dias a semanas para configurar …Fluxos OAuth2 que você controla…Propriedade total dos dados, on…Depende da sua maturidade opera…Grátis se você já roda infraVS

Se você não quer um fornecedor no seu caminho de autenticação, a via gratuita é rodar você mesmo: Keycloak é o padrão maduro, Authentik é o mais leve e moderno, e os dois são open source e não custam nada além da máquina onde você colocar. Uma coisa para checar antes de se comprometer: o passwordless embutido do Keycloak é WebAuthn, então magic links por email precisam de uma extensão. Escolhi o Firebase para esse projeto mesmo assim, porque o tempo-até-login-funcionando foi medido em horas e eu era um desenvolvedor só construindo uma primeira versão. O resto deste post percorre o caminho Firebase que implementei, e as opções self-hosted estão comparadas em detalhe no final.

Escolhendo o Firebase: Os Tradeoffs Que Fiz

A pergunta que a maioria dos desenvolvedores faz é: por que não implementar fluxos OAuth2 diretamente? Para login com Google e Facebook, eu absolutamente poderia. O fluxo OAuth2 é bem documentado, e existem boas bibliotecas Python para tratá-lo. Mas implementar OAuth2 corretamente para múltiplos provedores significa tratar token exchange, endpoints de user info específicos de cada provedor, formatos de token diferentes e respostas de erro diferentes para cada um. Você também assume a responsabilidade de tratar parâmetros de estado OAuth2 de forma segura para prevenir ataques CSRF.

Firebase Auth cuida de tudo isso. O SDK JavaScript no lado Angular gerencia o fluxo de redirect ou popup OAuth2, recebe o token do provedor, troca com Firebase e retorna um Firebase ID token. Meu backend Flask só vê Firebase ID tokens, nunca toco um token OAuth2 bruto. A complexidade específica de cada provedor fica escondida dentro do Firebase.

O tradeoff é uma dependência do Firebase para autenticação. Se Firebase Auth tiver uma queda, usuários não conseguem logar. Isso é um risco real. Mas para um projeto pessoal desenvolvido solo em fase inicial, a economia de tempo de desenvolvimento vale a dependência. Posso substituir Firebase Auth por uma implementação direta depois quando o projeto justificar o investimento de engenharia.

O Auth Service no Angular

O auth service no Angular encapsula chamadas do SDK Firebase e produz um estado de auth interno limpo que o resto da aplicação usa. O app Angular nunca fala com Firebase diretamente exceto por esse serviço.

typescript
// Angular auth service wrapping Firebase + Flask
@Injectable({ providedIn: 'root' })
export class AuthService {
  private auth = getAuth();
  private readonly apiService = inject(ApiService);

  // Internal state
  private _currentUser = signal<AppUser | null>(null);
  private _authState = signal<AuthState>('loading');

  readonly currentUser = this._currentUser.asReadonly();
  readonly authState = this._authState.asReadonly();
  readonly isAuthenticated = computed(() => this._currentUser() !== null);

  constructor() {
    // Listen to Firebase auth state changes
    onAuthStateChanged(this.auth, async (firebaseUser) => {
      if (!firebaseUser) {
        this._currentUser.set(null);
        this._authState.set('unauthenticated');
        return;
      }

      try {
        const idToken = await firebaseUser.getIdToken();
        const appUser = await this.exchangeTokenWithBackend(idToken);
        this._currentUser.set(appUser);
        this._authState.set('authenticated');
      } catch (error) {
        await signOut(this.auth);
        this._authState.set('error');
      }
    });
  }

  async signInWithGoogle(): Promise<void> {
    const provider = new GoogleAuthProvider();
    await signInWithPopup(this.auth, provider);
    // onAuthStateChanged handles the rest
  }

  async signInWithFacebook(): Promise<void> {
    const provider = new FacebookAuthProvider();
    await signInWithPopup(this.auth, provider);
  }

  private async exchangeTokenWithBackend(idToken: string): Promise<AppUser> {
    return firstValueFrom(
      this.apiService.post<AppUser>('/auth/verify', { id_token: idToken })
    );
  }
}

A Camada de Verificação de Token no Flask

Cada requisição que requer autenticação passa por um decorator Flask que verifica o Firebase ID token. Não armazeno tokens Firebase. Verifico on the fly usando o Firebase Admin SDK, que checa a assinatura do token contra as chaves públicas do Firebase e valida expiração, audience e issuer.

python
import firebase_admin
from firebase_admin import auth as firebase_auth
from functools import wraps
from flask import request, g
from app.models.user import User
from app import db
from sqlalchemy import select

def firebase_auth_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        auth_header = request.headers.get('Authorization', '')
        if not auth_header.startswith('Bearer '):
            return error_response([{'code': 'MISSING_TOKEN', 'message': 'Authorization header required'}], 401)

        id_token = auth_header.split('Bearer ')[1]

        try:
            decoded = firebase_auth.verify_id_token(id_token)
        except firebase_auth.ExpiredIdTokenError:
            return error_response([{'code': 'TOKEN_EXPIRED', 'message': 'Token has expired'}], 401)
        except firebase_auth.InvalidIdTokenError:
            return error_response([{'code': 'INVALID_TOKEN', 'message': 'Invalid token'}], 401)

        firebase_uid = decoded['uid']
        user = db.session.execute(
            select(User).where(User.firebase_uid == firebase_uid)
        ).scalar_one_or_none()

        if not user:
            return error_response([{'code': 'USER_NOT_FOUND', 'message': 'User not found'}], 404)

        g.current_user = user
        return f(*args, **kwargs)
    return decorated

Um endpoint não pode usar esse decorator: /auth/verify, o que o service do Angular chama a cada mudança de estado de autenticação do Firebase. É o único lugar onde um token válido do Firebase pode chegar legitimamente sem uma linha de usuário por trás, então ele verifica o token por conta própria e provisiona o usuário local no primeiro sign-in, a partir das claims verificadas e de mais nada. É por isso que o decorator pode tratar uma linha ausente como 404 em vez de criar uma em silêncio: quando qualquer outro endpoint roda, a linha já existe.

python
# /auth/verify: the only endpoint that runs before a local user row exists
@auth_bp.route('/verify', methods=['POST'])
def verify():
    id_token = (request.get_json(silent=True) or {}).get('id_token', '')

    try:
        decoded = firebase_auth.verify_id_token(id_token)
    except firebase_auth.ExpiredIdTokenError:
        return error_response([{'code': 'TOKEN_EXPIRED', 'message': 'Token has expired'}], 401)
    except firebase_auth.InvalidIdTokenError:
        return error_response([{'code': 'INVALID_TOKEN', 'message': 'Invalid token'}], 401)

    user = db.session.execute(
        select(User).where(User.firebase_uid == decoded['uid'])
    ).scalar_one_or_none()

    if user is None:
        # First sign-in. Trust the verified claims only, never the request body
        user = User(
            firebase_uid=decoded['uid'],
            email=decoded.get('email'),
            email_verified=decoded.get('email_verified', False),
            display_name=decoded.get('name'),
        )
        db.session.add(user)

    # sign_in_provider is what the login page uses to show the last method used
    user.last_sign_in_provider = decoded.get('firebase', {}).get('sign_in_provider')
    db.session.commit()

    return success_response(user_schema.dump(user))

Tratando Múltiplos Provedores para o Mesmo Usuário

É aqui que a maioria dos tutoriais de auth para e onde a maioria dos bugs mora. Um usuário se cadastra com Google usando seu endereço gmail.com. Depois, tenta logar com magic link para o mesmo email. São o mesmo usuário? Deveriam ser. Mas se você cria ingenuamente um novo registro de usuário para cada método de autenticação, eles viram duas contas diferentes.

O Firebase te dá uma parte disso. Com a configuração 'Link accounts that use the same email' (uma conta por endereço de email), o Firebase não cria uma segunda conta para um email que ele já conhece. Ele não mescla nada sozinho, porém: entrar com um provedor diferente para aquele email falha com auth/account-exists-with-different-credential, e cabe ao app autenticar o usuário com o método original e então chamar linkWithCredential. A exceção é o Google no próprio domínio de email dele, que o Firebase trata como autoritativo, então um usuário gmail.com entrando com Google nunca esbarra nesse erro. Eu habilito a configuração para os métodos baseados em email, e com esse erro tratado, login Google e magic link no mesmo email compartilham um Firebase UID.

Mas Facebook é diferente. Facebook não garante que o endereço de email que um usuário fornece é o mesmo do Google. Um usuário pode usar emails diferentes em plataformas sociais diferentes. Para Facebook, não consigo vincular contas automaticamente por email porque não posso confiar que os emails correspondem à intenção do usuário.

Minha solução: após o usuário estar autenticado, mostro os provedores conectados nas configurações da conta e deixo eles vincular provedores adicionais manualmente enquanto logados. Vincular enquanto autenticado é seguro. O SDK Firebase trata o fluxo de vinculação, e meu backend armazena a lista resultante de provedores vinculados. Usuários que querem mesclar contas devem fazer isso explicitamente.

Implementação de Magic Link

Auth passwordless com magic link é tratada pela autenticação Email Link do Firebase. O fluxo é: usuário digita email, Firebase envia um link de sign-in para esse email, usuário clica no link, SDK Firebase no lado Angular intercepta a URL e completa o sign-in produzindo um Firebase ID token. O mesmo fluxo de verificação de token do login social assume dali.

As partes complicadas estão na UX, não na implementação técnica. Os links de sign-in por email do Firebase expiram seis horas depois de enviados, e o SDK cliente não expõe nenhuma configuração para mudar isso. Completar o sign-in exige o endereço de email de novo (o Firebase pede para evitar fixação de sessão), e o fluxo padrão lê esse endereço do localStorage do browser onde o usuário digitou. No mobile, um link tocado pode abrir em um browser diferente daquele onde o email foi digitado, e o auth falha sem motivo aparente. Deep linking para apps mobile adiciona outra camada de complexidade.

Minhas soluções: a janela de seis horas é fixa, então o trabalho de UX vai para o caminho de falha. Detecto o cenário de cross-browser e mostro uma mensagem de erro específica com instruções para copiar e colar o link no browser desejado. Ainda não implementei deep linking, então no mobile o fallback é terminar o login no browser.

Login Bluesky: o AT Protocol é diferente

Bluesky é notável porque não se encaixa no molde do social login padrão. O AT Protocol, que sustenta o Bluesky, tem seu próprio modelo de identidade baseado em DIDs (Decentralized Identifiers) e resolução de handle, e o perfil OAuth que ele publicou em setembro de 2024 é OAuth 2.1 com PKCE, PAR e DPoP obrigatórios, o que uma configuração genérica de cliente OAuth não cobre de cara. Firebase Auth não tem provedor Bluesky integrado.

Minha implementação: autentico contra a API do Bluesky diretamente do Flask usando o handle Bluesky do usuário e app password (não a senha principal. App passwords do Bluesky são credenciais com escopo). Em auth Bluesky bem-sucedida, recebo um Bluesky DID e sessão. Então crio ou busco um usuário no meu banco por esse DID e emito meu próprio token de sessão. Não passei pelo Firebase para Bluesky porque não havia ponto de integração natural. App passwords são o caminho legado, porém: desde aquela publicação de setembro de 2024, o Bluesky pede que projetos novos usem OAuth no lugar, então trate a minha versão como a rápida, não como a que se deve copiar.

Isso significa que usuários autenticados via Bluesky usam um tipo de token diferente dos usuários autenticados via Firebase. Meu decorator de auth no backend trata ambos: primeiro verifica um Firebase Bearer token, e se falhar, verifica meu token de sessão Bluesky customizado. Isso é mais complexo que um único caminho de auth, mas o AT Protocol do Bluesky é convincente o suficiente como plataforma que quis suportá-lo apesar do overhead de integração.

Firebase (Google/Magic Link)Mature SDK with Angular supportToken refresh handled automatic…Email magic link out of the boxVerified on Flask with ID tokenBluesky (AT Protocol)DID-based decentralized identityOAuth 2.1 profile (PKCE, PAR, D…More integration overheadNo official Angular SDK. Manual…VS
python
# Bluesky authentication endpoint
from atproto import Client as BlueskyClient

@auth_bp.route('/bluesky', methods=['POST'])
def bluesky_login():
    data = request.get_json()
    handle = data.get('handle', '').strip()
    app_password = data.get('app_password', '')

    if not handle or not app_password:
        return validation_error('credentials', 'Handle and app password required')

    try:
        client = BlueskyClient()
        profile = client.login(handle, app_password)
        did = profile.did
    except Exception:
        return error_response(
            [{'code': 'BLUESKY_AUTH_FAILED', 'message': 'Invalid handle or app password'}],
            status=401
        )

    # Find or create user by Bluesky DID
    user = UserService.find_or_create_bluesky_user(
        did=did,
        handle=handle,
        display_name=profile.display_name
    )

    # Issue internal session token
    session_token = SessionTokenService.create(user.id)
    return success_response({'session_token': session_token, 'user': user_schema.dump(user)})

Considerações de Segurança Que Quase Perdi

Tokens de magic link devem ser de uso único. Após um magic link ser clicado e o auth completar, aquele link deve ser invalidado. Firebase trata isso automaticamente, pois magic links do Firebase são de uso único. Mas se você construir um sistema de magic link customizado sem Firebase, esse é um requisito crítico fácil de esquecer.

Refresh de token deve ser tratado de forma transparente. Firebase ID tokens expiram após uma hora. Eu não guardo o token. O HTTP interceptor que anexa o token às requisições de API pede ao Firebase o token atual a cada requisição, e getIdToken() devolve o token em cache enquanto ele ainda é válido e o renova quando está perto de expirar.

typescript
// HTTP interceptor that always gets a fresh Firebase token
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  private auth = getAuth();

  intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
    // Only attach auth header to our API requests
    if (!req.url.includes('/api') && !req.url.includes('/auth')) {
      return next.handle(req);
    }

    return from(this.getToken()).pipe(
      switchMap(token => {
        if (!token) return next.handle(req);
        const authReq = req.clone({
          setHeaders: { Authorization: `Bearer ${token}` }
        });
        return next.handle(authReq);
      })
    );
  }

  private async getToken(): Promise<string | null> {
    const user = this.auth.currentUser;
    if (!user) return null;
    // getIdToken() returns cached token if still valid (default); getIdToken(true) forces refresh
    return user.getIdToken(false);
  }
}

Rate limiting em endpoints de auth é inegociável. Endpoints de magic link em particular podem ser abusados para inundar a caixa de entrada de um usuário com emails de sign-in se não houver rate limiting. Neste projeto os emails de magic link saem pelo Firebase, que aplica as próprias cotas de envio e limites por IP; um sistema de magic link próprio precisa dos próprios limites por email e por IP. Para endpoints que controlo diretamente, como o endpoint de auth Bluesky, implementei rate limiting com Flask-Limiter.

As Lições de UX

A parte técnica de auth foi difícil. A UX de auth foi mais difícil. Usuários não pensam em termos de provedores de autenticação. Pensam em termos de como logaram da última vez. Se um usuário se cadastrou com Google e depois tenta usar magic link com o mesmo email, esperam cair na mesma conta. Quando não caem, acham que o produto está quebrado. Desenhar as regras de vinculação, e achar um texto de UI que deixe a regra óbvia sem um parágrafo de explicação, levou mais iteração do que qualquer peça técnica que construí.

A outra lição de UX: sempre diga ao usuário qual método de auth eles usaram quando retornam. Minha página de login lembra o último método usado em localStorage e mostra de forma proeminente. A premissa para a qual desenhei é que ninguém lembra se clicou em Google ou usou magic link seis semanas atrás. Exibir isso tira a frustração de tentar três métodos de auth antes de encontrar o certo.

Autenticação é a primeira experiência que usuários têm com seu produto. Investir o tempo para torná-la impecável, tanto tecnicamente quanto do ponto de vista de UX, se paga toda vez que um usuário retorna sem fricção.

O Caminho Self-Hosted em Detalhes

O Firebase Auth foi a escolha pragmática aqui porque o tempo-até-login-funcionando foi medido em horas, não semanas. Essa é uma razão legítima. Mas se você quer self-host ou evitar o lock-in do vendor, a stack tá resolvida o suficiente pra subir um equivalente funcional em dias, não num projeto de pesquisa.

As opções que eu escolheria: Keycloak (maduro, feature-complete, a escolha padrão pra OIDC self-hosted). Authentik (mais leve, moderno, boa developer experience). Lucia (auth como código dentro do seu próprio back end JavaScript; o autor anunciou em outubro de 2024 que o pacote seria depreciado em março de 2025 e seguiria vivo como um guia para você escrever esse código na mão). Supabase Auth (self-hostable se você roda o Supabase, managed se não). Ory (developer-first, mais partes móveis). Os quatro serviços te dão sessões e social login com trade-offs operacionais diferentes; com o Lucia essa parte você escreve. Passwordless é onde eles mais divergem: o passwordless embutido do Keycloak é WebAuthn, e magic links por email precisam de uma extensão, então confira essa peça antes de escolher.

Se você está começando hoje e sabe que quer self-hosted, Keycloak é minha recomendação padrão para a maioria dos times. Authentik se você quer algo mais leve e moderno. Os guias do Lucia se você se sente confortável em manter auth como código em vez de serviço. Entre Keycloak e Authentik os tradeoffs são operacionais, não de capacidade: os dois cobrem a superfície de OIDC e social login que o Firebase cobre, e para magic links por email, confira o que cada um suporta antes de escolher.

Referências

AuthenticationAngularFlaskFirebase