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:

  • state previne CSRF no passo de callback.
  • nonce vincula 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 o code_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:

  • oauth2Login ativa todo o fluxo da seção 3, incluindo redirecionamento, callback, troca de código e validação de ID token.
  • withPkce() adiciona code_challenge também para clientes confidenciais (clientes com secret). Clientes públicos já têm PKCE ativado por padrão no Spring.
  • OidcClientInitiatedLogoutSuccessHandler també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.
  • @RegisteredOAuth2AuthorizedClient recupera o access token para o registro shop. 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.of lança NullPointerException se um valor é null. O claim email existe apenas se você solicitar o scope email e o usuário tiver um email, então sempre trate o caso de claims ausentes.

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_uri do issuer.
  • Valida a assinatura e exp/nbf.
  • Valida que iss corresponde ao issuer-uri.
  • Valida que aud contém order-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 em scope. Se você quer autorizar com base em roles, declare um JwtAuthenticationConverter customizado para mapear esse claim para GrantedAuthority.

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 um JwtAuthenticationToken diretamente em SecurityContext, então os testes rodam rápido e independentemente.
  • Você deve passar authorities(...) explicitamente. jwt() tem por padrão apenas SCOPE_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-client com oauth2Login trata todo o login OIDC no lado do cliente.
  • spring-boot-starter-oauth2-resource-server valida JWTs contra issuer-uri e audiences.
  • spring-security-test permite 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.

oauth2openid-connectspring-securityjwtweb-security