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:
statepreviene CSRF en el paso de callback.noncevincula 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 elcode_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:
oauth2Loginactiva todo el flujo de la sección 3, incluyendo redirección, callback, canje de código y validación de ID token.withPkce()añadecode_challengeincluso para clientes confidenciales (clientes con secreto). Los clientes públicos ya tienen PKCE habilitado por defecto en Spring.OidcClientInitiatedLogoutSuccessHandlercierra 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:
OidcUserse 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.@RegisteredOAuth2AuthorizedClientobtiene el access token del registroshop. 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.oflanzaNullPointerExceptionsi un valor esnull. La claim
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_uridel emisor. - Valida la firma y
exp/nbf. - Verifica que
isscoincida conissuer-uri. - Verifica que
audcontengaorder-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 enscope. Si quieres autorizar por rol, declara unJwtAuthenticationConverterseparado para mapear esa claim aGrantedAuthority.
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 unJwtAuthenticationTokenenSecurityContext, así que las pruebas se ejecutan rápido e independientemente.- Debes pasar
authorities(...)explícitamente.jwt()tiene por defecto soloSCOPE_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-clientjunto conoauth2Loginse encarga del inicio de sesión OIDC completo en el lado del cliente.spring-boot-starter-oauth2-resource-servervalida JWT segúnissuer-uriyaudiences.spring-security-testpermite 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.