1. Overview

In modern web applications, user authentication and authorization are critical components. Building our authentication layer from scratch is a challenging and complex task. However, with the rise of cloud-based authentication services, this process has become much simpler.

One such example is Firebase Authentication, a fully managed authentication service offered by Firebase and Google.

In this tutorial, we’ll explore how to integrate Firebase Authentication with Spring Security to create and authenticate our users. We’ll walk through the necessary configuration, implement user registration and login functionality, and create a custom authentication filter to validate user tokens for private API endpoints.

2. Setting up the Project

Before we dive into the implementation, we’ll need to include an SDK dependency and configure our application correctly.

2.1. Dependencies

Let’s start by adding the Firebase admin dependency to our project’s pom.xml file:

<dependency>
    <groupId>com.google.firebase</groupId>
    <artifactId>firebase-admin</artifactId>
    <version>9.3.0</version>
</dependency>

This dependency provides us with the necessary classes to interact with the Firebase Authentication service from our application.

2.2. Defining Firebase Configuration Beans

Now, to interact with Firebase Authentication, we need to configure our private key to authenticate API requests.

For our demonstration, we’ll create the private-key.json file in our src/main/resources directory. However, in production, the private key should be loaded from an environment variable or fetched from a secret management system to enhance security.

We’ll load our private key using the @Value annotation and use it to define our beans:

@Value("classpath:/private-key.json")
private Resource privateKey;

@Bean
public FirebaseApp firebaseApp() {
    InputStream credentials = new ByteArrayInputStream(privateKey.getContentAsByteArray());
    FirebaseOptions firebaseOptions = FirebaseOptions.builder()
      .setCredentials(GoogleCredentials.fromStream(credentials))
      .build();
    return FirebaseApp.initializeApp(firebaseOptions);
}

@Bean
public FirebaseAuth firebaseAuth(FirebaseApp firebaseApp) {
    return FirebaseAuth.getInstance(firebaseApp);
}

We first define our FirebaseApp bean, which we then use to create our FirebaseAuth bean. This allows us to reuse the FirebaseApp bean when working with multiple Firebase services, such as Cloud Firestore Database, Firebase Messaging, etc.

The FirebaseAuth class is the main entry point for interacting with the Firebase Authentication service.

3. Creating Users in Firebase Authentication

Now that we’ve defined our FirebaseAuth bean, let’s create a UserService class and reference it to create new users in Firebase Authentication:

private static final String DUPLICATE_ACCOUNT_ERROR = "EMAIL_EXISTS";

public void create(String emailId, String password) {
    CreateRequest request = new CreateRequest();
    request.setEmail(emailId);
    request.setPassword(password);
    request.setEmailVerified(Boolean.TRUE);

    try {
        firebaseAuth.createUser(request);
    } catch (FirebaseAuthException exception) {
        if (exception.getMessage().contains(DUPLICATE_ACCOUNT_ERROR)) {
            throw new AccountAlreadyExistsException("Account with given email-id already exists");
        }
        throw exception;
    }
}

In our create() method, we initialize a new CreateRequest object with the user’s email and password. We also set the emailVerified value to true for simplicity, however, we might want to implement an email verification process before we do that in a production application.

Additionally, we handle the case where an account with the given emailId already exists, throwing a custom AccountAlreadyExistsException.

4. Implementing User Login Functionality

Now that we can create users, we’ll naturally have to allow them to authenticate themselves before they access our private API endpoints. We’ll implement the user login functionality that returns an ID token in the form of a JWT and a refresh token on successful authentication.

The Firebase admin SDK does not support token exchange with email/password credentials as this functionality is typically handled by the client applications. However, for our demonstration, we’ll call the sign-in REST API directly from our backend application.

First, we’ll declare a couple of records to represent the request and response payloads:

record FirebaseSignInRequest(String email, String password, boolean returnSecureToken) {}

record FirebaseSignInResponse(String idToken, String refreshToken) {}

To invoke the Firebase Authentication REST API, we’ll need the web API key of our Firebase project. We’ll store it in our application.yaml file and inject it into our new FirebaseAuthClient class using the @Value annotation:

private static final String API_KEY_PARAM = "key";
private static final String INVALID_CREDENTIALS_ERROR = "INVALID_LOGIN_CREDENTIALS";
private static final String SIGN_IN_BASE_URL = "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword";

@Value("${com.baeldung.firebase.web-api-key}")
private String webApiKey;

public FirebaseSignInResponse login(String emailId, String password) {
    FirebaseSignInRequest requestBody = new FirebaseSignInRequest(emailId, password, true);
    return sendSignInRequest(requestBody);
}

private FirebaseSignInResponse sendSignInRequest(FirebaseSignInRequest firebaseSignInRequest) {
    try {
        return RestClient.create(SIGN_IN_BASE_URL)
          .post()
          .uri(uriBuilder -> uriBuilder
            .queryParam(API_KEY_PARAM, webApiKey)
            .build())
          .body(firebaseSignInRequest)
          .contentType(MediaType.APPLICATION_JSON)
          .retrieve()
          .body(FirebaseSignInResponse.class);
    } catch (HttpClientErrorException exception) {
        if (exception.getResponseBodyAsString().contains(INVALID_CREDENTIALS_ERROR)) {
            throw new InvalidLoginCredentialsException("Invalid login credentials provided");
        }
        throw exception;
    }
}

In our login() method, we create a FirebaseSignInRequest with the user’s email, password, and set returnSecureToken to true. We then pass this request to our private sendSignInRequest() method, which sends a POST request to the Firebase Authentication REST API using RestClient.

If the request is successful, we return the response containing the user’s idToken and refreshToken to the caller. If the login credentials are invalid, we throw a custom InvalidLoginCredentialsException.

It’s important to note that the validity of the idToken we receive from Firebase is one hour, and we can’t change it. In the next section, we’ll explore how we can allow our client applications to use the returned refreshToken to obtain new ID tokens.

5. Exchanging Refresh Tokens for New ID Tokens

Now that we’ve got our login functionality in place, let’s see how we can use the refreshToken to get a new idToken when the current one expires. This allows our client application to keep our users logged-in for an extended period without requiring them to re-enter their credentials.

We’ll start by defining the records to represent the request and response payloads:

record RefreshTokenRequest(String grant_type, String refresh_token) {}

record RefreshTokenResponse(String id_token) {}

Next, in our FirebaseAuthClient class, let’s invoke the refresh token exchange REST API:

private static final String REFRESH_TOKEN_GRANT_TYPE = "refresh_token";
private static final String INVALID_REFRESH_TOKEN_ERROR = "INVALID_REFRESH_TOKEN";
private static final String REFRESH_TOKEN_BASE_URL = "https://securetoken.googleapis.com/v1/token";

public RefreshTokenResponse exchangeRefreshToken(String refreshToken) {
    RefreshTokenRequest requestBody = new RefreshTokenRequest(REFRESH_TOKEN_GRANT_TYPE, refreshToken);
    return sendRefreshTokenRequest(requestBody);
}

private RefreshTokenResponse sendRefreshTokenRequest(RefreshTokenRequest refreshTokenRequest) {
    try {
        return RestClient.create(REFRESH_TOKEN_BASE_URL)
          .post()
          .uri(uriBuilder -> uriBuilder
            .queryParam(API_KEY_PARAM, webApiKey)
            .build())
          .body(refreshTokenRequest)
          .contentType(MediaType.APPLICATION_JSON)
          .retrieve()
          .body(RefreshTokenResponse.class);
    } catch (HttpClientErrorException exception) {
        if (exception.getResponseBodyAsString().contains(INVALID_REFRESH_TOKEN_ERROR)) {
            throw new InvalidRefreshTokenException("Invalid refresh token provided");
        }
        throw exception;
    }
}

In our exchangeRefreshToken() method, we create a RefreshTokenRequest with the refresh_token grant type and the provided refreshToken. We then pass this request to our private sendRefreshTokenRequest() method, which sends a POST request to the desired API endpoint.

If the request is successful, we return the response containing the new idToken. And if the provided refreshToken is invalid, we throw a custom InvalidRefreshTokenException.

Additionally, if we need to force our users to re-authenticate, we can revoke their refresh tokens:

firebaseAuth.revokeRefreshTokens(userId);

We call the revokeRefreshTokens() method provided by the FirebaseAuth class. This not only invalidates all the refreshTokens issued to the user but also the user’s active idToken, effectively logging them out of our application.

6. Integrating With Spring Security

With our user creation and login functionality in place, let’s integrate Firebase Authentication with Spring Security to secure our private API endpoints.

6.1. Creating Custom Authentication Filter

First, we’ll create our custom authentication filter extending the OncePerRequestFilter class:

@Component
class TokenAuthenticationFilter extends OncePerRequestFilter {

    private static final String BEARER_PREFIX = "Bearer ";
    private static final String USER_ID_CLAIM = "user_id";
    private static final String AUTHORIZATION_HEADER = "Authorization";

    private final FirebaseAuth firebaseAuth;
    private final ObjectMapper objectMapper;

    // standard constructor

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
      FilterChain filterChain) {
        String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER);

        if (authorizationHeader != null && authorizationHeader.startsWith(BEARER_PREFIX)) {
            String token = authorizationHeader.replace(BEARER_PREFIX, "");
            Optional<String> userId = extractUserIdFromToken(token);

            if (userId.isPresent()) {
                var authentication = new UsernamePasswordAuthenticationToken(userId.get(), null, null);
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authentication);   
            } else {
                setAuthErrorDetails(response);
                return;
            }
        }
        filterChain.doFilter(request, response);
    }

    private Optional<String> extractUserIdFromToken(String token) {
        try {
            FirebaseToken firebaseToken = firebaseAuth.verifyIdToken(token, true);
            String userId = String.valueOf(firebaseToken.getClaims().get(USER_ID_CLAIM));
            return Optional.of(userId);
        } catch (FirebaseAuthException exception) {
            return Optional.empty();
        }
    }

    private void setAuthErrorDetails(HttpServletResponse response) {
        HttpStatus unauthorized = HttpStatus.UNAUTHORIZED;
        response.setStatus(unauthorized.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(unauthorized,
          "Authentication failure: Token missing, invalid or expired");
        response.getWriter().write(objectMapper.writeValueAsString(problemDetail));
    }

}

In our doFilterInternal() method, we extract the Authorization header from the incoming HTTP request and remove the Bearer prefix to get the JWT token.

Then, using our private extractUserIdFromToken() method, we verify the authenticity of the token and retrieve its user_id claim.

If the token verification fails, we create a ProblemDetail error response, convert it to JSON using ObjectMapper, and write it to the HttpServletResponse.

If the token is valid, we create a new instance of UsernamePasswordAuthenticationToken with the userId as the Principal and then set it in the SecurityContext.

After successful authentication, we can retrieve the authenticated user’s userId from the SecurityContext in our service layer:

String userId = Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
  .map(Authentication::getPrincipal)
  .filter(String.class::isInstance)
  .map(String.class::cast)
  .orElseThrow(IllegalStateException::new);

To follow the Single Responsibility Principle, we can have our above logic in a separate AuthenticatedUserIdProvider class. This helps the service layer maintain a relationship between the currently authenticated user and the operations they perform.

6.2. Configuring SecurityFilterChain

Finally, let’s configure our SecurityFilterChain to use our custom authentication filter:

private static final String[] WHITELISTED_API_ENDPOINTS = { "/user", "/user/login", "/user/refresh-token" };

private final TokenAuthenticationFilter tokenAuthenticationFilter;

// standard constructor

@Bean
public SecurityFilterChain configure(HttpSecurity http) {
    http
      .authorizeHttpRequests(authManager -> {
        authManager.requestMatchers(HttpMethod.POST, WHITELISTED_API_ENDPOINTS)
          .permitAll()
          .anyRequest()
          .authenticated();
      })
      .addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

    return http.build();
}

We allow unauthenticated access to the /user, /user/login, and /user/refresh-token endpoints, which correspond to our user registration, login and refresh token exchange functionality.

Finally, we add our custom TokenAuthenticationFilter before the UsernamePasswordAuthenticationFilter in the filter chain.

This setup ensures that our private API endpoints are protected, and only requests with a valid JWT token are allowed to access them.

7. Conclusion

In this article, we explored how to integrate Firebase Authentication with Spring Security.

We walked through the necessary configurations, implemented user registration, login, and refresh token exchange functionality, and created a custom Spring Security filter to secure our private API endpoints.

By using Firebase Authentication, we can offload the complexity of managing user credentials and access, allowing us to focus on building our core functionality.

As always, all the code examples used in this article are available over on GitHub.