Pular para o conteúdo

Trilha de Kotlin · parte 42 de 59

Segurança com JWT e OpenAPI

Aula da fase 6 da trilha de Kotlin: Como validar claims em um resource server e documentar a API com springdoc sem expor o que não deveria?

avançadoPublicada em · 17 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Pense num prédio de escritórios. Na portaria, o porteiro confere o crachá: ele foi emitido pela empresa certa, ainda está dentro da validade e não foi falsificado. Essa conferência responde a uma pergunta só: quem é você? Depois, cada andar tem uma porta que só abre para quem tem a autorização daquele setor. O crachá não diz se você pode entrar no almoxarifado, e a portaria não decide isso. Em uma API protegida por JWT, o token é o crachá, e a validação das suas declarações é a portaria. As portas dos andares são as regras de autorização. Esta aula trata da portaria, com o Spring Security como resource server, e de como a documentação da API é publicada com o springdoc. Tudo o que está aqui foi executado em um servidor local, com tokens assinados por uma chave RSA de teste.

JWT (JSON Web Token) é um token com três partes codificadas: um cabeçalho, um conjunto de declarações (os claims) e uma assinatura. As declarações mais comuns são iss, que diz quem emitiu o token, aud, que diz para quem ele foi emitido, exp, que diz até quando ele vale, e nbf, que diz a partir de quando ele vale. O resource server é a API que recebe o token e decide se aceita a requisição. OpenAPI é um formato padrão para descrever uma API HTTP em um documento que máquinas e pessoas conseguem ler.

o mapa da aula

A aula começa separando autenticação de autorização, porque boa parte dos erros de segurança com JWT vem de confundir as duas. Depois mostra a configuração de um resource server com o validador de audiência, com o código que subiu no servidor de teste. Em seguida apresenta o que o decoder confere por padrão, com a medição do clock skew e os casos de token inválido, expirado e com escopo insuficiente, todos com as respostas HTTP reais. Na sequência vem o springdoc, com o que foi observado na especificação gerada. Por fim, as bordas, os erros comuns e o que continua sem verificação.

Versões desta aula: Spring Boot 4.1.1, com Spring Security 7.1.1 e Spring Framework 7.0.9 gerenciados pelo BOM, Kotlin 2.3.21 e Nimbus JOSE JWT 10.9.1 como dependência de Spring Security. O springdoc é o starter springdoc-openapi-starter-webmvc-ui na versão 3.1.1, indicado pela documentação para Spring Boot 4.

1. autenticação e autorização

Autenticação responde a pergunta “quem é você?”. Autorização responde “você pode fazer isto?”. No modelo de resource server, o token prova a identidade, e as declarações dele podem trazer permissões. A regra final de quem acessa o quê fica na configuração de segurança da aplicação. Um token válido não garante acesso a todas as rotas, e um token inválido nunca deveria chegar à regra de autorização.

A documentação do Spring Security descreve o mapeamento de permissões. Cada scope presente no token vira uma autoridade com o prefixo SCOPE_. Por isso uma regra como hasAuthority("SCOPE_pedidos:ler") confere se o token tem o scope pedidos:ler. A página de JWT diz que o resource server vai “Map each scope to an authority with the prefix SCOPE_”. Essa convenção causa confusão quando o nome na regra não inclui o prefixo. O teste da seção 3 mostra o caso.

2. a configuração do resource server

A documentação do Spring Security dá a configuração mínima: autenticação em todas as requisições e validação de JWT com os valores padrão. A página de JWT do Spring Security acrescenta que “If the application doesn’t expose a SecurityFilterChain bean, then Spring Boot will expose the above default one.” Ou seja, o Spring Boot cria essa cadeia quando a aplicação não cria a sua. Declarar a própria é necessário para rotas públicas e para validações extras. A configuração abaixo é a que subiu no servidor de teste, em Kotlin. Ela troca o decoder padrão por um decoder que valida emissor, datas e audiência, e deixa as rotas públicas ou protegidas por escopo.

// imports omitidos: pacotes org.springframework.security.* e org.springframework.security.oauth2.*
@Configuration
@EnableWebSecurity
class ConfigSeguranca {
    @Bean
    fun jwtDecoder(@Value("\${app.jwks-uri}") jwks: String): JwtDecoder {
        val decoder = NimbusJwtDecoder.withJwkSetUri(jwks).build()
        val audiencia: OAuth2TokenValidator<Jwt> =
            JwtClaimValidator<List<String>>(JwtClaimNames.AUD) { aud -> aud.contains("pedidos-api") }
        decoder.setJwtValidator(
            DelegatingOAuth2TokenValidator(JwtValidators.createDefaultWithIssuer("https://idp.exemplo.com"), audiencia)
        )
        return decoder
    }

    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http.authorizeHttpRequests { a ->
            a.requestMatchers("/teste/**", "/jwks", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/error").permitAll()
                .requestMatchers("/seguro/ler").hasAuthority("SCOPE_pedidos:ler")
                .requestMatchers("/seguro/nome-errado").hasAuthority("pedidos:ler")
                .anyRequest().authenticated()
        }
        http.oauth2ResourceServer { o -> o.jwt(Customizer.withDefaults()) }
        return http.build()
    }
}

O decoder usa jwk-set-uri, que é o endereço das chaves públicas, e não issuer-uri. A documentação diz que, nesse caso, o resource server “will not ping the authorization server at startup”, e que o issuer-uri continua útil para validar o iss. A aplicação de teste passou o emissor diretamente em createDefaultWithIssuer, o que tem o mesmo efeito sobre o iss. A chave pública foi publicada pela própria aplicação em /jwks, e os tokens foram assinados com a chave privada correspondente, gerada para o teste.

Pergunta de entrevistaQual a diferença entre autenticação e autorização?

Autenticação responde quem é a pessoa ou o sistema, por exemplo, validando a assinatura de um JWT. Autorização responde o que essa identidade pode fazer, por exemplo, se o token tem o escopo pedidos:escrever. Na prática, o filtro JWT faz a autenticação, e as regras de acesso fazem a autorização. Um 401 indica que falta uma autenticação válida, e um 403 indica que a identidade é conhecida, mas não tem permissão.

3. o que o decoder confere por padrão

A documentação lista o que o resource server faz com um JWT bem formado. Ele valida a assinatura contra a chave pública obtida do endpoint de chaves, valida exp, nbf e iss, e converte os scopes em autoridades com o prefixo SCOPE_. A página diz que, com a configuração mínima apontando para o emissor, o resource server confere iss, exp e nbf, e que “By default, Resource Server configures a clock skew of 60 seconds.”

O aud não está nessa lista padrão. Um token emitido para outra API, com o mesmo emissor e datas válidas, passa pela validação padrão. O teste abaixo confirma isso, e mostra o skew. Ele usa JwtValidators.createDefault(), sem emissor, e tokens montados localmente, com as datas que cada caso pede:

-- validador (emissor + audiência + datas) --
[valido] aceito
[emissor errado] invalid_token: The iss claim is not valid
[audiencia errada] invalid_token: The aud claim is not valid
[expirado ha 3600 s] invalid_token: Jwt expired at <data omitida>
-- skew do validador padrão (createDefault, sem emissor) --
[expirado ha 30 s] aceito
[expirado ha 90 s] invalid_token: Jwt expired at <data omitida>
-- padrão sem checagem de aud --
[audiencia errada, padrao] aceito
-- padrão sem checagem de iss (createDefault) --
[emissor errado, padrao] aceito

A data de expiração foi omitida das linhas acima porque muda a cada execução. O resultado mostra três coisas. O validador composto rejeita emissor e audiência errados. O padrão aceita um token expirado há 30 segundos e rejeita um expirado há 90, o que confirma a tolerância de 60 segundos. E o padrão, sem o validador de audiência, aceita um token de outra audiência, que é o caso que a seção 4 corrige.

O mesmo comportamento apareceu no HTTP, com o servidor de teste e tokens assinados de verdade. A tabela resume as respostas:

Requisição a /seguro/ler (ou outra rota indicada)Resposta
Sem o header Authorization401, com WWW-Authenticate: Bearer resource_metadata="..."
Token válido, com SCOPE_pedidos:ler200
Token válido, sem o scope pedidos:ler403, com error="insufficient_scope"
Token com aud diferente de pedidos-api401, com error="invalid_token" e a descrição The aud claim is not valid
Token com iss diferente do emissor401, com a descrição The iss claim is not valid
Token expirado há 30 segundos200
Token expirado há 90 segundos401, com a descrição Jwt expired at ...
Token com scope pedidos:ler em /seguro/nome-errado, que exige hasAuthority("pedidos:ler")403

A última linha mostra a armadilha do prefixo. O token tinha o scope pedidos:ler, mas a autoridade gerada foi SCOPE_pedidos:ler, e a regra que pedia pedidos:ler sem o prefixo negou o acesso. O primeiro caso sem token responde 401, e a resposta traz o WWW-Authenticate com o endereço de metadados do recurso, que é um detalhe desta versão do Spring Security.

Os códigos 401 e 403 foram conferidos também no código-fonte do spring-security-oauth2-resource-server 7.1.1. A classe BearerTokenErrors define o erro INVALID_TOKEN com HttpStatus.UNAUTHORIZED e o erro INSUFFICIENT_SCOPE com HttpStatus.FORBIDDEN. A classe BearerTokenAccessDeniedHandler chama response.setStatus(HttpStatus.FORBIDDEN.value()). A documentação oficial descreve o mecanismo de AuthenticationEntryPoint e de WWW-Authenticate, mas não lista os códigos. Por isso a fonte para os códigos é o código-fonte, confirmado pelas respostas HTTP.

4. validador customizado de audiência

A documentação mostra a checagem do aud com a API OAuth2TokenValidator: “Adding a check for the aud claim is simple with the OAuth2TokenValidator API”. O validador é combinado com o padrão por DelegatingOAuth2TokenValidator, e o resultado vai para o JwtDecoder. O código abaixo é o que foi usado no teste sem servidor da seção 3. Ele monta os tokens localmente, e por isso não exercita a assinatura. A assinatura foi exercitada pelo servidor, na seção 3.

package aula.teste.v55

import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator
import org.springframework.security.oauth2.core.OAuth2TokenValidator
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.jwt.JwtClaimNames
import org.springframework.security.oauth2.jwt.JwtClaimValidator
import org.springframework.security.oauth2.jwt.JwtValidators
import java.time.Instant

fun token(iss: String, aud: List<String>, expiraEm: Instant): Jwt =
    Jwt.withTokenValue("token-de-teste")
        .header("alg", "RS256")
        .issuer(iss)
        .audience(aud)
        .issuedAt(expiraEm.minusSeconds(600))
        .expiresAt(expiraEm)
        .subject("ana")
        .build()

fun resultado(nome: String, v: OAuth2TokenValidator<Jwt>, jwt: Jwt) {
    val r = v.validate(jwt)
    val texto = if (!r.hasErrors()) "aceito" else r.errors.joinToString("; ") { "${it.errorCode}: ${it.description}" }
    println("[$nome] $texto")
}

fun main() {
    val emissor = "https://idp.exemplo.com"
    val audiencia: OAuth2TokenValidator<Jwt> = JwtClaimValidator<List<String>>(JwtClaimNames.AUD) { aud -> aud.contains("pedidos-api") }
    val validador = DelegatingOAuth2TokenValidator(JwtValidators.createDefaultWithIssuer(emissor), audiencia)
    val padraoSemIssuer = JwtValidators.createDefault()

    val agora = Instant.now()
    println("-- validador (emissor + audiência + datas) --")
    resultado("valido", validador, token(emissor, listOf("pedidos-api"), agora.plusSeconds(600)))
    resultado("emissor errado", validador, token("https://outro.exemplo.com", listOf("pedidos-api"), agora.plusSeconds(600)))
    resultado("audiencia errada", validador, token(emissor, listOf("outra-api"), agora.plusSeconds(600)))
    resultado("expirado ha 3600 s", validador, token(emissor, listOf("pedidos-api"), agora.minusSeconds(3600)))
    println("-- skew do validador padrão (createDefault, sem emissor) --")
    resultado("expirado ha 30 s", padraoSemIssuer, token(emissor, listOf("pedidos-api"), agora.minusSeconds(30)))
    resultado("expirado ha 90 s", padraoSemIssuer, token(emissor, listOf("pedidos-api"), agora.minusSeconds(90)))
    println("-- padrão sem checagem de aud --")
    resultado("audiencia errada, padrao", padraoSemIssuer, token(emissor, listOf("outra-api"), agora.plusSeconds(600)))
    println("-- padrão sem checagem de iss (createDefault) --")
    resultado("emissor errado, padrao", padraoSemIssuer, token("https://outro.exemplo.com", listOf("pedidos-api"), agora.plusSeconds(600)))
}

A figura abaixo mostra como os dois validadores se combinam. O padrão confere o emissor e as datas, o de audiência confere o aud, e o delegador junta os resultados. Os casos da parte de baixo vêm do teste da seção 3, com as respostas que o validador combinado deu para cada um.

Validadores de JWT compostos. JWT recebido → Padrão com emissor; JWT recebido → Audiência; Padrão com emissor → DelegatingOAuth2TokenValidator (resultado)…
figura 1 · Validadores de JWT compostos

Os dois validadores leem o mesmo JWT e não se conhecem. O de audiência não sabe que o emissor foi validado, e o padrão não sabe que existe uma regra de audiência. O delegador é o ponto em que os resultados se juntam. As setas verdes vão para os casos aceitos, e as rosas tracejadas para os que foram rejeitados. Como o validador de audiência é separado, outra regra pode ser somada sem mudar os existentes.

5. o que a requisição percorre

A figura abaixo resume o caminho completo de uma requisição protegida. Há três saídas antes do controller: a falta de token, a rejeição do token e a falta de autoridade. Cada uma tem um código HTTP diferente, e a diferença entre elas é a parte que mais vale guardar.

Requisição protegida por JWT. Cliente → Filtro do Spring Security (requisição); Filtro do Spring Security → JwtDecoder (token extraído); Emissor → JwtDecoder…
figura 2 · Requisição protegida por JWT

A primeira bifurcação é a portaria: o token é válido ou não. A segunda é a porta do andar: o token tem a autoridade exigida ou não. Uma requisição rejeitada na portaria responde 401 e nunca chega à regra de autorização. Uma requisição negada na porta já passou pela autenticação e responde 403. A seta tracejada do filtro para o 401 mostra o caso sem token, observado como 401 na tabela da seção 3.

6. documentar a API com springdoc

Um contrato de API só serve se estiver escrito em algum lugar. O springdoc gera uma especificação OpenAPI a partir dos controllers do Spring MVC. A documentação diz que a especificação JSON fica em http://server:port/context-path/v3/api-docs, a versão YAML em /v3/api-docs.yaml, e a interface do Swagger em http://server:port/context-path/swagger-ui.html. No servidor de teste, /v3/api-docs respondeu 200 e /swagger-ui.html respondeu 302, que é o redirecionamento para a interface. A versão YAML não foi requisitada no teste.

A geração segue os controllers. Cada rota nova aparece na documentação sem trabalho extra, e uma rota que deveria ficar fora dela precisa de uma decisão explícita. A documentação do springdoc cita a anotação @Hidden, do pacote io.swagger.v3.oas.annotations, para isso. O teste marcou um endpoint com @Hidden e outro sem a anotação. A especificação gerada trouxe /teste/visivel e não trouxe /teste/oculto. Esse é o comportamento observado.

A relação com a aula Tratamento de erros com ProblemDetail também foi testada. Os tipos de erro do ProblemDetail não aparecem na especificação gerada: o schema ProblemDetail não existe no documento, e o endpoint que lança PedidoNaoEncontrado não tem resposta 404 documentada. Para que o contrato de erro apareça, a equipe precisa declarar as respostas, e a aula não testou essa declaração.

7. detalhes e bordas

O clock skew de 60 segundos é uma tolerância, não um erro. A medição da seção 3 mostrou que um token expirado há 30 segundos passa e um expirado há 90 segundos não passa. A tolerância existe para absorver diferenças de relógio entre servidores. Ela não deve ser usada para estender a validade de um token, e alterá-la exige decisão da equipe.

A rotação de chaves é o segundo ponto. Com jwk-set-uri, o resource server usa o endereço indicado para buscar as chaves públicas. O caminho desse endereço depende do emissor, e a documentação do Spring Security não o padroniza. Cada emissor define o próprio caminho, e a configuração precisa ser conferida com a documentação de quem emite os tokens. A aula não testou a rotação de chaves, que exige um emissor que troque de chave durante a execução.

Sobre a assinatura, o servidor de teste validou um token assinado com a chave RSA publicada em /jwks. A assinatura de um token adulterado, que deveria ser recusada, não foi testada. Essa é uma lacuna declarada.

A terceira borda é o CSRF, e ela apareceu no servidor. A configuração acima não desabilita a proteção de CSRF, e por isso uma requisição POST sem token recebeu 403 antes de qualquer checagem de token. Um POST /pedidos sem token, com corpo válido ou inválido, respondeu 403, e não 401. Para uma API stateless que recebe o token no header, a equipe deve decidir explicitamente essa configuração. A aula não verificou a recomendação da documentação sobre CSRF em APIs com token, e apenas registra o comportamento observado.

8. erros comuns e boas práticas

O erro mais grave é aceitar um token sem conferir o aud. O token passa na validação padrão, o serviço responde, e a API aceita um token emitido para outro serviço do mesmo emissor. O teste da seção 3 mostrou esse caso: o padrão aceitou o token com a audiência errada. A correção é o validador da seção 4, e um teste que envie um token com aud errado deve esperar 401.

O segundo erro é confundir a autoridade com o scope. Uma regra escrita como hasAuthority("pedidos:ler") nunca é satisfeita por um token com o scope pedidos:ler, porque a autoridade gerada para esse scope é SCOPE_pedidos:ler. O teste mostrou o 403 esperado. A regra precisa usar o nome com o prefixo.

O terceiro erro é tratar a documentação como segurança. Uma página do Swagger aberta em produção mostra a superfície da API, e isso ajuda quem ataca. Se a documentação precisa existir fora do ambiente de desenvolvimento, a exposição deve ser decidida pela equipe, com a regra de acesso na configuração de segurança. A aula não verificou um mecanismo específico do springdoc para isso.

O quarto erro é guardar o token em log. O token é uma credencial, e um log que o registra permite que qualquer pessoa com acesso aos logs faça as mesmas chamadas. A boa prática é registrar o identificador da requisição e o motivo da rejeição, sem o token. Essa é uma prática geral de segurança, e não foi verificada em uma página do Spring.

A boa prática, em resumo, é validar emissor, audiência e datas no resource server, usar o prefixo SCOPE_ nas regras, decidir explicitamente a configuração de CSRF e manter a documentação gerada pelo springdoc como parte do contrato, com a exposição decidida de forma explícita. Os erros de cada passo voltam para o formato da aula Tratamento de erros com ProblemDetail, porque um token rejeitado também precisa de uma resposta padronizada.

9. exercícios

  1. Adicione um validador que exija o claim scope contendo pedidos:ler e rode o validador composto com tokens que tenham e não tenham esse scope. Explique por que a checagem é feita pelo claim e não pelo prefixo SCOPE_.
  2. Crie um controller com duas rotas, uma pública e uma protegida, e teste as duas com e sem token, medindo os códigos de resposta. Depois desabilite o CSRF na cadeia de filtros e repita o POST sem token. Compare os resultados.
  3. Gere um token com a chave de teste e altere um caractere da assinatura. Confirme que o decoder rejeita o token e descreva qual claim ou parte do token gerou o erro.

10. glossário

JWT: token com cabeçalho, declarações e assinatura, usado para transportar identidade e permissões entre serviços.

Claim: declaração dentro de um JWT, como iss, aud, exp ou nbf.

Resource server: serviço que recebe JWT e decide se aceita a requisição.

Autoridade: permissão reconhecida pelo Spring Security. Os scopes viram autoridades com o prefixo SCOPE_.

Clock skew: tolerância de diferença entre os relógios dos servidores ao conferir datas do token. Nesta versão, o padrão é 60 segundos.

CSRF: proteção contra requisições forjadas de um navegador. Em APIs que recebem token no header, ela precisa de uma decisão explícita.

OpenAPI: formato padrão para descrever uma API HTTP em um documento legível por máquinas e pessoas.

springdoc: biblioteca que gera a especificação OpenAPI a partir dos controllers do Spring.

fontes

Consultadas em 20/10/2026:

Verificações locais, feitas em 20/10/2026 com Temurin 21.0.8: a aplicação de verificação foi compilada com o Kotlin 2.3.21 e iniciada por java -jar, em localhost, com um JWKS publicado pela própria aplicação e tokens assinados com RSA 2048 gerados para o teste. Os resultados da seção 3 são as respostas HTTP reais. O validador da seção 4 rodou sem servidor, com Jwt montado localmente, e a data de expiração foi omitida das saídas.

Não verificado nesta aula: a rejeição de um token com assinatura adulterada, a rotação de chaves, a chamada JwtDecoders.fromIssuerLocation contra um emissor real, a resposta YAML do springdoc (o caminho está na documentação, mas não foi requisitado), a ocultação de endpoints além de @Hidden, e a representação de ProblemDetail na especificação com respostas declaradas.

relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?