OAuth 2.0 e OpenID Connect com exemplos em Spring Boot 3
1. Visão geral
OAuth 2.0 e OpenID Connect (OIDC) são frequentemente mencionados juntos, então muitas pessoas acham que são a mesma coisa. Na verdade, eles resolvem dois problemas diferentes. OAuth 2.0 trata da autorização (permitir que um aplicativo chame APIs em nome do usuário). OIDC trata da autenticação (saber quem é o usuário).
Neste artigo, você vai entender o fluxo Authorization Code + PKCE. Depois disso, você construirá duas aplicações Spring Boot 3: um cliente que faz login com OIDC e um resource server que valida tokens de acesso em formato JWT. No final, há testes para o resource server e uma lista de erros comuns.
2. Como OAuth 2.0 e OpenID Connect diferem
2.1. Os papéis em OAuth 2.0
OAuth 2.0 define quatro papéis:
- Resource Owner: o usuário, proprietário dos dados (por exemplo, um cliente que tem pedidos).
- Client: o aplicativo que quer acessar dados (por exemplo, a app web
shop-web). - Authorization Server: onde o usuário faz login e concede permissão, depois emite tokens. Exemplos: Keycloak, Okta, Auth0.
- Resource Server: a API que contém os dados (por exemplo,
order-api).
O resultado de OAuth 2.0 é um access token. Este token é para o resource server, não para o cliente. O cliente apenas o recebe e envia. Ele não deveria ler o token para inferir quem é o usuário.
2.2. O que OIDC adiciona
OIDC é uma camada construída sobre OAuth 2.0. Quando o cliente solicita o scope openid, o authorization server retorna um ID token adicional. Este é sempre um JWT, assinado, contendo informações de autenticação:
{
"iss": "http://localhost:8180/realms/shop",
"sub": "customer-42",
"aud": "shop-web",
"exp": 1760353200,
"iat": 1760352900,
"nonce": "n-0S6_WzA2Mj",
"email": "lan@example.com",
"name": "Nguyễn Thị Lan"
}
Os claims importantes são:
iss: quem emitiu o token.sub: o identificador único e estável do usuário.aud: para qual cliente este token é.nonce: previne ataques de replay.
Você pode resumir assim:
Access token -> envia para a API (resource server) -> "o que está permitido fazer"
ID token -> cliente lê para fazer login -> "quem é o usuário"
3. O fluxo Authorization Code + PKCE
Para web apps, SPAs e aplicativos móveis, você deve usar o fluxo Authorization Code, junto com PKCE (Proof Key for Code Exchange). O Implicit flow e o Password grant foram descontinuados.
1. Usuário clica em "Fazer login" em shop-web
2. shop-web gera um code_verifier aleatório, calcula code_challenge = SHA256(code_verifier)
3. Redireciona o navegador para:
/authorize?response_type=code&client_id=shop-web&scope=openid profile email
&redirect_uri=...&state=xyz&nonce=abc
&code_challenge=...&code_challenge_method=S256
4. Usuário faz login no Keycloak, Keycloak redireciona de volta para:
/login/oauth2/code/shop?code=SplxlOBe&state=xyz
5. shop-web (back-channel) chama POST /token com code + code_verifier + client_secret
6. Keycloak retorna: access_token, id_token, refresh_token
7. shop-web valida id_token (assinatura, iss, aud, exp, nonce) -> cria sessão
8. shop-web chama order-api com header: Authorization: Bearer <access_token>
Cada parâmetro bloqueia um tipo específico de ataque:
stateprevine CSRF no passo de callback.noncevincula o ID token à sessão de login correta.- PKCE garante que mesmo se alguém roubar o
code, não consegue trocá-lo por um token sem ocode_verifier.
A boa notícia é que Spring Security faz quase todos esses passos para você.
4. Construindo um cliente com login OIDC
4.1. Dependências e configuração
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
Suponha que Keycloak rode na porta 8180, com realm shop e cliente shop-web:
spring:
security:
oauth2:
client:
registration:
shop:
provider: keycloak
client-id: shop-web
client-secret: ${SHOP_CLIENT_SECRET}
authorization-grant-type: authorization_code
scope: openid, profile, email, orders.read
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
keycloak:
issuer-uri: http://localhost:8180/realms/shop
Você só precisa declarar issuer-uri. Spring lerá /.well-known/openid-configuration para descobrir automaticamente os endpoints authorize, token, jwks e end_session. O scope openid é o que transforma o fluxo OAuth 2.0 em um login OIDC.
4.2. Configuração de segurança
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
ClientRegistrationRepository registrations) throws Exception {
var resolver = new DefaultOAuth2AuthorizationRequestResolver(
registrations, "/oauth2/authorization");
resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
var logoutHandler = new OidcClientInitiatedLogoutSuccessHandler(registrations);
logoutHandler.setPostLogoutRedirectUri("{baseUrl}");
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/").permitAll()
.anyRequest().authenticated())
.oauth2Login(login -> login
.authorizationEndpoint(ep -> ep.authorizationRequestResolver(resolver)))
.logout(logout -> logout.logoutSuccessHandler(logoutHandler));
return http.build();
}
}
Esta configuração faz três coisas:
oauth2Loginativa todo o fluxo da seção 3, incluindo redirecionamento, callback, troca de código e validação de ID token.withPkce()adicionacode_challengetambém para clientes confidenciais (clientes com secret). Clientes públicos já têm PKCE ativado por padrão no Spring.OidcClientInitiatedLogoutSuccessHandlertambém faz logout no lado do Keycloak (RP-initiated logout). Sem isso, quando usuários clicarem em “Fazer login” novamente, serão automaticamente logados sem digitar a senha.
4.3. Lendo informações do usuário e chamando APIs
@RestController
public class AccountController {
private final RestClient orderApi = RestClient.create("http://localhost:8081");
@GetMapping("/me")
public Map<String, Object> me(@AuthenticationPrincipal OidcUser user) {
return Map.of(
"subject", user.getSubject(),
"email", Objects.requireNonNullElse(user.getEmail(), "(não tem)"),
"name", Objects.requireNonNullElse(user.getFullName(), "(não tem)"));
}
@GetMapping("/my-orders")
public String myOrders(@RegisteredOAuth2AuthorizedClient("shop") OAuth2AuthorizedClient client) {
return orderApi.get()
.uri("/api/orders")
.headers(h -> h.setBearerAuth(client.getAccessToken().getTokenValue()))
.retrieve()
.body(String.class);
}
}
Estes dois endpoints usam dois tipos diferentes de tokens:
OidcUseré construído a partir do ID token (e UserInfo se disponível). Esta é a forma correta de identificar quem é o usuário.@RegisteredOAuth2AuthorizedClientrecupera o access token para o registroshop. Se o token expirou e existe um refresh token, Spring o atualiza automaticamente antes de retornar.
Chamar /me depois de fazer login retorna:
{"subject":"customer-42","email":"lan@example.com","name":"Nguyễn Thị Lan"}
Nota:
Map.oflançaNullPointerExceptionse um valor énull. O claim
5. Construindo um resource server que valida JWT
5.1. Dependências e configuração
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
server:
port: 8081
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8180/realms/shop
audiences: order-api
Com esta configuração, Spring valida cada requisição assim:
- Recupera a chave pública do
jwks_urido issuer. - Valida a assinatura e
exp/nbf. - Valida que
isscorresponde aoissuer-uri. - Valida que
audcontémorder-api.
Pular a validação de audience é um erro muito comum. Nesse caso, a API aceitaria tokens emitidos para outros serviços no mesmo realm.
5.2. Autorização baseada em scope
public record Order(String id, String customerId, BigDecimal total) {}
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
@Bean
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders.read")
.anyRequest().authenticated())
.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
}
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping
public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
return orderService.findByCustomer(jwt.getSubject());
}
}
Por padrão, Spring converte o claim scope (ou scp) em authorities com o prefixo SCOPE_. Então o scope orders.read se torna SCOPE_orders.read.
Por que desabilitar CSRF? Esta API é stateless e aceita tokens apenas via header Authorization, não usa cookies. Portanto, desabilitar CSRF é apropriado.
O controller obtém sub do token em vez de aceitar customerId da query string. Assim, um usuário não consegue ver pedidos de outros usuários.
Resultados com diferentes requisições:
Sem token -> 401 Unauthorized
Token válido mas sem scope -> 403 Forbidden
Token válido com orders.read -> 200 OK + lista de pedidos do usuário
Nota: Keycloak armazena roles em
realm_access.roles, não emscope. Se você quer autorizar com base em roles, declare umJwtAuthenticationConvertercustomizado para mapear esse claim paraGrantedAuthority.
6. Testando o resource server
Você não precisa de um Keycloak real para testar. A biblioteca spring-security-test tem um post-processor jwt() para simular um token autenticado.
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(ResourceServerConfig.class)
class OrderControllerTest {
@Autowired
MockMvcTester mvc;
@MockitoBean
OrderService orderService;
@MockitoBean
JwtDecoder jwtDecoder; // evita chamar o issuer real ao iniciar o contexto
@Test
void returnsOrdersOfCurrentCustomer() {
given(orderService.findByCustomer("customer-42"))
.willReturn(List.of(new Order("A-1001", "customer-42", new BigDecimal("250000"))));
assertThat(mvc.get().uri("/api/orders")
.with(jwt().jwt(j -> j.subject("customer-42"))
.authorities(new SimpleGrantedAuthority("SCOPE_orders.read"))))
.hasStatusOk()
.bodyJson().extractingPath("$[0].id").isEqualTo("A-1001");
}
@Test
void rejectsTokenWithoutScope() {
assertThat(mvc.get().uri("/api/orders").with(jwt()))
.hasStatus(HttpStatus.FORBIDDEN);
}
@Test
void rejectsRequestWithoutToken() {
assertThat(mvc.get().uri("/api/orders"))
.hasStatus(HttpStatus.UNAUTHORIZED);
}
}
Estes três testes cobrem os três cenários da seção 5.2:
jwt()ignora a decodificação. Ele coloca umJwtAuthenticationTokendiretamente emSecurityContext, então os testes rodam rápido e independentemente.- Você deve passar
authorities(...)explicitamente.jwt()tem por padrão apenasSCOPE_read, então o segundo teste recebe 403. MockMvcTester(Spring Boot 3.4+) permite escrever assertions com AssertJ diretamente na resposta.
7. Erros comuns e quando não usar
Usar ID token para chamar APIs. O ID token tem aud para o cliente, não para a API. Um resource server configurado corretamente vai rejeitá-lo. Sempre envie o access token.
Usar access token para fazer login. No OAuth 2.0 puro, o access token pode ser uma string opaca sem informações de usuário. Se o cliente analisa o access token JWT para extrair identidade, ele depende de um formato que não é garantido. Use o ID token ou o endpoint UserInfo.
Armazenar tokens em localStorage em uma SPA. Uma única vulnerabilidade XSS é suficiente para expor todos os tokens. Um padrão mais seguro é BFF (Backend for Frontend). A aplicação Spring na seção 4 mantém tokens no servidor, enquanto o navegador tem apenas cookies de sessão HttpOnly.
Não validar aud, ou deixar access tokens viverem muito tempo. JWTs não podem ser revogados antes de expirar. Mantenha a expiração de access token curta (5–15 minutos) e use refresh tokens para estendê-los.
Descasamento em issuer-uri. Se Keycloak retorna iss como http://keycloak:8080/... (hostname em Docker) mas você configura localhost:8180, todos os tokens serão rejeitados com erro invalid_token. O valor iss deve corresponder exatamente.
Nota: Você nem sempre precisa de OAuth 2.0. Se você tem apenas uma aplicação monolítica com login em form e nenhum terceiro precisa chamar sua API, a sessão regular do Spring Security é mais simples e segura. Para comunicação máquina-com-máquina sem usuários, use o grant
client_credentials, não Authorization Code.
8. Conclusão
OAuth 2.0 responde “o que a aplicação está permitida fazer” com um access token. OIDC responde “quem é o usuário” com um ID token. O fluxo a usar é Authorization Code + PKCE, com state e nonce.
Com Spring Boot 3, você viu:
spring-boot-starter-oauth2-clientcomoauth2Logintrata todo o login OIDC no lado do cliente.spring-boot-starter-oauth2-resource-servervalida JWTs contraissuer-urieaudiences.spring-security-testpermite testar autorização baseada em scope sem um authorization server real.
Próximos passos que você pode tentar: mapear roles do Keycloak com JwtAuthenticationConverter, ou extrair o cliente em um BFF para uma SPA.