Pular para o conteúdo principal

ADR-009: Autenticação e Identidade — Keycloak + JWT

Status: Aceita
Data: 2026-06-27

Contexto

APIs e workers precisam de autenticação stateless compatível com o fator VI do 12-factor (processos sem estado). Sessões armazenadas em arquivo ou memória local vinculam requisições a uma instância específica do Pod — o que é incompatível com a escala horizontal do Kubernetes. A solução deve:

  • Validar identidade sem chamada de rede por requisição (performance em escala)
  • Suportar comunicação machine-to-machine (workers chamando APIs)
  • Ser operável como serviço self-hosted em Kubernetes (sem dependência de SaaS pago)

Decisão

Keycloak como Identity Provider (IdP) externo, JWT como formato de token de acesso, Crypt::JWT para validação local do token usando a chave pública do Keycloak obtida via endpoint JWKS.

Justificativa

O Keycloak é o provedor de identidade open source líder do ecossistema cloud-native. Implementa OpenID Connect e OAuth 2.0, roda como container em Kubernetes e é adotado por organizações que precisam de controle total sobre dados de identidade.

Validação local via JWKS é o padrão de performance para JWT: a API baixa a chave pública do Keycloak uma vez (no startup) e valida todos os tokens localmente — sem chamada de rede ao Keycloak em cada requisição. A rotação de chaves do Keycloak é tratada via refresh periódico do JWKS.

Crypt::JWT é o módulo Perl para codificação e decodificação de JWT (RS256, ES256, HS256), com suporte a validação de exp, iss, aud e claims customizados.

O fluxo client_credentials do OAuth 2.0 cobre a comunicação machine-to-machine: um worker ou microserviço obtém um token de acesso do Keycloak usando suas próprias credenciais (client_id + client_secret) e usa esse token para chamar APIs protegidas.

Referências: Keycloak, The Twelve-Factor App, Mojolicious

Fluxo de autenticação de usuário final

Usuário → Login no Keycloak → Recebe JWT access token
JWT access token → Authorization: Bearer <token> na requisição à API
API → valida token localmente com chave pública do Keycloak (JWKS)
API → extrai claims (sub, email, roles) do payload do JWT

Atenção: as roles (realm_access.roles) devem ser extraídas do access_token, não do id_token. Por padrão, o Keycloak não inclui realm_access no id_token — só no access_token. Aplicações que fazem login via authorization code flow (fluxo web, não apenas M2M) e usam apenas o id_token para montar a sessão do usuário não terão as roles disponíveis. A Stega troca o code por ambos os tokens no callback, mas sempre decodifica o access_token para obter realm_access.roles.

Fluxo machine-to-machine (workers e microserviços)

Worker → POST /realms/{realm}/protocol/openid-connect/token
com grant_type=client_credentials, client_id, client_secret
Worker recebe → access token JWT
Worker → Authorization: Bearer <token> na chamada à API

Configuração via variáveis de ambiente

# URL usada pelo servidor para chamadas servidor→servidor ao Keycloak
# (troca de código por token, busca de chave pública via JWKS)
KEYCLOAK_URL=https://auth.example.com

# URL do Keycloak visível pelo browser (redirects de login e logout).
# Necessária quando KEYCLOAK_URL aponta para um hostname interno (ex: Docker networking)
# que o browser não consegue resolver. Se omitida, usa KEYCLOAK_URL.
KEYCLOAK_FRONTEND_URL=https://auth.example.com

KEYCLOAK_REALM=myapp
KEYCLOAK_CLIENT_ID=myapp-api
KEYCLOAK_CLIENT_SECRET=secret # apenas para workers/M2M com client confidencial

Carregamento do JWKS e validação de JWT

O JWKS pode ser carregado no startup (mais eficiente) ou na primeira validação (mais simples). Recomenda-se carregá-lo no startup e cacheá-lo no processo — isso evita uma chamada HTTP ao Keycloak em cada requisição:

# lib/MyApp.pm
use Mojo::Base 'Mojolicious';
use Mojo::UserAgent;
use Crypt::JWT qw(decode_jwt);

my $jwks_cache; # cache por processo (cada worker Hypnotoad tem o seu)

sub startup {
my $self = shift;

$self->helper(verify_jwt => sub {
my ($c, $token) = @_;

my $claims = eval { _decode_jwt($token) };
return (undef, "Token inválido: $@") if $@;
return ($claims, undef);
});
}

sub _decode_jwt {
my $token = shift;

# Lê o alg do header sem validação criptográfica
my ($hdr_b64) = split /\./, $token;
$hdr_b64 =~ tr/-_/+\//;
$hdr_b64 .= '=' x ((4 - length($hdr_b64) % 4) % 4);
require MIME::Base64; require JSON::PP;
my $alg = JSON::PP::decode_json(MIME::Base64::decode_base64($hdr_b64))->{alg} // '';

if ($alg eq 'HS256') {
my $secret = $ENV{TEST_JWT_SECRET} or die "TEST_JWT_SECRET não configurada";
return decode_jwt(token => $token, key => $secret, accepted_alg => 'HS256');
}

# RS256/RS384/RS512: usa cache do JWKS para evitar HTTP por requisição
unless ($jwks_cache) {
my $url = $ENV{KEYCLOAK_URL} . '/realms/' . ($ENV{KEYCLOAK_REALM} // 'app')
. '/protocol/openid-connect/certs';
$jwks_cache = Mojo::UserAgent->new->get($url)->result->json;
}

my ($jwk) = grep { ($_->{use} // '') eq 'sig' } @{$jwks_cache->{keys} // []};
$jwk //= ($jwks_cache->{keys} // [])->[0];
die "Nenhuma chave no JWKS" unless $jwk;

# Crypt::JWT aceita um hash JWK diretamente no parâmetro 'key'
return decode_jwt(
token => $token,
key => $jwk,
accepted_alg => ['RS256', 'RS384', 'RS512'],
);
}

1;

Acessando claims nos controladores

# lib/MyApp/Controller/User.pm
package MyApp::Controller::User;
use Mojo::Base 'Mojolicious::Controller';

sub profile {
my $self = shift;
my $claims = $self->stash('jwt_claims');

# Sub (subject) é o ID do usuário no Keycloak
my $user_id = $claims->{sub};
my $email = $claims->{email};
my $roles = $claims->{realm_access}{roles} // [];

$self->render(json => {
user_id => $user_id,
email => $email,
roles => $roles,
});
}

1;

Obtenção de token em workers (client_credentials)

# lib/MyApp/Auth/ClientCredentials.pm
package MyApp::Auth::ClientCredentials;
use Moo;
use Mojo::UserAgent;
use namespace::clean;

has 'ua' => ( is => 'ro', default => sub { Mojo::UserAgent->new } );
has '_token' => ( is => 'rw' );
has '_exp' => ( is => 'rw', default => 0 );

sub token {
my $self = shift;

# Reutilizar token existente se ainda válido (com margem de 60s)
return $self->_token if time() < ($self->_exp - 60);

my $res = $self->ua->post(
$ENV{KEYCLOAK_URL} . '/realms/' . $ENV{KEYCLOAK_REALM}
. '/protocol/openid-connect/token',
form => {
grant_type => 'client_credentials',
client_id => $ENV{KEYCLOAK_CLIENT_ID},
client_secret => $ENV{KEYCLOAK_CLIENT_SECRET},
}
)->result;

die "Falha ao obter token: " . $res->body unless $res->is_success;

my $data = $res->json;
$self->_token($data->{access_token});
$self->_exp(time() + $data->{expires_in});

return $self->_token;
}

1;
# Uso no worker ao chamar outra API protegida
my $auth = MyApp::Auth::ClientCredentials->new;
my $result = $ua->get(
'http://other-service/api/v1/data',
{ Authorization => 'Bearer ' . $auth->token }
)->result->json;

Docker Compose: Keycloak em desenvolvimento

O Keycloak em desenvolvimento usa o mesmo PostgreSQL da aplicação (database separada keycloak). Um script de inicialização cria a database automaticamente:

-- docker/postgres-init/01-keycloak-db.sql
CREATE DATABASE keycloak;
services:
keycloak:
image: quay.io/keycloak/keycloak:26.6
command: start-dev
depends_on:
postgres:
condition: service_healthy
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: postgres # mesmo superusuário do compose em desenvolvimento
KC_DB_PASSWORD: postgres_dev
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- "8080:8080"

Usar PostgreSQL como backend do Keycloak em desenvolvimento garante que a configuração do realm (clients, roles, usuários) persiste entre restarts do container, eliminando a necessidade de reconfiguração a cada docker compose up.

Alternativas Consideradas

AlternativaMotivo da rejeição
Sessões locais (Mojolicious::Sessions)Com estado: vincula o cliente a uma instância específica do Pod, incompatível com escala horizontal no Kubernetes
Auth0 / OktaServiços SaaS com custo por usuário ativo; vendor lock-in; dados de identidade em infraestrutura de terceiros
Token Introspection (chamada ao Keycloak por requisição)Latência adicional em cada requisição; Keycloak vira single point of failure síncrono da API
DexAlternativa open source ao Keycloak, mas com menor ecossistema e sem interface administrativa madura
HTTP Basic AuthSem suporte a tokens de curta duração, sem RBAC integrado, sem M2M; insuficiente para arquitetura cloud-native
Mojolicious::Plugin::OAuth2Plugin genérico de cliente OAuth2/OIDC: descobre authorize_url/token_url via well_known_url e oferece um helper jwt_decode. Reduziria o código manual de login/callback (troca de code por token). Avaliado e rejeitado por ora: o fluxo de autorização já implementado (redirect + troca de código + validação via JWKS) tem ~40 linhas, é auditável linha a linha e já resolve o caso conhecido de id_token sem realm_access.roles (ver nota abaixo). Adotar o plugin trocaria código já testado por uma dependência nova cujo comportamento de cache/renovação de JWKS e de armazenamento de token precisaria ser revalidado do zero — sem redução real de complexidade
Mojolicious::Plugin::OIDCImplementação mais completa do protocolo OIDC (descoberta, PKCE, gestão de sessão). Mesma razão de rejeição do OAuth2: a Stega já usa validação local via JWKS com Crypt::JWT (necessária de qualquer forma para as rotas de API, que recebem apenas o Authorization: Bearer, sem fluxo de redirect) — introduzir um segundo mecanismo de validação apenas para o fluxo web duplicaria lógica em vez de reutilizá-la

Consequências

Positivo:

  • Validação local: nenhuma chamada de rede ao Keycloak em cada requisição
  • Stateless: qualquer réplica da API valida qualquer token
  • client_credentials cobre M2M sem gerenciar sessões manualmente
  • Keycloak self-hosted: sem dependência de SaaS e sem custo por usuário

Negativo:

  • Keycloak é um serviço adicional na infraestrutura (requer mais recursos de memória)
  • Rotação de chaves JWKS requer mecanismo de refresh periódico ou no startup
  • O exp (expiração) do JWT deve ser monitorado: tokens longos aumentam janela de risco; tokens curtos aumentam frequência de refresh nos clientes

Ações necessárias:

  • Adicionar Crypt::JWT ao cpanfile
  • Adicionar serviço keycloak ao Docker Compose com configuração de realm de desenvolvimento
  • Configurar Secret no Kubernetes com KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID e KEYCLOAK_CLIENT_SECRET para workers
  • Implementar refresh do JWKS (recarregar chaves periodicamente ou ao receber erro 401 downstream)