// ESTUDO · ARQUITETURA SAAS SEGURA · OPEN SOURCE

AuthStudy

Prova de conceito de uma arquitetura SaaS moderna: BFFs em .NET 10, Authentik self-hosted como provedor de identidade (OIDC), autorização ABAC/RBAC com EF Core + PostgreSQL e microfrontends React — tudo rodando 100% local, sem nenhuma dependência de nuvem.

OPEN SOURCE .NET 10BFFAUTHENTIK / OIDCABAC/RBACMICROFRONTEND

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

  1. 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.
  2. 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 do docker compose up.
  3. BFFs em .NET 10: uma camada de backend por frontend que troca o code por tokens, guarda a sessão no Redis atrás de um cookie HttpOnly, e valida bearer tokens via JWKS do Authentik.
  4. 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.
  5. Microfrontends React: App Shell em :3000 que carrega frontends independentes, consumindo o BFF em :5000.
  6. 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.