Spring OAuth 2.0 Resource Server - Google Sign In

Última revisión en diciembre del 2023.

El objetivo es implementar un servidor con Spring OAuth 2.0 Resource Server que utiliza el Servidor de autorización de Google Identity Services para autenticar al usuario.

Utilizamos Google Sign-In para generar un ID token en la aplicación del navegador. Este token acompaña las peticiones a nuestro servidor en forma de autorización Bearer. Nuestro servidor de recursos utiliza dicho token para autenticar al usuario una vez que ha verificado su validez mediante las claves públicas del servidor de autorización.

Aspectos técnicos generales

La parte del back-end se desarrollará con Spring Boot, siguiendo el principio de Convención sobre Configuración en Spring, utilizando Java. Se optará por la versión web no reactiva, que incluye por defecto un servidor Tomcat. Spring Boot generará un fat JAR que incorpora todas las dependencias, lo que lo hace ejecutable en cualquier entorno que disponga del entorno de ejecución Java (JRE) correspondiente.

En nuestro caso, para ejecutar el fat JAR, se utilizará la Plataforma como Servicio de Google, App Engine, aunque también sería válido cualquier contenedor que incluyera un JRE.

En cuanto al front-end, se ha utilizado Vanilla JS sin añadir adornos innecesarios.

Spring utiliza Gradle como su herramienta de automatización, pero en mi caso, he elegido utilizar Apache Maven. La razón principal es que el editor que estoy utilizando gestiona Maven de manera más eficiente. A pesar de esto, es importante destacar que Gradle se reconoce generalmente como una opción más eficiente en términos de rendimiento.

Tecnologías

  • Spring
  • Spring Boot
  • Spring Security
  • Spring OAuth 2.0 Resource Server
  • Google Sign In
  • Google Appengine
  • Java - JVM
  • Maven
  • Vanila JS

Autenticando un Usuario con OpenID Connect (OIDC)

OIDC es un protocolo que permite a la aplicación cliente autentificar a los usuarios utilizando un servidor de autorización. El servidor de autorización devuelve un ID Token que contiene información firmada del usuario.

OIDC representa una capa de identidad sobre el estándar OAuth2. Mientras que OAuth2 aborda la autorización mediante el uso de tokens de acceso, no aborda la autenticación del usuario, función que cumple OIDC.

  1. El usuario desea acceder a un recurso protegido, por lo que debe autentificarse con el servidor de autorización.
  2. El servidor de autorización responde proporcionando un ID token que contiene información firmada sobre el usuario.
  3. La aplicación cliente utiliza este ID token para identificarse ante el Servidor del Recurso.
  4. El Servidor del Recurso verifica la firma del ID token con el Servidor de Autorización.

El rol de aplicación cliente del servidor de autorización se puede asignar al frontend (navegador) o al backend (nuestro servidor), teniendo en cuenta que no hay ninguna manera de proteger completamente el ID Token en el navegador.

Terminología según estándar

OAuth2 OICD
User End-User
Client Relying Party
Authorization Server Provider

Google Sign-In

Google nos proporciona una librería que nos permite autenticar usuarios y obtener un ID Token, así como autorizar a los usuarios para acceder a las APIs de Google, devolviendo un Access Token. En nuestro caso, utilizaremos exclusivamente el ID Token para autenticar al usuario en el servidor.

En el caso del navegador, la librería Google Sign-In nos proporciona un botón "Sign In with Google". Si el usuario ya está autenticado en el navegador, también se muestra un cuadro de diálogo que le ofrece conectarse a la aplicación Consolidando.

A continuación, se detalla el código del navegador en Javascript donde pasamos la identificación de la aplicación cliente (_GOOGLE_CLIENT_ID), que previamente habremos configurado en algún proyecto en Google Cloud. Esta identificación tiene asociado un secreto que se utiliza en el servidor para verificar la validez del token. También le pasamos un callback a través del cual se nos devuelve el ID Token.

 
function onLoadGoogleIdentityInit(buttonId)
{
    return new Promise((resolve, reject) => {
        try {
            google.accounts.id.initialize({
                client_id: _GOOGLE_CLIENT_ID,
                callback: data => resolve(data)
            });

            google.accounts.id.renderButton(
                    document.getElementById(buttonId),
                    {theme: "outline", size: "medium"}
            );

            google.accounts.id.prompt();


        } catch (error) {
            console.error(error);
            reject("Error initializing Google Identity");
        }
    });
}

Spring - Servidor de Recursos con OAuth2

Spring está en constante evolución y nos proporciona un extenso framework de seguridad con soluciones para autenticación y autorización, así como múltiples protecciones contra diferentes tipos de ataques, como el Cross Site Request Forgery.

En el caso de OAuth2, Spring Security ofrece diversas opciones para implementar escenarios específicos. En esta ocasión, vamos a abordar el caso en el que el Cliente OAuth2 está en el navegador y accede directamente a un Servidor de Recursos.

Dependencia a incluir en Maven

 
<dependency>
   <groupId>org.springframework.boot</groupId>   
   <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

Configuración del Servidor de Recursos

En Spring Boot casi todo se puede especificar en application.yml.

En la primera parte, se registra el cliente OAuth2 con su identificación, secreto y el alcance de información deseado del usuario.

En la segunda parte, se configura cómo el servidor debe verificar la validez del token. Se proporciona la issuer-uri, que se valida contra el "iss" claim del token, y la jwk-set-uri, donde se encuentran las claves públicas de Google en formato JWK, utilizadas para validar la firma del token.

 
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: @client.id@
            client-secret: @client.secret@
            scope: openid,profile,email
      resourceserver:
        jwt:
          issuer-uri: https://accounts.google.com
          jwk-set-uri: https://www.googleapis.com/oauth2/v3/certs

Propiedades en Maven

Maven sobrescribe los valores reales de client-id y client-secret en application.yml al desplegar la aplicación, utilizando variables de entorno env.CLIENT_ID y env.CLIENT_SECRET respectivamente. Protegiendo así los secretos, evitando que estén almacenados en los repositorios.

 
    <properties>
        <java.version>17</java.version>
        <app.deploy.projectId>emp-2022</app.deploy.projectId>
        <app.deploy.version>1</app.deploy.version>
        <client.id>@env.CLIENT_ID@</client.id>
        <client.secret>@env.CLIENT_SECRET@</client.secret>
    </properties>

Notar que la .pom de Maven Spring Boot Start Parent tiene definido el delimitador @ en lugar ${*} y aplica el filtrado de recursos a todas de propiedades del proyecto.

 
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.0-SNAPSHOT</version>
        <relativePath/>
    </parent>

El main

En la única clase del proyecto, tenemos la anotación @EnableWebSecurity, lo que indica que estamos sobrescribiendo el filtro de seguridad. Además, encontramos la anotación @RestController, que señala que cualquier mapeo de un recurso REST en esta clase devolverá su resultado directamente en el body de la respuesta http en formato JSON.

 
@SpringBootApplication
@RestController
@EnableWebSecurity
public class GoogleSignInApplication
{
    //...

    public static void main(String[] args)
    {
        SpringApplication.run(GoogleSignInApplication.class, args);
    }
}

El Recurso "users"

Se ha creado un mini recurso que devuelve el objeto Principal, el cual representa al usuario autenticado en el servidor.

 
 @GetMapping("/users")
 public Principal users(Principal principal)
 {
        return principal;
 }

SecurityFilterChain

Por defecto, el servidor de recursos exige que todos los accesos tengan un token válido. Sin embargo, en nuestro caso, modificaremos el filtro de seguridad SecurityFilterChain para que sólo requiera autenticación para los accesos al recurso "users".

Además, especificamos que el servidor sea stateless y que no verifique el token CSRF.

 
@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception
    {
        http
                .authorizeHttpRequests((request)
                        -> request.requestMatchers("/users").authenticated()
                        .anyRequest().permitAll()
                )
                .oauth2ResourceServer((oauth2ResourceServer)
                        -> oauth2ResourceServer.jwt(Customizer.withDefaults())
                )
                .sessionManagement(sessionManagement -> sessionManagement
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .csrf(AbstractHttpConfigurer::disable);

        return http.build();
    }


401 - Unauthorized

Si accedemos a un recurso sin incluir un ID token por defecto el servidor nos devuelve un error status: 401 - Unauthorized que en nuestro caso preferimos convertir en una redirección a la página de bienvenida "/index.html" donde ofrecemos la posibilidad de hacer un Sign In

Debajo incluimos el código a añadir al filtro anterior para conseguir la redirección cuando hay una excepción de autentificación en el servidor

 
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception
    {
        http
                .// ...

                .exceptionHandling(exception
                        -> exception.authenticationEntryPoint(
                        (request, response, authException)
                        -> response.sendRedirect("/index.html?v=2")
                ))

               // ...

        return http.build();
    }

Google App Engine

Google App Engine (GAE) es una Platform as a Service (PaaS) que nos permite ejecutar directamente el fat JAR construido por Spring Boot, y solo necesitamos indicar la versión de Java que queremos utilizar.

A continuación, mostramos el archivo app.yaml de GAE, donde especificamos la versión de Java, la forma en que queremos que nuestra aplicación escale y un controlador para servir los archivos estáticos directamente desde GAE sin pasar por Spring.

 
runtime: java17
automatic_scaling:
  max_idle_instances: 1
  max_concurrent_requests: 10
  max_instances: 2

handlers:
- url: (/.*\.(gif|png|jpg|svg|ico|css|js|html))
  static_files: __static__\0
  upload: __NOT_USED__
  require_matching_file: true
  login: optional
  secure: optional
  expiration: 7d
- url: /
  static_files: __static__/index.html
  upload: __NOT_USED__
  require_matching_file: true
  login: optional
  secure: optional
  expiration: 7d

Archivos estáticos

Hemos incluido en Maven el plugin maven-resources-plugin, que utilizamos para mover todos los archivos estáticos al directorio __static__, desde donde se sirven directamente por Google Cloud. Esto hace que sea mucho más rápido que servirlos a través del servidor creado con Spring. También hemos definido en el servidor de archivos estáticos un archivo de bienvenida, index.html, lo que permite que la primera página se cargue casi de inmediato, ya que no es necesario esperar a que el servidor de recursos se inicie.

 
<plugin>
     <artifactId>maven-resources-plugin</artifactId>
      <version>3.3.1</version>
      <executions>
      <execution>
       <id>copy-resources</id>      
       <phase>prepare-package</phase>
       <goals>
         <goal>copy-resources</goal>
       </goals>
      <configuration>
      <outputDirectory>${project.build.directory}/appengine-staging/__static__</outputDirectory>
      <resources>
      <resource>
      <directory>${basedir}/src/main/resources/static</directory>
      <filtering>false</filtering>      
      </resource>
      </resources>
      </configuration>
      </execution>
      </executions>
      <configuration>
      <encoding>${project.build.sourceEncoding}</encoding>
      </configuration>
</plugin>

Cómo Probar el Servidor de Recursos

Para testear nuestro servidor usamos el cliente HTTP WebTestClient que forma parte de Spring Web Flux.

Test Invalid Token

Verificamos que devuelve un status 401 y que nos dice que el token es inválido.


    final String invalidTokenString = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";

    @Test
    public void testInvalidIDToken()
    {

        webTestClient
                .get().uri("/users")
                .headers(http -> http.setBearerAuth(invalidTokenString))
                .exchange()
                .expectStatus().isUnauthorized()
                .expectHeader().valueMatches(HttpHeaders.WWW_AUTHENTICATE,
                        "Bearer error=\"invalid_token\",.*");
    }

Test No Token

Verificamos que redirecciona al fichero de bienvenida.


    @Test
    public void testRedirectionWhenAccessUsersWithoutIDToken()
    {
        webTestClient
                .get().uri("/users")
                .exchange()
                .expectStatus()
                .is3xxRedirection()
                .expectHeader().valueMatches(HttpHeaders.LOCATION, ".*\\/index\\.html\\?v=2$");
    }

Para finalizar

To be done

El Código

Puedes encontrar el código de este post en el repositorio de GitHub: Consolidando - Spring Boot.

Más información

Comentarios