OAuth 2.0 y OpenID Connect con ejemplos en Spring Boot 3

1. Descripción general

OAuth 2.0 y OpenID Connect (OIDC) a menudo se mencionan juntos, por lo que muchas personas los consideran lo mismo. En realidad, resuelven dos problemas diferentes. OAuth 2.0 se ocupa de la autorización (permitir que una aplicación llame a API en nombre del usuario). OIDC se ocupa de la autenticación (saber quién es el usuario).

En este artículo, comprenderás el flujo Authorization Code + PKCE. Luego construirás dos aplicaciones Spring Boot 3: un cliente que inicia sesión usando OIDC y un servidor de recursos que valida tokens de acceso en formato JWT. Al final del artículo hay pruebas para el servidor de recursos y una lista de errores comunes.

2. En qué se diferencian OAuth 2.0 y OpenID Connect

2.1. Los roles en OAuth 2.0

OAuth 2.0 define cuatro roles:

  • Resource Owner: el usuario, propietario de los datos (por ejemplo, un cliente que tiene pedidos).
  • Client: la aplicación que quiere acceder a los datos (por ejemplo, la web shop-web).
  • Authorization Server: donde el usuario inicia sesión y otorga permisos, luego emite un token. Ejemplos: Keycloak, Okta, Auth0.
  • Resource Server: la API que contiene los datos (por ejemplo, order-api).

El resultado de OAuth 2.0 es un access token. Este token es para el servidor de recursos, no para el cliente. El cliente solo lo tiene y lo envía. No debería leerlo para deducir quién es el usuario.

2.2. Qué añade OIDC

OIDC es una capa que se coloca encima de OAuth 2.0. Cuando el cliente solicita el scope openid, el servidor de autorización devuelve un ID token adicional. Siempre es un JWT, está firmado y contiene información de autenticación:

{
  "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"
}

Las claims importantes incluyen:

  • iss: quién emitió el token.
  • sub: identificador único y estable del usuario.
  • aud: para qué cliente es este token.
  • nonce: prevención de reproducción.

Puedes resumirlo así:

Access token  -> se envía a la API (servidor de recursos)  -> "qué se puede hacer"
ID token      -> el cliente lo lee para iniciar sesión      -> "quién es el usuario"

3. El flujo Authorization Code + PKCE

Para aplicaciones web, SPA y móviles, deberías usar el flujo Authorization Code, más PKCE (Proof Key for Code Exchange). El flujo Implicit y Password grant ya no se recomiendan.

1. El usuario hace clic en "Iniciar sesión" en shop-web
2. shop-web genera un code_verifier aleatorio, calcula code_challenge = SHA256(code_verifier)
3. Redirige el navegador a:
   /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. El usuario inicia sesión en Keycloak, Keycloak redirige de vuelta a:
   /login/oauth2/code/shop?code=SplxlOBe&state=xyz
5. shop-web (back-channel) llama a POST /token con code + code_verifier + client_secret
6. Keycloak devuelve: access_token, id_token, refresh_token
7. shop-web valida id_token (firma, iss, aud, exp, nonce) -> crea sesión
8. shop-web llama a order-api con encabezado: Authorization: Bearer <access_token>

Cada parámetro bloquea un tipo de ataque diferente:

  • state previene CSRF en el paso de callback.
  • nonce vincula el ID token a la sesión de inicio de sesión correcta.
  • PKCE garantiza que incluso si alguien roba el code, no puede canjearlo por un token sin tener el code_verifier.

La buena noticia es que Spring Security hace la mayoría de estos pasos por ti.

4. Construir un cliente de inicio de sesión con OIDC

4.1. Dependencias y configuración

<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>

Supongamos que Keycloak se ejecuta en el puerto 8180, tiene un reino shop y un 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

Solo necesitas declarar issuer-uri. Spring leerá /.well-known/openid-configuration para obtener automáticamente los endpoints authorize, token, jwks y end_session. El scope openid es lo que convierte el flujo OAuth 2.0 en un inicio de sesión OIDC.

4.2. Configuración de seguridad

@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 configuración hace tres cosas:

  • oauth2Login activa todo el flujo de la sección 3, incluyendo redirección, callback, canje de código y validación de ID token.
  • withPkce() añade code_challenge incluso para clientes confidenciales (clientes con secreto). Los clientes públicos ya tienen PKCE habilitado por defecto en Spring.
  • OidcClientInitiatedLogoutSuccessHandler cierra la sesión también en Keycloak (RP-initiated logout). Sin esto, cuando el usuario hace clic en “Iniciar sesión” nuevamente, entrará directamente sin necesidad de ingresar la contraseña.

4.3. Leer información del usuario y llamar a la API

@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(), "(no disponible)"),
                "name", Objects.requireNonNullElse(user.getFullName(), "(no disponible)"));
    }

    @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);
    }
}

Los dos endpoints usan dos tipos de tokens diferentes:

  • OidcUser se construye a partir del ID token (e información de usuario si está disponible). Esta es la forma correcta de saber quién es el usuario.
  • @RegisteredOAuth2AuthorizedClient obtiene el access token del registro shop. Si el token ha expirado y hay un refresh token, Spring lo renueva automáticamente antes de devolverlo.

Llamar a /me después de iniciar sesión devolverá:

{"subject":"customer-42","email":"lan@example.com","name":"Nguyễn Thị Lan"}

Nota: Map.of lanza NullPointerException si un valor es null. La claim email solo está presente si solicitas el scope email y el usuario tiene un email, así que siempre maneja el caso de claims faltantes.

5. Construir un servidor de recursos que valide JWT

5.1. Dependencias y configuración

<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

Con esta configuración, Spring valida cada solicitud con estos pasos:

  • Obtiene la clave pública de jwks_uri del emisor.
  • Valida la firma y exp/nbf.
  • Verifica que iss coincida con issuer-uri.
  • Verifica que aud contenga order-api.

No verificar la audiencia es un error muy común. Cuando falta esto, la API aceptará tokens emitidos para otro servicio en el mismo reino.

5.2. Autorización por 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 defecto, Spring convierte la claim scope (o scp) en autoridades con prefijo SCOPE_. Por lo tanto, el scope orders.read se convierte en SCOPE_orders.read.

¿Por qué desactivar CSRF? Esta API es sin estado y solo acepta tokens a través del encabezado Authorization, no usa cookies. Por lo tanto, desactivar CSRF es razonable.

El controlador obtiene sub del token, no recibe customerId de la cadena de consulta. De esta manera, un usuario no puede ver los pedidos de otra persona.

Resultados con diferentes tipos de solicitudes:

Sin token                          -> 401 Unauthorized
Token válido pero sin scope        -> 403 Forbidden
Token válido, con orders.read      -> 200 OK + lista de pedidos del usuario

Nota: Keycloak almacena los roles en realm_access.roles, no en scope. Si quieres autorizar por rol, declara un JwtAuthenticationConverter separado para mapear esa claim a GrantedAuthority.

6. Probar el servidor de recursos

No necesitas un Keycloak real para probar. La biblioteca spring-security-test tiene un post-procesador jwt() para simular un token ya 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; // evitar llamar al emisor real al arrancar el 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);
    }
}

Estas tres pruebas cubren las tres ramas de la sección 5.2:

  • jwt() omite el paso de decodificación. Coloca directamente un JwtAuthenticationToken en SecurityContext, así que las pruebas se ejecutan rápido e independientemente.
  • Debes pasar authorities(...) explícitamente. jwt() tiene por defecto solo SCOPE_read, así que la segunda prueba recibe 403.
  • MockMvcTester (Spring Boot 3.4+) permite escribir aserciones usando AssertJ directamente en la respuesta.

7. Errores comunes y cuándo no usar esto

Usar el ID token para llamar a la API. El ID token tiene aud como cliente, no como API. Un servidor de recursos configurado correctamente lo rechazará. Siempre envía el access token.

Usar el access token para iniciar sesión. Con OAuth 2.0 puro, el access token puede ser una cadena opaca y no contener nada sobre el usuario. Si el cliente intenta analizar el access token JWT para obtener la identidad, está confiando en un formato que no se le garantiza. Usa ID token o el endpoint UserInfo.

Almacenar el token en localStorage en una SPA. Una vulnerabilidad XSS es suficiente para exponer el token completo. Un patrón más seguro es BFF (Backend for Frontend). La aplicación Spring de la sección 4 mantiene el token en el servidor, mientras que el navegador solo tiene una cookie de sesión HttpOnly.

No validar aud, o dejar que el access token viva demasiado tiempo. JWT no se puede revocar antes de expirar. Mantén el tiempo de expiración del access token corto (5–15 minutos) y usa el refresh token para renovar.

Desajuste en issuer-uri. Si Keycloak devuelve iss como http://keycloak:8080/... (nombre de host en Docker) pero configuras localhost:8180, todos los tokens se rechazan con error invalid_token. El valor iss debe coincidir exactamente.

Nota: No siempre necesitas OAuth 2.0. Si solo tienes una aplicación monolítica con login por formulario y ningún tercero necesita llamar a la API, la sesión tradicional de Spring Security es más simple y segura. Para comunicación máquina-a-máquina sin usuarios, usa el grant client_credentials, no Authorization Code.

8. Conclusión

OAuth 2.0 responde a la pregunta “qué puede hacer esta aplicación” con un access token. OIDC responde a la pregunta “quién es el usuario” con un ID token. El flujo a usar es Authorization Code + PKCE, con state y nonce incluidos.

Con Spring Boot 3, has visto:

  • spring-boot-starter-oauth2-client junto con oauth2Login se encarga del inicio de sesión OIDC completo en el lado del cliente.
  • spring-boot-starter-oauth2-resource-server valida JWT según issuer-uri y audiences.
  • spring-security-test permite probar la autorización por scope sin necesidad de un servidor de autorización real.

El siguiente paso que puedes intentar es mapear los roles de Keycloak usando JwtAuthenticationConverter, o separar el cliente en un BFF para una SPA.

oauth2openid-connectspring-securityjwtweb-security