Spring SecurityLección 10
OAuth2 Login, clientes y Authorization Server
Cuando hay varias aplicaciones, la emisión de tokens se centraliza. Login con un proveedor externo, el patrón BFF y tu propio Authorization Server, que en la 7 ya forma parte de Spring Security.
Al terminar sabrás
- Mapear los roles de OAuth2 a las piezas de Spring Security 7.
- Iniciar sesión con un proveedor OIDC usando
oauth2Login. - Montar un Authorization Server con
oauth2AuthorizationServery clientes por propiedades. - Conectar una API (resource server) a ese emisor.
En la lección de JWT la propia API emitía sus tokens. Cuando hay varias aplicaciones, varios clientes o login con Google, ese trabajo se centraliza en un Authorization Server: el único que ve contraseñas y el único que firma tokens. Los conceptos están en OAuth 2 y OpenID Connect; aquí vemos las piezas de Spring.
Los cuatro roles en Spring Security 7
Sección titulada «Los cuatro roles en Spring Security 7»| Rol OAuth2 | Qué hace | Pieza en Spring Security 7 |
|---|---|---|
| Resource owner | El usuario dueño de los datos | — |
| Client | La app que pide tokens | oauth2Login() (login de usuarios) y oauth2Client() (llamar APIs con token) |
| Authorization Server | Autentica y emite tokens | oauth2AuthorizationServer(): antes un proyecto aparte, ahora dentro de Spring Security |
| Resource Server | La API que valida tokens | oauth2ResourceServer() |
- Navegadorsolo una cookie
HttpOnly - BFF / web
oauth2Login+oauth2Client - Authorization Server
oauth2AuthorizationServer - API
oauth2ResourceServer
El cliente: login con un proveedor externo
Sección titulada «El cliente: login con un proveedor externo»Con el starter spring-boot-starter-security-oauth2-client, iniciar sesión con Google (o Keycloak, Okta, Entra ID…) son propiedades:
spring: security: oauth2: client: registration: google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} scope: openid, profile, email@BeanSecurityFilterChain web(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/", "/error").permitAll() .anyRequest().authenticated()) .oauth2Login(Customizer.withDefaults()) // /oauth2/authorization/google inicia el flujo .logout(Customizer.withDefaults()); return http.build();}El principal resultante es un OidcUser con los claims del id_token. Spring hace por ti el flujo Authorization Code con PKCE (activado por defecto en la 7, también para clientes confidenciales), valida state y nonce, canjea el code y verifica el id_token.
Este es el patrón BFF (Backend for Frontend): el backend guarda los tokens en la sesión del servidor y el navegador solo tiene una cookie HttpOnly. Es hoy la recomendación para SPAs, porque ningún token queda al alcance de JavaScript. Si el BFF necesita llamar a tu API con el token del usuario, oauth2Client gestiona el access token (y su renovación con el refresh token).
El servidor: Authorization Server dentro de Spring Security
Sección titulada «El servidor: Authorization Server dentro de Spring Security»Desde la 7.0 el Authorization Server es un módulo de Spring Security: su versión es la de spring-security.version. Starter: spring-boot-starter-security-oauth2-authorization-server. La configuración mínima tiene dos cadenas: una para los endpoints del protocolo y otra para el login de los usuarios.
@Bean@Order(1)SecurityFilterChain authorizationServer(HttpSecurity http) throws Exception { http .oauth2AuthorizationServer(as -> { http.securityMatcher(as.getEndpointsMatcher()); // /oauth2/authorize, /oauth2/token, /oauth2/jwks… as.oidc(Customizer.withDefaults()); // activa OpenID Connect }) .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) .exceptionHandling(ex -> ex.defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint("/login"), // navegadores → formulario new MediaTypeRequestMatcher(MediaType.TEXT_HTML))); return http.build();}
@Bean@Order(2)SecurityFilterChain login(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) .formLogin(Customizer.withDefaults()); return http.build();}Además necesita un RegisteredClientRepository (qué clientes existen), un JWKSource (con qué claves firma) y un UserDetailsService. Spring Boot puede crear los dos primeros a partir de propiedades:
server: port: 9000spring: security: user: name: ana password: "{noop}ana-password-123" # solo para el lab oauth2: authorizationserver: client: notes-cli: registration: client-id: notes-cli client-secret: "{noop}cli-secret" client-authentication-methods: client_secret_basic authorization-grant-types: client_credentials scopes: notes:read postman: registration: client-id: postman client-secret: "{noop}postman-secret" client-authentication-methods: client_secret_basic authorization-grant-types: authorization_code, refresh_token redirect-uris: https://oauth.pstmn.io/v1/callback scopes: openid, profile, notes:read, notes:write require-authorization-consent: trueEndpoints que expone, todos descubribles en /.well-known/openid-configuration:
| Endpoint | Para qué |
|---|---|
/oauth2/authorize | Inicio del flujo con el usuario |
/oauth2/token | Canjear code, client credentials o refresh token |
/oauth2/jwks | Claves públicas para verificar firmas |
/oauth2/introspect · /oauth2/revoke | Consultar o revocar un token |
/userinfo | Datos del usuario (OIDC) |
Y la API pasa a confiar en él con una sola propiedad:
spring: security: oauth2: resourceserver: jwt: issuer-uri: http://localhost:9000Mini lab · 45 min
Tu propio Authorization Server
Objetivo: Sacar la emisión de tokens de la API de notas y delegarla en un Authorization Server en el puerto 9000.
- Crea un proyecto nuevo
auth-serverconspring-boot-starter-webmvcyspring-boot-starter-security-oauth2-authorization-server. Copia las dos cadenas y elapplication.ymlde arriba. - En la API de notas, borra el endpoint de token, el
JwtEncoder, elJwtDecodery las claves RSA, y configuraissuer-uri: http://localhost:9000. - Obtén un token de servicio y úsalo:
TOKEN=$(curl -s -u notes-cli:cli-secret \ -d grant_type=client_credentials -d scope=notes:read \ http://localhost:9000/oauth2/token | jq -r .access_token)
curl -i -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/notes- En Postman, pestaña Authorization → OAuth 2.0 → Authorization Code (With PKCE) con los datos del cliente
postman. Inicia sesión comoana, acepta el consentimiento y llama aGET /api/notes.
Preguntas:
- Abre
http://localhost:9000/.well-known/openid-configuration. ¿Qué URL usa la API para descargar las claves? - Compara el
subde un tokenclient_credentialscon el de un token deana. Si el primero creara una nota, ¿de quién sería? - Pide
scope=notes:writeconnotes-cli. ¿Qué responde el Authorization Server? - Reinicia el Authorization Server y reutiliza un token viejo en la API. ¿Qué pasa?
Pista
En client_credentials no hay usuario: ¿quién es el “sujeto” del token?
Mostrar respuestaOcultar respuesta
- La del campo
jwks_uri:http://localhost:9000/oauth2/jwks. La API la descubre a partir deissuer-uriy cachea las claves. - En
client_credentialselsubes elclient_id(notes-cli), no un usuario. Si ese token pudiera crear notas, quedarían a nombre denotes-cli. Por eso a los clientes de servicio se les dan scopes mínimos (aquí, solo lectura) y la API debe distinguir tokens de usuario y de servicio cuando importa. 400con{"error":"invalid_scope"}: el cliente no tiene registrado ese scope. El Authorization Server aplica el mínimo privilegio por cliente.- La API responde 401 con
invalid_token: el token viejo está firmado con una clave que ya no existe (se generó en memoria y se perdió al reiniciar). En producción, las claves deben ser persistentes y rotarse publicando la nueva antes de retirar la vieja.