Ir para o conteúdo

OIDC e Segurança de Tokens

Todo serviço Wittgenstein delega a identidade ao Keycloak via OpenID Connect (OIDC) com PKCE, e toda aplicação construída sobre a plataforma recebe o mesmo tratamento reforçado de tokens, pronto para uso, por meio de duas bibliotecas compartilhadas:

Biblioteca Executa em Responsabilidade
wittgenstein-oidc-backend (Python) API / BFF Troca de tokens como cliente confidencial, custódia do refresh token, verificação de JWT, checagem de papéis, provisionamento JIT de usuários
@wittgenstein/oidc-client (npm) SPA no navegador Login PKCE, endpoint /oidc no mesmo origin, refresh e nova tentativa automáticos em 401

O objetivo é uma única implementação, auditada, das partes do OIDC que são fáceis de errar de forma sutil — para que nenhuma aplicação precise reinventar armazenamento de tokens, refresh ou validação de assinatura.

Segurança por construção

Uma aplicação que adota as duas bibliotecas nunca expõe um refresh token ao JavaScript, nunca envia um client secret ao navegador e nunca aceita um token sem assinatura onde um assinado é exigido. Essas são propriedades das bibliotecas, não convenções que cada time precisa lembrar.

Modelo de ameaças em resumo

Ameaça Mitigação
XSS rouba uma sessão de longa duração O refresh token vive apenas em um cookie HttpOnly; o JavaScript só enxerga um access token de vida curta
Interceptação do authorization code O PKCE (S256) vincula o code ao navegador que iniciou o fluxo
Client secret vazado pela SPA O backend é o cliente confidencial; o secret nunca sai do servidor
Requisição cross-site forja um refresh de token Cookie SameSite=Lax mais uma lista de Origin permitidas no grant de refresh
Credenciais roubadas reutilizadas via password grant Direct access grants (ROPC) desabilitados nos clientes SPA
JWT forjado ou adulterado Verificação de assinatura RS256 contra o JWKS do realm, com checagem do emissor
Tentativas de força bruta no login Detecção de força bruta e bloqueio no nível do realm
Sessão antiga ou revogada persiste Um refresh rejeitado remove o cookie; a SPA volta ao redirecionamento de login
Login fraco de fator único Verificação de email e MFA aplicados no IdP

Arquitetura de referência

O navegador só conversa com o próprio origin. A rota /oidc é servida pelo backend da aplicação, que é o verdadeiro cliente OIDC.

sequenceDiagram
    participant User as Usuário
    participant SPA as SPA React<br/>(@wittgenstein/oidc-client)
    participant BFF as Backend<br/>(wittgenstein-oidc-backend)
    participant KC as Keycloak

    User->>SPA: Clica em "Entrar"
    SPA->>SPA: Gera code_verifier + code_challenge (S256)
    SPA->>KC: Redireciona para /auth (code_challenge)
    KC-->>User: Tela de login: senha e depois MFA
    User->>KC: Credenciais + segundo fator
    KC-->>SPA: Redireciona para /callback?code=...
    SPA->>BFF: POST /oidc/.../token (code, code_verifier)
    BFF->>KC: POST /token (code, code_verifier, client_id + client_secret)
    KC-->>BFF: access_token, id_token, refresh_token
    BFF-->>SPA: access_token, id_token + Set-Cookie: refresh_token (HttpOnly, Secure, SameSite=Lax)
    Note over SPA,BFF: O refresh token nunca chega ao JavaScript
    SPA->>BFF: Chamada de API com Bearer access_token
    BFF-->>SPA: 401 (access token expirado)
    SPA->>BFF: POST /oidc/.../token (grant_type=refresh_token)
    BFF->>KC: refresh_token do cookie + client secret
    KC-->>BFF: novos tokens (refresh token rotacionado)
    BFF-->>SPA: novo access_token + cookie rotacionado
    SPA->>BFF: Repete a chamada original

Backend: wittgenstein-oidc-backend

O endpoint de tokens em uma chamada

O create_oidc_bff_router monta o roteador /oidc no mesmo origin. Ele troca o authorization code, executa os grants de refresh, remove o refresh token do corpo JSON e o move para um cookie, e repassa JWKS/userinfo/logout.

from fastapi import FastAPI
from wittgenstein_oidc_backend import create_oidc_bff_router

app = FastAPI()
app.include_router(
    create_oidc_bff_router(
        authority="https://auth.example.com/realms/acme",
        client_id="acme-app",
        client_secret=settings.oidc_client_secret,   # permanece no servidor
        allowed_origins=["https://app.example.com"],  # protege o grant de refresh
    ),
    prefix="/oidc",
)

O que isso entrega sem código adicional:

  • Troca confidencial — o client_secret é anexado pelo backend; a SPA envia apenas o code e o code_verifier.
  • Custódia do refresh token — HttpOnly, Secure, SameSite=Lax, restrito ao caminho /oidc e rotacionado a cada refresh.
  • Cerca de origin — uma requisição de refresh com cabeçalho Origin fora de allowed_origins é recusada com 403.
  • Remoção fail-closed — se o Keycloak rejeitar um refresh (expirado, revogado, reutilizado), o cookie morto é apagado e a próxima tentativa passa por um login completo.

Verificando tokens

O decode_token espelha os dois modos da política JWT do próprio AgentGateway, de modo que um serviço pode ser protegido no gateway, no serviço, ou nos dois:

Modo Comportamento Use quando
"validate" Verificação completa de assinatura RS256 contra o JWKS do realm (com cache por TTL), mais checagem do emissor O token chega diretamente ao serviço
"trust" Apenas decodifica, sem checar a assinatura Um gateway a montante já validou o token
import functools
from wittgenstein_oidc_backend import JwksClient, OidcMiddleware, decode_token, has_role

issuer = "https://auth.example.com/realms/acme"
jwks = JwksClient(f"{issuer}/protocol/openid-connect/certs")
decode = functools.partial(decode_token, mode="validate", jwks_client=jwks, issuer=issuer)

app.add_middleware(OidcMiddleware, decode=decode)   # interpreta o token uma vez por requisição


@app.post("/access")
@has_role(["admin", "manager"])                     # 401 sem token válido, 403 sem o papel
async def update_access(): ...

trust é opt-in

O modo trust nunca é um padrão silencioso. Use-o apenas atrás de um gateway que valide assinaturas; caso contrário, use validate.

Provisionamento de identidades

O jit_provision_user(payload, store) encontra ou cria o usuário local por trás de uma identidade Keycloak e sincroniza seus papéis. Ele é escrito sobre um pequeno protocolo UserStore, então funciona com qualquer ORM e não adiciona dependência de banco de dados à biblioteca.

Frontend: @wittgenstein/oidc-client

A SPA nunca guarda um refresh token, então a renovação silenciosa é feita sob demanda, apenas quando a API informa que o access token não é mais válido:

import { OidcProvider, createAuthFetch } from "@wittgenstein/oidc-client";

let apiFetch = fetch;

<OidcProvider
  authority="https://auth.example.com/realms/acme"
  clientId="acme-app"
  onManager={(userManager) => { apiFetch = createAuthFetch(userManager); }}
>
  {children}
</OidcProvider>

O createAuthFetch anexa o access token e, em um 401/403, faz o refresh uma vez pelo backend e repete a requisição em vez de deslogar o usuário. Quem está no meio de um formulário longo não perde o trabalho quando um token expira.

Defesa em profundidade

O tratamento de tokens é uma camada entre várias:

  1. Borda — o TLS termina no AgentGateway, que valida JWTs contra o JWKS do Keycloak (pelo sidecar extProc) e injeta cabeçalhos de identidade confiáveis a montante.
  2. Serviço — o wittgenstein-oidc-backend verifica o token novamente (validate) ou confia no gateway (trust), e então aplica os papéis por rota.
  3. Chamadas que alteram estado — os POSTs entre serviços usam um token CSRF double-submit (cookie e cabeçalho X-CSRF-Token precisam coincidir).
  4. Provedor de identidade — detecção de força bruta, verificação de email e MFA, e um modelo de realm/client por aplicação.

Verifique você mesmo

Confirme as propriedades acima pelo terminal. Troque o host pelo do seu ambiente.

$ curl -s -o /dev/null -D - -X POST https://app.example.com/oidc/protocol/openid-connect/token \
    -d "grant_type=refresh_token" | grep -i -E "^(HTTP|set-cookie)"
HTTP/2 400
$ # nenhum cookie de refresh foi enviado, então o backend recusa em vez de adivinhar

$ curl -s -X POST https://app.example.com/oidc/protocol/openid-connect/token \
    -H "Origin: https://evil.example.net" -d "grant_type=refresh_token"
{"error":"origin not allowed"}
$ # a cerca de Origin rejeita tentativas de refresh cross-site

$ curl -s https://auth.example.com/realms/acme/.well-known/openid-configuration | jq '.code_challenge_methods_supported'
[
  "plain",
  "S256"
]

Decodifique, não confie

Para depurar um token, decodifique-o localmente (jq -R 'split(".")[1] | @base64d | fromjson') em vez de usar um site externo. Access tokens são credenciais.