Auth Firebase (ID token)¶
O app mobile faz login no Firebase, recebe um ID token e manda esse token pra sua API. O backend não emitiu nada — ele só precisa provar que o token é real antes de resolver quem é o usuário.
FirebaseAuth embala esse caminho inteiro:
- inicializa o app do
firebase_adminuma vez só, mesmo que você construa o autenticador em dois módulos diferentes; - verifica o ID token fora do event loop (o verificador do Google é síncrono);
- entrega uma identidade tipada (
FirebaseIdentity), não umdict[str, Any]; e - traduz cada falha do Firebase para a hierarquia de exceções do SDK,
com um
codediferente pra cada uma.
Instalação
Precisa do extra [firebase] — uv add "tempest-fastapi-sdk[firebase]".
Ele é pesado: firebase-admin traz grpcio, protobuf,
google-api-core, google-auth e os clientes de Firestore/Storage.
Medido com firebase-admin 7.5.0 numa venv limpa: 33 pacotes,
52 MB. Por isso ele fica fora do extra [all], e o import é
preguiçoso — import tempest_fastapi_sdk continua funcionando sem
ele instalado.
Quando usar isto
Use quando o cliente já chega com um ID token do Firebase (app Flutter/React Native, ou web usando o Firebase JS SDK).
- Se o seu serviço é quem faz login e emite tokens, você quer Auth flow.
- Se você recebe um bearer opaco e valida perguntando a um
userinfoupstream, você quer Auth por introspecção.
O caminho mínimo¶
Construa uma vez, na camada de dependências:
# src/api/dependencies/auth.py
from tempest_fastapi_sdk import FirebaseAuth
from src.core.settings import settings
firebase = FirebaseAuth(
credentials_path=settings.FIREBASE_CREDENTIALS_PATH,
project_id=settings.FIREBASE_PROJECT_ID,
)
E use os métodos direto como dependências:
# src/api/routers/profile.py
from fastapi import APIRouter, Depends
from tempest_fastapi_sdk import FirebaseAuth, FirebaseIdentity
from src.core.settings import settings
firebase = FirebaseAuth(credentials_path=settings.FIREBASE_CREDENTIALS_PATH)
router = APIRouter(prefix="/api/profile", tags=["profile"])
@router.get("/me")
async def me(
identity: FirebaseIdentity = Depends(firebase.get_identity),
) -> dict[str, str]:
"""Devolve o usuário autenticado pelo ID token do Firebase."""
return {"uid": identity.uid, "email": identity.email or ""}
@router.get("/uid")
async def uid(user_id: str = Depends(firebase.get_uid)) -> dict[str, str]:
"""Quando você só precisa do id, não da identidade inteira."""
return {"uid": user_id}
Pronto. Requisição sem Authorization recebe 401; token expirado,
adulterado ou de outro projeto recebe 401 — cada um com o seu
code.
Ligue os handlers de exceção
FirebaseAuth levanta subclasses de UnauthorizedException e
ForbiddenException do próprio SDK. Chame
register_exception_handlers(app) (o create_app() do SDK já faz)
pra que virem 401/403 com corpo {"detail": ..., "code": ...} em
vez de 500.
Como funciona, peça por peça¶
Inicialização idempotente¶
firebase_admin.initialize_app() levanta ValueError na segunda
chamada. É por isso que todo serviço acaba com o mesmo bloco
get_app() / except ValueError espalhado por três arquivos.
FirebaseAuth é dono desse bloco. Construir duas vezes com o mesmo
app_name reaproveita o app existente:
from tempest_fastapi_sdk import FirebaseAuth
from src.core.settings import settings
first = FirebaseAuth(credentials_path=settings.FIREBASE_CREDENTIALS_PATH)
second = FirebaseAuth(credentials_path=settings.FIREBASE_CREDENTIALS_PATH)
Isso não é teoria: tests/auth/test_firebase.py constrói dois
autenticadores e afirma que ambos apontam para o mesmo app.
Precisa falar com dois projetos Firebase no mesmo processo? Dê nomes diferentes:
from tempest_fastapi_sdk import FirebaseAuth
from src.core.settings import settings
consumers = FirebaseAuth(
credentials_path=settings.FIREBASE_CREDENTIALS_PATH,
app_name="consumers",
)
drivers = FirebaseAuth(
credentials_path=settings.FIREBASE_DRIVERS_CREDENTIALS_PATH,
app_name="drivers",
)
De onde vem a credencial¶
Três canais, nesta ordem de precedência:
credentials_json— o JSON da service account inline, pra deploy que injeta segredo por variável de ambiente e não monta volume;credentials_path— o arquivo da service account no disco;- nada — cai na credencial default do ambiente
(
GOOGLE_APPLICATION_CREDENTIALS, ou o metadata server quando você roda dentro da infraestrutura do Google).
import os
from tempest_fastapi_sdk import FirebaseAuth
firebase = FirebaseAuth(
credentials_json=os.environ["FIREBASE_CREDENTIALS_JSON"],
project_id="meu-app-3f21c",
)
JSON inválido, arquivo ausente ou ambiente sem credencial default viram
FirebaseCredentialError — que é um RuntimeError, não uma
AppException. O motivo: isso é erro de configuração, acontece na
construção, e nunca no meio de uma requisição.
A identidade tipada¶
FirebaseIdentity é um dataclass congelado. O handler nunca vê o dict
cru:
import asyncio
from tempest_fastapi_sdk import FirebaseAuth, FirebaseIdentity
from src.core.settings import settings
firebase = FirebaseAuth(credentials_path=settings.FIREBASE_CREDENTIALS_PATH)
async def main() -> None:
"""Run this example."""
identity: FirebaseIdentity = await firebase.verify("eyJhbGciOi...")
print(identity.uid)
print(identity.email, identity.email_verified)
print(identity.phone_number)
print(identity.provider) # "google.com", "password", "phone", ...
print(identity.claims["role"]) # custom claims continuam acessíveis
asyncio.run(main())
claims guarda tudo que o token trouxe, incluindo custom claims que
você setou com set_custom_user_claims. Os campos nomeados são o que
99% das rotas usam; claims é a saída pro resto.
Os erros, um code por falha¶
| Situação | Exceção | HTTP | code |
|---|---|---|---|
Sem header Authorization |
FirebaseTokenMissingError |
401 | FIREBASE_TOKEN_MISSING |
| Token malformado, assinatura errada, outro projeto | FirebaseTokenInvalidError |
401 | FIREBASE_TOKEN_INVALID |
| Token expirado | FirebaseTokenExpiredError |
401 | FIREBASE_TOKEN_EXPIRED |
Token revogado (só com check_revoked=True) |
FirebaseTokenRevokedError |
401 | FIREBASE_TOKEN_REVOKED |
Usuário desabilitado (só com check_revoked=True) |
FirebaseUserDisabledError |
403 | FIREBASE_USER_DISABLED |
| Certificados do Google inacessíveis | FirebaseUnavailableError |
401 | FIREBASE_UNAVAILABLE |
Por que a ordem dos except importa
No firebase-admin 7.5.0, ExpiredIdTokenError e
RevokedIdTokenError são subclasses de InvalidIdTokenError
(medido, não deduzido). Uma implementação que capturasse a classe
mãe primeiro colapsaria os três casos em FIREBASE_TOKEN_INVALID —
e o cliente perderia a informação que decide entre "renove o token"
e "faça login de novo". O teste parametrizado do SDK trava essa
ordem.
Usuário desabilitado é 403, não 401
Ele provou quem é; o que falta é permissão. Por isso é a única falha que a variante soft (abaixo) continua levantando.
A variante soft — rota que serve anônimo e logado¶
get_optional_identity devolve None em vez de levantar:
from fastapi import APIRouter, Depends
from tempest_fastapi_sdk import FirebaseAuth, FirebaseIdentity
from src.core.settings import settings
firebase = FirebaseAuth(credentials_path=settings.FIREBASE_CREDENTIALS_PATH)
router = APIRouter(prefix="/api/feed", tags=["feed"])
@router.get("/")
async def feed(
identity: FirebaseIdentity | None = Depends(firebase.get_optional_identity),
) -> dict[str, bool]:
"""Personaliza quando há token, e serve anônimo quando não há."""
return {"personalized": identity is not None}
Sem header → None. Token que não verifica → None também (logado em
DEBUG com o code, nunca com o token). Usuário desabilitado →
continua 403.
Precisa estreitar o tipo dentro do handler? Use o guard do SDK:
from fastapi import APIRouter, Depends
from tempest_fastapi_sdk import FirebaseAuth, FirebaseIdentity
from tempest_fastapi_sdk.auth import require_authenticated
from src.core.settings import settings
firebase = FirebaseAuth(credentials_path=settings.FIREBASE_CREDENTIALS_PATH)
router = APIRouter(prefix="/api/orders", tags=["orders"])
@router.post("/")
async def create_order(
maybe: FirebaseIdentity | None = Depends(firebase.get_optional_identity),
) -> dict[str, str]:
"""Aceita a requisição só quando o token veio e verificou."""
identity: FirebaseIdentity = require_authenticated(maybe)
return {"uid": identity.uid}
Do uid ao seu usuário¶
O SDK não decide como um uid do Firebase vira usuário do seu banco —
isso é regra sua (lookup por coluna, provisionamento just-in-time,
chamada a outro serviço). FirebaseUserResolver é a costura:
from fastapi import APIRouter, Depends
from tempest_fastapi_sdk import (
FirebaseAuth,
FirebaseIdentity,
FirebaseUserResolver,
)
from src.core.settings import settings
from src.db.models import UserModel
from src.db.repositories import UserRepository
firebase = FirebaseAuth(credentials_path=settings.FIREBASE_CREDENTIALS_PATH)
repository = UserRepository()
async def load_user(identity: FirebaseIdentity) -> UserModel | None:
"""Mapeia a identidade verificada para o usuário local."""
return await repository.get_by_firebase_uid(identity.uid)
users: FirebaseUserResolver[UserModel] = FirebaseUserResolver(firebase, load_user)
router = APIRouter(prefix="/api/account", tags=["account"])
@router.get("/")
async def account(user: UserModel = Depends(users.get_user)) -> dict[str, str]:
"""A rota já recebe o usuário do banco, com o tipo concreto."""
return {"id": str(user.id)}
Resolver que devolve None significa "essa identidade não tem usuário
aqui" — vira 401 com FIREBASE_TOKEN_INVALID e details={"uid": ...},
não uma resposta vazia. get_optional_user é a versão soft.
repository e UserModel são glue da sua aplicação
UserRepository.get_by_firebase_uid(...) representa a sua camada de
dados — não faz parte do SDK. Troque pela chamada real do projeto.
check_revoked — o custo de saber na hora¶
Por padrão a verificação é local: assinatura + claims contra os
certificados públicos do Google, que o firebase_admin busca e cacheia.
Nesse modo, revogar uma sessão no console não derruba o token até ele
expirar.
check_revoked=True faz cada verificação também perguntar ao backend do
Firebase se o token foi revogado e se o usuário está desabilitado —
uma ida à rede por requisição:
from tempest_fastapi_sdk import FirebaseAuth
from src.core.settings import settings
firebase = FirebaseAuth(
credentials_path=settings.FIREBASE_CREDENTIALS_PATH,
check_revoked=True,
clock_skew_seconds=5,
)
clock_skew_seconds dá folga pro relógio do cliente adiantado — útil
quando o app roda em celular com hora manual.
Configuração via settings¶
O mixin FirebaseSettings já traz as três variáveis, com título e
descrição no padrão dos outros:
from tempest_fastapi_sdk import BaseAppSettings, FirebaseAuth
from tempest_fastapi_sdk.settings import FirebaseSettings
class Settings(FirebaseSettings, BaseAppSettings):
"""Settings da aplicação."""
settings = Settings()
firebase = FirebaseAuth(**settings.firebase_kwargs())
| Variável | Para quê |
|---|---|
FIREBASE_PROJECT_ID |
Projeto dos tokens. Opcional quando a service account já carrega. |
FIREBASE_CREDENTIALS_PATH |
Caminho do arquivo de service account. |
FIREBASE_CREDENTIALS_JSON |
O mesmo JSON inline, pra deploy sem volume montado. |
firebase_kwargs() descarta valores vazios, então variável não
setada deixa o default do construtor de pé em vez de mandar caminho
vazio. settings.enabled diz se há service account explícita — note que
False não impede verificar: a credencial default do ambiente ainda
funciona.
Testando¶
Patch em verify_id_token, no módulo real — assim o mapeamento de erro
é exercitado contra as classes de exceção verdadeiras, com a herança
verdadeira:
from typing import Any
import pytest
from firebase_admin import auth as firebase_auth
from tempest_fastapi_sdk import FirebaseAuth, FirebaseTokenExpiredError
CLAIMS: dict[str, Any] = {"uid": "uid-123", "email": "person@example.com"}
async def test_expired_token(monkeypatch: pytest.MonkeyPatch) -> None:
"""Token expirado vira o code do SDK, não uma exceção do Google."""
def fake_verify(id_token: str, **_: Any) -> dict[str, Any]:
"""Simula o verificador do Google."""
raise firebase_auth.ExpiredIdTokenError("expired", None)
monkeypatch.setattr(firebase_auth, "verify_id_token", fake_verify)
firebase = FirebaseAuth(credentials_path="tests/fixtures/service-account.json")
with pytest.raises(FirebaseTokenExpiredError) as error:
await firebase.verify("token")
assert error.value.code == "FIREBASE_TOKEN_EXPIRED"
Nada de rede, nada de credencial real
A suíte do SDK gera uma chave RSA local e monta um arquivo de
service account sintático — initialize_app aceita, porque ele não
conversa com o Google. E um token que não é um JWT é recusado
sem tocar a rede (medido): o verificador confere a estrutura
antes de qualquer busca de certificado.
Recap¶
FirebaseAuthverifica ID token do Firebase; quem emite o token é o Firebase, não o seu serviço.- Inicialização é idempotente por
app_name— construir duas vezes reaproveita o mesmo app; nomes distintos falam com projetos distintos. - Credencial vem de JSON inline, arquivo, ou credencial default do
ambiente — nessa ordem. Falha de configuração é
FirebaseCredentialError. get_identity/get_uidsão estritos;get_optional_identityé a variante soft que devolveNone. Usuário desabilitado é 403 nos dois.- Cada falha tem
codepróprio; a ordem dosexceptpreserva a diferença entre expirado, revogado e inválido. FirebaseUserResolver[UserT]liga a identidade ao usuário do seu banco sem o SDK decidir a regra.- O extra
[firebase]é pesado (33 pacotes, 52 MB medidos) e fica fora de[all]; o import é preguiçoso, então só instanciar exige ele.