Motivação do estudo
O objetivo do AuthStudy é entender, na prática, como montar a espinha dorsal de autenticação e autorização de um produto SaaS multi-tenant de forma segura e desacoplada — e como fazer isso sem depender de nenhum provedor hospedado.
Em vez de guardar tokens no localStorage (exposto a XSS), o estudo explora o padrão BFF (Backend for
Frontend). O token nunca chega ao JavaScript do navegador: o BFF conduz um fluxo OIDC Authorization
Code + PKCE contra o Authentik — um provedor de identidade
open source rodando localmente — e guarda os tokens resultantes em sessões no Redis, atrás de um
cookie HttpOnly. Além disso, o estudo investiga autorização fina (ABAC/RBAC) e como compor uma UI a
partir de microfrontends independentes sob um App Shell.
O que foi feito
- Login/registro sem sair da tela: o BFF faz proxy da Flow Executor API do Authentik — o mesmo
motor JSON que a própria UI do Authentik usa — então o formulário de login/cadastro renderiza dentro
do AuthStudy. O navegador nunca visita a UI hospedada do Authentik; ele só entra em cena, de forma
invisível, no exato instante da troca
authorization code → token. - Provisionamento declarativo: um blueprint YAML sobe automaticamente o client OIDC, o fluxo de
enrollment self-service e o mapeamento de
roles(grupos do Authentik viram claim no token), sem precisar clicar em nada na primeira subida dodocker compose up. - BFFs em .NET 10: uma camada de backend por frontend que troca o
codepor tokens, guarda a sessão no Redis atrás de um cookieHttpOnly, e valida bearer tokens via JWKS do Authentik. - Times e permissões granulares: todo usuário nasce dono (Owner) do seu próprio time e também é promovido a administrador da plataforma automaticamente no primeiro login — como estudo, não há fronteira de confiança entre contas aqui. Como dono, ele convida outros usuários e cria papéis (cargos) sob medida a partir de um catálogo de permissões — cada requisição ao serviço de produtos é autorizada em tempo real contra esse modelo, não só escondida na UI.
- Microfrontends React: App Shell em
:3000que carrega frontends independentes, consumindo o BFF em:5000. - Zero dependência externa: todo o stack — identity provider incluído — sobe com um único
docker compose up --build, sem chave de API, sem projeto hospedado, sem internet.
Como funciona
Visão geral do sistema
flowchart TB
Browser["🌐 Navegador (usuário)"]
subgraph MFEs["Frontends — React"]
AuthMFE["auth-mfe :3000\nlogin · registro · dashboard · perfil"]
CatalogMFE["catalog-mfe :3001\nprodutos · lojas · preços"]
TeamsMFE["teams-mfe :3002\nmembros do time · cargos"]
end
BFF["bff :5000 — .NET 10\ncliente OIDC · proxy Flow Executor · YARP · Cookie→JWT"]
Redis[("redis\nsessões + estado PKCE")]
subgraph AppServices["Serviços de app — .NET 10"]
Roles["roles\nTimes · Usuários · Cargos · Permissões"]
Products["products\nProdutos · Lojas · Preços"]
end
Postgres[("postgres\ndados da aplicação")]
subgraph AuthentikStack["Authentik — OIDC self-hosted"]
AkServer["authentik-server :9000\nOIDC + Flow Executor API"]
AkWorker["authentik-worker\ntarefas em background"]
AkPg[("authentik-postgresql")]
AkRedis[("authentik-redis")]
end
Browser --> AuthMFE
Browser --> CatalogMFE
Browser --> TeamsMFE
AuthMFE -->|mesma origem /api/*| BFF
CatalogMFE --> BFF
TeamsMFE --> BFF
BFF -->|sessões, estado PKCE| Redis
BFF -->|"proxy /api/auth/flow/**\n+ OIDC authorize/token/JWKS"| AkServer
BFF -->|"YARP: /api/roles/*, /api/teams/*"| Roles
BFF -->|"YARP: /api/products/*, /api/stores/*, /api/prices/*"| Products
Roles --> Postgres
Products --> Postgres
Products -->|"GET /api/internal/permissions/check"| Roles
AkServer --> AkPg
AkServer --> AkRedis
AkWorker --> AkPg
AkWorker --> AkRedis
Autenticação: login e registro sem sair da tela
sequenceDiagram
actor U as Usuário (navegador)
participant FE as auth-mfe
participant BFF as bff
participant AK as authentik-server
participant R as redis
participant Roles as roles service
Note over U,AK: Registro (ou login) — nunca navega até localhost:9000
U->>FE: abre /register
FE->>BFF: GET /api/auth/flow/authstudy-enrollment-flow/
BFF->>AK: GET /api/v3/flows/executor/... (proxy)
AK-->>BFF: JSON do estágio (usuário/email/nome/senha) + Set-Cookie
BFF-->>FE: JSON do estágio (cookie reescrito p/ o host do BFF)
FE-->>U: renderiza formulário nativo do AuthStudy
U->>FE: envia os campos do formulário
FE->>BFF: POST /api/auth/flow/authstudy-enrollment-flow/ (+cookie)
BFF->>AK: POST (proxy, +cookie relay)
AK-->>BFF: estágio user-write → conta criada
BFF-->>FE: fluxo concluído → redireciona para /login
U->>FE: envia identificação + senha
FE->>BFF: POST /api/auth/flow/default-authentication-flow/
BFF->>AK: POST (proxy)
AK-->>BFF: fluxo concluído + cookie de sessão do Authentik
BFF-->>FE: cookie relay, fluxo concluído
FE->>BFF: GET /api/auth/login (navegação completa)
BFF->>R: guarda code_verifier (PKCE) por state
BFF-->>U: 302 → AK /authorize?...&code_challenge=...
Note over U,AK: Authentik já tem sessão + provider com implicit consent\n→ sem prompt interativo
U->>AK: GET /authorize (cookie do Authentik anexado)
AK-->>U: 302 → BFF /api/auth/callback?code=...&state=...
U->>BFF: GET /api/auth/callback
BFF->>R: busca + apaga code_verifier pelo state
BFF->>AK: POST /application/o/token/ (code + verifier)
AK-->>BFF: access_token, id_token, refresh_token
BFF->>R: SET session:{id} = tokens (TTL 8h)
BFF->>Roles: POST /api/teams/bootstrap (Bearer access_token)
Roles-->>BFF: time pessoal garantido (idempotente)
BFF-->>U: Set-Cookie SessionID (HttpOnly) + 302 → /dashboard
Autorização: permissões por time, em tempo real
sequenceDiagram
actor U as Usuário (navegador)
participant FE as catalog-mfe
participant BFF as bff
participant MW as CookieToJwtMiddleware
participant P as products service
participant TH as TeamPermissionHandler
participant RC as RolesPermissionClient
participant Roles as roles service (interno)
U->>FE: "criar produto" no time X
FE->>BFF: POST /api/products (cookie SessionID, X-Tenant-Id: timeX)
BFF->>MW: requisição de entrada
MW->>MW: SessionID → sessão no redis → Authorization: Bearer access_token
MW->>P: POST /api/products via YARP (Bearer + X-Tenant-Id)
P->>P: AddJwtBearer valida o token contra o JWKS do Authentik
P->>TH: autoriza política "products:write"
TH->>TH: o token tem a claim de admin da plataforma?
alt admin da plataforma
TH-->>P: permite (bypass)
else checagem por time
TH->>RC: check(subject, tenantId=X-Tenant-Id, action="products:write")
RC->>Roles: GET /api/internal/permissions/check?...
Roles->>Roles: Usuário → Cargo do usuário → Cargo → Permissão do cargo → Permissão
Roles-->>RC: { allowed: true|false }
RC-->>TH: permitido?
TH-->>P: permite / nega
end
alt permitido
P->>P: insere Produto, salva no postgres
P-->>BFF: 201 Created
else negado
P-->>BFF: 403 Forbidden
end
BFF-->>FE: resposta repassada
FE-->>U: UI atualiza / mostra "sem permissão"
Consumindo uma API: carregando o dashboard
sequenceDiagram
actor U as Usuário
participant FE as auth-mfe (Dashboard)
participant BFF as bff
participant MW as CookieToJwtMiddleware
participant Roles as roles service
U->>FE: navega até /dashboard
FE->>BFF: GET /api/v1/auth/me (cookie SessionID)
BFF->>MW: cookie → Bearer token
MW->>BFF: [Authorize] lê as claims validadas do JWT
BFF-->>FE: { isAuthenticated, userId, email, roles }
FE->>BFF: GET /api/teams (YARP → roles)
BFF->>MW: cookie → Bearer token
MW->>Roles: GET /api/teams (proxy)
Roles->>Roles: busca Times onde o usuário é membro
Roles-->>BFF: [{ id, nome, éPessoal }]
BFF-->>FE: lista de times
FE->>BFF: GET /api/teams/{id}/members
BFF->>Roles: GET (proxy YARP)
Roles->>Roles: Usuários ⋈ Cargos-do-usuário ⋈ Cargos do time {id}
Roles-->>BFF: [{ userId, email, nomeDoCargo }]
BFF-->>FE: lista de membros
FE-->>U: renderiza card de perfil + time + membros
Modelo de dados
Cada serviço é dono do seu próprio schema — nenhuma chave estrangeira cruza a fronteira entre serviços,
mesmo os dois compartilhando um único container postgres por conveniência local.
roles — o modelo de times e permissões:
erDiagram
TENANT ||--o{ USER : "tem membros"
TENANT ||--o{ ROLE : define
ROLE ||--o{ ROLE_PERMISSION : concede
PERMISSION ||--o{ ROLE_PERMISSION : "concedida via"
USER ||--o{ USER_ROLE : "atribuído"
ROLE ||--o{ USER_ROLE : "atribuído a"
TENANT {
guid Id PK
string Name
bool IsPersonal "true só no time pessoal auto-criado"
datetimeoffset CreatedAt
}
USER {
guid Id PK
guid TenantId FK
string AuthentikSubjectId "claim sub do JWT do Authentik"
string Email
bool IsActive
}
ROLE {
guid Id PK
guid TenantId FK
string Name
jsonb Conditions
}
PERMISSION {
guid Id PK
string Action "ex.: products:write, teams:manage_roles"
}
ROLE_PERMISSION {
guid RoleId FK
guid PermissionId FK
}
USER_ROLE {
guid UserId FK
guid RoleId FK
}
products — catálogo (sem TenantId próprio; o isolamento por time é aplicado só na camada de
autorização, via o header X-Tenant-Id):
erDiagram
PRODUCT ||--o{ PRODUCT_PRICE : "precificado em"
STORE ||--o{ PRODUCT_PRICE : "vendido em"
STORE ||--o{ USER_STORE : "atendido por"
PRODUCT {
int Id PK
string Name
string Description
decimal Price "preço base/tabela"
}
STORE {
guid Id PK
string Name
string Description
bool IsActive
}
PRODUCT_PRICE {
guid Id PK
int ProductId FK
guid StoreId FK
decimal Price
bool IsActive
}
USER_STORE {
guid UserId "subject do Authentik — sem FK local, cross-service"
guid StoreId FK
}
Stack
- ◆ BACKEND: .NET 10 (BFFs), EF Core, PostgreSQL, Redis (sessões).
- ⛨ IDENTIDADE: Authentik self-hosted, OIDC Authorization Code + PKCE, JWKS.
- ◈ FRONTEND: React em arquitetura de microfrontends sob um App Shell.
- ⌂ INFRA: Docker Compose pra orquestrar todos os serviços — app e identity provider — localmente.
Como rodar
# 1. Copiar o template de env (os valores padrão já funcionam)
cp .env.example .env
# 2. Subir tudo — a primeira subida demora alguns minutos: o Authentik roda
# suas próprias migrations e aplica o blueprint de provisionamento
docker compose up --build
# 3. App Shell React → http://localhost:3000
# BFF (.NET 10) → http://localhost:5000
# Authentik → http://localhost:9000 # Código aberto
O AuthStudy é open source, feito pra estudar e servir de referência de arquitetura.