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 ocodee ocode_verifier. - Custódia do refresh token —
HttpOnly,Secure,SameSite=Lax, restrito ao caminho/oidce rotacionado a cada refresh. - Cerca de origin — uma requisição de refresh com cabeçalho
Originfora deallowed_originsé recusada com403. - 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:
- 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. - Serviço — o
wittgenstein-oidc-backendverifica o token novamente (validate) ou confia no gateway (trust), e então aplica os papéis por rota. - Chamadas que alteram estado — os
POSTs entre serviços usam um token CSRF double-submit (cookie e cabeçalhoX-CSRF-Tokenprecisam coincidir). - 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.